From 17c1c132e5ae47b5333e5564fb8100b938c675a5 Mon Sep 17 00:00:00 2001 From: Josh Poole Date: Fri, 25 Sep 2026 07:07:53 +0100 Subject: [PATCH] 20260925 - Report the owner's support email in Mender inventory Adds mender-inventory-retina-contact, which reports contact_email from /data/retina-gui/telemetry-contact.json: the support address the owner gave in the setup wizard's contact step or under Configuration > How we reach you. The node's IP address needed no change, since the stock network script already reports ipv4_ on every enrolled board. This is the contact address, not the claim address in telemetry-claim.json. retina-gui keeps the two apart because the claim address decides who owns the node, and this keeps them apart too. An owner who gave no email reports nothing. retina-gui removes the file once every contact box is empty, and a file without an email yields no attribute, so consumers must treat a missing contact_email as "none given". retina-gui checks only the length of this field, not its shape, so an owner can save a value with a newline in it. Echoed as-is, that would let their text add inventory attributes of its own, such as remote_access=true. The script reports only a single local@domain token with no whitespace or control characters, and drops anything else. A missing, truncated or non-object file also reports nothing, and the script always exits 0 so it cannot hold up the rest of the inventory. It parses with jq, which the mender role already installs. Tests run the real script under /bin/sh and pass against jq 1.6 (bookworm, as on the nodes) and jq 1.7.1. CI also checks that the script is executable. Reaches a node only with the next owl-os release, and only while its cloud services are enabled. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/test.yml | 1 + .../inventory/mender-inventory-retina-contact | 24 ++++++ tests/contact-email/test_contact_email.py | 77 +++++++++++++++++++ 3 files changed, 102 insertions(+) create mode 100755 configuration/mender/inventory/mender-inventory-retina-contact create mode 100644 tests/contact-email/test_contact_email.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e3de1c..a57d12d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,6 @@ jobs: test -x plugins/playbooks/board_support/roles/mender/files/update-modules/docker-compose test -x plugins/playbooks/os_setup/roles/radar_data_bootstrap/files/retina-env-guard test -x configuration/mender/inventory/mender-inventory-retina-stack + test -x configuration/mender/inventory/mender-inventory-retina-contact - name: Tests run: pytest tests/ diff --git a/configuration/mender/inventory/mender-inventory-retina-contact b/configuration/mender/inventory/mender-inventory-retina-contact new file mode 100755 index 0000000..81081f5 --- /dev/null +++ b/configuration/mender/inventory/mender-inventory-retina-contact @@ -0,0 +1,24 @@ +#!/bin/sh + +# Reports the support email this device's owner entered, if they entered one. +# +# This is the contact address from the setup wizard's contact step and +# Configuration > How we reach you: whom to get in touch with about this node. +# It is not the claim address in telemetry-claim.json, which decides who owns +# the node. retina-gui keeps the two apart on purpose, and so does this. +# +# retina-gui removes the file once every contact box is empty, so a node whose +# owner gave nothing reports nothing rather than an empty value. +# +# retina-gui checks only the length of this field, not its shape, and a newline +# in it would let the owner's text add inventory attributes of its own. Anything +# that is not a single local@domain token is dropped rather than reported. + +CONTACT_FILE="${RETINA_CONTACT_FILE:-/data/retina-gui/telemetry-contact.json}" + +[ -f "${CONTACT_FILE}" ] || exit 0 + +jq -r '.email | strings + | select(test("^[^@\\s[:cntrl:]]+@[^@\\s[:cntrl:]]+$")) + | "contact_email=" + .' "${CONTACT_FILE}" 2>/dev/null +exit 0 diff --git a/tests/contact-email/test_contact_email.py b/tests/contact-email/test_contact_email.py new file mode 100644 index 0000000..63e75fd --- /dev/null +++ b/tests/contact-email/test_contact_email.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" +Tests for the mender-inventory-retina-contact inventory script. + +It runs as root on every node every 600 s and puts text the owner typed into +Mender inventory, so each test runs the real script under /bin/sh against a +contact file written the way retina-gui writes it, or deliberately not. +""" + +import json +import os +import shutil +import subprocess +import tempfile +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +INVENTORY = os.path.join(REPO, 'configuration', 'mender', 'inventory', 'mender-inventory-retina-contact') + + +class TestContactInventory(unittest.TestCase): + + def setUp(self): + self.dir = tempfile.mkdtemp() + self.contact_file = os.path.join(self.dir, 'telemetry-contact.json') + + def tearDown(self): + shutil.rmtree(self.dir) + + def write(self, text): + with open(self.contact_file, 'w') as f: + f.write(text) + + def run_script(self): + env = dict(os.environ, RETINA_CONTACT_FILE=self.contact_file) + result = subprocess.run(['/bin/sh', INVENTORY], env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stderr, '') + return result.stdout + + def test_an_entered_email_is_reported(self): + self.write(json.dumps({'email': 'ann@example.com', 'first_name': 'Ann'})) + self.assertEqual(self.run_script(), 'contact_email=ann@example.com\n') + + def test_a_non_ascii_email_is_reported(self): + self.write(json.dumps({'email': 'josé@exämple.de'}, ensure_ascii=False)) + self.assertEqual(self.run_script(), 'contact_email=josé@exämple.de\n') + + def test_no_file_reports_nothing(self): + # retina-gui removes the file once every contact box is empty. + self.assertEqual(self.run_script(), '') + + def test_contact_details_without_an_email_report_nothing(self): + self.write(json.dumps({'first_name': 'Ann', 'phone': '+44 20 7946 0000'})) + self.assertEqual(self.run_script(), '') + + def test_a_newline_cannot_add_attributes(self): + # retina-gui checks only the length, so this can be saved. + self.write(json.dumps({'email': 'ann@example.com\nremote_access=true'})) + self.assertEqual(self.run_script(), '') + + def test_values_that_are_not_one_address_are_dropped(self): + for email in ('ann smith@example.com', 'a@b@example.com', 'ann@example.com\u0007', + 'example.com', '@example.com', 'ann@', 42, None): + with self.subTest(email=email): + self.write(json.dumps({'email': email})) + self.assertEqual(self.run_script(), '') + + def test_an_unreadable_file_reports_nothing(self): + for text in ('{"email": "ann@exa', '["ann@example.com"]', ''): + with self.subTest(text=text): + self.write(text) + self.assertEqual(self.run_script(), '') + + +if __name__ == '__main__': + unittest.main()