Merge ~bowenfan/snapstore-client:SN2278-model_service_auth_and_cli_skeleton into snapstore-client:main

Proposed by Bowen Fan
Status: Merged
Approved by: Bowen Fan
Approved revision: 02f92010a1ce54568500c909815f4e8be2be9044
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~bowenfan/snapstore-client:SN2278-model_service_auth_and_cli_skeleton
Merge into: snapstore-client:main
Diff against target: 532 lines (+339/-6)
12 files modified
conftest.py (+22/-2)
store_admin/cli/model_service.py (+19/-0)
store_admin/cli/runner.py (+9/-1)
store_admin/logic/accounts.py (+17/-0)
store_admin/logic/login.py (+15/-1)
store_admin/logic/model_service.py (+45/-0)
store_admin/logic/stores.py (+3/-1)
store_admin/logic/tests/conftest.py (+59/-0)
store_admin/logic/tests/test_login.py (+54/-0)
store_admin/logic/tests/test_model_service.py (+74/-0)
store_admin/logic/tests/test_stores.py (+3/-1)
store_admin/webservices.py (+19/-0)
Reviewer Review Type Date Requested Status
Deep Fowdar Approve
Review via email: mp+457208@code.launchpad.net

Commit message

Add snapstore-client support for publishergw and model service

Upon first login to an airgapped proxy, snapstore-client will now
exchange the configured store admin macaroon for the offline publishergw
admin auth macaroon. The exchanged macaroon will be saved in the client
config and used automatically for subsequent publishergw requests.

Also add initial CLI for model service model creation and logic
webservices layers dependencies.

Solves: https://warthogs.atlassian.net/browse/SN-2337

Description of the change

This MP is part 1 of 5 for adding model service support to snapstore-client.

1: Add publishergw support and a skeleton for model service
2: Add remaining create CLI functions
3: List
4: Update
5: Delete

MPs 2-5 will be raised shortly.

CLI commands have been implemented, as much as possible, to follow this command structure guidance doc: https://discourse.ubuntu.com/t/command-structure/18556

On-prem model service spec: https://docs.google.com/document/d/1uxJ0Z1hCN_-6aJRwy9RhM3KblSuMgHqPwYEyP_88Ijk/edit

To post a comment you must log in.
Revision history for this message
Deep Fowdar (superalpaca) wrote :

Thanks for this! I've left one comment in the diff

review: Approve
02f9201... by Bowen Fan

Hardcode UC series to 16 per MP feedback

Model series must be "16" for historical compatibility reasons, so we hardcode it to avoid confusing users.

Also improve model service model creation mock response (currently unused).

Revision history for this message
Bowen Fan (bowenfan) wrote :

> Thanks for this! I've left one comment in the diff

Thanks for the review! I've hardcoded 'series' so that users aren't asked for it in the CLI. Eventually as you said, we can hardcode it in publishergw or even snapmodels too.

Updating diff...

