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/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') 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()