Merge ~woutervb/snap-store-proxy-charm:start into snap-store-proxy-charm:master
- Git
- lp:~woutervb/snap-store-proxy-charm
- start
- Merge into master
Status: | Merged |
---|---|
Approved by: | Wouter van Bommel |
Approved revision: | 839bd6eb5a860ea7100799a9662a1c5984548b70 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~woutervb/snap-store-proxy-charm:start |
Merge into: | snap-store-proxy-charm:master |
Diff against target: |
2325 lines (+2146/-1) 29 files modified
.gitignore (+14/-0) .jujuignore (+5/-0) CONTRIBUTING.md (+35/-0) DEVELOP.md (+20/-0) LICENSE (+1/-1) Makefile (+121/-0) README.md (+81/-0) actions.yaml (+9/-0) charmcraft.yaml (+16/-0) config.yaml (+31/-0) conftest.py (+10/-0) juju/overlay.yaml.example (+4/-0) juju/resource-overlay.yaml (+9/-0) juju/test-bundle.yaml (+20/-0) metadata.yaml (+33/-0) ols-vms.conf (+5/-0) requirements-dev.txt (+14/-0) requirements.txt (+2/-0) setup.cfg (+31/-0) src/charm.py (+425/-0) src/exceptions.py (+23/-0) src/helpers.py (+60/-0) src/optionvalidation.py (+76/-0) src/resource_helpers.py (+71/-0) tests/__init__.py (+0/-0) tests/test_charm.py (+716/-0) tests/test_helpers.py (+56/-0) tests/test_optionvalidation.py (+136/-0) tests/test_resource_helpers.py (+122/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Przemysław Suliga | Approve | ||
Review via email: mp+415435@code.launchpad.net |
Commit message
Start of a basic charm
Start a charm that can be used to manage a snap-store-proxy instance
using juju.
The charm supports multiple ways to register the proxy and depends on a
postgresql relation to be present, as the database is used to store
relevant information.
The charm does not support installing a proxy in offline (detached from
the internet) mode.
Description of the change
Wouter van Bommel (woutervb) wrote : | # |
> lgtm, +1
>
> But noting that there're a bunch of "snapstore-proxy.*" appearing in various
> places. It seems like they could be changed either to snap-store-proxy or
> snap-store-
> new name/spelling: snapstore-proxy.
Thanks, have fixed this before committing
Otto Co-Pilot (otto-copilot) wrote : | # |
Running landing tests failed
https:/
Preview Diff
1 | diff --git a/.gitignore b/.gitignore |
2 | new file mode 100644 |
3 | index 0000000..68bf9ed |
4 | --- /dev/null |
5 | +++ b/.gitignore |
6 | @@ -0,0 +1,14 @@ |
7 | +env/ |
8 | +tmp/ |
9 | +build/ |
10 | +*.charm |
11 | + |
12 | +.coverage |
13 | +__pycache__/ |
14 | +*.py[cod] |
15 | + |
16 | +.pytest_cache/ |
17 | + |
18 | +juju/overlay.yaml |
19 | + |
20 | +*.snap |
21 | diff --git a/.jujuignore b/.jujuignore |
22 | new file mode 100644 |
23 | index 0000000..71c6212 |
24 | --- /dev/null |
25 | +++ b/.jujuignore |
26 | @@ -0,0 +1,5 @@ |
27 | +venv/ |
28 | +*.py[cod] |
29 | +*.charm |
30 | +.pytest_cache/ |
31 | +.git/ |
32 | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md |
33 | new file mode 100644 |
34 | index 0000000..00133f6 |
35 | --- /dev/null |
36 | +++ b/CONTRIBUTING.md |
37 | @@ -0,0 +1,35 @@ |
38 | +# snap-store-proxy-charm |
39 | + |
40 | +## Developing |
41 | + |
42 | +The whole testing / linting and building of the charm is driven via the |
43 | +included Makefile. The most commonly used targets are: |
44 | + |
45 | + * make test ; creates a python virtualenv and will install the |
46 | + dependencies needed to run the tests |
47 | + * make coverage ; similar to the one above, but will also display which |
48 | + lines are not tested |
49 | + * make charm ; will build the charm from the code |
50 | + * make deploy ; build the charm and deploy it using juju in the current |
51 | + model |
52 | + * make upgrade_charm ; build a new version of the charm, if applicable and use |
53 | + this to update the version deployed using the deploy target |
54 | + * make black ; blackify and isort the current codebase |
55 | + * make lint ; check that the code is linted correctly |
56 | + * make clean ; remove all files that can be reproduced with make |
57 | + targets |
58 | + |
59 | +## Intended use case |
60 | + |
61 | +This charm is inteded to be used to deploy and manage the snapstore proxy snap. |
62 | +The snapstore proxy snap allows one to run a proxy, pointing local instances of |
63 | +snapd / ubuntu core instances to this proxy to limit outgoing connections. |
64 | +For more details, please check [the snapstore proxy snap itself](https://docs.ubuntu.com/snap-store-proxy/en/). |
65 | + |
66 | + |
67 | +## Testing |
68 | + |
69 | +The Python operator framework includes a very nice harness for testing |
70 | +operator behaviour without full deployment. Just `make test`: |
71 | + |
72 | + make test |
73 | diff --git a/DEVELOP.md b/DEVELOP.md |
74 | new file mode 100644 |
75 | index 0000000..53f42ae |
76 | --- /dev/null |
77 | +++ b/DEVELOP.md |
78 | @@ -0,0 +1,20 @@ |
79 | +# Development information |
80 | + |
81 | +This project runs its integration tests on a node that has no internet connection. To overcome this for the python libraries, an wheels repository is used, that contains the wheels that this project uses, and that can be installed. |
82 | + |
83 | +So when building on an environment that has internet connectivity it can happen that additional python dependencies are introduced, that will break the landing tests. This is not a problem, but new dependencies need to be explicitly mentioned in the commit, so that appropriate action can be taken. |
84 | + |
85 | + |
86 | +# Virtual environment |
87 | + |
88 | +By default the project creates an virtual environment under the project root, called env. This can be changed, by overriding the variable ENV when invoking make targets. |
89 | + |
90 | + |
91 | +# Most important make targets and their function |
92 | + |
93 | +- test - Test the code |
94 | +- coverage - Test the code and produce a coverage report, target is 100% |
95 | +- deploy - Build the charm and deploy it with default, assumes juju is available |
96 | +- deploy-resource - Downloads some snaps and uses them as resources for a juju deployment. Only works if this machine has internet connectivity, it is not needed for the juju managed machines/containers. |
97 | +- clean - Remove all files that don't belong in the git repository. Does NOT touch the juju model. |
98 | + |
99 | diff --git a/LICENSE b/LICENSE |
100 | index d645695..9d0f8e6 100644 |
101 | --- a/LICENSE |
102 | +++ b/LICENSE |
103 | @@ -187,7 +187,7 @@ |
104 | same "printed page" as the copyright notice for easier |
105 | identification within third-party archives. |
106 | |
107 | - Copyright [yyyy] [name of copyright owner] |
108 | + Copyright 2021 Canonical |
109 | |
110 | Licensed under the Apache License, Version 2.0 (the "License"); |
111 | you may not use this file except in compliance with the License. |
112 | diff --git a/Makefile b/Makefile |
113 | new file mode 100644 |
114 | index 0000000..11342be |
115 | --- /dev/null |
116 | +++ b/Makefile |
117 | @@ -0,0 +1,121 @@ |
118 | +ENV_TMP = $(CURDIR)/env |
119 | +TMPDIR ?= $(CURDIR)/tmp |
120 | +ENV ?= $(ENV_TMP) |
121 | +CHARM_NAME = snap-store-proxy-charm |
122 | +CHARMCRAFT = $(shell which charmcraft) |
123 | +# Releases should match the run-on section in charmcraft.yaml in the same order |
124 | +RELEASES = 20.04 |
125 | +CHARM_FILENAME = $(CHARM_NAME)$(subst $(noop) $(noop),,$(addsuffix -amd64, $(addprefix _ubuntu-, $(RELEASES)))).charm |
126 | +DEPS = $(shell find src -name '*.py') requirements.txt actions.yaml config.yaml metadata.yaml |
127 | + |
128 | +# Needed for Jenkins builds, but defining doesn't hurt |
129 | +OLS_WHEELS_TMP_DIR = $(TMPDIR)/dependencies |
130 | +OLS_WHEELS_DIR ?= $(OLS_WHEELS_TMP_DIR) |
131 | +OLS_WHEELS_REPO ?= lp:~ubuntuone-pqm-team/ols-goodyear/+git/wheels |
132 | + |
133 | +ifndef JENKINS_HOME |
134 | +# If not running under the landing server, don't use the wheel repository |
135 | +PIP_FLAGS := |
136 | +VENV_FLAGS := |
137 | +OLS_WHEELS_DIR := |
138 | +else |
139 | +PIP_FLAGS := --find-links=$(OLS_WHEELS_DIR) --no-index |
140 | +VENV_FLAGS := --no-download --extra-search-dir=$(OLS_WHEELS_DIR) |
141 | +endif |
142 | + |
143 | +.PHONY: charm |
144 | +charm: $(CHARM_FILENAME) |
145 | + |
146 | +# Ensure that the building of the charm depends on the python files that make |
147 | +# up the charm |
148 | +$(CHARM_FILENAME): $(DEPS) $(CHARMCRAFT) |
149 | + $(CHARMCRAFT) pack |
150 | + |
151 | +$(ENV): $(OLS_WHEELS_DIR) requirements-dev.txt requirements.txt |
152 | + @echo Starting a new virtualenv |
153 | + @virtualenv --clear $(VENV_FLAGS) $(ENV) |
154 | + @echo Installing python dependencies |
155 | + @$(ENV)/bin/pip install $(PIP_FLAGS) -r requirements-dev.txt |
156 | + |
157 | +.PHONY: test |
158 | +test: $(ENV) |
159 | + $(ENV)/bin/pytest |
160 | + $(MAKE) lint |
161 | + |
162 | +.PHONY: coverage |
163 | +coverage: $(ENV) |
164 | + $(ENV)/bin/pytest --cov=src --cov-report= |
165 | + $(ENV)/bin/coverage report -m |
166 | + |
167 | +.PHONY: black |
168 | +black: $(ENV) |
169 | + $(ENV)/bin/isort --profile black src tests |
170 | + $(ENV)/bin/black src tests |
171 | + |
172 | +.PHONY: lint |
173 | +lint: $(ENV) |
174 | + @echo Running flake8 |
175 | + @$(ENV)/bin/flake8 src tests |
176 | + @echo Running black |
177 | + @$(ENV)/bin/black --check src tests |
178 | + @echo Running isort |
179 | + @$(ENV)/bin/isort --profile black --check src tests |
180 | + |
181 | +.PHONY: clean |
182 | +clean: $(CHARMCRAFT) |
183 | + @echo "Cleaning the project" |
184 | + @$(CHARMCRAFT) clean |
185 | + @# Delete the helper reference, so we don't kill shared virtualenvs |
186 | + @rm -rf $(ENV_TMP) .pytest_cache build |
187 | + @rm -f $(CHARM_FILENAME) .coverage |
188 | + @rm -f *.snap |
189 | + @rm -fr $(TMPDIR) |
190 | + @-find . -name __pycache__ | xargs rm -rf |
191 | + |
192 | +.PHONY: deploy |
193 | +deploy: $(CHARM_FILENAME) ./juju/test-bundle.yaml ./juju/overlay.yaml |
194 | + @echo deploying the test bundle |
195 | + @juju deploy ./juju/test-bundle.yaml --overlay ./juju/overlay.yaml |
196 | + |
197 | +.PHONY: deploy-resource |
198 | +deploy-resource: $(CHARM_FILENAME) ./juju/test-bundle.yaml ./juju/resource-overlay.yaml ./juju/overlay.yaml core20.snap snap-store-proxy.snap |
199 | + @echo deploying the resouce bundle |
200 | + @juju deploy ./juju/test-bundle.yaml --overlay ./juju/resource-overlay.yaml --overlay ./juju/overlay.yaml |
201 | + |
202 | +.PHONY: upgrade_charm |
203 | +upgrade_charm: $(CHARM_FILENAME) |
204 | + @echo Upgrading the charm with the latest version |
205 | + @juju upgrade-charm snap-store-proxy --path ./$(CHARM_FILENAME) |
206 | + |
207 | + |
208 | +./juju/overlay.yaml: |
209 | + @if [ -f $@ ] ; then \ |
210 | + touch $@ ; \ |
211 | + else \ |
212 | + echo "Please copy the overlay.yaml.example in the juju subdirectory" ;\ |
213 | + echo "and fill in the options to something valid" ;\ |
214 | + exit 1 ;\ |
215 | + fi |
216 | + |
217 | +$(CHARMCRAFT): |
218 | + @if [ -f $@ ] ; then \ |
219 | + exit 0; \ |
220 | + else \ |
221 | + echo "Please install the snap snapcaft via 'sudo snap snapcraft'" ;\ |
222 | + exit 1;\ |
223 | + fi |
224 | + |
225 | + |
226 | +%.snap: |
227 | + @echo Downloading $(@:.snap=) |
228 | + @snap download $(@:.snap=) |
229 | + @echo Removing the assert |
230 | + @rm *.assert |
231 | + @echo Renaming the snap |
232 | + @mv $(@:.snap=)*snap $(@) |
233 | + |
234 | +$(OLS_WHEELS_DIR): |
235 | + git clone $(OLS_WHEELS_REPO) $(OLS_WHEELS_DIR) |
236 | + |
237 | +# The noop below is a helper variable, don't change it to something |
238 | +noop := |
239 | diff --git a/README.md b/README.md |
240 | new file mode 100644 |
241 | index 0000000..2a76997 |
242 | --- /dev/null |
243 | +++ b/README.md |
244 | @@ -0,0 +1,81 @@ |
245 | +# snap-store-proxy-charm |
246 | + |
247 | +## Description |
248 | + |
249 | +This charm will allow the installation and management of the snapstore proxy charm via juju. This means that all needed settings, etc. can be done via a juju installation bundle, allowing for reproducable installations. |
250 | + |
251 | +# Usage |
252 | + |
253 | +Minimal items that have to be set are the domain this proxy runs on, and a username / password, on ubuntu one that correspond to an account that does not have 2fa enabled, and has accepted the developer agreement on snapstore.io. |
254 | + |
255 | +## Example bundle |
256 | + |
257 | +Below is a minimal bundle example that can be used to deploy the charm using juju. |
258 | + |
259 | + applications: |
260 | + postgresql: |
261 | + charm: postgresql |
262 | + channel: stable |
263 | + snap-store-proxy: |
264 | + charm: snap-store-proxy-charm |
265 | + options: |
266 | + registration_bundle: <content of registration file base64 encoded> |
267 | + |
268 | + relations: |
269 | + - - postgresql:db-admin |
270 | + - snap-store-proxy:db-admin |
271 | + |
272 | +# Relations |
273 | + |
274 | +This charm depends on a postgresql database, so this charm should relate to a postgresql database, before anything else can be done. |
275 | + |
276 | +# Actions |
277 | + |
278 | +The charm supports the following actions: |
279 | + |
280 | +* **status** - Get the status result of the proxy, use like; |
281 | + |
282 | + juju run-action snap-store-proxy/leader status --wait |
283 | + |
284 | +# Advanced options |
285 | + |
286 | +## Options |
287 | + |
288 | +There are some mandatory config options that have to be configured for the charm to work, these are: |
289 | + |
290 | +* **registration_bundle** - A bundle created with a snap called `snapstore-admin`. This tool supports accounts with 2 Factor authentication for Ubuntu One accounts, and contains everything to reproduce the proxy. This is the recommended way to configure the proxy. It is also a required way, if the machine on which the proxy is installed does not have an internet connection to the store. Easiest way to provide this option on the cli is the following: |
291 | + |
292 | + juju config snap-store-proxy registration_bundle=$(cat <path to file created with snapstore-client> | base64) |
293 | + |
294 | + |
295 | +Or (deprecated) |
296 | + |
297 | +* **domain** - The domain on which the proxy will listen. This is needed for the registration, and the proxy itself needs to be able to resolve it. Ip addresses don't work. It has to be prepended with the protocol (http). |
298 | +* **username** - This is the username of the Ubuntu One account that will be used to register the proxy |
299 | +* **password** - The password of the above username |
300 | + |
301 | +## Juju resource usage |
302 | + |
303 | +The charm supports 2 modes to install the snapstore proxy code itself. This code is distributed as a snap from the actual snapstore, which means that the unit to which this charm is deployed, will need internet access, or at least access to the actual snapstore. If this is undesired, it is possible to deploy this charm in complete offline mode, which means that the snaps(\*) will need to be added as a resource. |
304 | + |
305 | +(\*) One will need both the `core20.snap` and the `snap-store-proxy.snap` to be added, as the snap-store-proxy.snap depends on the core. |
306 | + |
307 | +An example bundle to do such a deployment will look like the following. |
308 | + |
309 | + applications: |
310 | + postgresql: |
311 | + charm: postgresql |
312 | + channel: stable |
313 | + snap-store-proxy: |
314 | + charm: snap-store-proxy |
315 | + options: |
316 | + registration_bundle: <content of registration file base64 encoded> |
317 | + resources: |
318 | + snap-store-proxy: ./snap-store-proxy.snap |
319 | + core: ./core20.snap |
320 | + |
321 | + relations: |
322 | + - - postgresql:db-admin |
323 | + - snap-store-proxy:db-admin |
324 | + |
325 | +With the above example it is assumed that both the `core20.snap` and the `snap-store-proxy.snap` are available in the current directory. The snaps can be downloaded using the `snap download core20` and `snap download snap-store-proxy` resp. |
326 | diff --git a/actions.yaml b/actions.yaml |
327 | new file mode 100644 |
328 | index 0000000..feb312c |
329 | --- /dev/null |
330 | +++ b/actions.yaml |
331 | @@ -0,0 +1,9 @@ |
332 | +# Copyright 2021 Canonical |
333 | +# See LICENSE file for licensing details. |
334 | +# |
335 | +# Learn more about actions at: https://juju.is/docs/sdk/actions |
336 | +status: |
337 | + description: | |
338 | + Get the status of the proxy services, ie |
339 | + juju run-action snap-store-proxy/leader status --wait |
340 | + |
341 | diff --git a/charmcraft.yaml b/charmcraft.yaml |
342 | new file mode 100644 |
343 | index 0000000..77b5206 |
344 | --- /dev/null |
345 | +++ b/charmcraft.yaml |
346 | @@ -0,0 +1,16 @@ |
347 | +# Copyright 2021 Canonical |
348 | +# See LICENSE file for licensing details. |
349 | +# |
350 | + |
351 | +# Learn more about charmcraft.yaml configuration at: |
352 | +# https://juju.is/docs/sdk/charmcraft-config |
353 | + |
354 | +type: "charm" |
355 | +bases: |
356 | + - build-on: |
357 | + - name: "ubuntu" |
358 | + channel: "20.04" |
359 | + run-on: |
360 | + - name: "ubuntu" |
361 | + channel: "20.04" |
362 | + |
363 | diff --git a/config.yaml b/config.yaml |
364 | new file mode 100644 |
365 | index 0000000..1c131a6 |
366 | --- /dev/null |
367 | +++ b/config.yaml |
368 | @@ -0,0 +1,31 @@ |
369 | +# Copyright 2021 Canonical |
370 | +# See LICENSE file for licensing details. |
371 | +# |
372 | +# We need to ensure that every entry has a value, so that a 'juju reset' is |
373 | +# always possible |
374 | + |
375 | +options: |
376 | + registration_bundle: |
377 | + default: null |
378 | + desciption: | |
379 | + A bundle created via `snapstore-proxy_registration_bundle` added as a base64 |
380 | + encoded string. ie "$(cat <file> | base64)" |
381 | + type: string |
382 | + domain: |
383 | + default: http://localhost.localdomain |
384 | + description: Domain name for the Snap Store Proxy. |
385 | + type: string |
386 | + username: |
387 | + default: null |
388 | + description: | |
389 | + Ubuntu one username used to register this proxy, this account cannot |
390 | + have 2 factor authentication enabled, and needs to have accepted |
391 | + the developer agreement on the snapstore.io website. |
392 | + type: string |
393 | + password: |
394 | + default: null |
395 | + decription: | |
396 | + The passsword the belongs to the Ubuntu one username to register |
397 | + this proxy. |
398 | + type: string |
399 | + |
400 | diff --git a/conftest.py b/conftest.py |
401 | new file mode 100644 |
402 | index 0000000..31b44ee |
403 | --- /dev/null |
404 | +++ b/conftest.py |
405 | @@ -0,0 +1,10 @@ |
406 | +# Copyright 2021 Canonical |
407 | +# See LICENSE file for licensing details. |
408 | +# |
409 | + |
410 | +import pytest |
411 | + |
412 | + |
413 | +@pytest.fixture(autouse=True) |
414 | +def dummy_install_from_the_charmstore(mocker): |
415 | + mocker.patch("charm.install_from_the_charmstore") |
416 | diff --git a/juju/overlay.yaml.example b/juju/overlay.yaml.example |
417 | new file mode 100644 |
418 | index 0000000..d6f4220 |
419 | --- /dev/null |
420 | +++ b/juju/overlay.yaml.example |
421 | @@ -0,0 +1,4 @@ |
422 | +applications: |
423 | + snap-store-proxy: |
424 | + options: |
425 | + registration_bundle: `cat <registation file> | base64` |
426 | diff --git a/juju/resource-overlay.yaml b/juju/resource-overlay.yaml |
427 | new file mode 100644 |
428 | index 0000000..317f11b |
429 | --- /dev/null |
430 | +++ b/juju/resource-overlay.yaml |
431 | @@ -0,0 +1,9 @@ |
432 | +# Copyright 2021 Canonical |
433 | +# See LICENSE file for licensing details. |
434 | +# |
435 | + |
436 | +applications: |
437 | + snap-store-proxy: |
438 | + resources: |
439 | + snap-store-proxy: ../snap-store-proxy.snap |
440 | + core: ../core20.snap |
441 | diff --git a/juju/test-bundle.yaml b/juju/test-bundle.yaml |
442 | new file mode 100644 |
443 | index 0000000..0d8ed91 |
444 | --- /dev/null |
445 | +++ b/juju/test-bundle.yaml |
446 | @@ -0,0 +1,20 @@ |
447 | +# Copyright 2021 Canonical |
448 | +# See LICENSE file for licensing details. |
449 | +# |
450 | + |
451 | +applications: |
452 | + postgresql: |
453 | + charm: cs:postgresql |
454 | + channel: stable |
455 | + resources: |
456 | + wal-e: 0 |
457 | + num_units: 1 |
458 | + snap-store-proxy: |
459 | + charm: ../snap-store-proxy-charm_ubuntu-20.04-amd64.charm |
460 | + num_units: 1 |
461 | + |
462 | + |
463 | +relations: |
464 | +- - postgresql:db-admin |
465 | + - snap-store-proxy:db-admin |
466 | + |
467 | diff --git a/metadata.yaml b/metadata.yaml |
468 | new file mode 100644 |
469 | index 0000000..d4997d2 |
470 | --- /dev/null |
471 | +++ b/metadata.yaml |
472 | @@ -0,0 +1,33 @@ |
473 | +# Copyright 2021 Canonical |
474 | +# See LICENSE file for licensing details. |
475 | + |
476 | +# For a complete list of supported options, see: |
477 | +# https://discourse.charmhub.io/t/charm-metadata-v2/3674/15 |
478 | +name: snap-store-proxy-charm |
479 | +description: | |
480 | + Install and manage a snap-store-proxy on premise. |
481 | +summary: | |
482 | + The Snap Store Proxy provides an on-premise edge proxy to the general Snap Store for your devices. Devices are registered with the proxy, and all communication with the Store will flow through the proxy. |
483 | +maintainers: |
484 | + - Wouter van Bommel <wouter.bommel>@canonical.com |
485 | +tags: |
486 | + - Snap store proxy |
487 | + |
488 | +subordinate: false |
489 | + |
490 | +requires: |
491 | + db-admin: |
492 | + interface: pgsql |
493 | + limit: 1 |
494 | + desciprtion: Postgresql database used for charm administration, needs to connect to the db-admin interface |
495 | + |
496 | +resources: |
497 | + snap-store-proxy: |
498 | + type: file |
499 | + filename: snap-store-proxy.snap |
500 | + description: Snapstore Proxy Snap, used for offline / airgapped installation |
501 | + |
502 | + core: |
503 | + type: file |
504 | + filename: core20.snap |
505 | + description: Core snap needed for the snap-store-proxy.snap |
506 | diff --git a/ols-vms.conf b/ols-vms.conf |
507 | new file mode 100644 |
508 | index 0000000..0ff8c80 |
509 | --- /dev/null |
510 | +++ b/ols-vms.conf |
511 | @@ -0,0 +1,5 @@ |
512 | +vm.architecture = amd64 |
513 | +vm.release = focal |
514 | + |
515 | +[snap-store-proxy-charm] |
516 | +vm.packages = build-essential, virtualenv |
517 | diff --git a/requirements-dev.txt b/requirements-dev.txt |
518 | new file mode 100644 |
519 | index 0000000..0948d86 |
520 | --- /dev/null |
521 | +++ b/requirements-dev.txt |
522 | @@ -0,0 +1,14 @@ |
523 | +# Copyright 2021 Canonical |
524 | +# See LICENSE file for licensing details. |
525 | +-r requirements.txt |
526 | +coverage |
527 | +flake8 |
528 | +black |
529 | +flake8-black |
530 | +pytest<7 # Needed due to pytest-pythonpath |
531 | +pytest-pythonpath |
532 | +pytest-mock |
533 | +pytest-xdist |
534 | +pytest-sugar |
535 | +pytest-cov |
536 | +pytest-isort |
537 | diff --git a/requirements.txt b/requirements.txt |
538 | new file mode 100644 |
539 | index 0000000..c29abf0 |
540 | --- /dev/null |
541 | +++ b/requirements.txt |
542 | @@ -0,0 +1,2 @@ |
543 | +ops >= 1.2.0 |
544 | +ops-lib-pgsql |
545 | diff --git a/setup.cfg b/setup.cfg |
546 | new file mode 100644 |
547 | index 0000000..8e3f700 |
548 | --- /dev/null |
549 | +++ b/setup.cfg |
550 | @@ -0,0 +1,31 @@ |
551 | +[flake8] |
552 | +max-line-length = 99 |
553 | +select: E,W,F,C,N |
554 | +exclude: |
555 | + venv |
556 | + .git |
557 | + build |
558 | + dist |
559 | + *.egg_info |
560 | +extend-ignore = |
561 | + E203 |
562 | + |
563 | +[coverage:report] |
564 | +# Regexes for lines to exclude from consideration |
565 | +exclude_lines = |
566 | + # Have to re-enable the standard pragma |
567 | + pragma: no cover |
568 | + |
569 | + # Don't complain if non-runnable code isn't run: |
570 | + if 0: |
571 | + if __name__ == .__main__.: |
572 | + |
573 | +ignore_errors = True |
574 | + |
575 | +[tool:pytest] |
576 | +python_paths = lib src |
577 | +addopts = --numprocesses auto --showlocals --isort |
578 | + |
579 | +[isort] |
580 | +profile = black |
581 | + |
582 | diff --git a/src/charm.py b/src/charm.py |
583 | new file mode 100755 |
584 | index 0000000..65eeb23 |
585 | --- /dev/null |
586 | +++ b/src/charm.py |
587 | @@ -0,0 +1,425 @@ |
588 | +#!/usr/bin/env python3 |
589 | +# Copyright 2021 Canonical |
590 | +# See LICENSE file for licensing details. |
591 | +# |
592 | +# Learn more at: https://juju.is/docs/sdk |
593 | +# |
594 | + |
595 | +import base64 |
596 | +import hashlib |
597 | +import json |
598 | +import logging |
599 | +from urllib.parse import urlparse |
600 | + |
601 | +from ops.charm import CharmBase |
602 | +from ops.framework import StoredState |
603 | +from ops.lib import use |
604 | +from ops.main import main |
605 | +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, ModelError |
606 | + |
607 | +from exceptions import ConfigException |
608 | +from helpers import all_config_options, configure_proxy, default_values |
609 | +from optionvalidation import OptionValidation |
610 | +from resource_helpers import ( |
611 | + create_database, |
612 | + get_status, |
613 | + hash_from_resource, |
614 | + install_from_resource, |
615 | + install_from_the_charmstore, |
616 | + register_store, |
617 | + snap_details, |
618 | +) |
619 | + |
620 | +logger = logging.getLogger(__name__) |
621 | + |
622 | + |
623 | +pgsql = use("pgsql", 1, "postgresql-charmers@lists.launchpad.net") |
624 | + |
625 | +DATABASE_NAME = "snap-store-proxy" |
626 | + |
627 | + |
628 | +class SnapstoreProxyCharm(CharmBase): |
629 | + """Charm the service.""" |
630 | + |
631 | + _stored = StoredState() |
632 | + |
633 | + def __init__(self, *args): |
634 | + super().__init__(*args) |
635 | + |
636 | + self.errors = [] |
637 | + |
638 | + self.framework.observe(self.on.config_changed, self._on_config_changed) |
639 | + self.framework.observe(self.on.update_status, self._on_update_status) |
640 | + |
641 | + # Database stuff |
642 | + self._stored.set_default(db_uri=None) |
643 | + self.db = pgsql.PostgreSQLClient( |
644 | + self, "db-admin" |
645 | + ) # 'db-admin' relation in metadata.yaml |
646 | + self.framework.observe( |
647 | + self.db.on.database_relation_joined, self._on_database_relation_joined |
648 | + ) |
649 | + self.framework.observe( |
650 | + self.db.on.master_changed, self._on_database_master_changed |
651 | + ) |
652 | + self.framework.observe( |
653 | + self.db.on.database_relation_broken, self._on_database_relation_broken |
654 | + ) |
655 | + |
656 | + # Actions |
657 | + self.framework.observe(self.on.status_action, self._on_status_action) |
658 | + |
659 | + def _on_config_changed(self, event): |
660 | + """Handle update on the configuration of the proxy. |
661 | + |
662 | + Here we handle changes to: |
663 | + * domain |
664 | + * username |
665 | + * password |
666 | + """ |
667 | + self.unit.status = MaintenanceStatus("Installing application snap") |
668 | + self.handle_installation() |
669 | + |
670 | + # Wait until there is a database relation ready, skip everything if not |
671 | + if self._stored.db_uri is None: |
672 | + logger.debug("Could not find a value for the database uri") |
673 | + self.evaluate_status() |
674 | + event.defer() |
675 | + return |
676 | + |
677 | + if getattr(self._stored, "snap_install_source", None) is None: |
678 | + self.errors.append("Snap has not been installed") |
679 | + logger.warning("Store is not installed") |
680 | + self.evaluate_status() |
681 | + event.defer() |
682 | + return |
683 | + |
684 | + self.handle_pgsql_dsn_change() |
685 | + |
686 | + self.unit.status = MaintenanceStatus("Configuring application snap") |
687 | + |
688 | + # Handle the validation and interal storage of the configuration options |
689 | + logger.info("Parsing configuration options") |
690 | + try: |
691 | + self.validate_config_item() |
692 | + except ConfigException as exc: |
693 | + self.errors.append(f"Could not parse the config option for {exc.message}") |
694 | + self.evaluate_status() |
695 | + return |
696 | + else: |
697 | + # With config options stored, we can try to configure the snap |
698 | + for option in all_config_options: |
699 | + |
700 | + if getattr(self._stored, "registered", False): |
701 | + # We are registered, so we hold any updates of the domain |
702 | + if option == "domain": |
703 | + continue |
704 | + |
705 | + if option in self.config: |
706 | + value = self.config[option] |
707 | + logger.debug(f"Storing {value} in {option}") |
708 | + self._stored.__setattr__(option, value) |
709 | + |
710 | + # Then configure it, and only then we can attempt to configure it |
711 | + self.handle_config() |
712 | + |
713 | + if not getattr(self._stored, "registered", False): |
714 | + self.unit.status = MaintenanceStatus("Registering proxy") |
715 | + self.handle_registration(event) |
716 | + |
717 | + self.evaluate_status() |
718 | + |
719 | + def validate_config_item(self): |
720 | + # We have some dependencie between the tls certificate and the domain |
721 | + for config_name, test_type in all_config_options.items(): |
722 | + if config_name in self.config: |
723 | + if self.config[config_name]: |
724 | + # Skip None values |
725 | + test = OptionValidation.new(test_type) |
726 | + test.validate(config_name, self.config[config_name]) |
727 | + |
728 | + def handle_installation(self): |
729 | + """Install the snap-store-proxy. |
730 | + |
731 | + We can install updates if provided via resources, but we cannot switch |
732 | + between resource and store installation""" |
733 | + |
734 | + # If we have the attribute snap_install_source with the value set to resource, |
735 | + # or it is absent, we can try to install from a resource |
736 | + if getattr(self._stored, "snap_install_source", None) in [None, "resource"]: |
737 | + try: |
738 | + snap = self.model.resources.fetch("snap-store-proxy") |
739 | + core = self.model.resources.fetch("core") |
740 | + snap_md5 = hash_from_resource(snap) |
741 | + core_md5 = hash_from_resource(core) |
742 | + if (getattr(self._stored, "snap_md5", None) != snap_md5) or ( |
743 | + getattr(self._stored, "core_md5", None) != core_md5 |
744 | + ): |
745 | + install_from_resource(core, snap) |
746 | + self._stored.core_md5 = core_md5 |
747 | + self._stored.snap_md5 = snap_md5 |
748 | + self._stored.snap_install_source = "resource" |
749 | + logger.info("Installed the proxy from the resource") |
750 | + except ModelError: |
751 | + logger.info("Could not install snap from resource, attempting online") |
752 | + |
753 | + if not hasattr(self._stored, "snap_install_source"): |
754 | + try: |
755 | + install_from_the_charmstore("snap-store-proxy") |
756 | + self._stored.snap_install_source = "store" |
757 | + except Exception: |
758 | + logger.info( |
759 | + "Could not install online, retry again if resources are updated" |
760 | + ) |
761 | + self.errors.append("Failed to install the snap-store-proxy") |
762 | + |
763 | + def handle_config(self): |
764 | + |
765 | + # We will store dicts of item, potential new value in this list |
766 | + to_update = [] |
767 | + |
768 | + for item in all_config_options.keys(): |
769 | + logger.debug(f"Testing config item {item} for a new value") |
770 | + |
771 | + # try to get the value from the charm config, if set |
772 | + new_value = getattr(self._stored, item, default_values.get(item, None)) |
773 | + if new_value is None: |
774 | + # No value set, or default for this item, skip |
775 | + logger.debug(f"No default for {item}, skipping") |
776 | + continue |
777 | + |
778 | + # Now see if we have already stored it. |
779 | + current_md5 = getattr(self._stored, f"{item}_md5", None) |
780 | + new_md5 = hashlib.md5(new_value.encode()).hexdigest() |
781 | + if current_md5 is None: |
782 | + # A new entry, so we handle it |
783 | + to_update.append({"name": item, "value": new_value, "md5": new_md5}) |
784 | + logger.info(f"Adding new item {item}") |
785 | + elif current_md5 != new_md5: |
786 | + # a different md5, so we update |
787 | + to_update.append({"name": item, "value": new_value, "md5": new_md5}) |
788 | + logger.info(f"Updating value for {item}") |
789 | + else: |
790 | + logger.info(f"Skipping update for {item}, no new value") |
791 | + |
792 | + self.do_snap_updates(to_update) |
793 | + |
794 | + def do_snap_updates(self, updates): |
795 | + """Here we handle the updates of the proxy snap. |
796 | + |
797 | + A different method, as it will contain mappings between items and the |
798 | + actual snap entries""" |
799 | + |
800 | + has_registration_bundle = False |
801 | + for item in updates: |
802 | + has_registration_bundle |= item["name"] == "registration_bundle" |
803 | + |
804 | + # If the has_registration_bundle is true, we omit changes to the domain |
805 | + for item in updates: |
806 | + if item["name"] == "domain": |
807 | + logger.info("Configuring the domain") |
808 | + # Handle the storage of the domain |
809 | + # but we cannot actually store the url, so we need to break it |
810 | + # down to the hostname only |
811 | + hostname = urlparse(item["value"]).netloc.split(":")[0] |
812 | + configure_proxy("proxy.domain", hostname) |
813 | + else: |
814 | + logger.debug(f"Nothing todo for {item['name']}") |
815 | + |
816 | + # Ensure we record the values in the charm storage |
817 | + setattr(self._stored, item["name"], item["value"]) |
818 | + setattr(self._stored, f"{item['name']}_md5", item["value"]) |
819 | + |
820 | + if has_registration_bundle: |
821 | + # If this config option is set, we basically ignore the username and |
822 | + # password and domain settings. And we take all from this field. |
823 | + for item in updates: |
824 | + if item["name"] == "registration_bundle": |
825 | + break |
826 | + if item["value"]: |
827 | + # A bit harsh, but this way we ensure that only this bundle is used |
828 | + self._stored.registered = True |
829 | + else: |
830 | + # The value is removed / reset, so act accordingly |
831 | + self._stored.registered = False |
832 | + return |
833 | + |
834 | + data = json.loads(base64.b64decode(item["value"])) |
835 | + for entries in [ |
836 | + "domain", |
837 | + "private_key", |
838 | + "public_key", |
839 | + "store_id", |
840 | + # "store_assertion", needed once airgap mode is implemented |
841 | + ]: |
842 | + # see if any of these keys are missing |
843 | + if entries not in data.keys(): |
844 | + self.errors.append( |
845 | + f"Missing key {entries} in the registration bundle" |
846 | + ) |
847 | + |
848 | + if self.errors: |
849 | + # Incomplete bundle(?) so stop doing anything |
850 | + return |
851 | + |
852 | + # Now make 2 lists, option names and values where the indexes |
853 | + # correspond with eachother |
854 | + options = [] |
855 | + values = [] |
856 | + for entry in data.keys(): |
857 | + if entry == "domain": |
858 | + options.append("proxy.domain") |
859 | + values.append(data[entry]) |
860 | + elif entry == "public_key": |
861 | + options.append("proxy.key.public") |
862 | + values.append(data[entry]) |
863 | + elif entry == "private_key": |
864 | + options.append("proxy.key.private") |
865 | + values.append(data[entry]) |
866 | + elif entry == "store_id": |
867 | + options.append("internal.store.id") |
868 | + values.append(data[entry]) |
869 | + configure_proxy(options, values, force=True) |
870 | + |
871 | + def handle_registration(self, event): |
872 | + """Try to register the store. |
873 | + |
874 | + Depends on both a username and password provided for the registration. |
875 | + """ |
876 | + if getattr(self._stored, "registered", False): |
877 | + logger.info("This proxy is already registered") |
878 | + return |
879 | + |
880 | + if self._stored.db_uri is None: |
881 | + logger.info("The database relation is not yet setup") |
882 | + # defer, so we get retried in the future |
883 | + event.defer() |
884 | + return |
885 | + |
886 | + if ( |
887 | + getattr(self._stored, "username", None) is None |
888 | + or getattr(self._stored, "password", None) is None |
889 | + ): |
890 | + logger.info("Missing username or password, unable to register") |
891 | + return |
892 | + |
893 | + if getattr(self._stored, "domain", None) is None: |
894 | + logger.info("Missing domain needed to register") |
895 | + return |
896 | + |
897 | + registered = register_store(self._stored.username, self._stored.password) |
898 | + if registered: |
899 | + logger.info("Proxy is registered successfully") |
900 | + else: |
901 | + logger.info("Failed to register, check logging to find the reason") |
902 | + self._stored.registered = registered |
903 | + |
904 | + def handle_pgsql_dsn_change(self): |
905 | + """If the database is configured, then we try to configure the snap.""" |
906 | + installation_source = getattr(self._stored, "snap_install_source", None) |
907 | + database_url = getattr(self._stored, "db_uri", None) |
908 | + database_created = getattr(self._stored, "database_created", False) |
909 | + if installation_source and database_url and not database_created: |
910 | + logger.info("Handling the database configuration of the proxy") |
911 | + if not hasattr(self._stored, "database_created"): |
912 | + create_database(self._stored.db_uri) |
913 | + self._stored.database_created = True |
914 | + |
915 | + def evaluate_status(self): |
916 | + """This is the only place where we can set the status to Active""" |
917 | + |
918 | + if self.errors: |
919 | + self.unit.status = BlockedStatus("\n".join(self.errors).strip()) |
920 | + return |
921 | + |
922 | + if self._stored.db_uri is None: |
923 | + self.unit.status = BlockedStatus("Missing database relation") |
924 | + return |
925 | + |
926 | + if not hasattr(self._stored, "username") and ( |
927 | + not getattr(self._stored, "registered", False) |
928 | + ): |
929 | + self.unit.status = BlockedStatus( |
930 | + "Missing username, needed for registration" |
931 | + ) |
932 | + return |
933 | + |
934 | + if not hasattr(self._stored, "password") and ( |
935 | + not getattr(self._stored, "registered", False) |
936 | + ): |
937 | + self.unit.status = BlockedStatus( |
938 | + "Missing password, needed for registration" |
939 | + ) |
940 | + return |
941 | + |
942 | + if not getattr(self._stored, "registered", False): |
943 | + self.unit.status = BlockedStatus( |
944 | + "The unit is not registered, please supply username and password" |
945 | + ) |
946 | + return |
947 | + |
948 | + self.unit.status = ActiveStatus() |
949 | + |
950 | + def _on_update_status(self, _): |
951 | + |
952 | + # Only the leader should update, to prevent conflicting updates |
953 | + if not self.unit.is_leader(): |
954 | + return |
955 | + |
956 | + version = "unknown" |
957 | + try: |
958 | + (name, version, snap_revision, channel, publisher, notes) = snap_details( |
959 | + "snap-store-proxy" |
960 | + ) |
961 | + except Exception as exc: |
962 | + logger.info("Failure during determination of snap version", exc_info=exc) |
963 | + logger.info(f"Setting version to {version}") |
964 | + self.unit.set_workload_version(f"{version}") |
965 | + |
966 | + def _on_database_relation_joined(self, event: pgsql.DatabaseRelationJoinedEvent): |
967 | + logger.debug("Database relation joined") |
968 | + if self.unit.is_leader(): |
969 | + logger.info("As leader informing the database of our wishes.") |
970 | + # Provide requirements to the PostgreSQL server. |
971 | + event.database = DATABASE_NAME # Request database named mydbname |
972 | + event.extensions = [ |
973 | + "btree_gist", |
974 | + ] # Request the btree_gist extension installed |
975 | + elif event.database != DATABASE_NAME: |
976 | + logger.info("Got some database event, but not for us retrying.") |
977 | + # Leader has not yet set requirements. Defer, incase this unit |
978 | + # becomes leader and needs to perform that operation. |
979 | + event.defer() |
980 | + return |
981 | + |
982 | + def _on_database_master_changed(self, event: pgsql.MasterChangedEvent): |
983 | + logger.debug("Called due to database master change") |
984 | + if event.database != DATABASE_NAME: |
985 | + # Leader has not yet set requirements. Wait until next event, |
986 | + # or risk connecting to an incorrect database. |
987 | + logger.info("Database is not yet setup by master, waiting for a new event.") |
988 | + return |
989 | + |
990 | + if event.master is not None: |
991 | + logger.info("The database connection is ready, configuring proxy.") |
992 | + self._stored.db_uri = event.master.uri |
993 | + self.handle_pgsql_dsn_change() |
994 | + |
995 | + self.evaluate_status() |
996 | + |
997 | + def _on_database_relation_broken(self, event): |
998 | + logger.debug("Erasing the database connection string.") |
999 | + self._stored.db_uri = None |
1000 | + |
1001 | + self.evaluate_status() |
1002 | + |
1003 | + def _on_status_action(self, event): |
1004 | + output, exitcode = get_status() |
1005 | + if exitcode == 0: |
1006 | + event.set_results({"result": output}) |
1007 | + else: |
1008 | + event.fail(f"Failed to get status, errorcode {exitcode}\n, result {output}") |
1009 | + |
1010 | + |
1011 | +if __name__ == "__main__": |
1012 | + main(SnapstoreProxyCharm) |
1013 | diff --git a/src/exceptions.py b/src/exceptions.py |
1014 | new file mode 100644 |
1015 | index 0000000..661a7c4 |
1016 | --- /dev/null |
1017 | +++ b/src/exceptions.py |
1018 | @@ -0,0 +1,23 @@ |
1019 | +# Copyright 2021 Canonical |
1020 | +# See LICENSE file for licensing details. |
1021 | +# |
1022 | + |
1023 | + |
1024 | +class ConfigException(Exception): |
1025 | + def __init__(self, message): |
1026 | + self.message = message |
1027 | + |
1028 | + |
1029 | +class UnknownTypeException(ConfigException): |
1030 | + def __init__(self, message): |
1031 | + super().__init__( |
1032 | + f"Unknown config type defined '{message}', don't know how to validate input." |
1033 | + ) |
1034 | + |
1035 | + |
1036 | +class InvalidTypeException(ConfigException): |
1037 | + def __init__(self, message, expected, value): |
1038 | + super().__init__( |
1039 | + f"Charm option '{message}' contains the wrong datatype. " |
1040 | + f"Expected type {expected}, given {value}" |
1041 | + ) |
1042 | diff --git a/src/helpers.py b/src/helpers.py |
1043 | new file mode 100644 |
1044 | index 0000000..07cf19c |
1045 | --- /dev/null |
1046 | +++ b/src/helpers.py |
1047 | @@ -0,0 +1,60 @@ |
1048 | +# Copyright 2021 Canonical |
1049 | +# See LICENSE file for licensing details. |
1050 | +# |
1051 | +import logging |
1052 | +from pathlib import Path |
1053 | +from subprocess import run |
1054 | + |
1055 | +import yaml |
1056 | + |
1057 | +logger = logging.getLogger(__name__) |
1058 | + |
1059 | + |
1060 | +def configure_proxy(option, value, force=False): |
1061 | + """Simple wrapper around calling snap settings""" |
1062 | + |
1063 | + if isinstance(option, list): |
1064 | + # Make sure that we set multiple values in one go |
1065 | + combined = [] |
1066 | + for count, entry in enumerate(option): |
1067 | + combined.append(f'{entry}="{value[count]}"') |
1068 | + command = ["snap-store-proxy", "config"] + combined |
1069 | + else: |
1070 | + logger.debug(f"Running config for option {option} with value {value}") |
1071 | + command = ["snap-store-proxy", "config", f"{option}={value}"] |
1072 | + |
1073 | + if force: |
1074 | + command.append("--force") |
1075 | + |
1076 | + run(command) |
1077 | + |
1078 | + |
1079 | +def config_options(): |
1080 | + """Helper function to take the charm config.yaml and make it a dict. |
1081 | + |
1082 | + Will return a dict with the types expected for the config items and a dict |
1083 | + with the default values (if any) for the config item. |
1084 | + Goal is to prevent duplication of this type of registration.""" |
1085 | + |
1086 | + config_file = Path(__file__).parent.parent / "config.yaml" |
1087 | + with open(config_file) as fh: |
1088 | + config = yaml.safe_load(fh) |
1089 | + type_dict = {} |
1090 | + default_value = {} |
1091 | + for item in config["options"].keys(): |
1092 | + default = config["options"][item]["default"] |
1093 | + if default: |
1094 | + default_value.update({item: default}) |
1095 | + if item == "domain": |
1096 | + type_dict.update({item: "url"}) |
1097 | + elif item == "username": |
1098 | + type_dict.update({item: "email"}) |
1099 | + elif item == "registration_bundle": |
1100 | + type_dict.update({item: "base64+json"}) |
1101 | + else: |
1102 | + type_dict.update({item: config["options"][item]["type"]}) |
1103 | + |
1104 | + return type_dict, default_value |
1105 | + |
1106 | + |
1107 | +all_config_options, default_values = config_options() |
1108 | diff --git a/src/optionvalidation.py b/src/optionvalidation.py |
1109 | new file mode 100644 |
1110 | index 0000000..2e8efae |
1111 | --- /dev/null |
1112 | +++ b/src/optionvalidation.py |
1113 | @@ -0,0 +1,76 @@ |
1114 | +# Copyright 2021 Canonical |
1115 | +# See LICENSE file for licensing details. |
1116 | +# |
1117 | + |
1118 | +import base64 |
1119 | +import json |
1120 | +import re |
1121 | +from logging import Logger |
1122 | + |
1123 | +from exceptions import InvalidTypeException, UnknownTypeException |
1124 | + |
1125 | +logger = Logger(__name__) |
1126 | + |
1127 | + |
1128 | +class OptionValidation: |
1129 | + @staticmethod |
1130 | + def new(test_type): |
1131 | + if test_type == "string": |
1132 | + return OptionValidationString() |
1133 | + if test_type == "url": |
1134 | + return OptionValidationURL() |
1135 | + if test_type == "email": |
1136 | + return OptionValidationEmail() |
1137 | + if test_type == "base64+json": |
1138 | + return OptionValidationBase64Json() |
1139 | + raise UnknownTypeException(test_type) |
1140 | + |
1141 | + |
1142 | +class OptionValidationString(OptionValidation): |
1143 | + @staticmethod |
1144 | + def validate(config_name, value): |
1145 | + logger.debug("Validating the type string for {config_name}") |
1146 | + if not isinstance(value, str): |
1147 | + raise InvalidTypeException(config_name, "string", value) |
1148 | + |
1149 | + |
1150 | +class OptionValidationURL(OptionValidation): |
1151 | + @staticmethod |
1152 | + def validate(config_name, value): |
1153 | + logger.debug("Validating the type url for {config_name}") |
1154 | + regex = re.compile( |
1155 | + r"^(?:http)://" # http:// or https:// |
1156 | + r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|" |
1157 | + r"[A-Z0-9-]{2,}\.?)|" # domain... |
1158 | + r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip |
1159 | + r"(?::\d+)?" # optional port |
1160 | + r"(?:/?|[/?]\S+)$", |
1161 | + re.IGNORECASE, |
1162 | + ) |
1163 | + if re.match(regex, value) is None: |
1164 | + raise InvalidTypeException(config_name, "url", value) |
1165 | + |
1166 | + |
1167 | +class OptionValidationEmail(OptionValidation): |
1168 | + @staticmethod |
1169 | + def validate(config_name, value): |
1170 | + logger.debug("Validating the type email for {config_name}") |
1171 | + regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" |
1172 | + if (not isinstance(value, str)) or (not re.fullmatch(regex, value)): |
1173 | + raise InvalidTypeException(config_name, "email", value) |
1174 | + |
1175 | + |
1176 | +class OptionValidationBase64Json(OptionValidation): |
1177 | + @staticmethod |
1178 | + def validate(config_name, value): |
1179 | + logger.debug("Validating the type base64+json for {config_name}") |
1180 | + try: |
1181 | + result = base64.b64decode(value) |
1182 | + print(result) |
1183 | + except Exception: |
1184 | + raise InvalidTypeException(config_name, "base64", value) |
1185 | + |
1186 | + try: |
1187 | + json.loads(result) |
1188 | + except Exception: |
1189 | + raise InvalidTypeException(config_name, "json", value) |
1190 | diff --git a/src/resource_helpers.py b/src/resource_helpers.py |
1191 | new file mode 100644 |
1192 | index 0000000..007683b |
1193 | --- /dev/null |
1194 | +++ b/src/resource_helpers.py |
1195 | @@ -0,0 +1,71 @@ |
1196 | +# Copyright 2021 Canonical |
1197 | +# See LICENSE file for licensing details. |
1198 | +# |
1199 | + |
1200 | +import hashlib |
1201 | +import logging |
1202 | +from subprocess import PIPE, CalledProcessError, check_output, run |
1203 | + |
1204 | +logger = logging.getLogger(__name__) |
1205 | + |
1206 | + |
1207 | +def install_from_the_charmstore(snap, channel="stable"): |
1208 | + return run(["snap", "install", f"--channel={channel}", snap], check=True) |
1209 | + |
1210 | + |
1211 | +def install_from_resource(core, proxy): |
1212 | + run(["snap", "install", "--dangerous", core], check=True) |
1213 | + run(["snap", "install", "--dangerous", proxy], check=True) |
1214 | + |
1215 | + |
1216 | +def hash_from_resource(resource): |
1217 | + with open(resource, mode="rb") as fh: |
1218 | + hash = hashlib.md5() |
1219 | + while True: |
1220 | + buf = fh.read(4096) |
1221 | + if not buf: |
1222 | + break |
1223 | + hash.update(buf) |
1224 | + return hash.hexdigest() |
1225 | + |
1226 | + |
1227 | +def snap_details(snap): |
1228 | + return ( |
1229 | + run(["snap", "list", snap], stdout=PIPE) |
1230 | + .stdout.decode() |
1231 | + .strip() |
1232 | + .split("\n")[-1] |
1233 | + .split() |
1234 | + ) |
1235 | + |
1236 | + |
1237 | +def register_store(username, password): |
1238 | + """Use the provided username and password in an attempt to register. |
1239 | + |
1240 | + Returns a boolean indicating success or failure""" |
1241 | + env = { |
1242 | + "SNAPSTORE_EMAIL": username, |
1243 | + "SNAPSTORE_PASSWORD": password, |
1244 | + } |
1245 | + try: |
1246 | + check_output( |
1247 | + ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env |
1248 | + ) |
1249 | + except CalledProcessError as error: |
1250 | + logger.error(f"Registration of proxy failed, error; {error.output}") |
1251 | + return False |
1252 | + else: |
1253 | + logger.info("Proxy registred sucessfully") |
1254 | + return True |
1255 | + |
1256 | + |
1257 | +def create_database(uri): |
1258 | + """Let the proxy configure the database.""" |
1259 | + run(["/snap/bin/snap-store-proxy", "create-database", uri]) |
1260 | + |
1261 | + |
1262 | +def get_status(): |
1263 | + """Return the status result and exitcode""" |
1264 | + result = run(["/snap/bin/snap-store-proxy", "status"], capture_output=True) |
1265 | + |
1266 | + return result.stdout.decode("utf-8"), result.returncode |
1267 | diff --git a/tests/__init__.py b/tests/__init__.py |
1268 | new file mode 100644 |
1269 | index 0000000..e69de29 |
1270 | --- /dev/null |
1271 | +++ b/tests/__init__.py |
1272 | diff --git a/tests/test_charm.py b/tests/test_charm.py |
1273 | new file mode 100644 |
1274 | index 0000000..6dc62f1 |
1275 | --- /dev/null |
1276 | +++ b/tests/test_charm.py |
1277 | @@ -0,0 +1,716 @@ |
1278 | +# Copyright 2021 Canonical |
1279 | +# See LICENSE file for licensing details. |
1280 | +# |
1281 | +# Learn more about testing at: https://juju.is/docs/sdk/testing |
1282 | +# |
1283 | + |
1284 | +import base64 |
1285 | +import logging |
1286 | +import subprocess |
1287 | +from pathlib import Path |
1288 | +from unittest.mock import MagicMock, patch |
1289 | + |
1290 | +import pytest |
1291 | +import yaml |
1292 | +from ops.model import ActiveStatus, BlockedStatus |
1293 | +from ops.testing import Harness |
1294 | + |
1295 | +from charm import DATABASE_NAME, SnapstoreProxyCharm |
1296 | + |
1297 | + |
1298 | +class CharmTest: |
1299 | + def setup_method(self): |
1300 | + self.harness = Harness(SnapstoreProxyCharm) |
1301 | + self.harness.begin() |
1302 | + cfg = yaml.safe_load(Path("config.yaml").read_text()) |
1303 | + # a future, KeyError "default"; means no default value is set for the |
1304 | + # newly introduced config option |
1305 | + self.config_defaults = { |
1306 | + key: cfg["options"][key]["default"] for key in cfg["options"].keys() |
1307 | + } |
1308 | + |
1309 | + def teardown_method(self): |
1310 | + self.harness.cleanup() |
1311 | + |
1312 | + |
1313 | +class TestConfig(CharmTest): |
1314 | + def test_default_config_values(self): |
1315 | + # Our implicit requirement is that the database uri has been set. |
1316 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1317 | + self.harness.charm._stored.snap_install_source = "store" |
1318 | + self.harness.charm._stored.database_created = True |
1319 | + |
1320 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1321 | + with patch("charm.configure_proxy"): |
1322 | + # Ensure that the default values are set, as expected |
1323 | + self.harness.update_config() |
1324 | + |
1325 | + for key, value in self.config_defaults.items(): |
1326 | + # Accept the default value of None, for items that don't have a default value |
1327 | + assert getattr(self.harness.charm._stored, key, None) == value |
1328 | + |
1329 | + def test_config_changed_option_update(self): |
1330 | + # Our implicit requirement is that the database uri has been set. |
1331 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1332 | + self.harness.charm._stored.snap_install_source = "store" |
1333 | + self.harness.charm._stored.database_created = True |
1334 | + |
1335 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1336 | + with patch("charm.configure_proxy"): |
1337 | + self.harness.update_config({"domain": "http://foo.com"}) |
1338 | + |
1339 | + assert self.harness.charm._stored.domain == "http://foo.com" |
1340 | + |
1341 | + def test_on_config_changed(self): |
1342 | + # When there is an upodate, but no database set, we should end up with an |
1343 | + # Missing database relation blocked state |
1344 | + event = MagicMock() |
1345 | + self.harness.charm._stored.snap_install_source = "store" |
1346 | + |
1347 | + self.harness.charm._on_config_changed(event) |
1348 | + |
1349 | + event.defer.assert_called_once() |
1350 | + assert self.harness.charm.unit.status == BlockedStatus( |
1351 | + "Missing database relation" |
1352 | + ) |
1353 | + |
1354 | + def test_on_config_changed_missing_snap(self): |
1355 | + # Here we test what happens if the snap installation fails for some reason |
1356 | + event = MagicMock() |
1357 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1358 | + |
1359 | + with patch.object( |
1360 | + self.harness.charm, "handle_installation" |
1361 | + ) as patched_handle_installation: |
1362 | + self.harness.charm._on_config_changed(event) |
1363 | + |
1364 | + patched_handle_installation.assert_called_once() |
1365 | + event.defer.assert_called_once() |
1366 | + assert not getattr(self.harness.charm._stored, "snap_install_source", None) |
1367 | + assert self.harness.charm.unit.status == BlockedStatus( |
1368 | + "Snap has not been installed" |
1369 | + ) |
1370 | + |
1371 | + def test_field_is_updated(self): |
1372 | + # Check that we have an update is the md5 matches |
1373 | + |
1374 | + self.harness.charm._stored.domain = "My new domain" |
1375 | + self.harness.charm._stored.domain_md5 = "test" |
1376 | + |
1377 | + with patch.object( |
1378 | + self.harness.charm, "do_snap_updates" |
1379 | + ) as patched_do_snap_updates: |
1380 | + self.harness.charm.handle_config() |
1381 | + |
1382 | + # This will be called with an array of dicts, containing the new |
1383 | + # md5 value and the new resource value |
1384 | + patched_do_snap_updates.assert_called_once_with( |
1385 | + [ |
1386 | + { |
1387 | + "name": "domain", |
1388 | + "value": "My new domain", |
1389 | + "md5": "79911e6e27b92c112ea0034734bf9f14", |
1390 | + } |
1391 | + ] |
1392 | + ) |
1393 | + |
1394 | + def test_field_is_not_updated(self, caplog): |
1395 | + caplog.clear() |
1396 | + caplog.set_level(logging.INFO) |
1397 | + |
1398 | + self.harness.charm._stored.snap_install_source = "store" |
1399 | + self.harness.charm._stored.domain = "My new domain" |
1400 | + # We know the md5 |
1401 | + self.harness.charm._stored.domain_md5 = "79911e6e27b92c112ea0034734bf9f14" |
1402 | + |
1403 | + with patch.object( |
1404 | + self.harness.charm, "do_snap_updates" |
1405 | + ) as patched_do_snap_updates: |
1406 | + self.harness.charm.handle_config() |
1407 | + |
1408 | + # As there are no fields to update, we expect an empty array |
1409 | + patched_do_snap_updates.assert_called_once_with([]) |
1410 | + assert len(caplog.records) == 1 |
1411 | + assert caplog.records[0].message == "Skipping update for domain, no new value" |
1412 | + |
1413 | + @pytest.mark.parametrize( |
1414 | + "config,value", |
1415 | + [ |
1416 | + ("domain", "not.valid"), |
1417 | + ("username", 1), |
1418 | + ("password", 1), |
1419 | + ], |
1420 | + ) |
1421 | + def test_config_validation_invalid(self, config, value): |
1422 | + """ |
1423 | + Here we test both that tests work and fail. |
1424 | + |
1425 | + Using 2 differen parameterize decorators, mean that they iterate over |
1426 | + eachother, basically multiplying the number of tests executed. |
1427 | + """ |
1428 | + # Our implicit requirement is that the database uri has been set. |
1429 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1430 | + self.harness.charm.errors = [] |
1431 | + self.harness.charm._stored.snap_install_source = "store" |
1432 | + self.harness.charm._stored.database_created = True |
1433 | + |
1434 | + self.harness.update_config({config: value}) |
1435 | + |
1436 | + assert len(self.harness.charm.errors) == 1 |
1437 | + assert ( |
1438 | + f"Could not parse the config option for Charm option '{config}'" |
1439 | + in self.harness.charm.errors[0] |
1440 | + ) |
1441 | + |
1442 | + @pytest.mark.parametrize( |
1443 | + "config,value", |
1444 | + [ |
1445 | + ("domain", "http://valid.domain"), |
1446 | + ("username", "user@domain.country"), |
1447 | + ("password", "a valid string"), |
1448 | + ], |
1449 | + ) |
1450 | + def test_config_validation_valid(self, config, value): |
1451 | + """ |
1452 | + Here we test both that tests work and fail. |
1453 | + |
1454 | + Using 2 differen parameterize decorators, mean that they iterate over |
1455 | + eachother, basically multiplying the number of tests executed. |
1456 | + """ |
1457 | + # Our implicit requirement is that the database uri has been set. |
1458 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1459 | + self.harness.charm._stored.snap_install_source = "store" |
1460 | + self.harness.charm._stored.database_created = True |
1461 | + |
1462 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1463 | + with patch("charm.configure_proxy"): |
1464 | + self.harness.update_config({config: value}) |
1465 | + assert getattr(self.harness.charm._stored, config) == value |
1466 | + |
1467 | + def test_handle_pgsql_dsn_change(self): |
1468 | + self.harness.charm._stored.db_uri = "postgresql://fake/database" |
1469 | + self.harness.charm._stored.snap_install_source = "store" |
1470 | + |
1471 | + with patch("charm.create_database") as patched_proxy: |
1472 | + self.harness.charm.handle_pgsql_dsn_change() |
1473 | + |
1474 | + assert patched_proxy.called |
1475 | + patched_proxy.assert_called_once_with("postgresql://fake/database") |
1476 | + |
1477 | + |
1478 | +class TestRegistrationBundle(CharmTest): |
1479 | + # Split off to ensure that tests are specific for what they do |
1480 | + # data that is passed to do_snap_updates has alrady been validated |
1481 | + # via the optionvalidation functions, so no need to test invalid data |
1482 | + def test_no_bundle(self): |
1483 | + assert not hasattr(self.harness.charm._stored, "registered") |
1484 | + |
1485 | + self.harness.charm.do_snap_updates({}) |
1486 | + |
1487 | + # If we start without the registered key, and provide no bundle, no |
1488 | + # key will be added |
1489 | + assert not hasattr(self.harness.charm._stored, "registered") |
1490 | + |
1491 | + def test_registration_removed(self): |
1492 | + setattr(self.harness.charm._stored, "registered", True) |
1493 | + |
1494 | + self.harness.charm.do_snap_updates( |
1495 | + [{"name": "registration_bundle", "value": None}] |
1496 | + ) |
1497 | + |
1498 | + # We started with a key, so it will stay with us |
1499 | + assert not getattr(self.harness.charm._stored, "registered") |
1500 | + |
1501 | + def test_has_incomplete_bundle_missing_all(self): |
1502 | + to_update = [ |
1503 | + { |
1504 | + "name": "registration_bundle", |
1505 | + "value": base64.b64encode("{}".encode("UTF-8")), |
1506 | + } |
1507 | + ] |
1508 | + |
1509 | + self.harness.charm.do_snap_updates(to_update) |
1510 | + assert len(self.harness.charm.errors) == 4 |
1511 | + assert ( |
1512 | + self.harness.charm.errors[0] |
1513 | + == "Missing key domain in the registration bundle" |
1514 | + ) |
1515 | + |
1516 | + def test_has_incomplete_bundle_added_domain(self): |
1517 | + to_update = [ |
1518 | + { |
1519 | + "name": "registration_bundle", |
1520 | + "value": base64.b64encode('{"domain": "sdfsd"}'.encode("UTF-8")), |
1521 | + } |
1522 | + ] |
1523 | + |
1524 | + self.harness.charm.do_snap_updates(to_update) |
1525 | + assert len(self.harness.charm.errors) == 3 |
1526 | + assert ( |
1527 | + self.harness.charm.errors[0] |
1528 | + == "Missing key private_key in the registration bundle" |
1529 | + ) |
1530 | + |
1531 | + def test_has_incomplete_bundle_added_private_key(self): |
1532 | + to_update = [ |
1533 | + { |
1534 | + "name": "registration_bundle", |
1535 | + "value": base64.b64encode( |
1536 | + '{"domain": "sdfsd",' '"private_key": "12"}'.encode("UTF-8") |
1537 | + ), |
1538 | + } |
1539 | + ] |
1540 | + |
1541 | + self.harness.charm.do_snap_updates(to_update) |
1542 | + assert len(self.harness.charm.errors) == 2 |
1543 | + assert ( |
1544 | + self.harness.charm.errors[0] |
1545 | + == "Missing key public_key in the registration bundle" |
1546 | + ) |
1547 | + |
1548 | + def test_has_incomplete_bundle_added_public_key(self): |
1549 | + to_update = [ |
1550 | + { |
1551 | + "name": "registration_bundle", |
1552 | + "value": base64.b64encode( |
1553 | + '{"domain": "sdfsd",' |
1554 | + '"public_key": "12",' |
1555 | + '"private_key": "12"}'.encode("UTF-8") |
1556 | + ), |
1557 | + } |
1558 | + ] |
1559 | + |
1560 | + self.harness.charm.do_snap_updates(to_update) |
1561 | + assert len(self.harness.charm.errors) == 1 |
1562 | + assert ( |
1563 | + self.harness.charm.errors[0] |
1564 | + == "Missing key store_id in the registration bundle" |
1565 | + ) |
1566 | + |
1567 | + # Possibly needed once airgap mode has been implemented |
1568 | + # def test_has_incomplete_bundle_added_store_id(self): |
1569 | + # to_update = [ |
1570 | + # { |
1571 | + # "name": "registration_bundle", |
1572 | + # "value": base64.b64encode( |
1573 | + # '{"domain": "sdfsd",' |
1574 | + # '"public_key": "12",' |
1575 | + # '"store_id": "12",' |
1576 | + # '"private_key": "12"}'.encode("UTF-8") |
1577 | + # ), |
1578 | + # } |
1579 | + # ] |
1580 | + |
1581 | + # self.harness.charm.do_snap_updates(to_update) |
1582 | + # assert len(self.harness.charm.errors) == 1 |
1583 | + # assert ( |
1584 | + # self.harness.charm.errors[0] |
1585 | + # == "Missing key store_assertion in the registration bundle" |
1586 | + # ) |
1587 | + |
1588 | + def test_has_complete_bundle(self): |
1589 | + to_update = [ |
1590 | + { |
1591 | + "name": "registration_bundle", |
1592 | + "value": base64.b64encode( |
1593 | + '{"domain": "sdfsd",' |
1594 | + '"public_key": "10",' |
1595 | + '"store_id": "11",' |
1596 | + '"store_assertion": "0",' |
1597 | + '"private_key": "12"}'.encode("UTF-8") |
1598 | + ), |
1599 | + } |
1600 | + ] |
1601 | + with patch("charm.configure_proxy") as patched_configure: |
1602 | + self.harness.charm.do_snap_updates(to_update) |
1603 | + |
1604 | + patched_configure.assert_called_once_with( |
1605 | + [ |
1606 | + "proxy.domain", |
1607 | + "proxy.key.public", |
1608 | + "internal.store.id", |
1609 | + "proxy.key.private", |
1610 | + ], |
1611 | + ["sdfsd", "10", "11", "12"], |
1612 | + force=True, |
1613 | + ) |
1614 | + |
1615 | + assert getattr(self.harness.charm._stored, "registered", None) |
1616 | + |
1617 | + |
1618 | +class TestStatus(CharmTest): |
1619 | + def test_status_missing_db_uri(self): |
1620 | + self.harness.charm._stored.db_uri = None |
1621 | + |
1622 | + self.harness.charm.evaluate_status() |
1623 | + |
1624 | + assert self.harness.charm.unit.status.message == "Missing database relation" |
1625 | + assert isinstance(self.harness.charm.unit.status, BlockedStatus) |
1626 | + |
1627 | + def test_status_missing_username(self): |
1628 | + self.harness.charm._stored.db_uri = "Filled, but useless" |
1629 | + |
1630 | + self.harness.charm.evaluate_status() |
1631 | + |
1632 | + assert ( |
1633 | + self.harness.charm.unit.status.message |
1634 | + == "Missing username, needed for registration" |
1635 | + ) |
1636 | + assert isinstance(self.harness.charm.unit.status, BlockedStatus) |
1637 | + |
1638 | + def test_status_missing_password(self): |
1639 | + self.harness.charm._stored.db_uri = "Filled, but useless" |
1640 | + self.harness.charm._stored.username = "minion" |
1641 | + |
1642 | + self.harness.charm.evaluate_status() |
1643 | + |
1644 | + assert ( |
1645 | + self.harness.charm.unit.status.message |
1646 | + == "Missing password, needed for registration" |
1647 | + ) |
1648 | + assert isinstance(self.harness.charm.unit.status, BlockedStatus) |
1649 | + |
1650 | + def test_status_not_registered(self): |
1651 | + self.harness.charm._stored.db_uri = "Filled, but useless" |
1652 | + self.harness.charm._stored.username = "minion" |
1653 | + self.harness.charm._stored.password = "minions password" |
1654 | + self.harness.charm._stored.registered = None |
1655 | + |
1656 | + self.harness.charm.evaluate_status() |
1657 | + |
1658 | + assert ( |
1659 | + self.harness.charm.unit.status.message |
1660 | + == "The unit is not registered, please supply username and password" |
1661 | + ) |
1662 | + assert isinstance(self.harness.charm.unit.status, BlockedStatus) |
1663 | + |
1664 | + def test_unit_ready(self): |
1665 | + self.harness.charm._stored.db_uri = "Filled, but useless" |
1666 | + self.harness.charm._stored.username = "minion" |
1667 | + self.harness.charm._stored.password = "minions password" |
1668 | + self.harness.charm._stored.registered = True |
1669 | + |
1670 | + self.harness.charm.evaluate_status() |
1671 | + |
1672 | + assert self.harness.charm.unit.status.message == "" |
1673 | + |
1674 | + |
1675 | +class TestEvents(CharmTest): |
1676 | + def test_on_update_status(self): |
1677 | + self.harness.disable_hooks() |
1678 | + self.harness.set_leader(True) |
1679 | + self.harness.enable_hooks() |
1680 | + |
1681 | + with patch("charm.snap_details") as mocked_snap_details: |
1682 | + mocked_snap_details.return_value = (None, 12, None, None, None, None) |
1683 | + self.harness.charm._on_update_status(None) |
1684 | + |
1685 | + assert mocked_snap_details.called_once |
1686 | + mocked_snap_details.assert_called_once_with("snap-store-proxy") |
1687 | + assert self.harness.get_workload_version() == "12" |
1688 | + |
1689 | + def test_on_update_status_exception(self): |
1690 | + self.harness.disable_hooks() |
1691 | + self.harness.set_leader(True) |
1692 | + self.harness.enable_hooks() |
1693 | + |
1694 | + with patch("charm.snap_details") as mocked_snap_details: |
1695 | + mocked_snap_details.side_effect = subprocess.CalledProcessError(1, ["snap"]) |
1696 | + self.harness.charm._on_update_status(None) |
1697 | + |
1698 | + assert self.harness.get_workload_version() == "unknown" |
1699 | + |
1700 | + def test_on_update_status_not_leader(self): |
1701 | + self.harness.disable_hooks() |
1702 | + self.harness.set_leader(False) |
1703 | + self.harness.enable_hooks() |
1704 | + |
1705 | + with patch("charm.snap_details") as patched_snap_details: |
1706 | + self.harness.charm._on_update_status(None) |
1707 | + |
1708 | + patched_snap_details.assert_not_called() |
1709 | + |
1710 | + def test_on_database_relation_joined_as_leader(self): |
1711 | + # Disable the hooks, as the pgsql library will call get-leader via subprocess |
1712 | + self.harness.disable_hooks() |
1713 | + self.harness.set_leader(True) |
1714 | + self.harness.enable_hooks() |
1715 | + mocked_event = MagicMock() |
1716 | + |
1717 | + self.harness.charm._on_database_relation_joined(mocked_event) |
1718 | + |
1719 | + assert mocked_event.database == DATABASE_NAME |
1720 | + assert mocked_event.extensions == ["btree_gist"] |
1721 | + |
1722 | + def test_on_database_relation_joined_as_not_leader(self): |
1723 | + # Disable the hooks, as the pgsql library will call get-leader via subprocess |
1724 | + self.harness.disable_hooks() |
1725 | + self.harness.set_leader(False) |
1726 | + self.harness.enable_hooks() |
1727 | + mocked_event = MagicMock() |
1728 | + mocked_event.database == "snap-store-proxy" |
1729 | + |
1730 | + self.harness.charm._on_database_relation_joined(mocked_event) |
1731 | + |
1732 | + assert mocked_event.defer.called_once |
1733 | + mocked_event.defer.assert_called_once_with() |
1734 | + |
1735 | + def test_on_database_master_changed_wrong_database(self): |
1736 | + mocked_event = MagicMock() |
1737 | + mocked_event.database = "something invalid" |
1738 | + |
1739 | + assert self.harness.charm._stored.db_uri is None |
1740 | + |
1741 | + self.harness.charm._on_database_master_changed(mocked_event) |
1742 | + |
1743 | + assert self.harness.charm._stored.db_uri is None |
1744 | + |
1745 | + def test_on_database_master_changed_correct_database(self): |
1746 | + dummy_uri = "postgresql://test@fake.db" |
1747 | + mocked_event = MagicMock() |
1748 | + mocked_event.database = DATABASE_NAME |
1749 | + mocked_event.master.uri = dummy_uri |
1750 | + |
1751 | + self.harness.charm._stored.snap_install_source = "store" |
1752 | + |
1753 | + assert self.harness.charm._stored.db_uri is None |
1754 | + |
1755 | + with patch("charm.create_database") as mocked_call: |
1756 | + self.harness.charm._on_database_master_changed(mocked_event) |
1757 | + |
1758 | + assert self.harness.charm._stored.db_uri == dummy_uri |
1759 | + mocked_call.assert_called_once_with(dummy_uri) |
1760 | + |
1761 | + def test_on_database_relation_broken(self): |
1762 | + self.harness.charm._stored.db_uri = "Some value" |
1763 | + with patch.object( |
1764 | + self.harness.charm, "evaluate_status" |
1765 | + ) as patched_evaluate_status: |
1766 | + self.harness.charm._on_database_relation_broken(None) |
1767 | + |
1768 | + assert self.harness.charm._stored.db_uri is None |
1769 | + patched_evaluate_status.assert_called_once_with() |
1770 | + |
1771 | + def test_on_status_action_succeeds(self): |
1772 | + event = MagicMock() |
1773 | + with patch("charm.get_status") as patched_get_status: |
1774 | + patched_get_status.return_value = ("String", 0) |
1775 | + self.harness.charm._on_status_action(event) |
1776 | + |
1777 | + patched_get_status.assert_called_once_with() |
1778 | + event.set_results.assert_called_once_with({"result": "String"}) |
1779 | + |
1780 | + def test_on_status_action_fails(self): |
1781 | + event = MagicMock() |
1782 | + with patch("charm.get_status") as patched_get_status: |
1783 | + patched_get_status.return_value = ("String", 1) |
1784 | + self.harness.charm._on_status_action(event) |
1785 | + |
1786 | + patched_get_status.assert_called_once_with() |
1787 | + event.fail.assert_called_once_with( |
1788 | + "Failed to get status, errorcode 1\n, result String" |
1789 | + ) |
1790 | + |
1791 | + |
1792 | +class TestRegistration(CharmTest): |
1793 | + def test_handle_registration_no_database(self, caplog): |
1794 | + caplog.clear() |
1795 | + caplog.set_level(logging.INFO) |
1796 | + event = MagicMock() |
1797 | + |
1798 | + self.harness.charm.handle_registration(event) |
1799 | + |
1800 | + assert len(caplog.records) == 1 |
1801 | + assert caplog.records[0].message == "The database relation is not yet setup" |
1802 | + |
1803 | + def test_handle_registration_no_details(self, caplog): |
1804 | + caplog.clear() |
1805 | + caplog.set_level(logging.INFO) |
1806 | + |
1807 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1808 | + event = MagicMock() |
1809 | + |
1810 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1811 | + self.harness.charm.handle_registration(event) |
1812 | + |
1813 | + assert len(caplog.records) == 1 |
1814 | + assert ( |
1815 | + caplog.records[0].message |
1816 | + == "Missing username or password, unable to register" |
1817 | + ) |
1818 | + |
1819 | + def test_handle_registration_already_registered(self, caplog): |
1820 | + caplog.clear() |
1821 | + caplog.set_level(logging.INFO) |
1822 | + |
1823 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1824 | + self.harness.charm._stored.registered = True |
1825 | + event = MagicMock() |
1826 | + |
1827 | + self.harness.charm.handle_registration(event) |
1828 | + |
1829 | + assert len(caplog.records) == 1 |
1830 | + assert caplog.records[0].message == "This proxy is already registered" |
1831 | + |
1832 | + def test_handle_registration_missing_domain(self, caplog): |
1833 | + caplog.clear() |
1834 | + caplog.set_level(logging.INFO) |
1835 | + |
1836 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1837 | + self.harness.charm._stored.registered = False |
1838 | + self.harness.charm._stored.username = "user@domain.country" |
1839 | + self.harness.charm._stored.password = "password" |
1840 | + event = MagicMock() |
1841 | + |
1842 | + self.harness.charm.handle_registration(event) |
1843 | + |
1844 | + assert len(caplog.records) == 1 |
1845 | + assert caplog.records[0].message == "Missing domain needed to register" |
1846 | + |
1847 | + def test_handle_registration_register_succeeds(self, caplog): |
1848 | + caplog.clear() |
1849 | + caplog.set_level(logging.INFO) |
1850 | + |
1851 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1852 | + self.harness.charm._stored.registered = False |
1853 | + self.harness.charm._stored.username = "user@domain.country" |
1854 | + self.harness.charm._stored.password = "password" |
1855 | + self.harness.charm._stored.domain = "http://localhost" |
1856 | + event = MagicMock() |
1857 | + |
1858 | + with patch("charm.register_store") as patched_registration: |
1859 | + patched_registration.return_value = True |
1860 | + self.harness.charm.handle_registration(event) |
1861 | + |
1862 | + assert len(caplog.records) == 1 |
1863 | + assert caplog.records[0].message == "Proxy is registered successfully" |
1864 | + |
1865 | + def test_handle_registration_register_fails(self, caplog): |
1866 | + caplog.clear() |
1867 | + caplog.set_level(logging.INFO) |
1868 | + |
1869 | + self.harness.charm._stored.db_uri = "postrgresql://fake/database" |
1870 | + self.harness.charm._stored.registered = False |
1871 | + self.harness.charm._stored.username = "user@domain.country" |
1872 | + self.harness.charm._stored.password = "password" |
1873 | + self.harness.charm._stored.domain = "http://localhost" |
1874 | + event = MagicMock() |
1875 | + |
1876 | + with patch("charm.register_store") as patched_registration: |
1877 | + patched_registration.return_value = False |
1878 | + self.harness.charm.handle_registration(event) |
1879 | + |
1880 | + assert len(caplog.records) == 1 |
1881 | + assert ( |
1882 | + caplog.records[0].message |
1883 | + == "Failed to register, check logging to find the reason" |
1884 | + ) |
1885 | + |
1886 | + |
1887 | +class TestSanity(CharmTest): |
1888 | + def test_empty_username_after_registration_and_active_status(self): |
1889 | + self.harness.charm._stored.snap_install_source = "store" |
1890 | + self.harness.charm._stored.registered = True |
1891 | + self.harness.charm._stored.database_created = True |
1892 | + self.harness.charm._stored.username = "Some value" |
1893 | + self.harness.charm._stored.db_uri = "postgresql://user@host/database" |
1894 | + |
1895 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1896 | + with patch("charm.configure_proxy"): |
1897 | + # This should not result in a change, as none is not possible to be passed on |
1898 | + self.harness.update_config({"username": None}) |
1899 | + |
1900 | + assert self.harness.charm._stored.username == "Some value" |
1901 | + assert self.harness.charm.unit.status == ActiveStatus("") |
1902 | + |
1903 | + def test_empty_password_after_registration_and_active_status(self): |
1904 | + self.harness.charm._stored.registered = True |
1905 | + self.harness.charm._stored.database_created = True |
1906 | + self.harness.charm._stored.password = "Some value" |
1907 | + self.harness.charm._stored.db_uri = "postgresql://user@host/database" |
1908 | + |
1909 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1910 | + with patch("charm.configure_proxy"): |
1911 | + self.harness.update_config({"password": None}) |
1912 | + |
1913 | + assert self.harness.charm._stored.password == "Some value" |
1914 | + assert self.harness.charm.unit.status == ActiveStatus("") |
1915 | + |
1916 | + def test_update_domain_after_registration(self): |
1917 | + self.harness.charm._stored.snap_install_source = "store" |
1918 | + self.harness.charm._stored.registered = True |
1919 | + self.harness.charm._stored.database_created = True |
1920 | + self.harness.charm._stored.domain = "Some value" |
1921 | + self.harness.charm._stored.db_uri = "postgresql://user@host/database" |
1922 | + |
1923 | + # Since valid values will be pushed on to the proxy, we need to stub that here |
1924 | + with patch("charm.configure_proxy"): |
1925 | + self.harness.update_config({"domain": "http://my.super.domain"}) |
1926 | + |
1927 | + assert self.harness.charm._stored.domain == "Some value" |
1928 | + assert self.harness.charm.unit.status == ActiveStatus("") |
1929 | + |
1930 | + |
1931 | +class TestInstallation(CharmTest): |
1932 | + def test_install_from_resource(self): |
1933 | + self.harness.add_resource("core", "Dummy core") |
1934 | + self.harness.add_resource("snap-store-proxy", "Dummy proxy") |
1935 | + |
1936 | + with patch("charm.install_from_resource") as patched_install: |
1937 | + self.harness.charm.handle_installation() |
1938 | + |
1939 | + # We cannot determine the parameters, as the resources are |
1940 | + # placed in a randomly named directory |
1941 | + patched_install.assert_called_once() |
1942 | + assert self.harness.charm._stored.snap_install_source == "resource" |
1943 | + assert self.harness.charm._stored.core_md5 == "73eeccd64008a01814825637a6593bb2" |
1944 | + assert self.harness.charm._stored.snap_md5 == "97bc2632325e456ad67ca1626baa573c" |
1945 | + |
1946 | + def test_upgrade_from_resource(self): |
1947 | + self.harness.charm._stored.snap_install_source = "resource" |
1948 | + self.harness.charm._stored.core_md5 = "73eeccd64008a01814825637a6593bb2" |
1949 | + self.harness.charm._stored.snap_md5 = "97bc2632325e456ad67ca1626baa573c" |
1950 | + |
1951 | + self.harness.add_resource("core", "My new core") |
1952 | + self.harness.add_resource("snap-store-proxy", "My new proxy") |
1953 | + |
1954 | + with patch("charm.install_from_resource") as patched_install: |
1955 | + self.harness.charm.handle_installation() |
1956 | + |
1957 | + # We cannot determine the parameters, as the resources are |
1958 | + # placed in a randomly named directory |
1959 | + patched_install.assert_called_once() |
1960 | + assert self.harness.charm._stored.snap_install_source == "resource" |
1961 | + assert self.harness.charm._stored.core_md5 == "59f15fd274b73cb00369c905eeab5e70" |
1962 | + assert self.harness.charm._stored.snap_md5 == "d7486ac5da29427a54fe1d6e731bd334" |
1963 | + |
1964 | + def test_install_from_store(self): |
1965 | + with patch("charm.install_from_the_charmstore") as patched_install: |
1966 | + self.harness.charm.handle_installation() |
1967 | + |
1968 | + patched_install.assert_called_once_with("snap-store-proxy") |
1969 | + assert self.harness.charm._stored.snap_install_source == "store" |
1970 | + assert not hasattr(self.harness.charm._stored, "core_md5") |
1971 | + assert not hasattr(self.harness.charm._stored, "snap_md5") |
1972 | + |
1973 | + def test_install_from_store_update(self): |
1974 | + # In case of an update (repeated call) nothing should happen if installed from store |
1975 | + self.harness.charm._stored.snap_install_source = "store" |
1976 | + |
1977 | + with patch("charm.install_from_the_charmstore") as patched_install: |
1978 | + self.harness.charm.handle_installation() |
1979 | + |
1980 | + patched_install.assert_not_called() |
1981 | + assert not hasattr(self.harness.charm._stored, "core_md5") |
1982 | + assert not hasattr(self.harness.charm._stored, "snap_md5") |
1983 | + |
1984 | + def test_install_from_store_fails(self): |
1985 | + with patch("charm.install_from_the_charmstore") as patched_install: |
1986 | + patched_install.side_effect = Exception("dummy") |
1987 | + self.harness.charm.handle_installation() |
1988 | + |
1989 | + patched_install.assert_called_once() |
1990 | + assert not hasattr(self.harness.charm._stored, "core_md5") |
1991 | + assert not hasattr(self.harness.charm._stored, "snap_md5") |
1992 | + assert len(self.harness.charm.errors) == 1 |
1993 | + assert self.harness.charm.errors[0] == "Failed to install the snap-store-proxy" |
1994 | diff --git a/tests/test_helpers.py b/tests/test_helpers.py |
1995 | new file mode 100644 |
1996 | index 0000000..1e61b15 |
1997 | --- /dev/null |
1998 | +++ b/tests/test_helpers.py |
1999 | @@ -0,0 +1,56 @@ |
2000 | +from unittest import mock |
2001 | + |
2002 | +import helpers |
2003 | + |
2004 | + |
2005 | +def test_configure_proxy(): |
2006 | + with mock.patch("helpers.run") as mocked_run: |
2007 | + helpers.configure_proxy("myvalue", "myoption") |
2008 | + |
2009 | + assert mocked_run.called |
2010 | + mocked_run.assert_called_once_with( |
2011 | + ["snap-store-proxy", "config", "myvalue=myoption"] |
2012 | + ) |
2013 | + |
2014 | + |
2015 | +def test_configure_proxy_list(): |
2016 | + with mock.patch("helpers.run") as mocked_run: |
2017 | + options = ["one", "two"] |
2018 | + values = ["value_one", "value_two"] |
2019 | + helpers.configure_proxy(options, values) |
2020 | + |
2021 | + assert mocked_run.called |
2022 | + mocked_run.assert_called_once_with( |
2023 | + ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"'] |
2024 | + ) |
2025 | + |
2026 | + |
2027 | +def test_configure_proxy_forced(): |
2028 | + with mock.patch("helpers.run") as mocked_run: |
2029 | + helpers.configure_proxy("myvalue", "myoption", True) |
2030 | + |
2031 | + assert mocked_run.called |
2032 | + mocked_run.assert_called_once_with( |
2033 | + ["snap-store-proxy", "config", "myvalue=myoption", "--force"] |
2034 | + ) |
2035 | + |
2036 | + |
2037 | +def test_configure_proxy_list_forced(): |
2038 | + with mock.patch("helpers.run") as mocked_run: |
2039 | + options = ["one", "two"] |
2040 | + values = ["value_one", "value_two"] |
2041 | + helpers.configure_proxy(options, values, True) |
2042 | + |
2043 | + assert mocked_run.called |
2044 | + mocked_run.assert_called_once_with( |
2045 | + ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"', "--force"] |
2046 | + ) |
2047 | + |
2048 | + |
2049 | +def test_config_options(): |
2050 | + # Not sure how to do a clean test here, as this function is made |
2051 | + # in an attempt to prevent double registration of config items |
2052 | + all_options, default_values = helpers.config_options() |
2053 | + |
2054 | + for default_value_key in default_values.keys(): |
2055 | + assert default_value_key in [key for key in all_options.keys()] |
2056 | diff --git a/tests/test_optionvalidation.py b/tests/test_optionvalidation.py |
2057 | new file mode 100644 |
2058 | index 0000000..4144c79 |
2059 | --- /dev/null |
2060 | +++ b/tests/test_optionvalidation.py |
2061 | @@ -0,0 +1,136 @@ |
2062 | +import base64 |
2063 | + |
2064 | +import pytest |
2065 | + |
2066 | +from exceptions import InvalidTypeException, UnknownTypeException |
2067 | +from optionvalidation import ( |
2068 | + OptionValidation, |
2069 | + OptionValidationBase64Json, |
2070 | + OptionValidationEmail, |
2071 | + OptionValidationString, |
2072 | + OptionValidationURL, |
2073 | +) |
2074 | + |
2075 | + |
2076 | +def test_string_validation_type(): |
2077 | + assert isinstance(OptionValidation.new("string"), OptionValidationString) |
2078 | + |
2079 | + |
2080 | +def test_url_validation_type(): |
2081 | + assert isinstance(OptionValidation.new("url"), OptionValidationURL) |
2082 | + |
2083 | + |
2084 | +def test_email_validation_type(): |
2085 | + assert isinstance(OptionValidation.new("email"), OptionValidationEmail) |
2086 | + |
2087 | + |
2088 | +def test_base64_json_validation_type(): |
2089 | + assert isinstance(OptionValidation.new("base64+json"), OptionValidationBase64Json) |
2090 | + |
2091 | + |
2092 | +def test_validation_raises_on_unknown(): |
2093 | + with pytest.raises(UnknownTypeException): |
2094 | + OptionValidation.new("clown") |
2095 | + |
2096 | + |
2097 | +def test_string_valid(): |
2098 | + # This should neither rais an assert, or an exception |
2099 | + OptionValidation.new("string").validate("option", "string") |
2100 | + |
2101 | + |
2102 | +@pytest.mark.parametrize( |
2103 | + "input", |
2104 | + [ |
2105 | + 1, |
2106 | + True, |
2107 | + [1, 2, 3], |
2108 | + (1, 2, 3), |
2109 | + {"1": 1, "2": 2}, |
2110 | + ], |
2111 | +) |
2112 | +def test_string_invalid(input): |
2113 | + with pytest.raises(InvalidTypeException) as exc: |
2114 | + OptionValidation.new("string").validate("option", input) |
2115 | + assert str(input) in str(exc.value) |
2116 | + |
2117 | + |
2118 | +@pytest.mark.parametrize( |
2119 | + "url", |
2120 | + [ |
2121 | + "http://http.domain", |
2122 | + "http://127.0.0.1", |
2123 | + "http://localhost.localdomain", |
2124 | + "http://RanDom.cApitaLS", |
2125 | + "http://super.deep.domain.with.lots.of.dots.in.it", |
2126 | + ], |
2127 | +) |
2128 | +def test_url_valid(url): |
2129 | + OptionValidation.new("url").validate("option", url) |
2130 | + |
2131 | + |
2132 | +@pytest.mark.parametrize( |
2133 | + "url", |
2134 | + [ |
2135 | + "http://httpdomain", |
2136 | + "https://httpsdomain", |
2137 | + "https://https.domain", |
2138 | + "http://localhost", |
2139 | + "ftp://localhost", |
2140 | + "ftps://localhost", |
2141 | + "ssh://localhost", |
2142 | + "mailto://my.email.domain", |
2143 | + ], |
2144 | +) |
2145 | +def test_url_invalid(url): |
2146 | + with pytest.raises(InvalidTypeException) as exc: |
2147 | + OptionValidation.new("url").validate("option", url) |
2148 | + assert url in str(exc.value) |
2149 | + |
2150 | + |
2151 | +@pytest.mark.parametrize( |
2152 | + "email", |
2153 | + [ |
2154 | + "user@domain.extension", |
2155 | + "user@domain.subdomain.extension", |
2156 | + "user+label@domain.extension", |
2157 | + ], |
2158 | +) |
2159 | +def test_email_valid(email): |
2160 | + OptionValidation.new("email").validate("option", email) |
2161 | + |
2162 | + |
2163 | +@pytest.mark.parametrize( |
2164 | + "email", |
2165 | + [ |
2166 | + "@domain", |
2167 | + "@domain.extension", |
2168 | + "user@", |
2169 | + "user+label@", |
2170 | + "user@domain", |
2171 | + ], |
2172 | +) |
2173 | +def test_email_invalid(email): |
2174 | + with pytest.raises(InvalidTypeException) as exc: |
2175 | + OptionValidation.new("email").validate("option", email) |
2176 | + assert email in str(exc.value) |
2177 | + |
2178 | + |
2179 | +def test_base64_json_valid(): |
2180 | + value = base64.b64encode("{}".encode("UTF-8")) |
2181 | + OptionValidation.new("base64+json").validate("option", value) |
2182 | + |
2183 | + |
2184 | +def test_base64_json_both_invalid(): |
2185 | + value = "+_=-" |
2186 | + with pytest.raises(InvalidTypeException) as exc: |
2187 | + OptionValidation.new("base64+json").validate("option", value) |
2188 | + assert value in str(exc.value) |
2189 | + assert "base64" in str(exc.value) |
2190 | + |
2191 | + |
2192 | +def test_base64_json_invalid(): |
2193 | + value = base64.b64encode("{".encode("UTF-8")) |
2194 | + with pytest.raises(InvalidTypeException) as exc: |
2195 | + OptionValidation.new("base64+json").validate("option", value) |
2196 | + assert value.decode("UTF-8") in str(exc.value) |
2197 | + assert "json" in str(exc.value) |
2198 | diff --git a/tests/test_resource_helpers.py b/tests/test_resource_helpers.py |
2199 | new file mode 100644 |
2200 | index 0000000..2827148 |
2201 | --- /dev/null |
2202 | +++ b/tests/test_resource_helpers.py |
2203 | @@ -0,0 +1,122 @@ |
2204 | +import logging |
2205 | +from subprocess import PIPE, CalledProcessError |
2206 | +from unittest.mock import MagicMock, call, mock_open, patch |
2207 | + |
2208 | +import resource_helpers |
2209 | + |
2210 | + |
2211 | +def test_install_from_the_charmstore(): |
2212 | + with patch("resource_helpers.run") as patched_run: |
2213 | + resource_helpers.install_from_the_charmstore("mysnap", "mychannel") |
2214 | + |
2215 | + assert patched_run.called |
2216 | + patched_run.assert_called_once_with( |
2217 | + ["snap", "install", "--channel=mychannel", "mysnap"], check=True |
2218 | + ) |
2219 | + |
2220 | + |
2221 | +def test_install_from_resource(): |
2222 | + with patch("resource_helpers.run") as patched_run: |
2223 | + resource_helpers.install_from_resource("core", "proxy") |
2224 | + |
2225 | + assert patched_run.called |
2226 | + assert patched_run.call_count == 2 |
2227 | + patched_run.assert_has_calls( |
2228 | + [ |
2229 | + call(["snap", "install", "--dangerous", "core"], check=True), |
2230 | + call(["snap", "install", "--dangerous", "proxy"], check=True), |
2231 | + ] |
2232 | + ) |
2233 | + |
2234 | + |
2235 | +def test_snap_details(): |
2236 | + with patch("resource_helpers.run") as patched_run: |
2237 | + resource_helpers.snap_details("mysnap") |
2238 | + |
2239 | + assert patched_run.called |
2240 | + patched_run.assert_called_once_with(["snap", "list", "mysnap"], stdout=PIPE) |
2241 | + |
2242 | + |
2243 | +def test_register_store_succeeds(caplog): |
2244 | + username = "username@domain.ext" |
2245 | + password = "super secret" |
2246 | + env = { |
2247 | + "SNAPSTORE_EMAIL": username, |
2248 | + "SNAPSTORE_PASSWORD": password, |
2249 | + } |
2250 | + caplog.clear() |
2251 | + caplog.set_level(logging.INFO) |
2252 | + |
2253 | + with patch("resource_helpers.check_output") as patched_check_output: |
2254 | + result = resource_helpers.register_store(username, password) |
2255 | + |
2256 | + assert result |
2257 | + patched_check_output.assert_called_once_with( |
2258 | + ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env |
2259 | + ) |
2260 | + assert len(caplog.records) == 1 |
2261 | + assert caplog.records[0].message == "Proxy registred sucessfully" |
2262 | + |
2263 | + |
2264 | +def test_register_store_fails(caplog): |
2265 | + username = "username@domain.ext" |
2266 | + password = "super secret" |
2267 | + env = { |
2268 | + "SNAPSTORE_EMAIL": username, |
2269 | + "SNAPSTORE_PASSWORD": password, |
2270 | + } |
2271 | + caplog.clear() |
2272 | + caplog.set_level(logging.INFO) |
2273 | + |
2274 | + with patch("resource_helpers.check_output") as patched_check_output: |
2275 | + patched_check_output.side_effect = CalledProcessError( |
2276 | + 1, |
2277 | + ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], |
2278 | + "my dummy error", |
2279 | + ) |
2280 | + result = resource_helpers.register_store(username, password) |
2281 | + |
2282 | + assert not result |
2283 | + patched_check_output.assert_called_once_with( |
2284 | + ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env |
2285 | + ) |
2286 | + assert len(caplog.records) == 1 |
2287 | + assert ( |
2288 | + caplog.records[0].message |
2289 | + == "Registration of proxy failed, error; my dummy error" |
2290 | + ) |
2291 | + |
2292 | + |
2293 | +def test_create_database(): |
2294 | + test_dsn = "postgresql://user:password@host/database" |
2295 | + with patch("resource_helpers.run") as patched_run: |
2296 | + resource_helpers.create_database(test_dsn) |
2297 | + |
2298 | + assert patched_run.called |
2299 | + patched_run.assert_called_once_with( |
2300 | + ["/snap/bin/snap-store-proxy", "create-database", test_dsn] |
2301 | + ) |
2302 | + |
2303 | + |
2304 | +def test_get_status(): |
2305 | + result = MagicMock() |
2306 | + result.returncode = 10 |
2307 | + result.stdout = "My magic result".encode("utf-8") |
2308 | + |
2309 | + with patch("resource_helpers.run") as patched_run: |
2310 | + patched_run.return_value = result |
2311 | + output, exitcode = resource_helpers.get_status() |
2312 | + |
2313 | + assert patched_run.called |
2314 | + patched_run.assert_called_once_with( |
2315 | + ["/snap/bin/snap-store-proxy", "status"], capture_output=True |
2316 | + ) |
2317 | + assert exitcode == 10 |
2318 | + assert output == "My magic result" |
2319 | + |
2320 | + |
2321 | +def test_hash_from_resource(): |
2322 | + with patch("builtins.open", mock_open(read_data="My Dummy Resource".encode())): |
2323 | + hash = resource_helpers.hash_from_resource("dummy") |
2324 | + |
2325 | + assert hash == "edfe387dbf4a72c067943c79dffa51b8" |
lgtm, +1
But noting that there're a bunch of "snapstore-proxy.*" appearing in various places. It seems like they could be changed either to snap-store-proxy or snap-store- proxy-charm depending on context -- to not introduce a yet another new name/spelling: snapstore-proxy.