Merge ~cjwatson/charm-clamav-database-mirror:initial into charm-clamav-database-mirror:main

Proposed by Colin Watson
Status: Merged
Merged at revision: d09540ef93fe67e180e1c07145a48127cc634bc6
Proposed branch: ~cjwatson/charm-clamav-database-mirror:initial
Merge into: charm-clamav-database-mirror:main
Diff against target: 2117 lines (+2009/-0)
18 files modified
.gitignore (+9/-0)
CONTRIBUTING.md (+31/-0)
LICENSE (+202/-0)
README.md (+12/-0)
charmcraft.yaml (+16/-0)
config.yaml (+13/-0)
files/cvdupdate.timer (+12/-0)
lib/charms/operator_libs_linux/v0/apt.py (+1329/-0)
metadata.yaml (+14/-0)
pyproject.toml (+33/-0)
requirements.txt (+2/-0)
src/charm.py (+106/-0)
templates/cvdupdate.service.j2 (+22/-0)
templates/nginx-site.conf.j2 (+9/-0)
tests/integration/test_charm.py (+34/-0)
tests/unit/test_charm.py (+88/-0)
tox.ini (+73/-0)
update-lib (+4/-0)
Reviewer Review Type Date Requested Status
Andrey Fedoseev (community) Approve
Review via email: mp+431861@code.launchpad.net

Commit message

Add new charm

Description of the change

Much of the project skeleton came from `charmcraft init --profile machine`.

`lib/charms/operator_libs_linux/v0/apt.py` is maintained by the `update-lib` script; the convention for operator charms is to vendor these files.

I considered using either the existing `apache2` charm or the existing `nginx` charm. However, `apache2` currently only works up to focal, and the `clamav-cvdupdate` package is only available starting from jammy; while the `nginx` charm doesn't allow deploying a site as a subordinate, which makes it rather cumbersome to use here. Since it's fairly trivial to handle installing `nginx` directly, I just went with that.

The test suite doesn't quite have 100% coverage, but there's enough here that it caught a number of my mistakes during development, and I wanted to get this up for review without too much more delay.

