diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py index 430a32a31..2c5aa640f 100644 --- a/craft_application/services/provider.py +++ b/craft_application/services/provider.py @@ -120,7 +120,15 @@ def setup(self) -> None: self.snaps.extend(_REQUESTED_SNAPS.values()) if util.is_running_from_snap(self._app.name): - # use the aliased name of the snap when injecting + # Inject the base snap from the host if available + if base_snap := util.get_snap_base(self._app.name): + emit.debug( + f"Setting {base_snap} to be injected from the " + "host into the build environment." + ) + self.snaps.append(Snap(name=base_snap, channel=None, classic=False)) + + # Inject the app snap (use the aliased name when injecting from host) name = os.getenv("SNAP_INSTANCE_NAME", self._app.name) channel = None emit.debug( diff --git a/craft_application/util/__init__.py b/craft_application/util/__init__.py index aade87a8c..82c56c553 100644 --- a/craft_application/util/__init__.py +++ b/craft_application/util/__init__.py @@ -38,6 +38,7 @@ SnapConfig, get_snap_config, is_running_from_snap, + get_snap_base, ) from craft_application.util.string import humanize_list, strtobool from craft_application.util.system import get_parallel_build_count @@ -57,6 +58,7 @@ "convert_architecture_deb_to_platform", "get_snap_config", "is_running_from_snap", + "get_snap_base", "is_valid_architecture", "SnapConfig", "humanize_list", diff --git a/craft_application/util/snap_config.py b/craft_application/util/snap_config.py index a3c0b9ec0..f753d3554 100644 --- a/craft_application/util/snap_config.py +++ b/craft_application/util/snap_config.py @@ -17,9 +17,11 @@ """Snap config file definitions and helpers.""" import os +import pathlib from typing import Any, Literal import pydantic +import yaml from craft_cli import emit from snaphelpers import SnapConfigOptions, SnapCtlError @@ -40,6 +42,46 @@ def is_running_from_snap(app_name: str) -> bool: return os.getenv("SNAP_NAME") == app_name and os.getenv("SNAP") is not None +def get_snap_base(app_name: str) -> str | None: + """Get the base snap name from snap.yaml. + + :param app_name: The name of the application. + + :returns: The base snap name (e.g., 'core24') or None if not running from a snap + or if the snap.yaml doesn't have a base defined. + """ + if not is_running_from_snap(app_name): + emit.debug( + f"Not reading snap base because {app_name} is not running as a snap." + ) + return None + + snap_dir = os.getenv("SNAP") + if not snap_dir: + emit.debug("SNAP environment variable not set.") + return None + + snap_yaml_path = pathlib.Path(snap_dir) / "meta" / "snap.yaml" + if not snap_yaml_path.exists(): + emit.debug(f"snap.yaml not found at {snap_yaml_path}") + return None + + try: + with snap_yaml_path.open() as f: + snap_data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as error: + emit.debug(f"Failed to read snap metadata: {error!r}") + return None + + base: str | None = snap_data.get("base") + if base: + emit.debug(f"Found base snap: {base}") + return base + + emit.debug("No base defined in snap.yaml") + return None + + class SnapConfig(pydantic.BaseModel, extra="forbid"): """Data stored in a snap config. diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 6f5fea08e..2ba5ad83d 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -15,6 +15,17 @@ Changelog For a complete list of commits, check out the `1.2.3`_ release on GitHub. +Unreleased +---------- + +Services +======== + +- When running from a snap, the application's base snap is now injected into + managed instances alongside the application snap. This prevents the managed + instance from downloading the base snap from the network when it's already + available on the host. + 6.0.1 (2025-11-19) ------------------ diff --git a/tests/spread/testcraft/inject-base-snap/task.yaml b/tests/spread/testcraft/inject-base-snap/task.yaml new file mode 100644 index 000000000..c5fe51abd --- /dev/null +++ b/tests/spread/testcraft/inject-base-snap/task.yaml @@ -0,0 +1,89 @@ +summary: Verify base snap is injected with correct revision in managed mode + +priority: 100 + +systems: + - ubuntu-22.04-64 # Test on 22.04 with a core24 snap + - ubuntu-24.04-64 + +prepare: | + # Determine the base snap for the test (use core24 for testing) + HOST_BASE="core24" + + # Install from edge channel to ensure different revision from container preinstall + snap install "$HOST_BASE" --channel=edge || true + + # Get the revision of the base snap on the host + HOST_BASE_REVISION=$(snap list "$HOST_BASE" | tail -n 1 | awk '{print $3}') + echo "Host base snap: $HOST_BASE (revision $HOST_BASE_REVISION)" + + # Save for later use + echo "$HOST_BASE" > /tmp/base_snap_name + echo "$HOST_BASE_REVISION" > /tmp/base_snap_revision + +execute: | + mkdir inject-test + cd inject-test + + # Initialize a project + testcraft init + . /etc/os-release + sed -i "s/base: ubuntu@[0-9.]\\+/base: ubuntu@${VERSION_ID}/" testcraft.yaml + + # Set a short idle time for faster test + export CRAFT_IDLE_MINS=1 + + # Run testcraft pack in managed mode - this should inject the base snap + testcraft pack --verbose --debug + + # Get the base snap info + HOST_BASE=$(cat /tmp/base_snap_name) + HOST_BASE_REVISION=$(cat /tmp/base_snap_revision) + + # Connect to the managed instance and check if the base snap is present + # The instance name follows the pattern: testcraft-inject-test-- + ARCH=$(dpkg --print-architecture) + + # Find the LXD instance (testcraft uses LXD on Linux) + INSTANCE=$(lxc list --project=testcraft --format csv -c n | grep "^testcraft-" | head -n 1) + + if [ -z "$INSTANCE" ]; then + echo "Could not find managed instance" + echo "Available instances:" + lxc list --project=testcraft + exit 1 + fi + + echo "Found instance: $INSTANCE" + + # Check if the base snap is installed in the instance + INSTANCE_BASE_REVISION=$(lxc exec --project=testcraft "$INSTANCE" -- snap list "$HOST_BASE" 2>/dev/null | tail -n 1 | awk '{print $3}' || echo "") + + if [ -z "$INSTANCE_BASE_REVISION" ]; then + echo "ERROR: Base snap '$HOST_BASE' is not installed in the managed instance" + echo "Installed snaps in instance:" + lxc exec --project=testcraft "$INSTANCE" -- snap list + exit 1 + fi + + echo "Instance base snap: $HOST_BASE (revision $INSTANCE_BASE_REVISION)" + + # Verify the revisions match + if [ "$HOST_BASE_REVISION" != "$INSTANCE_BASE_REVISION" ]; then + echo "ERROR: Base snap revision mismatch!" + echo " Host revision: $HOST_BASE_REVISION" + echo " Instance revision: $INSTANCE_BASE_REVISION" + exit 1 + fi + + echo "SUCCESS: Base snap '$HOST_BASE' revision $HOST_BASE_REVISION matches on host and instance" + +restore: | + cd inject-test 2>/dev/null || true + testcraft clean || true + cd .. + rm -rf inject-test + rm -f /tmp/base_snap_name /tmp/base_snap_revision + + # Restore core24 to stable channel + snap refresh core24 --channel=stable || true diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py index 88f8573ca..00178fb54 100644 --- a/tests/unit/services/test_provider.py +++ b/tests/unit/services/test_provider.py @@ -248,6 +248,87 @@ def test_install_snap( assert service.snaps == [] +@pytest.mark.parametrize( + ("base_snap", "expected_snaps"), + [ + pytest.param( + "core24", + [ + Snap(name="core24", channel=None, classic=False), + Snap(name="testcraft_1", channel=None, classic=True), + ], + id="with-core24-base", + ), + pytest.param( + "core22", + [ + Snap(name="core22", channel=None, classic=False), + Snap(name="testcraft_1", channel=None, classic=True), + ], + id="with-core22-base", + ), + pytest.param( + "core20", + [ + Snap(name="core20", channel=None, classic=False), + Snap(name="testcraft_1", channel=None, classic=True), + ], + id="with-core20-base", + ), + pytest.param( + None, + [ + Snap(name="testcraft_1", channel=None, classic=True), + ], + id="without-base", + ), + ], +) +def test_install_snap_with_base( + tmp_path, + monkeypatch, + app_metadata, + fake_project, + fake_process: pytest_subprocess.FakeProcess, + fake_services, + base_snap, + expected_snaps, +): + """Test that the base snap is injected when running from a snap.""" + monkeypatch.setattr("snaphelpers._ctl.Popen", subprocess.Popen) + fake_process.register( + ["/usr/bin/snapctl", "get", "-d", fake_process.any()], + stdout="{}", + occurrences=50, + ) + # Set up snap environment + monkeypatch.setenv("SNAP_NAME", "testcraft") + monkeypatch.setenv("SNAP_INSTANCE_NAME", "testcraft_1") + monkeypatch.setenv("SNAP", str(tmp_path)) + monkeypatch.delenv("CRAFT_SNAP_CHANNEL", raising=False) + + # Create snap.yaml with or without base + meta_dir = tmp_path / "meta" + meta_dir.mkdir() + snap_yaml = meta_dir / "snap.yaml" + if base_snap: + snap_yaml.write_text(f"name: testcraft\nbase: {base_snap}\nversion: 1.0\n") + else: + # Base snaps themselves (like core24) don't have a base field + snap_yaml.write_text("name: testcraft\ntype: base\nversion: 1.0\n") + + service = provider.ProviderService( + app_metadata, + fake_services, + work_dir=pathlib.Path(), + install_snap=True, + ) + service.setup() + + # Verify the expected snaps are injected + assert service.snaps == expected_snaps + + @pytest.mark.parametrize( "additional_snaps", [ diff --git a/tests/unit/util/test_snap_config.py b/tests/unit/util/test_snap_config.py index 92e2593f3..edd26e64b 100644 --- a/tests/unit/util/test_snap_config.py +++ b/tests/unit/util/test_snap_config.py @@ -20,7 +20,12 @@ import pytest from craft_application.errors import CraftValidationError -from craft_application.util import SnapConfig, get_snap_config, is_running_from_snap +from craft_application.util import ( + SnapConfig, + get_snap_base, + get_snap_config, + is_running_from_snap, +) from snaphelpers import SnapCtlError @@ -151,3 +156,123 @@ def test_get_snap_config_handle_fetch_error(error, mock_config): mock_config.return_value.fetch.side_effect = error assert get_snap_config(app_name="testcraft") is None + + +@pytest.mark.parametrize( + ("snap_yaml_content", "expected_base"), + [ + pytest.param( + # Simple snap with core24 + "name: testcraft\nbase: core24\nversion: 1.0\n", + "core24", + id="core24", + ), + pytest.param( + # Snap with core22 (common in 22.04 snaps) + "name: my-snap\nbase: core22\nversion: 2.0\nsummary: A test snap\n", + "core22", + id="core22", + ), + pytest.param( + # Snap with core20 + "name: hello-world\nversion: 6.4\nsummary: Hello world example\nbase: core20\n", + "core20", + id="core20", + ), + pytest.param( + # More complex snap.yaml similar to real snaps like snapcraft + """name: test-app +version: '8.0' +summary: Test application +description: | + A test application for craft-application +base: core24 +grade: stable +confinement: classic +""", + "core24", + id="complex-snap", + ), + ], +) +def test_get_snap_base_success(tmp_path, monkeypatch, snap_yaml_content, expected_base): + """Test get_snap_base returns the correct base from various snap.yaml formats.""" + # Set up environment + monkeypatch.setenv("SNAP_NAME", "testcraft") + monkeypatch.setenv("SNAP", str(tmp_path)) + + # Create snap.yaml with base + meta_dir = tmp_path / "meta" + meta_dir.mkdir() + snap_yaml = meta_dir / "snap.yaml" + snap_yaml.write_text(snap_yaml_content) + + assert get_snap_base("testcraft") == expected_base + + +def test_get_snap_base_not_from_snap(monkeypatch): + """Test get_snap_base returns None when not running from snap.""" + monkeypatch.delenv("SNAP_NAME", raising=False) + monkeypatch.delenv("SNAP", raising=False) + + assert get_snap_base("testcraft") is None + + +@pytest.mark.parametrize( + ("setup_snap_env", "create_yaml", "yaml_content", "reason"), + [ + pytest.param( + False, + False, + None, + "SNAP env var not set", + id="no-snap-env", + ), + pytest.param( + True, + False, + None, + "snap.yaml doesn't exist", + id="no-yaml", + ), + pytest.param( + True, + True, + "name: testcraft\nversion: 1.0\n", + "no base in snap.yaml", + id="no-base", + ), + ], +) +def test_get_snap_base_returns_none( + tmp_path, monkeypatch, setup_snap_env, create_yaml, yaml_content, reason +): + """Test get_snap_base returns None in various scenarios.""" + monkeypatch.setenv("SNAP_NAME", "testcraft") + + if setup_snap_env: + monkeypatch.setenv("SNAP", str(tmp_path)) + else: + monkeypatch.delenv("SNAP", raising=False) + + if create_yaml: + meta_dir = tmp_path / "meta" + meta_dir.mkdir() + snap_yaml = meta_dir / "snap.yaml" + snap_yaml.write_text(yaml_content) + + assert get_snap_base("testcraft") is None, f"Should return None when {reason}" + + +def test_get_snap_base_invalid_yaml(tmp_path, monkeypatch): + """Test get_snap_base returns None when snap.yaml is invalid.""" + monkeypatch.setenv("SNAP_NAME", "testcraft") + monkeypatch.setenv("SNAP", str(tmp_path)) + + # Create invalid snap.yaml + meta_dir = tmp_path / "meta" + meta_dir.mkdir() + snap_yaml = meta_dir / "snap.yaml" + snap_yaml.write_text("invalid: yaml: content: [") + + assert get_snap_base("testcraft") is None