Repository: canonical/snapcraft

Issue:
"snapcraft try" using provider LXD generates a prime/ directory with no payload
### Bug Description

Building a trivial snap on Ubuntu 22.04 with snapcraft 7.4.3 and provider LXD with "snapcraft try", the eventual prime/ directory contains nothing but:

$ tree prime
prime
└── meta
    ├── gui
    └── snap.yaml
$

This appears to be entirely reproducible for me.

### To Reproduce

Pick a random snap and run "snapcraft try" using LXD provider -- the output suggests you can now run "snap try prime", but given that there is no meaningful payload, you get:

error: cannot try /home/rpjday/sc101v23/snapcraft-101-labs/basic-snap/prime: snap is
       unusable due to missing files; contact developer

### Environment

Ubuntu 22.04, snapcraft 7.4.3.

### snapcraft.yaml

```shell
name: basic-snap
base: core22 # the base snap is the execution environment for this snap
version: '0.99'
summary: Demo snap to show basic snap features.
description: |
  Snap to demo numerous basic snap properties and operations.

grade: devel
confinement: devmode

apps:
  basic-snap:
    command: basic.sh
  hello:
    command: hello.sh
  hi:
    command: d1/d2/hi.sh

parts:
  basic:
    source: src1/
    plugin: dump
  hello:
    source: src2/
    plugin: dump
  hi:
    source: src3/
    plugin: dump
```


### Relevant log output

```shell
$ snap try prime
error: cannot try /home/rpjday/sc101v23/snapcraft-101-labs/basic-snap/prime: snap is
       unusable due to missing files; contact developer
```


### Additional context

_No response_


Discussion:
I can reproduce this issue.  
For core22, I believe the problem is that snapcraft is shutting down the instance instead of leaving it running.

Although I am not sure how the metadata remains.
@mr-cal, the prime directory actually gets created on the host and is mounted into the container. So if the first thing you ever do is run `snapcraft try`, then everything works. The problem is if you primed once before and then run try, and empty directory will take over the existing prime and since the lifecycle is not aware of this, there is no repriming taking place
So all I need to do is advise to run "snapcraft try" first thing, and everything should be fine? I'm good with that.

Resolving patch:
diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py
--- a/snapcraft/parts/lifecycle.py
+++ b/snapcraft/parts/lifecycle.py
@@ -346,6 +346,9 @@ def _run_lifecycle_and_pack(
             step_name,
             shell=getattr(parsed_args, "shell", False),
             shell_after=getattr(parsed_args, "shell_after", False),
+            # Repriming needs to happen to take into account any changes to
+            # the actual target directory.
+            rerun_step=command_name == "try",
         )
 
     # Extract metadata and generate snap.yaml
