Skip to content
Open
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
6 changes: 6 additions & 0 deletions snap/src/charmlibs/snap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@
ensure,
ensure_revision,
)
from ._snapd_aliases import (
alias,
unalias,
)
from ._snapd_logs import (
LogEntry,
logs,
Expand Down Expand Up @@ -94,6 +98,7 @@
'OptionNotFoundError',
'RevisionNotAvailableError',
'TimeoutError',
'alias',
'ensure',
'ensure_revision',
'hold',
Expand All @@ -102,5 +107,6 @@
'logs',
'refresh',
'remove',
'unalias',
'unhold',
]
59 changes: 59 additions & 0 deletions snap/src/charmlibs/snap/_snapd_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright 2026 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Snap alias operations, implemented as calls to the snapd REST API's /v2/aliases endpoint."""

from __future__ import annotations

import logging

from . import _client

logger = logging.getLogger(__name__)


def alias(snap: str, app: str, alias_name: str) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about dropping "alias"? It's there in the function name and pretty implied.

Suggested change
def alias(snap: str, app: str, alias_name: str) -> None:
def alias(snap: str, app: str, name: str) -> None:

Or, simplifying in a different way, but maybe more explicit even though it keeps the duplication:

Suggested change
def alias(snap: str, app: str, alias_name: str) -> None:
def alias(snap: str, app: str, alias: str) -> None:

(Maybe linters don't like the shadow there, but it's not like this is recursive or ever would be.)

"""Create an alias for a snap app.

If the alias already exists for the same snap, this call succeeds silently,
reassigning the alias to the new app if it differs.

Args:
snap: The name of the snap that owns the app.
app: The name of the app within the snap to alias.
alias_name: The alias (command name) to create for the app.

Raises:
NotInstalledError: if the snap is not installed.
ChangeError: if the alias name is already claimed by a different snap,
conflicts with the command namespace of an installed snap,
or if the specified app does not exist within the snap.
"""
data = {'action': 'alias', 'snap': snap, 'app': app, 'alias': alias_name}
_client.post('/v2/aliases', body=data)


def unalias(alias_name: str) -> None:
"""Remove an alias.

Args:
alias_name: The alias to remove.

Raises:
ChangeError: if the unalias change fails after starting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically correct, but maybe more friendly as

Suggested change
ChangeError: if the unalias change fails after starting.
ChangeError: if the alias removal fails after starting.

APIError: if the alias does not exist (e.g. was never created, or the snap it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
APIError: if the alias does not exist (e.g. was never created, or the snap it
APIError: if the alias does not exist (for example, was never created, or the snap it

belonged to was removed — aliases do not survive snap removal).
"""
data = {'action': 'unalias', 'alias': alias_name}
_client.post('/v2/aliases', body=data)
166 changes: 166 additions & 0 deletions snap/tests/functional/test_snapd_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

"""Functional tests for _snapd_aliases: alias, unalias."""

from __future__ import annotations

import typing

import pytest

from charmlibs.snap import _client, _errors, _snapd_aliases
from conftest import ensure_installed, ensure_removed

if typing.TYPE_CHECKING:
from collections.abc import Mapping

_SNAP = 'lxd'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know there's an issue for having dedicated test snaps, but in the meantime, isn't lxd rather large to be using?

_APP = 'lxc'
_ALIAS = 'test-functional-lxc-alias'

# A snap name that is never installed — used for error paths where any absent
# snap produces the same error response, avoiding unnecessary remove operations.
_ABSENT_SNAP = 'this-snap-does-not-exist-xyz-abc-123'


# Test helper and possible future candidate for library public API.
def _list_aliases() -> Mapping[str, Mapping[str, Mapping[str, str]]]:
"""List all aliases, keyed by snap then alias name: {snap: {alias: {command, status, ...}}}."""
aliases = _client.get('/v2/aliases')
assert isinstance(aliases, dict)
return typing.cast('dict[str, dict[str, dict[str, str]]]', aliases)


def _cleanup_alias() -> None:
"""Remove the test alias if it exists, ignoring errors."""
try:
_snapd_aliases.unalias(_ALIAS)
except Exception: # noqa: S110
pass


def _alias_exists() -> bool:
info = _list_aliases().get(_SNAP, {}).get(_ALIAS)
return info is not None and info.get('command') == f'{_SNAP}.{_APP}'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess strictly speaking this is the definition of it existing and working. I wonder if a functional test ought to check the higher level "I can run the command" rather than only "snap db has the value". But maybe that falls out of testing "snap library" and into testing snapd.



# ---------------------------------------------------------------------------
# alias (lxd installed)
# ---------------------------------------------------------------------------


def test_alias_creates_alias():
ensure_installed(_SNAP)
_cleanup_alias()
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
assert _alias_exists()
_cleanup_alias()