To post a comment you must log in.
Revision history for this message
Andrey Fedoseev (andrey-fedoseev) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/.gitignore b/.gitignore
0new file mode 1006440new file mode 100644
index 0000000..a26d707
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
1venv/
2build/
3*.charm
4.tox/
5.coverage
6__pycache__/
7*.py[cod]
8.idea
9.vscode/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
0new file mode 10064410new file mode 100644
index 0000000..5863a00
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,31 @@
1# Contributing
2
3To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup).
4
5You can use the environments created by `tox` for development:
6
7```shell
8tox --notest -e unit
9source .tox/unit/bin/activate
10```
11
12## Testing
13
14This project uses `tox` for managing test environments. There are some pre-configured environments
15that can be used for linting and formatting code when you're preparing contributions to the charm:
16
17```shell
18tox -e fmt # update your code according to linting rules
19tox -e lint # code style
20tox -e unit # unit tests
21tox -e integration # integration tests
22tox # runs 'lint' and 'unit' environments
23```
24
25## Build the charm
26
27Build the charm in this git repository using:
28
29```shell
30charmcraft pack
31```
diff --git a/LICENSE b/LICENSE
0new file mode 10064432new file mode 100644
index 0000000..7e9d504
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
1
2 Apache License
3 Version 2.0, January 2004
4 http://www.apache.org/licenses/
5
6 TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
8 1. Definitions.
9
10 "License" shall mean the terms and conditions for use, reproduction,
11 and distribution as defined by Sections 1 through 9 of this document.
12
13 "Licensor" shall mean the copyright owner or entity authorized by
14 the copyright owner that is granting the License.
15
16 "Legal Entity" shall mean the union of the acting entity and all
17 other entities that control, are controlled by, or are under common
18 control with that entity. For the purposes of this definition,
19 "control" means (i) the power, direct or indirect, to cause the
20 direction or management of such entity, whether by contract or
21 otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 outstanding shares, or (iii) beneficial ownership of such entity.
23
24 "You" (or "Your") shall mean an individual or Legal Entity
25 exercising permissions granted by this License.
26
27 "Source" form shall mean the preferred form for making modifications,
28 including but not limited to software source code, documentation
29 source, and configuration files.
30
31 "Object" form shall mean any form resulting from mechanical
32 transformation or translation of a Source form, including but
33 not limited to compiled object code, generated documentation,
34 and conversions to other media types.
35
36 "Work" shall mean the work of authorship, whether in Source or
37 Object form, made available under the License, as indicated by a
38 copyright notice that is included in or attached to the work
39 (an example is provided in the Appendix below).
40
41 "Derivative Works" shall mean any work, whether in Source or Object
42 form, that is based on (or derived from) the Work and for which the
43 editorial revisions, annotations, elaborations, or other modifications
44 represent, as a whole, an original work of authorship. For the purposes
45 of this License, Derivative Works shall not include works that remain
46 separable from, or merely link (or bind by name) to the interfaces of,
47 the Work and Derivative Works thereof.
48
49 "Contribution" shall mean any work of authorship, including
50 the original version of the Work and any modifications or additions
51 to that Work or Derivative Works thereof, that is intentionally
52 submitted to Licensor for inclusion in the Work by the copyright owner
53 or by an individual or Legal Entity authorized to submit on behalf of
54 the copyright owner. For the purposes of this definition, "submitted"
55 means any form of electronic, verbal, or written communication sent
56 to the Licensor or its representatives, including but not limited to
57 communication on electronic mailing lists, source code control systems,
58 and issue tracking systems that are managed by, or on behalf of, the
59 Licensor for the purpose of discussing and improving the Work, but
60 excluding communication that is conspicuously marked or otherwise
61 designated in writing by the copyright owner as "Not a Contribution."
62
63 "Contributor" shall mean Licensor and any individual or Legal Entity
64 on behalf of whom a Contribution has been received by Licensor and
65 subsequently incorporated within the Work.
66
67 2. Grant of Copyright License. Subject to the terms and conditions of
68 this License, each Contributor hereby grants to You a perpetual,
69 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 copyright license to reproduce, prepare Derivative Works of,
71 publicly display, publicly perform, sublicense, and distribute the
72 Work and such Derivative Works in Source or Object form.
73
74 3. Grant of Patent License. Subject to the terms and conditions of
75 this License, each Contributor hereby grants to You a perpetual,
76 worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 (except as stated in this section) patent license to make, have made,
78 use, offer to sell, sell, import, and otherwise transfer the Work,
79 where such license applies only to those patent claims licensable
80 by such Contributor that are necessarily infringed by their
81 Contribution(s) alone or by combination of their Contribution(s)
82 with the Work to which such Contribution(s) was submitted. If You
83 institute patent litigation against any entity (including a
84 cross-claim or counterclaim in a lawsuit) alleging that the Work
85 or a Contribution incorporated within the Work constitutes direct
86 or contributory patent infringement, then any patent licenses
87 granted to You under this License for that Work shall terminate
88 as of the date such litigation is filed.
89
90 4. Redistribution. You may reproduce and distribute copies of the
91 Work or Derivative Works thereof in any medium, with or without
92 modifications, and in Source or Object form, provided that You
93 meet the following conditions:
94
95 (a) You must give any other recipients of the Work or
96 Derivative Works a copy of this License; and
97
98 (b) You must cause any modified files to carry prominent notices
99 stating that You changed the files; and
100
101 (c) You must retain, in the Source form of any Derivative Works
102 that You distribute, all copyright, patent, trademark, and
103 attribution notices from the Source form of the Work,
104 excluding those notices that do not pertain to any part of
105 the Derivative Works; and
106
107 (d) If the Work includes a "NOTICE" text file as part of its
108 distribution, then any Derivative Works that You distribute must
109 include a readable copy of the attribution notices contained
110 within such NOTICE file, excluding those notices that do not
111 pertain to any part of the Derivative Works, in at least one
112 of the following places: within a NOTICE text file distributed
113 as part of the Derivative Works; within the Source form or
114 documentation, if provided along with the Derivative Works; or,
115 within a display generated by the Derivative Works, if and
116 wherever such third-party notices normally appear. The contents
117 of the NOTICE file are for informational purposes only and
118 do not modify the License. You may add Your own attribution
119 notices within Derivative Works that You distribute, alongside
120 or as an addendum to the NOTICE text from the Work, provided
121 that such additional attribution notices cannot be construed
122 as modifying the License.
123
124 You may add Your own copyright statement to Your modifications and
125 may provide additional or different license terms and conditions
126 for use, reproduction, or distribution of Your modifications, or
127 for any such Derivative Works as a whole, provided Your use,
128 reproduction, and distribution of the Work otherwise complies with
129 the conditions stated in this License.
130
131 5. Submission of Contributions. Unless You explicitly state otherwise,
132 any Contribution intentionally submitted for inclusion in the Work
133 by You to the Licensor shall be under the terms and conditions of
134 this License, without any additional terms or conditions.
135 Notwithstanding the above, nothing herein shall supersede or modify
136 the terms of any separate license agreement you may have executed
137 with Licensor regarding such Contributions.
138
139 6. Trademarks. This License does not grant permission to use the trade
140 names, trademarks, service marks, or product names of the Licensor,
141 except as required for reasonable and customary use in describing the
142 origin of the Work and reproducing the content of the NOTICE file.
143
144 7. Disclaimer of Warranty. Unless required by applicable law or
145 agreed to in writing, Licensor provides the Work (and each
146 Contributor provides its Contributions) on an "AS IS" BASIS,
147 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 implied, including, without limitation, any warranties or conditions
149 of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 PARTICULAR PURPOSE. You are solely responsible for determining the
151 appropriateness of using or redistributing the Work and assume any
152 risks associated with Your exercise of permissions under this License.
153
154 8. Limitation of Liability. In no event and under no legal theory,
155 whether in tort (including negligence), contract, or otherwise,
156 unless required by applicable law (such as deliberate and grossly
157 negligent acts) or agreed to in writing, shall any Contributor be
158 liable to You for damages, including any direct, indirect, special,
159 incidental, or consequential damages of any character arising as a
160 result of this License or out of the use or inability to use the
161 Work (including but not limited to damages for loss of goodwill,
162 work stoppage, computer failure or malfunction, or any and all
163 other commercial damages or losses), even if such Contributor
164 has been advised of the possibility of such damages.
165
166 9. Accepting Warranty or Additional Liability. While redistributing
167 the Work or Derivative Works thereof, You may choose to offer,
168 and charge a fee for, acceptance of support, warranty, indemnity,
169 or other liability obligations and/or rights consistent with this
170 License. However, in accepting such obligations, You may act only
171 on Your own behalf and on Your sole responsibility, not on behalf
172 of any other Contributor, and only if You agree to indemnify,
173 defend, and hold each Contributor harmless for any liability
174 incurred by, or claims asserted against, such Contributor by reason
175 of your accepting any such warranty or additional liability.
176
177 END OF TERMS AND CONDITIONS
178
179 APPENDIX: How to apply the Apache License to your work.
180
181 To apply the Apache License to your work, attach the following
182 boilerplate notice, with the fields enclosed by brackets "[]"
183 replaced with your own identifying information. (Don't include
184 the brackets!) The text should be enclosed in the appropriate
185 comment syntax for the file format. We also recommend that a
186 file or class name and description of purpose be included on the
187 same "printed page" as the copyright notice for easier
188 identification within third-party archives.
189
190 Copyright 2022 Canonical Ltd.
191
192 Licensed under the Apache License, Version 2.0 (the "License");
193 you may not use this file except in compliance with the License.
194 You may obtain a copy of the License at
195
196 http://www.apache.org/licenses/LICENSE-2.0
197
198 Unless required by applicable law or agreed to in writing, software
199 distributed under the License is distributed on an "AS IS" BASIS,
200 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 See the License for the specific language governing permissions and
202 limitations under the License.
diff --git a/README.md b/README.md
0new file mode 100644203new file mode 100644
index 0000000..d78d2a3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,12 @@
1# clamav-database-mirror
2
3Charmhub package name: clamav-database-mirror
4More information: https://charmhub.io/clamav-database-mirror
5
6Maintains a local mirror of the latest ClamAV databases.
7
8## Other resources
9
10- [Contributing](CONTRIBUTING.md) <!-- or link to other contribution documentation -->
11
12- See the [Juju SDK documentation](https://juju.is/docs/sdk) for more information about developing and improving charms.
diff --git a/charmcraft.yaml b/charmcraft.yaml
0new file mode 10064413new file mode 100644
index 0000000..0cd3b34
--- /dev/null
+++ b/charmcraft.yaml
@@ -0,0 +1,16 @@
1# This file configures Charmcraft.
2# See https://juju.is/docs/sdk/charmcraft-config for guidance.
3
4type: charm
5bases:
6 - build-on:
7 - name: ubuntu
8 channel: "22.04"
9 run-on:
10 - name: ubuntu
11 channel: "22.04"
12parts:
13 charm:
14 prime:
15 - files/*
16 - templates/*
diff --git a/config.yaml b/config.yaml
0new file mode 10064417new file mode 100644
index 0000000..f100171
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,13 @@
1options:
2 host:
3 type: string
4 default: "localhost"
5 description: The mirror's host name.
6 port:
7 type: int
8 default: 80
9 description: The mirror's listen port.
10 http-proxy:
11 type: string
12 default: ""
13 description: HTTP proxy to use for requests.
diff --git a/files/cvdupdate.timer b/files/cvdupdate.timer
0new file mode 10064414new file mode 100644
index 0000000..dda6d5d
--- /dev/null
+++ b/files/cvdupdate.timer
@@ -0,0 +1,12 @@
1[Unit]
2Description=Update ClamAV database mirror
3Documentation=man:cvdupdate(1)
4
5[Timer]
6OnCalendar=*-*-* 0/4:30:00
7RandomizedDelaySec=1h
8FixedRandomDelay=true
9Persistent=true
10
11[Install]
12WantedBy=timers.target
diff --git a/lib/charms/operator_libs_linux/v0/apt.py b/lib/charms/operator_libs_linux/v0/apt.py
0new file mode 10064413new file mode 100644
index 0000000..2b5c8f2
--- /dev/null
+++ b/lib/charms/operator_libs_linux/v0/apt.py
@@ -0,0 +1,1329 @@
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"""Abstractions for the system's Debian/Ubuntu package information and repositories.
16
17This module contains abstractions and wrappers around Debian/Ubuntu-style repositories and
18packages, in order to easily provide an idiomatic and Pythonic mechanism for adding packages and/or
19repositories to systems for use in machine charms.
20
21A sane default configuration is attainable through nothing more than instantiation of the
22appropriate classes. `DebianPackage` objects provide information about the architecture, version,
23name, and status of a package.
24
25`DebianPackage` will try to look up a package either from `dpkg -L` or from `apt-cache` when
26provided with a string indicating the package name. If it cannot be located, `PackageNotFoundError`
27will be returned, as `apt` and `dpkg` otherwise return `100` for all errors, and a meaningful error
28message if the package is not known is desirable.
29
30To install packages with convenience methods:
31
32```python
33try:
34 # Run `apt-get update`
35 apt.update()
36 apt.add_package("zsh")
37 apt.add_package(["vim", "htop", "wget"])
38except PackageNotFoundError:
39 logger.error("a specified package not found in package cache or on system")
40except PackageError as e:
41 logger.error("could not install package. Reason: %s", e.message)
42````
43
44To find details of a specific package:
45
46```python
47try:
48 vim = apt.DebianPackage.from_system("vim")
49
50 # To find from the apt cache only
51 # apt.DebianPackage.from_apt_cache("vim")
52
53 # To find from installed packages only
54 # apt.DebianPackage.from_installed_package("vim")
55
56 vim.ensure(PackageState.Latest)
57 logger.info("updated vim to version: %s", vim.fullversion)
58except PackageNotFoundError:
59 logger.error("a specified package not found in package cache or on system")
60except PackageError as e:
61 logger.error("could not install package. Reason: %s", e.message)
62```
63
64
65`RepositoryMapping` will return a dict-like object containing enabled system repositories
66and their properties (available groups, baseuri. gpg key). This class can add, disable, or
67manipulate repositories. Items can be retrieved as `DebianRepository` objects.
68
69In order add a new repository with explicit details for fields, a new `DebianRepository` can
70be added to `RepositoryMapping`
71
72`RepositoryMapping` provides an abstraction around the existing repositories on the system,
73and can be accessed and iterated over like any `Mapping` object, to retrieve values by key,
74iterate, or perform other operations.
75
76Keys are constructed as `{repo_type}-{}-{release}` in order to uniquely identify a repository.
77
78Repositories can be added with explicit values through a Python constructor.
79
80Example:
81
82```python
83repositories = apt.RepositoryMapping()
84
85if "deb-example.com-focal" not in repositories:
86 repositories.add(DebianRepository(enabled=True, repotype="deb",
87 uri="https://example.com", release="focal", groups=["universe"]))
88```
89
90Alternatively, any valid `sources.list` line may be used to construct a new
91`DebianRepository`.
92
93Example:
94
95```python
96repositories = apt.RepositoryMapping()
97
98if "deb-us.archive.ubuntu.com-xenial" not in repositories:
99 line = "deb http://us.archive.ubuntu.com/ubuntu xenial main restricted"
100 repo = DebianRepository.from_repo_line(line)
101 repositories.add(repo)
102```
103"""
104
105import fileinput
106import glob
107import logging
108import os
109import re
110import subprocess
111from collections.abc import Mapping
112from enum import Enum
113from subprocess import PIPE, CalledProcessError, check_call, check_output
114from typing import Iterable, List, Optional, Tuple, Union
115from urllib.parse import urlparse
116
117logger = logging.getLogger(__name__)
118
119# The unique Charmhub library identifier, never change it
120LIBID = "7c3dbc9c2ad44a47bd6fcb25caa270e5"
121
122# Increment this major API version when introducing breaking changes
123LIBAPI = 0
124
125# Increment this PATCH version before using `charmcraft publish-lib` or reset
126# to 0 if you are raising the major API version
127LIBPATCH = 7
128
129
130VALID_SOURCE_TYPES = ("deb", "deb-src")
131OPTIONS_MATCHER = re.compile(r"\[.*?\]")
132
133
134class Error(Exception):
135 """Base class of most errors raised by this library."""
136
137 def __repr__(self):
138 """String representation of Error."""
139 return "<{}.{} {}>".format(type(self).__module__, type(self).__name__, self.args)
140
141 @property
142 def name(self):
143 """Return a string representation of the model plus class."""
144 return "<{}.{}>".format(type(self).__module__, type(self).__name__)
145
146 @property
147 def message(self):
148 """Return the message passed as an argument."""
149 return self.args[0]
150
151
152class PackageError(Error):
153 """Raised when there's an error installing or removing a package."""
154
155
156class PackageNotFoundError(Error):
157 """Raised when a requested package is not known to the system."""
158
159
160class PackageState(Enum):
161 """A class to represent possible package states."""
162
163 Present = "present"
164 Absent = "absent"
165 Latest = "latest"
166 Available = "available"
167
168
169class DebianPackage:
170 """Represents a traditional Debian package and its utility functions.
171
172 `DebianPackage` wraps information and functionality around a known package, whether installed
173 or available. The version, epoch, name, and architecture can be easily queried and compared
174 against other `DebianPackage` objects to determine the latest version or to install a specific
175 version.
176
177 The representation of this object as a string mimics the output from `dpkg` for familiarity.
178
179 Installation and removal of packages is handled through the `state` property or `ensure`
180 method, with the following options:
181
182 apt.PackageState.Absent
183 apt.PackageState.Available
184 apt.PackageState.Present
185 apt.PackageState.Latest
186
187 When `DebianPackage` is initialized, the state of a given `DebianPackage` object will be set to
188 `Available`, `Present`, or `Latest`, with `Absent` implemented as a convenience for removal
189 (though it operates essentially the same as `Available`).
190 """
191
192 def __init__(
193 self, name: str, version: str, epoch: str, arch: str, state: PackageState
194 ) -> None:
195 self._name = name
196 self._arch = arch
197 self._state = state
198 self._version = Version(version, epoch)
199
200 def __eq__(self, other) -> bool:
201 """Equality for comparison.
202
203 Args:
204 other: a `DebianPackage` object for comparison
205
206 Returns:
207 A boolean reflecting equality
208 """
209 return isinstance(other, self.__class__) and (
210 self._name,
211 self._version.number,
212 ) == (other._name, other._version.number)
213
214 def __hash__(self):
215 """A basic hash so this class can be used in Mappings and dicts."""
216 return hash((self._name, self._version.number))
217
218 def __repr__(self):
219 """A representation of the package."""
220 return "<{}.{}: {}>".format(self.__module__, self.__class__.__name__, self.__dict__)
221
222 def __str__(self):
223 """A human-readable representation of the package."""
224 return "<{}: {}-{}.{} -- {}>".format(
225 self.__class__.__name__,
226 self._name,
227 self._version,
228 self._arch,
229 str(self._state),
230 )
231
232 @staticmethod
233 def _apt(
234 command: str,
235 package_names: Union[str, List],
236 optargs: Optional[List[str]] = None,
237 ) -> None:
238 """Wrap package management commands for Debian/Ubuntu systems.
239
240 Args:
241 command: the command given to `apt-get`
242 package_names: a package name or list of package names to operate on
243 optargs: an (Optional) list of additioanl arguments
244
245 Raises:
246 PackageError if an error is encountered
247 """
248 optargs = optargs if optargs is not None else []
249 if isinstance(package_names, str):
250 package_names = [package_names]
251 _cmd = ["apt-get", "-y", *optargs, command, *package_names]
252 try:
253 check_call(_cmd, stderr=PIPE, stdout=PIPE)
254 except CalledProcessError as e:
255 raise PackageError(
256 "Could not {} package(s) [{}]: {}".format(command, [*package_names], e.output)
257 ) from None
258
259 def _add(self) -> None:
260 """Add a package to the system."""
261 self._apt(
262 "install",
263 "{}={}".format(self.name, self.version),
264 optargs=["--option=Dpkg::Options::=--force-confold"],
265 )
266
267 def _remove(self) -> None:
268 """Removes a package from the system. Implementation-specific."""
269 return self._apt("remove", "{}={}".format(self.name, self.version))
270
271 @property
272 def name(self) -> str:
273 """Returns the name of the package."""
274 return self._name
275
276 def ensure(self, state: PackageState):
277 """Ensures that a package is in a given state.
278
279 Args:
280 state: a `PackageState` to reconcile the package to
281
282 Raises:
283 PackageError from the underlying call to apt
284 """
285 if self._state is not state:
286 if state not in (PackageState.Present, PackageState.Latest):
287 self._remove()
288 else:
289 self._add()
290 self._state = state
291
292 @property
293 def present(self) -> bool:
294 """Returns whether or not a package is present."""
295 return self._state in (PackageState.Present, PackageState.Latest)
296
297 @property
298 def latest(self) -> bool:
299 """Returns whether the package is the most recent version."""
300 return self._state is PackageState.Latest
301
302 @property
303 def state(self) -> PackageState:
304 """Returns the current package state."""
305 return self._state
306
307 @state.setter
308 def state(self, state: PackageState) -> None:
309 """Sets the package state to a given value.
310
311 Args:
312 state: a `PackageState` to reconcile the package to
313
314 Raises:
315 PackageError from the underlying call to apt
316 """
317 if state in (PackageState.Latest, PackageState.Present):
318 self._add()
319 else:
320 self._remove()
321 self._state = state
322
323 @property
324 def version(self) -> "Version":
325 """Returns the version for a package."""
326 return self._version
327
328 @property
329 def epoch(self) -> str:
330 """Returns the epoch for a package. May be unset."""
331 return self._version.epoch
332
333 @property
334 def arch(self) -> str:
335 """Returns the architecture for a package."""
336 return self._arch
337
338 @property
339 def fullversion(self) -> str:
340 """Returns the name+epoch for a package."""
341 return "{}.{}".format(self._version, self._arch)
342
343 @staticmethod
344 def _get_epoch_from_version(version: str) -> Tuple[str, str]:
345 """Pull the epoch, if any, out of a version string."""
346 epoch_matcher = re.compile(r"^((?P<epoch>\d+):)?(?P<version>.*)")
347 matches = epoch_matcher.search(version).groupdict()
348 return matches.get("epoch", ""), matches.get("version")
349
350 @classmethod
351 def from_system(
352 cls, package: str, version: Optional[str] = "", arch: Optional[str] = ""
353 ) -> "DebianPackage":
354 """Locates a package, either on the system or known to apt, and serializes the information.
355
356 Args:
357 package: a string representing the package
358 version: an optional string if a specific version isr equested
359 arch: an optional architecture, defaulting to `dpkg --print-architecture`. If an
360 architecture is not specified, this will be used for selection.
361
362 """
363 try:
364 return DebianPackage.from_installed_package(package, version, arch)
365 except PackageNotFoundError:
366 logger.debug(
367 "package '%s' is not currently installed or has the wrong architecture.", package
368 )
369
370 # Ok, try `apt-cache ...`
371 try:
372 return DebianPackage.from_apt_cache(package, version, arch)
373 except (PackageNotFoundError, PackageError):
374 # If we get here, it's not known to the systems.
375 # This seems unnecessary, but virtually all `apt` commands have a return code of `100`,
376 # and providing meaningful error messages without this is ugly.
377 raise PackageNotFoundError(
378 "Package '{}{}' could not be found on the system or in the apt cache!".format(
379 package, ".{}".format(arch) if arch else ""
380 )
381 ) from None
382
383 @classmethod
384 def from_installed_package(
385 cls, package: str, version: Optional[str] = "", arch: Optional[str] = ""
386 ) -> "DebianPackage":
387 """Check whether the package is already installed and return an instance.
388
389 Args:
390 package: a string representing the package
391 version: an optional string if a specific version isr equested
392 arch: an optional architecture, defaulting to `dpkg --print-architecture`.
393 If an architecture is not specified, this will be used for selection.
394 """
395 system_arch = check_output(
396 ["dpkg", "--print-architecture"], universal_newlines=True
397 ).strip()
398 arch = arch if arch else system_arch
399
400 # Regexps are a really terrible way to do this. Thanks dpkg
401 output = ""
402 try:
403 output = check_output(["dpkg", "-l", package], stderr=PIPE, universal_newlines=True)
404 except CalledProcessError:
405 raise PackageNotFoundError("Package is not installed: {}".format(package)) from None
406
407 # Pop off the output from `dpkg -l' because there's no flag to
408 # omit it`
409 lines = str(output).splitlines()[5:]
410
411 dpkg_matcher = re.compile(
412 r"""
413 ^(?P<package_status>\w+?)\s+
414 (?P<package_name>.*?)(?P<throwaway_arch>:\w+?)?\s+
415 (?P<version>.*?)\s+
416 (?P<arch>\w+?)\s+
417 (?P<description>.*)
418 """,
419 re.VERBOSE,
420 )
421
422 for line in lines:
423 try:
424 matches = dpkg_matcher.search(line).groupdict()
425 package_status = matches["package_status"]
426
427 if not package_status.endswith("i"):
428 logger.debug(
429 "package '%s' in dpkg output but not installed, status: '%s'",
430 package,
431 package_status,
432 )
433 break
434
435 epoch, split_version = DebianPackage._get_epoch_from_version(matches["version"])
436 pkg = DebianPackage(
437 matches["package_name"],
438 split_version,
439 epoch,
440 matches["arch"],
441 PackageState.Present,
442 )
443 if (pkg.arch == "all" or pkg.arch == arch) and (
444 version == "" or str(pkg.version) == version
445 ):
446 return pkg
447 except AttributeError:
448 logger.warning("dpkg matcher could not parse line: %s", line)
449
450 # If we didn't find it, fail through
451 raise PackageNotFoundError("Package {}.{} is not installed!".format(package, arch))
452
453 @classmethod
454 def from_apt_cache(
455 cls, package: str, version: Optional[str] = "", arch: Optional[str] = ""
456 ) -> "DebianPackage":
457 """Check whether the package is already installed and return an instance.
458
459 Args:
460 package: a string representing the package
461 version: an optional string if a specific version isr equested
462 arch: an optional architecture, defaulting to `dpkg --print-architecture`.
463 If an architecture is not specified, this will be used for selection.
464 """
465 system_arch = check_output(
466 ["dpkg", "--print-architecture"], universal_newlines=True
467 ).strip()
468 arch = arch if arch else system_arch
469
470 # Regexps are a really terrible way to do this. Thanks dpkg
471 keys = ("Package", "Architecture", "Version")
472
473 try:
474 output = check_output(
475 ["apt-cache", "show", package], stderr=PIPE, universal_newlines=True
476 )
477 except CalledProcessError as e:
478 raise PackageError(
479 "Could not list packages in apt-cache: {}".format(e.output)
480 ) from None
481
482 pkg_groups = output.strip().split("\n\n")
483 keys = ("Package", "Architecture", "Version")
484
485 for pkg_raw in pkg_groups:
486 lines = str(pkg_raw).splitlines()
487 vals = {}
488 for line in lines:
489 if line.startswith(keys):
490 items = line.split(":", 1)
491 vals[items[0]] = items[1].strip()
492 else:
493 continue
494
495 epoch, split_version = DebianPackage._get_epoch_from_version(vals["Version"])
496 pkg = DebianPackage(
497 vals["Package"],
498 split_version,
499 epoch,
500 vals["Architecture"],
501 PackageState.Available,
502 )
503
504 if (pkg.arch == "all" or pkg.arch == arch) and (
505 version == "" or str(pkg.version) == version
506 ):
507 return pkg
508
509 # If we didn't find it, fail through
510 raise PackageNotFoundError("Package {}.{} is not in the apt cache!".format(package, arch))
511
512
513class Version:
514 """An abstraction around package versions.
515
516 This seems like it should be strictly unnecessary, except that `apt_pkg` is not usable inside a
517 venv, and wedging version comparisions into `DebianPackage` would overcomplicate it.
518
519 This class implements the algorithm found here:
520 https://www.debian.org/doc/debian-policy/ch-controlfields.html#version
521 """
522
523 def __init__(self, version: str, epoch: str):
524 self._version = version
525 self._epoch = epoch or ""
526
527 def __repr__(self):
528 """A representation of the package."""
529 return "<{}.{}: {}>".format(self.__module__, self.__class__.__name__, self.__dict__)
530
531 def __str__(self):
532 """A human-readable representation of the package."""
533 return "{}{}".format("{}:".format(self._epoch) if self._epoch else "", self._version)
534
535 @property
536 def epoch(self):
537 """Returns the epoch for a package. May be empty."""
538 return self._epoch
539
540 @property
541 def number(self) -> str:
542 """Returns the version number for a package."""
543 return self._version
544
545 def _get_parts(self, version: str) -> Tuple[str, str]:
546 """Separate the version into component upstream and Debian pieces."""
547 try:
548 version.rindex("-")
549 except ValueError:
550 # No hyphens means no Debian version
551 return version, "0"
552
553 upstream, debian = version.rsplit("-", 1)
554 return upstream, debian
555
556 def _listify(self, revision: str) -> List[str]:
557 """Split a revision string into a listself.
558
559 This list is comprised of alternating between strings and numbers,
560 padded on either end to always be "str, int, str, int..." and
561 always be of even length. This allows us to trivially implement the
562 comparison algorithm described.
563 """
564 result = []
565 while revision:
566 rev_1, remains = self._get_alphas(revision)
567 rev_2, remains = self._get_digits(remains)
568 result.extend([rev_1, rev_2])
569 revision = remains
570 return result
571
572 def _get_alphas(self, revision: str) -> Tuple[str, str]:
573 """Return a tuple of the first non-digit characters of a revision."""
574 # get the index of the first digit
575 for i, char in enumerate(revision):
576 if char.isdigit():
577 if i == 0:
578 return "", revision
579 return revision[0:i], revision[i:]
580 # string is entirely alphas
581 return revision, ""
582
583 def _get_digits(self, revision: str) -> Tuple[int, str]:
584 """Return a tuple of the first integer characters of a revision."""
585 # If the string is empty, return (0,'')
586 if not revision:
587 return 0, ""
588 # get the index of the first non-digit
589 for i, char in enumerate(revision):
590 if not char.isdigit():
591 if i == 0:
592 return 0, revision
593 return int(revision[0:i]), revision[i:]
594 # string is entirely digits
595 return int(revision), ""
596
597 def _dstringcmp(self, a, b): # noqa: C901
598 """Debian package version string section lexical sort algorithm.
599
600 The lexical comparison is a comparison of ASCII values modified so
601 that all the letters sort earlier than all the non-letters and so that
602 a tilde sorts before anything, even the end of a part.
603 """
604 if a == b:
605 return 0
606 try:
607 for i, char in enumerate(a):
608 if char == b[i]:
609 continue
610 # "a tilde sorts before anything, even the end of a part"
611 # (emptyness)
612 if char == "~":
613 return -1
614 if b[i] == "~":
615 return 1
616 # "all the letters sort earlier than all the non-letters"
617 if char.isalpha() and not b[i].isalpha():
618 return -1
619 if not char.isalpha() and b[i].isalpha():
620 return 1
621 # otherwise lexical sort
622 if ord(char) > ord(b[i]):
623 return 1
624 if ord(char) < ord(b[i]):
625 return -1
626 except IndexError:
627 # a is longer than b but otherwise equal, greater unless there are tildes
628 if char == "~":
629 return -1
630 return 1
631 # if we get here, a is shorter than b but otherwise equal, so check for tildes...
632 if b[len(a)] == "~":
633 return 1
634 return -1
635
636 def _compare_revision_strings(self, first: str, second: str): # noqa: C901
637 """Compare two debian revision strings."""
638 if first == second:
639 return 0
640
641 # listify pads results so that we will always be comparing ints to ints
642 # and strings to strings (at least until we fall off the end of a list)
643 first_list = self._listify(first)
644 second_list = self._listify(second)
645 if first_list == second_list:
646 return 0
647 try:
648 for i, item in enumerate(first_list):
649 # explicitly raise IndexError if we've fallen off the edge of list2
650 if i >= len(second_list):
651 raise IndexError
652 # if the items are equal, next
653 if item == second_list[i]:
654 continue
655 # numeric comparison
656 if isinstance(item, int):
657 if item > second_list[i]:
658 return 1
659 if item < second_list[i]:
660 return -1
661 else:
662 # string comparison
663 return self._dstringcmp(item, second_list[i])
664 except IndexError:
665 # rev1 is longer than rev2 but otherwise equal, hence greater
666 # ...except for goddamn tildes
667 if first_list[len(second_list)][0][0] == "~":
668 return 1
669 return 1
670 # rev1 is shorter than rev2 but otherwise equal, hence lesser
671 # ...except for goddamn tildes
672 if second_list[len(first_list)][0][0] == "~":
673 return -1
674 return -1
675
676 def _compare_version(self, other) -> int:
677 if (self.number, self.epoch) == (other.number, other.epoch):
678 return 0
679
680 if self.epoch < other.epoch:
681 return -1
682 if self.epoch > other.epoch:
683 return 1
684
685 # If none of these are true, follow the algorithm
686 upstream_version, debian_version = self._get_parts(self.number)
687 other_upstream_version, other_debian_version = self._get_parts(other.number)
688
689 upstream_cmp = self._compare_revision_strings(upstream_version, other_upstream_version)
690 if upstream_cmp != 0:
691 return upstream_cmp
692
693 debian_cmp = self._compare_revision_strings(debian_version, other_debian_version)
694 if debian_cmp != 0:
695 return debian_cmp
696
697 return 0
698
699 def __lt__(self, other) -> bool:
700 """Less than magic method impl."""
701 return self._compare_version(other) < 0
702
703 def __eq__(self, other) -> bool:
704 """Equality magic method impl."""
705 return self._compare_version(other) == 0
706
707 def __gt__(self, other) -> bool:
708 """Greater than magic method impl."""
709 return self._compare_version(other) > 0
710
711 def __le__(self, other) -> bool:
712 """Less than or equal to magic method impl."""
713 return self.__eq__(other) or self.__lt__(other)
714
715 def __ge__(self, other) -> bool:
716 """Greater than or equal to magic method impl."""
717 return self.__gt__(other) or self.__eq__(other)
718
719 def __ne__(self, other) -> bool:
720 """Not equal to magic method impl."""
721 return not self.__eq__(other)
722
723
724def add_package(
725 package_names: Union[str, List[str]],
726 version: Optional[str] = "",
727 arch: Optional[str] = "",
728 update_cache: Optional[bool] = False,
729) -> Union[DebianPackage, List[DebianPackage]]:
730 """Add a package or list of packages to the system.
731
732 Args:
733 name: the name(s) of the package(s)
734 version: an (Optional) version as a string. Defaults to the latest known
735 arch: an optional architecture for the package
736 update_cache: whether or not to run `apt-get update` prior to operating
737
738 Raises:
739 PackageNotFoundError if the package is not in the cache.
740 """
741 cache_refreshed = False
742 if update_cache:
743 update()
744 cache_refreshed = True
745
746 packages = {"success": [], "retry": [], "failed": []}
747
748 package_names = [package_names] if type(package_names) is str else package_names
749 if not package_names:
750 raise TypeError("Expected at least one package name to add, received zero!")
751
752 if len(package_names) != 1 and version:
753 raise TypeError(
754 "Explicit version should not be set if more than one package is being added!"
755 )
756
757 for p in package_names:
758 pkg, success = _add(p, version, arch)
759 if success:
760 packages["success"].append(pkg)
761 else:
762 logger.warning("failed to locate and install/update '%s'", pkg)
763 packages["retry"].append(p)
764
765 if packages["retry"] and not cache_refreshed:
766 logger.info("updating the apt-cache and retrying installation of failed packages.")
767 update()
768
769 for p in packages["retry"]:
770 pkg, success = _add(p, version, arch)
771 if success:
772 packages["success"].append(pkg)
773 else:
774 packages["failed"].append(p)
775
776 if packages["failed"]:
777 raise PackageError("Failed to install packages: {}".format(", ".join(packages["failed"])))
778
779 return packages["success"] if len(packages["success"]) > 1 else packages["success"][0]
780
781
782def _add(
783 name: str,
784 version: Optional[str] = "",
785 arch: Optional[str] = "",
786) -> Tuple[Union[DebianPackage, str], bool]:
787 """Adds a package.
788
789 Args:
790 name: the name(s) of the package(s)
791 version: an (Optional) version as a string. Defaults to the latest known
792 arch: an optional architecture for the package
793
794 Returns: a tuple of `DebianPackage` if found, or a :str: if it is not, and
795 a boolean indicating success
796 """
797 try:
798 pkg = DebianPackage.from_system(name, version, arch)
799 pkg.ensure(state=PackageState.Present)
800 return pkg, True
801 except PackageNotFoundError:
802 return name, False
803
804
805def remove_package(
806 package_names: Union[str, List[str]]
807) -> Union[DebianPackage, List[DebianPackage]]:
808 """Removes a package from the system.
809
810 Args:
811 package_names: the name of a package
812
813 Raises:
814 PackageNotFoundError if the package is not found.
815 """
816 packages = []
817
818 package_names = [package_names] if type(package_names) is str else package_names
819 if not package_names:
820 raise TypeError("Expected at least one package name to add, received zero!")
821
822 for p in package_names:
823 try:
824 pkg = DebianPackage.from_installed_package(p)
825 pkg.ensure(state=PackageState.Absent)
826 packages.append(pkg)
827 except PackageNotFoundError:
828 logger.info("package '%s' was requested for removal, but it was not installed.", p)
829
830 # the list of packages will be empty when no package is removed
831 logger.debug("packages: '%s'", packages)
832 return packages[0] if len(packages) == 1 else packages
833
834
835def update() -> None:
836 """Updates the apt cache via `apt-get update`."""
837 check_call(["apt-get", "update"], stderr=PIPE, stdout=PIPE)
838
839
840class InvalidSourceError(Error):
841 """Exceptions for invalid source entries."""
842
843
844class GPGKeyError(Error):
845 """Exceptions for GPG keys."""
846
847
848class DebianRepository:
849 """An abstraction to represent a repository."""
850
851 def __init__(
852 self,
853 enabled: bool,
854 repotype: str,
855 uri: str,
856 release: str,
857 groups: List[str],
858 filename: Optional[str] = "",
859 gpg_key_filename: Optional[str] = "",
860 options: Optional[dict] = None,
861 ):
862 self._enabled = enabled
863 self._repotype = repotype
864 self._uri = uri
865 self._release = release
866 self._groups = groups
867 self._filename = filename
868 self._gpg_key_filename = gpg_key_filename
869 self._options = options
870
871 @property
872 def enabled(self):
873 """Return whether or not the repository is enabled."""
874 return self._enabled
875
876 @property
877 def repotype(self):
878 """Return whether it is binary or source."""
879 return self._repotype
880
881 @property
882 def uri(self):
883 """Return the URI."""
884 return self._uri
885
886 @property
887 def release(self):
888 """Return which Debian/Ubuntu releases it is valid for."""
889 return self._release
890
891 @property
892 def groups(self):
893 """Return the enabled package groups."""
894 return self._groups
895
896 @property
897 def filename(self):
898 """Returns the filename for a repository."""
899 return self._filename
900
901 @filename.setter
902 def filename(self, fname: str) -> None:
903 """Sets the filename used when a repo is written back to diskself.
904
905 Args:
906 fname: a filename to write the repository information to.
907 """
908 if not fname.endswith(".list"):
909 raise InvalidSourceError("apt source filenames should end in .list!")
910
911 self._filename = fname
912
913 @property
914 def gpg_key(self):
915 """Returns the path to the GPG key for this repository."""
916 return self._gpg_key_filename
917
918 @property
919 def options(self):
920 """Returns any additional repo options which are set."""
921 return self._options
922
923 def make_options_string(self) -> str:
924 """Generate the complete options string for a a repository.
925
926 Combining `gpg_key`, if set, and the rest of the options to find
927 a complex repo string.
928 """
929 options = self._options if self._options else {}
930 if self._gpg_key_filename:
931 options["signed-by"] = self._gpg_key_filename
932
933 return (
934 "[{}] ".format(" ".join(["{}={}".format(k, v) for k, v in options.items()]))
935 if options
936 else ""
937 )
938
939 @staticmethod
940 def prefix_from_uri(uri: str) -> str:
941 """Get a repo list prefix from the uri, depending on whether a path is set."""
942 uridetails = urlparse(uri)
943 path = (
944 uridetails.path.lstrip("/").replace("/", "-") if uridetails.path else uridetails.netloc
945 )
946 return "/etc/apt/sources.list.d/{}".format(path)
947
948 @staticmethod
949 def from_repo_line(repo_line: str, write_file: Optional[bool] = True) -> "DebianRepository":
950 """Instantiate a new `DebianRepository` a `sources.list` entry line.
951
952 Args:
953 repo_line: a string representing a repository entry
954 write_file: boolean to enable writing the new repo to disk
955 """
956 repo = RepositoryMapping._parse(repo_line, "UserInput")
957 fname = "{}-{}.list".format(
958 DebianRepository.prefix_from_uri(repo.uri), repo.release.replace("/", "-")
959 )
960 repo.filename = fname
961
962 options = repo.options if repo.options else {}
963 if repo.gpg_key:
964 options["signed-by"] = repo.gpg_key
965
966 # For Python 3.5 it's required to use sorted in the options dict in order to not have
967 # different results in the order of the options between executions.
968 options_str = (
969 "[{}] ".format(" ".join(["{}={}".format(k, v) for k, v in sorted(options.items())]))
970 if options
971 else ""
972 )
973
974 if write_file:
975 with open(fname, "wb") as f:
976 f.write(
977 (
978 "{}".format("#" if not repo.enabled else "")
979 + "{} {}{} ".format(repo.repotype, options_str, repo.uri)
980 + "{} {}\n".format(repo.release, " ".join(repo.groups))
981 ).encode("utf-8")
982 )
983
984 return repo
985
986 def disable(self) -> None:
987 """Remove this repository from consideration.
988
989 Disable it instead of removing from the repository file.
990 """
991 searcher = "{} {}{} {}".format(
992 self.repotype, self.make_options_string(), self.uri, self.release
993 )
994 for line in fileinput.input(self._filename, inplace=True):
995 if re.match(r"^{}\s".format(re.escape(searcher)), line):
996 print("# {}".format(line), end="")
997 else:
998 print(line, end="")
999
1000 def import_key(self, key: str) -> None:
1001 """Import an ASCII Armor key.
1002
1003 A Radix64 format keyid is also supported for backwards
1004 compatibility. In this case Ubuntu keyserver will be
1005 queried for a key via HTTPS by its keyid. This method
1006 is less preferrable because https proxy servers may
1007 require traffic decryption which is equivalent to a
1008 man-in-the-middle attack (a proxy server impersonates
1009 keyserver TLS certificates and has to be explicitly
1010 trusted by the system).
1011
1012 Args:
1013 key: A GPG key in ASCII armor format,
1014 including BEGIN and END markers or a keyid.
1015
1016 Raises:
1017 GPGKeyError if the key could not be imported
1018 """
1019 key = key.strip()
1020 if "-" in key or "\n" in key:
1021 # Send everything not obviously a keyid to GPG to import, as
1022 # we trust its validation better than our own. eg. handling
1023 # comments before the key.
1024 logger.debug("PGP key found (looks like ASCII Armor format)")
1025 if (
1026 "-----BEGIN PGP PUBLIC KEY BLOCK-----" in key
1027 and "-----END PGP PUBLIC KEY BLOCK-----" in key
1028 ):
1029 logger.debug("Writing provided PGP key in the binary format")
1030 key_bytes = key.encode("utf-8")
1031 key_name = self._get_keyid_by_gpg_key(key_bytes)
1032 key_gpg = self._dearmor_gpg_key(key_bytes)
1033 self._gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key_name)
1034 self._write_apt_gpg_keyfile(key_name=self._gpg_key_filename, key_material=key_gpg)
1035 else:
1036 raise GPGKeyError("ASCII armor markers missing from GPG key")
1037 else:
1038 logger.warning(
1039 "PGP key found (looks like Radix64 format). "
1040 "SECURELY importing PGP key from keyserver; "
1041 "full key not provided."
1042 )
1043 # as of bionic add-apt-repository uses curl with an HTTPS keyserver URL
1044 # to retrieve GPG keys. `apt-key adv` command is deprecated as is
1045 # apt-key in general as noted in its manpage. See lp:1433761 for more
1046 # history. Instead, /etc/apt/trusted.gpg.d is used directly to drop
1047 # gpg
1048 key_asc = self._get_key_by_keyid(key)
1049 # write the key in GPG format so that apt-key list shows it
1050 key_gpg = self._dearmor_gpg_key(key_asc.encode("utf-8"))
1051 self._gpg_key_filename = "/etc/apt/trusted.gpg.d/{}.gpg".format(key)
1052 self._write_apt_gpg_keyfile(key_name=key, key_material=key_gpg)
1053
1054 @staticmethod
1055 def _get_keyid_by_gpg_key(key_material: bytes) -> str:
1056 """Get a GPG key fingerprint by GPG key material.
1057
1058 Gets a GPG key fingerprint (40-digit, 160-bit) by the ASCII armor-encoded
1059 or binary GPG key material. Can be used, for example, to generate file
1060 names for keys passed via charm options.
1061 """
1062 # Use the same gpg command for both Xenial and Bionic
1063 cmd = ["gpg", "--with-colons", "--with-fingerprint"]
1064 ps = subprocess.run(
1065 cmd,
1066 stdout=PIPE,
1067 stderr=PIPE,
1068 input=key_material,
1069 )
1070 out, err = ps.stdout.decode(), ps.stderr.decode()
1071 if "gpg: no valid OpenPGP data found." in err:
1072 raise GPGKeyError("Invalid GPG key material provided")
1073 # from gnupg2 docs: fpr :: Fingerprint (fingerprint is in field 10)
1074 return re.search(r"^fpr:{9}([0-9A-F]{40}):$", out, re.MULTILINE).group(1)
1075
1076 @staticmethod
1077 def _get_key_by_keyid(keyid: str) -> str:
1078 """Get a key via HTTPS from the Ubuntu keyserver.
1079
1080 Different key ID formats are supported by SKS keyservers (the longer ones
1081 are more secure, see "dead beef attack" and https://evil32.com/). Since
1082 HTTPS is used, if SSLBump-like HTTPS proxies are in place, they will
1083 impersonate keyserver.ubuntu.com and generate a certificate with
1084 keyserver.ubuntu.com in the CN field or in SubjAltName fields of a
1085 certificate. If such proxy behavior is expected it is necessary to add the
1086 CA certificate chain containing the intermediate CA of the SSLBump proxy to
1087 every machine that this code runs on via ca-certs cloud-init directive (via
1088 cloudinit-userdata model-config) or via other means (such as through a
1089 custom charm option). Also note that DNS resolution for the hostname in a
1090 URL is done at a proxy server - not at the client side.
1091 8-digit (32 bit) key ID
1092 https://keyserver.ubuntu.com/pks/lookup?search=0x4652B4E6
1093 16-digit (64 bit) key ID
1094 https://keyserver.ubuntu.com/pks/lookup?search=0x6E85A86E4652B4E6
1095 40-digit key ID:
1096 https://keyserver.ubuntu.com/pks/lookup?search=0x35F77D63B5CEC106C577ED856E85A86E4652B4E6
1097
1098 Args:
1099 keyid: An 8, 16 or 40 hex digit keyid to find a key for
1100
1101 Returns:
1102 A string contining key material for the specified GPG key id
1103
1104
1105 Raises:
1106 subprocess.CalledProcessError
1107 """
1108 # options=mr - machine-readable output (disables html wrappers)
1109 keyserver_url = (
1110 "https://keyserver.ubuntu.com" "/pks/lookup?op=get&options=mr&exact=on&search=0x{}"
1111 )
1112 curl_cmd = ["curl", keyserver_url.format(keyid)]
1113 # use proxy server settings in order to retrieve the key
1114 return check_output(curl_cmd).decode()
1115
1116 @staticmethod
1117 def _dearmor_gpg_key(key_asc: bytes) -> bytes:
1118 """Converts a GPG key in the ASCII armor format to the binary format.
1119
1120 Args:
1121 key_asc: A GPG key in ASCII armor format.
1122
1123 Returns:
1124 A GPG key in binary format as a string
1125
1126 Raises:
1127 GPGKeyError
1128 """
1129 ps = subprocess.run(["gpg", "--dearmor"], stdout=PIPE, stderr=PIPE, input=key_asc)
1130 out, err = ps.stdout, ps.stderr.decode()
1131 if "gpg: no valid OpenPGP data found." in err:
1132 raise GPGKeyError(
1133 "Invalid GPG key material. Check your network setup"
1134 " (MTU, routing, DNS) and/or proxy server settings"
1135 " as well as destination keyserver status."
1136 )
1137 else:
1138 return out
1139
1140 @staticmethod
1141 def _write_apt_gpg_keyfile(key_name: str, key_material: bytes) -> None:
1142 """Writes GPG key material into a file at a provided path.
1143
1144 Args:
1145 key_name: A key name to use for a key file (could be a fingerprint)
1146 key_material: A GPG key material (binary)
1147 """
1148 with open(key_name, "wb") as keyf:
1149 keyf.write(key_material)
1150
1151
1152class RepositoryMapping(Mapping):
1153 """An representation of known repositories.
1154
1155 Instantiation of `RepositoryMapping` will iterate through the
1156 filesystem, parse out repository files in `/etc/apt/...`, and create
1157 `DebianRepository` objects in this list.
1158
1159 Typical usage:
1160
1161 repositories = apt.RepositoryMapping()
1162 repositories.add(DebianRepository(
1163 enabled=True, repotype="deb", uri="https://example.com", release="focal",
1164 groups=["universe"]
1165 ))
1166 """
1167
1168 def __init__(self):
1169 self._repository_map = {}
1170 # Repositories that we're adding -- used to implement mode param
1171 self.default_file = "/etc/apt/sources.list"
1172
1173 # read sources.list if it exists
1174 if os.path.isfile(self.default_file):
1175 self.load(self.default_file)
1176
1177 # read sources.list.d
1178 for file in glob.iglob("/etc/apt/sources.list.d/*.list"):
1179 self.load(file)
1180
1181 def __contains__(self, key: str) -> bool:
1182 """Magic method for checking presence of repo in mapping."""
1183 return key in self._repository_map
1184
1185 def __len__(self) -> int:
1186 """Return number of repositories in map."""
1187 return len(self._repository_map)
1188
1189 def __iter__(self) -> Iterable[DebianRepository]:
1190 """Iterator magic method for RepositoryMapping."""
1191 return iter(self._repository_map.values())
1192
1193 def __getitem__(self, repository_uri: str) -> DebianRepository:
1194 """Return a given `DebianRepository`."""
1195 return self._repository_map[repository_uri]
1196
1197 def __setitem__(self, repository_uri: str, repository: DebianRepository) -> None:
1198 """Add a `DebianRepository` to the cache."""
1199 self._repository_map[repository_uri] = repository
1200
1201 def load(self, filename: str):
1202 """Load a repository source file into the cache.
1203
1204 Args:
1205 filename: the path to the repository file
1206 """
1207 parsed = []
1208 skipped = []
1209 with open(filename, "r") as f:
1210 for n, line in enumerate(f):
1211 try:
1212 repo = self._parse(line, filename)
1213 except InvalidSourceError:
1214 skipped.append(n)
1215 else:
1216 repo_identifier = "{}-{}-{}".format(repo.repotype, repo.uri, repo.release)
1217 self._repository_map[repo_identifier] = repo
1218 parsed.append(n)
1219 logger.debug("parsed repo: '%s'", repo_identifier)
1220
1221 if skipped:
1222 skip_list = ", ".join(str(s) for s in skipped)
1223 logger.debug("skipped the following lines in file '%s': %s", filename, skip_list)
1224
1225 if parsed:
1226 logger.info("parsed %d apt package repositories", len(parsed))
1227 else:
1228 raise InvalidSourceError("all repository lines in '{}' were invalid!".format(filename))
1229
1230 @staticmethod
1231 def _parse(line: str, filename: str) -> DebianRepository:
1232 """Parse a line in a sources.list file.
1233
1234 Args:
1235 line: a single line from `load` to parse
1236 filename: the filename being read
1237
1238 Raises:
1239 InvalidSourceError if the source type is unknown
1240 """
1241 enabled = True
1242 repotype = uri = release = gpg_key = ""
1243 options = {}
1244 groups = []
1245
1246 line = line.strip()
1247 if line.startswith("#"):
1248 enabled = False
1249 line = line[1:]
1250
1251 # Check for "#" in the line and treat a part after it as a comment then strip it off.
1252 i = line.find("#")
1253 if i > 0:
1254 line = line[:i]
1255
1256 # Split a source into substrings to initialize a new repo.
1257 source = line.strip()
1258 if source:
1259 # Match any repo options, and get a dict representation.
1260 for v in re.findall(OPTIONS_MATCHER, source):
1261 opts = dict(o.split("=") for o in v.strip("[]").split())
1262 # Extract the 'signed-by' option for the gpg_key
1263 gpg_key = opts.pop("signed-by", "")
1264 options = opts
1265
1266 # Remove any options from the source string and split the string into chunks
1267 source = re.sub(OPTIONS_MATCHER, "", source)
1268 chunks = source.split()
1269
1270 # Check we've got a valid list of chunks
1271 if len(chunks) < 3 or chunks[0] not in VALID_SOURCE_TYPES:
1272 raise InvalidSourceError("An invalid sources line was found in %s!", filename)
1273
1274 repotype = chunks[0]
1275 uri = chunks[1]
1276 release = chunks[2]
1277 groups = chunks[3:]
1278
1279 return DebianRepository(
1280 enabled, repotype, uri, release, groups, filename, gpg_key, options
1281 )
1282 else:
1283 raise InvalidSourceError("An invalid sources line was found in %s!", filename)
1284
1285 def add(self, repo: DebianRepository, default_filename: Optional[bool] = False) -> None:
1286 """Add a new repository to the system.
1287
1288 Args:
1289 repo: a `DebianRepository` object
1290 default_filename: an (Optional) filename if the default is not desirable
1291 """
1292 new_filename = "{}-{}.list".format(
1293 DebianRepository.prefix_from_uri(repo.uri), repo.release.replace("/", "-")
1294 )
1295
1296 fname = repo.filename or new_filename
1297
1298 options = repo.options if repo.options else {}
1299 if repo.gpg_key:
1300 options["signed-by"] = repo.gpg_key
1301
1302 with open(fname, "wb") as f:
1303 f.write(
1304 (
1305 "{}".format("#" if not repo.enabled else "")
1306 + "{} {}{} ".format(repo.repotype, repo.make_options_string(), repo.uri)
1307 + "{} {}\n".format(repo.release, " ".join(repo.groups))
1308 ).encode("utf-8")
1309 )
1310
1311 self._repository_map["{}-{}-{}".format(repo.repotype, repo.uri, repo.release)] = repo
1312
1313 def disable(self, repo: DebianRepository) -> None:
1314 """Remove a repository. Disable by default.
1315
1316 Args:
1317 repo: a `DebianRepository` to disable
1318 """
1319 searcher = "{} {}{} {}".format(
1320 repo.repotype, repo.make_options_string(), repo.uri, repo.release
1321 )
1322
1323 for line in fileinput.input(repo.filename, inplace=True):
1324 if re.match(r"^{}\s".format(re.escape(searcher)), line):
1325 print("# {}".format(line), end="")
1326 else:
1327 print(line, end="")
1328
1329 self._repository_map["{}-{}-{}".format(repo.repotype, repo.uri, repo.release)] = repo
diff --git a/metadata.yaml b/metadata.yaml
0new file mode 1006441330new file mode 100644
index 0000000..3cd1f95
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,14 @@
1name: clamav-database-mirror
2display-name: ClamAV Database Mirror
3summary: A local mirror of the ClamAV database.
4description: |
5 Maintains a local mirror of the latest ClamAV databases.
6
7 Workloads that perform ClamAV scans in ephemeral environments need to
8 fetch fresh copies of virus definitions before doing anything else; but if
9 they do that using the default configuration that points to
10 database.clamav.net, they will likely find themselves rate-limited in
11 short order. To avoid that, point those workloads at a deployment of this
12 charm instead, which maintains a local mirror more economically.
13series:
14 - jammy
diff --git a/pyproject.toml b/pyproject.toml
0new file mode 10064415new file mode 100644
index 0000000..2edc519
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,33 @@
1# Testing tools configuration
2[tool.coverage.run]
3branch = true
4
5[tool.coverage.report]
6show_missing = true
7
8[tool.pytest.ini_options]
9minversion = "6.0"
10log_cli_level = "INFO"
11
12# Formatting tools configuration
13[tool.black]
14line-length = 99
15target-version = ["py38"]
16
17[tool.isort]
18line_length = 99
19profile = "black"
20
21# Linting tools configuration
22[tool.flake8]
23max-line-length = 99
24max-doc-length = 99
25max-complexity = 10
26exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"]
27select = ["E", "W", "F", "C", "N", "R", "D", "H"]
28# Ignore W503, E501 because using black creates errors with this
29# Ignore D107 Missing docstring in __init__
30ignore = ["W503", "E501", "D107"]
31# D100, D101, D102, D103: Ignore missing docstrings in tests
32per-file-ignores = ["tests/*:D100,D101,D102,D103,D104"]
33docstring-convention = "google"
diff --git a/requirements.txt b/requirements.txt
0new file mode 10064434new file mode 100644
index 0000000..4bbf401
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
1jinja2
2ops >= 1.5.0
diff --git a/src/charm.py b/src/charm.py
0new file mode 1007553new file mode 100755
index 0000000..fbbf2c0
--- /dev/null
+++ b/src/charm.py
@@ -0,0 +1,106 @@
1#!/usr/bin/env python3
2# Copyright 2022 Canonical Ltd.
3# See LICENSE file for licensing details.
4#
5# Learn more at: https://juju.is/docs/sdk
6
7"""Charm the service.
8
9Refer to the following post for a quick-start guide that will help you
10develop a new k8s charm using the Operator Framework:
11
12 https://discourse.charmhub.io/t/4208
13"""
14
15import logging
16import shutil
17import subprocess
18from pathlib import Path
19
20from charms.operator_libs_linux.v0 import apt
21from jinja2 import Environment, FileSystemLoader
22from ops.charm import CharmBase
23from ops.main import main
24from ops.model import ActiveStatus, MaintenanceStatus
25
26logger = logging.getLogger(__name__)
27
28db_path = "/srv/clamav-database-mirror/database"
29
30
31class ClamAVDatabaseMirrorCharm(CharmBase):
32 """Charm the service."""
33
34 def __init__(self, *args):
35 super().__init__(*args)
36 self.framework.observe(self.on.install, self._on_install)
37 self.framework.observe(self.on.config_changed, self._on_config_changed)
38
39 def _set_maintenance_step(self, description):
40 self.unit.status = MaintenanceStatus(description)
41 logger.info(description)
42
43 def _install(self):
44 """Install our dependencies."""
45 self._set_maintenance_step("Installing dependencies")
46 apt.add_package(package_names=["clamav-cvdupdate", "nginx"], update_cache=True)
47 subprocess.run(["systemctl", "disable", "--now", "nginx.service"], check=True)
48 Path("/etc/nginx/sites-enabled/default").unlink(missing_ok=True)
49
50 def _run_as(self, user, args, **kwargs):
51 subprocess.run(
52 ["setpriv", "--reuid", user, "--regid", user, "--init-groups", "--"] + args, **kwargs
53 )
54
55 def _configure_database(self):
56 """Configure the database."""
57 self._set_maintenance_step("Configuring database")
58 subprocess.run(["mkdir", "-p", db_path])
59 subprocess.run(["chown", "ubuntu:ubuntu", db_path])
60 self._run_as("ubuntu", ["cvdupdate", "config", "set", "--dbdir", db_path])
61
62 def _configure_timer(self):
63 """Create a systemd timer to update the database automatically."""
64 self._set_maintenance_step("Configuring timer")
65 template_env = Environment(loader=FileSystemLoader(str(self.charm_dir / "templates")))
66 template = template_env.get_template("cvdupdate.service.j2")
67 service = template.render(config=self.config)
68 Path("/lib/systemd/system/cvdupdate.service").write_text(service)
69 shutil.copy(
70 self.charm_dir / "files" / "cvdupdate.timer", "/lib/systemd/system/cvdupdate.timer"
71 )
72 subprocess.run(["systemctl", "daemon-reload"])
73 # Start the update service once before enabling the timer, to ensure
74 # that the database is populated immediately.
75 subprocess.run(["systemctl", "start", "cvdupdate.service"], check=True)
76 subprocess.run(["systemctl", "enable", "--now", "cvdupdate.timer"], check=True)
77
78 def _configure_nginx(self):
79 """Configure nginx to serve the database."""
80 self._set_maintenance_step("Configuring nginx")
81 template_env = Environment(loader=FileSystemLoader(str(self.charm_dir / "templates")))
82 template = template_env.get_template("nginx-site.conf.j2")
83 site = template.render(config=self.config, db_path=db_path)
84 available_conf = Path("/etc/nginx/sites-available/clamav-database-mirror.conf")
85 enabled_conf = Path("/etc/nginx/sites-enabled/clamav-database-mirror.conf")
86 available_conf.write_text(site)
87 if not enabled_conf.exists():
88 enabled_conf.symlink_to(available_conf)
89 subprocess.run(["systemctl", "enable", "nginx.service"], check=True)
90 subprocess.run(["systemctl", "reload-or-restart", "nginx.service"], check=True)
91
92 def _on_install(self, event):
93 self._install()
94 self._configure_database()
95 self._configure_timer()
96 self._configure_nginx()
97 self.unit.status = ActiveStatus()
98
99 def _on_config_changed(self, event):
100 self._configure_timer()
101 self._configure_nginx()
102 self.unit.status = ActiveStatus()
103
104
105if __name__ == "__main__": # pragma: nocover
106 main(ClamAVDatabaseMirrorCharm)
diff --git a/templates/cvdupdate.service.j2 b/templates/cvdupdate.service.j2
0new file mode 100644107new file mode 100644
index 0000000..3946693
--- /dev/null
+++ b/templates/cvdupdate.service.j2
@@ -0,0 +1,22 @@
1[Unit]
2Description=Update ClamAV database mirror
3Documentation=man:cvdupdate(1)
4
5[Service]
6Type=oneshot
7ExecStart=/usr/bin/cvdupdate update
8User=ubuntu
9{% if config["http-proxy"] -%}
10Environment=http_proxy={{ config["http-proxy"] }}
11Environment=https_proxy={{ config["http-proxy"] }}
12{% endif -%}
13ProtectSystem=full
14PrivateTmp=true
15PrivateDevices=true
16ProtectHostname=true
17ProtectClock=true
18ProtectKernelTunables=true
19ProtectKernelModules=true
20ProtectKernelLogs=true
21ProtectControlGroups=true
22
diff --git a/templates/nginx-site.conf.j2 b/templates/nginx-site.conf.j2
0new file mode 10064423new file mode 100644
index 0000000..5fec382
--- /dev/null
+++ b/templates/nginx-site.conf.j2
@@ -0,0 +1,9 @@
1server {
2 server_name {{ config["host"] }};
3 listen {{ config["port"] }};
4 location / {
5 alias {{ db_path }}/;
6 autoindex on;
7 }
8}
9
diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py
0new file mode 10064410new file mode 100644
index 0000000..ffcc9ec
--- /dev/null
+++ b/tests/integration/test_charm.py
@@ -0,0 +1,34 @@
1#!/usr/bin/env python3
2# Copyright 2022 Canonical Ltd.
3# See LICENSE file for licensing details.
4
5import asyncio
6import logging
7from pathlib import Path
8
9import pytest
10import yaml
11from pytest_operator.plugin import OpsTest
12
13logger = logging.getLogger(__name__)
14
15METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
16APP_NAME = METADATA["name"]
17
18
19@pytest.mark.abort_on_fail
20async def test_build_and_deploy(ops_test: OpsTest):
21 """Build the charm-under-test and deploy it together with related charms.
22
23 Assert on the unit status before any relations/configurations take place.
24 """
25 # Build and deploy charm from local source folder
26 charm = await ops_test.build_charm(".")
27
28 # Deploy the charm and wait for active/idle status
29 await asyncio.gather(
30 ops_test.model.deploy(charm, application_name=APP_NAME),
31 ops_test.model.wait_for_idle(
32 apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000
33 ),
34 )
diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py
0new file mode 10064435new file mode 100644
index 0000000..bae3788
--- /dev/null
+++ b/tests/unit/test_charm.py
@@ -0,0 +1,88 @@
1# Copyright 2022 Canonical Ltd.
2# See LICENSE file for licensing details.
3#
4# Learn more about testing at: https://juju.is/docs/sdk/testing
5
6from pathlib import Path
7
8import ops.testing
9import pytest
10from ops.model import ActiveStatus
11from ops.testing import Harness
12
13from charm import ClamAVDatabaseMirrorCharm
14
15# The "fs" fixture is a fake filesystem from pyfakefs; the "fp" fixture is
16# from pytest-subprocess.
17
18
19@pytest.fixture(autouse=True)
20def simulate_can_connect(monkeypatch):
21 # Enable more accurate simulation of container networking.
22 # For more information, see https://juju.is/docs/sdk/testing#heading--simulate-can-connect
23 monkeypatch.setattr(ops.testing, "SIMULATE_CAN_CONNECT", True)
24
25
26@pytest.fixture
27def harness():
28 harness = Harness(ClamAVDatabaseMirrorCharm)
29 harness.begin()
30 yield harness
31 harness.cleanup()
32
33
34def test_install(harness, fs, fp):
35 fs.add_real_directory(harness.charm.charm_dir / "files")
36 fs.add_real_directory(harness.charm.charm_dir / "templates")
37 Path("/lib/systemd/system").mkdir(parents=True)
38 Path("/etc/nginx/sites-available").mkdir(parents=True)
39 Path("/etc/nginx/sites-enabled").mkdir(parents=True)
40 fp.keep_last_process(True)
41 fp.register(
42 ["apt-cache", "show", "clamav-cvdupdate"],
43 stdout="Package: clamav-cvdupdate\nArchitecture: all\nVersion: 0.1\n",
44 )
45 fp.register(
46 ["apt-cache", "show", "nginx"], stdout="Package: nginx\nArchitecture: all\nVersion: 0.2\n"
47 )
48 fp.register([fp.any()])
49
50 harness.charm.on.install.emit()
51
52 assert harness.model.unit.status == ActiveStatus()
53 run_as_ubuntu = ["setpriv", "--reuid", "ubuntu", "--regid", "ubuntu", "--init-groups", "--"]
54 assert list(fp.calls) == [
55 ["apt-get", "update"],
56 ["dpkg", "--print-architecture"],
57 ["dpkg", "-l", "clamav-cvdupdate"],
58 ["dpkg", "--print-architecture"],
59 ["apt-cache", "show", "clamav-cvdupdate"],
60 [
61 "apt-get",
62 "-y",
63 "--option=Dpkg::Options::=--force-confold",
64 "install",
65 "clamav-cvdupdate=0.1",
66 ],
67 ["dpkg", "--print-architecture"],
68 ["dpkg", "-l", "nginx"],
69 ["dpkg", "--print-architecture"],
70 ["apt-cache", "show", "nginx"],
71 [
72 "apt-get",
73 "-y",
74 "--option=Dpkg::Options::=--force-confold",
75 "install",
76 "nginx=0.2",
77 ],
78 ["systemctl", "disable", "--now", "nginx.service"],
79 ["mkdir", "-p", "/srv/clamav-database-mirror/database"],
80 ["chown", "ubuntu:ubuntu", "/srv/clamav-database-mirror/database"],
81 run_as_ubuntu
82 + ["cvdupdate", "config", "set", "--dbdir", "/srv/clamav-database-mirror/database"],
83 ["systemctl", "daemon-reload"],
84 ["systemctl", "start", "cvdupdate.service"],
85 ["systemctl", "enable", "--now", "cvdupdate.timer"],
86 ["systemctl", "enable", "nginx.service"],
87 ["systemctl", "reload-or-restart", "nginx.service"],
88 ]
diff --git a/tox.ini b/tox.ini
0new file mode 10064489new file mode 100644
index 0000000..dc12427
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,73 @@
1# Copyright 2022 Canonical Ltd.
2# See LICENSE file for licensing details.
3
4[tox]
5skipsdist=True
6skip_missing_interpreters = True
7envlist = lint, unit
8
9[vars]
10src_path = {toxinidir}/src/
11tst_path = {toxinidir}/tests/
12all_path = {[vars]src_path} {[vars]tst_path}
13
14[testenv]
15setenv =
16 PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}
17 PYTHONBREAKPOINT=pdb.set_trace
18 PY_COLORS=1
19passenv =
20 PYTHONPATH
21 CHARM_BUILD_DIR
22 MODEL_SETTINGS
23
24[testenv:fmt]
25description = Apply coding style standards to code
26deps =
27 black
28 isort
29commands =
30 isort {[vars]all_path}
31 black {[vars]all_path}
32
33[testenv:lint]
34description = Check code against coding style standards
35deps =
36 black
37 flake8-docstrings
38 flake8-builtins
39 pyproject-flake8
40 pep8-naming
41 isort
42 codespell
43commands =
44 codespell {toxinidir}/. --skip {toxinidir}/.git --skip {toxinidir}/.tox \
45 --skip {toxinidir}/build --skip {toxinidir}/lib --skip {toxinidir}/venv \
46 --skip {toxinidir}/.mypy_cache --skip {toxinidir}/icon.svg
47 # pflake8 wrapper supports config from pyproject.toml
48 pflake8 {[vars]all_path}
49 isort --check-only --diff {[vars]all_path}
50 black --check --diff {[vars]all_path}
51
52[testenv:unit]
53description = Run unit tests
54deps =
55 pyfakefs
56 pytest
57 pytest-subprocess
58 coverage[toml]
59 -r{toxinidir}/requirements.txt
60commands =
61 coverage run --source={[vars]src_path} \
62 -m pytest --ignore={[vars]tst_path}integration -v --tb native -s {posargs}
63 coverage report
64
65[testenv:integration]
66description = Run integration tests
67deps =
68 pytest
69 juju
70 pytest-operator
71 -r{toxinidir}/requirements.txt
72commands =
73 pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs}
diff --git a/update-lib b/update-lib
0new file mode 10075574new file mode 100755
index 0000000..943e8fe
--- /dev/null
+++ b/update-lib
@@ -0,0 +1,4 @@
1#! /bin/sh
2set -e
3
4charmcraft fetch-lib charms.operator_libs_linux.v0.apt

Subscribers

People subscribed via source and target branches