diff --git a/snapcraft/parts/parts.py b/snapcraft/parts/parts.py
--- a/snapcraft/parts/parts.py
+++ b/snapcraft/parts/parts.py
@@ -141,10 +141,14 @@ def run(
         *,
         shell: bool = False,
         shell_after: bool = False,
+        rerun_step: bool = False,
     ) -> None:
         """Run the parts lifecycle.
 
         :param target_step: The final step to execute.
+        :param shell: Enter a shell instead of running step_name.
+        :param shell_after: Enter a shell after running step_name.
+        :param rerun_step: Force running step_name.
 
         :raises PartsLifecycleError: On error during lifecycle.
         :raises RuntimeError: On unexpected error.
@@ -171,6 +175,16 @@ def run(
 
             with self._lcm.action_executor() as aex:
                 for action in actions:
+                    # Workaround until canonical/craft-parts#540 is fixed
+                    if action.step == target_step and rerun_step:
+                        action = craft_parts.Action(
+                            part_name=action.part_name,
+                            step=action.step,
+                            action_type=ActionType.RERUN,
+                            reason="forced rerun",
+                            project_vars=action.project_vars,
+                            properties=action.properties,
+                        )
                     message = _action_message(action)
                     emit.progress(f"Executing parts lifecycle: {message}")
                     with emit.open_stream("Executing action") as stream:


Test patch:
diff --git a/tests/spread/core22/try/task.yaml b/tests/spread/core22/try/task.yaml
--- a/tests/spread/core22/try/task.yaml
+++ b/tests/spread/core22/try/task.yaml
@@ -7,6 +7,9 @@ execute: |
   chmod a+w prime
   
   unset SNAPCRAFT_BUILD_ENVIRONMENT
+  # Prime first to regression test snapcore/snapcraft#4219
+  snapcraft prime --use-lxd
+  # Followed by the actual try
   snapcraft try --use-lxd
   
   find prime/meta/snap.yaml
@@ -14,4 +17,4 @@ execute: |
   
   snap try prime
   hello-try | MATCH "Hello, world"
-  snap remove hello-try
\ No newline at end of file
+  snap remove hello-try
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
@@ -299,13 +299,45 @@ def test_lifecycle_run_command_step(
         parsed_args=parsed_args,
     )
 
-    call_args = {"shell": False, "shell_after": False}
+    call_args = {"shell": False, "shell_after": False, "rerun_step": False}
     if debug_shell:
         call_args[debug_shell] = True
 
     assert run_mock.mock_calls == [call(step, **call_args)]
 
 
+def test_lifecycle_run_try_command(snapcraft_yaml, project_vars, new_dir, mocker):
+    project = Project.unmarshal(snapcraft_yaml(base="core22"))
+    run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run")
+    mocker.patch("snapcraft.meta.snap_yaml.write")
+    mocker.patch("snapcraft.pack.pack_snap")
+
+    parsed_args = argparse.Namespace(
+        debug=False,
+        destructive_mode=True,
+        enable_manifest=False,
+        shell=False,
+        shell_after=False,
+        use_lxd=False,
+        ua_token=None,
+        parts=[],
+    )
+
+    parts_lifecycle._run_command(
+        "try",
+        project=project,
+        parse_info={},
+        assets_dir=Path(),
+        start_time=datetime.now(),
+        parallel_build_count=8,
+        parsed_args=parsed_args,
+    )
+
+    assert run_mock.mock_calls == [
+        call("prime", shell=False, shell_after=False, rerun_step=True)
+    ]
+
+
 @pytest.mark.parametrize("cmd", ["pack", "snap"])
 def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir, mocker):
     project = Project.unmarshal(snapcraft_yaml(base="core22"))
@@ -333,7 +365,9 @@ def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir,
         ),
     )
 
-    assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
+    assert run_mock.mock_calls == [
+        call("prime", shell=False, shell_after=False, rerun_step=False)
+    ]
     assert pack_mock.mock_calls[:1] == [
         call(
             new_dir / "prime",
@@ -382,7 +416,9 @@ def test_lifecycle_pack_destructive_mode(
     )
 
     assert run_in_provider_mock.mock_calls == []
-    assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
+    assert run_mock.mock_calls == [
+        call("prime", shell=False, shell_after=False, rerun_step=False)
+    ]
     assert pack_mock.mock_calls[:1] == [
         call(
             new_dir / "home/prime",
@@ -432,7 +468,9 @@ def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mock
     )
 
     assert run_in_provider_mock.mock_calls == []
-    assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
+    assert run_mock.mock_calls == [
+        call("prime", shell=False, shell_after=False, rerun_step=False)
+    ]
     assert pack_mock.mock_calls[:1] == [
         call(
             new_dir / "home/prime",
@@ -525,7 +563,9 @@ def test_lifecycle_pack_metadata_error(cmd, snapcraft_yaml, new_dir, mocker):
     assert str(raised.value) == (
         "error setting grade: unexpected value; permitted: 'stable', 'devel'"
     )
-    assert run_mock.mock_calls == [call("prime", shell=False, shell_after=False)]
+    assert run_mock.mock_calls == [
+        call("prime", shell=False, shell_after=False, rerun_step=False)
+    ]
     assert pack_mock.mock_calls == []