An updated diff will be available in a few minutes. Reload to see the changes.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/conftest.py b/conftest.py
2index 6ba7f19..48a6240 100644
3--- a/conftest.py
4+++ b/conftest.py
5@@ -5,12 +5,11 @@ from pathlib import Path
6 from unittest import mock
7 from unittest.mock import patch
8
9-from pymacaroons import Macaroon
10 import pytest
11+from pymacaroons import Macaroon
12
13 from store_admin.config import Config
14
15-
16 root_logger = logging.getLogger(None)
17 root_logger.setLevel(logging.DEBUG)
18
19@@ -79,6 +78,27 @@ def mocked_config(mocked_xdgconfig):
20
21
22 @pytest.fixture
23+def mocked_offline_config(mocked_xdgconfig, exchanged_macaroon):
24+ """Example expected config when using the client against an offline proxy"""
25+ app_config_path = os.path.join(mocked_xdgconfig, "store-admin")
26+ os.makedirs(app_config_path)
27+
28+ with open(os.path.join(app_config_path, "config.ini"), "w") as f:
29+ f.write(
30+ (
31+ "[store:default]\n"
32+ "gw_url = {gw_url}\n"
33+ "pubgw_token = {pubgw_token}\n"
34+ ).format(
35+ gw_url="http://offline.local/",
36+ pubgw_token=exchanged_macaroon,
37+ )
38+ )
39+
40+ yield Config()
41+
42+
43+@pytest.fixture
44 def mocked_empty_config(mocked_xdgconfig):
45 app_config_path = os.path.join(mocked_xdgconfig, "store-admin")
46 os.makedirs(app_config_path)
47diff --git a/store_admin/cli/model_service.py b/store_admin/cli/model_service.py
48new file mode 100644
49index 0000000..10b84d6
50--- /dev/null
51+++ b/store_admin/cli/model_service.py
52@@ -0,0 +1,19 @@
53+import click
54+from store_admin.logic import model_service
55+
56+
57+@click.command(name="model")
58+@click.argument("model_name")
59+@click.option(
60+ "--api-key",
61+ help="Model API key for validating serial requests",
62+ prompt="API key (alphanumeric, `pwgen 40` for options)",
63+)
64+def create_model(model_name, api_key):
65+ """Create a model in the model service.
66+
67+ MODEL_NAME must be lowercase alphanumeric, optionally with hyphens.
68+ """
69+ # UC series is hardcoded as 16 for historical reasons
70+ series = "16"
71+ model_service.create_model(model_name, api_key, series)
72diff --git a/store_admin/cli/runner.py b/store_admin/cli/runner.py
73index 03c172e..5ec6421 100644
74--- a/store_admin/cli/runner.py
75+++ b/store_admin/cli/runner.py
76@@ -3,7 +3,7 @@ import logging
77 import click
78
79 from store_admin import defaults, log, version
80-from store_admin.cli import common, overrides, snaps, stores
81+from store_admin.cli import common, model_service, overrides, snaps, stores
82
83
84 @click.group(
85@@ -45,6 +45,11 @@ def run(debug, dashboard_root, devicegw_root, sso_url):
86 log.configure(log_level=log_level)
87
88
89+@run.group()
90+def create():
91+ "Create various artifacts on the managed proxy."
92+
93+
94 @run.group(name="list")
95 def lst():
96 "List snaps or overrides stored on the proxy."
97@@ -69,6 +74,9 @@ def export():
98 run.add_command(common.login)
99 run.add_command(common.register)
100
101+# Commands under create
102+create.add_command(model_service.create_model)
103+
104 # Commands under list
105 lst.add_command(overrides.list_overrides)
106
107diff --git a/store_admin/logic/accounts.py b/store_admin/logic/accounts.py
108index 5440af1..b8f0d6a 100644
109--- a/store_admin/logic/accounts.py
110+++ b/store_admin/logic/accounts.py
111@@ -71,3 +71,20 @@ def export_token(ttl):
112 logger.info(f"Exported token with permissions={permissions!r} and ttl={ttl!r}")
113 logger.info("Usage:")
114 logger.info(f" export {STORE_ADMIN_ENV_AUTH_KEY}={auth_token}")
115+
116+
117+def exchange_publishergw_macaroon(gw_url, admin_auth_token):
118+ """Exchange a store admin macaroon for a publishergw gateway store admin token.
119+
120+ The exported store, which includes the store admin macaroon, must have already
121+ been pushed to the proxy.
122+ """
123+ logger.info(
124+ "Exchanging store admin macaroon for a publisher gateway admin macaroon..."
125+ )
126+ offline_exchange_url = urljoin(gw_url, "/publishergw/v1/tokens/offline/exchange")
127+
128+ resp = requests.post(offline_exchange_url, json={"macaroon": admin_auth_token})
129+
130+ exchanged_macaroon = resp.json()["macaroon"]
131+ return exchanged_macaroon
132diff --git a/store_admin/logic/login.py b/store_admin/logic/login.py
133index 42cb43e..6b40ab0 100644
134--- a/store_admin/logic/login.py
135+++ b/store_admin/logic/login.py
136@@ -2,6 +2,7 @@
137
138 import getpass
139 import logging
140+import os
141 from urllib.parse import urlparse
142
143 from pymacaroons import Macaroon
144@@ -11,7 +12,8 @@ from store_admin import (
145 exceptions,
146 webservices as ws,
147 )
148-
149+from store_admin.logic.http_client import STORE_ADMIN_ENV_AUTH_KEY
150+from store_admin.logic.accounts import exchange_publishergw_macaroon
151
152 logger = logging.getLogger(__name__)
153
154@@ -34,6 +36,18 @@ def login(store_url, email, sso_url, offline):
155 cfg = config.Config()
156 store = cfg.store_section("default")
157 store.set("gw_url", gw_url)
158+
159+ # Logging in to an airgapped proxy should set the exchanged publishergw token
160+ admin_token = os.getenv(STORE_ADMIN_ENV_AUTH_KEY)
161+ if not admin_token:
162+ logger.warning(
163+ "Could not find store admin macaroon. Publisher gateway token not set."
164+ )
165+ return
166+
167+ pubgw_token = exchange_publishergw_macaroon(gw_url, admin_token)
168+ store.set("pubgw_token", pubgw_token)
169+
170 cfg.save()
171 return
172
173diff --git a/store_admin/logic/model_service.py b/store_admin/logic/model_service.py
174new file mode 100644
175index 0000000..115d102
176--- /dev/null
177+++ b/store_admin/logic/model_service.py
178@@ -0,0 +1,45 @@
179+import logging
180+import os
181+from store_admin.utils import _check_default_store
182+from store_admin import (
183+ config,
184+ webservices as ws,
185+)
186+
187+logger = logging.getLogger(__name__)
188+
189+
190+BRAND_ACCOUNT_ID_ENV_KEY = "BRAND_ACCOUNT_ID"
191+
192+
193+def _get_or_set_brand_account_id(cfg):
194+ brand_account_id = os.getenv(BRAND_ACCOUNT_ID_ENV_KEY)
195+ store = cfg.store_section("default")
196+
197+ if brand_account_id:
198+ store.set("brand_account_id", brand_account_id)
199+ cfg.save()
200+ return brand_account_id
201+
202+ saved = store.get("brand_account_id")
203+ if not saved:
204+ logger.error(
205+ "No brand account ID saved.\n Re-run command after "
206+ f"setting the {BRAND_ACCOUNT_ID_ENV_KEY} environment variable.\n"
207+ "It will be saved for subsequent use."
208+ )
209+ return
210+ return saved
211+
212+
213+def create_model(model_name, api_key, series):
214+ cfg = config.Config()
215+ store = _check_default_store(cfg)
216+ brand_account_id = _get_or_set_brand_account_id(cfg)
217+ if not store or not brand_account_id:
218+ return 1
219+
220+ json = {"name": model_name, "api-key": api_key, "series": series}
221+ path = f"/publishergw/v1/brand/{brand_account_id}/model"
222+ ws.model_service_create(store, path, json)
223+ logger.info(f"Model {model_name} created.")
224diff --git a/store_admin/logic/stores.py b/store_admin/logic/stores.py
225index 7388ad0..46d2271 100644
226--- a/store_admin/logic/stores.py
227+++ b/store_admin/logic/stores.py
228@@ -17,7 +17,7 @@ import requests
229
230 from . import assertions, snaps
231 from .accounts import get_account_details
232-from .http_client import get_developer_session
233+from .http_client import STORE_ADMIN_ENV_AUTH_KEY, get_developer_session
234 from .. import defaults, exceptions
235
236
237@@ -54,6 +54,8 @@ def export(store_id, channels, architectures, skip_snaps, account_key_ids=None):
238 export_tarball_path.chmod(0o400)
239 logger.info(f"Store data exported to: {export_tarball_path}")
240 logger.info(f"Admin token exported to: {export_token_path}")
241+ logger.info("Admin token usage:")
242+ logger.info(f" export {STORE_ADMIN_ENV_AUTH_KEY}=$(cat {export_token_path})")
243
244
245 def export_files(
246diff --git a/store_admin/logic/tests/conftest.py b/store_admin/logic/tests/conftest.py
247index ac92aeb..914a994 100644
248--- a/store_admin/logic/tests/conftest.py
249+++ b/store_admin/logic/tests/conftest.py
250@@ -2,6 +2,7 @@
251 # Copyright 2022 Canonical Ltd. This software is licensed under the
252 # GNU General Public License version 3 (see the file LICENSE).
253
254+from datetime import datetime
255 import pytest
256 import responses
257 from pymacaroons import Macaroon, MACAROON_V2
258@@ -197,6 +198,64 @@ def whoami_api(dashboard_root, requests_mock, whoami_data):
259
260
261 @pytest.fixture
262+def brand_account_id():
263+ return "test-brand-account-id"
264+
265+
266+def _create_model_service_user_response(display_name=None, id=None, username=None):
267+ return {
268+ "display-name": display_name or "User Display Name",
269+ "id": id or "user_account_id",
270+ "username": username or "username",
271+ }
272+
273+
274+@pytest.fixture
275+def model_service_model_apis(
276+ requests_mock,
277+ brand_account_id,
278+):
279+ def _make_model_response(brand_account_id, **kwargs):
280+ model = {}
281+
282+ model["brand_account_id"] = brand_account_id
283+ model["name"] = kwargs.get("name") or "test-model"
284+ model["series"] = "16"
285+ model["api-key"] = kwargs.get("api_key") or "test-api-key"
286+
287+ model["created-by"] = (
288+ kwargs.get("created_by") or _create_model_service_user_response()
289+ )
290+ model["created-at"] = (
291+ kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
292+ )
293+ model["modified-by"] = (
294+ _create_model_service_user_response()
295+ if "modified_by" not in kwargs
296+ else kwargs["modified_by"]
297+ )
298+ model["modified-at"] = (
299+ datetime.utcnow().isoformat() + "Z"
300+ if "modified_at" not in kwargs
301+ else kwargs["modified_at"]
302+ )
303+
304+ return model
305+
306+ def setup(gw_url):
307+ create_response = _make_model_response(brand_account_id)
308+ requests_mock.add(
309+ "POST",
310+ gw_url + f"publishergw/v1/brand/{brand_account_id}/model",
311+ json=create_response,
312+ status=201,
313+ )
314+ return (create_response, requests_mock)
315+
316+ return setup
317+
318+
319+@pytest.fixture
320 def asserts_mock(devicegw_root, requests_mock):
321 def add(assert_type, key, body=None, json=None):
322 assert (json is None) != (body is None)
323diff --git a/store_admin/logic/tests/test_login.py b/store_admin/logic/tests/test_login.py
324index d4c6230..75b567d 100644
325--- a/store_admin/logic/tests/test_login.py
326+++ b/store_admin/logic/tests/test_login.py
327@@ -2,12 +2,14 @@
328
329 import logging
330 import json
331+import os
332 from unittest import mock
333 from urllib.parse import urljoin, urlparse
334
335 import pytest
336 import responses
337 from pymacaroons import Macaroon
338+from store_admin.logic import http_client
339 from testtools.matchers import (
340 ContainsDict,
341 Equals,
342@@ -241,6 +243,58 @@ class TestLogin:
343 is None
344 )
345
346+ @mock.patch.dict(os.environ, {})
347+ def test_login_to_offline_proxy_no_admin_macaroon(self, caplog):
348+ gw_url = "http://offline.local:1234/"
349+
350+ login(**self.make_args(store_url=gw_url, offline=True))
351+
352+ assert len(caplog.records) == 1
353+ assert (
354+ caplog.records[-1].msg
355+ == "Could not find store admin macaroon. Publisher gateway token not set."
356+ )
357+
358+ @mock.patch.dict(
359+ os.environ, {http_client.STORE_ADMIN_ENV_AUTH_KEY: "mock-credentials"}
360+ )
361+ def test_login_to_offline_proxy_success(
362+ self, requests_mock, exchanged_macaroon, caplog
363+ ):
364+ gw_url = "http://offline.local:1234/"
365+ exchanged = {"macaroon": exchanged_macaroon}
366+ requests_mock.add(
367+ "POST",
368+ gw_url + "publishergw/v1/tokens/offline/exchange",
369+ match=[
370+ responses.matchers.json_params_matcher({"macaroon": "mock-credentials"})
371+ ],
372+ json=exchanged,
373+ status=200,
374+ )
375+
376+ login(**self.make_args(store_url=gw_url, offline=True))
377+
378+ assert len(requests_mock.calls) == 1
379+ assert (
380+ ContainsDict(
381+ {
382+ "store:default": ContainsDict(
383+ {
384+ "gw_url": Equals(gw_url),
385+ "pubgw_token": Equals(exchanged_macaroon),
386+ }
387+ ),
388+ }
389+ ).match(config.Config().parser)
390+ is None
391+ )
392+
393+ assert len(caplog.messages) == 1
394+ assert caplog.messages[-1] == (
395+ "Exchanging store admin macaroon for a publisher gateway admin macaroon..."
396+ )
397+
398 @responses.activate
399 def test_store_url(self, mock_input, mock_getpass):
400 gw_url = "http://otherstore.local:1234/"
401diff --git a/store_admin/logic/tests/test_model_service.py b/store_admin/logic/tests/test_model_service.py
402new file mode 100644
403index 0000000..53b035b
404--- /dev/null
405+++ b/store_admin/logic/tests/test_model_service.py
406@@ -0,0 +1,74 @@
407+import json
408+import os
409+from unittest import mock
410+import pytest
411+from store_admin.logic.model_service import (
412+ BRAND_ACCOUNT_ID_ENV_KEY,
413+ create_model,
414+)
415+from testtools.matchers import (
416+ ContainsDict,
417+ Equals,
418+)
419+from store_admin import (
420+ config,
421+)
422+import requests
423+
424+
425+@pytest.mark.usefixtures("mocked_offline_config")
426+class TestModelService:
427+ gw_url = "http://offline.local/"
428+
429+ @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
430+ def test_set_brand_account_id(self, model_service_model_apis):
431+ model_service_model_apis(self.gw_url)
432+ create_model("test-name", "test-key", "16")
433+
434+ assert (
435+ ContainsDict(
436+ {
437+ "store:default": ContainsDict(
438+ {
439+ "brand_account_id": Equals("test-brand-account-id"),
440+ }
441+ ),
442+ }
443+ ).match(config.Config().parser)
444+ is None
445+ )
446+
447+ @mock.patch.dict(os.environ, {})
448+ def test_no_brand_account_id_saved(self, caplog):
449+ create_model("test-name", "test-key", "16")
450+
451+ assert caplog.messages[-1].startswith("No brand account ID saved.")
452+
453+ @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
454+ def test_authentication_failure(self, requests_mock, caplog):
455+ requests_mock.add(
456+ "POST",
457+ self.gw_url + "publishergw/v1/brand/test-brand-account-id/model",
458+ status=401,
459+ )
460+
461+ with pytest.raises(requests.exceptions.HTTPError):
462+ create_model("test-name", "test-key", "16")
463+
464+ assert (
465+ caplog.messages[-1]
466+ == "Authentication error. Please (re-)login to the offline store."
467+ )
468+
469+ @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
470+ def test_create_model_success(self, model_service_model_apis, caplog):
471+ _, api_mock = model_service_model_apis(self.gw_url)
472+ create_model("test-name", "test-key", "16")
473+
474+ assert len(api_mock.calls) == 1
475+ assert {
476+ "name": "test-name",
477+ "api-key": "test-key",
478+ "series": "16",
479+ } == json.loads(api_mock.calls[-1].request.body.decode())
480+ assert caplog.messages[-1] == "Model test-name created."
481diff --git a/store_admin/logic/tests/test_stores.py b/store_admin/logic/tests/test_stores.py
482index 4793fe9..ee69b8d 100644
483--- a/store_admin/logic/tests/test_stores.py
484+++ b/store_admin/logic/tests/test_stores.py
485@@ -13,7 +13,7 @@ import requests
486
487 from store_admin import defaults, exceptions
488 from store_admin.logic import stores
489-
490+from store_admin.logic.http_client import STORE_ADMIN_ENV_AUTH_KEY
491
492 PATCH_PREFIX = "store_admin.logic.stores."
493
494@@ -431,6 +431,8 @@ def test_export(caplog, tmpdir, snap_env, requests_mock, store_id):
495 "Creating the export archive...",
496 f"Store data exported to: {tarball_path}",
497 f"Admin token exported to: {token_path}",
498+ "Admin token usage:",
499+ f" export {STORE_ADMIN_ENV_AUTH_KEY}=$(cat {token_path})",
500 ]
501
502
503diff --git a/store_admin/webservices.py b/store_admin/webservices.py
504index efd5484..a2196cb 100644
505--- a/store_admin/webservices.py
506+++ b/store_admin/webservices.py
507@@ -164,6 +164,25 @@ def set_overrides(store, overrides, password=None):
508 return resp.json()
509
510
511+def model_service_create(store, create_path, json):
512+ """Send a POST creation request for model service entities to the proxy URL."""
513+ create_url = urllib.parse.urljoin(store.get("gw_url"), create_path)
514+ pubgw_admin_token = store.get("pubgw_token")
515+ headers = {"Authorization": f"Macaroon {pubgw_admin_token}"}
516+
517+ resp = requests.post(create_url, headers=headers, json=json)
518+
519+ if resp.status_code != 201:
520+ if resp.status_code == 401:
521+ logger.error(
522+ "Authentication error. Please (re-)login to the offline store."
523+ )
524+ else:
525+ _print_error_message("create model service entity", resp)
526+ resp.raise_for_status()
527+ return resp.json()
528+
529+
530 def _print_error_message(action, response):
531 """Print failure messages from other services in a standard way."""
532 logger.error("Failed to %s:", action)

Subscribers

People subscribed via source and target branches

to all changes: