-
Notifications
You must be signed in to change notification settings - Fork 28
feat(snap): add snap aliases module #571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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: | ||||||
| """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. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Technically correct, but maybe more friendly as
Suggested change
|
||||||
| APIError: if the alias does not exist (e.g. was never created, or the snap it | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| belonged to was removed — aliases do not survive snap removal). | ||||||
| """ | ||||||
| data = {'action': 'unalias', 'alias': alias_name} | ||||||
| _client.post('/v2/aliases', body=data) | ||||||
| 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' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}' | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| 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'}, | ||
| ) |
There was a problem hiding this comment.
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.
Or, simplifying in a different way, but maybe more explicit even though it keeps the duplication:
(Maybe linters don't like the shadow there, but it's not like this is recursive or ever would be.)