diff --git a/snap/src/charmlibs/snap/__init__.py b/snap/src/charmlibs/snap/__init__.py index 624d77113..5a711bf21 100644 --- a/snap/src/charmlibs/snap/__init__.py +++ b/snap/src/charmlibs/snap/__init__.py @@ -23,7 +23,7 @@ - Automatic refreshes with :func:`hold` and :func:`unhold`. - Services with :func:`start`, :func:`stop`, and :func:`restart`. -- Config with :func:`config_get`, :func:`config_set`, and :func:`config_unset`. +- Config with :func:`get`, :func:`set`, and :func:`unset`. - Connections between snaps with :func:`connect` and :func:`disconnect`. - Application aliases with :func:`alias` and :func:`unalias`. @@ -68,6 +68,11 @@ start, stop, ) +from ._snapd_conf import ( + get, + set, # noqa: A004 (shadowing a Python builtin) + unset, +) from ._snapd_logs import ( LogEntry, logs, @@ -101,6 +106,7 @@ 'TimeoutError', 'ensure', 'ensure_revision', + 'get', 'hold', 'info', 'install', @@ -108,7 +114,9 @@ 'refresh', 'remove', 'restart', + 'set', 'start', 'stop', 'unhold', + 'unset', ] diff --git a/snap/src/charmlibs/snap/_errors.py b/snap/src/charmlibs/snap/_errors.py index 5458c6390..f21372bb9 100644 --- a/snap/src/charmlibs/snap/_errors.py +++ b/snap/src/charmlibs/snap/_errors.py @@ -127,7 +127,11 @@ class AppNotFoundError(APIError): class NotFoundError(APIError): - """Raised via the API when a snap is not found in the store.""" + """Raised via the API when a snap is not found. + + Depending on the operation, this means not found in the store (for example install), + or not installed on the system (for example configuration operations). + """ class NotInstalledError(APIError): diff --git a/snap/src/charmlibs/snap/_snapd_conf.py b/snap/src/charmlibs/snap/_snapd_conf.py new file mode 100644 index 000000000..76b968165 --- /dev/null +++ b/snap/src/charmlibs/snap/_snapd_conf.py @@ -0,0 +1,165 @@ +# 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 config operations implemented as direct calls to the snapd REST API.""" + +from __future__ import annotations + +import logging +import typing + +from . import _client, _errors + +if typing.TYPE_CHECKING: + from collections.abc import Iterable + from typing import Any + +logger = logging.getLogger(__name__) + + +# /v2/snaps/{snap}/conf + + +# Getting one config value looks like get(s, [k])[k]. In future we could add a get_one(s) helper. +# Get with keys=None returns the entire config, following the CLI (get_all is unnecessary). +def get(snap: str, keys: Iterable[str] | None = None) -> dict[str, Any]: + """Get snap configuration. + + Args: + snap: The name of the snap to read configuration from. + keys: Configuration keys to read. Nested options may be accessed with dotted notation, + for example ``['server.port']``. If ``None``, the full config is returned as a + nested dict. If an empty iterable, an empty dict is returned if the snap is installed. + Must not be a bare string. + + Returns: + A dict mapping each requested key to its configured value. If all keys are requested + (keys=None), the entire config is returned as a nested dict (empty if the snap has no + configuration). If no keys are requested (keys=[]), an empty dict is returned if the snap + is installed. Each dotted key queried is returned as a top-level entry. + + Raises: + TypeError: if ``keys`` is a string (must be a non-string iterable of strings, or ``None``). + NotFoundError: if the snap is not installed. Never raised for ``system`` or ``core``, + whose configuration is served whether or not the core snap is installed. + OptionNotFoundError: if a requested key has no value stored in the snap's configuration. + Snap configuration is schemaless, so snapd does not distinguish between a key the + snap doesn't recognise, a key that was never set, and a key that was unset. Any + defaults a snap applies internally are invisible here unless its configure hook has + stored them with ``snapctl set``. + + :: + + # Full config. + get('foo') # {'server': {'port': 8080}, 'client': {'timeout': 30}} + get('foo', keys=None) # {'server': {'port': 8080}, 'client': {'timeout': 30}} + # Querying specific keys. + get('foo', ['client']) # {'client': {'timeout': 30}} + get('foo', ['client.timeout']) # {'client.timeout': 30} + get('foo', ['server', 'server.port']) # {'server': {'port': 8080}, 'server.port': 8080} + # Querying no keys. + get('foo', keys=[]) # {} + # Invalid keys argument. + get('foo', keys='client') # TypeError + """ + if isinstance(keys, str): + raise TypeError('keys must be an iterable of strings, or None (not a string)') + if keys is not None: + keys = list(keys) + if not keys: + # NOTE: snapd returns the full configuration if no keys are specified. + # We pass this behaviour through for keys=None, but for keys=[] we return + # an empty dict, since the caller explicitly requested no keys. + _raise_if_snap_not_installed_or_system(snap) + return {} + params = {'keys': ','.join(keys)} + else: + params = None + try: + config = _client.get(f'/v2/snaps/{snap}/conf', query=params) + except _errors.OptionNotFoundError: + # NOTE: snapd reports option-not-found both for a missing key and for a missing snap. + # The CLI returns 'error: snap "foo" has no "bar" configuration' in both cases. + # For symmetry with PUT (set/unset), we raise NotFoundError here for a missing snap. + _raise_if_snap_not_installed_or_system(snap) + raise + if keys is None and not config: + # NOTE: snapd returns {} for an installed snap with no config and for a missing snap. + # The CLI returns 'error: snap "foo" has no configuration' for a missing snap. + # For symmetry with PUT (set/unset), we raise NotFoundError here for a missing snap. + _raise_if_snap_not_installed_or_system(snap) + assert isinstance(config, dict) + return typing.cast('dict[str, Any]', config) + + +def _raise_if_snap_not_installed_or_system(snap: str) -> None: + """Raise NotFoundError if the snap is not installed, unless it's system/core.""" + # NOTE: snapd's conf endpoints treat 'system' as an alias for 'core'. + # System configuration is served whether or not the core snap is installed. + # /v2/snaps/system always 404s (it's a hardcoded alias, not a real snap). + # /v2/snaps/core 404s when the core snap is absent (typical when no other snaps depend on it). + # For the purposes of this module, we can skip snap installed checks for both names. + if snap in ('system', 'core'): + return + _client.get(f'/v2/snaps/{snap}') # Raise NotFoundError if the snap isn't installed. + + +def unset(snap: str, keys: Iterable[str]) -> None: + """Unset snap configuration keys. + + Unsetting a key that is not currently set is a no-op and does not raise. + + Args: + snap: The name of the snap to unset configuration on. + keys: Configuration keys to unset. Nested options may be addressed with dotted + notation, for example ``['server.port']``. Must not be a bare string. + An empty iterable is still passed to snapd, and may trigger the snap's config hook. + + Raises: + TypeError: if ``keys`` is a string (must be a non-string iterable of strings). + NotFoundError: if the snap is not installed. + ChangeError: if the snap's configure hook fails. This includes unsetting any + configuration on a snap that does not define a configure hook. A failed change + is rolled back: no key from the request is unset. + """ + if isinstance(keys, str): + raise TypeError('keys must be an iterable of strings (not a string)') + # NOTE: snap-not-found is returned for a missing snap, but not for system or core, + # even if the core snap isn't installed -- configuration changes are still applied. + # NOTE: Unset with no keys is a no-op (like set with an empty dict). We let snapd handle it. + _client.put(f'/v2/snaps/{snap}/conf', body=dict.fromkeys(keys)) + + +# Defined last to minimise the chance of meaningfully shadowing the built-in set type. +def set(snap: str, config: dict[str, Any]) -> None: # noqa: A001 (shadowing a Python builtin) + """Set snap configuration. + + Args: + snap: The name of the snap to configure. + config: A mapping of configuration keys to values. Values may be any JSON-serialisable + type, including nested dicts and lists. Setting a key to ``None`` unsets it. + Nested options may be addressed with dotted keys, for example ``server.port``. + An empty mapping is accepted as a no-op. + + Raises: + NotFoundError: if the snap is not installed. + ChangeError: if the snap's configure hook fails. This includes setting any + configuration on a snap that does not define a configure hook, and configuration + rejected by a validating configure hook. A failed change is rolled back: no key + from the request is applied. + """ + # NOTE: snap-not-found is returned for a missing snap, but not for system or core, + # even if the core snap isn't installed -- configuration changes are still applied. + # NOTE: Set with an empty dict is a no-op. We let snapd handle it. + _client.put(f'/v2/snaps/{snap}/conf', body=config) diff --git a/snap/tests/functional/setup.sh b/snap/tests/functional/setup.sh index 1830b63c2..ed8e57141 100644 --- a/snap/tests/functional/setup.sh +++ b/snap/tests/functional/setup.sh @@ -3,3 +3,4 @@ SNAPS_DIR="tests/functional/snaps" mksquashfs "$SNAPS_DIR/test-snap-1.0" "$SNAPS_DIR/test-snap_1.0.snap" -noappend -comp xz -quiet mksquashfs "$SNAPS_DIR/test-snap-2.0" "$SNAPS_DIR/test-snap_2.0.snap" -noappend -comp xz -quiet mksquashfs "$SNAPS_DIR/test-classic-snap-1.0" "$SNAPS_DIR/test-classic-snap_1.0.snap" -noappend -comp xz -quiet +mksquashfs "$SNAPS_DIR/test-configure-snap-1.0" "$SNAPS_DIR/test-configure-snap_1.0.snap" -noappend -comp xz -quiet diff --git a/snap/tests/functional/snaps/test-configure-snap-1.0/meta/hooks/configure b/snap/tests/functional/snaps/test-configure-snap-1.0/meta/hooks/configure new file mode 100755 index 000000000..3c26cb795 --- /dev/null +++ b/snap/tests/functional/snaps/test-configure-snap-1.0/meta/hooks/configure @@ -0,0 +1,8 @@ +#!/bin/sh -e +# Validating configure hook: rejects any value for 'bad-key', accepts everything else. +# Used by functional tests to exercise how snaps reject configuration at set time. +bad="$(snapctl get bad-key)" +if [ -n "$bad" ]; then + echo "bad-key is not allowed" >&2 + exit 1 +fi diff --git a/snap/tests/functional/snaps/test-configure-snap-1.0/meta/snap.yaml b/snap/tests/functional/snaps/test-configure-snap-1.0/meta/snap.yaml new file mode 100644 index 000000000..96b144852 --- /dev/null +++ b/snap/tests/functional/snaps/test-configure-snap-1.0/meta/snap.yaml @@ -0,0 +1,5 @@ +name: test-configure-snap +version: "1.0" +summary: Snap with a validating configure hook for charmlibs.snap functional tests +confinement: strict +base: core24 diff --git a/snap/tests/functional/test_snapd_conf.py b/snap/tests/functional/test_snapd_conf.py new file mode 100644 index 000000000..099df2a71 --- /dev/null +++ b/snap/tests/functional/test_snapd_conf.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Functional tests for _snapd_conf: get, set, unset.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +from charmlibs.snap import _client, _errors, _snapd_conf +from conftest import ensure_installed, ensure_removed +from test_snapd_local import SNAPS_DIR, install_local + +if TYPE_CHECKING: + from collections.abc import Iterator + +# A small Canonical-owned snap with a passthrough configure hook. +# Defined in https://github.com/canonical/snapd/tree/master/tests/lib/snaps +# Only published on latest/edge. +_SNAP = 'test-snapd-with-configure' +# A key prefix we use to avoid colliding with any other configuration on the snap. +_KEY = 'test-functional-key' +_KEY2 = 'test-functional-key2' + +# 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 _get_one(snap: str, key: str, /) -> Any: + """Get a single snap configuration key.""" + config = _snapd_conf.get(snap, [key]) + return config[key] + + +def _cleanup(*keys: str) -> None: + """Unset test keys to avoid contaminating other tests.""" + _snapd_conf.unset(_SNAP, [_KEY, *keys]) + + +# --------------------------------------------------------------------------- +# set and get roundtrip +# --------------------------------------------------------------------------- + + +def test_set_and_get_bool_true(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: True}) + assert _get_one(_SNAP, _KEY) is True + _cleanup() + + +def test_set_and_get_bool_false(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: False}) + assert _get_one(_SNAP, _KEY) is False + _cleanup() + + +def test_set_and_get_integer(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 42}) + assert _get_one(_SNAP, _KEY) == 42 + _cleanup() + + +def test_set_and_get_float(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 3.14}) + assert _get_one(_SNAP, _KEY) == 3.14 + _cleanup() + + +def test_set_and_get_string(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'hello'}) + assert _get_one(_SNAP, _KEY) == 'hello' + _cleanup() + + +def test_set_and_get_list(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: [1, 2, 3]}) + assert _get_one(_SNAP, _KEY) == [1, 2, 3] + _cleanup() + + +def test_set_and_get_dict(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: {'a': 1, 'b': 'two'}}) + assert _get_one(_SNAP, _KEY) == {'a': 1, 'b': 'two'} + # Dotted notation reads back a single nested value. + assert _get_one(_SNAP, f'{_KEY}.a') == 1 + _cleanup() + + +def test_set_null_unsets_key(): + # Setting a key to None (JSON null) unsets it at the top level. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'hello'}) + assert _snapd_conf.get(_SNAP, [_KEY]).get(_KEY) == 'hello' + _snapd_conf.set(_SNAP, {_KEY: None}) + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(_SNAP, [_KEY]) + + +# --------------------------------------------------------------------------- +# get +# --------------------------------------------------------------------------- + + +def test_get_all_keys(): + # get() with no keys returns all config as a dict. Nested values are returned in full: + # the returned dict is keyed by top-level option, and each value keeps its full structure. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'value', _KEY2: {'nested': {'deep': 1}}}) + config = _snapd_conf.get(_SNAP) + assert isinstance(config, dict) + assert config.get(_KEY) == 'value' + assert config.get(_KEY2) == {'nested': {'deep': 1}} + _cleanup(_KEY2) + + +def test_get_mixed_dotted_and_non_dotted_keys(): + # Requesting an option and a dotted sub-key of it returns both as separate entries: + # the plain key yields the full subtree, the dotted key yields the leaf, keyed by the + # literal dotted string. The two do not merge or collide. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: {'nested': 'value'}}) + result = _snapd_conf.get(_SNAP, [_KEY, f'{_KEY}.nested']) + assert result == {_KEY: {'nested': 'value'}, f'{_KEY}.nested': 'value'} + _cleanup() + + +def test_get_specific_keys_returns_subset(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'alpha', _KEY2: 'beta'}) + subset = _snapd_conf.get(_SNAP, [_KEY]) + assert _KEY in subset + assert _KEY2 not in subset + _cleanup(_KEY2) + + +def test_get_multiple_specific_keys(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'alpha', _KEY2: 'beta'}) + result = _snapd_conf.get(_SNAP, [_KEY, _KEY2]) + assert result[_KEY] == 'alpha' + assert result[_KEY2] == 'beta' + _cleanup(_KEY2) + + +def test_get_option_not_found_raises(): + ensure_installed(_SNAP, channel='latest/edge') + with pytest.raises(_errors.OptionNotFoundError) as ctx: + _snapd_conf.get(_SNAP, ['key-that-should-not-exist']) + assert ctx.value.kind == 'option-not-found' + assert ctx.value.message + + +def test_get_option_not_found_value_contains_snap_and_key(): + ensure_installed(_SNAP, channel='latest/edge') + with pytest.raises(_errors.OptionNotFoundError) as ctx: + _snapd_conf.get(_SNAP, ['key-that-should-not-exist']) + value = str(ctx.value.value) + assert 'SnapName' in value + assert 'Key' in value + assert _SNAP in value + assert 'key-that-should-not-exist' in value + + +def test_get_not_installed_snap_raises_not_found(): + # Config GET for a non-installed snap returns option-not-found (not snap-not-found), so + # get() probes /v2/snaps/{snap} and raises NotFoundError, consistent with set and unset. + with pytest.raises(_errors.NotFoundError) as ctx: + _snapd_conf.get(_ABSENT_SNAP, ['any-key']) + assert ctx.value.kind == 'snap-not-found' + + +def test_get_all_not_installed_snap_raises_not_found(): + # Config GET with no keys for a non-installed snap is a 200 with an empty result, + # indistinguishable from an installed snap with no configuration, so get() probes + # /v2/snaps/{snap} and raises NotFoundError instead of returning {}. + with pytest.raises(_errors.NotFoundError) as ctx: + _snapd_conf.get(_ABSENT_SNAP) + assert ctx.value.kind == 'snap-not-found' + + +def test_raw_get_all_not_installed_snap_returns_empty_dict(): + # Pin the raw snapd behaviour that get()'s empty-config probe relies on: a bare conf GET (no + # keys) for a non-installed snap is a 200 with an empty result, NOT an error. This is what + # makes an absent snap indistinguishable from an installed snap with no configuration, and + # hence why get() must probe. Asserted at the _client level because get() converts it to + # NotFoundError; if snapd ever reported the absent snap directly here, this fails loudly + # rather than leaving get()'s probe branch as untested dead code. + assert _client.get(f'/v2/snaps/{_ABSENT_SNAP}/conf') == {} + + +def test_get_all_installed_snap_with_no_config_returns_empty_dict(): + # hello-world has no configure hook, so it can never have configuration. Unlike the CLI + # (`snap get hello-world` errors with 'has no configuration'), get() returns an empty dict. + ensure_installed('hello-world') + assert _snapd_conf.get('hello-world') == {} + + +# --------------------------------------------------------------------------- +# get: keys=[] ("give me nothing") vs keys=None ("give me everything") +# --------------------------------------------------------------------------- + + +def test_get_empty_keys_returns_empty_dict(): + # keys=[] is a deliberate "no keys requested" query, distinct from the CLI-following + # default of keys=None ("give me everything"). It never reaches the conf endpoint, but + # still confirms the snap is installed before returning {}. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'value'}) + assert _snapd_conf.get(_SNAP, []) == {} + _cleanup() + + +def test_get_empty_keys_not_installed_snap_raises_not_found(): + with pytest.raises(_errors.NotFoundError) as ctx: + _snapd_conf.get(_ABSENT_SNAP, []) + assert ctx.value.kind == 'snap-not-found' + + +def test_get_keys_of_only_empty_strings_returns_full_config(): + # Undocumented snapd quirk, not a decision this library makes: keys=[''] and + # keys=['', ''] are NOT the same as our own keys=[] contract above. keys=[] is caught + # by our own `keys == []` check before any network call is made, but a list containing + # only empty strings doesn't match that check, so it falls through to being joined into + # the 'keys' query parameter -- '' and ',' respectively. snapd's own parsing of that + # query string treats both the same as no 'keys' param at all, so the full config comes + # back, equivalent to keys=None rather than keys=[]. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'value'}) + full_config = _snapd_conf.get(_SNAP) + assert full_config != {} + assert _snapd_conf.get(_SNAP, ['']) == full_config + assert _snapd_conf.get(_SNAP, ['', '']) == full_config + _cleanup() + + +# --------------------------------------------------------------------------- +# unset +# --------------------------------------------------------------------------- + + +def test_unset_key(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'hello'}) + assert _snapd_conf.get(_SNAP, [_KEY]).get(_KEY) == 'hello' + _snapd_conf.unset(_SNAP, [_KEY]) + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(_SNAP, [_KEY]) + + +def test_unset_nonexistent_key_no_error(): + # Unsetting a key that doesn't exist should not raise. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.unset(_SNAP, ['key-that-does-not-exist']) + + +def test_unset_nonexistent_key_deeply_dotted_no_error(): + # Unsetting a dotted-path key that doesn't exist should not raise. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.unset(_SNAP, ['key-that-does-not-exist.nested.deep']) + + +def test_unset_multiple_keys(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'val1', _KEY2: 'val2'}) + _snapd_conf.unset(_SNAP, [_KEY, _KEY2]) + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(_SNAP, [_KEY]) + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(_SNAP, [_KEY2]) + + +def test_unset_empty_keys_is_noop(): + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'value'}) + _snapd_conf.unset(_SNAP, []) # Should not raise, and should not touch existing config. + assert _get_one(_SNAP, _KEY) == 'value' + _cleanup() + + +# --------------------------------------------------------------------------- +# not-installed snap (uses a never-installed name to avoid churn) +# --------------------------------------------------------------------------- + + +# An empty body is checked alongside a non-empty one: snapd validates that the snap is +# installed regardless of the patch contents, so both raise the same error. +@pytest.mark.parametrize('config', [{'test-key': 'value'}, {}]) +def test_set_not_installed_snap_raises_snap_not_found(config: dict[str, Any]): + with pytest.raises(_errors.NotFoundError) as ctx: + _snapd_conf.set(_ABSENT_SNAP, config) + assert ctx.value.kind == 'snap-not-found' + + +def test_unset_not_installed_snap_raises_snap_not_found(): + with pytest.raises(_errors.NotFoundError) as ctx: + _snapd_conf.unset(_ABSENT_SNAP, ['test-key']) + assert ctx.value.kind == 'snap-not-found' + + +# --------------------------------------------------------------------------- +# set +# --------------------------------------------------------------------------- + + +def test_set_multiple_keys_at_once(): + ensure_installed(_SNAP, channel='latest/edge') + values: dict[str, Any] = {_KEY: 'v1', _KEY2: 'v2'} + _snapd_conf.set(_SNAP, values) + result = _snapd_conf.get(_SNAP, [_KEY, _KEY2]) + assert result[_KEY] == 'v1' + assert result[_KEY2] == 'v2' + _cleanup(_KEY2) + + +def test_set_empty_dict_is_noop(): + # set(snap, {}) is a no-op — the API accepts an empty body without error, and existing + # configuration is left untouched (an empty body does NOT unset all keys). + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'before-empty-set'}) + _snapd_conf.set(_SNAP, {}) + assert _get_one(_SNAP, _KEY) == 'before-empty-set' + _cleanup() + + +def test_get_mixed_keys_raises_option_not_found(): + # When some requested keys exist and some don't, the API raises option-not-found + # for the first missing key rather than returning partial results. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: 'exists'}) + with pytest.raises(_errors.OptionNotFoundError) as ctx: + _snapd_conf.get(_SNAP, [_KEY, 'key-that-does-not-exist-xyz']) + assert ctx.value.kind == 'option-not-found' + _cleanup() + + +def test_unset_dotted_path_no_error(): + # unset() accepts dotted-path keys and the API handles them without error. + ensure_installed(_SNAP, channel='latest/edge') + _snapd_conf.set(_SNAP, {_KEY: {'nested': 'value'}}) + _snapd_conf.unset(_SNAP, [f'{_KEY}.nested']) # Should not raise. + _cleanup() + + +# --------------------------------------------------------------------------- +# configure hook failure -> ChangeError +# --------------------------------------------------------------------------- + + +def test_set_no_configure_hook_raises_change_error(): + # set/unset run the snap's configure hook as an async change. hello-world has no + # configure hook, so snapd fails the change and we surface it as a ChangeError. + ensure_installed('hello-world') + with pytest.raises(_errors.ChangeError): + _snapd_conf.set('hello-world', {'any-key': 'value'}) + + +def test_unset_no_configure_hook_raises_change_error(): + ensure_installed('hello-world') + with pytest.raises(_errors.ChangeError): + _snapd_conf.unset('hello-world', ['any-key']) + + +def test_set_empty_dict_no_configure_hook_is_noop(): + # An empty patch is the exception to the rule above: snapd marks the configure hook optional + # when there is nothing to set (Optional: len(patch) == 0), so a missing hook is not an + # error and the change completes as a no-op. Contrast test_set_no_configure_hook_*, where a + # non-empty patch on the same hook-less snap does raise. + ensure_installed('hello-world') + _snapd_conf.set('hello-world', {}) # Should not raise. + + +# --------------------------------------------------------------------------- +# configure hook validation -> ChangeError +# --------------------------------------------------------------------------- +# A snap's configure hook can validate incoming configuration (read via snapctl get) and +# reject it by exiting non-zero. test-configure-snap (tests/functional/snaps) rejects any +# value for 'bad-key' and accepts everything else. + + +@pytest.fixture(scope='module') +def configure_snap() -> Iterator[None]: + install_local(SNAPS_DIR / 'test-configure-snap_1.0.snap', dangerous=True) + yield + ensure_removed('test-configure-snap') + + +def test_set_rejected_by_configure_hook_raises_change_error(configure_snap: None): + with pytest.raises(_errors.ChangeError) as ctx: + _snapd_conf.set('test-configure-snap', {'bad-key': 'x'}) + # The hook's stderr is embedded in the change error message. + assert 'bad-key is not allowed' in ctx.value.message + # The rejected change is rolled back: nothing was stored. + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get('test-configure-snap', ['bad-key']) + + +def test_set_accepted_by_configure_hook(configure_snap: None): + _snapd_conf.set('test-configure-snap', {'good-key': 'hello'}) + assert _snapd_conf.get('test-configure-snap', ['good-key']) == {'good-key': 'hello'} + _snapd_conf.unset('test-configure-snap', ['good-key']) + + +def test_rejected_set_rolls_back_entire_transaction(configure_snap: None): + # Configuration changes are transactional: if the hook rejects any key, no key in the + # request is applied, and previously stored values are preserved. + _snapd_conf.set('test-configure-snap', {'good-key': 'before'}) + with pytest.raises(_errors.ChangeError): + _snapd_conf.set('test-configure-snap', {'good-key': 'after', 'bad-key': 'x'}) + assert _snapd_conf.get('test-configure-snap', ['good-key']) == {'good-key': 'before'} + _snapd_conf.unset('test-configure-snap', ['good-key']) diff --git a/snap/tests/functional/test_snapd_conf_system.py b/snap/tests/functional/test_snapd_conf_system.py new file mode 100644 index 000000000..7d0b507e1 --- /dev/null +++ b/snap/tests/functional/test_snapd_conf_system.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Functional tests for system configuration via _snapd_conf: get/set/unset on 'system'. + +snapd's conf endpoints treat 'system' as an alias for the 'core' snap and serve system +configuration whether or not the core snap is installed. The `core_snap` fixture runs every +test here in both states -- core installed and core absent -- because the absent state is where +get()'s installed-snap probe must be skipped: /v2/snaps/system always 404s and /v2/snaps/core +404s with no core snap, so probing would turn working calls into NotFoundError. + +This module is destructive to stored system configuration: removing the core snap deletes it +(snapd treats core's config like any other snap's; options snapd maintains itself, such as +seed.loaded, are re-mirrored on the next snapd restart). Tests therefore never preserve +pre-existing system options -- run this only where system configuration is disposable, as in +CI containers. + +Ordering: the module needs no special ordering relative to other modules. It removes and +restores the core snap within its own fixture, no other module depends on system configuration, +and any snap a module needs is (re)installed via ensure_installed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from charmlibs import snap +from charmlibs.snap import _errors, _snapd_conf +from conftest import ensure_removed + +if TYPE_CHECKING: + from collections.abc import Iterator + +# A validated system option (documented range 2-20) that is unset by default. +_OPTION = 'refresh.retain' + + +@pytest.fixture(scope='module', params=['installed', 'absent']) +def core_snap(request: pytest.FixtureRequest) -> Iterator[str]: + """Run each test with the core snap installed and again with it absent. + + pytest groups tests by this module-scoped parameter, so the core snap's install state is + flipped at most once per state, not once per test. + + In the 'absent' state we remove the core snap after removing any snap that has it as a base + (a base-less snap like hello-world or test-snapd-with-configure, installed by other modules, + otherwise blocks removal). A failed removal is deliberately left to error loudly rather than + skip: if a base-less snap we don't manage is installed, that's worth surfacing so we can + handle it explicitly. + """ + if request.param == 'absent': + ensure_removed('hello-world', 'test-snapd-with-configure') + snap.remove('core') # Errors loudly if an unmanaged snap still depends on core. + yield request.param + snap.install('core') + else: + snap.install('core') # A no-op if already installed. + yield request.param + + +@pytest.mark.parametrize('name', ['system', 'core']) +def test_get_system_conf(core_snap: str, name: str): + config = _snapd_conf.get(name) + assert isinstance(config, dict) + assert 'system' in config # Computed live by snapd (hostname, timezone, and so on). + + +def test_get_system_and_core_are_aliases(core_snap: str): + assert _snapd_conf.get('system') == _snapd_conf.get('core') + + +@pytest.mark.parametrize('name', ['system', 'core']) +def test_get_system_missing_key_raises_option_not_found(core_snap: str, name: str): + # The case get()'s probe would break: /v2/snaps/{name} 404s while the configuration is + # served, so the probe must be skipped for these names. + with pytest.raises(_errors.OptionNotFoundError) as ctx: + _snapd_conf.get(name, ['key-that-does-not-exist-xyz']) + assert ctx.value.kind == 'option-not-found' + # snapd resolves the alias in the error details: SnapName is always reported as 'core'. + assert 'core' in str(ctx.value.value) + + +@pytest.mark.parametrize('name', ['system', 'core']) +def test_set_system_unknown_option_raises_change_error(core_snap: str, name: str): + # Unlike schemaless snap configuration, system configuration is validated: snapd's internal + # configure handler rejects unknown options and the failed change surfaces as ChangeError. + with pytest.raises(_errors.ChangeError) as ctx: + _snapd_conf.set(name, {'test-unknown-key-xyz': 'value'}) + assert 'unsupported system option' in ctx.value.message + # The failed change is rolled back: nothing was stored. + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(name, ['test-unknown-key-xyz']) + + +@pytest.mark.parametrize('name', ['system', 'core']) +def test_set_get_unset_system_option(core_snap: str, name: str): + # System set/unset are handled internally by snapd (no configure hook or snap required). + # This module treats stored system options as expendable, so we don't preserve/restore. + try: + _snapd_conf.set(name, {_OPTION: 3}) + assert _snapd_conf.get(name, [_OPTION]) == {_OPTION: 3} + finally: + _snapd_conf.unset(name, [_OPTION]) + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get(name, [_OPTION]) + + +@pytest.mark.parametrize('name', ['system', 'core']) +def test_set_empty_dict_is_noop(core_snap: str, name: str): + # set(name, {}) sends an empty body, accepted as a no-op that leaves existing configuration + # untouched (it does NOT unset everything), whether or not the core snap is installed. + try: + _snapd_conf.set(name, {_OPTION: 3}) + _snapd_conf.set(name, {}) + assert _snapd_conf.get(name, [_OPTION]) == {_OPTION: 3} + finally: + _snapd_conf.unset(name, [_OPTION]) + + +def test_removing_core_snap_deletes_stored_system_config(): + # Stored system options live in snapd state under the snap name 'core'. Removing the core + # snap deletes them like any other snap's config, while options computed live by snapd + # (system.hostname and so on) survive. This test manages the core snap itself, so it does + # not use the core_snap fixture. + snap.install('core') # Ensure installed, so its removal actually deletes stored config. + _snapd_conf.set('system', {_OPTION: 3}) + assert _snapd_conf.get('system', [_OPTION]) == {_OPTION: 3} + ensure_removed('hello-world') # Base-less; would otherwise block core removal. + snap.remove('core') + try: + # Removal deleted the stored option... + with pytest.raises(_errors.OptionNotFoundError): + _snapd_conf.get('system', [_OPTION]) + # ...while computed configuration remains, so bare get is not empty. + assert 'system' in _snapd_conf.get('system') + finally: + snap.install('core') # Restore the core snap for other tests. + _snapd_conf.unset('system', [_OPTION]) # A no-op if the removal already wiped it. diff --git a/snap/tests/unit/test_snapd_conf.py b/snap/tests/unit/test_snapd_conf.py new file mode 100644 index 000000000..7d52c17d9 --- /dev/null +++ b/snap/tests/unit/test_snapd_conf.py @@ -0,0 +1,225 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +# pyright: reportPrivateUsage=false + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from charmlibs.snap import _snapd_conf +from charmlibs.snap._errors import ChangeError, NotFoundError, OptionNotFoundError +from conftest import result_of + +if TYPE_CHECKING: + from conftest import MockClient + + +class TestGet: + def test_get_all(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_all.json') + _snapd_conf.get('lxd') + mock_client.get.assert_called_once_with('/v2/snaps/lxd/conf', query=None) + + def test_get_all_explicit_keys_none(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_all.json') + _snapd_conf.get('lxd', keys=None) + mock_client.get.assert_called_once_with('/v2/snaps/lxd/conf', query=None) + + def test_get_specific_key(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_single_key.json') + _snapd_conf.get('lxd', ['integer']) + mock_client.get.assert_called_once_with('/v2/snaps/lxd/conf', query={'keys': 'integer'}) + + def test_get_multiple_keys(self, mock_client: MockClient): + mock_client.get.return_value = {'a': 1, 'b': 2} + _snapd_conf.get('lxd', ['a', 'b']) + query = mock_client.get.call_args.kwargs['query'] + assert query == {'keys': 'a,b'} + + def test_get_accepts_arbitrary_iterable(self, mock_client: MockClient): + # keys need not be a list -- any non-string iterable of strings works. + mock_client.get.return_value = {'a': 1, 'b': 2} + _snapd_conf.get('lxd', (k for k in ('a', 'b'))) + query = mock_client.get.call_args.kwargs['query'] + assert query == {'keys': 'a,b'} + + def test_get_single_empty_string_key_sends_empty_keys_param(self, mock_client: MockClient): + # ['' ] doesn't match our own keys=[] short-circuit (`keys == []`), so it falls + # through to the query string as an empty 'keys' value. See the functional tests + # for what snapd actually does with that (spoiler: it's not the same as keys=[]). + mock_client.get.return_value = result_of('conf_lxd_all.json') + _snapd_conf.get('lxd', ['']) + mock_client.get.assert_called_once_with('/v2/snaps/lxd/conf', query={'keys': ''}) + + def test_get_multiple_empty_string_keys_sends_comma_keys_param(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_all.json') + _snapd_conf.get('lxd', ['', '']) + mock_client.get.assert_called_once_with('/v2/snaps/lxd/conf', query={'keys': ','}) + + def test_get_returns_dict(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_all.json') + result = _snapd_conf.get('lxd') + assert isinstance(result, dict) + assert 'criu' in result + + def test_get_string_keys_raises_typeerror(self, mock_client: MockClient): + # A bare string is iterable, so it's rejected explicitly rather than being + # silently split into single-character keys. + with pytest.raises(TypeError): + _snapd_conf.get('lxd', 'integer') + mock_client.get.assert_not_called() + + +class TestGetEmptyKeys: + def test_get_empty_keys_returns_empty_dict(self, mock_client: MockClient): + mock_client.get.return_value = result_of('snap_info_hello_world.json') + assert _snapd_conf.get('hello-world', []) == {} + + def test_get_empty_keys_probes_installed_snap_not_conf_endpoint(self, mock_client: MockClient): + mock_client.get.return_value = result_of('snap_info_hello_world.json') + _snapd_conf.get('hello-world', []) + mock_client.get.assert_called_once_with('/v2/snaps/hello-world') + + def test_get_empty_keys_not_installed_raises_not_found(self, mock_client: MockClient): + mock_client.get.side_effect = NotFoundError( + 'snap not installed', kind='snap-not-found', value='hello-world' + ) + with pytest.raises(NotFoundError): + _snapd_conf.get('hello-world', []) + + def test_get_empty_keys_system_not_probed(self, mock_client: MockClient): + # system/core skip the installed-snap probe entirely, so no network call is made. + assert _snapd_conf.get('system', []) == {} + mock_client.get.assert_not_called() + + +class TestGetAbsentSnapProbe: + # The conf GET endpoint alone can't distinguish an absent snap from a missing key (or from + # empty configuration), so get() probes /v2/snaps/{snap} on those paths to raise + # NotFoundError, consistent with set and unset. See the functional tests for captured + # responses. + _OPTION_NOT_FOUND = OptionNotFoundError( + 'snap "hello-world" has no "mykey" configuration option', + kind='option-not-found', + value="{'SnapName': 'hello-world', 'Key': 'mykey'}", + ) + _SNAP_NOT_FOUND = NotFoundError( + 'snap not installed', kind='snap-not-found', value='hello-world' + ) + + def test_missing_key_on_installed_snap_reraises_option_not_found( + self, mock_client: MockClient + ): + mock_client.get.side_effect = [ + self._OPTION_NOT_FOUND, + result_of('snap_info_hello_world.json'), + ] + with pytest.raises(OptionNotFoundError): + _snapd_conf.get('hello-world', ['mykey']) + probe_call = mock_client.get.call_args_list[1] + assert probe_call.args[0] == '/v2/snaps/hello-world' + + def test_missing_key_on_absent_snap_raises_not_found(self, mock_client: MockClient): + mock_client.get.side_effect = [self._OPTION_NOT_FOUND, self._SNAP_NOT_FOUND] + with pytest.raises(NotFoundError): + _snapd_conf.get('hello-world', ['mykey']) + + def test_get_all_empty_on_absent_snap_raises_not_found(self, mock_client: MockClient): + # A bare conf GET on an absent snap is a 200 with an empty result, so the probe is + # what turns it into an error. + mock_client.get.side_effect = [{}, self._SNAP_NOT_FOUND] + with pytest.raises(NotFoundError): + _snapd_conf.get('hello-world') + + def test_get_all_empty_on_installed_snap_returns_empty_dict(self, mock_client: MockClient): + mock_client.get.side_effect = [{}, result_of('snap_info_hello_world.json')] + assert _snapd_conf.get('hello-world') == {} + + def test_get_all_nonempty_is_not_probed(self, mock_client: MockClient): + mock_client.get.return_value = result_of('conf_lxd_all.json') + _snapd_conf.get('lxd') + mock_client.get.assert_called_once() + + def test_missing_key_on_system_is_not_probed(self, mock_client: MockClient): + # /v2/snaps/system always 404s while its conf is served, so system names skip the probe. + mock_client.get.side_effect = self._OPTION_NOT_FOUND + with pytest.raises(OptionNotFoundError): + _snapd_conf.get('system', ['mykey']) + mock_client.get.assert_called_once() + + def test_get_all_empty_on_core_is_not_probed(self, mock_client: MockClient): + mock_client.get.return_value = {} + assert _snapd_conf.get('core') == {} + mock_client.get.assert_called_once() + + +class TestSet: + def test_set(self, mock_client: MockClient): + _snapd_conf.set('lxd', {'mykey': 'myval'}) + mock_client.put.assert_called_once_with('/v2/snaps/lxd/conf', body={'mykey': 'myval'}) + + +class TestUnset: + def test_unset_single(self, mock_client: MockClient): + _snapd_conf.unset('lxd', ['mykey']) + mock_client.put.assert_called_once_with('/v2/snaps/lxd/conf', body={'mykey': None}) + + def test_unset_multiple(self, mock_client: MockClient): + _snapd_conf.unset('lxd', ['a', 'b']) + body = mock_client.put.call_args.kwargs['body'] + assert body == {'a': None, 'b': None} + + def test_unset_accepts_arbitrary_iterable(self, mock_client: MockClient): + _snapd_conf.unset('lxd', (k for k in ('a', 'b'))) + body = mock_client.put.call_args.kwargs['body'] + assert body == {'a': None, 'b': None} + + def test_unset_string_keys_raises_typeerror(self, mock_client: MockClient): + # A bare string is iterable, so it's rejected explicitly rather than being + # silently split into single-character keys. + with pytest.raises(TypeError): + _snapd_conf.unset('lxd', 'mykey') + mock_client.put.assert_not_called() + + def test_unset_empty_keys_is_noop(self, mock_client: MockClient): + _snapd_conf.unset('lxd', []) + mock_client.put.assert_called_once_with('/v2/snaps/lxd/conf', body={}) + + +class TestSetAdditional: + def test_set_empty_dict(self, mock_client: MockClient): + # set({}) sends an empty body — the API accepts it as a no-op. + _snapd_conf.set('lxd', {}) + mock_client.put.assert_called_once_with('/v2/snaps/lxd/conf', body={}) + + +class TestUnsetAdditional: + def test_unset_dotted_path(self, mock_client: MockClient): + # unset() with a dotted-path key passes it as-is to the API. + _snapd_conf.unset('lxd', ['parent.child']) + mock_client.put.assert_called_once_with('/v2/snaps/lxd/conf', body={'parent.child': None}) + + +class TestConfigureHookFailure: + # Setting or unsetting config runs the snap's configure hook as an async change. A failing + # hook (including a snap with no configure hook) surfaces as a ChangeError. + _CHANGE_ERROR = ChangeError( + 'cannot perform the following tasks:\n' + '- Run configure hook of "hello-world" snap (snap "hello-world" has no "configure" hook)', + kind='charmlibs-snap-change-error', + value='63', + status='Error', + ) + + def test_set_change_error_propagates(self, mock_client: MockClient): + mock_client.put.side_effect = self._CHANGE_ERROR + with pytest.raises(ChangeError): + _snapd_conf.set('hello-world', {'mykey': 'myval'}) + + def test_unset_change_error_propagates(self, mock_client: MockClient): + mock_client.put.side_effect = self._CHANGE_ERROR + with pytest.raises(ChangeError): + _snapd_conf.unset('hello-world', ['mykey'])