Merge ~woutervb/various/+git/snapstore-proxy-charm:registration_bundle into ~woutervb/various/+git/snapstore-proxy-charm:main

Proposed by Wouter van Bommel
Status: Rejected
Rejected by: Wouter van Bommel
Proposed branch: ~woutervb/various/+git/snapstore-proxy-charm:registration_bundle
Merge into: ~woutervb/various/+git/snapstore-proxy-charm:main
Diff against target: 575 lines (+328/-21)
10 files modified
README.md (+11/-9)
config.yaml (+6/-1)
juju/overlay.yaml.example (+1/-2)
juju/test-bundle.yaml (+0/-2)
src/charm.py (+61/-2)
src/helpers.py (+18/-3)
src/optionvalidation.py (+26/-0)
tests/test_charm.py (+142/-1)
tests/test_helpers.py (+34/-0)
tests/test_optionvalidation.py (+29/-1)
Reviewer Review Type Date Requested Status
Wouter van Bommel Pending
Review via email: mp+414138@code.launchpad.net

Commit message

Add the option to provide the charm with a registration bundle

This bundle, created with snapstore-client, is preferred, as it will allow registration with an ubuntu one account that has 2fa enabled.

To post a comment you must log in.

Unmerged commits

647db14... by Wouter van Bommel

Support a registration bundle

