diff --git a/landscape/client/configuration.py b/landscape/client/configuration.py index 759b45f75..1e408a6f5 100644 --- a/landscape/client/configuration.py +++ b/landscape/client/configuration.py @@ -5,11 +5,15 @@ """ import getpass import io +import json import os import pwd import shlex +import subprocess import sys import textwrap +from datetime import datetime +from datetime import timezone from functools import partial from urllib.parse import urlparse @@ -23,6 +27,7 @@ from landscape.client.reactor import LandscapeReactor from landscape.client.serviceconfig import ServiceConfig from landscape.client.serviceconfig import ServiceConfigException +from landscape.client.snap_utils import get_snap_info from landscape.lib import base64 from landscape.lib.amp import MethodCallError from landscape.lib.bootstrap import BootstrapDirectory @@ -30,7 +35,9 @@ from landscape.lib.compat import input from landscape.lib.fetch import fetch from landscape.lib.fetch import FetchError +from landscape.lib.format import expandvars from landscape.lib.fs import create_binary_file +from landscape.lib.network import get_active_device_info from landscape.lib.network import get_fqdn from landscape.lib.persist import Persist from landscape.lib.tag import is_valid_tag @@ -601,6 +608,54 @@ def decode_base64_ssl_public_certificate(config): config.ssl_public_key = store_public_key_data(config, decoded_cert) +def generate_computer_title(auto_enroll_config): + """Generate the computer title. + + This follows the LA017 specification and falls back to `hostname` + if generating the title fails due to missing data. + """ + snap_info = get_snap_info() + wait_for_serial = auto_enroll_config.get("wait-for-serial-as", True) + if "serial" not in snap_info and wait_for_serial: + return + + hostname = get_fqdn() + wait_for_hostname = auto_enroll_config.get("wait-for-hostname", False) + if hostname == "localhost" and wait_for_hostname: + return + + (nic,) = get_active_device_info(default_only=True)[0:1] or [{}] + + lshw = subprocess.run( + ["lshw", "-json", "-quiet", "-c", "system"], + capture_output=True, + text=True, + ) + (hardware,) = json.loads(lshw.stdout)[0:1] or [{}] + + computer_title_pattern = auto_enroll_config.get( + "computer-title-pattern", + "${hostname}", + ) + title = expandvars( + computer_title_pattern, + serial=snap_info.get("serial", ""), + model=snap_info.get("model", ""), + brand=snap_info.get("brand", ""), + hostname=hostname, + ip=nic.get("ip_address", ""), + mac=nic.get("mac_address", ""), + prodiden=hardware.get("product", ""), + serialno=hardware.get("serial", ""), + datetime=datetime.now(timezone.utc), + ) + + if title == "": # on the off-chance substitute values are missing + title = hostname + + return title + + def setup(config): """ Perform steps to ensure that landscape-client is correctly configured diff --git a/landscape/client/deployment.py b/landscape/client/deployment.py index 2b4ca6885..cdc243267 100644 --- a/landscape/client/deployment.py +++ b/landscape/client/deployment.py @@ -6,6 +6,7 @@ from landscape import VERSION from landscape.client import DEFAULT_CONFIG +from landscape.client import snap_http from landscape.client.upgraders import UPGRADE_MANAGERS from landscape.lib import logging from landscape.lib.config import BaseConfiguration as _BaseConfiguration @@ -189,6 +190,27 @@ def juju_filename(self): backwards-compatibility.""" return os.path.join(self.data_path, "juju-info.json") + def auto_configure(self): + """Automatically configure the client snap.""" + from landscape.client.configuration import generate_computer_title + + client_conf = snap_http.get_conf("landscape-client").result + auto_enroll_conf = client_conf.get("auto-register", {}) + + enabled = auto_enroll_conf.get("enabled", False) + configured = auto_enroll_conf.get("configured", False) + if not enabled or configured: + return + + title = generate_computer_title(auto_enroll_conf) + if title: + self.computer_title = title + self.write() + + auto_enroll_conf["configured"] = True + client_conf["auto-register"] = auto_enroll_conf + snap_http.set_conf("landscape-client", client_conf) + def get_versioned_persist(service): """Get a L{Persist} database with upgrade rules applied. diff --git a/landscape/client/monitor/computerinfo.py b/landscape/client/monitor/computerinfo.py index 1ceca6440..c1b353e80 100644 --- a/landscape/client/monitor/computerinfo.py +++ b/landscape/client/monitor/computerinfo.py @@ -5,7 +5,7 @@ from twisted.internet.defer import returnValue from landscape.client.monitor.plugin import MonitorPlugin -from landscape.client.snap_utils import get_assertions +from landscape.client.snap_utils import get_snap_info from landscape.lib.cloud import fetch_ec2_meta_data from landscape.lib.fetch import fetch_async from landscape.lib.fs import read_text_file @@ -221,10 +221,9 @@ def log_success(result): def _create_snap_info_message(self): """Create message with the snapd serial metadata.""" message = {} - assertions = get_assertions("serial") - if assertions: - assertion = assertions[0] - self._add_if_new(message, "brand", assertion["brand-id"]) - self._add_if_new(message, "model", assertion["model"]) - self._add_if_new(message, "serial", assertion["serial"]) + snap_info = get_snap_info() + if snap_info: + self._add_if_new(message, "brand", snap_info["brand"]) + self._add_if_new(message, "model", snap_info["model"]) + self._add_if_new(message, "serial", snap_info["serial"]) return message diff --git a/landscape/client/monitor/tests/test_computerinfo.py b/landscape/client/monitor/tests/test_computerinfo.py index 4014ca7ee..f1b1ac97a 100644 --- a/landscape/client/monitor/tests/test_computerinfo.py +++ b/landscape/client/monitor/tests/test_computerinfo.py @@ -560,7 +560,7 @@ def test_fetch_ec2_meta_data_bad_result_retry(self): result, ) - @mock.patch("landscape.client.monitor.computerinfo.get_assertions") + @mock.patch("landscape.client.snap_utils.get_assertions") def test_snap_info(self, mock_get_assertions): """Test getting the snap info message.""" mock_get_assertions.return_value = [ @@ -586,7 +586,7 @@ def test_snap_info(self, mock_get_assertions): "03961d5d-26e5-443f-838d-6db046126bea", ) - @mock.patch("landscape.client.monitor.computerinfo.get_assertions") + @mock.patch("landscape.client.snap_utils.get_assertions") def test_snap_info_no_results(self, mock_get_assertions): """Test getting the snap info message when there are no results. diff --git a/landscape/client/snap_utils.py b/landscape/client/snap_utils.py index 8a34e8305..840b918cf 100644 --- a/landscape/client/snap_utils.py +++ b/landscape/client/snap_utils.py @@ -36,3 +36,16 @@ def get_assertions(assertion_type: str): assertions.append(assertion) return assertions + + +def get_snap_info(): + """Get the snap device information.""" + info = {} + + serial_as = get_assertions("serial") + if serial_as: + info["serial"] = serial_as[0]["serial"] + info["model"] = serial_as[0]["model"] + info["brand"] = serial_as[0]["brand-id"] + + return info diff --git a/landscape/client/tests/test_configuration.py b/landscape/client/tests/test_configuration.py index f026a842b..5848b756d 100644 --- a/landscape/client/tests/test_configuration.py +++ b/landscape/client/tests/test_configuration.py @@ -2,6 +2,7 @@ import sys import textwrap import unittest +from datetime import datetime from functools import partial from unittest import mock @@ -21,6 +22,7 @@ from landscape.client.configuration import exchange_failure from landscape.client.configuration import EXIT_NOT_REGISTERED from landscape.client.configuration import failure +from landscape.client.configuration import generate_computer_title from landscape.client.configuration import got_connection from landscape.client.configuration import got_error from landscape.client.configuration import handle_registration_errors @@ -2760,3 +2762,205 @@ def test_function(self, Identity, Persist): Persist().save.assert_called_once_with() Identity.assert_called_once_with(config, Persist()) self.assertEqual(Identity().secure_id, "fancysecureid") + + +class GenerateComputerTitleTest(unittest.TestCase): + """Tests for the `generate_computer_title` function.""" + + @mock.patch("landscape.client.configuration.subprocess") + @mock.patch("landscape.client.configuration.get_active_device_info") + @mock.patch("landscape.client.configuration.get_fqdn") + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title( + self, + mock_snap_info, + mock_fqdn, + mock_active_device_info, + mock_subprocess, + ): + """Returns a computer title matching `computer-title-pattern`.""" + mock_snap_info.return_value = { + "serial": "f315cab5-ba74-4d3c-be85-713406455773", + "model": "generic-classic", + "brand": "generic", + } + mock_fqdn.return_value = "terra" + mock_active_device_info.return_value = [ + { + "interface": "wlp108s0", + "ip_address": "192.168.0.104", + "mac_address": "5c:80:b6:99:42:8d", + "broadcast_address": "192.168.0.255", + "netmask": "255.255.255.0", + }, + ] + mock_subprocess.run.return_value.stdout = """ +[{ + "id" : "terra", + "class" : "system", + "claimed" : true, + "handle" : "DMI:0002", + "description" : "Convertible", + "product" : "HP EliteBook x360 1030 G4 (8TK37UC#ABA)", + "vendor" : "HP", + "serial" : "ABCDE" +}] +""" + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${model:8:7}-${serial:0:8}", + "wait-for-serial-as": True, + "wait-for-hostname": True, + }, + ) + self.assertEqual(title, "classic-f315cab5") + + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title_wait_for_serial_no_serial_assertion( + self, + mock_snap_info, + ): + """Returns `None`.""" + mock_snap_info.return_value = {} + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${model:8:7}-${serial:0:8}", + "wait-for-serial-as": True, + "wait-for-hostname": True, + }, + ) + self.assertIsNone(title) + + @mock.patch("landscape.client.configuration.get_fqdn") + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title_wait_for_hostname( + self, + mock_snap_info, + mock_fqdn, + ): + """Returns `None`.""" + mock_snap_info.return_value = { + "serial": "f315cab5-ba74-4d3c-be85-713406455773", + "model": "generic-classic", + "brand": "generic", + } + mock_fqdn.return_value = "localhost" + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${model:8:7}-${serial:0:8}", + "wait-for-serial-as": True, + "wait-for-hostname": True, + }, + ) + self.assertIsNone(title) + + @mock.patch("landscape.client.configuration.subprocess") + @mock.patch("landscape.client.configuration.get_active_device_info") + @mock.patch("landscape.client.configuration.get_fqdn") + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title_no_nic( + self, + mock_snap_info, + mock_fqdn, + mock_active_device_info, + mock_subprocess, + ): + """Returns a title (almost) matching `computer-title-pattern`.""" + mock_snap_info.return_value = { + "serial": "f315cab5-ba74-4d3c-be85-713406455773", + "model": "generic-classic", + "brand": "generic", + } + mock_fqdn.return_value = "terra" + mock_active_device_info.return_value = [] + mock_subprocess.run.return_value.stdout = """ +[{ + "id" : "terra", + "class" : "system", + "claimed" : true, + "handle" : "DMI:0002", + "description" : "Convertible", + "product" : "HP EliteBook x360 1030 G4 (8TK37UC#ABA)", + "vendor" : "HP", + "serial" : "ABCDE" +}] +""" + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${serialno:1}-${ip}", + "wait-for-serial-as": True, + "wait-for-hostname": True, + }, + ) + self.assertEqual(title, "BCDE-") + + @mock.patch("landscape.client.configuration.subprocess") + @mock.patch("landscape.client.configuration.get_active_device_info") + @mock.patch("landscape.client.configuration.get_fqdn") + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title_with_missing_data( + self, + mock_snap_info, + mock_fqdn, + mock_active_device_info, + mock_subprocess, + ): + """Returns the default title `hostname`.""" + mock_snap_info.return_value = {} + mock_fqdn.return_value = "localhost" + mock_active_device_info.return_value = [] + mock_subprocess.run.return_value.stdout = "[]" + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${mac}${serialno}", + "wait-for-serial-as": False, + "wait-for-hostname": False, + }, + ) + self.assertEqual(title, "localhost") + + @mock.patch("landscape.client.configuration.datetime") + @mock.patch("landscape.client.configuration.subprocess") + @mock.patch("landscape.client.configuration.get_active_device_info") + @mock.patch("landscape.client.configuration.get_fqdn") + @mock.patch("landscape.client.configuration.get_snap_info") + def test_generate_computer_title_with_date( + self, + mock_snap_info, + mock_fqdn, + mock_active_device_info, + mock_subprocess, + mock_datetime, + ): + """Returns a computer title matching `computer-title-pattern`.""" + mock_snap_info.return_value = {} + mock_fqdn.return_value = "localhost" + mock_active_device_info.return_value = [] + mock_subprocess.run.return_value.stdout = "[]" + mock_datetime.now.return_value = datetime(2024, 1, 2, 0, 0, 0) + + title = generate_computer_title( + { + "enabled": True, + "configured": False, + "computer-title-pattern": "${datetime:0:4}-machine", + "wait-for-serial-as": False, + "wait-for-hostname": False, + }, + ) + self.assertEqual(title, "2024-machine") diff --git a/landscape/client/tests/test_deployment.py b/landscape/client/tests/test_deployment.py index 4456aec72..9c1326223 100644 --- a/landscape/client/tests/test_deployment.py +++ b/landscape/client/tests/test_deployment.py @@ -4,6 +4,7 @@ from landscape.client.deployment import Configuration from landscape.client.deployment import get_versioned_persist from landscape.client.deployment import init_logging +from landscape.client.snap_http import SnapdResponse from landscape.client.tests.helpers import LandscapeTest from landscape.lib.fs import create_text_file from landscape.lib.fs import read_text_file @@ -273,6 +274,83 @@ def test_juju_filename(self): self.config.juju_filename, ) + # auto configuration + + @mock.patch("landscape.client.configuration.generate_computer_title") + @mock.patch("landscape.client.deployment.snap_http") + def test_auto_configuration(self, mock_snap_http, mock_generate_title): + """Automatically configures the client.""" + mock_snap_http.get_conf.return_value = SnapdResponse( + "sync", + 200, + "OK", + {"auto-register": {"enabled": True, "configured": False}}, + ) + mock_generate_title.return_value = "ubuntu-123" + + self.assertIsNone(self.config.get("computer_title")) + + self.config.auto_configure() + self.assertEqual(self.config.get("computer_title"), "ubuntu-123") + mock_snap_http.set_conf.assert_called_once_with( + "landscape-client", + {"auto-register": {"enabled": True, "configured": True}}, + ) + + @mock.patch("landscape.client.deployment.snap_http") + def test_auto_configuration_not_enabled(self, mock_snap_http): + """The client is not configured.""" + mock_snap_http.get_conf.return_value = SnapdResponse( + "sync", + 200, + "OK", + {"auto-register": {"enabled": False, "configured": False}}, + ) + + self.assertIsNone(self.config.get("computer_title")) + + self.config.auto_configure() + self.assertIsNone(self.config.get("computer_title")) + mock_snap_http.set_conf.assert_not_called() + + @mock.patch("landscape.client.deployment.snap_http") + def test_auto_configuration_already_configured(self, mock_snap_http): + """The client is not re-configured.""" + mock_snap_http.get_conf.return_value = SnapdResponse( + "sync", + 200, + "OK", + {"auto-register": {"enabled": True, "configured": True}}, + ) + + self.config.computer_title = "foo-bar" + + self.config.auto_configure() + self.assertEqual(self.config.get("computer_title"), "foo-bar") + mock_snap_http.set_conf.assert_not_called() + + @mock.patch("landscape.client.configuration.generate_computer_title") + @mock.patch("landscape.client.deployment.snap_http") + def test_auto_configuration_no_title_generated( + self, + mock_snap_http, + mock_generate_title, + ): + """The client is not configured.""" + mock_snap_http.get_conf.return_value = SnapdResponse( + "sync", + 200, + "OK", + {"auto-register": {"enabled": True, "configured": False}}, + ) + mock_generate_title.return_value = None + + self.assertIsNone(self.config.get("computer_title")) + + self.config.auto_configure() + self.assertIsNone(self.config.get("computer_title")) + mock_snap_http.set_conf.assert_not_called() + class GetVersionedPersistTest(LandscapeTest): def test_upgrade_service(self): diff --git a/landscape/client/tests/test_watchdog.py b/landscape/client/tests/test_watchdog.py index 58b18b882..4044fa9da 100644 --- a/landscape/client/tests/test_watchdog.py +++ b/landscape/client/tests/test_watchdog.py @@ -1509,3 +1509,23 @@ def test_clean_environment(self): self.assertNotIn("LANDSCAPE_ATTACHMENTS", os.environ) self.assertNotIn("MAIL", os.environ) self.assertEqual(os.environ["UNRELATED"], "unrelated") + + @mock.patch.object(WatchDogConfiguration, "auto_configure") + @mock.patch("landscape.client.watchdog.IS_SNAP", "1") + def test_is_snap(self, mock_auto_configure): + """Should call `WatchDogConfiguration.auto_configure`.""" + reactor = FakeReactor() + self.fake_pwd.addUser( + "landscape", + None, + os.getuid(), + None, + None, + None, + None, + ) + with mock.patch("landscape.client.watchdog.pwd", new=self.fake_pwd): + run(["--log-dir", self.makeDir()], reactor=reactor) + + mock_auto_configure.assert_called_once_with() + self.assertTrue(reactor.running) diff --git a/landscape/client/watchdog.py b/landscape/client/watchdog.py index 4938ccf42..2b5b469ed 100644 --- a/landscape/client/watchdog.py +++ b/landscape/client/watchdog.py @@ -25,6 +25,7 @@ from twisted.internet.error import ProcessExitedAlready from twisted.internet.protocol import ProcessProtocol +from landscape.client import IS_SNAP from landscape.client import USER from landscape.client.broker.amp import RemoteBrokerConnector from landscape.client.broker.amp import RemoteManagerConnector @@ -719,6 +720,9 @@ def run(args=sys.argv, reactor=None): init_logging(config, "watchdog") + if IS_SNAP: + config.auto_configure() + application = Application("landscape-client") watchdog_service = WatchDogService(config) watchdog_service.setServiceParent(application) diff --git a/landscape/lib/format.py b/landscape/lib/format.py index d5d48d0fc..860e0f36a 100644 --- a/landscape/lib/format.py +++ b/landscape/lib/format.py @@ -1,4 +1,5 @@ import inspect +import re def format_object(object): @@ -29,3 +30,36 @@ def format_percent(percent): if not percent: percent = 0.0 return f"{float(percent):.02f}%" + + +def expandvars(pattern: str, **kwargs) -> str: + """Expand the pattern by replacing the params with values in `kwargs`. + + This implements a small subset of shell parameter expansion and the + patterns can only be in the following forms: + - ${parameter} + - ${parameter:offset} - start at `offset` to the end + - ${parameter:offset:length} - start at `offset` to `offset + length` + For simplicity, `offset` and `length` MUST be positive values. + """ + regex = re.compile( + r"\$\{([a-zA-Z][a-zA-Z0-9]*)(?::([0-9]+))?(?::([0-9]+))?\}", + re.MULTILINE, + ) + values = {k: str(v) for k, v in kwargs.items()} + + def _replace(match): + param = match.group(1) + result = values[param.lower()] + + offset, length = match.group(2), match.group(3) + if offset: + start = int(offset) + end = None + if length: + end = start + int(length) + return result[start:end] + + return result + + return re.sub(regex, _replace, pattern) diff --git a/landscape/lib/tests/test_format.py b/landscape/lib/tests/test_format.py index 1f690c9a2..2ecafcde6 100644 --- a/landscape/lib/tests/test_format.py +++ b/landscape/lib/tests/test_format.py @@ -1,5 +1,6 @@ import unittest +from landscape.lib.format import expandvars from landscape.lib.format import format_delta from landscape.lib.format import format_object from landscape.lib.format import format_percent @@ -63,3 +64,95 @@ def test_format_int(self): def test_format_none(self): self.assertEqual(format_percent(None), "0.00%") + + +class ExpandVarsTest(unittest.TestCase): + def test_expand_without_offset_and_length(self): + self.assertEqual( + expandvars("${serial}", serial="f315cab5"), + "f315cab5", + ) + self.assertEqual( + expandvars("before:${Serial}", serial="f315cab5"), + "before:f315cab5", + ) + self.assertEqual( + expandvars("${serial}:after", serial="f315cab5"), + "f315cab5:after", + ) + self.assertEqual( + expandvars("be$fore:${serial}:after", serial="f315cab5"), + "be$fore:f315cab5:after", + ) + + def test_expand_with_offset(self): + self.assertEqual( + expandvars("${serial:7}", serial="01234567890abcdefgh"), + "7890abcdefgh", + ) + self.assertEqual( + expandvars("before:${SERIAL:7}", serial="01234567890abcdefgh"), + "before:7890abcdefgh", + ) + self.assertEqual( + expandvars("${serial:7}:after", serial="01234567890abcdefgh"), + "7890abcdefgh:after", + ) + self.assertEqual( + expandvars( + "be$fore:${serial:7}:after", + serial="01234567890abcdefgh", + ), + "be$fore:7890abcdefgh:after", + ) + + def test_expand_with_offset_and_length(self): + self.assertEqual( + expandvars("${serial:7:0}", serial="01234567890abcdefgh"), + "", + ) + self.assertEqual( + expandvars("before:${serial:7:2}", serial="01234567890abcdefgh"), + "before:78", + ) + self.assertEqual( + expandvars("${serial:7:2}:after", serial="01234567890abcdefgh"), + "78:after", + ) + self.assertEqual( + expandvars( + "be$fore:${serial:7:2}:after", + serial="01234567890abcdefgh", + ), + "be$fore:78:after", + ) + + def test_expand_multiple(self): + self.assertEqual( + expandvars( + "${model:8:7}-${serial:0:8}", + model="generic-classic", + serial="f315cab5-ba74-4d3c-be85-713406455773", + ), + "classic-f315cab5", + ) + + def test_expand_offset_longer_than_substitute(self): + self.assertEqual( + expandvars("${serial:50}", serial="01234567890abcdefgh"), + "", + ) + + def test_expand_length_longer_than_substitute(self): + self.assertEqual( + expandvars("${serial:1:100}", serial="01234567890abcdefgh"), + "1234567890abcdefgh", + ) + + def test_expand_with_unknown_param(self): + with self.assertRaises(KeyError): + expandvars("${serial}-${unknown}", serial="01234567890abcdefgh") + + def test_expand_with_non_string_substitutes(self): + self.assertEqual(expandvars("${foo}", foo=42), "42") + self.assertEqual(expandvars("${foo}.bar", foo=42), "42.bar") diff --git a/snap/hooks/install b/snap/hooks/install new file mode 100644 index 000000000..2da21b9e9 --- /dev/null +++ b/snap/hooks/install @@ -0,0 +1,5 @@ +#!/bin/sh -e + +if [ "$( snapctl model | awk '/^classic:/ { print $2 }' )" != "true" ] +then snapctl start --enable "$SNAP_INSTANCE_NAME.landscape-client" +fi