Merge ~bowenfan/snapstore-client:SN2279-model_service_update_commands into snapstore-client:main

Proposed by Bowen Fan
Status: Merged
Approved by: Bowen Fan
Approved revision: 7a3520aef5ab851bb2d754b6399381c05cb0aa38
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~bowenfan/snapstore-client:SN2279-model_service_update_commands
Merge into: snapstore-client:main
Diff against target: 357 lines (+159/-78)
6 files modified
store_admin/cli/model_service.py (+8/-0)
store_admin/cli/runner.py (+8/-0)
store_admin/logic/model_service.py (+13/-0)
store_admin/logic/tests/conftest.py (+88/-68)
store_admin/logic/tests/test_model_service.py (+23/-10)
store_admin/webservices.py (+19/-0)
Reviewer Review Type Date Requested Status
Maximiliano Bertacchini Approve
Review via email: mp+457353@code.launchpad.net

Commit message

Add model service update CLI for updating model API key

Model API key is the only updatable attribute in the on-prem model service
for now. Signing key names cannot be updated in a straightforward way because
the user would have to manually update the new corresponding account-key assertion.

Also add associated tests, fixtures, and CLI runner config.

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

Description of the change

Part of 5 snapstore-client MPs to add model service support

1: Add publishergw support and a skeleton for model service < Done [1]
2: Add remaining create CLI functions < Done [2]
3: List
4: Update < This MP
5: Delete

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

-----

[1] https://code.launchpad.net/~bowenfan/snapstore-client/+git/snapstore-client/+merge/457208
[2] https://code.launchpad.net/~bowenfan/snapstore-client/+git/snapstore-client/+merge/457241

To post a comment you must log in.
Revision history for this message
Maximiliano Bertacchini (maxiberta) wrote :

LGTM +1

