Merge ~woutervb/snap-store-proxy-charm:start into snap-store-proxy-charm:master

Proposed by Wouter van Bommel
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)
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.

To post a comment you must log in.
Revision history for this message
Przemysław Suliga (suligap) 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-proxy-charm depending on context -- to not introduce a yet another new name/spelling: snapstore-proxy.

review: Approve
Revision history for this message
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-proxy-charm depending on context -- to not introduce a yet another
> new name/spelling: snapstore-proxy.

Thanks, have fixed this before committing

Revision history for this message
Otto Co-Pilot (otto-copilot) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 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
21diff --git a/.jujuignore b/.jujuignore
22new file mode 100644
23index 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/
32diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
33new file mode 100644
34index 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
73diff --git a/DEVELOP.md b/DEVELOP.md
74new file mode 100644
75index 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+
99diff --git a/LICENSE b/LICENSE
100index 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.
112diff --git a/Makefile b/Makefile
113new file mode 100644
114index 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 :=
239diff --git a/README.md b/README.md
240new file mode 100644
241index 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.
326diff --git a/actions.yaml b/actions.yaml
327new file mode 100644
328index 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+
341diff --git a/charmcraft.yaml b/charmcraft.yaml
342new file mode 100644
343index 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+
363diff --git a/config.yaml b/config.yaml
364new file mode 100644
365index 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+
400diff --git a/conftest.py b/conftest.py
401new file mode 100644
402index 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")
416diff --git a/juju/overlay.yaml.example b/juju/overlay.yaml.example
417new file mode 100644
418index 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`
426diff --git a/juju/resource-overlay.yaml b/juju/resource-overlay.yaml
427new file mode 100644
428index 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
441diff --git a/juju/test-bundle.yaml b/juju/test-bundle.yaml
442new file mode 100644
443index 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+
467diff --git a/metadata.yaml b/metadata.yaml
468new file mode 100644
469index 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
506diff --git a/ols-vms.conf b/ols-vms.conf
507new file mode 100644
508index 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
517diff --git a/requirements-dev.txt b/requirements-dev.txt
518new file mode 100644
519index 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
537diff --git a/requirements.txt b/requirements.txt
538new file mode 100644
539index 0000000..c29abf0
540--- /dev/null
541+++ b/requirements.txt
542@@ -0,0 +1,2 @@
543+ops >= 1.2.0
544+ops-lib-pgsql
545diff --git a/setup.cfg b/setup.cfg
546new file mode 100644
547index 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+
582diff --git a/src/charm.py b/src/charm.py
583new file mode 100755
584index 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)
1013diff --git a/src/exceptions.py b/src/exceptions.py
1014new file mode 100644
1015index 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+ )
1042diff --git a/src/helpers.py b/src/helpers.py
1043new file mode 100644
1044index 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()
1108diff --git a/src/optionvalidation.py b/src/optionvalidation.py
1109new file mode 100644
1110index 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)
1190diff --git a/src/resource_helpers.py b/src/resource_helpers.py
1191new file mode 100644
1192index 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
1267diff --git a/tests/__init__.py b/tests/__init__.py
1268new file mode 100644
1269index 0000000..e69de29
1270--- /dev/null
1271+++ b/tests/__init__.py
1272diff --git a/tests/test_charm.py b/tests/test_charm.py
1273new file mode 100644
1274index 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"
1994diff --git a/tests/test_helpers.py b/tests/test_helpers.py
1995new file mode 100644
1996index 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()]
2056diff --git a/tests/test_optionvalidation.py b/tests/test_optionvalidation.py
2057new file mode 100644
2058index 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)
2198diff --git a/tests/test_resource_helpers.py b/tests/test_resource_helpers.py
2199new file mode 100644
2200index 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"

Subscribers

People subscribed via source and target branches

to all changes: