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
diff --git a/.gitignore b/.gitignore
0new file mode 1006440new file mode 100644
index 0000000..68bf9ed
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
1env/
2tmp/
3build/
4*.charm
5
6.coverage
7__pycache__/
8*.py[cod]
9
10.pytest_cache/
11
12juju/overlay.yaml
13
14*.snap
diff --git a/.jujuignore b/.jujuignore
0new file mode 10064415new file mode 100644
index 0000000..71c6212
--- /dev/null
+++ b/.jujuignore
@@ -0,0 +1,5 @@
1venv/
2*.py[cod]
3*.charm
4.pytest_cache/
5.git/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
0new file mode 1006446new file mode 100644
index 0000000..00133f6
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,35 @@
1# snap-store-proxy-charm
2
3## Developing
4
5The whole testing / linting and building of the charm is driven via the
6included Makefile. The most commonly used targets are:
7
8 * make test ; creates a python virtualenv and will install the
9 dependencies needed to run the tests
10 * make coverage ; similar to the one above, but will also display which
11 lines are not tested
12 * make charm ; will build the charm from the code
13 * make deploy ; build the charm and deploy it using juju in the current
14 model
15 * make upgrade_charm ; build a new version of the charm, if applicable and use
16 this to update the version deployed using the deploy target
17 * make black ; blackify and isort the current codebase
18 * make lint ; check that the code is linted correctly
19 * make clean ; remove all files that can be reproduced with make
20 targets
21
22## Intended use case
23
24This charm is inteded to be used to deploy and manage the snapstore proxy snap.
25The snapstore proxy snap allows one to run a proxy, pointing local instances of
26snapd / ubuntu core instances to this proxy to limit outgoing connections.
27For more details, please check [the snapstore proxy snap itself](https://docs.ubuntu.com/snap-store-proxy/en/).
28
29
30## Testing
31
32The Python operator framework includes a very nice harness for testing
33operator behaviour without full deployment. Just `make test`:
34
35 make test
diff --git a/DEVELOP.md b/DEVELOP.md
0new file mode 10064436new file mode 100644
index 0000000..53f42ae
--- /dev/null
+++ b/DEVELOP.md
@@ -0,0 +1,20 @@
1# Development information
2
3This 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.
4
5So 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.
6
7
8# Virtual environment
9
10By 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.
11
12
13# Most important make targets and their function
14
15- test - Test the code
16- coverage - Test the code and produce a coverage report, target is 100%
17- deploy - Build the charm and deploy it with default, assumes juju is available
18- 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.
19- clean - Remove all files that don't belong in the git repository. Does NOT touch the juju model.
20
diff --git a/LICENSE b/LICENSE
index d645695..9d0f8e6 100644
--- a/LICENSE
+++ b/LICENSE
@@ -187,7 +187,7 @@
187 same "printed page" as the copyright notice for easier187 same "printed page" as the copyright notice for easier
188 identification within third-party archives.188 identification within third-party archives.
189189
190 Copyright [yyyy] [name of copyright owner]190 Copyright 2021 Canonical
191191
192 Licensed under the Apache License, Version 2.0 (the "License");192 Licensed under the Apache License, Version 2.0 (the "License");
193 you may not use this file except in compliance with the License.193 you may not use this file except in compliance with the License.
diff --git a/Makefile b/Makefile
194new file mode 100644194new file mode 100644
index 0000000..11342be
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,121 @@
1ENV_TMP = $(CURDIR)/env
2TMPDIR ?= $(CURDIR)/tmp
3ENV ?= $(ENV_TMP)
4CHARM_NAME = snap-store-proxy-charm
5CHARMCRAFT = $(shell which charmcraft)
6# Releases should match the run-on section in charmcraft.yaml in the same order
7RELEASES = 20.04
8CHARM_FILENAME = $(CHARM_NAME)$(subst $(noop) $(noop),,$(addsuffix -amd64, $(addprefix _ubuntu-, $(RELEASES)))).charm
9DEPS = $(shell find src -name '*.py') requirements.txt actions.yaml config.yaml metadata.yaml
10
11# Needed for Jenkins builds, but defining doesn't hurt
12OLS_WHEELS_TMP_DIR = $(TMPDIR)/dependencies
13OLS_WHEELS_DIR ?= $(OLS_WHEELS_TMP_DIR)
14OLS_WHEELS_REPO ?= lp:~ubuntuone-pqm-team/ols-goodyear/+git/wheels
15
16ifndef JENKINS_HOME
17# If not running under the landing server, don't use the wheel repository
18PIP_FLAGS :=
19VENV_FLAGS :=
20OLS_WHEELS_DIR :=
21else
22PIP_FLAGS := --find-links=$(OLS_WHEELS_DIR) --no-index
23VENV_FLAGS := --no-download --extra-search-dir=$(OLS_WHEELS_DIR)
24endif
25
26.PHONY: charm
27charm: $(CHARM_FILENAME)
28
29# Ensure that the building of the charm depends on the python files that make
30# up the charm
31$(CHARM_FILENAME): $(DEPS) $(CHARMCRAFT)
32 $(CHARMCRAFT) pack
33
34$(ENV): $(OLS_WHEELS_DIR) requirements-dev.txt requirements.txt
35 @echo Starting a new virtualenv
36 @virtualenv --clear $(VENV_FLAGS) $(ENV)
37 @echo Installing python dependencies
38 @$(ENV)/bin/pip install $(PIP_FLAGS) -r requirements-dev.txt
39
40.PHONY: test
41test: $(ENV)
42 $(ENV)/bin/pytest
43 $(MAKE) lint
44
45.PHONY: coverage
46coverage: $(ENV)
47 $(ENV)/bin/pytest --cov=src --cov-report=
48 $(ENV)/bin/coverage report -m
49
50.PHONY: black
51black: $(ENV)
52 $(ENV)/bin/isort --profile black src tests
53 $(ENV)/bin/black src tests
54
55.PHONY: lint
56lint: $(ENV)
57 @echo Running flake8
58 @$(ENV)/bin/flake8 src tests
59 @echo Running black
60 @$(ENV)/bin/black --check src tests
61 @echo Running isort
62 @$(ENV)/bin/isort --profile black --check src tests
63
64.PHONY: clean
65clean: $(CHARMCRAFT)
66 @echo "Cleaning the project"
67 @$(CHARMCRAFT) clean
68 @# Delete the helper reference, so we don't kill shared virtualenvs
69 @rm -rf $(ENV_TMP) .pytest_cache build
70 @rm -f $(CHARM_FILENAME) .coverage
71 @rm -f *.snap
72 @rm -fr $(TMPDIR)
73 @-find . -name __pycache__ | xargs rm -rf
74
75.PHONY: deploy
76deploy: $(CHARM_FILENAME) ./juju/test-bundle.yaml ./juju/overlay.yaml
77 @echo deploying the test bundle
78 @juju deploy ./juju/test-bundle.yaml --overlay ./juju/overlay.yaml
79
80.PHONY: deploy-resource
81deploy-resource: $(CHARM_FILENAME) ./juju/test-bundle.yaml ./juju/resource-overlay.yaml ./juju/overlay.yaml core20.snap snap-store-proxy.snap
82 @echo deploying the resouce bundle
83 @juju deploy ./juju/test-bundle.yaml --overlay ./juju/resource-overlay.yaml --overlay ./juju/overlay.yaml
84
85.PHONY: upgrade_charm
86upgrade_charm: $(CHARM_FILENAME)
87 @echo Upgrading the charm with the latest version
88 @juju upgrade-charm snap-store-proxy --path ./$(CHARM_FILENAME)
89
90
91./juju/overlay.yaml:
92 @if [ -f $@ ] ; then \
93 touch $@ ; \
94 else \
95 echo "Please copy the overlay.yaml.example in the juju subdirectory" ;\
96 echo "and fill in the options to something valid" ;\
97 exit 1 ;\
98 fi
99
100$(CHARMCRAFT):
101 @if [ -f $@ ] ; then \
102 exit 0; \
103 else \
104 echo "Please install the snap snapcaft via 'sudo snap snapcraft'" ;\
105 exit 1;\
106 fi
107
108
109%.snap:
110 @echo Downloading $(@:.snap=)
111 @snap download $(@:.snap=)
112 @echo Removing the assert
113 @rm *.assert
114 @echo Renaming the snap
115 @mv $(@:.snap=)*snap $(@)
116
117$(OLS_WHEELS_DIR):
118 git clone $(OLS_WHEELS_REPO) $(OLS_WHEELS_DIR)
119
120# The noop below is a helper variable, don't change it to something
121noop :=
diff --git a/README.md b/README.md
0new file mode 100644122new file mode 100644
index 0000000..2a76997
--- /dev/null
+++ b/README.md
@@ -0,0 +1,81 @@
1# snap-store-proxy-charm
2
3## Description
4
5This 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.
6
7# Usage
8
9Minimal 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.
10
11## Example bundle
12
13Below is a minimal bundle example that can be used to deploy the charm using juju.
14
15 applications:
16 postgresql:
17 charm: postgresql
18 channel: stable
19 snap-store-proxy:
20 charm: snap-store-proxy-charm
21 options:
22 registration_bundle: <content of registration file base64 encoded>
23
24 relations:
25 - - postgresql:db-admin
26 - snap-store-proxy:db-admin
27
28# Relations
29
30This charm depends on a postgresql database, so this charm should relate to a postgresql database, before anything else can be done.
31
32# Actions
33
34The charm supports the following actions:
35
36* **status** - Get the status result of the proxy, use like;
37
38 juju run-action snap-store-proxy/leader status --wait
39
40# Advanced options
41
42## Options
43
44There are some mandatory config options that have to be configured for the charm to work, these are:
45
46* **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:
47
48 juju config snap-store-proxy registration_bundle=$(cat <path to file created with snapstore-client> | base64)
49
50
51Or (deprecated)
52
53* **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).
54* **username** - This is the username of the Ubuntu One account that will be used to register the proxy
55* **password** - The password of the above username
56
57## Juju resource usage
58
59The 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.
60
61(\*) 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.
62
63An example bundle to do such a deployment will look like the following.
64
65 applications:
66 postgresql:
67 charm: postgresql
68 channel: stable
69 snap-store-proxy:
70 charm: snap-store-proxy
71 options:
72 registration_bundle: <content of registration file base64 encoded>
73 resources:
74 snap-store-proxy: ./snap-store-proxy.snap
75 core: ./core20.snap
76
77 relations:
78 - - postgresql:db-admin
79 - snap-store-proxy:db-admin
80
81With 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.
diff --git a/actions.yaml b/actions.yaml
0new file mode 10064482new file mode 100644
index 0000000..feb312c
--- /dev/null
+++ b/actions.yaml
@@ -0,0 +1,9 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4# Learn more about actions at: https://juju.is/docs/sdk/actions
5status:
6 description: |
7 Get the status of the proxy services, ie
8 juju run-action snap-store-proxy/leader status --wait
9
diff --git a/charmcraft.yaml b/charmcraft.yaml
0new file mode 10064410new file mode 100644
index 0000000..77b5206
--- /dev/null
+++ b/charmcraft.yaml
@@ -0,0 +1,16 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5# Learn more about charmcraft.yaml configuration at:
6# https://juju.is/docs/sdk/charmcraft-config
7
8type: "charm"
9bases:
10 - build-on:
11 - name: "ubuntu"
12 channel: "20.04"
13 run-on:
14 - name: "ubuntu"
15 channel: "20.04"
16
diff --git a/config.yaml b/config.yaml
0new file mode 10064417new file mode 100644
index 0000000..1c131a6
--- /dev/null
+++ b/config.yaml
@@ -0,0 +1,31 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4# We need to ensure that every entry has a value, so that a 'juju reset' is
5# always possible
6
7options:
8 registration_bundle:
9 default: null
10 desciption: |
11 A bundle created via `snapstore-proxy_registration_bundle` added as a base64
12 encoded string. ie "$(cat <file> | base64)"
13 type: string
14 domain:
15 default: http://localhost.localdomain
16 description: Domain name for the Snap Store Proxy.
17 type: string
18 username:
19 default: null
20 description: |
21 Ubuntu one username used to register this proxy, this account cannot
22 have 2 factor authentication enabled, and needs to have accepted
23 the developer agreement on the snapstore.io website.
24 type: string
25 password:
26 default: null
27 decription: |
28 The passsword the belongs to the Ubuntu one username to register
29 this proxy.
30 type: string
31
diff --git a/conftest.py b/conftest.py
0new file mode 10064432new file mode 100644
index 0000000..31b44ee
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,10 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5import pytest
6
7
8@pytest.fixture(autouse=True)
9def dummy_install_from_the_charmstore(mocker):
10 mocker.patch("charm.install_from_the_charmstore")
diff --git a/juju/overlay.yaml.example b/juju/overlay.yaml.example
0new file mode 10064411new file mode 100644
index 0000000..d6f4220
--- /dev/null
+++ b/juju/overlay.yaml.example
@@ -0,0 +1,4 @@
1applications:
2 snap-store-proxy:
3 options:
4 registration_bundle: `cat <registation file> | base64`
diff --git a/juju/resource-overlay.yaml b/juju/resource-overlay.yaml
0new file mode 1006445new file mode 100644
index 0000000..317f11b
--- /dev/null
+++ b/juju/resource-overlay.yaml
@@ -0,0 +1,9 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5applications:
6 snap-store-proxy:
7 resources:
8 snap-store-proxy: ../snap-store-proxy.snap
9 core: ../core20.snap
diff --git a/juju/test-bundle.yaml b/juju/test-bundle.yaml
0new file mode 10064410new file mode 100644
index 0000000..0d8ed91
--- /dev/null
+++ b/juju/test-bundle.yaml
@@ -0,0 +1,20 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5applications:
6 postgresql:
7 charm: cs:postgresql
8 channel: stable
9 resources:
10 wal-e: 0
11 num_units: 1
12 snap-store-proxy:
13 charm: ../snap-store-proxy-charm_ubuntu-20.04-amd64.charm
14 num_units: 1
15
16
17relations:
18- - postgresql:db-admin
19 - snap-store-proxy:db-admin
20
diff --git a/metadata.yaml b/metadata.yaml
0new file mode 10064421new file mode 100644
index 0000000..d4997d2
--- /dev/null
+++ b/metadata.yaml
@@ -0,0 +1,33 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3
4# For a complete list of supported options, see:
5# https://discourse.charmhub.io/t/charm-metadata-v2/3674/15
6name: snap-store-proxy-charm
7description: |
8 Install and manage a snap-store-proxy on premise.
9summary: |
10 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.
11maintainers:
12 - Wouter van Bommel <wouter.bommel>@canonical.com
13tags:
14 - Snap store proxy
15
16subordinate: false
17
18requires:
19 db-admin:
20 interface: pgsql
21 limit: 1
22 desciprtion: Postgresql database used for charm administration, needs to connect to the db-admin interface
23
24resources:
25 snap-store-proxy:
26 type: file
27 filename: snap-store-proxy.snap
28 description: Snapstore Proxy Snap, used for offline / airgapped installation
29
30 core:
31 type: file
32 filename: core20.snap
33 description: Core snap needed for the snap-store-proxy.snap
diff --git a/ols-vms.conf b/ols-vms.conf
0new file mode 10064434new file mode 100644
index 0000000..0ff8c80
--- /dev/null
+++ b/ols-vms.conf
@@ -0,0 +1,5 @@
1vm.architecture = amd64
2vm.release = focal
3
4[snap-store-proxy-charm]
5vm.packages = build-essential, virtualenv
diff --git a/requirements-dev.txt b/requirements-dev.txt
0new file mode 1006446new file mode 100644
index 0000000..0948d86
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,14 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3-r requirements.txt
4coverage
5flake8
6black
7flake8-black
8pytest<7 # Needed due to pytest-pythonpath
9pytest-pythonpath
10pytest-mock
11pytest-xdist
12pytest-sugar
13pytest-cov
14pytest-isort
diff --git a/requirements.txt b/requirements.txt
0new file mode 10064415new file mode 100644
index 0000000..c29abf0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
1ops >= 1.2.0
2ops-lib-pgsql
diff --git a/setup.cfg b/setup.cfg
0new file mode 1006443new file mode 100644
index 0000000..8e3f700
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,31 @@
1[flake8]
2max-line-length = 99
3select: E,W,F,C,N
4exclude:
5 venv
6 .git
7 build
8 dist
9 *.egg_info
10extend-ignore =
11 E203
12
13[coverage:report]
14# Regexes for lines to exclude from consideration
15exclude_lines =
16 # Have to re-enable the standard pragma
17 pragma: no cover
18
19 # Don't complain if non-runnable code isn't run:
20 if 0:
21 if __name__ == .__main__.:
22
23ignore_errors = True
24
25[tool:pytest]
26python_paths = lib src
27addopts = --numprocesses auto --showlocals --isort
28
29[isort]
30profile = black
31
diff --git a/src/charm.py b/src/charm.py
0new file mode 10075532new file mode 100755
index 0000000..65eeb23
--- /dev/null
+++ b/src/charm.py
@@ -0,0 +1,425 @@
1#!/usr/bin/env python3
2# Copyright 2021 Canonical
3# See LICENSE file for licensing details.
4#
5# Learn more at: https://juju.is/docs/sdk
6#
7
8import base64
9import hashlib
10import json
11import logging
12from urllib.parse import urlparse
13
14from ops.charm import CharmBase
15from ops.framework import StoredState
16from ops.lib import use
17from ops.main import main
18from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, ModelError
19
20from exceptions import ConfigException
21from helpers import all_config_options, configure_proxy, default_values
22from optionvalidation import OptionValidation
23from resource_helpers import (
24 create_database,
25 get_status,
26 hash_from_resource,
27 install_from_resource,
28 install_from_the_charmstore,
29 register_store,
30 snap_details,
31)
32
33logger = logging.getLogger(__name__)
34
35
36pgsql = use("pgsql", 1, "postgresql-charmers@lists.launchpad.net")
37
38DATABASE_NAME = "snap-store-proxy"
39
40
41class SnapstoreProxyCharm(CharmBase):
42 """Charm the service."""
43
44 _stored = StoredState()
45
46 def __init__(self, *args):
47 super().__init__(*args)
48
49 self.errors = []
50
51 self.framework.observe(self.on.config_changed, self._on_config_changed)
52 self.framework.observe(self.on.update_status, self._on_update_status)
53
54 # Database stuff
55 self._stored.set_default(db_uri=None)
56 self.db = pgsql.PostgreSQLClient(
57 self, "db-admin"
58 ) # 'db-admin' relation in metadata.yaml
59 self.framework.observe(
60 self.db.on.database_relation_joined, self._on_database_relation_joined
61 )
62 self.framework.observe(
63 self.db.on.master_changed, self._on_database_master_changed
64 )
65 self.framework.observe(
66 self.db.on.database_relation_broken, self._on_database_relation_broken
67 )
68
69 # Actions
70 self.framework.observe(self.on.status_action, self._on_status_action)
71
72 def _on_config_changed(self, event):
73 """Handle update on the configuration of the proxy.
74
75 Here we handle changes to:
76 * domain
77 * username
78 * password
79 """
80 self.unit.status = MaintenanceStatus("Installing application snap")
81 self.handle_installation()
82
83 # Wait until there is a database relation ready, skip everything if not
84 if self._stored.db_uri is None:
85 logger.debug("Could not find a value for the database uri")
86 self.evaluate_status()
87 event.defer()
88 return
89
90 if getattr(self._stored, "snap_install_source", None) is None:
91 self.errors.append("Snap has not been installed")
92 logger.warning("Store is not installed")
93 self.evaluate_status()
94 event.defer()
95 return
96
97 self.handle_pgsql_dsn_change()
98
99 self.unit.status = MaintenanceStatus("Configuring application snap")
100
101 # Handle the validation and interal storage of the configuration options
102 logger.info("Parsing configuration options")
103 try:
104 self.validate_config_item()
105 except ConfigException as exc:
106 self.errors.append(f"Could not parse the config option for {exc.message}")
107 self.evaluate_status()
108 return
109 else:
110 # With config options stored, we can try to configure the snap
111 for option in all_config_options:
112
113 if getattr(self._stored, "registered", False):
114 # We are registered, so we hold any updates of the domain
115 if option == "domain":
116 continue
117
118 if option in self.config:
119 value = self.config[option]
120 logger.debug(f"Storing {value} in {option}")
121 self._stored.__setattr__(option, value)
122
123 # Then configure it, and only then we can attempt to configure it
124 self.handle_config()
125
126 if not getattr(self._stored, "registered", False):
127 self.unit.status = MaintenanceStatus("Registering proxy")
128 self.handle_registration(event)
129
130 self.evaluate_status()
131
132 def validate_config_item(self):
133 # We have some dependencie between the tls certificate and the domain
134 for config_name, test_type in all_config_options.items():
135 if config_name in self.config:
136 if self.config[config_name]:
137 # Skip None values
138 test = OptionValidation.new(test_type)
139 test.validate(config_name, self.config[config_name])
140
141 def handle_installation(self):
142 """Install the snap-store-proxy.
143
144 We can install updates if provided via resources, but we cannot switch
145 between resource and store installation"""
146
147 # If we have the attribute snap_install_source with the value set to resource,
148 # or it is absent, we can try to install from a resource
149 if getattr(self._stored, "snap_install_source", None) in [None, "resource"]:
150 try:
151 snap = self.model.resources.fetch("snap-store-proxy")
152 core = self.model.resources.fetch("core")
153 snap_md5 = hash_from_resource(snap)
154 core_md5 = hash_from_resource(core)
155 if (getattr(self._stored, "snap_md5", None) != snap_md5) or (
156 getattr(self._stored, "core_md5", None) != core_md5
157 ):
158 install_from_resource(core, snap)
159 self._stored.core_md5 = core_md5
160 self._stored.snap_md5 = snap_md5
161 self._stored.snap_install_source = "resource"
162 logger.info("Installed the proxy from the resource")
163 except ModelError:
164 logger.info("Could not install snap from resource, attempting online")
165
166 if not hasattr(self._stored, "snap_install_source"):
167 try:
168 install_from_the_charmstore("snap-store-proxy")
169 self._stored.snap_install_source = "store"
170 except Exception:
171 logger.info(
172 "Could not install online, retry again if resources are updated"
173 )
174 self.errors.append("Failed to install the snap-store-proxy")
175
176 def handle_config(self):
177
178 # We will store dicts of item, potential new value in this list
179 to_update = []
180
181 for item in all_config_options.keys():
182 logger.debug(f"Testing config item {item} for a new value")
183
184 # try to get the value from the charm config, if set
185 new_value = getattr(self._stored, item, default_values.get(item, None))
186 if new_value is None:
187 # No value set, or default for this item, skip
188 logger.debug(f"No default for {item}, skipping")
189 continue
190
191 # Now see if we have already stored it.
192 current_md5 = getattr(self._stored, f"{item}_md5", None)
193 new_md5 = hashlib.md5(new_value.encode()).hexdigest()
194 if current_md5 is None:
195 # A new entry, so we handle it
196 to_update.append({"name": item, "value": new_value, "md5": new_md5})
197 logger.info(f"Adding new item {item}")
198 elif current_md5 != new_md5:
199 # a different md5, so we update
200 to_update.append({"name": item, "value": new_value, "md5": new_md5})
201 logger.info(f"Updating value for {item}")
202 else:
203 logger.info(f"Skipping update for {item}, no new value")
204
205 self.do_snap_updates(to_update)
206
207 def do_snap_updates(self, updates):
208 """Here we handle the updates of the proxy snap.
209
210 A different method, as it will contain mappings between items and the
211 actual snap entries"""
212
213 has_registration_bundle = False
214 for item in updates:
215 has_registration_bundle |= item["name"] == "registration_bundle"
216
217 # If the has_registration_bundle is true, we omit changes to the domain
218 for item in updates:
219 if item["name"] == "domain":
220 logger.info("Configuring the domain")
221 # Handle the storage of the domain
222 # but we cannot actually store the url, so we need to break it
223 # down to the hostname only
224 hostname = urlparse(item["value"]).netloc.split(":")[0]
225 configure_proxy("proxy.domain", hostname)
226 else:
227 logger.debug(f"Nothing todo for {item['name']}")
228
229 # Ensure we record the values in the charm storage
230 setattr(self._stored, item["name"], item["value"])
231 setattr(self._stored, f"{item['name']}_md5", item["value"])
232
233 if has_registration_bundle:
234 # If this config option is set, we basically ignore the username and
235 # password and domain settings. And we take all from this field.
236 for item in updates:
237 if item["name"] == "registration_bundle":
238 break
239 if item["value"]:
240 # A bit harsh, but this way we ensure that only this bundle is used
241 self._stored.registered = True
242 else:
243 # The value is removed / reset, so act accordingly
244 self._stored.registered = False
245 return
246
247 data = json.loads(base64.b64decode(item["value"]))
248 for entries in [
249 "domain",
250 "private_key",
251 "public_key",
252 "store_id",
253 # "store_assertion", needed once airgap mode is implemented
254 ]:
255 # see if any of these keys are missing
256 if entries not in data.keys():
257 self.errors.append(
258 f"Missing key {entries} in the registration bundle"
259 )
260
261 if self.errors:
262 # Incomplete bundle(?) so stop doing anything
263 return
264
265 # Now make 2 lists, option names and values where the indexes
266 # correspond with eachother
267 options = []
268 values = []
269 for entry in data.keys():
270 if entry == "domain":
271 options.append("proxy.domain")
272 values.append(data[entry])
273 elif entry == "public_key":
274 options.append("proxy.key.public")
275 values.append(data[entry])
276 elif entry == "private_key":
277 options.append("proxy.key.private")
278 values.append(data[entry])
279 elif entry == "store_id":
280 options.append("internal.store.id")
281 values.append(data[entry])
282 configure_proxy(options, values, force=True)
283
284 def handle_registration(self, event):
285 """Try to register the store.
286
287 Depends on both a username and password provided for the registration.
288 """
289 if getattr(self._stored, "registered", False):
290 logger.info("This proxy is already registered")
291 return
292
293 if self._stored.db_uri is None:
294 logger.info("The database relation is not yet setup")
295 # defer, so we get retried in the future
296 event.defer()
297 return
298
299 if (
300 getattr(self._stored, "username", None) is None
301 or getattr(self._stored, "password", None) is None
302 ):
303 logger.info("Missing username or password, unable to register")
304 return
305
306 if getattr(self._stored, "domain", None) is None:
307 logger.info("Missing domain needed to register")
308 return
309
310 registered = register_store(self._stored.username, self._stored.password)
311 if registered:
312 logger.info("Proxy is registered successfully")
313 else:
314 logger.info("Failed to register, check logging to find the reason")
315 self._stored.registered = registered
316
317 def handle_pgsql_dsn_change(self):
318 """If the database is configured, then we try to configure the snap."""
319 installation_source = getattr(self._stored, "snap_install_source", None)
320 database_url = getattr(self._stored, "db_uri", None)
321 database_created = getattr(self._stored, "database_created", False)
322 if installation_source and database_url and not database_created:
323 logger.info("Handling the database configuration of the proxy")
324 if not hasattr(self._stored, "database_created"):
325 create_database(self._stored.db_uri)
326 self._stored.database_created = True
327
328 def evaluate_status(self):
329 """This is the only place where we can set the status to Active"""
330
331 if self.errors:
332 self.unit.status = BlockedStatus("\n".join(self.errors).strip())
333 return
334
335 if self._stored.db_uri is None:
336 self.unit.status = BlockedStatus("Missing database relation")
337 return
338
339 if not hasattr(self._stored, "username") and (
340 not getattr(self._stored, "registered", False)
341 ):
342 self.unit.status = BlockedStatus(
343 "Missing username, needed for registration"
344 )
345 return
346
347 if not hasattr(self._stored, "password") and (
348 not getattr(self._stored, "registered", False)
349 ):
350 self.unit.status = BlockedStatus(
351 "Missing password, needed for registration"
352 )
353 return
354
355 if not getattr(self._stored, "registered", False):
356 self.unit.status = BlockedStatus(
357 "The unit is not registered, please supply username and password"
358 )
359 return
360
361 self.unit.status = ActiveStatus()
362
363 def _on_update_status(self, _):
364
365 # Only the leader should update, to prevent conflicting updates
366 if not self.unit.is_leader():
367 return
368
369 version = "unknown"
370 try:
371 (name, version, snap_revision, channel, publisher, notes) = snap_details(
372 "snap-store-proxy"
373 )
374 except Exception as exc:
375 logger.info("Failure during determination of snap version", exc_info=exc)
376 logger.info(f"Setting version to {version}")
377 self.unit.set_workload_version(f"{version}")
378
379 def _on_database_relation_joined(self, event: pgsql.DatabaseRelationJoinedEvent):
380 logger.debug("Database relation joined")
381 if self.unit.is_leader():
382 logger.info("As leader informing the database of our wishes.")
383 # Provide requirements to the PostgreSQL server.
384 event.database = DATABASE_NAME # Request database named mydbname
385 event.extensions = [
386 "btree_gist",
387 ] # Request the btree_gist extension installed
388 elif event.database != DATABASE_NAME:
389 logger.info("Got some database event, but not for us retrying.")
390 # Leader has not yet set requirements. Defer, incase this unit
391 # becomes leader and needs to perform that operation.
392 event.defer()
393 return
394
395 def _on_database_master_changed(self, event: pgsql.MasterChangedEvent):
396 logger.debug("Called due to database master change")
397 if event.database != DATABASE_NAME:
398 # Leader has not yet set requirements. Wait until next event,
399 # or risk connecting to an incorrect database.
400 logger.info("Database is not yet setup by master, waiting for a new event.")
401 return
402
403 if event.master is not None:
404 logger.info("The database connection is ready, configuring proxy.")
405 self._stored.db_uri = event.master.uri
406 self.handle_pgsql_dsn_change()
407
408 self.evaluate_status()
409
410 def _on_database_relation_broken(self, event):
411 logger.debug("Erasing the database connection string.")
412 self._stored.db_uri = None
413
414 self.evaluate_status()
415
416 def _on_status_action(self, event):
417 output, exitcode = get_status()
418 if exitcode == 0:
419 event.set_results({"result": output})
420 else:
421 event.fail(f"Failed to get status, errorcode {exitcode}\n, result {output}")
422
423
424if __name__ == "__main__":
425 main(SnapstoreProxyCharm)
diff --git a/src/exceptions.py b/src/exceptions.py
0new file mode 100644426new file mode 100644
index 0000000..661a7c4
--- /dev/null
+++ b/src/exceptions.py
@@ -0,0 +1,23 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5
6class ConfigException(Exception):
7 def __init__(self, message):
8 self.message = message
9
10
11class UnknownTypeException(ConfigException):
12 def __init__(self, message):
13 super().__init__(
14 f"Unknown config type defined '{message}', don't know how to validate input."
15 )
16
17
18class InvalidTypeException(ConfigException):
19 def __init__(self, message, expected, value):
20 super().__init__(
21 f"Charm option '{message}' contains the wrong datatype. "
22 f"Expected type {expected}, given {value}"
23 )
diff --git a/src/helpers.py b/src/helpers.py
0new file mode 10064424new file mode 100644
index 0000000..07cf19c
--- /dev/null
+++ b/src/helpers.py
@@ -0,0 +1,60 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4import logging
5from pathlib import Path
6from subprocess import run
7
8import yaml
9
10logger = logging.getLogger(__name__)
11
12
13def configure_proxy(option, value, force=False):
14 """Simple wrapper around calling snap settings"""
15
16 if isinstance(option, list):
17 # Make sure that we set multiple values in one go
18 combined = []
19 for count, entry in enumerate(option):
20 combined.append(f'{entry}="{value[count]}"')
21 command = ["snap-store-proxy", "config"] + combined
22 else:
23 logger.debug(f"Running config for option {option} with value {value}")
24 command = ["snap-store-proxy", "config", f"{option}={value}"]
25
26 if force:
27 command.append("--force")
28
29 run(command)
30
31
32def config_options():
33 """Helper function to take the charm config.yaml and make it a dict.
34
35 Will return a dict with the types expected for the config items and a dict
36 with the default values (if any) for the config item.
37 Goal is to prevent duplication of this type of registration."""
38
39 config_file = Path(__file__).parent.parent / "config.yaml"
40 with open(config_file) as fh:
41 config = yaml.safe_load(fh)
42 type_dict = {}
43 default_value = {}
44 for item in config["options"].keys():
45 default = config["options"][item]["default"]
46 if default:
47 default_value.update({item: default})
48 if item == "domain":
49 type_dict.update({item: "url"})
50 elif item == "username":
51 type_dict.update({item: "email"})
52 elif item == "registration_bundle":
53 type_dict.update({item: "base64+json"})
54 else:
55 type_dict.update({item: config["options"][item]["type"]})
56
57 return type_dict, default_value
58
59
60all_config_options, default_values = config_options()
diff --git a/src/optionvalidation.py b/src/optionvalidation.py
0new file mode 10064461new file mode 100644
index 0000000..2e8efae
--- /dev/null
+++ b/src/optionvalidation.py
@@ -0,0 +1,76 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5import base64
6import json
7import re
8from logging import Logger
9
10from exceptions import InvalidTypeException, UnknownTypeException
11
12logger = Logger(__name__)
13
14
15class OptionValidation:
16 @staticmethod
17 def new(test_type):
18 if test_type == "string":
19 return OptionValidationString()
20 if test_type == "url":
21 return OptionValidationURL()
22 if test_type == "email":
23 return OptionValidationEmail()
24 if test_type == "base64+json":
25 return OptionValidationBase64Json()
26 raise UnknownTypeException(test_type)
27
28
29class OptionValidationString(OptionValidation):
30 @staticmethod
31 def validate(config_name, value):
32 logger.debug("Validating the type string for {config_name}")
33 if not isinstance(value, str):
34 raise InvalidTypeException(config_name, "string", value)
35
36
37class OptionValidationURL(OptionValidation):
38 @staticmethod
39 def validate(config_name, value):
40 logger.debug("Validating the type url for {config_name}")
41 regex = re.compile(
42 r"^(?:http)://" # http:// or https://
43 r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|"
44 r"[A-Z0-9-]{2,}\.?)|" # domain...
45 r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip
46 r"(?::\d+)?" # optional port
47 r"(?:/?|[/?]\S+)$",
48 re.IGNORECASE,
49 )
50 if re.match(regex, value) is None:
51 raise InvalidTypeException(config_name, "url", value)
52
53
54class OptionValidationEmail(OptionValidation):
55 @staticmethod
56 def validate(config_name, value):
57 logger.debug("Validating the type email for {config_name}")
58 regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
59 if (not isinstance(value, str)) or (not re.fullmatch(regex, value)):
60 raise InvalidTypeException(config_name, "email", value)
61
62
63class OptionValidationBase64Json(OptionValidation):
64 @staticmethod
65 def validate(config_name, value):
66 logger.debug("Validating the type base64+json for {config_name}")
67 try:
68 result = base64.b64decode(value)
69 print(result)
70 except Exception:
71 raise InvalidTypeException(config_name, "base64", value)
72
73 try:
74 json.loads(result)
75 except Exception:
76 raise InvalidTypeException(config_name, "json", value)
diff --git a/src/resource_helpers.py b/src/resource_helpers.py
0new file mode 10064477new file mode 100644
index 0000000..007683b
--- /dev/null
+++ b/src/resource_helpers.py
@@ -0,0 +1,71 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4
5import hashlib
6import logging
7from subprocess import PIPE, CalledProcessError, check_output, run
8
9logger = logging.getLogger(__name__)
10
11
12def install_from_the_charmstore(snap, channel="stable"):
13 return run(["snap", "install", f"--channel={channel}", snap], check=True)
14
15
16def install_from_resource(core, proxy):
17 run(["snap", "install", "--dangerous", core], check=True)
18 run(["snap", "install", "--dangerous", proxy], check=True)
19
20
21def hash_from_resource(resource):
22 with open(resource, mode="rb") as fh:
23 hash = hashlib.md5()
24 while True:
25 buf = fh.read(4096)
26 if not buf:
27 break
28 hash.update(buf)
29 return hash.hexdigest()
30
31
32def snap_details(snap):
33 return (
34 run(["snap", "list", snap], stdout=PIPE)
35 .stdout.decode()
36 .strip()
37 .split("\n")[-1]
38 .split()
39 )
40
41
42def register_store(username, password):
43 """Use the provided username and password in an attempt to register.
44
45 Returns a boolean indicating success or failure"""
46 env = {
47 "SNAPSTORE_EMAIL": username,
48 "SNAPSTORE_PASSWORD": password,
49 }
50 try:
51 check_output(
52 ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env
53 )
54 except CalledProcessError as error:
55 logger.error(f"Registration of proxy failed, error; {error.output}")
56 return False
57 else:
58 logger.info("Proxy registred sucessfully")
59 return True
60
61
62def create_database(uri):
63 """Let the proxy configure the database."""
64 run(["/snap/bin/snap-store-proxy", "create-database", uri])
65
66
67def get_status():
68 """Return the status result and exitcode"""
69 result = run(["/snap/bin/snap-store-proxy", "status"], capture_output=True)
70
71 return result.stdout.decode("utf-8"), result.returncode
diff --git a/tests/__init__.py b/tests/__init__.py
0new file mode 10064472new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/test_charm.py b/tests/test_charm.py
1new file mode 10064473new file mode 100644
index 0000000..6dc62f1
--- /dev/null
+++ b/tests/test_charm.py
@@ -0,0 +1,716 @@
1# Copyright 2021 Canonical
2# See LICENSE file for licensing details.
3#
4# Learn more about testing at: https://juju.is/docs/sdk/testing
5#
6
7import base64
8import logging
9import subprocess
10from pathlib import Path
11from unittest.mock import MagicMock, patch
12
13import pytest
14import yaml
15from ops.model import ActiveStatus, BlockedStatus
16from ops.testing import Harness
17
18from charm import DATABASE_NAME, SnapstoreProxyCharm
19
20
21class CharmTest:
22 def setup_method(self):
23 self.harness = Harness(SnapstoreProxyCharm)
24 self.harness.begin()
25 cfg = yaml.safe_load(Path("config.yaml").read_text())
26 # a future, KeyError "default"; means no default value is set for the
27 # newly introduced config option
28 self.config_defaults = {
29 key: cfg["options"][key]["default"] for key in cfg["options"].keys()
30 }
31
32 def teardown_method(self):
33 self.harness.cleanup()
34
35
36class TestConfig(CharmTest):
37 def test_default_config_values(self):
38 # Our implicit requirement is that the database uri has been set.
39 self.harness.charm._stored.db_uri = "postgresql://fake/database"
40 self.harness.charm._stored.snap_install_source = "store"
41 self.harness.charm._stored.database_created = True
42
43 # Since valid values will be pushed on to the proxy, we need to stub that here
44 with patch("charm.configure_proxy"):
45 # Ensure that the default values are set, as expected
46 self.harness.update_config()
47
48 for key, value in self.config_defaults.items():
49 # Accept the default value of None, for items that don't have a default value
50 assert getattr(self.harness.charm._stored, key, None) == value
51
52 def test_config_changed_option_update(self):
53 # Our implicit requirement is that the database uri has been set.
54 self.harness.charm._stored.db_uri = "postgresql://fake/database"
55 self.harness.charm._stored.snap_install_source = "store"
56 self.harness.charm._stored.database_created = True
57
58 # Since valid values will be pushed on to the proxy, we need to stub that here
59 with patch("charm.configure_proxy"):
60 self.harness.update_config({"domain": "http://foo.com"})
61
62 assert self.harness.charm._stored.domain == "http://foo.com"
63
64 def test_on_config_changed(self):
65 # When there is an upodate, but no database set, we should end up with an
66 # Missing database relation blocked state
67 event = MagicMock()
68 self.harness.charm._stored.snap_install_source = "store"
69
70 self.harness.charm._on_config_changed(event)
71
72 event.defer.assert_called_once()
73 assert self.harness.charm.unit.status == BlockedStatus(
74 "Missing database relation"
75 )
76
77 def test_on_config_changed_missing_snap(self):
78 # Here we test what happens if the snap installation fails for some reason
79 event = MagicMock()
80 self.harness.charm._stored.db_uri = "postgresql://fake/database"
81
82 with patch.object(
83 self.harness.charm, "handle_installation"
84 ) as patched_handle_installation:
85 self.harness.charm._on_config_changed(event)
86
87 patched_handle_installation.assert_called_once()
88 event.defer.assert_called_once()
89 assert not getattr(self.harness.charm._stored, "snap_install_source", None)
90 assert self.harness.charm.unit.status == BlockedStatus(
91 "Snap has not been installed"
92 )
93
94 def test_field_is_updated(self):
95 # Check that we have an update is the md5 matches
96
97 self.harness.charm._stored.domain = "My new domain"
98 self.harness.charm._stored.domain_md5 = "test"
99
100 with patch.object(
101 self.harness.charm, "do_snap_updates"
102 ) as patched_do_snap_updates:
103 self.harness.charm.handle_config()
104
105 # This will be called with an array of dicts, containing the new
106 # md5 value and the new resource value
107 patched_do_snap_updates.assert_called_once_with(
108 [
109 {
110 "name": "domain",
111 "value": "My new domain",
112 "md5": "79911e6e27b92c112ea0034734bf9f14",
113 }
114 ]
115 )
116
117 def test_field_is_not_updated(self, caplog):
118 caplog.clear()
119 caplog.set_level(logging.INFO)
120
121 self.harness.charm._stored.snap_install_source = "store"
122 self.harness.charm._stored.domain = "My new domain"
123 # We know the md5
124 self.harness.charm._stored.domain_md5 = "79911e6e27b92c112ea0034734bf9f14"
125
126 with patch.object(
127 self.harness.charm, "do_snap_updates"
128 ) as patched_do_snap_updates:
129 self.harness.charm.handle_config()
130
131 # As there are no fields to update, we expect an empty array
132 patched_do_snap_updates.assert_called_once_with([])
133 assert len(caplog.records) == 1
134 assert caplog.records[0].message == "Skipping update for domain, no new value"
135
136 @pytest.mark.parametrize(
137 "config,value",
138 [
139 ("domain", "not.valid"),
140 ("username", 1),
141 ("password", 1),
142 ],
143 )
144 def test_config_validation_invalid(self, config, value):
145 """
146 Here we test both that tests work and fail.
147
148 Using 2 differen parameterize decorators, mean that they iterate over
149 eachother, basically multiplying the number of tests executed.
150 """
151 # Our implicit requirement is that the database uri has been set.
152 self.harness.charm._stored.db_uri = "postgresql://fake/database"
153 self.harness.charm.errors = []
154 self.harness.charm._stored.snap_install_source = "store"
155 self.harness.charm._stored.database_created = True
156
157 self.harness.update_config({config: value})
158
159 assert len(self.harness.charm.errors) == 1
160 assert (
161 f"Could not parse the config option for Charm option '{config}'"
162 in self.harness.charm.errors[0]
163 )
164
165 @pytest.mark.parametrize(
166 "config,value",
167 [
168 ("domain", "http://valid.domain"),
169 ("username", "user@domain.country"),
170 ("password", "a valid string"),
171 ],
172 )
173 def test_config_validation_valid(self, config, value):
174 """
175 Here we test both that tests work and fail.
176
177 Using 2 differen parameterize decorators, mean that they iterate over
178 eachother, basically multiplying the number of tests executed.
179 """
180 # Our implicit requirement is that the database uri has been set.
181 self.harness.charm._stored.db_uri = "postgresql://fake/database"
182 self.harness.charm._stored.snap_install_source = "store"
183 self.harness.charm._stored.database_created = True
184
185 # Since valid values will be pushed on to the proxy, we need to stub that here
186 with patch("charm.configure_proxy"):
187 self.harness.update_config({config: value})
188 assert getattr(self.harness.charm._stored, config) == value
189
190 def test_handle_pgsql_dsn_change(self):
191 self.harness.charm._stored.db_uri = "postgresql://fake/database"
192 self.harness.charm._stored.snap_install_source = "store"
193
194 with patch("charm.create_database") as patched_proxy:
195 self.harness.charm.handle_pgsql_dsn_change()
196
197 assert patched_proxy.called
198 patched_proxy.assert_called_once_with("postgresql://fake/database")
199
200
201class TestRegistrationBundle(CharmTest):
202 # Split off to ensure that tests are specific for what they do
203 # data that is passed to do_snap_updates has alrady been validated
204 # via the optionvalidation functions, so no need to test invalid data
205 def test_no_bundle(self):
206 assert not hasattr(self.harness.charm._stored, "registered")
207
208 self.harness.charm.do_snap_updates({})
209
210 # If we start without the registered key, and provide no bundle, no
211 # key will be added
212 assert not hasattr(self.harness.charm._stored, "registered")
213
214 def test_registration_removed(self):
215 setattr(self.harness.charm._stored, "registered", True)
216
217 self.harness.charm.do_snap_updates(
218 [{"name": "registration_bundle", "value": None}]
219 )
220
221 # We started with a key, so it will stay with us
222 assert not getattr(self.harness.charm._stored, "registered")
223
224 def test_has_incomplete_bundle_missing_all(self):
225 to_update = [
226 {
227 "name": "registration_bundle",
228 "value": base64.b64encode("{}".encode("UTF-8")),
229 }
230 ]
231
232 self.harness.charm.do_snap_updates(to_update)
233 assert len(self.harness.charm.errors) == 4
234 assert (
235 self.harness.charm.errors[0]
236 == "Missing key domain in the registration bundle"
237 )
238
239 def test_has_incomplete_bundle_added_domain(self):
240 to_update = [
241 {
242 "name": "registration_bundle",
243 "value": base64.b64encode('{"domain": "sdfsd"}'.encode("UTF-8")),
244 }
245 ]
246
247 self.harness.charm.do_snap_updates(to_update)
248 assert len(self.harness.charm.errors) == 3
249 assert (
250 self.harness.charm.errors[0]
251 == "Missing key private_key in the registration bundle"
252 )
253
254 def test_has_incomplete_bundle_added_private_key(self):
255 to_update = [
256 {
257 "name": "registration_bundle",
258 "value": base64.b64encode(
259 '{"domain": "sdfsd",' '"private_key": "12"}'.encode("UTF-8")
260 ),
261 }
262 ]
263
264 self.harness.charm.do_snap_updates(to_update)
265 assert len(self.harness.charm.errors) == 2
266 assert (
267 self.harness.charm.errors[0]
268 == "Missing key public_key in the registration bundle"
269 )
270
271 def test_has_incomplete_bundle_added_public_key(self):
272 to_update = [
273 {
274 "name": "registration_bundle",
275 "value": base64.b64encode(
276 '{"domain": "sdfsd",'
277 '"public_key": "12",'
278 '"private_key": "12"}'.encode("UTF-8")
279 ),
280 }
281 ]
282
283 self.harness.charm.do_snap_updates(to_update)
284 assert len(self.harness.charm.errors) == 1
285 assert (
286 self.harness.charm.errors[0]
287 == "Missing key store_id in the registration bundle"
288 )
289
290 # Possibly needed once airgap mode has been implemented
291 # def test_has_incomplete_bundle_added_store_id(self):
292 # to_update = [
293 # {
294 # "name": "registration_bundle",
295 # "value": base64.b64encode(
296 # '{"domain": "sdfsd",'
297 # '"public_key": "12",'
298 # '"store_id": "12",'
299 # '"private_key": "12"}'.encode("UTF-8")
300 # ),
301 # }
302 # ]
303
304 # self.harness.charm.do_snap_updates(to_update)
305 # assert len(self.harness.charm.errors) == 1
306 # assert (
307 # self.harness.charm.errors[0]
308 # == "Missing key store_assertion in the registration bundle"
309 # )
310
311 def test_has_complete_bundle(self):
312 to_update = [
313 {
314 "name": "registration_bundle",
315 "value": base64.b64encode(
316 '{"domain": "sdfsd",'
317 '"public_key": "10",'
318 '"store_id": "11",'
319 '"store_assertion": "0",'
320 '"private_key": "12"}'.encode("UTF-8")
321 ),
322 }
323 ]
324 with patch("charm.configure_proxy") as patched_configure:
325 self.harness.charm.do_snap_updates(to_update)
326
327 patched_configure.assert_called_once_with(
328 [
329 "proxy.domain",
330 "proxy.key.public",
331 "internal.store.id",
332 "proxy.key.private",
333 ],
334 ["sdfsd", "10", "11", "12"],
335 force=True,
336 )
337
338 assert getattr(self.harness.charm._stored, "registered", None)
339
340
341class TestStatus(CharmTest):
342 def test_status_missing_db_uri(self):
343 self.harness.charm._stored.db_uri = None
344
345 self.harness.charm.evaluate_status()
346
347 assert self.harness.charm.unit.status.message == "Missing database relation"
348 assert isinstance(self.harness.charm.unit.status, BlockedStatus)
349
350 def test_status_missing_username(self):
351 self.harness.charm._stored.db_uri = "Filled, but useless"
352
353 self.harness.charm.evaluate_status()
354
355 assert (
356 self.harness.charm.unit.status.message
357 == "Missing username, needed for registration"
358 )
359 assert isinstance(self.harness.charm.unit.status, BlockedStatus)
360
361 def test_status_missing_password(self):
362 self.harness.charm._stored.db_uri = "Filled, but useless"
363 self.harness.charm._stored.username = "minion"
364
365 self.harness.charm.evaluate_status()
366
367 assert (
368 self.harness.charm.unit.status.message
369 == "Missing password, needed for registration"
370 )
371 assert isinstance(self.harness.charm.unit.status, BlockedStatus)
372
373 def test_status_not_registered(self):
374 self.harness.charm._stored.db_uri = "Filled, but useless"
375 self.harness.charm._stored.username = "minion"
376 self.harness.charm._stored.password = "minions password"
377 self.harness.charm._stored.registered = None
378
379 self.harness.charm.evaluate_status()
380
381 assert (
382 self.harness.charm.unit.status.message
383 == "The unit is not registered, please supply username and password"
384 )
385 assert isinstance(self.harness.charm.unit.status, BlockedStatus)
386
387 def test_unit_ready(self):
388 self.harness.charm._stored.db_uri = "Filled, but useless"
389 self.harness.charm._stored.username = "minion"
390 self.harness.charm._stored.password = "minions password"
391 self.harness.charm._stored.registered = True
392
393 self.harness.charm.evaluate_status()
394
395 assert self.harness.charm.unit.status.message == ""
396
397
398class TestEvents(CharmTest):
399 def test_on_update_status(self):
400 self.harness.disable_hooks()
401 self.harness.set_leader(True)
402 self.harness.enable_hooks()
403
404 with patch("charm.snap_details") as mocked_snap_details:
405 mocked_snap_details.return_value = (None, 12, None, None, None, None)
406 self.harness.charm._on_update_status(None)
407
408 assert mocked_snap_details.called_once
409 mocked_snap_details.assert_called_once_with("snap-store-proxy")
410 assert self.harness.get_workload_version() == "12"
411
412 def test_on_update_status_exception(self):
413 self.harness.disable_hooks()
414 self.harness.set_leader(True)
415 self.harness.enable_hooks()
416
417 with patch("charm.snap_details") as mocked_snap_details:
418 mocked_snap_details.side_effect = subprocess.CalledProcessError(1, ["snap"])
419 self.harness.charm._on_update_status(None)
420
421 assert self.harness.get_workload_version() == "unknown"
422
423 def test_on_update_status_not_leader(self):
424 self.harness.disable_hooks()
425 self.harness.set_leader(False)
426 self.harness.enable_hooks()
427
428 with patch("charm.snap_details") as patched_snap_details:
429 self.harness.charm._on_update_status(None)
430
431 patched_snap_details.assert_not_called()
432
433 def test_on_database_relation_joined_as_leader(self):
434 # Disable the hooks, as the pgsql library will call get-leader via subprocess
435 self.harness.disable_hooks()
436 self.harness.set_leader(True)
437 self.harness.enable_hooks()
438 mocked_event = MagicMock()
439
440 self.harness.charm._on_database_relation_joined(mocked_event)
441
442 assert mocked_event.database == DATABASE_NAME
443 assert mocked_event.extensions == ["btree_gist"]
444
445 def test_on_database_relation_joined_as_not_leader(self):
446 # Disable the hooks, as the pgsql library will call get-leader via subprocess
447 self.harness.disable_hooks()
448 self.harness.set_leader(False)
449 self.harness.enable_hooks()
450 mocked_event = MagicMock()
451 mocked_event.database == "snap-store-proxy"
452
453 self.harness.charm._on_database_relation_joined(mocked_event)
454
455 assert mocked_event.defer.called_once
456 mocked_event.defer.assert_called_once_with()
457
458 def test_on_database_master_changed_wrong_database(self):
459 mocked_event = MagicMock()
460 mocked_event.database = "something invalid"
461
462 assert self.harness.charm._stored.db_uri is None
463
464 self.harness.charm._on_database_master_changed(mocked_event)
465
466 assert self.harness.charm._stored.db_uri is None
467
468 def test_on_database_master_changed_correct_database(self):
469 dummy_uri = "postgresql://test@fake.db"
470 mocked_event = MagicMock()
471 mocked_event.database = DATABASE_NAME
472 mocked_event.master.uri = dummy_uri
473
474 self.harness.charm._stored.snap_install_source = "store"
475
476 assert self.harness.charm._stored.db_uri is None
477
478 with patch("charm.create_database") as mocked_call:
479 self.harness.charm._on_database_master_changed(mocked_event)
480
481 assert self.harness.charm._stored.db_uri == dummy_uri
482 mocked_call.assert_called_once_with(dummy_uri)
483
484 def test_on_database_relation_broken(self):
485 self.harness.charm._stored.db_uri = "Some value"
486 with patch.object(
487 self.harness.charm, "evaluate_status"
488 ) as patched_evaluate_status:
489 self.harness.charm._on_database_relation_broken(None)
490
491 assert self.harness.charm._stored.db_uri is None
492 patched_evaluate_status.assert_called_once_with()
493
494 def test_on_status_action_succeeds(self):
495 event = MagicMock()
496 with patch("charm.get_status") as patched_get_status:
497 patched_get_status.return_value = ("String", 0)
498 self.harness.charm._on_status_action(event)
499
500 patched_get_status.assert_called_once_with()
501 event.set_results.assert_called_once_with({"result": "String"})
502
503 def test_on_status_action_fails(self):
504 event = MagicMock()
505 with patch("charm.get_status") as patched_get_status:
506 patched_get_status.return_value = ("String", 1)
507 self.harness.charm._on_status_action(event)
508
509 patched_get_status.assert_called_once_with()
510 event.fail.assert_called_once_with(
511 "Failed to get status, errorcode 1\n, result String"
512 )
513
514
515class TestRegistration(CharmTest):
516 def test_handle_registration_no_database(self, caplog):
517 caplog.clear()
518 caplog.set_level(logging.INFO)
519 event = MagicMock()
520
521 self.harness.charm.handle_registration(event)
522
523 assert len(caplog.records) == 1
524 assert caplog.records[0].message == "The database relation is not yet setup"
525
526 def test_handle_registration_no_details(self, caplog):
527 caplog.clear()
528 caplog.set_level(logging.INFO)
529
530 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
531 event = MagicMock()
532
533 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
534 self.harness.charm.handle_registration(event)
535
536 assert len(caplog.records) == 1
537 assert (
538 caplog.records[0].message
539 == "Missing username or password, unable to register"
540 )
541
542 def test_handle_registration_already_registered(self, caplog):
543 caplog.clear()
544 caplog.set_level(logging.INFO)
545
546 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
547 self.harness.charm._stored.registered = True
548 event = MagicMock()
549
550 self.harness.charm.handle_registration(event)
551
552 assert len(caplog.records) == 1
553 assert caplog.records[0].message == "This proxy is already registered"
554
555 def test_handle_registration_missing_domain(self, caplog):
556 caplog.clear()
557 caplog.set_level(logging.INFO)
558
559 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
560 self.harness.charm._stored.registered = False
561 self.harness.charm._stored.username = "user@domain.country"
562 self.harness.charm._stored.password = "password"
563 event = MagicMock()
564
565 self.harness.charm.handle_registration(event)
566
567 assert len(caplog.records) == 1
568 assert caplog.records[0].message == "Missing domain needed to register"
569
570 def test_handle_registration_register_succeeds(self, caplog):
571 caplog.clear()
572 caplog.set_level(logging.INFO)
573
574 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
575 self.harness.charm._stored.registered = False
576 self.harness.charm._stored.username = "user@domain.country"
577 self.harness.charm._stored.password = "password"
578 self.harness.charm._stored.domain = "http://localhost"
579 event = MagicMock()
580
581 with patch("charm.register_store") as patched_registration:
582 patched_registration.return_value = True
583 self.harness.charm.handle_registration(event)
584
585 assert len(caplog.records) == 1
586 assert caplog.records[0].message == "Proxy is registered successfully"
587
588 def test_handle_registration_register_fails(self, caplog):
589 caplog.clear()
590 caplog.set_level(logging.INFO)
591
592 self.harness.charm._stored.db_uri = "postrgresql://fake/database"
593 self.harness.charm._stored.registered = False
594 self.harness.charm._stored.username = "user@domain.country"
595 self.harness.charm._stored.password = "password"
596 self.harness.charm._stored.domain = "http://localhost"
597 event = MagicMock()
598
599 with patch("charm.register_store") as patched_registration:
600 patched_registration.return_value = False
601 self.harness.charm.handle_registration(event)
602
603 assert len(caplog.records) == 1
604 assert (
605 caplog.records[0].message
606 == "Failed to register, check logging to find the reason"
607 )
608
609
610class TestSanity(CharmTest):
611 def test_empty_username_after_registration_and_active_status(self):
612 self.harness.charm._stored.snap_install_source = "store"
613 self.harness.charm._stored.registered = True
614 self.harness.charm._stored.database_created = True
615 self.harness.charm._stored.username = "Some value"
616 self.harness.charm._stored.db_uri = "postgresql://user@host/database"
617
618 # Since valid values will be pushed on to the proxy, we need to stub that here
619 with patch("charm.configure_proxy"):
620 # This should not result in a change, as none is not possible to be passed on
621 self.harness.update_config({"username": None})
622
623 assert self.harness.charm._stored.username == "Some value"
624 assert self.harness.charm.unit.status == ActiveStatus("")
625
626 def test_empty_password_after_registration_and_active_status(self):
627 self.harness.charm._stored.registered = True
628 self.harness.charm._stored.database_created = True
629 self.harness.charm._stored.password = "Some value"
630 self.harness.charm._stored.db_uri = "postgresql://user@host/database"
631
632 # Since valid values will be pushed on to the proxy, we need to stub that here
633 with patch("charm.configure_proxy"):
634 self.harness.update_config({"password": None})
635
636 assert self.harness.charm._stored.password == "Some value"
637 assert self.harness.charm.unit.status == ActiveStatus("")
638
639 def test_update_domain_after_registration(self):
640 self.harness.charm._stored.snap_install_source = "store"
641 self.harness.charm._stored.registered = True
642 self.harness.charm._stored.database_created = True
643 self.harness.charm._stored.domain = "Some value"
644 self.harness.charm._stored.db_uri = "postgresql://user@host/database"
645
646 # Since valid values will be pushed on to the proxy, we need to stub that here
647 with patch("charm.configure_proxy"):
648 self.harness.update_config({"domain": "http://my.super.domain"})
649
650 assert self.harness.charm._stored.domain == "Some value"
651 assert self.harness.charm.unit.status == ActiveStatus("")
652
653
654class TestInstallation(CharmTest):
655 def test_install_from_resource(self):
656 self.harness.add_resource("core", "Dummy core")
657 self.harness.add_resource("snap-store-proxy", "Dummy proxy")
658
659 with patch("charm.install_from_resource") as patched_install:
660 self.harness.charm.handle_installation()
661
662 # We cannot determine the parameters, as the resources are
663 # placed in a randomly named directory
664 patched_install.assert_called_once()
665 assert self.harness.charm._stored.snap_install_source == "resource"
666 assert self.harness.charm._stored.core_md5 == "73eeccd64008a01814825637a6593bb2"
667 assert self.harness.charm._stored.snap_md5 == "97bc2632325e456ad67ca1626baa573c"
668
669 def test_upgrade_from_resource(self):
670 self.harness.charm._stored.snap_install_source = "resource"
671 self.harness.charm._stored.core_md5 = "73eeccd64008a01814825637a6593bb2"
672 self.harness.charm._stored.snap_md5 = "97bc2632325e456ad67ca1626baa573c"
673
674 self.harness.add_resource("core", "My new core")
675 self.harness.add_resource("snap-store-proxy", "My new proxy")
676
677 with patch("charm.install_from_resource") as patched_install:
678 self.harness.charm.handle_installation()
679
680 # We cannot determine the parameters, as the resources are
681 # placed in a randomly named directory
682 patched_install.assert_called_once()
683 assert self.harness.charm._stored.snap_install_source == "resource"
684 assert self.harness.charm._stored.core_md5 == "59f15fd274b73cb00369c905eeab5e70"
685 assert self.harness.charm._stored.snap_md5 == "d7486ac5da29427a54fe1d6e731bd334"
686
687 def test_install_from_store(self):
688 with patch("charm.install_from_the_charmstore") as patched_install:
689 self.harness.charm.handle_installation()
690
691 patched_install.assert_called_once_with("snap-store-proxy")
692 assert self.harness.charm._stored.snap_install_source == "store"
693 assert not hasattr(self.harness.charm._stored, "core_md5")
694 assert not hasattr(self.harness.charm._stored, "snap_md5")
695
696 def test_install_from_store_update(self):
697 # In case of an update (repeated call) nothing should happen if installed from store
698 self.harness.charm._stored.snap_install_source = "store"
699
700 with patch("charm.install_from_the_charmstore") as patched_install:
701 self.harness.charm.handle_installation()
702
703 patched_install.assert_not_called()
704 assert not hasattr(self.harness.charm._stored, "core_md5")
705 assert not hasattr(self.harness.charm._stored, "snap_md5")
706
707 def test_install_from_store_fails(self):
708 with patch("charm.install_from_the_charmstore") as patched_install:
709 patched_install.side_effect = Exception("dummy")
710 self.harness.charm.handle_installation()
711
712 patched_install.assert_called_once()
713 assert not hasattr(self.harness.charm._stored, "core_md5")
714 assert not hasattr(self.harness.charm._stored, "snap_md5")
715 assert len(self.harness.charm.errors) == 1
716 assert self.harness.charm.errors[0] == "Failed to install the snap-store-proxy"
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
0new file mode 100644717new file mode 100644
index 0000000..1e61b15
--- /dev/null
+++ b/tests/test_helpers.py
@@ -0,0 +1,56 @@
1from unittest import mock
2
3import helpers
4
5
6def test_configure_proxy():
7 with mock.patch("helpers.run") as mocked_run:
8 helpers.configure_proxy("myvalue", "myoption")
9
10 assert mocked_run.called
11 mocked_run.assert_called_once_with(
12 ["snap-store-proxy", "config", "myvalue=myoption"]
13 )
14
15
16def test_configure_proxy_list():
17 with mock.patch("helpers.run") as mocked_run:
18 options = ["one", "two"]
19 values = ["value_one", "value_two"]
20 helpers.configure_proxy(options, values)
21
22 assert mocked_run.called
23 mocked_run.assert_called_once_with(
24 ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"']
25 )
26
27
28def test_configure_proxy_forced():
29 with mock.patch("helpers.run") as mocked_run:
30 helpers.configure_proxy("myvalue", "myoption", True)
31
32 assert mocked_run.called
33 mocked_run.assert_called_once_with(
34 ["snap-store-proxy", "config", "myvalue=myoption", "--force"]
35 )
36
37
38def test_configure_proxy_list_forced():
39 with mock.patch("helpers.run") as mocked_run:
40 options = ["one", "two"]
41 values = ["value_one", "value_two"]
42 helpers.configure_proxy(options, values, True)
43
44 assert mocked_run.called
45 mocked_run.assert_called_once_with(
46 ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"', "--force"]
47 )
48
49
50def test_config_options():
51 # Not sure how to do a clean test here, as this function is made
52 # in an attempt to prevent double registration of config items
53 all_options, default_values = helpers.config_options()
54
55 for default_value_key in default_values.keys():
56 assert default_value_key in [key for key in all_options.keys()]
diff --git a/tests/test_optionvalidation.py b/tests/test_optionvalidation.py
0new file mode 10064457new file mode 100644
index 0000000..4144c79
--- /dev/null
+++ b/tests/test_optionvalidation.py
@@ -0,0 +1,136 @@
1import base64
2
3import pytest
4
5from exceptions import InvalidTypeException, UnknownTypeException
6from optionvalidation import (
7 OptionValidation,
8 OptionValidationBase64Json,
9 OptionValidationEmail,
10 OptionValidationString,
11 OptionValidationURL,
12)
13
14
15def test_string_validation_type():
16 assert isinstance(OptionValidation.new("string"), OptionValidationString)
17
18
19def test_url_validation_type():
20 assert isinstance(OptionValidation.new("url"), OptionValidationURL)
21
22
23def test_email_validation_type():
24 assert isinstance(OptionValidation.new("email"), OptionValidationEmail)
25
26
27def test_base64_json_validation_type():
28 assert isinstance(OptionValidation.new("base64+json"), OptionValidationBase64Json)
29
30
31def test_validation_raises_on_unknown():
32 with pytest.raises(UnknownTypeException):
33 OptionValidation.new("clown")
34
35
36def test_string_valid():
37 # This should neither rais an assert, or an exception
38 OptionValidation.new("string").validate("option", "string")
39
40
41@pytest.mark.parametrize(
42 "input",
43 [
44 1,
45 True,
46 [1, 2, 3],
47 (1, 2, 3),
48 {"1": 1, "2": 2},
49 ],
50)
51def test_string_invalid(input):
52 with pytest.raises(InvalidTypeException) as exc:
53 OptionValidation.new("string").validate("option", input)
54 assert str(input) in str(exc.value)
55
56
57@pytest.mark.parametrize(
58 "url",
59 [
60 "http://http.domain",
61 "http://127.0.0.1",
62 "http://localhost.localdomain",
63 "http://RanDom.cApitaLS",
64 "http://super.deep.domain.with.lots.of.dots.in.it",
65 ],
66)
67def test_url_valid(url):
68 OptionValidation.new("url").validate("option", url)
69
70
71@pytest.mark.parametrize(
72 "url",
73 [
74 "http://httpdomain",
75 "https://httpsdomain",
76 "https://https.domain",
77 "http://localhost",
78 "ftp://localhost",
79 "ftps://localhost",
80 "ssh://localhost",
81 "mailto://my.email.domain",
82 ],
83)
84def test_url_invalid(url):
85 with pytest.raises(InvalidTypeException) as exc:
86 OptionValidation.new("url").validate("option", url)
87 assert url in str(exc.value)
88
89
90@pytest.mark.parametrize(
91 "email",
92 [
93 "user@domain.extension",
94 "user@domain.subdomain.extension",
95 "user+label@domain.extension",
96 ],
97)
98def test_email_valid(email):
99 OptionValidation.new("email").validate("option", email)
100
101
102@pytest.mark.parametrize(
103 "email",
104 [
105 "@domain",
106 "@domain.extension",
107 "user@",
108 "user+label@",
109 "user@domain",
110 ],
111)
112def test_email_invalid(email):
113 with pytest.raises(InvalidTypeException) as exc:
114 OptionValidation.new("email").validate("option", email)
115 assert email in str(exc.value)
116
117
118def test_base64_json_valid():
119 value = base64.b64encode("{}".encode("UTF-8"))
120 OptionValidation.new("base64+json").validate("option", value)
121
122
123def test_base64_json_both_invalid():
124 value = "+_=-"
125 with pytest.raises(InvalidTypeException) as exc:
126 OptionValidation.new("base64+json").validate("option", value)
127 assert value in str(exc.value)
128 assert "base64" in str(exc.value)
129
130
131def test_base64_json_invalid():
132 value = base64.b64encode("{".encode("UTF-8"))
133 with pytest.raises(InvalidTypeException) as exc:
134 OptionValidation.new("base64+json").validate("option", value)
135 assert value.decode("UTF-8") in str(exc.value)
136 assert "json" in str(exc.value)
diff --git a/tests/test_resource_helpers.py b/tests/test_resource_helpers.py
0new file mode 100644137new file mode 100644
index 0000000..2827148
--- /dev/null
+++ b/tests/test_resource_helpers.py
@@ -0,0 +1,122 @@
1import logging
2from subprocess import PIPE, CalledProcessError
3from unittest.mock import MagicMock, call, mock_open, patch
4
5import resource_helpers
6
7
8def test_install_from_the_charmstore():
9 with patch("resource_helpers.run") as patched_run:
10 resource_helpers.install_from_the_charmstore("mysnap", "mychannel")
11
12 assert patched_run.called
13 patched_run.assert_called_once_with(
14 ["snap", "install", "--channel=mychannel", "mysnap"], check=True
15 )
16
17
18def test_install_from_resource():
19 with patch("resource_helpers.run") as patched_run:
20 resource_helpers.install_from_resource("core", "proxy")
21
22 assert patched_run.called
23 assert patched_run.call_count == 2
24 patched_run.assert_has_calls(
25 [
26 call(["snap", "install", "--dangerous", "core"], check=True),
27 call(["snap", "install", "--dangerous", "proxy"], check=True),
28 ]
29 )
30
31
32def test_snap_details():
33 with patch("resource_helpers.run") as patched_run:
34 resource_helpers.snap_details("mysnap")
35
36 assert patched_run.called
37 patched_run.assert_called_once_with(["snap", "list", "mysnap"], stdout=PIPE)
38
39
40def test_register_store_succeeds(caplog):
41 username = "username@domain.ext"
42 password = "super secret"
43 env = {
44 "SNAPSTORE_EMAIL": username,
45 "SNAPSTORE_PASSWORD": password,
46 }
47 caplog.clear()
48 caplog.set_level(logging.INFO)
49
50 with patch("resource_helpers.check_output") as patched_check_output:
51 result = resource_helpers.register_store(username, password)
52
53 assert result
54 patched_check_output.assert_called_once_with(
55 ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env
56 )
57 assert len(caplog.records) == 1
58 assert caplog.records[0].message == "Proxy registred sucessfully"
59
60
61def test_register_store_fails(caplog):
62 username = "username@domain.ext"
63 password = "super secret"
64 env = {
65 "SNAPSTORE_EMAIL": username,
66 "SNAPSTORE_PASSWORD": password,
67 }
68 caplog.clear()
69 caplog.set_level(logging.INFO)
70
71 with patch("resource_helpers.check_output") as patched_check_output:
72 patched_check_output.side_effect = CalledProcessError(
73 1,
74 ["/snap/bin/snap-store-proxy", "register", "--skip-questions"],
75 "my dummy error",
76 )
77 result = resource_helpers.register_store(username, password)
78
79 assert not result
80 patched_check_output.assert_called_once_with(
81 ["/snap/bin/snap-store-proxy", "register", "--skip-questions"], env=env
82 )
83 assert len(caplog.records) == 1
84 assert (
85 caplog.records[0].message
86 == "Registration of proxy failed, error; my dummy error"
87 )
88
89
90def test_create_database():
91 test_dsn = "postgresql://user:password@host/database"
92 with patch("resource_helpers.run") as patched_run:
93 resource_helpers.create_database(test_dsn)
94
95 assert patched_run.called
96 patched_run.assert_called_once_with(
97 ["/snap/bin/snap-store-proxy", "create-database", test_dsn]
98 )
99
100
101def test_get_status():
102 result = MagicMock()
103 result.returncode = 10
104 result.stdout = "My magic result".encode("utf-8")
105
106 with patch("resource_helpers.run") as patched_run:
107 patched_run.return_value = result
108 output, exitcode = resource_helpers.get_status()
109
110 assert patched_run.called
111 patched_run.assert_called_once_with(
112 ["/snap/bin/snap-store-proxy", "status"], capture_output=True
113 )
114 assert exitcode == 10
115 assert output == "My magic result"
116
117
118def test_hash_from_resource():
119 with patch("builtins.open", mock_open(read_data="My Dummy Resource".encode())):
120 hash = resource_helpers.hash_from_resource("dummy")
121
122 assert hash == "edfe387dbf4a72c067943c79dffa51b8"

Subscribers

People subscribed via source and target branches

to all changes: