From ae405d22a66998960e41210c4642b3b1b2dccc1f Mon Sep 17 00:00:00 2001 From: Tom Lehmann Date: Thu, 1 Oct 2026 12:11:55 +0200 Subject: [PATCH 1/2] feat(functions/linbo): add getLinboGroupId()/splitLinboGroupId() On a multi-school server every LINBO hardware group is server-wide: two schools with a group raum101 share one start.conf.raum101. As agreed in linuxmuster-linbo7#178, every school other than default-school gets the school-qualified group id + for the LINBO artefacts derived from the group. default-school keeps the plain group, so single-school installations see no change. getLinboGroupId(school, group) builds the id, splitLinboGroupId() reverses it: no '+' means default-school, otherwise split on the first '+'. Neither part can contain '+' (sophomorix restricts group names to [A-Za-z0-9_-], the school is part of every hostname that isValidHostname() checks), so the id round-trips without the school list. Both are re-exported from linuxmuster_base7.functions so that linbo_mkgrubimg.py (linbo7) and linuxmuster-tools can reuse the naming rule instead of duplicating it. Added tests/test_functions_linbo.py. Refs #206 Signed-off-by: Tom Lehmann Assisted-by: Claude --- src/linuxmuster_base7/functions/__init__.py | 8 ++- src/linuxmuster_base7/functions/linbo.py | 41 ++++++++++- tests/test_functions_linbo.py | 77 +++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/test_functions_linbo.py diff --git a/src/linuxmuster_base7/functions/__init__.py b/src/linuxmuster_base7/functions/__init__.py index 766165d..71e8859 100644 --- a/src/linuxmuster_base7/functions/__init__.py +++ b/src/linuxmuster_base7/functions/__init__.py @@ -8,7 +8,7 @@ # core, files, network, samba, linbo, certs, remote, security. # Signed-off by: thomas@linuxmuster.net # Assisted by : Claude -# Date : 20260915 +# Date : 20261001 # import datetime # re-exported: some callers do "from ...functions import datetime" @@ -25,7 +25,8 @@ from .samba import getBaseDN, adSearch, isDynamicIpDevice, sambaTool from .linbo import getGrubPart, getGrubOstype, readStartconf, \ getStartconfOption, getStartconfPartlabel, getStartconfPartnr, \ - setGlobalStartconfOption, getStartconfOsValues, getLinboVersion + setGlobalStartconfOption, getStartconfOsValues, getLinboGroupId, \ + splitLinboGroupId, getLinboVersion from .certs import encodeCertToBase64, renewCaCertificate, \ signCertificateWithCa, createCertificateChain, createCnfFromTemplate, \ createServerCert, buildCaSubjectAndSan, writeCaCertificate @@ -53,7 +54,8 @@ # linbo 'getGrubPart', 'getGrubOstype', 'readStartconf', 'getStartconfOption', 'getStartconfPartlabel', 'getStartconfPartnr', 'setGlobalStartconfOption', - 'getStartconfOsValues', 'getLinboVersion', + 'getStartconfOsValues', 'getLinboGroupId', 'splitLinboGroupId', + 'getLinboVersion', # certs 'encodeCertToBase64', 'renewCaCertificate', 'signCertificateWithCa', 'createCertificateChain', 'createCnfFromTemplate', 'createServerCert', diff --git a/src/linuxmuster_base7/functions/linbo.py b/src/linuxmuster_base7/functions/linbo.py index 1aeb20b..da838fa 100644 --- a/src/linuxmuster_base7/functions/linbo.py +++ b/src/linuxmuster_base7/functions/linbo.py @@ -4,7 +4,7 @@ # Description : LINBO start.conf and GRUB boot configuration helpers # Signed-off by: thomas@linuxmuster.net # Assisted by : Claude -# Date : 20260818 +# Date : 20261001 # import configparser @@ -210,6 +210,45 @@ def getStartconfOsValues(startconf): return None +# return school-qualified linbo group id (see issue #206) +def getLinboGroupId(school, group): + """Return the school-qualified LINBO group id of a device group. + + default-school keeps the plain group, so single-school installations keep + their file names. Every other school gets '+'. Neither + part can contain '+' (sophomorix restricts group names to [A-Za-z0-9_-], + and the school is part of every device hostname, which isValidHostname() + checks), so the id can be split again with splitLinboGroupId(). + + Args: + school: School name, e.g. 'default-school' or 'abc' + group: Device group from devices.csv, e.g. 'raum101' + + Returns: + 'raum101' for default-school, 'abc+raum101' otherwise + """ + if school == 'default-school': + return group + return school + '+' + group + + +# return school and group of a linbo group id +def splitLinboGroupId(group_id): + """Split a LINBO group id into school and group, see getLinboGroupId(). + + Args: + group_id: LINBO group id, e.g. 'raum101' or 'abc+raum101' + + Returns: + Tuple (school, group): ('default-school', 'raum101') for an id + without '+', otherwise split on the first '+', e.g. ('abc', 'raum101') + """ + school, separator, group = group_id.partition('+') + if not separator: + return 'default-school', group_id + return school, group + + def getLinboVersion(): rc, content = readTextfile(environment.LINBOVERFILE) if not rc: diff --git a/tests/test_functions_linbo.py b/tests/test_functions_linbo.py new file mode 100644 index 0000000..a1bb296 --- /dev/null +++ b/tests/test_functions_linbo.py @@ -0,0 +1,77 @@ +#!/usr/bin/python3 +# +# Filename : test_functions_linbo.py +# Description : Tests for the LINBO group id helper pair +# getLinboGroupId()/splitLinboGroupId(). +# Signed-off by: tom.lehmann@netzint.de +# Assisted by : Claude +# Date : 20260928 +# +""" +Tests for linuxmuster_base7.functions.linbo.getLinboGroupId() and +splitLinboGroupId(). + +Background: on a multi-school server every school other than default-school +gets school-qualified LINBO group ids '+', so two schools can +use the same group name without sharing one start.conf (see issue #206 and +linuxmuster-linbo7#178). default-school keeps the plain group, so +single-school installations see no change. The helpers must round-trip, and +group names may contain '-' and '_' (sophomorix allows [A-Za-z0-9_-]). +""" + +import pytest + +pytest.importorskip('environment', + reason='requires linuxmuster-common (environment.py) on sys.path') + +from linuxmuster_base7.functions import getLinboGroupId, splitLinboGroupId # noqa: E402 + + +@pytest.mark.parametrize('school, group, expected', [ + # default-school keeps the plain group + ('default-school', 'raum101', 'raum101'), + ('default-school', 'win11-efi_2', 'win11-efi_2'), + # every other school is qualified with '+' + ('abc', 'raum101', 'abc+raum101'), + ('abc', 'win11-efi_2', 'abc+win11-efi_2'), + # school names may contain '-' as well + ('gs-nord', 'raum101', 'gs-nord+raum101'), + ('a', 'b-x', 'a+b-x'), + ('a-b', 'x', 'a-b+x'), +]) +def test_get_linbo_group_id(school, group, expected): + assert getLinboGroupId(school, group) == expected + + +@pytest.mark.parametrize('group_id, expected', [ + # no '+' means default-school + ('raum101', ('default-school', 'raum101')), + ('win11-efi_2', ('default-school', 'win11-efi_2')), + # otherwise split on the first '+' + ('abc+raum101', ('abc', 'raum101')), + ('gs-nord+win11-efi_2', ('gs-nord', 'win11-efi_2')), + ('a+b+c', ('a', 'b+c')), +]) +def test_split_linbo_group_id(group_id, expected): + assert splitLinboGroupId(group_id) == expected + + +@pytest.mark.parametrize('school, group', [ + ('default-school', 'raum101'), + ('default-school', 'win11-efi_2'), + ('abc', 'raum101'), + ('gs-nord', 'win11-efi_2'), + # '-' in either part must not make the ids collide ('a-b-x' with '-') + ('a', 'b-x'), + ('a-b', 'x'), +]) +def test_round_trip(school, group): + assert splitLinboGroupId(getLinboGroupId(school, group)) == (school, group) + + +def test_no_collision_between_schools(): + # school 'a' + group 'b-x' and school 'a-b' + group 'x' would both be + # 'a-b-x' with '-' as separator + assert getLinboGroupId('a', 'b-x') != getLinboGroupId('a-b', 'x') + # a default-school group can never look like a qualified id + assert getLinboGroupId('default-school', 'abc-raum101') != getLinboGroupId('abc', 'raum101') From 4b962a3751fc25117dabb8db31c6605ecabbb1c3 Mon Sep 17 00:00:00 2001 From: Tom Lehmann Date: Thu, 1 Oct 2026 12:11:55 +0200 Subject: [PATCH 2/2] feat(import_devices): use school-qualified LINBO group ids Use getLinboGroupId() wherever import-devices derives a LINBO name from the device group: - processDevicesForSubnet(): the group passed to buildDhcpHostDeclaration() for option extensions-path/nis-domain - doPxeGroupsBySchool(): the start.conf. and ../.cfg link sources and the returned pxe_groups, which carry the id on to doLinboStartconf() (start.conf.) and doGrubCfg() (.cfg) The id is built right after unpacking each device row, where the school is already known, so no function signature changes. default-school output (DHCP config, link csv, file names) stays byte for byte the same. The group is deliberately not qualified in validateDeviceRow()/ getDevicesArray(), where the hostname gets its school prefix: linbo_remote_lib.py (linbo7) compares that group column to the plain group given to linbo-remote -g -s . Freshly created unconfigured start.conf copies keep their Group = line; rewriting it belongs to the migration. Added tests/test_import_devices.py. The default-school cases also pass against the previous import_devices.py. Refs #206 Signed-off-by: Tom Lehmann Assisted-by: Claude --- src/linuxmuster_base7/cli/import_devices.py | 17 ++- tests/test_import_devices.py | 149 ++++++++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 tests/test_import_devices.py diff --git a/src/linuxmuster_base7/cli/import_devices.py b/src/linuxmuster_base7/cli/import_devices.py index fb383e0..aa2c4c2 100755 --- a/src/linuxmuster_base7/cli/import_devices.py +++ b/src/linuxmuster_base7/cli/import_devices.py @@ -4,7 +4,7 @@ # Description : Import devices from devices.csv into DHCP, DNS and LINBO/GRUB config # Signed-off by: thomas@linuxmuster.net # Assisted by : Claude -# Date : 20260818 +# Date : 20261001 # import configparser @@ -25,7 +25,7 @@ from linuxmuster_base7.functions import getDevicesArray, getGrubOstype, getGrubPart, getStartconfOsValues, \ getStartconfOption, getStartconfPartnr, getStartconfPartlabel, getSubnetArray, \ - getLinboVersion, printScript, readTextfile, writeTextfile + getLinboGroupId, getLinboVersion, printScript, readTextfile, writeTextfile # Setup logging logfile = environment.LOGDIR + '/import-devices.log' @@ -211,7 +211,7 @@ def doGrubCfg(startconf, group, kopts): Args: startconf: Path to start.conf file - group: Device group name + group: LINBO group id (see getLinboGroupId()) kopts: Kernel options Returns: @@ -258,7 +258,7 @@ def doLinboStartconf(group): checks configuration status, and generates corresponding GRUB boot configuration. Args: - group: Device group name + group: LINBO group id (see getLinboGroupId()) """ startconf = environment.LINBODIR + '/start.conf.' + group # provide unconfigured start.conf if there is none for this group @@ -287,7 +287,7 @@ def buildDhcpHostDeclaration(hostname, group, mac, ip, dhcpopts, pxeflag): Args: hostname: Device hostname - group: Device group name + group: LINBO group id (see getLinboGroupId()) mac: MAC address ip: IP address or 'DHCP' dhcpopts: DHCP options string (comma-separated) @@ -359,6 +359,8 @@ def processDevicesForSubnet(outfile, subnet, school): # Unpack device fields (see DEVICE_FIELDS_DHCP constant for field mapping) hostname, group, mac, ip, dhcpopts, computertype, pxeflag = device_array + # school-qualified group id for the linbo dhcp options + group = getLinboGroupId(school, group) # Truncate long computer type names for clean output if len(computertype) > 15: computertype = computertype[0:15] @@ -432,7 +434,8 @@ def doPxeGroupsBySchool(school='default-school'): school: School name (default: 'default-school') Returns: - List of unique PXE-enabled groups for this school + List of unique LINBO group ids (see getLinboGroupId()) of the + PXE-enabled devices of this school """ pxe_groups = [] @@ -449,6 +452,8 @@ def doPxeGroupsBySchool(school='default-school'): for device_array in getDevicesArray(fieldnrs=DEVICE_FIELDS_LINKS, subnet='all', pxeflag='1,2,3', school=school): host, group, mac, ip, pxeflag = device_array + # school-qualified group id for all linbo file names below + group = getLinboGroupId(school, group) # collect groups with pxe for later use if group not in pxe_groups: pxe_groups.append(group) diff --git a/tests/test_import_devices.py b/tests/test_import_devices.py new file mode 100644 index 0000000..92f7955 --- /dev/null +++ b/tests/test_import_devices.py @@ -0,0 +1,149 @@ +#!/usr/bin/python3 +# +# Filename : test_import_devices.py +# Description : Tests for the LINBO group ids that import_devices.py writes +# into the DHCP config, the link csv and the start.conf/grub +# cfg file names. +# Signed-off by: tom.lehmann@netzint.de +# Assisted by : Claude +# Date : 20260928 +# +""" +Tests for the use of getLinboGroupId() in linuxmuster_base7.cli.import_devices. + +default-school output must stay byte-for-byte identical (plain group names), +every other school gets the qualified id '+' in the DHCP +extensions-path/nis-domain options, the start.conf/grub cfg link sources and +the start.conf./.cfg files (see issue #206). getDevicesArray() is +stubbed with the rows it would return, and the LINBO directories of the +environment module are pointed to tmp_path. +""" + +import io + +import pytest + +environment = pytest.importorskip('environment', + reason='requires linuxmuster-common (environment.py) on sys.path') + +from linuxmuster_base7.cli import import_devices # noqa: E402 + +# rows as getDevicesArray() returns them: the hostname already carries the +# school prefix for non-default schools, the group does not +DHCP_ROWS = { + 'default-school': [['pc01', 'raum101', '00:11:22:33:44:55', '10.0.0.1', '', 'pc', '1']], + 'abc': [['abc-pc01', 'raum101', '00:11:22:33:44:56', '10.0.0.2', '', 'pc', '1']], +} +LINK_ROWS = { + 'default-school': [ + ['pc01', 'raum101', '00:11:22:33:44:55', '10.0.0.1', '1'], + ['pc02', 'raum101', '00:11:22:33:44:57', 'DHCP', '1'], + ], + 'abc': [ + ['abc-pc01', 'raum101', '00:11:22:33:44:56', '10.0.0.2', '1'], + ['abc-pc02', 'win11-efi_2', 'AA:11:22:33:44:58', 'DHCP', '1'], + ], +} + + +def stubDevicesArray(monkeypatch, rows): + def getDevicesArray(fieldnrs='', subnet='', pxeflag='', school='default-school'): + return rows[school] + monkeypatch.setattr(import_devices, 'getDevicesArray', getDevicesArray) + + +@pytest.fixture +def linbodir(tmp_path, monkeypatch): + linbo_dir = tmp_path / 'srv' / 'linbo' + grub_dir = linbo_dir / 'boot' / 'grub' + tpl_dir = tmp_path / 'templates' + (grub_dir / 'hostcfg').mkdir(parents=True) + tpl_dir.mkdir() + # unconfigured default start.conf (no Cache) and forced netboot template + (linbo_dir / 'start.conf').write_text('[LINBO]\nServer = 10.0.0.1\nGroup = \n') + (tpl_dir / 'grub.cfg.forced_netboot').write_text('# forced netboot\n') + monkeypatch.setattr(environment, 'LINBODIR', str(linbo_dir)) + monkeypatch.setattr(environment, 'LINBOGRUBDIR', str(grub_dir)) + monkeypatch.setattr(environment, 'LINBOTPLDIR', str(tpl_dir)) + return linbo_dir + + +def test_dhcp_default_school_unchanged(monkeypatch): + stubDevicesArray(monkeypatch, DHCP_ROWS) + outfile = io.StringIO() + import_devices.processDevicesForSubnet(outfile, '10.0.0.0/24', 'default-school') + assert outfile.getvalue() == ( + '# subnet 10.0.0.0/24\n' + 'host pc01 {\n' + ' option host-name "pc01";\n' + ' hardware ethernet 00:11:22:33:44:55;\n' + ' fixed-address 10.0.0.1;\n' + ' option extensions-path "raum101";\n' + ' option nis-domain "raum101";\n' + '}\n' + ) + + +def test_dhcp_other_school_qualified(monkeypatch): + stubDevicesArray(monkeypatch, DHCP_ROWS) + outfile = io.StringIO() + import_devices.processDevicesForSubnet(outfile, '10.0.0.0/24', 'abc') + assert outfile.getvalue() == ( + '# subnet 10.0.0.0/24\n' + 'host abc-pc01 {\n' + ' option host-name "abc-pc01";\n' + ' hardware ethernet 00:11:22:33:44:56;\n' + ' fixed-address 10.0.0.2;\n' + ' option extensions-path "abc+raum101";\n' + ' option nis-domain "abc+raum101";\n' + '}\n' + ) + + +def test_links_default_school_unchanged(monkeypatch, linbodir): + stubDevicesArray(monkeypatch, LINK_ROWS) + pxe_groups = import_devices.doPxeGroupsBySchool(school='default-school') + assert pxe_groups == ['raum101'] + links = (linbodir / 'boot' / 'links' / 'default-school.csv').read_bytes().decode() + grub_dir = environment.LINBOGRUBDIR + assert links == ( + f'start.conf.raum101;{linbodir}/start.conf-10.0.0.1\r\n' + f'../raum101.cfg;{grub_dir}/hostcfg/pc01.cfg\r\n' + f'start.conf.raum101;{linbodir}/start.conf-00:11:22:33:44:57\r\n' + f'../raum101.cfg;{grub_dir}/hostcfg/pc02.cfg\r\n' + ) + + +def test_links_other_school_qualified(monkeypatch, linbodir): + stubDevicesArray(monkeypatch, LINK_ROWS) + pxe_groups = import_devices.doPxeGroupsBySchool(school='abc') + assert pxe_groups == ['abc+raum101', 'abc+win11-efi_2'] + links = (linbodir / 'boot' / 'links' / 'abc.csv').read_bytes().decode() + grub_dir = environment.LINBOGRUBDIR + assert links == ( + f'start.conf.abc+raum101;{linbodir}/start.conf-10.0.0.2\r\n' + f'../abc+raum101.cfg;{grub_dir}/hostcfg/abc-pc01.cfg\r\n' + f'start.conf.abc+win11-efi_2;{linbodir}/start.conf-aa:11:22:33:44:58\r\n' + f'../abc+win11-efi_2.cfg;{grub_dir}/hostcfg/abc-pc02.cfg\r\n' + ) + + +@pytest.mark.parametrize('school, group_id, host', [ + ('default-school', 'raum101', 'pc01'), + ('abc', 'abc+raum101', 'abc-pc01'), +]) +def test_startconf_grubcfg_and_links(monkeypatch, linbodir, school, group_id, host): + stubDevicesArray(monkeypatch, LINK_ROWS) + versionfile = linbodir / 'linbo-version' + versionfile.write_text('LINBO 4.3.12: Codename\n') + monkeypatch.setattr(environment, 'LINBOVERFILE', str(versionfile)) + import_devices.generateGrubConfigsForGroups(school) + grub_dir = linbodir / 'boot' / 'grub' + # doLinboStartconf()/doGrubCfg() create the files under the group id ... + assert (linbodir / ('start.conf.' + group_id)).is_file() + assert (grub_dir / (group_id + '.cfg')).is_file() + # ... and doAllGroupLinks() points the host links at them + assert (linbodir / ('start.conf-' + LINK_ROWS[school][0][3])).resolve() == \ + (linbodir / ('start.conf.' + group_id)).resolve() + assert (grub_dir / 'hostcfg' / (host + '.cfg')).resolve() == \ + (grub_dir / (group_id + '.cfg')).resolve()