1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-06-04 15:27:00 +00:00

[PR #12097/850ef03f backport][stable-13] snap: enforce hold when installing at a specific revision (#12101)

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



* feat(changelog): add fragment for snap hold fix (#12097)



* 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.



* snap: add bare-refresh hold test and docs warning for manual refresh bypass



---------


(cherry picked from commit 850ef03fe7)

Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
patchback[bot] 2026-05-25 15:58:44 +02:00 committed by GitHub
parent 62e60952e7
commit f074ca9b05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 75 additions and 6 deletions

View file

@ -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).

View file

@ -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"),

View file

@ -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 <name>) 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<notes>\S+)")
__set_param_re = re.compile(r"(?P<snap_prefix>\S+:)?(?P<key>\S+)\s*=\s*(?P<value>.+)")
__list_re = re.compile(r"^(?P<name>\S+)\s+\S+\s+(?P<rev>\S+)\s+(?P<channel>\S+)")
__list_re = re.compile(r"^(?P<name>\S+)\s+\S+\s+(?P<rev>\S+)\s+(?P<channel>\S+)\s+\S+\s+(?P<notes>\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):

View file

@ -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