Merge ~johnsca/layer-snap:feature/lp/1845559/snap-coherence into ~stub/layer-snap:master

Proposed by Kevin W Monroe
Status: Merged
Merged at revision: 9bf203fd51d04afc48e67fa40410b8a661726b7e
Proposed branch: ~johnsca/layer-snap:feature/lp/1845559/snap-coherence
Merge into: ~stub/layer-snap:master
Diff against target: 213 lines (+114/-1)
3 files modified
README.md (+14/-0)
lib/charms/layer/snap.py (+87/-0)
reactive/snap.py (+13/-1)
Reviewer Review Type Date Requested Status
Stuart Bishop Approve
Review via email: mp+375315@code.launchpad.net

Commit message

Add support for snap coherence

Add additional functions and flags to create and manage cohort snapshots.

Description of the change

This is in support of k8s snap coherence:

https://bugs.launchpad.net/charm-kubernetes-master/+bug/1845559

It has been working well for me as a way to manage snap cohorts amongst k8s applications. When coupled with https://github.com/johnsca/charm-snap-store-proxy, I've been able to hold k8s snaps at specific revs, e.g.:

$ juju run --unit snap-store-proxy/0 'sudo snap-proxy list-overrides kubectl'
kubectl 1.16/stable amd64 1342 (upstream 1309)

$ juju run --application kubernetes-master 'sudo snap list kubectl'
- Stdout: |
    Name Version Rev Tracking Publisher Notes
    kubectl 1.16.3-beta.0 1342 1.16 canonical* classic,in-cohort
  UnitId: kubernetes-master/1
- Stdout: |
    Name Version Rev Tracking Publisher Notes
    kubectl 1.16.3-beta.0 1342 1.16 canonical* classic,in-cohort
  UnitId: kubernetes-master/0

There's still work to do in the k8s charms and docs, but this is ready for upstream layer-snap consideration.

To post a comment you must log in.
Revision history for this message
Stuart Bishop (stub) wrote :

Code is good.

If create_cohort_snapshot and join_cohort_snapshot are to be part of the public API, they should be documented in README.md; I haven't wired up any documentation generation and snap.py contains a mix of public API and support functions in any case (the line isn't clear, so I guess that makes all the methods public). Minimally would be fine. Some care may be needed to explain expected behavior when the refresh() method is called on snap that is part of a cohort.

Revision history for this message
Stuart Bishop (stub) wrote :