def test_alias_nonexistent_app_raises_snap_change_error():
# Aliasing to an app that doesn't exist fails as an async change error.
ensure_installed(_SNAP)
_cleanup_alias()
with pytest.raises(_errors.ChangeError):
_snapd_aliases.alias(_SNAP, 'nonexistent-app', _ALIAS)
_cleanup_alias()


def test_alias_is_idempotent():
# Calling alias() again with the same snap, app, and alias name succeeds silently.
ensure_installed(_SNAP)
_cleanup_alias()
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
_snapd_aliases.alias(_SNAP, _APP, _ALIAS) # Second call — no error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To really test idempotency this should check it exists after both calls. Otherwise it maybe just worked the second time and failed the first.

assert _alias_exists()
_cleanup_alias()


def test_alias_reassigns_within_same_snap():
# Calling alias() with the same alias name but a different app of the same snap
# silently reassigns the alias — no error raised.
ensure_installed(_SNAP)
_cleanup_alias()
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
_snapd_aliases.alias(_SNAP, 'lxd', _ALIAS) # Different app, same snap — no error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be testing that the alias exists (both times) not just that there is no error.

_cleanup_alias()


def test_alias_name_conflicts_with_snap_command_namespace():
# Using a snap's own name as the alias name conflicts with its command namespace.
ensure_installed(_SNAP)
_cleanup_alias()
with pytest.raises(_errors.ChangeError) as ctx:
_snapd_aliases.alias(_SNAP, _APP, _SNAP) # Alias name = 'lxd'.
assert 'conflicts with the command namespace' in ctx.value.message


# ---------------------------------------------------------------------------
# unalias (lxd installed)
# ---------------------------------------------------------------------------


def test_unalias_removes_alias():
ensure_installed(_SNAP)
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
assert _alias_exists()
_snapd_aliases.unalias(_ALIAS)
assert not _alias_exists()


def test_unalias_nonexistent_alias_raises():
# Unaliasing an alias that doesn't exist raises a base Error (no kind).
ensure_installed(_SNAP)
_cleanup_alias()
with pytest.raises(_errors.Error) as ctx:
_snapd_aliases.unalias(_ALIAS)
assert not ctx.value.kind
assert 'cannot find' in ctx.value.message or _ALIAS in ctx.value.message


# ---------------------------------------------------------------------------
# hello-world installed (test cross-snap alias conflicts)
# ---------------------------------------------------------------------------


def test_alias_duplicate_name_different_snap_raises():
# An alias name already claimed by another snap raises ChangeError.
ensure_installed(_SNAP)
ensure_installed('hello-world')
_cleanup_alias()
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
with pytest.raises(_errors.ChangeError) as ctx:
_snapd_aliases.alias('hello-world', 'hello-world', _ALIAS)
assert 'already enabled for' in ctx.value.message
_cleanup_alias()


# ---------------------------------------------------------------------------
# not-installed snap (uses a never-installed name to avoid churn)
# ---------------------------------------------------------------------------


def test_alias_not_installed_snap_raises():
with pytest.raises(_errors.NotInstalledError) as ctx:
_snapd_aliases.alias(_ABSENT_SNAP, 'hello', 'test-not-installed-alias')
assert ctx.value.kind == 'snap-not-installed'


# ---------------------------------------------------------------------------
# unalias after snap removed — last because it removes lxd
# ---------------------------------------------------------------------------


def test_unalias_after_snap_removed_raises():
# Aliases don't survive snap removal; unaliasing after removal raises the same error
# as attempting to remove an alias that was never created.
ensure_installed(_SNAP)
_cleanup_alias()
_snapd_aliases.alias(_SNAP, _APP, _ALIAS)
ensure_removed(_SNAP)
with pytest.raises(_errors.APIError) as ctx:
_snapd_aliases.unalias(_ALIAS)
assert not ctx.value.kind
assert 'cannot find' in ctx.value.message
31 changes: 31 additions & 0 deletions snap/tests/unit/test_snapd_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright 2026 Canonical Ltd.
# See LICENSE file for licensing details.

# pyright: reportPrivateUsage=false

from __future__ import annotations

from typing import TYPE_CHECKING

from charmlibs.snap import _snapd_aliases

if TYPE_CHECKING:
from conftest import MockClient


class TestAlias:
def test_alias(self, mock_client: MockClient):
_snapd_aliases.alias('lxd', 'lxc', 'testlxc')
mock_client.post.assert_called_once_with(
'/v2/aliases',
body={'action': 'alias', 'snap': 'lxd', 'app': 'lxc', 'alias': 'testlxc'},
)


class TestUnalias:
def test_unalias(self, mock_client: MockClient):
_snapd_aliases.unalias('testlxc')
mock_client.post.assert_called_once_with(
'/v2/aliases',
body={'action': 'unalias', 'alias': 'testlxc'},
)