When using a registration bundle, it is no longer needed to add an
username or password to the config.
Updated the README to reflex the change
Updated the overlay.yaml.example file to reflex this change

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/README.md b/README.md
2index cd1ef35..817c9d7 100644
3--- a/README.md
4+++ b/README.md
5@@ -19,9 +19,7 @@ Below is a minimal bundle example that can be used to deploy the charm using juj
6 snapstore-proxy:
7 charm: snapstore-proxy
8 options:
9- domain: http://something.not.local
10- username: some.ubuntu@one.account
11- password: UserPassword
12+ registration_bundle: <content of registration file base64 encoded>
13
14 relations:
15 - - postgresql:db-admin
16@@ -37,7 +35,7 @@ The charm supports the following actions:
17
18 * **status** - Get the status result of the proxy, use like;
19
20- `juju run-action snapstore-proxy/leader status --wait`
21+ juju run-action snapstore-proxy/leader status --wait
22
23 # Advanced options
24
25@@ -45,9 +43,15 @@ The charm supports the following actions:
26
27 There are some mandatory config options that have to be configured for the charm to work, these are:
28
29-* **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).
30+* **registration_bundle** - A bundle created with a snap called `snapstore-client`. 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:
31+
32+ juju config snapstore-proxy registration_bundle=$(cat <path to file created with snapstore-client> | base64)
33+
34
35-* **username** - This is the username of the ubuntu one account that will be used to register the proxy
36+Or (deprecated)
37+
38+* **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).
39+* **username** - This is the username of the Ubuntu One account that will be used to register the proxy
40 * **password** - The password of the above username
41
42 ## Juju resource usage
43@@ -65,9 +69,7 @@ An example bundle to do such a deployment will look like the following.
44 snapstore-proxy:
45 charm: snapstore-proxy
46 options:
47- domain: http://something.not.local
48- username: some.ubuntu@one.account
49- password: UserPassword
50+ registration_bundle: <content of registration file base64 encoded>
51 resources:
52 snap-store-proxy: ./snap-store-proxy.snap
53 core: ./core20.snap
54diff --git a/config.yaml b/config.yaml
55index eaaa7df..1c131a6 100644
56--- a/config.yaml
57+++ b/config.yaml
58@@ -5,11 +5,16 @@
59 # always possible
60
61 options:
62+ registration_bundle:
63+ default: null
64+ desciption: |
65+ A bundle created via `snapstore-proxy_registration_bundle` added as a base64
66+ encoded string. ie "$(cat <file> | base64)"
67+ type: string
68 domain:
69 default: http://localhost.localdomain
70 description: Domain name for the Snap Store Proxy.
71 type: string
72-
73 username:
74 default: null
75 description: |
76diff --git a/juju/overlay.yaml.example b/juju/overlay.yaml.example
77index fc9b38d..ddd4f49 100644
78--- a/juju/overlay.yaml.example
79+++ b/juju/overlay.yaml.example
80@@ -1,5 +1,4 @@
81 applications:
82 snapstore-proxy:
83 options:
84- password: my super password
85- username: my ubuntu one email
86\ No newline at end of file
87+ registration_bundle: `cat <registation file> | base64`
88\ No newline at end of file
89diff --git a/juju/test-bundle.yaml b/juju/test-bundle.yaml
90index 39c1f08..a2be469 100644
91--- a/juju/test-bundle.yaml
92+++ b/juju/test-bundle.yaml
93@@ -13,8 +13,6 @@ applications:
94 snapstore-proxy:
95 charm: ../snapstore-proxy_ubuntu-20.04-amd64.charm
96 num_units: 1
97- options:
98- domain: http://something.not.local
99
100
101 relations:
102diff --git a/src/charm.py b/src/charm.py
103index 591965f..002cebb 100755
104--- a/src/charm.py
105+++ b/src/charm.py
106@@ -5,7 +5,9 @@
107 # Learn more at: https://juju.is/docs/sdk
108 #
109
110+import base64
111 import hashlib
112+import json
113 import logging
114 from urllib.parse import urlparse
115
116@@ -121,8 +123,9 @@ class SnapstoreProxyCharm(CharmBase):
117 # Then configure it, and only then we can attempt to configure it
118 self.handle_config()
119
120- self.unit.status = MaintenanceStatus("Registering proxy")
121- self.handle_registration(event)
122+ if not getattr(self._stored, "registered", False):
123+ self.unit.status = MaintenanceStatus("Registering proxy")
124+ self.handle_registration(event)
125
126 self.evaluate_status()
127
128@@ -207,6 +210,11 @@ class SnapstoreProxyCharm(CharmBase):
129 A different method, as it will contain mappings between items and the
130 actual snap entries"""
131
132+ has_registration_bundle = False
133+ for item in updates:
134+ has_registration_bundle |= item["name"] == "registration_bundle"
135+
136+ # If the has_registration_bundle is true, we omit changes to the domain
137 for item in updates:
138 if item["name"] == "domain":
139 logger.info("Configuring the domain")
140@@ -222,6 +230,57 @@ class SnapstoreProxyCharm(CharmBase):
141 setattr(self._stored, item["name"], item["value"])
142 setattr(self._stored, f"{item['name']}_md5", item["value"])
143
144+ if has_registration_bundle:
145+ # If this config option is set, we basically ignore the username and
146+ # password and domain settings. And we take all from this field.
147+ for item in updates:
148+ if item["name"] == "registration_bundle":
149+ break
150+ if item["value"]:
151+ # A bit harsh, but this way we ensure that only this bundle is used
152+ self._stored.registered = True
153+ else:
154+ # The value is removed / reset, so act accordingly
155+ self._stored.registered = False
156+ return
157+
158+ data = json.loads(base64.b64decode(item["value"]))
159+ for entries in [
160+ "domain",
161+ "private_key",
162+ "public_key",
163+ "store_id",
164+ # "store_assertion", needed once airgap mode is implemented
165+ ]:
166+ # see if any of these keys are missing
167+ if entries not in data.keys():
168+ self.errors.append(
169+ f"Missing key {entries} in the registration bundle"
170+ )
171+
172+ if self.errors:
173+ # Incomplete bundle(?) so stop doing anything
174+ return
175+
176+ # Now make 2 lists, option names and values where the indexes
177+ # correspond with eachother
178+ options = []
179+ values = []
180+ for entry in data.keys():
181+ if entry == "domain":
182+ options.append("proxy.domain")
183+ values.append(data[entry])
184+ elif entry == "public_key":
185+ options.append("proxy.key.public")
186+ values.append(data[entry])
187+ elif entry == "private_key":
188+ options.append("proxy.key.private")
189+ values.append(data[entry])
190+ elif entry == "store_id":
191+ options.append("internal.store.id")
192+ values.append(data[entry])
193+ configure_proxy(options, values, force=True)
194+
195 def handle_registration(self, event):
196 """Try to register the store.
197
198diff --git a/src/helpers.py b/src/helpers.py
199index 0575279..07cf19c 100644
200--- a/src/helpers.py
201+++ b/src/helpers.py
202@@ -10,10 +10,23 @@ import yaml
203 logger = logging.getLogger(__name__)
204
205
206-def configure_proxy(option, value):
207+def configure_proxy(option, value, force=False):
208 """Simple wrapper around calling snap settings"""
209- logger.debug(f"Running config for option {option} with value {value}")
210- run(["snap-store-proxy", "config", f"{option}={value}"])
211+
212+ if isinstance(option, list):
213+ # Make sure that we set multiple values in one go
214+ combined = []
215+ for count, entry in enumerate(option):
216+ combined.append(f'{entry}="{value[count]}"')
217+ command = ["snap-store-proxy", "config"] + combined
218+ else:
219+ logger.debug(f"Running config for option {option} with value {value}")
220+ command = ["snap-store-proxy", "config", f"{option}={value}"]
221+
222+ if force:
223+ command.append("--force")
224+
225+ run(command)
226
227
228 def config_options():
229@@ -36,6 +49,8 @@ def config_options():
230 type_dict.update({item: "url"})
231 elif item == "username":
232 type_dict.update({item: "email"})
233+ elif item == "registration_bundle":
234+ type_dict.update({item: "base64+json"})
235 else:
236 type_dict.update({item: config["options"][item]["type"]})
237
238diff --git a/src/optionvalidation.py b/src/optionvalidation.py
239index 0ef81d7..2e8efae 100644
240--- a/src/optionvalidation.py
241+++ b/src/optionvalidation.py
242@@ -2,10 +2,15 @@
243 # See LICENSE file for licensing details.
244 #
245
246+import base64
247+import json
248 import re
249+from logging import Logger
250
251 from exceptions import InvalidTypeException, UnknownTypeException
252
253+logger = Logger(__name__)
254+
255
256 class OptionValidation:
257 @staticmethod
258@@ -16,12 +21,15 @@ class OptionValidation:
259 return OptionValidationURL()
260 if test_type == "email":
261 return OptionValidationEmail()
262+ if test_type == "base64+json":
263+ return OptionValidationBase64Json()
264 raise UnknownTypeException(test_type)
265
266
267 class OptionValidationString(OptionValidation):
268 @staticmethod
269 def validate(config_name, value):
270+ logger.debug("Validating the type string for {config_name}")
271 if not isinstance(value, str):
272 raise InvalidTypeException(config_name, "string", value)
273
274@@ -29,6 +37,7 @@ class OptionValidationString(OptionValidation):
275 class OptionValidationURL(OptionValidation):
276 @staticmethod
277 def validate(config_name, value):
278+ logger.debug("Validating the type url for {config_name}")
279 regex = re.compile(
280 r"^(?:http)://" # http:// or https://
281 r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|"
282@@ -45,6 +54,23 @@ class OptionValidationURL(OptionValidation):
283 class OptionValidationEmail(OptionValidation):
284 @staticmethod
285 def validate(config_name, value):
286+ logger.debug("Validating the type email for {config_name}")
287 regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
288 if (not isinstance(value, str)) or (not re.fullmatch(regex, value)):
289 raise InvalidTypeException(config_name, "email", value)
290+
291+
292+class OptionValidationBase64Json(OptionValidation):
293+ @staticmethod
294+ def validate(config_name, value):
295+ logger.debug("Validating the type base64+json for {config_name}")
296+ try:
297+ result = base64.b64decode(value)
298+ print(result)
299+ except Exception:
300+ raise InvalidTypeException(config_name, "base64", value)
301+
302+ try:
303+ json.loads(result)
304+ except Exception:
305+ raise InvalidTypeException(config_name, "json", value)
306diff --git a/tests/test_charm.py b/tests/test_charm.py
307index a224ba7..6dc62f1 100644
308--- a/tests/test_charm.py
309+++ b/tests/test_charm.py
310@@ -4,6 +4,7 @@
311 # Learn more about testing at: https://juju.is/docs/sdk/testing
312 #
313
314+import base64
315 import logging
316 import subprocess
317 from pathlib import Path
318@@ -197,6 +198,146 @@ class TestConfig(CharmTest):
319 patched_proxy.assert_called_once_with("postgresql://fake/database")
320
321
322+class TestRegistrationBundle(CharmTest):
323+ # Split off to ensure that tests are specific for what they do
324+ # data that is passed to do_snap_updates has alrady been validated
325+ # via the optionvalidation functions, so no need to test invalid data
326+ def test_no_bundle(self):
327+ assert not hasattr(self.harness.charm._stored, "registered")
328+
329+ self.harness.charm.do_snap_updates({})
330+
331+ # If we start without the registered key, and provide no bundle, no
332+ # key will be added
333+ assert not hasattr(self.harness.charm._stored, "registered")
334+
335+ def test_registration_removed(self):
336+ setattr(self.harness.charm._stored, "registered", True)
337+
338+ self.harness.charm.do_snap_updates(
339+ [{"name": "registration_bundle", "value": None}]
340+ )
341+
342+ # We started with a key, so it will stay with us
343+ assert not getattr(self.harness.charm._stored, "registered")
344+
345+ def test_has_incomplete_bundle_missing_all(self):
346+ to_update = [
347+ {
348+ "name": "registration_bundle",
349+ "value": base64.b64encode("{}".encode("UTF-8")),
350+ }
351+ ]
352+
353+ self.harness.charm.do_snap_updates(to_update)
354+ assert len(self.harness.charm.errors) == 4
355+ assert (
356+ self.harness.charm.errors[0]
357+ == "Missing key domain in the registration bundle"
358+ )
359+
360+ def test_has_incomplete_bundle_added_domain(self):
361+ to_update = [
362+ {
363+ "name": "registration_bundle",
364+ "value": base64.b64encode('{"domain": "sdfsd"}'.encode("UTF-8")),
365+ }
366+ ]
367+
368+ self.harness.charm.do_snap_updates(to_update)
369+ assert len(self.harness.charm.errors) == 3
370+ assert (
371+ self.harness.charm.errors[0]
372+ == "Missing key private_key in the registration bundle"
373+ )
374+
375+ def test_has_incomplete_bundle_added_private_key(self):
376+ to_update = [
377+ {
378+ "name": "registration_bundle",
379+ "value": base64.b64encode(
380+ '{"domain": "sdfsd",' '"private_key": "12"}'.encode("UTF-8")
381+ ),
382+ }
383+ ]
384+
385+ self.harness.charm.do_snap_updates(to_update)
386+ assert len(self.harness.charm.errors) == 2
387+ assert (
388+ self.harness.charm.errors[0]
389+ == "Missing key public_key in the registration bundle"
390+ )
391+
392+ def test_has_incomplete_bundle_added_public_key(self):
393+ to_update = [
394+ {
395+ "name": "registration_bundle",
396+ "value": base64.b64encode(
397+ '{"domain": "sdfsd",'
398+ '"public_key": "12",'
399+ '"private_key": "12"}'.encode("UTF-8")
400+ ),
401+ }
402+ ]
403+
404+ self.harness.charm.do_snap_updates(to_update)
405+ assert len(self.harness.charm.errors) == 1
406+ assert (
407+ self.harness.charm.errors[0]
408+ == "Missing key store_id in the registration bundle"
409+ )
410+
411+ # Possibly needed once airgap mode has been implemented
412+ # def test_has_incomplete_bundle_added_store_id(self):
413+ # to_update = [
414+ # {
415+ # "name": "registration_bundle",
416+ # "value": base64.b64encode(
417+ # '{"domain": "sdfsd",'
418+ # '"public_key": "12",'
419+ # '"store_id": "12",'
420+ # '"private_key": "12"}'.encode("UTF-8")
421+ # ),
422+ # }
423+ # ]
424+
425+ # self.harness.charm.do_snap_updates(to_update)
426+ # assert len(self.harness.charm.errors) == 1
427+ # assert (
428+ # self.harness.charm.errors[0]
429+ # == "Missing key store_assertion in the registration bundle"
430+ # )
431+
432+ def test_has_complete_bundle(self):
433+ to_update = [
434+ {
435+ "name": "registration_bundle",
436+ "value": base64.b64encode(
437+ '{"domain": "sdfsd",'
438+ '"public_key": "10",'
439+ '"store_id": "11",'
440+ '"store_assertion": "0",'
441+ '"private_key": "12"}'.encode("UTF-8")
442+ ),
443+ }
444+ ]
445+ with patch("charm.configure_proxy") as patched_configure:
446+ self.harness.charm.do_snap_updates(to_update)
447+
448+ patched_configure.assert_called_once_with(
449+ [
450+ "proxy.domain",
451+ "proxy.key.public",
452+ "internal.store.id",
453+ "proxy.key.private",
454+ ],
455+ ["sdfsd", "10", "11", "12"],
456+ force=True,
457+ )
458+
459+ assert getattr(self.harness.charm._stored, "registered", None)
460+
461+
462 class TestStatus(CharmTest):
463 def test_status_missing_db_uri(self):
464 self.harness.charm._stored.db_uri = None
465@@ -367,7 +508,7 @@ class TestEvents(CharmTest):
466
467 patched_get_status.assert_called_once_with()
468 event.fail.assert_called_once_with(
469- "Failed to get status, result String, errorcode 1"
470+ "Failed to get status, errorcode 1\n, result String"
471 )
472
473
474diff --git a/tests/test_helpers.py b/tests/test_helpers.py
475index 730f683..1e61b15 100644
476--- a/tests/test_helpers.py
477+++ b/tests/test_helpers.py
478@@ -13,6 +13,40 @@ def test_configure_proxy():
479 )
480
481
482+def test_configure_proxy_list():
483+ with mock.patch("helpers.run") as mocked_run:
484+ options = ["one", "two"]
485+ values = ["value_one", "value_two"]
486+ helpers.configure_proxy(options, values)
487+
488+ assert mocked_run.called
489+ mocked_run.assert_called_once_with(
490+ ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"']
491+ )
492+
493+
494+def test_configure_proxy_forced():
495+ with mock.patch("helpers.run") as mocked_run:
496+ helpers.configure_proxy("myvalue", "myoption", True)
497+
498+ assert mocked_run.called
499+ mocked_run.assert_called_once_with(
500+ ["snap-store-proxy", "config", "myvalue=myoption", "--force"]
501+ )
502+
503+
504+def test_configure_proxy_list_forced():
505+ with mock.patch("helpers.run") as mocked_run:
506+ options = ["one", "two"]
507+ values = ["value_one", "value_two"]
508+ helpers.configure_proxy(options, values, True)
509+
510+ assert mocked_run.called
511+ mocked_run.assert_called_once_with(
512+ ["snap-store-proxy", "config", 'one="value_one"', 'two="value_two"', "--force"]
513+ )
514+
515+
516 def test_config_options():
517 # Not sure how to do a clean test here, as this function is made
518 # in an attempt to prevent double registration of config items
519diff --git a/tests/test_optionvalidation.py b/tests/test_optionvalidation.py
520index f95ffed..4144c79 100644
521--- a/tests/test_optionvalidation.py
522+++ b/tests/test_optionvalidation.py
523@@ -1,8 +1,11 @@
524+import base64
525+
526 import pytest
527
528 from exceptions import InvalidTypeException, UnknownTypeException
529 from optionvalidation import (
530 OptionValidation,
531+ OptionValidationBase64Json,
532 OptionValidationEmail,
533 OptionValidationString,
534 OptionValidationURL,
535@@ -21,6 +24,10 @@ def test_email_validation_type():
536 assert isinstance(OptionValidation.new("email"), OptionValidationEmail)
537
538
539+def test_base64_json_validation_type():
540+ assert isinstance(OptionValidation.new("base64+json"), OptionValidationBase64Json)
541+
542+
543 def test_validation_raises_on_unknown():
544 with pytest.raises(UnknownTypeException):
545 OptionValidation.new("clown")
546@@ -102,7 +109,28 @@ def test_email_valid(email):
547 "user@domain",
548 ],
549 )
550-def test_email_imvalid(email):
551+def test_email_invalid(email):
552 with pytest.raises(InvalidTypeException) as exc:
553 OptionValidation.new("email").validate("option", email)
554 assert email in str(exc.value)
555+
556+
557+def test_base64_json_valid():
558+ value = base64.b64encode("{}".encode("UTF-8"))
559+ OptionValidation.new("base64+json").validate("option", value)
560+
561+
562+def test_base64_json_both_invalid():
563+ value = "+_=-"
564+ with pytest.raises(InvalidTypeException) as exc:
565+ OptionValidation.new("base64+json").validate("option", value)
566+ assert value in str(exc.value)
567+ assert "base64" in str(exc.value)
568+
569+
570+def test_base64_json_invalid():
571+ value = base64.b64encode("{".encode("UTF-8"))
572+ with pytest.raises(InvalidTypeException) as exc:
573+ OptionValidation.new("base64+json").validate("option", value)
574+ assert value.decode("UTF-8") in str(exc.value)
575+ assert "json" in str(exc.value)

Subscribers

People subscribed via source and target branches

to all changes: