From 55a1bff1661da5ad5fc67d96c8815551f129d2e0 Mon Sep 17 00:00:00 2001 From: Ondrej Kubik Date: Tue, 10 Oct 2023 14:06:02 +0100 Subject: [PATCH 1/7] console_conf: identity: use snapd unix socket When running in strict snap confinement snap client binary is not accessible. Additionally output format of snap client binary is not guaranteed not to change. snapd REST API should be used instead. Signed-off-by: Ondrej Kubik --- console_conf/controllers/identity.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/console_conf/controllers/identity.py b/console_conf/controllers/identity.py index 8ae54fb3..29697a8d 100644 --- a/console_conf/controllers/identity.py +++ b/console_conf/controllers/identity.py @@ -13,7 +13,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import json import logging import os import pwd @@ -178,18 +177,20 @@ class IdentityController(TuiController): login_details_path = self.opts.output_base + "/login-details.txt" else: self.app.urwid_loop.draw_screen() - cp = run_command(["snap", "create-user", "--sudoer", "--json", email]) - if cp.returncode != 0: + con = SnapdConnection("", "/run/snapd.socket") + user_action = {"action": "create", "email": email, "sudoer": True} + res = con.post("v2/users", body=user_action) + if res.json()["status"] != "OK": if isinstance(self.ui.body, IdentityView): self.ui.body.snap_create_user_failed( - "Creating user failed:", cp.stderr + "Creating user failed:", res.json()["result"]["message"] ) return else: - data = json.loads(cp.stdout) + username = res.json()["result"][0]["username"] result = { "realname": email, - "username": data["username"], + "username": username, } login_details_path = self.app.state_path("login-details.txt") self.model.add_user(result) From f0183d1cee8ac3f2de1433bd0192410d98a65b4c Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 13:53:40 +0100 Subject: [PATCH 2/7] console_conf: set application level snapd connection Signed-off-by: Maciej Borzecki --- console_conf/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/console_conf/core.py b/console_conf/core.py index 257c5e1f..a7bcd6e5 100644 --- a/console_conf/core.py +++ b/console_conf/core.py @@ -19,6 +19,7 @@ from console_conf.models.console_conf import ConsoleConfModel from console_conf.models.systems import RecoverySystemsModel from subiquitycore.prober import Prober from subiquitycore.snap import snap_name +from subiquitycore.snapd import SnapdConnection from subiquitycore.tui import TuiApplication log = logging.getLogger("console_conf.core") @@ -46,6 +47,9 @@ class ConsoleConf(TuiApplication): def __init__(self, opts): super().__init__(opts) self.prober = Prober(opts.machine_config, self.debug_flags) + # we're talking to snapd over the main socket, this may require + # snapd-control if executing inside a snap + self.snapdcon = SnapdConnection(self.root, "/run/snapd.socket") class RecoveryChooser(TuiApplication): From 8cfb2406b0ad42ad8ede7d6db50c5797f1f0a1c8 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 13:54:45 +0100 Subject: [PATCH 3/7] subiquitycore: allow to register callbacks for POST for fake snapd connections Allow registration of handlers for POST. Signed-off-by: Maciej Borzecki --- subiquitycore/snapd.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/subiquitycore/snapd.py b/subiquitycore/snapd.py index 108e8d37..fa95223f 100644 --- a/subiquitycore/snapd.py +++ b/subiquitycore/snapd.py @@ -127,6 +127,7 @@ class FakeSnapdConnection: self.scale_factor = scale_factor self.response_sets = {} self.output_base = output_base + self.post_cb = {} def configure_proxy(self, proxy): log.debug("pretending to restart snapd to pick up proxy config") @@ -167,6 +168,9 @@ class FakeSnapdConnection: "status": "Accepted", } ) + if path in self.post_cb: + return _FakeMemoryResponse(self.post_cb[path](path, body, **args)) + raise Exception( "Don't know how to fake POST response to {}".format((path, args)) ) From 7144a65d2296e74c50121f73811c80323d8706c3 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 13:55:48 +0100 Subject: [PATCH 4/7] console_conf/controllers/identity: use app level snapd connection Signed-off-by: Maciej Borzecki --- console_conf/controllers/identity.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/console_conf/controllers/identity.py b/console_conf/controllers/identity.py index 29697a8d..c15943c1 100644 --- a/console_conf/controllers/identity.py +++ b/console_conf/controllers/identity.py @@ -24,7 +24,7 @@ from subiquitycore import snap from subiquitycore.snapd import SnapdConnection from subiquitycore.ssh import get_ips_standalone, host_key_info from subiquitycore.tuicontroller import TuiController -from subiquitycore.utils import disable_console_conf, run_command +from subiquitycore.utils import disable_console_conf log = logging.getLogger("console_conf.controllers.identity") @@ -51,9 +51,8 @@ def get_core_version(): return version -def get_managed(): +def get_managed(con): """Check if device is managed""" - con = SnapdConnection("", "/run/snapd.socket") return con.get("v2/system-info").json()["result"]["managed"] @@ -65,9 +64,8 @@ def get_realname(username): return info.pw_gecos.split(",", 1)[0] -def get_device_owner(): +def get_device_owner(con): """Get device owner, if any""" - con = SnapdConnection("", "/run/snapd.socket") for user in con.get("v2/users").json()["result"]: if "username" not in user: continue @@ -129,7 +127,9 @@ def write_login_details(fp, username, ips, state_dir=None): def write_login_details_standalone(): - owner = get_device_owner() + # running in standalone mode + con = SnapdConnection("/", "/run/snapd.socket") + owner = get_device_owner(con) ips = get_ips_standalone() if len(ips) == 0: if owner is None: @@ -159,8 +159,8 @@ class IdentityController(TuiController): self.model = app.base_model.identity def make_ui(self): - if get_managed(): - device_owner = get_device_owner() + if get_managed(self.app.snapdcon): + device_owner = get_device_owner(self.app.snapdcon) if device_owner: self.model.add_user(device_owner) return self.make_login_view() @@ -177,9 +177,8 @@ class IdentityController(TuiController): login_details_path = self.opts.output_base + "/login-details.txt" else: self.app.urwid_loop.draw_screen() - con = SnapdConnection("", "/run/snapd.socket") user_action = {"action": "create", "email": email, "sudoer": True} - res = con.post("v2/users", body=user_action) + res = self.app.snapdcon.post("v2/users", body=user_action) if res.json()["status"] != "OK": if isinstance(self.ui.body, IdentityView): self.ui.body.snap_create_user_failed( From de123837258f602dc80accd740bda04cd76ffe74 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 13:56:09 +0100 Subject: [PATCH 5/7] console_conf/controllers/identity: update unit tests to fake snapd socket connection Signed-off-by: Maciej Borzecki --- .../controllers/tests/test_identity.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/console_conf/controllers/tests/test_identity.py b/console_conf/controllers/tests/test_identity.py index 1522e162..20a64d8b 100644 --- a/console_conf/controllers/tests/test_identity.py +++ b/console_conf/controllers/tests/test_identity.py @@ -20,21 +20,18 @@ from unittest.mock import MagicMock, patch from console_conf.controllers.identity import IdentityController from subiquitycore.models.network import NetworkDev from subiquitycore.tests.mocks import make_app +from subiquitycore.snapd import get_fake_connection class TestIdentityController(unittest.TestCase): @patch("os.ttyname", return_value="/dev/tty1") @patch("console_conf.controllers.identity.get_core_version", return_value="24") - @patch("console_conf.controllers.identity.run_command") - def test_snap_integration(self, run_command, core_version, ttyname): + def test_snap_integration(self, core_version, ttyname): with tempfile.TemporaryDirectory(suffix="console-conf-test") as statedir: - proc_mock = MagicMock() - run_command.return_value = proc_mock - proc_mock.returncode = 0 - proc_mock.stdout = '{"username":"foo"}' - app = make_app() app.opts.dry_run = False + app.snapdcon = get_fake_connection() + app.state_dir = statedir network_model = MagicMock() mock_devs = [MagicMock(spec=NetworkDev)] network_model.get_all_netdevs.return_value = mock_devs @@ -47,11 +44,31 @@ class TestIdentityController(unittest.TestCase): app.state_path = MagicMock(side_effect=state_path) + create_user_calls = 0 + + def create_user_cb(path, body, **args): + nonlocal create_user_calls + create_user_calls += 1 + self.assertEqual(path, "v2/users") + self.assertEqual( + body, {"action": "create", "email": "foo@bar.com", "sudoer": True} + ) + return { + "status": "OK", + "result": [ + { + "username": "foo", + } + ], + } + + # fake POST handlers + app.snapdcon.post_cb["v2/users"] = create_user_cb + c = IdentityController(app) c.identity_done("foo@bar.com") - run_command.assert_called_with( - ["snap", "create-user", "--sudoer", "--json", "foo@bar.com"] - ) + + self.assertEqual(create_user_calls, 1) with open(os.path.join(statedir, "login-details.txt")) as inf: data = inf.read() From ea86499691aa58d21cbd68709dc81ce171651d68 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 15:43:22 +0100 Subject: [PATCH 6/7] subiquitycore/snapd: add response set serving objects from memory Signed-off-by: Maciej Borzecki --- subiquitycore/snapd.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/subiquitycore/snapd.py b/subiquitycore/snapd.py index fa95223f..97bba5b2 100644 --- a/subiquitycore/snapd.py +++ b/subiquitycore/snapd.py @@ -121,6 +121,19 @@ class ResponseSet: return _FakeFileResponse(f) +class MemoryResponseSet: + """Set of response for an endpoint which returns data stored in memory.""" + + def __init__(self, data): + self.data = data + self.index = 0 + + def next(self): + d = self.data[self.index] + self.index += 1 + return _FakeMemoryResponse(d) + + class FakeSnapdConnection: def __init__(self, snap_data_dir, scale_factor, output_base): self.snap_data_dir = snap_data_dir From 9e081f4fd834cae2ffcb27f5ad852bd6060250d4 Mon Sep 17 00:00:00 2001 From: Maciej Borzecki Date: Wed, 7 Feb 2024 15:48:20 +0100 Subject: [PATCH 7/7] console_conf/controllers/identity: extend unit tests Signed-off-by: Maciej Borzecki --- .../controllers/tests/test_identity.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/console_conf/controllers/tests/test_identity.py b/console_conf/controllers/tests/test_identity.py index 20a64d8b..2712fbd9 100644 --- a/console_conf/controllers/tests/test_identity.py +++ b/console_conf/controllers/tests/test_identity.py @@ -18,9 +18,10 @@ import unittest from unittest.mock import MagicMock, patch from console_conf.controllers.identity import IdentityController +from console_conf.ui.views import IdentityView, LoginView from subiquitycore.models.network import NetworkDev +from subiquitycore.snapd import MemoryResponseSet, get_fake_connection from subiquitycore.tests.mocks import make_app -from subiquitycore.snapd import get_fake_connection class TestIdentityController(unittest.TestCase): @@ -73,3 +74,48 @@ class TestIdentityController(unittest.TestCase): with open(os.path.join(statedir, "login-details.txt")) as inf: data = inf.read() self.assertIn("Ubuntu Core 24 on 1.2.3.4 (tty1)\n", data) + + @patch("pwd.getpwnam") + @patch("os.path.isdir", return_value=True) + def test_make_ui_managed_with_user(self, isdir, getpwnam): + pwinfo = MagicMock() + pwinfo.pw_gecos = "Foo,Bar" + getpwnam.return_value = pwinfo + + app = make_app() + app.opts.dry_run = False + app.snapdcon = get_fake_connection() + # app.state_dir = statedir + network_model = MagicMock() + mock_devs = [MagicMock(spec=NetworkDev)] + network_model.get_all_netdevs.return_value = mock_devs + mock_devs[0].actual_global_ip_addresses = ["1.2.3.4"] + app.base_model.network = network_model + + app.snapdcon.response_sets = { + "v2-system-info": MemoryResponseSet([{"result": {"managed": True}}]), + "v2-users": MemoryResponseSet( + [ + # no "username" for first entry + {"result": [{}, {"username": "foo"}]} + ] + ), + } + + c = IdentityController(app) + ui = c.make_ui() + self.assertIsInstance(ui, LoginView) + getpwnam.assert_called_with("foo") + + def test_make_ui_unmanaged(self): + app = make_app() + app.opts.dry_run = False + app.snapdcon = get_fake_connection() + + app.snapdcon.response_sets = { + "v2-system-info": MemoryResponseSet([{"result": {"managed": False}}]), + } + + c = IdentityController(app) + ui = c.make_ui() + self.assertIsInstance(ui, IdentityView)