charmlibs-snap 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- charmlibs/snap/__init__.py +98 -0
- charmlibs/snap/_snap.py +1335 -0
- charmlibs/snap/_version.py +15 -0
- charmlibs/snap/py.typed +0 -0
- charmlibs_snap-0.8.0.dist-info/METADATA +29 -0
- charmlibs_snap-0.8.0.dist-info/RECORD +7 -0
- charmlibs_snap-0.8.0.dist-info/WHEEL +4 -0
charmlibs/snap/_snap.py
ADDED
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
# Copyright 2021 Canonical Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Source code of operator_libs_linux.v2.snap, with minimal exclusions.
|
|
16
|
+
|
|
17
|
+
Snapshot of version 2.14. Charmhub-hosted lib specific metadata has been removed,
|
|
18
|
+
and the docstring has been moved to the package docstring.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import http.client
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import socket
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
import typing
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
from enum import Enum
|
|
38
|
+
from subprocess import CalledProcessError, CompletedProcess
|
|
39
|
+
from typing import (
|
|
40
|
+
Literal,
|
|
41
|
+
Mapping,
|
|
42
|
+
NoReturn,
|
|
43
|
+
TypedDict,
|
|
44
|
+
TypeVar,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
import opentelemetry.trace
|
|
48
|
+
|
|
49
|
+
if typing.TYPE_CHECKING:
|
|
50
|
+
# avoid typing_extensions import at runtime
|
|
51
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
52
|
+
from typing import TypeAlias
|
|
53
|
+
|
|
54
|
+
from typing_extensions import NotRequired, ParamSpec, Required, Self, Unpack
|
|
55
|
+
|
|
56
|
+
_P = ParamSpec('_P')
|
|
57
|
+
_T = TypeVar('_T')
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
tracer = opentelemetry.trace.get_tracer(__name__)
|
|
61
|
+
|
|
62
|
+
# Regex to locate 7-bit C1 ANSI sequences
|
|
63
|
+
ansi_filter = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cache_init(func: Callable[_P, _T]) -> Callable[_P, _T]:
|
|
67
|
+
def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
|
|
68
|
+
if _Cache.cache is None:
|
|
69
|
+
_Cache.cache = SnapCache()
|
|
70
|
+
return func(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
return inner
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# this is used for return types, so it (a) uses concrete types and (b) does not contain None
|
|
76
|
+
# because setting snap config values to null removes the key so a null value can't be returned
|
|
77
|
+
_JSONLeaf: TypeAlias = 'str | int | float | bool'
|
|
78
|
+
JSONType: TypeAlias = 'dict[str, JSONType] | list[JSONType] | _JSONLeaf'
|
|
79
|
+
# we also need a jsonable type for arguments,
|
|
80
|
+
# which (a) uses abstract types and (b) may contain None
|
|
81
|
+
JSONAble: TypeAlias = 'Mapping[str, JSONAble] | Sequence[JSONAble] | _JSONLeaf | None'
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _AsyncChangeDict(TypedDict, total=True):
|
|
85
|
+
"""The subset of the json returned by GET changes that we care about internally."""
|
|
86
|
+
|
|
87
|
+
status: str
|
|
88
|
+
data: JSONType
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _SnapDict(TypedDict, total=True):
|
|
92
|
+
"""The subset of the json returned by GET snap/find that we care about internally."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
channel: str
|
|
96
|
+
revision: str
|
|
97
|
+
version: str
|
|
98
|
+
confinement: str
|
|
99
|
+
apps: NotRequired[list[dict[str, JSONType]] | None]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SnapServiceDict(TypedDict, total=True):
|
|
103
|
+
"""Dictionary representation returned by SnapService.as_dict."""
|
|
104
|
+
|
|
105
|
+
daemon: str | None
|
|
106
|
+
daemon_scope: str | None
|
|
107
|
+
enabled: bool
|
|
108
|
+
active: bool
|
|
109
|
+
activators: list[str]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# TypedDicts with hyphenated keys
|
|
113
|
+
_SnapServiceKwargsDict = TypedDict('_SnapServiceKwargsDict', {'daemon-scope': str}, total=False)
|
|
114
|
+
# the kwargs accepted by SnapService
|
|
115
|
+
_SnapServiceAppDict = TypedDict(
|
|
116
|
+
# the data we expect a Snap._apps entry to contain for a daemon
|
|
117
|
+
'_SnapServiceAppDict',
|
|
118
|
+
{
|
|
119
|
+
'name': 'Required[str]',
|
|
120
|
+
'daemon': str,
|
|
121
|
+
'daemon_scope': str,
|
|
122
|
+
'daemon-scope': str,
|
|
123
|
+
'enabled': bool,
|
|
124
|
+
'active': bool,
|
|
125
|
+
'activators': 'list[str]',
|
|
126
|
+
},
|
|
127
|
+
total=False,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class SnapService:
|
|
132
|
+
"""Data wrapper for snap services."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
daemon: str | None = None,
|
|
137
|
+
daemon_scope: str | None = None,
|
|
138
|
+
enabled: bool = False,
|
|
139
|
+
active: bool = False,
|
|
140
|
+
activators: list[str] | None = None,
|
|
141
|
+
**kwargs: Unpack[_SnapServiceKwargsDict],
|
|
142
|
+
):
|
|
143
|
+
self.daemon = daemon
|
|
144
|
+
self.daemon_scope = kwargs.get('daemon-scope') or daemon_scope
|
|
145
|
+
self.enabled = enabled
|
|
146
|
+
self.active = active
|
|
147
|
+
self.activators = activators if activators is not None else []
|
|
148
|
+
|
|
149
|
+
def as_dict(self) -> SnapServiceDict:
|
|
150
|
+
"""Return instance representation as dict."""
|
|
151
|
+
return {
|
|
152
|
+
'daemon': self.daemon,
|
|
153
|
+
'daemon_scope': self.daemon_scope,
|
|
154
|
+
'enabled': self.enabled,
|
|
155
|
+
'active': self.active,
|
|
156
|
+
'activators': self.activators,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MetaCache(type):
|
|
161
|
+
"""MetaCache class used for initialising the snap cache."""
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def cache(cls) -> SnapCache:
|
|
165
|
+
"""Property for returning the snap cache."""
|
|
166
|
+
return cls._cache
|
|
167
|
+
|
|
168
|
+
@cache.setter
|
|
169
|
+
def cache(cls, cache: SnapCache) -> None:
|
|
170
|
+
"""Setter for the snap cache."""
|
|
171
|
+
cls._cache = cache
|
|
172
|
+
|
|
173
|
+
def __getitem__(cls, name: str) -> Snap:
|
|
174
|
+
"""Snap cache getter."""
|
|
175
|
+
return cls._cache[name]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _Cache(metaclass=MetaCache):
|
|
179
|
+
_cache = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Error(Exception):
|
|
183
|
+
"""Base class of most errors raised by this library."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, message: str = '', *args: object):
|
|
186
|
+
super().__init__(message, *args)
|
|
187
|
+
self.message = message
|
|
188
|
+
|
|
189
|
+
def __repr__(self) -> str:
|
|
190
|
+
"""Represent the Error class."""
|
|
191
|
+
return f'<{type(self).__module__}.{type(self).__name__} {self.args}>'
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def name(self) -> str:
|
|
195
|
+
"""Return a string representation of the model plus class."""
|
|
196
|
+
return f'<{type(self).__module__}.{type(self).__name__}>'
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class SnapAPIError(Error):
|
|
200
|
+
"""Raised when an HTTP API error occurs talking to the Snapd server."""
|
|
201
|
+
|
|
202
|
+
def __init__(self, body: Mapping[str, JSONAble], code: int, status: str, message: str):
|
|
203
|
+
super().__init__(message) # Makes str(e) return message
|
|
204
|
+
self.body = body
|
|
205
|
+
self.code = code
|
|
206
|
+
self.status = status
|
|
207
|
+
self._message = message
|
|
208
|
+
|
|
209
|
+
def __repr__(self) -> str:
|
|
210
|
+
"""Represent the SnapAPIError class."""
|
|
211
|
+
return f'APIError({self.body!r}, {self.code!r}, {self.status!r}, {self._message!r})'
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SnapState(Enum):
|
|
215
|
+
"""The state of a snap on the system or in the cache."""
|
|
216
|
+
|
|
217
|
+
Present = 'present'
|
|
218
|
+
Absent = 'absent'
|
|
219
|
+
Latest = 'latest'
|
|
220
|
+
Available = 'available'
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class SnapError(Error):
|
|
224
|
+
"""Raised when there's an error running snap control commands."""
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def _from_called_process_error(cls, msg: str, error: CalledProcessError) -> Self:
|
|
228
|
+
lines = [msg]
|
|
229
|
+
if error.stdout:
|
|
230
|
+
lines.extend(['Stdout:', error.stdout])
|
|
231
|
+
if error.stderr:
|
|
232
|
+
lines.extend(['Stderr:', error.stderr])
|
|
233
|
+
try:
|
|
234
|
+
cmd = ['journalctl', '--unit', 'snapd', '--lines', '20']
|
|
235
|
+
with tracer.start_as_current_span(cmd[0]) as span:
|
|
236
|
+
span.set_attribute('argv', cmd)
|
|
237
|
+
logs = subprocess.check_output(cmd, text=True)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
lines.extend(['Error fetching logs:', str(e)])
|
|
240
|
+
else:
|
|
241
|
+
lines.extend(['Latest logs:', logs])
|
|
242
|
+
return cls('\n'.join(lines))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class SnapNotFoundError(Error):
|
|
246
|
+
"""Raised when a requested snap is not known to the system."""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class Snap:
|
|
250
|
+
"""Represents a snap package and its properties.
|
|
251
|
+
|
|
252
|
+
`Snap` exposes the following properties about a snap:
|
|
253
|
+
- name: the name of the snap
|
|
254
|
+
- state: a `SnapState` representation of its install status
|
|
255
|
+
- channel: "stable", "candidate", "beta", and "edge" are common
|
|
256
|
+
- revision: a string representing the snap's revision
|
|
257
|
+
- confinement: "classic", "strict", or "devmode"
|
|
258
|
+
- version: a string representing the snap's version, if set by the snap author
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
name: str,
|
|
264
|
+
state: SnapState,
|
|
265
|
+
channel: str,
|
|
266
|
+
revision: str,
|
|
267
|
+
confinement: str,
|
|
268
|
+
apps: list[dict[str, JSONType]] | None = None,
|
|
269
|
+
cohort: str | None = None,
|
|
270
|
+
*,
|
|
271
|
+
version: str | None = None,
|
|
272
|
+
) -> None:
|
|
273
|
+
self._name = name
|
|
274
|
+
self._state = state
|
|
275
|
+
self._channel = channel
|
|
276
|
+
self._revision = revision
|
|
277
|
+
self._confinement = confinement
|
|
278
|
+
self._cohort = cohort or ''
|
|
279
|
+
self._apps = apps or []
|
|
280
|
+
self._version = version
|
|
281
|
+
self._snap_client = SnapClient()
|
|
282
|
+
|
|
283
|
+
def __eq__(self, other: object) -> bool:
|
|
284
|
+
"""Equality for comparison."""
|
|
285
|
+
return isinstance(other, self.__class__) and (
|
|
286
|
+
self._name,
|
|
287
|
+
self._revision,
|
|
288
|
+
) == (other._name, other._revision)
|
|
289
|
+
|
|
290
|
+
def __hash__(self) -> int:
|
|
291
|
+
"""Calculate a hash for this snap."""
|
|
292
|
+
return hash((self._name, self._revision))
|
|
293
|
+
|
|
294
|
+
def __repr__(self) -> str:
|
|
295
|
+
"""Represent the object such that it can be reconstructed."""
|
|
296
|
+
return f'<{self.__module__}.{type(self).__name__}: {self.__dict__}>'
|
|
297
|
+
|
|
298
|
+
def __str__(self) -> str:
|
|
299
|
+
"""Represent the snap object as a string."""
|
|
300
|
+
return (
|
|
301
|
+
f'<{type(self).__name__}: '
|
|
302
|
+
f'{self._name}-{self._revision}.{self._channel}'
|
|
303
|
+
f' -- {self._state}>'
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _snap(self, command: str, optargs: Iterable[str] | None = None) -> str:
|
|
307
|
+
"""Perform a snap operation.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
command: the snap command to execute
|
|
311
|
+
optargs: an (optional) list of additional arguments to pass,
|
|
312
|
+
commonly confinement or channel
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
SnapError if there is a problem encountered
|
|
316
|
+
"""
|
|
317
|
+
optargs = optargs or []
|
|
318
|
+
args = ['snap', command, self._name, *optargs]
|
|
319
|
+
try:
|
|
320
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
321
|
+
span.set_attribute('argv', args)
|
|
322
|
+
return subprocess.check_output(args, text=True, stderr=subprocess.PIPE)
|
|
323
|
+
except CalledProcessError as e:
|
|
324
|
+
msg = f'Snap: {self._name!r} -- command {args!r} failed!'
|
|
325
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
326
|
+
|
|
327
|
+
def _snap_daemons(
|
|
328
|
+
self,
|
|
329
|
+
command: list[str],
|
|
330
|
+
services: list[str] | None = None,
|
|
331
|
+
) -> CompletedProcess[str]:
|
|
332
|
+
"""Perform snap app commands.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
command: the snap command to execute
|
|
336
|
+
services: the snap service to execute command on
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
SnapError if there is a problem encountered
|
|
340
|
+
"""
|
|
341
|
+
if services:
|
|
342
|
+
# an attempt to keep the command constrained to the snap instance's services
|
|
343
|
+
services = [f'{self._name}.{service}' for service in services]
|
|
344
|
+
else:
|
|
345
|
+
services = [self._name]
|
|
346
|
+
|
|
347
|
+
args = ['snap', *command, *services]
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
351
|
+
span.set_attribute('argv', args)
|
|
352
|
+
return subprocess.run(args, text=True, check=True, capture_output=True)
|
|
353
|
+
except CalledProcessError as e:
|
|
354
|
+
msg = f'Snap: {self._name!r} -- command {args!r} failed!'
|
|
355
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
356
|
+
|
|
357
|
+
@typing.overload
|
|
358
|
+
def get(self, key: Literal[''] | None, *, typed: Literal[False] = False) -> NoReturn: ...
|
|
359
|
+
@typing.overload
|
|
360
|
+
def get(self, key: str, *, typed: Literal[False] = False) -> str: ...
|
|
361
|
+
@typing.overload
|
|
362
|
+
def get(self, key: Literal[''] | None, *, typed: Literal[True]) -> dict[str, JSONType]: ...
|
|
363
|
+
@typing.overload
|
|
364
|
+
def get(self, key: str, *, typed: Literal[True]) -> JSONType: ...
|
|
365
|
+
def get(self, key: str | None, *, typed: bool = False) -> JSONType | str:
|
|
366
|
+
"""Fetch snap configuration values.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
key: the key to retrieve. Default to retrieve all values for typed=True.
|
|
370
|
+
typed: set to True to retrieve typed values (set with typed=True).
|
|
371
|
+
Default is to return a string.
|
|
372
|
+
"""
|
|
373
|
+
if typed:
|
|
374
|
+
args = ['-d']
|
|
375
|
+
if key:
|
|
376
|
+
args.append(key)
|
|
377
|
+
config = json.loads(self._snap('get', args)) # json.loads -> Any
|
|
378
|
+
if key:
|
|
379
|
+
return config.get(key)
|
|
380
|
+
return config
|
|
381
|
+
|
|
382
|
+
if not key:
|
|
383
|
+
raise TypeError('Key must be provided when typed=False')
|
|
384
|
+
|
|
385
|
+
# return a string
|
|
386
|
+
return self._snap('get', [key]).strip()
|
|
387
|
+
|
|
388
|
+
def set(self, config: Mapping[str, JSONAble], *, typed: bool = False) -> None:
|
|
389
|
+
"""Set a snap configuration value.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
config: a dictionary containing keys and values specifying the config to set.
|
|
393
|
+
typed: set to True to convert all values in the config into typed values while
|
|
394
|
+
configuring the snap (set with typed=True). Default is not to convert.
|
|
395
|
+
"""
|
|
396
|
+
if not typed:
|
|
397
|
+
config = {k: str(v) for k, v in config.items()}
|
|
398
|
+
self._snap_client._put_snap_conf(self._name, config)
|
|
399
|
+
|
|
400
|
+
def unset(self, key: str) -> str:
|
|
401
|
+
"""Unset a snap configuration value.
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
key: the key to unset
|
|
405
|
+
"""
|
|
406
|
+
return self._snap('unset', [key])
|
|
407
|
+
|
|
408
|
+
def start(self, services: list[str] | None = None, enable: bool = False) -> None:
|
|
409
|
+
"""Start a snap's services.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
services (list): (optional) list of individual snap services to start (otherwise all)
|
|
413
|
+
enable (bool): (optional) flag to enable snap services on start. Default `false`
|
|
414
|
+
"""
|
|
415
|
+
args = ['start', '--enable'] if enable else ['start']
|
|
416
|
+
self._snap_daemons(args, services)
|
|
417
|
+
|
|
418
|
+
def stop(self, services: list[str] | None = None, disable: bool = False) -> None:
|
|
419
|
+
"""Stop a snap's services.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
services (list): (optional) list of individual snap services to stop (otherwise all)
|
|
423
|
+
disable (bool): (optional) flag to disable snap services on stop. Default `False`
|
|
424
|
+
"""
|
|
425
|
+
args = ['stop', '--disable'] if disable else ['stop']
|
|
426
|
+
self._snap_daemons(args, services)
|
|
427
|
+
|
|
428
|
+
def logs(self, services: list[str] | None = None, num_lines: int | Literal['all'] = 10) -> str:
|
|
429
|
+
"""Fetch a snap services' logs.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
services: individual snap services to show logs from. Includes all by default.
|
|
433
|
+
num_lines: number of log lines to return, or 'all'. Returns 10 by default.
|
|
434
|
+
"""
|
|
435
|
+
args = ['logs', f'-n={num_lines}'] if num_lines else ['logs']
|
|
436
|
+
return self._snap_daemons(args, services).stdout
|
|
437
|
+
|
|
438
|
+
def connect(self, plug: str, service: str | None = None, slot: str | None = None) -> None:
|
|
439
|
+
"""Connect a plug to a slot.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
plug (str): the plug to connect
|
|
443
|
+
service (str): (optional) the snap service name to plug into
|
|
444
|
+
slot (str): (optional) the snap service slot to plug in to
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
SnapError if there is a problem encountered
|
|
448
|
+
"""
|
|
449
|
+
command = ['connect', f'{self._name}:{plug}']
|
|
450
|
+
|
|
451
|
+
if service and slot:
|
|
452
|
+
command.append(f'{service}:{slot}')
|
|
453
|
+
elif slot:
|
|
454
|
+
command.append(slot)
|
|
455
|
+
|
|
456
|
+
args = ['snap', *command]
|
|
457
|
+
try:
|
|
458
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
459
|
+
span.set_attribute('argv', args)
|
|
460
|
+
subprocess.run(args, text=True, check=True, capture_output=True)
|
|
461
|
+
except CalledProcessError as e:
|
|
462
|
+
msg = f'Snap: {self._name!r} -- command {args!r} failed!'
|
|
463
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
464
|
+
|
|
465
|
+
def hold(self, duration: timedelta | None = None) -> None:
|
|
466
|
+
"""Add a refresh hold to a snap.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
duration: duration for the hold, or None (the default) to hold this snap indefinitely.
|
|
470
|
+
"""
|
|
471
|
+
hold_str = 'forever'
|
|
472
|
+
if duration is not None:
|
|
473
|
+
seconds = round(duration.total_seconds())
|
|
474
|
+
hold_str = f'{seconds}s'
|
|
475
|
+
self._snap('refresh', [f'--hold={hold_str}'])
|
|
476
|
+
|
|
477
|
+
def unhold(self) -> None:
|
|
478
|
+
"""Remove the refresh hold of a snap."""
|
|
479
|
+
self._snap('refresh', ['--unhold'])
|
|
480
|
+
|
|
481
|
+
def alias(self, application: str, alias: str | None = None) -> None:
|
|
482
|
+
"""Create an alias for a given application.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
application: application to get an alias.
|
|
486
|
+
alias: (optional) name of the alias; if not provided, the application name is used.
|
|
487
|
+
"""
|
|
488
|
+
if alias is None:
|
|
489
|
+
alias = application
|
|
490
|
+
args = ['snap', 'alias', f'{self.name}.{application}', alias]
|
|
491
|
+
try:
|
|
492
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
493
|
+
span.set_attribute('argv', args)
|
|
494
|
+
subprocess.run(args, text=True, check=True, capture_output=True)
|
|
495
|
+
except CalledProcessError as e:
|
|
496
|
+
msg = f'Snap: {self._name!r} -- command {args!r} failed!'
|
|
497
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
498
|
+
|
|
499
|
+
def restart(self, services: list[str] | None = None, reload: bool = False) -> None:
|
|
500
|
+
"""Restarts a snap's services.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
services (list): (optional) list of individual snap services to restart.
|
|
504
|
+
(otherwise all)
|
|
505
|
+
reload (bool): (optional) flag to use the service reload command, if available.
|
|
506
|
+
Default `False`
|
|
507
|
+
"""
|
|
508
|
+
args = ['restart', '--reload'] if reload else ['restart']
|
|
509
|
+
self._snap_daemons(args, services)
|
|
510
|
+
|
|
511
|
+
def _install(
|
|
512
|
+
self,
|
|
513
|
+
channel: str = '',
|
|
514
|
+
cohort: str = '',
|
|
515
|
+
revision: str = '',
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Add a snap to the system.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
channel: the channel to install from
|
|
521
|
+
cohort: optional, the key of a cohort that this snap belongs to
|
|
522
|
+
revision: optional, the revision of the snap to install
|
|
523
|
+
"""
|
|
524
|
+
cohort = cohort or self._cohort
|
|
525
|
+
|
|
526
|
+
args: list[str] = []
|
|
527
|
+
if self.confinement == 'classic':
|
|
528
|
+
args.append('--classic')
|
|
529
|
+
if self.confinement == 'devmode':
|
|
530
|
+
args.append('--devmode')
|
|
531
|
+
if channel:
|
|
532
|
+
args.append(f'--channel="{channel}"')
|
|
533
|
+
if revision:
|
|
534
|
+
args.append(f'--revision="{revision}"')
|
|
535
|
+
if cohort:
|
|
536
|
+
args.append(f'--cohort="{cohort}"')
|
|
537
|
+
|
|
538
|
+
self._snap('install', args)
|
|
539
|
+
|
|
540
|
+
def _refresh(
|
|
541
|
+
self,
|
|
542
|
+
channel: str = '',
|
|
543
|
+
cohort: str = '',
|
|
544
|
+
revision: str = '',
|
|
545
|
+
devmode: bool = False,
|
|
546
|
+
leave_cohort: bool = False,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Refresh a snap.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
channel: the channel to install from
|
|
552
|
+
cohort: optionally, specify a cohort.
|
|
553
|
+
revision: optionally, specify the revision of the snap to refresh
|
|
554
|
+
devmode: optionally, specify devmode confinement
|
|
555
|
+
leave_cohort: leave the current cohort.
|
|
556
|
+
"""
|
|
557
|
+
args: list[str] = []
|
|
558
|
+
if channel:
|
|
559
|
+
args.append(f'--channel="{channel}"')
|
|
560
|
+
|
|
561
|
+
if revision:
|
|
562
|
+
args.append(f'--revision="{revision}"')
|
|
563
|
+
|
|
564
|
+
if self.confinement == 'classic':
|
|
565
|
+
args.append('--classic')
|
|
566
|
+
|
|
567
|
+
if devmode:
|
|
568
|
+
args.append('--devmode')
|
|
569
|
+
|
|
570
|
+
if not cohort:
|
|
571
|
+
cohort = self._cohort
|
|
572
|
+
|
|
573
|
+
if leave_cohort:
|
|
574
|
+
self._cohort = ''
|
|
575
|
+
args.append('--leave-cohort')
|
|
576
|
+
elif cohort:
|
|
577
|
+
args.append(f'--cohort="{cohort}"')
|
|
578
|
+
|
|
579
|
+
self._snap('refresh', args)
|
|
580
|
+
|
|
581
|
+
def _remove(self) -> str:
|
|
582
|
+
"""Remove a snap from the system."""
|
|
583
|
+
return self._snap('remove')
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def name(self) -> str:
|
|
587
|
+
"""Returns the name of the snap."""
|
|
588
|
+
return self._name
|
|
589
|
+
|
|
590
|
+
def ensure(
|
|
591
|
+
self,
|
|
592
|
+
state: SnapState,
|
|
593
|
+
classic: bool = False,
|
|
594
|
+
devmode: bool = False,
|
|
595
|
+
channel: str | None = None,
|
|
596
|
+
cohort: str | None = None,
|
|
597
|
+
revision: str | int | None = None,
|
|
598
|
+
):
|
|
599
|
+
"""Ensure that a snap is in a given state.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
state: a `SnapState` to reconcile to.
|
|
603
|
+
classic: an (Optional) boolean indicating whether classic confinement should be used
|
|
604
|
+
devmode: an (Optional) boolean indicating whether devmode confinement should be used
|
|
605
|
+
channel: the channel to install from
|
|
606
|
+
cohort: optional. Specify the key of a snap cohort.
|
|
607
|
+
revision: optional. the revision of the snap to install/refresh
|
|
608
|
+
|
|
609
|
+
While both channel and revision could be specified, the underlying snap install/refresh
|
|
610
|
+
command will determine which one takes precedence (revision at this time)
|
|
611
|
+
|
|
612
|
+
Raises:
|
|
613
|
+
SnapError if an error is encountered
|
|
614
|
+
"""
|
|
615
|
+
channel = channel or ''
|
|
616
|
+
cohort = cohort or ''
|
|
617
|
+
revision = str(revision) if revision is not None else ''
|
|
618
|
+
|
|
619
|
+
if classic and devmode:
|
|
620
|
+
raise ValueError('Cannot set both classic and devmode confinement')
|
|
621
|
+
|
|
622
|
+
if classic or self._confinement == 'classic':
|
|
623
|
+
self._confinement = 'classic'
|
|
624
|
+
elif devmode or self._confinement == 'devmode':
|
|
625
|
+
self._confinement = 'devmode'
|
|
626
|
+
else:
|
|
627
|
+
self._confinement = ''
|
|
628
|
+
|
|
629
|
+
if state not in (SnapState.Present, SnapState.Latest):
|
|
630
|
+
# We are attempting to remove this snap.
|
|
631
|
+
if self._state in (SnapState.Present, SnapState.Latest):
|
|
632
|
+
# The snap is installed, so we run _remove.
|
|
633
|
+
self._remove()
|
|
634
|
+
else:
|
|
635
|
+
# The snap is not installed -- no need to do anything.
|
|
636
|
+
pass
|
|
637
|
+
else:
|
|
638
|
+
# We are installing or refreshing a snap.
|
|
639
|
+
if self._state not in (SnapState.Present, SnapState.Latest):
|
|
640
|
+
# The snap is not installed, so we install it.
|
|
641
|
+
logger.info(
|
|
642
|
+
'Installing snap %s, revision %s, tracking %s', self._name, revision, channel
|
|
643
|
+
)
|
|
644
|
+
self._install(channel, cohort, revision)
|
|
645
|
+
logger.info('The snap installation completed successfully')
|
|
646
|
+
elif revision != self._revision:
|
|
647
|
+
# The snap is installed, but we are changing it (e.g., switching channels).
|
|
648
|
+
logger.info(
|
|
649
|
+
'Refreshing snap %s, revision %s, tracking %s', self._name, revision, channel
|
|
650
|
+
)
|
|
651
|
+
self._refresh(channel=channel, cohort=cohort, revision=revision, devmode=devmode)
|
|
652
|
+
logger.info('The snap refresh completed successfully')
|
|
653
|
+
else:
|
|
654
|
+
logger.info('Refresh of snap %s was unnecessary', self._name)
|
|
655
|
+
|
|
656
|
+
self._update_snap_apps()
|
|
657
|
+
self._state = state
|
|
658
|
+
|
|
659
|
+
def _update_snap_apps(self) -> None:
|
|
660
|
+
"""Update a snap's apps after snap changes state."""
|
|
661
|
+
try:
|
|
662
|
+
self._apps = self._snap_client.get_installed_snap_apps(self._name)
|
|
663
|
+
except SnapAPIError:
|
|
664
|
+
logger.debug('Unable to retrieve snap apps for %s', self._name)
|
|
665
|
+
self._apps = []
|
|
666
|
+
|
|
667
|
+
@property
|
|
668
|
+
def present(self) -> bool:
|
|
669
|
+
"""Report whether or not a snap is present."""
|
|
670
|
+
return self._state in (SnapState.Present, SnapState.Latest)
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def latest(self) -> bool:
|
|
674
|
+
"""Report whether the snap is the most recent version."""
|
|
675
|
+
return self._state is SnapState.Latest
|
|
676
|
+
|
|
677
|
+
@property
|
|
678
|
+
def state(self) -> SnapState:
|
|
679
|
+
"""Report the current snap state."""
|
|
680
|
+
return self._state
|
|
681
|
+
|
|
682
|
+
@state.setter
|
|
683
|
+
def state(self, state: SnapState) -> None:
|
|
684
|
+
"""Set the snap state to a given value.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
state: a `SnapState` to reconcile the snap to.
|
|
688
|
+
|
|
689
|
+
Raises:
|
|
690
|
+
SnapError if an error is encountered
|
|
691
|
+
"""
|
|
692
|
+
if self._state is not state:
|
|
693
|
+
self.ensure(state)
|
|
694
|
+
self._state = state
|
|
695
|
+
|
|
696
|
+
@property
|
|
697
|
+
def revision(self) -> str:
|
|
698
|
+
"""Returns the revision for a snap."""
|
|
699
|
+
return self._revision
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def channel(self) -> str:
|
|
703
|
+
"""Returns the channel for a snap."""
|
|
704
|
+
return self._channel
|
|
705
|
+
|
|
706
|
+
@property
|
|
707
|
+
def confinement(self) -> str:
|
|
708
|
+
"""Returns the confinement for a snap."""
|
|
709
|
+
return self._confinement
|
|
710
|
+
|
|
711
|
+
@property
|
|
712
|
+
def apps(self) -> list[dict[str, JSONType]]:
|
|
713
|
+
"""Returns (if any) the installed apps of the snap."""
|
|
714
|
+
self._update_snap_apps()
|
|
715
|
+
return self._apps
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def services(self) -> dict[str, SnapServiceDict]:
|
|
719
|
+
"""Returns (if any) the installed services of the snap."""
|
|
720
|
+
self._update_snap_apps()
|
|
721
|
+
services: dict[str, SnapServiceDict] = {}
|
|
722
|
+
for app in self._apps:
|
|
723
|
+
if 'daemon' in app:
|
|
724
|
+
app = typing.cast('_SnapServiceAppDict', app)
|
|
725
|
+
services[app['name']] = SnapService(**app).as_dict()
|
|
726
|
+
|
|
727
|
+
return services
|
|
728
|
+
|
|
729
|
+
@property
|
|
730
|
+
def held(self) -> bool:
|
|
731
|
+
"""Report whether the snap has a hold."""
|
|
732
|
+
info = self._snap('info')
|
|
733
|
+
return 'hold:' in info
|
|
734
|
+
|
|
735
|
+
@property
|
|
736
|
+
def version(self) -> str | None:
|
|
737
|
+
"""Returns the version for a snap."""
|
|
738
|
+
return self._version
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class _UnixSocketConnection(http.client.HTTPConnection):
|
|
742
|
+
"""Implementation of HTTPConnection that connects to a named Unix socket."""
|
|
743
|
+
|
|
744
|
+
def __init__(self, host: str, timeout: float | None = None, socket_path: str | None = None):
|
|
745
|
+
if timeout is None:
|
|
746
|
+
super().__init__(host)
|
|
747
|
+
else:
|
|
748
|
+
super().__init__(host, timeout=timeout)
|
|
749
|
+
self.socket_path = socket_path
|
|
750
|
+
|
|
751
|
+
def connect(self):
|
|
752
|
+
"""Override connect to use Unix socket (instead of TCP socket)."""
|
|
753
|
+
if not hasattr(socket, 'AF_UNIX'):
|
|
754
|
+
raise NotImplementedError(f'Unix sockets not supported on {sys.platform}')
|
|
755
|
+
assert self.socket_path is not None # else TypeError on self.socket.connect
|
|
756
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
757
|
+
self.sock.connect(self.socket_path)
|
|
758
|
+
if self.timeout is not None:
|
|
759
|
+
self.sock.settimeout(self.timeout)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class _UnixSocketHandler(urllib.request.AbstractHTTPHandler):
|
|
763
|
+
"""Implementation of HTTPHandler that uses a named Unix socket."""
|
|
764
|
+
|
|
765
|
+
def __init__(self, socket_path: str):
|
|
766
|
+
super().__init__()
|
|
767
|
+
self.socket_path = socket_path
|
|
768
|
+
|
|
769
|
+
def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse:
|
|
770
|
+
"""Override http_open to use a Unix socket connection (instead of TCP)."""
|
|
771
|
+
return self.do_open(
|
|
772
|
+
typing.cast('urllib.request._HTTPConnectionProtocol', _UnixSocketConnection),
|
|
773
|
+
req,
|
|
774
|
+
socket_path=self.socket_path,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
class SnapClient:
|
|
779
|
+
"""Snapd API client to talk to HTTP over UNIX sockets.
|
|
780
|
+
|
|
781
|
+
In order to avoid shelling out and/or involving sudo in calling the snapd API,
|
|
782
|
+
use a wrapper based on the Pebble Client, trimmed down to only the utility methods
|
|
783
|
+
needed for talking to snapd.
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
def __init__(
|
|
787
|
+
self,
|
|
788
|
+
socket_path: str = '/run/snapd.socket',
|
|
789
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
790
|
+
base_url: str = 'http://localhost/v2/',
|
|
791
|
+
timeout: float = 30.0,
|
|
792
|
+
):
|
|
793
|
+
"""Initialize a client instance.
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
socket_path: a path to the socket on the filesystem. Defaults to /run/snap/snapd.socket
|
|
797
|
+
opener: specifies an opener for unix socket, if unspecified a default is used
|
|
798
|
+
base_url: base URL for making requests to the snap client. Must be an HTTP(S) URL.
|
|
799
|
+
Defaults to http://localhost/v2/
|
|
800
|
+
timeout: timeout in seconds to use when making requests to the API. Default is 30.0s.
|
|
801
|
+
"""
|
|
802
|
+
if opener is None:
|
|
803
|
+
opener = self._get_default_opener(socket_path)
|
|
804
|
+
self.opener = opener
|
|
805
|
+
# Address ruff's suspicious-url-open-usage (S310)
|
|
806
|
+
if not base_url.startswith(('http:', 'https:')):
|
|
807
|
+
raise ValueError("base_url must start with 'http:' or 'https:'")
|
|
808
|
+
self.base_url = base_url
|
|
809
|
+
self.timeout = timeout
|
|
810
|
+
|
|
811
|
+
@classmethod
|
|
812
|
+
def _get_default_opener(cls, socket_path: str) -> urllib.request.OpenerDirector:
|
|
813
|
+
"""Build the default opener to use for requests (HTTP over Unix socket)."""
|
|
814
|
+
opener = urllib.request.OpenerDirector()
|
|
815
|
+
opener.add_handler(_UnixSocketHandler(socket_path))
|
|
816
|
+
opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
|
|
817
|
+
opener.add_handler(urllib.request.HTTPRedirectHandler())
|
|
818
|
+
opener.add_handler(urllib.request.HTTPErrorProcessor())
|
|
819
|
+
return opener
|
|
820
|
+
|
|
821
|
+
def _request(
|
|
822
|
+
self,
|
|
823
|
+
method: str,
|
|
824
|
+
path: str,
|
|
825
|
+
query: Mapping[str, str] | None = None,
|
|
826
|
+
body: Mapping[str, JSONAble] | None = None,
|
|
827
|
+
) -> JSONType | None:
|
|
828
|
+
"""Make a JSON request to the Snapd server with the given HTTP method and path.
|
|
829
|
+
|
|
830
|
+
If query dict is provided, it is encoded and appended as a query string
|
|
831
|
+
to the URL. If body dict is provided, it is serialied as JSON and used
|
|
832
|
+
as the HTTP body (with Content-Type: "application/json"). The resulting
|
|
833
|
+
body is decoded from JSON.
|
|
834
|
+
"""
|
|
835
|
+
headers = {'Accept': 'application/json'}
|
|
836
|
+
data = None
|
|
837
|
+
if body is not None:
|
|
838
|
+
data = json.dumps(body).encode('utf-8')
|
|
839
|
+
headers['Content-Type'] = 'application/json'
|
|
840
|
+
|
|
841
|
+
response = self._request_raw(method, path, query, headers, data)
|
|
842
|
+
response = json.loads(response.read().decode()) # json.loads -> Any
|
|
843
|
+
if response['type'] == 'async':
|
|
844
|
+
return self._wait(response['change']) # may be `None` due to `get`
|
|
845
|
+
return response['result']
|
|
846
|
+
|
|
847
|
+
def _wait(self, change_id: str, timeout: float = 300) -> JSONType | None:
|
|
848
|
+
"""Wait for an async change to complete.
|
|
849
|
+
|
|
850
|
+
The poll time is 100 milliseconds, the same as in snap clients.
|
|
851
|
+
"""
|
|
852
|
+
deadline = time.time() + timeout
|
|
853
|
+
while True:
|
|
854
|
+
if time.time() > deadline:
|
|
855
|
+
raise TimeoutError(f'timeout waiting for snap change {change_id}')
|
|
856
|
+
response = self._request('GET', f'changes/{change_id}')
|
|
857
|
+
response = typing.cast('_AsyncChangeDict', response)
|
|
858
|
+
status = response['status']
|
|
859
|
+
if status == 'Done':
|
|
860
|
+
return response.get('data')
|
|
861
|
+
if status == 'Doing' or status == 'Do':
|
|
862
|
+
time.sleep(0.1)
|
|
863
|
+
continue
|
|
864
|
+
if status == 'Wait':
|
|
865
|
+
logger.warning("snap change %s succeeded with status 'Wait'", change_id)
|
|
866
|
+
return response.get('data')
|
|
867
|
+
raise SnapError(
|
|
868
|
+
f'snap change {response.get("kind")!r} id {change_id} failed with status {status}'
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
def _request_raw(
|
|
872
|
+
self,
|
|
873
|
+
method: str,
|
|
874
|
+
path: str,
|
|
875
|
+
query: Mapping[str, str] | None = None,
|
|
876
|
+
headers: dict[str, str] | None = None,
|
|
877
|
+
data: bytes | None = None,
|
|
878
|
+
) -> http.client.HTTPResponse:
|
|
879
|
+
"""Make a request to the Snapd server; return the raw HTTPResponse object."""
|
|
880
|
+
url = self.base_url + path
|
|
881
|
+
if query:
|
|
882
|
+
url = url + '?' + urllib.parse.urlencode(query)
|
|
883
|
+
|
|
884
|
+
if headers is None:
|
|
885
|
+
headers = {}
|
|
886
|
+
request = urllib.request.Request(url, method=method, data=data, headers=headers) # noqa: S310
|
|
887
|
+
|
|
888
|
+
try:
|
|
889
|
+
response = self.opener.open(request, timeout=self.timeout)
|
|
890
|
+
except urllib.error.HTTPError as e:
|
|
891
|
+
code = e.code
|
|
892
|
+
status = e.reason
|
|
893
|
+
message = ''
|
|
894
|
+
body: dict[str, JSONType]
|
|
895
|
+
try:
|
|
896
|
+
body = json.loads(e.read().decode())['result'] # json.loads -> Any
|
|
897
|
+
except (OSError, ValueError, KeyError) as e2:
|
|
898
|
+
# Will only happen on read error or if Pebble sends invalid JSON.
|
|
899
|
+
body = {}
|
|
900
|
+
message = f'{type(e2).__name__} - {e2}'
|
|
901
|
+
raise SnapAPIError(body, code, status, message) from e
|
|
902
|
+
except urllib.error.URLError as e:
|
|
903
|
+
raise SnapAPIError({}, 500, 'Not found', str(e.reason)) from e
|
|
904
|
+
return response
|
|
905
|
+
|
|
906
|
+
def get_installed_snaps(self) -> list[dict[str, JSONType]]:
|
|
907
|
+
"""Get information about currently installed snaps."""
|
|
908
|
+
with tracer.start_as_current_span('get_installed_snaps'):
|
|
909
|
+
return self._request('GET', 'snaps') # type: ignore
|
|
910
|
+
|
|
911
|
+
def get_snap_information(self, name: str) -> dict[str, JSONType]:
|
|
912
|
+
"""Query the snap server for information about single snap."""
|
|
913
|
+
with tracer.start_as_current_span('get_snap_information') as span:
|
|
914
|
+
span.set_attribute('name', name)
|
|
915
|
+
return self._request('GET', 'find', {'name': name})[0] # type: ignore
|
|
916
|
+
|
|
917
|
+
def get_installed_snap_apps(self, name: str) -> list[dict[str, JSONType]]:
|
|
918
|
+
"""Query the snap server for apps belonging to a named, currently installed snap."""
|
|
919
|
+
with tracer.start_as_current_span('get_installed_snap_apps') as span:
|
|
920
|
+
span.set_attribute('name', name)
|
|
921
|
+
return self._request('GET', 'apps', {'names': name, 'select': 'service'}) # type: ignore
|
|
922
|
+
|
|
923
|
+
def _put_snap_conf(self, name: str, conf: Mapping[str, JSONAble]) -> None:
|
|
924
|
+
"""Set the configuration details for an installed snap."""
|
|
925
|
+
self._request('PUT', f'snaps/{name}/conf', body=conf)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
class SnapCache(Mapping[str, Snap]):
|
|
929
|
+
"""An abstraction to represent installed/available packages.
|
|
930
|
+
|
|
931
|
+
When instantiated, `SnapCache` iterates through the list of installed
|
|
932
|
+
snaps using the `snapd` HTTP API, and a list of available snaps by reading
|
|
933
|
+
the filesystem to populate the cache. Information about available snaps is lazily-loaded
|
|
934
|
+
from the `snapd` API when requested.
|
|
935
|
+
"""
|
|
936
|
+
|
|
937
|
+
def __init__(self):
|
|
938
|
+
if not self.snapd_installed:
|
|
939
|
+
raise SnapError('snapd is not installed or not in /usr/bin') from None
|
|
940
|
+
self._snap_client = SnapClient()
|
|
941
|
+
self._snap_map: dict[str, Snap | None] = {}
|
|
942
|
+
if self.snapd_installed:
|
|
943
|
+
self._load_available_snaps()
|
|
944
|
+
self._load_installed_snaps()
|
|
945
|
+
|
|
946
|
+
def __contains__(self, key: object) -> bool:
|
|
947
|
+
"""Check if a given snap is in the cache."""
|
|
948
|
+
return key in self._snap_map
|
|
949
|
+
|
|
950
|
+
def __len__(self) -> int:
|
|
951
|
+
"""Report number of items in the snap cache."""
|
|
952
|
+
return len(self._snap_map)
|
|
953
|
+
|
|
954
|
+
def __iter__(self) -> Iterable[Snap | None]: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
955
|
+
"""Provide iterator for the snap cache."""
|
|
956
|
+
return iter(self._snap_map.values())
|
|
957
|
+
|
|
958
|
+
def __getitem__(self, snap_name: str) -> Snap:
|
|
959
|
+
"""Return either the installed version or latest version for a given snap."""
|
|
960
|
+
snap = self._snap_map.get(snap_name)
|
|
961
|
+
if snap is not None:
|
|
962
|
+
return snap
|
|
963
|
+
# The snapd cache file may not have existed when _snap_map was
|
|
964
|
+
# populated. This is normal.
|
|
965
|
+
try:
|
|
966
|
+
snap = self._snap_map[snap_name] = self._load_info(snap_name)
|
|
967
|
+
except SnapAPIError as e:
|
|
968
|
+
raise SnapNotFoundError(f"Snap '{snap_name}' not found!") from e
|
|
969
|
+
return snap
|
|
970
|
+
|
|
971
|
+
@property
|
|
972
|
+
def snapd_installed(self) -> bool:
|
|
973
|
+
"""Check whether snapd has been installed on the system."""
|
|
974
|
+
return os.path.isfile('/usr/bin/snap')
|
|
975
|
+
|
|
976
|
+
def _load_available_snaps(self) -> None:
|
|
977
|
+
"""Load the list of available snaps from disk.
|
|
978
|
+
|
|
979
|
+
Leave them empty and lazily load later if asked for.
|
|
980
|
+
"""
|
|
981
|
+
if not os.path.isfile('/var/cache/snapd/names'):
|
|
982
|
+
# The snap catalog may not be populated yet; this is normal.
|
|
983
|
+
# snapd updates the cache infrequently and the cache file may not
|
|
984
|
+
# currently exist.
|
|
985
|
+
return
|
|
986
|
+
|
|
987
|
+
with open('/var/cache/snapd/names') as f:
|
|
988
|
+
for line in f:
|
|
989
|
+
if line.strip():
|
|
990
|
+
self._snap_map[line.strip()] = None
|
|
991
|
+
|
|
992
|
+
def _load_installed_snaps(self) -> None:
|
|
993
|
+
"""Load the installed snaps into the dict."""
|
|
994
|
+
installed = self._snap_client.get_installed_snaps()
|
|
995
|
+
|
|
996
|
+
for i in installed:
|
|
997
|
+
i = typing.cast('_SnapDict', i)
|
|
998
|
+
snap = Snap(
|
|
999
|
+
name=i['name'],
|
|
1000
|
+
state=SnapState.Latest,
|
|
1001
|
+
channel=i['channel'],
|
|
1002
|
+
revision=i['revision'],
|
|
1003
|
+
confinement=i['confinement'],
|
|
1004
|
+
apps=i.get('apps'),
|
|
1005
|
+
version=i.get('version'),
|
|
1006
|
+
)
|
|
1007
|
+
self._snap_map[snap.name] = snap
|
|
1008
|
+
|
|
1009
|
+
def _load_info(self, name: str) -> Snap:
|
|
1010
|
+
"""Load info for snaps which are not installed if requested.
|
|
1011
|
+
|
|
1012
|
+
Args:
|
|
1013
|
+
name: a string representing the name of the snap
|
|
1014
|
+
"""
|
|
1015
|
+
info = self._snap_client.get_snap_information(name)
|
|
1016
|
+
info = typing.cast('_SnapDict', info)
|
|
1017
|
+
|
|
1018
|
+
return Snap(
|
|
1019
|
+
name=info['name'],
|
|
1020
|
+
state=SnapState.Available,
|
|
1021
|
+
channel=info['channel'],
|
|
1022
|
+
revision=info['revision'],
|
|
1023
|
+
confinement=info['confinement'],
|
|
1024
|
+
apps=None,
|
|
1025
|
+
version=info.get('version'),
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
@typing.overload
|
|
1030
|
+
def add( # return a single Snap if snap name is given as a string
|
|
1031
|
+
snap_names: str,
|
|
1032
|
+
state: str | SnapState = SnapState.Latest,
|
|
1033
|
+
channel: str | None = None,
|
|
1034
|
+
classic: bool = False,
|
|
1035
|
+
devmode: bool = False,
|
|
1036
|
+
cohort: str | None = None,
|
|
1037
|
+
revision: str | int | None = None,
|
|
1038
|
+
) -> Snap: ...
|
|
1039
|
+
@typing.overload
|
|
1040
|
+
def add( # may return a single Snap or a list depending if one or more snap names were given
|
|
1041
|
+
snap_names: list[str],
|
|
1042
|
+
state: str | SnapState = SnapState.Latest,
|
|
1043
|
+
channel: str | None = None,
|
|
1044
|
+
classic: bool = False,
|
|
1045
|
+
devmode: bool = False,
|
|
1046
|
+
cohort: str | None = None,
|
|
1047
|
+
revision: str | int | None = None,
|
|
1048
|
+
) -> Snap | list[Snap]: ...
|
|
1049
|
+
@_cache_init
|
|
1050
|
+
def add(
|
|
1051
|
+
snap_names: str | list[str],
|
|
1052
|
+
state: str | SnapState = SnapState.Latest,
|
|
1053
|
+
channel: str | None = None,
|
|
1054
|
+
classic: bool = False,
|
|
1055
|
+
devmode: bool = False,
|
|
1056
|
+
cohort: str | None = None,
|
|
1057
|
+
revision: str | int | None = None,
|
|
1058
|
+
) -> Snap | list[Snap]:
|
|
1059
|
+
"""Add a snap to the system.
|
|
1060
|
+
|
|
1061
|
+
Args:
|
|
1062
|
+
snap_names: the name or names of the snaps to install
|
|
1063
|
+
state: a string or `SnapState` representation of the desired state, one of
|
|
1064
|
+
[`Present` or `Latest`]
|
|
1065
|
+
channel: an (Optional) channel as a string. Defaults to 'latest'
|
|
1066
|
+
classic: an (Optional) boolean specifying whether it should be added with classic
|
|
1067
|
+
confinement. Default `False`
|
|
1068
|
+
devmode: an (Optional) boolean specifying whether it should be added with devmode
|
|
1069
|
+
confinement. Default `False`
|
|
1070
|
+
cohort: an (Optional) string specifying the snap cohort to use
|
|
1071
|
+
revision: an (Optional) string specifying the snap revision to use
|
|
1072
|
+
|
|
1073
|
+
Raises:
|
|
1074
|
+
SnapError if some snaps failed to install or were not found.
|
|
1075
|
+
"""
|
|
1076
|
+
if not channel and not revision:
|
|
1077
|
+
channel = 'latest'
|
|
1078
|
+
|
|
1079
|
+
snap_names = [snap_names] if isinstance(snap_names, str) else snap_names
|
|
1080
|
+
if not snap_names:
|
|
1081
|
+
raise TypeError('Expected at least one snap to add, received zero!')
|
|
1082
|
+
|
|
1083
|
+
if isinstance(state, str):
|
|
1084
|
+
state = SnapState(state)
|
|
1085
|
+
|
|
1086
|
+
return _wrap_snap_operations(
|
|
1087
|
+
snap_names=snap_names,
|
|
1088
|
+
state=state,
|
|
1089
|
+
channel=channel or '',
|
|
1090
|
+
classic=classic,
|
|
1091
|
+
devmode=devmode,
|
|
1092
|
+
cohort=cohort or '',
|
|
1093
|
+
revision=str(revision) if revision is not None else '',
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
@typing.overload
|
|
1098
|
+
def remove(snap_names: str) -> Snap: ...
|
|
1099
|
+
# return a single Snap if snap name is given as a string
|
|
1100
|
+
@typing.overload
|
|
1101
|
+
def remove(snap_names: list[str]) -> Snap | list[Snap]: ...
|
|
1102
|
+
# may return a single Snap or a list depending if one or more snap names were given
|
|
1103
|
+
@_cache_init
|
|
1104
|
+
def remove(snap_names: str | list[str]) -> Snap | list[Snap]:
|
|
1105
|
+
"""Remove specified snap(s) from the system.
|
|
1106
|
+
|
|
1107
|
+
Args:
|
|
1108
|
+
snap_names: the name or names of the snaps to install
|
|
1109
|
+
|
|
1110
|
+
Raises:
|
|
1111
|
+
SnapError if some snaps failed to install.
|
|
1112
|
+
"""
|
|
1113
|
+
snap_names = [snap_names] if isinstance(snap_names, str) else snap_names
|
|
1114
|
+
if not snap_names:
|
|
1115
|
+
raise TypeError('Expected at least one snap to add, received zero!')
|
|
1116
|
+
return _wrap_snap_operations(
|
|
1117
|
+
snap_names=snap_names,
|
|
1118
|
+
state=SnapState.Absent,
|
|
1119
|
+
channel='',
|
|
1120
|
+
classic=False,
|
|
1121
|
+
devmode=False,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@typing.overload
|
|
1126
|
+
def ensure( # return a single Snap if snap name is given as a string
|
|
1127
|
+
snap_names: str,
|
|
1128
|
+
state: str,
|
|
1129
|
+
channel: str | None = None,
|
|
1130
|
+
classic: bool = False,
|
|
1131
|
+
devmode: bool = False,
|
|
1132
|
+
cohort: str | None = None,
|
|
1133
|
+
revision: str | int | None = None,
|
|
1134
|
+
) -> Snap: ...
|
|
1135
|
+
@typing.overload
|
|
1136
|
+
def ensure( # may return a single Snap or a list depending if one or more snap names were given
|
|
1137
|
+
snap_names: list[str],
|
|
1138
|
+
state: str,
|
|
1139
|
+
channel: str | None = None,
|
|
1140
|
+
classic: bool = False,
|
|
1141
|
+
devmode: bool = False,
|
|
1142
|
+
cohort: str | None = None,
|
|
1143
|
+
revision: str | int | None = None,
|
|
1144
|
+
) -> Snap | list[Snap]: ...
|
|
1145
|
+
@_cache_init
|
|
1146
|
+
def ensure(
|
|
1147
|
+
snap_names: str | list[str],
|
|
1148
|
+
state: str,
|
|
1149
|
+
channel: str | None = None,
|
|
1150
|
+
classic: bool = False,
|
|
1151
|
+
devmode: bool = False,
|
|
1152
|
+
cohort: str | None = None,
|
|
1153
|
+
revision: str | int | None = None,
|
|
1154
|
+
) -> Snap | list[Snap]:
|
|
1155
|
+
"""Ensure specified snaps are in a given state on the system.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
snap_names: the name(s) of the snaps to operate on
|
|
1159
|
+
state: a string representation of the desired state, from `SnapState`
|
|
1160
|
+
channel: an (Optional) channel as a string. Defaults to 'latest'
|
|
1161
|
+
classic: an (Optional) boolean specifying whether it should be added with classic
|
|
1162
|
+
confinement. Default `False`
|
|
1163
|
+
devmode: an (Optional) boolean specifying whether it should be added with devmode
|
|
1164
|
+
confinement. Default `False`
|
|
1165
|
+
cohort: an (Optional) string specifying the snap cohort to use
|
|
1166
|
+
revision: an (Optional) string specifying the snap revision to use
|
|
1167
|
+
|
|
1168
|
+
When both channel and revision are specified, the underlying snap install/refresh
|
|
1169
|
+
command will determine the precedence (revision at the time of adding this)
|
|
1170
|
+
|
|
1171
|
+
Raises:
|
|
1172
|
+
SnapError if the snap is not in the cache.
|
|
1173
|
+
"""
|
|
1174
|
+
if not revision and not channel:
|
|
1175
|
+
channel = 'latest'
|
|
1176
|
+
|
|
1177
|
+
if state in ('present', 'latest') or revision:
|
|
1178
|
+
return add(
|
|
1179
|
+
snap_names=snap_names,
|
|
1180
|
+
state=SnapState(state),
|
|
1181
|
+
channel=channel,
|
|
1182
|
+
classic=classic,
|
|
1183
|
+
devmode=devmode,
|
|
1184
|
+
cohort=cohort,
|
|
1185
|
+
revision=str(revision) if revision is not None else '',
|
|
1186
|
+
)
|
|
1187
|
+
else:
|
|
1188
|
+
return remove(snap_names)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _wrap_snap_operations(
|
|
1192
|
+
snap_names: list[str],
|
|
1193
|
+
state: SnapState,
|
|
1194
|
+
channel: str,
|
|
1195
|
+
classic: bool,
|
|
1196
|
+
devmode: bool,
|
|
1197
|
+
cohort: str = '',
|
|
1198
|
+
revision: str = '',
|
|
1199
|
+
) -> Snap | list[Snap]:
|
|
1200
|
+
"""Wrap common operations for bare commands."""
|
|
1201
|
+
snaps: list[Snap] = []
|
|
1202
|
+
errors: list[str] = []
|
|
1203
|
+
|
|
1204
|
+
op = 'remove' if state is SnapState.Absent else 'install or refresh'
|
|
1205
|
+
|
|
1206
|
+
for s in snap_names:
|
|
1207
|
+
try:
|
|
1208
|
+
snap = _Cache[s]
|
|
1209
|
+
if state is SnapState.Absent:
|
|
1210
|
+
snap.ensure(state=SnapState.Absent)
|
|
1211
|
+
else:
|
|
1212
|
+
snap.ensure(
|
|
1213
|
+
state=state,
|
|
1214
|
+
classic=classic,
|
|
1215
|
+
devmode=devmode,
|
|
1216
|
+
channel=channel,
|
|
1217
|
+
cohort=cohort,
|
|
1218
|
+
revision=revision,
|
|
1219
|
+
)
|
|
1220
|
+
snaps.append(snap)
|
|
1221
|
+
except SnapError as e: # noqa: PERF203
|
|
1222
|
+
logger.warning('Failed to %s snap %s: %s!', op, s, e.message)
|
|
1223
|
+
errors.append(s)
|
|
1224
|
+
except SnapNotFoundError:
|
|
1225
|
+
logger.warning("Snap '%s' not found in cache!", s)
|
|
1226
|
+
errors.append(s)
|
|
1227
|
+
|
|
1228
|
+
if errors:
|
|
1229
|
+
raise SnapError(f'Failed to install or refresh snap(s): {", ".join(errors)}')
|
|
1230
|
+
|
|
1231
|
+
return snaps if len(snaps) > 1 else snaps[0]
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def install_local(
|
|
1235
|
+
filename: str,
|
|
1236
|
+
classic: bool = False,
|
|
1237
|
+
devmode: bool = False,
|
|
1238
|
+
dangerous: bool = False,
|
|
1239
|
+
) -> Snap:
|
|
1240
|
+
"""Perform a snap operation.
|
|
1241
|
+
|
|
1242
|
+
Args:
|
|
1243
|
+
filename: the path to a local .snap file to install
|
|
1244
|
+
classic: whether to use classic confinement
|
|
1245
|
+
devmode: whether to use devmode confinement
|
|
1246
|
+
dangerous: whether --dangerous should be passed to install snaps without a signature
|
|
1247
|
+
|
|
1248
|
+
Raises:
|
|
1249
|
+
SnapError if there is a problem encountered
|
|
1250
|
+
"""
|
|
1251
|
+
args = [
|
|
1252
|
+
'snap',
|
|
1253
|
+
'install',
|
|
1254
|
+
filename,
|
|
1255
|
+
]
|
|
1256
|
+
if classic:
|
|
1257
|
+
args.append('--classic')
|
|
1258
|
+
if devmode:
|
|
1259
|
+
args.append('--devmode')
|
|
1260
|
+
if dangerous:
|
|
1261
|
+
args.append('--dangerous')
|
|
1262
|
+
try:
|
|
1263
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
1264
|
+
span.set_attribute('argv', args)
|
|
1265
|
+
result = subprocess.check_output(
|
|
1266
|
+
args,
|
|
1267
|
+
text=True,
|
|
1268
|
+
stderr=subprocess.PIPE,
|
|
1269
|
+
).splitlines()[-1]
|
|
1270
|
+
snap_name, _ = result.split(' ', 1)
|
|
1271
|
+
snap_name = ansi_filter.sub('', snap_name)
|
|
1272
|
+
|
|
1273
|
+
c = SnapCache()
|
|
1274
|
+
|
|
1275
|
+
try:
|
|
1276
|
+
return c[snap_name]
|
|
1277
|
+
except SnapAPIError as e:
|
|
1278
|
+
logger.error(
|
|
1279
|
+
'Could not find snap %s when querying Snapd socket: %s',
|
|
1280
|
+
snap_name,
|
|
1281
|
+
e.body,
|
|
1282
|
+
)
|
|
1283
|
+
raise SnapError(f'Failed to find snap {snap_name} in Snap cache') from e
|
|
1284
|
+
except CalledProcessError as e:
|
|
1285
|
+
msg = f'Cound not install snap {filename}!'
|
|
1286
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _system_set(config_item: str, value: str) -> None:
|
|
1290
|
+
"""Set system snapd config values.
|
|
1291
|
+
|
|
1292
|
+
Args:
|
|
1293
|
+
config_item: name of snap system setting. E.g. 'refresh.hold'
|
|
1294
|
+
value: value to assign
|
|
1295
|
+
"""
|
|
1296
|
+
args = ['snap', 'set', 'system', f'{config_item}={value}']
|
|
1297
|
+
try:
|
|
1298
|
+
with tracer.start_as_current_span(args[0]) as span:
|
|
1299
|
+
span.set_attribute('argv', args)
|
|
1300
|
+
subprocess.run(args, text=True, check=True, capture_output=True)
|
|
1301
|
+
except CalledProcessError as e:
|
|
1302
|
+
msg = f"Failed setting system config '{config_item}' to '{value}'"
|
|
1303
|
+
raise SnapError._from_called_process_error(msg=msg, error=e) from e
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def hold_refresh(days: int = 90, forever: bool = False) -> None:
|
|
1307
|
+
"""Set the system-wide snap refresh hold.
|
|
1308
|
+
|
|
1309
|
+
Args:
|
|
1310
|
+
days: number of days to hold system refreshes for. Maximum 90. Set to zero to remove hold.
|
|
1311
|
+
forever: if True, will set a hold forever.
|
|
1312
|
+
"""
|
|
1313
|
+
if not isinstance(forever, bool): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1314
|
+
raise TypeError('forever must be a bool')
|
|
1315
|
+
if not isinstance(days, int): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
1316
|
+
raise TypeError('days must be an int')
|
|
1317
|
+
if forever:
|
|
1318
|
+
_system_set('refresh.hold', 'forever')
|
|
1319
|
+
logger.info('Set system-wide snap refresh hold to: forever')
|
|
1320
|
+
elif days == 0:
|
|
1321
|
+
_system_set('refresh.hold', '')
|
|
1322
|
+
logger.info('Removed system-wide snap refresh hold')
|
|
1323
|
+
else:
|
|
1324
|
+
# Currently the snap daemon can only hold for a maximum of 90 days
|
|
1325
|
+
if not 1 <= days <= 90:
|
|
1326
|
+
raise ValueError('days must be between 1 and 90')
|
|
1327
|
+
# Add the number of days to current time
|
|
1328
|
+
target_date = datetime.now(timezone.utc).astimezone() + timedelta(days=days)
|
|
1329
|
+
# Format for the correct datetime format
|
|
1330
|
+
hold_date = target_date.strftime('%Y-%m-%dT%H:%M:%S%z')
|
|
1331
|
+
# Python dumps the offset in format '+0100', we need '+01:00'
|
|
1332
|
+
hold_date = f'{hold_date[:-2]}:{hold_date[-2:]}'
|
|
1333
|
+
# Actually set the hold date
|
|
1334
|
+
_system_set('refresh.hold', hold_date)
|
|
1335
|
+
logger.info('Set system-wide snap refresh hold to: %s', hold_date)
|