review: Approve

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/store_admin/cli/model_service.py b/store_admin/cli/model_service.py
2index 2f990b3..8929a65 100644
3--- a/store_admin/cli/model_service.py
4+++ b/store_admin/cli/model_service.py
5@@ -43,3 +43,11 @@ def create_signing_key(key_name):
6 def create_serial_policy(model_name, signing_key_sha3_384):
7 """Create a serial policy in the model service."""
8 model_service.create_serial_policy(model_name, signing_key_sha3_384)
9+
10+
11+@click.command(name="model")
12+@click.argument("model_name")
13+@click.option("--api-key", help="New API key")
14+def update_model(model_name, api_key):
15+ """Update an existing model in the model service."""
16+ model_service.update_model(model_name, api_key)
17diff --git a/store_admin/cli/runner.py b/store_admin/cli/runner.py
18index 7ff337d..88a6ce9 100644
19--- a/store_admin/cli/runner.py
20+++ b/store_admin/cli/runner.py
21@@ -56,6 +56,11 @@ def lst():
22
23
24 @run.group()
25+def update():
26+ "Update various artifacts on the managed proxy."
27+
28+
29+@run.group()
30 def upload():
31 "Upload various artifacts to the managed proxy."
32
33@@ -83,6 +88,9 @@ create.add_command(model_service.create_serial_policy)
34 # Commands under list
35 lst.add_command(overrides.list_overrides)
36
37+# Commands under update
38+update.add_command(model_service.update_model)
39+
40 # Commands under upload
41 upload.add_command(overrides.upload_overrides)
42 upload.add_command(snaps.upload_snap)
43diff --git a/store_admin/logic/model_service.py b/store_admin/logic/model_service.py
44index 8dae87d..de7a084 100644
45--- a/store_admin/logic/model_service.py
46+++ b/store_admin/logic/model_service.py
47@@ -75,3 +75,16 @@ def create_serial_policy(model_name, signing_key_sha3_384):
48 f"Model '{model_name}' configured to sign serials "
49 f"with key '{signing_key_sha3_384}'."
50 )
51+
52+
53+def update_model(model_name, api_key):
54+ cfg = config.Config()
55+ store = _check_default_store(cfg)
56+ brand_account_id = _get_or_set_brand_account_id(cfg)
57+ if not store or not brand_account_id:
58+ return 1
59+
60+ json = {"api-key": api_key}
61+ path = f"/publishergw/v1/brand/{brand_account_id}/model/{model_name}"
62+ ws.model_service_update(store, path, json)
63+ logger.info(f"Model '{model_name}' updated with new API key '{api_key}'.")
64diff --git a/store_admin/logic/tests/conftest.py b/store_admin/logic/tests/conftest.py
65index b0b08ac..2c7dc58 100644
66--- a/store_admin/logic/tests/conftest.py
67+++ b/store_admin/logic/tests/conftest.py
68@@ -215,35 +215,84 @@ def _create_model_service_user_response(display_name=None, id=None, username=Non
69 }
70
71
72-@pytest.fixture
73-def model_service_model_apis(requests_mock, brand_account_id):
74- def _make_model_response(brand_account_id, **kwargs):
75- model = {}
76+def _make_model_response(brand_account_id, **kwargs):
77+ model = {}
78
79- model["brand_account_id"] = brand_account_id
80- model["name"] = kwargs.get("name") or "test-model"
81- model["series"] = "16"
82- model["api-key"] = kwargs.get("api_key") or "test-api-key"
83+ model["brand_account_id"] = brand_account_id
84+ model["name"] = kwargs.get("name") or "test-model"
85+ model["series"] = "16"
86+ model["api-key"] = kwargs.get("api_key") or "test-api-key"
87+
88+ model["created-by"] = (
89+ kwargs.get("created_by") or _create_model_service_user_response()
90+ )
91+ model["created-at"] = (
92+ kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
93+ )
94+ model["modified-by"] = (
95+ _create_model_service_user_response()
96+ if "modified_by" not in kwargs
97+ else kwargs["modified_by"]
98+ )
99+ model["modified-at"] = (
100+ datetime.utcnow().isoformat() + "Z"
101+ if "modified_at" not in kwargs
102+ else kwargs["modified_at"]
103+ )
104+
105+ return model
106
107- model["created-by"] = (
108- kwargs.get("created_by") or _create_model_service_user_response()
109- )
110- model["created-at"] = (
111- kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
112- )
113- model["modified-by"] = (
114- _create_model_service_user_response()
115- if "modified_by" not in kwargs
116- else kwargs["modified_by"]
117- )
118- model["modified-at"] = (
119- datetime.utcnow().isoformat() + "Z"
120- if "modified_at" not in kwargs
121- else kwargs["modified_at"]
122- )
123
124- return model
125+def _make_signing_key_response(brand_account_id, **kwargs):
126+ key = {}
127
128+ key["brand_account_id"] = brand_account_id
129+ key["name"] = kwargs.get("name") or "test-signing-key"
130+ key["fingerprint"] = kwargs.get("fingerprint") or "test-fingerprint"
131+ key["sha3-384"] = kwargs.get("sha3_384") or "test-sha3-384"
132+
133+ key["created-by"] = (
134+ kwargs.get("created_by") or _create_model_service_user_response()
135+ )
136+ key["created-at"] = (
137+ kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
138+ )
139+ key["modified-by"] = (
140+ _create_model_service_user_response()
141+ if "modified_by" not in kwargs
142+ else kwargs["modified_by"]
143+ )
144+ key["modified-at"] = (
145+ datetime.utcnow().isoformat() + "Z"
146+ if "modified_at" not in kwargs
147+ else kwargs["modified_at"]
148+ )
149+
150+ return key
151+
152+
153+def _make_serial_policy_response(model_name, **kwargs):
154+ policy = {}
155+
156+ policy["model-name"] = model_name
157+ policy["revision"] = kwargs.get("revision") or 1
158+ policy["signing-key-sha3-384"] = (
159+ kwargs.get("signing_key_sha3_384") or "test-key-sha3-384"
160+ )
161+ policy["authority"] = kwargs.get("authority") or "test-authority"
162+
163+ policy["created-by"] = (
164+ kwargs.get("created_by") or _create_model_service_user_response()
165+ )
166+ policy["created-at"] = (
167+ kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
168+ )
169+
170+ return policy
171+
172+
173+@pytest.fixture
174+def create_model_api(requests_mock, brand_account_id):
175 def setup(gw_url):
176 create_response = _make_model_response(brand_account_id)
177 requests_mock.add(
178@@ -258,33 +307,22 @@ def model_service_model_apis(requests_mock, brand_account_id):
179
180
181 @pytest.fixture
182-def model_service_signing_key_apis(requests_mock, brand_account_id):
183- def _make_signing_key_response(brand_account_id, **kwargs):
184- key = {}
185+def update_model_api(requests_mock, brand_account_id):
186+ def setup(gw_url, model_name):
187+ update_response = _make_model_response(brand_account_id)
188+ requests_mock.add(
189+ "PATCH",
190+ gw_url + f"publishergw/v1/brand/{brand_account_id}/model/{model_name}",
191+ json=update_response,
192+ status=200,
193+ )
194+ return (update_response, requests_mock)
195
196- key["brand_account_id"] = brand_account_id
197- key["name"] = kwargs.get("name") or "test-signing-key"
198- key["fingerprint"] = kwargs.get("fingerprint") or "test-fingerprint"
199- key["sha3-384"] = kwargs.get("sha3_384") or "test-sha3-384"
200+ return setup
201
202- key["created-by"] = (
203- kwargs.get("created_by") or _create_model_service_user_response()
204- )
205- key["created-at"] = (
206- kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
207- )
208- key["modified-by"] = (
209- _create_model_service_user_response()
210- if "modified_by" not in kwargs
211- else kwargs["modified_by"]
212- )
213- key["modified-at"] = (
214- datetime.utcnow().isoformat() + "Z"
215- if "modified_at" not in kwargs
216- else kwargs["modified_at"]
217- )
218- return key
219
220+@pytest.fixture
221+def create_signing_key_api(requests_mock, brand_account_id):
222 def setup(gw_url):
223 create_response = _make_signing_key_response(brand_account_id)
224 requests_mock.add(
225@@ -300,25 +338,7 @@ def model_service_signing_key_apis(requests_mock, brand_account_id):
226
227
228 @pytest.fixture
229-def model_service_serial_policy_apis(requests_mock, brand_account_id, model_name):
230- def _make_serial_policy_response(model_name, **kwargs):
231- policy = {}
232-
233- policy["model-name"] = model_name
234- policy["revision"] = kwargs.get("revision") or 1
235- policy["signing-key-sha3-384"] = (
236- kwargs.get("signing_key_sha3_384") or "test-key-sha3-384"
237- )
238- policy["authority"] = kwargs.get("authority") or "test-authority"
239-
240- policy["created-by"] = (
241- kwargs.get("created_by") or _create_model_service_user_response()
242- )
243- policy["created-at"] = (
244- kwargs.get("created_at") or datetime.utcnow().isoformat() + "Z",
245- )
246- return policy
247-
248+def create_serial_policy_api(requests_mock, brand_account_id, model_name):
249 def setup(gw_url):
250 create_response = _make_serial_policy_response(model_name)
251 requests_mock.add(
252diff --git a/store_admin/logic/tests/test_model_service.py b/store_admin/logic/tests/test_model_service.py
253index 4496bfd..a07d5d3 100644
254--- a/store_admin/logic/tests/test_model_service.py
255+++ b/store_admin/logic/tests/test_model_service.py
256@@ -7,6 +7,7 @@ from store_admin.logic.model_service import (
257 create_model,
258 create_signing_key,
259 create_serial_policy,
260+ update_model,
261 )
262 from testtools.matchers import (
263 ContainsDict,
264@@ -23,8 +24,8 @@ class TestModelService:
265 gw_url = "http://offline.local/"
266
267 @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
268- def test_set_brand_account_id(self, model_service_model_apis):
269- model_service_model_apis(self.gw_url)
270+ def test_set_brand_account_id(self, create_model_api):
271+ create_model_api(self.gw_url)
272 create_model("test-name", "test-key", "16")
273
274 assert (
275@@ -63,8 +64,8 @@ class TestModelService:
276 )
277
278 @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
279- def test_create_model_success(self, model_service_model_apis, caplog):
280- _, api_mock = model_service_model_apis(self.gw_url)
281+ def test_create_model_success(self, create_model_api, caplog):
282+ _, api_mock = create_model_api(self.gw_url)
283 create_model("test-name", "test-key", "16")
284
285 assert len(api_mock.calls) == 1
286@@ -76,8 +77,8 @@ class TestModelService:
287 assert caplog.messages[-1] == "Model 'test-name' created."
288
289 @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
290- def test_create_signing_key_success(self, model_service_signing_key_apis, caplog):
291- _, api_mock = model_service_signing_key_apis(self.gw_url)
292+ def test_create_signing_key_success(self, create_signing_key_api, caplog):
293+ _, api_mock = create_signing_key_api(self.gw_url)
294 create_signing_key("test-name")
295
296 assert len(api_mock.calls) == 1
297@@ -91,10 +92,8 @@ class TestModelService:
298 assert caplog.messages[-1] == "Signing key 'test-name' created."
299
300 @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
301- def test_create_serial_policy_success(
302- self, model_service_serial_policy_apis, caplog
303- ):
304- _, api_mock = model_service_serial_policy_apis(self.gw_url)
305+ def test_create_serial_policy_success(self, create_serial_policy_api, caplog):
306+ _, api_mock = create_serial_policy_api(self.gw_url)
307 create_serial_policy("test-model-name", "test-sha3-384")
308
309 assert len(api_mock.calls) == 1
310@@ -106,3 +105,17 @@ class TestModelService:
311 "Model 'test-model-name' configured to "
312 "sign serials with key 'test-sha3-384'."
313 )
314+
315+ @mock.patch.dict(os.environ, {BRAND_ACCOUNT_ID_ENV_KEY: "test-brand-account-id"})
316+ def test_update_model_success(self, update_model_api, caplog):
317+ _, api_mock = update_model_api(self.gw_url, "test-model")
318+ update_model("test-model", "updated-api-key")
319+
320+ assert len(api_mock.calls) == 1
321+ assert {
322+ "api-key": "updated-api-key",
323+ } == json.loads(api_mock.calls[-1].request.body.decode())
324+ assert (
325+ caplog.messages[-1]
326+ == "Model 'test-model' updated with new API key 'updated-api-key'."
327+ )
328diff --git a/store_admin/webservices.py b/store_admin/webservices.py
329index a2196cb..b219ecf 100644
330--- a/store_admin/webservices.py
331+++ b/store_admin/webservices.py
332@@ -183,6 +183,25 @@ def model_service_create(store, create_path, json):
333 return resp.json()
334
335
336+def model_service_update(store, update_path, json):
337+ """Send a PATCH update request for model service entities to the proxy URL."""
338+ read_url = urllib.parse.urljoin(store.get("gw_url"), update_path)
339+ pubgw_admin_token = store.get("pubgw_token")
340+ headers = {"Authorization": f"Macaroon {pubgw_admin_token}"}
341+
342+ resp = requests.patch(read_url, headers=headers, json=json)
343+
344+ if resp.status_code != 200:
345+ if resp.status_code == 401:
346+ logger.error(
347+ "Authentication error. Please (re-)login to the offline store."
348+ )
349+ else:
350+ _print_error_message("update model service entity", resp)
351+ resp.raise_for_status()
352+ return resp.json()
353+
354+
355 def _print_error_message(action, response):
356 """Print failure messages from other services in a standard way."""
357 logger.error("Failed to %s:", action)

Subscribers

People subscribed via source and target branches

to all changes: