diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 0dfe27c791..6cff176487 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1338,6 +1338,9 @@ files: $modules/snap_alias.py: labels: snap maintainers: russoz + $modules/snap_connect.py: + labels: snap + maintainers: russoz $modules/snmp_facts.py: maintainers: ogenstad ujwalkomarla lalten $modules/solaris_zone.py: diff --git a/plugins/module_utils/snap.py b/plugins/module_utils/snap.py index f14b7a5315..23d59350de 100644 --- a/plugins/module_utils/snap.py +++ b/plugins/module_utils/snap.py @@ -45,6 +45,10 @@ def snap_runner(module: AnsibleModule, **kwargs) -> CmdRunner: info=cmd_runner_fmt.as_fixed("info"), dangerous=cmd_runner_fmt.as_bool("--dangerous"), version=cmd_runner_fmt.as_fixed("version"), + _connect=cmd_runner_fmt.as_func(lambda v: ["connect", v]), + _disconnect=cmd_runner_fmt.as_func(lambda v: ["disconnect", v]), + _connections=cmd_runner_fmt.as_fixed("connections"), + slot=cmd_runner_fmt.as_list(), ), check_rc=False, **kwargs, diff --git a/plugins/modules/snap_connect.py b/plugins/modules/snap_connect.py new file mode 100644 index 0000000000..91615725d5 --- /dev/null +++ b/plugins/modules/snap_connect.py @@ -0,0 +1,163 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Alexei Znamensky (russoz) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: snap_connect +short_description: Manages snap interface connections +version_added: "12.6.0" +description: + - Manages connections between snap plugs and slots. + - Snaps run in a sandbox and need explicit interface connections to access system resources + or communicate with other snaps. +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + state: + description: + - Desired state of the connection. + type: str + choices: [absent, present] + default: present + plug: + description: + - The plug endpoint in the format C(:). + type: str + required: true + slot: + description: + - The slot endpoint in the format C(:) or C(:) for system slots. + - If not specified, snapd will attempt to find a matching slot automatically. + type: str +notes: + - Privileged operations require root privileges. +author: + - Alexei Znamensky (@russoz) +seealso: + - module: community.general.snap + - module: community.general.snap_alias +""" + +EXAMPLES = r""" +- name: Connect firefox camera plug to system camera slot + community.general.snap_connect: + plug: firefox:camera + slot: ":camera" + +- name: Connect snap plug (slot resolved automatically by snapd) + community.general.snap_connect: + plug: my-app:network + +- name: Disconnect a specific connection + community.general.snap_connect: + plug: firefox:camera + slot: ":camera" + state: absent +""" + +RETURN = r""" +snap_connections: + description: The list of snap connections after execution. + type: list + elements: dict + returned: always + contains: + interface: + description: The interface name. + type: str + plug: + description: The plug endpoint. + type: str + slot: + description: The slot endpoint. + type: str +version: + description: Versions of snap components as reported by C(snap version). + type: dict + returned: always +""" + +import re + +from ansible_collections.community.general.plugins.module_utils.module_helper import StateModuleHelper +from ansible_collections.community.general.plugins.module_utils.snap import get_version, snap_runner + + +class SnapConnect(StateModuleHelper): + _RE_CONNECTIONS = re.compile(r"^(?P\S+)\s+(?P\S+)\s+(?P\S+)\s+.*$") + + module = dict( + argument_spec={ + "state": dict(type="str", choices=["absent", "present"], default="present"), + "plug": dict(type="str", required=True), + "slot": dict(type="str"), + }, + supports_check_mode=True, + ) + + def __init_module__(self): + self.runner = snap_runner(self.module) + self.vars.version = get_version(self.runner) + self.vars.set("snap_connections", self._get_connections(), change=True, diff=True) + + def __quit_module__(self): + self.vars.snap_connections = self._get_connections() + + def _get_connections(self): + def process(rc, out, err): + if rc != 0: + return [] + connections = [] + for line in out.splitlines()[1:]: + match = self._RE_CONNECTIONS.match(line.strip()) + if match: + connections.append(match.groupdict()) + return connections + + with self.runner("_connections", output_process=process) as ctx: + return ctx.run() + + def _is_connected(self): + plug = self.vars.plug + slot = self.vars.slot + return any( + conn["slot"] != "-" and conn["plug"] == plug and (slot is None or conn["slot"] == slot) + for conn in self.vars.snap_connections + ) + + def state_present(self): + if not self._is_connected(): + self.changed = True + if self.check_mode: + return + with self.runner("_connect slot") as ctx: + rc, dummy, err = ctx.run(_connect=self.vars.plug, slot=self.vars.slot) + if rc != 0: + self.do_raise(msg=f"snap connect failed: {err}") + + def state_absent(self): + if self._is_connected(): + self.changed = True + if self.check_mode: + return + with self.runner("_disconnect slot") as ctx: + rc, dummy, err = ctx.run(_disconnect=self.vars.plug, slot=self.vars.slot) + if rc != 0: + self.do_raise(msg=f"snap disconnect failed: {err}") + + +def main(): + SnapConnect.execute() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/snap_connect/aliases b/tests/integration/targets/snap_connect/aliases new file mode 100644 index 0000000000..4e52ff0753 --- /dev/null +++ b/tests/integration/targets/snap_connect/aliases @@ -0,0 +1,13 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 +azp/posix/vm +skip/alpine +skip/fedora +skip/freebsd +skip/osx +skip/macos +skip/docker +skip/rhel8.8 # TODO: fix diff --git a/tests/integration/targets/snap_connect/meta/main.yml b/tests/integration/targets/snap_connect/meta/main.yml new file mode 100644 index 0000000000..f36427f71a --- /dev/null +++ b/tests/integration/targets/snap_connect/meta/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - setup_snap diff --git a/tests/integration/targets/snap_connect/tasks/main.yml b/tests/integration/targets/snap_connect/tasks/main.yml new file mode 100644 index 0000000000..1934eeb9f6 --- /dev/null +++ b/tests/integration/targets/snap_connect/tasks/main.yml @@ -0,0 +1,13 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Test + include_tasks: test.yml + when: has_snap diff --git a/tests/integration/targets/snap_connect/tasks/test.yml b/tests/integration/targets/snap_connect/tasks/test.yml new file mode 100644 index 0000000000..144cc86e5e --- /dev/null +++ b/tests/integration/targets/snap_connect/tasks/test.yml @@ -0,0 +1,99 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# test-snapd-upower-observe-consumer declares a single plug: upower-observe +# which auto-connects to the :upower-observe system slot. +# We use this well-known test snap to exercise connect/disconnect. + +- name: Ensure snap 'test-snapd-upower-observe-consumer' is not installed + community.general.snap: + name: test-snapd-upower-observe-consumer + state: absent + +- name: Ensure snap 'test-snapd-upower-observe-consumer' is installed + community.general.snap: + name: test-snapd-upower-observe-consumer + +################################################################################ + +- name: Disconnect upower-observe plug (ensure clean state) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + state: absent + +- name: Connect upower-observe plug (check mode) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + check_mode: true + register: connect_0 + +- name: Connect upower-observe plug + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + register: connect_1 + +- name: Connect upower-observe plug (check mode idempotent) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + check_mode: true + register: connect_2 + +- name: Connect upower-observe plug (idempotent) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + register: connect_3 + +- name: Assert connect results + assert: + that: + - connect_0 is changed + - connect_1 is changed + - connect_2 is not changed + - connect_3 is not changed + +################################################################################ + +- name: Disconnect upower-observe plug (check mode) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + state: absent + check_mode: true + register: disconnect_0 + +- name: Disconnect upower-observe plug + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + state: absent + register: disconnect_1 + +- name: Disconnect upower-observe plug (check mode idempotent) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + state: absent + check_mode: true + register: disconnect_2 + +- name: Disconnect upower-observe plug (idempotent) + community.general.snap_connect: + plug: test-snapd-upower-observe-consumer:upower-observe + slot: ":upower-observe" + state: absent + register: disconnect_3 + +- name: Assert disconnect results + assert: + that: + - disconnect_0 is changed + - disconnect_1 is changed + - disconnect_2 is not changed + - disconnect_3 is not changed