Yup, looks good.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/README.md b/README.md
2index 1ba2ebe..c9f6f16 100644
3--- a/README.md
4+++ b/README.md
5@@ -146,6 +146,20 @@ package::
6 snap updated if the snap or arguments have changed. If the snap was
7 installed from the Snap Store, `snap refresh` is run to update the snap.
8
9+* `create_cohort_snapshot(snapname)`. Creates a new cohort snapshot and
10+ returns the associated key. A cohort snapshot allows snaps on different
11+ machines to coordinate their refreshes by sharing the cohort snapshot key.
12+
13+* `join_cohort_snapshot(snapname, cohort_key)`. Joins a cohort snapshot.
14+ When in a cohort snapshot, new snap revisions will not be automatically
15+ applied; instead, the leader should periodically check for an available
16+ refresh, then create a new cohort snapshot and distribute the key in
17+ a controlled fashion to roll out updates.
18+
19+* `is_refresh_available(snapname)`. Check whether the given snap can be
20+ updated. Also available as an automatically managed flag, of the form
21+ `snap.refresh-available.{snapname}`.
22+
23 * `remove(snapname)`. The snap is removed.
24
25 Keyword arguments correspond to the layer.yaml options and snap command line
26diff --git a/lib/charms/layer/snap.py b/lib/charms/layer/snap.py
27index 1254351..f760d69 100644
28--- a/lib/charms/layer/snap.py
29+++ b/lib/charms/layer/snap.py
30@@ -17,6 +17,8 @@
31 import os
32 import subprocess
33
34+import yaml
35+
36 from charmhelpers.core import hookenv
37 from charms import layer
38 from charms import reactive
39@@ -28,6 +30,14 @@ def get_installed_flag(snapname):
40 return 'snap.installed.{}'.format(snapname)
41
42
43+def get_refresh_available_flag(snapname):
44+ return 'snap.refresh-available.{}'.format(snapname)
45+
46+
47+def get_local_flag(snapname):
48+ return 'snap.local.{}'.format(snapname)
49+
50+
51 def get_disabled_flag(snapname):
52 return 'snap.disabled.{}'.format(snapname)
53
54@@ -44,6 +54,7 @@ def install(snapname, **kw):
55 function is called.
56 '''
57 installed_flag = get_installed_flag(snapname)
58+ local_flag = get_local_flag(snapname)
59 if reactive.is_flag_set(installed_flag):
60 refresh(snapname, **kw)
61 else:
62@@ -53,6 +64,7 @@ def install(snapname, **kw):
63 _install_store(snapname, **kw)
64 else:
65 _install_local(res_path, **kw)
66+ reactive.set_flag(local_flag)
67 else:
68 _install_store(snapname, **kw)
69 reactive.set_flag(installed_flag)
70@@ -68,6 +80,19 @@ def is_installed(snapname):
71 return reactive.is_flag_set(get_installed_flag(snapname))
72
73
74+def is_local(snapname):
75+ return reactive.is_flag_set(get_local_flag(snapname))
76+
77+
78+def get_installed_snaps():
79+ '''Return a list of snaps which are installed by this layer.
80+ '''
81+ flag_prefix = 'snap.installed.'
82+ return [flag[len(flag_prefix):]
83+ for flag in reactive.get_flags()
84+ if flag.startswith(flag_prefix)]
85+
86+
87 def refresh(snapname, **kw):
88 '''Update a snap.
89
90@@ -83,14 +108,18 @@ def refresh(snapname, **kw):
91 # upload a zero byte resource, but then we would need to uninstall
92 # the snap before reinstalling from the store and that has the
93 # potential for data loss.
94+ local_flag = get_local_flag(snapname)
95 if hookenv.has_juju_version('2.0'):
96 res_path = _resource_get(snapname)
97 if res_path is False:
98 _refresh_store(snapname, **kw)
99+ reactive.clear_flag(local_flag)
100 else:
101 _install_local(res_path, **kw)
102+ reactive.set_flag(local_flag)
103 else:
104 _refresh_store(snapname, **kw)
105+ reactive.clear_flag(local_flag)
106
107
108 def remove(snapname):
109@@ -317,6 +346,7 @@ def _install_store(snapname, **kw):
110 hookenv.log('Installation successful cmd="{}" output="{}"'
111 .format(cmd, out),
112 level=hookenv.DEBUG)
113+ reactive.clear_flag(get_local_flag(snapname))
114 except subprocess.CalledProcessError as cp:
115 hookenv.log('Installation failed cmd="{}" returncode={} output="{}"'
116 .format(cmd, cp.returncode, cp.output),
117@@ -347,3 +377,60 @@ def _resource_get(snapname):
118 if res_path and os.stat(res_path).st_size != 0:
119 return res_path
120 return False
121+
122+
123+def get_available_refreshes():
124+ '''Return a list of snaps which have refreshes available.
125+ '''
126+ out = subprocess.check_output(['snap', 'refresh', '--list']).decode('utf8')
127+ if out == 'All snaps up to date.':
128+ return []
129+ else:
130+ return [l.split()[0] for l in out.splitlines()[1:]]
131+
132+
133+def is_refresh_available(snapname):
134+ '''Check whether a new revision is available for the given snap.
135+ '''
136+ return reactive.is_flag_set(get_refresh_available_flag(snapname))
137+
138+
139+def _check_refresh_available(snapname):
140+ return snapname in get_available_refreshes()
141+
142+
143+def create_cohort_snapshot(snapname):
144+ '''Create a new cohort key for the given snap.
145+
146+ Cohort keys represent a snapshot of the revision of a snap at the time
147+ the key was created. These keys can then be used on any machine to lock
148+ the revision of the snap until a new cohort is joined (or the key expires,
149+ after 90 days). This is used to maintain consistency of the revision of
150+ the snap across units or applications, and to manage the refresh of the
151+ snap in a controlled manner.
152+
153+ Returns a cohort key.
154+ '''
155+ out = subprocess.check_output(['snap', 'create-cohort', snapname])
156+ data = yaml.safe_load(out.decode('utf8'))
157+ return data['cohorts'][snapname]['cohort-key']
158+
159+
160+def join_cohort_snapshot(snapname, cohort_key):
161+ '''Refresh the snap into the given cohort.
162+
163+ If the snap was previously in a cohort, this will update the revision
164+ to that of the new cohort snapshot. Note that this does not change the
165+ channel that the snap is in, only the revision within that channel.
166+ '''
167+ if is_local(snapname):
168+ # joining a cohort can override a locally installed snap
169+ hookenv.log('Skipping joining cohort for local snap: '
170+ '{}'.format(snapname))
171+ return
172+ subprocess.check_output(['snap', 'refresh', snapname,
173+ '--cohort', cohort_key])
174+ # even though we just refreshed to the latest in the cohort, it's
175+ # slightly possible that there's a newer rev available beyond the cohort
176+ reactive.toggle_flag(get_refresh_available_flag(snapname),
177+ _check_refresh_available(snapname))
178diff --git a/reactive/snap.py b/reactive/snap.py
179index 882280a..669a56d 100644
180--- a/reactive/snap.py
181+++ b/reactive/snap.py
182@@ -32,7 +32,7 @@ from charmhelpers.core.host import write_file
183 from charms import layer
184 from charms import reactive
185 from charms.layer import snap
186-from charms.reactive import register_trigger, when, when_not
187+from charms.reactive import register_trigger, when, when_not, toggle_flag
188 from charms.reactive.helpers import data_changed
189
190
191@@ -85,6 +85,17 @@ def install():
192 snap.connect_all()
193
194
195+def check_refresh_available():
196+ # Do nothing if we don't have kernel support yet
197+ if not kernel_supported():
198+ return
199+
200+ available_refreshes = snap.get_available_refreshes()
201+ for snapname in snap.get_installed_snaps():
202+ toggle_flag(snap.get_refresh_available_flag(snapname),
203+ snapname in available_refreshes)
204+
205+
206 def refresh():
207 # Do nothing if we don't have kernel support yet
208 if not kernel_supported():
209@@ -332,3 +343,4 @@ hookenv.atstart(ensure_path)
210 hookenv.atstart(update_snap_proxy)
211 hookenv.atstart(configure_snap_store_proxy)
212 hookenv.atstart(install)
213+hookenv.atstart(check_refresh_available)

Subscribers

People subscribed via source and target branches

to all changes: