Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/linuxmuster_base7/cli/import_devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 = []

Expand All @@ -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)
Expand Down
8 changes: 5 additions & 3 deletions src/linuxmuster_base7/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
41 changes: 40 additions & 1 deletion src/linuxmuster_base7/functions/linbo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '<school>+<group>'. 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:
Expand Down
77 changes: 77 additions & 0 deletions tests/test_functions_linbo.py
Original file line number Diff line number Diff line change
@@ -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 '<school>+<group>', 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')
149 changes: 149 additions & 0 deletions tests/test_import_devices.py
Original file line number Diff line number Diff line change
@@ -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 '<school>+<group>' in the DHCP
extensions-path/nis-domain options, the start.conf/grub cfg link sources and
the start.conf.<id>/<id>.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()