From 850ef03fe767cbfad59443ef515f5b352829bfaa Mon Sep 17 00:00:00 2001 From: Alexei Znamensky <103110+russoz@users.noreply.github.com> Date: Tue, 26 May 2026 01:34:48 +1200 Subject: [PATCH] snap: enforce hold when installing at a specific revision (#12097) * snap: enforce hold when installing at a specific revision When `revision` is specified, run `snap refresh --hold` after install/refresh to actually pin the snap and prevent automatic updates from overriding it. Detects hold-mismatch idempotently via the Notes column of `snap list`. Fixes #12088 Co-Authored-By: Claude Sonnet 4.6 * feat(changelog): add fragment for snap hold fix (#12097) Co-Authored-By: Claude Sonnet 4.6 * test(snap): remove incorrect manual-refresh assertion from hold test snap refresh --hold only blocks the snapd auto-refresh daemon; a manual snap refresh bypasses the hold. Remove the block that ran snap refresh manually and asserted the revision was unchanged. Co-Authored-By: Claude Sonnet 4.6 * snap: add bare-refresh hold test and docs warning for manual refresh bypass Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../fragments/12097-snap-hold-revision.yml | 2 + plugins/module_utils/_snap.py | 2 + plugins/modules/snap.py | 40 ++++++++++++++++--- .../targets/snap/tasks/test_revision.yml | 37 +++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/12097-snap-hold-revision.yml diff --git a/changelogs/fragments/12097-snap-hold-revision.yml b/changelogs/fragments/12097-snap-hold-revision.yml new file mode 100644 index 0000000000..6e47f9bc84 --- /dev/null +++ b/changelogs/fragments/12097-snap-hold-revision.yml @@ -0,0 +1,2 @@ +bugfixes: + - snap - enforce ``snap refresh --hold`` after installing at a specific revision (https://github.com/ansible-collections/community.general/issues/12088, https://github.com/ansible-collections/community.general/pull/12097). diff --git a/plugins/module_utils/_snap.py b/plugins/module_utils/_snap.py index d90ce5dd7b..913d16d31b 100644 --- a/plugins/module_utils/_snap.py +++ b/plugins/module_utils/_snap.py @@ -47,6 +47,8 @@ def snap_runner(module: AnsibleModule, **kwargs) -> CmdRunner: options=cmd_runner_fmt.as_list(), info=cmd_runner_fmt.as_fixed("info"), revision=cmd_runner_fmt.as_opt_val("--revision"), + hold=cmd_runner_fmt.as_bool("--hold"), + unhold=cmd_runner_fmt.as_bool("--unhold"), dangerous=cmd_runner_fmt.as_bool("--dangerous"), devmode=cmd_runner_fmt.as_bool("--devmode"), version=cmd_runner_fmt.as_fixed("version"), diff --git a/plugins/modules/snap.py b/plugins/modules/snap.py index 629b2482cf..ee51964986 100644 --- a/plugins/modules/snap.py +++ b/plugins/modules/snap.py @@ -92,7 +92,12 @@ options: description: - Install a specific revision of the snap. - This option can only be specified if there is a single snap in the task. - - Mutually exclusive with O(channel). Installing a specific revision pins the snap and disables automatic updates. + - Mutually exclusive with O(channel). + - When a specific revision is set, the snap is held (C(snap refresh --hold)) to prevent automatic updates from + overriding the pinned revision. + - "B(Note:) running C(snap refresh ) manually bypasses the hold and will update the snap regardless. + The hold only prevents snapd's automatic background refreshes. + See U(https://snapcraft.io/docs/how-to-guides/manage-snaps/manage-updates/) for details." - See U(https://snapcraft.io/docs/revisions) for more details about snap revisions. type: int version_added: 13.0.0 @@ -153,7 +158,7 @@ EXAMPLES = r""" name: foo channel: latest/edge -# Install a specific revision of a snap +# Install a specific revision of a snap (automatically held to prevent auto-updates) - name: Install revision 481 of "helm" community.general.snap: name: helm @@ -224,10 +229,11 @@ class Snap(StateModuleHelper): CHANNEL_MISMATCH = 1 INSTALLED = 2 REVISION_MISMATCH = 3 + HOLD_MISMATCH = 4 __disable_re = re.compile(r"(?:\S+\s+){5}(?P\S+)") __set_param_re = re.compile(r"(?P\S+:)?(?P\S+)\s*=\s*(?P.+)") - __list_re = re.compile(r"^(?P\S+)\s+\S+\s+(?P\S+)\s+(?P\S+)") + __list_re = re.compile(r"^(?P\S+)\s+\S+\s+(?P\S+)\s+(?P\S+)\s+\S+\s+(?P\S+)") module = dict( argument_spec={ "name": dict(type="list", elements="str", required=True), @@ -403,24 +409,30 @@ class Snap(StateModuleHelper): return [s if s in _VIRTUAL_SNAPS else next(real_name_iter) for s in snaps] def snap_status(self, snap_name, channel, revision=None): + should_be_held = revision is not None + def _status_check(name, channel, revision, installed): if name in _VIRTUAL_SNAPS: return Snap.INSTALLED - match = [(r, c) for n, r, c in installed if n == name] + match = [(r, c, notes) for n, r, c, notes in installed if n == name] if not match: return Snap.NOT_INSTALLED - installed_rev, installed_channel = match[0] + installed_rev, installed_channel, installed_notes = match[0] if revision is not None and str(revision) != installed_rev: return Snap.REVISION_MISMATCH if channel and installed_channel not in (channel, f"latest/{channel}"): return Snap.CHANNEL_MISMATCH + if should_be_held: + is_held = "held" in installed_notes.split(",") + if not is_held: + return Snap.HOLD_MISMATCH return Snap.INSTALLED with self.runner("_list") as ctx: rc, out, err = ctx.run(check_rc=True) list_out = out.split("\n")[1:] list_out = [self.__list_re.match(x) for x in list_out] - list_out = [(m.group("name"), m.group("rev"), m.group("channel")) for m in list_out if m] + list_out = [(m.group("name"), m.group("rev"), m.group("channel"), m.group("notes")) for m in list_out if m] self.vars.status_out = list_out self.vars.status_run_info = ctx.run_info @@ -474,6 +486,18 @@ class Snap(StateModuleHelper): msg = f"Ooops! Snap installation failed while executing '{self.vars.cmd}', please examine logs and error output for more details." self.do_raise(msg=msg) + def _apply_hold(self, snaps): + if not snaps: + return + self.changed = True + if self.check_mode: + return + for snap_name in snaps: + with self.runner("state hold name") as ctx: + rc, out, err = ctx.run(state="refresh", hold=True, name=snap_name) + if rc != 0: + self.do_raise(msg=f"Snap hold failed for '{snap_name}': {err}") + def state_present(self): self.vars.set_meta("classic", output=True) self.vars.set_meta("channel", output=True) @@ -490,6 +514,10 @@ class Snap(StateModuleHelper): if actionable_install: self._present(actionable_install) + if self.vars.revision is not None: + hold_mismatch = [snap for snap in self.vars.name if self.vars.snap_status_map[snap] == Snap.HOLD_MISMATCH] + self._apply_hold(actionable_install + actionable_refresh + hold_mismatch) + self.set_options() def set_options(self): diff --git a/tests/integration/targets/snap/tasks/test_revision.yml b/tests/integration/targets/snap/tasks/test_revision.yml index 2a7b40b718..71766b2f4f 100644 --- a/tests/integration/targets/snap/tasks/test_revision.yml +++ b/tests/integration/targets/snap/tasks/test_revision.yml @@ -28,6 +28,16 @@ - install_revision is changed - install_revision_again is not changed +- name: Verify snap is held after revision install + ansible.builtin.command: snap list uhttpd + register: snap_list_after_install + changed_when: false + +- name: Assert snap is held + assert: + that: + - "'held' in snap_list_after_install.stdout" + - name: Install different revision (uhttpd rev 45) community.general.snap: name: uhttpd @@ -40,6 +50,33 @@ that: - install_different_revision is changed +- name: Verify snap is still held after switching revision + ansible.builtin.command: snap list uhttpd + register: snap_list_after_switch + changed_when: false + +- name: Assert snap is still held + assert: + that: + - "'held' in snap_list_after_switch.stdout" + +- name: Run bare snap refresh to verify hold prevents auto-update + ansible.builtin.command: snap refresh + register: bare_snap_refresh + changed_when: false + failed_when: false + +- name: Verify snap is still at revision 45 after bare snap refresh + ansible.builtin.command: snap list uhttpd + register: snap_list_after_bare_refresh + changed_when: false + +- name: Assert snap did not update past the held revision + assert: + that: + - "'45' in snap_list_after_bare_refresh.stdout" + - "'held' in snap_list_after_bare_refresh.stdout" + - name: Remove package (uhttpd) community.general.snap: name: uhttpd