Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion craft_application/services/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions craft_application/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions craft_application/util/snap_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
11 changes: 11 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------

Expand Down
89 changes: 89 additions & 0 deletions tests/spread/testcraft/inject-base-snap/task.yaml
Original file line number Diff line number Diff line change
@@ -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>-<build_base>
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
81 changes: 81 additions & 0 deletions tests/unit/services/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading
Loading