Skip to content

Commit eef21f1

Browse files
committed
config: warn instead of failing on unknown keys
A config written for a newer version of the project may contain keys this version does not support. Load it anyway, dropping the unknown keys with a warning instead of aborting, so the same config can be shared across project versions. Signed-off-by: Gaëtan Lehmann <gaetan.lehmann@vates.tech>
1 parent a73d5fa commit eef21f1

2 files changed

Lines changed: 88 additions & 40 deletions

File tree

lib/config_loader.py

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,53 @@
66
import tomllib
77
from pathlib import Path
88

9-
from pydantic import BaseModel, Field, field_validator
9+
from pydantic import BaseModel, Field, field_validator, model_validator
1010

1111
from lib.passwords import hash_password
1212
from lib.sizes import parse_size
1313
from lib.typing import ConfigDict, JSONType
1414

1515
from typing import cast, overload
1616

17+
logger = logging.getLogger(__name__)
18+
1719
class ConfigError(Exception):
1820
"""Raised when the TOML configuration cannot be loaded or validated."""
1921

2022

21-
class _StrictModel(BaseModel):
22-
"""Reject unknown keys at load time (extra="forbid")."""
23+
class WarnOnExtraModel(BaseModel):
24+
"""Drop unknown config keys, warning about them instead of failing (extra="ignore")."""
25+
model_config = {"extra": "ignore"}
2326

24-
model_config = {"extra": "forbid"}
27+
@model_validator(mode="before")
28+
@classmethod
29+
def _warn_unknown_fields(cls, data: object) -> object:
30+
if not isinstance(data, dict):
31+
return data
32+
if cls.model_config.get("extra") == "allow":
33+
return data
34+
expected = set(cls.model_fields) | {
35+
f.alias for f in cls.model_fields.values() if f.alias
36+
}
37+
unknown = set(data) - expected
38+
if unknown:
39+
logger.warning(
40+
"[%s] Unknown config key(s) ignored (config may be from a newer project version): %s",
41+
cls.__name__, ", ".join(sorted(unknown)),
42+
)
43+
return data
2544

2645

2746
REPO_ROOT = Path(__file__).resolve().parent.parent
2847

2948

30-
class HostConfig(_StrictModel):
49+
class HostConfig(WarnOnExtraModel):
3150
default_user: str
3251
default_password: str
3352
default_password_hash: str = ""
3453

3554

36-
class HostOverride(_StrictModel):
55+
class HostOverride(WarnOnExtraModel):
3756
user: str | None = None
3857
password: str | None = None
3958
skip_xo_config: bool | None = None
@@ -42,25 +61,25 @@ class HostOverride(_StrictModel):
4261
hosting_pool: str | None = None
4362

4463

45-
class NetworkConfig(_StrictModel):
64+
class NetworkConfig(WarnOnExtraModel):
4665
mgmt: str
4766
free_nics: list[str]
4867

4968

50-
class PXEConfig(_StrictModel):
69+
class PXEConfig(WarnOnExtraModel):
5170
config_server: str
5271
arp_server: str
5372

5473

55-
class VMConfig(_StrictModel):
74+
class VMConfig(WarnOnExtraModel):
5675
def_url: str
5776
cache_imported: bool
5877
default_sr: str
5978
images: dict[str, str]
6079
equivalents: dict[str, str]
6180

6281

63-
class IsoImageDef(_StrictModel):
82+
class IsoImageDef(WarnOnExtraModel):
6483
path: str
6584
net_url: str | None = Field(default=None, alias="net-url")
6685
net_only: bool | None = Field(default=None, alias="net-only")
@@ -69,26 +88,26 @@ class IsoImageDef(_StrictModel):
6988
model_config = {"populate_by_name": True}
7089

7190

72-
class InstallIsosConfig(_StrictModel):
91+
class InstallIsosConfig(WarnOnExtraModel):
7392
base_url: str
7493
cache_dir: str
7594
definitions: dict[str, IsoImageDef]
7695

7796

78-
class AnswerFileDef(_StrictModel):
97+
class AnswerFileDef(WarnOnExtraModel):
7998
model_config = {"extra": "allow"}
8099

81100
TAG: str
82101
CONTENTS: str | list[AnswerFileDef] | None = None
83102

84103

85-
class InstallConfig(_StrictModel):
104+
class InstallConfig(WarnOnExtraModel):
86105
answerfiles: dict[str, AnswerFileDef]
87106
isos: InstallIsosConfig
88107
iso_remaster: str = ""
89108

90109

91-
class WinGuestToolDef(_StrictModel):
110+
class WinGuestToolDef(WarnOnExtraModel):
92111
name: str
93112
download: bool
94113
package: str
@@ -97,12 +116,12 @@ class WinGuestToolDef(_StrictModel):
97116
onboard_family: str | None = None
98117

99118

100-
class OtherGuestToolDef(_StrictModel):
119+
class OtherGuestToolDef(WarnOnExtraModel):
101120
name: str
102121
download: bool
103122

104123

105-
class InstalledGuestToolDef(_StrictModel):
124+
class InstalledGuestToolDef(WarnOnExtraModel):
106125
type: str | None = None
107126
path: str | None = None
108127
package: str | None = None
@@ -112,74 +131,74 @@ class InstalledGuestToolDef(_StrictModel):
112131
onboarding_phase: str | None = None
113132

114133

115-
class GuestToolsConfig(_StrictModel):
134+
class GuestToolsConfig(WarnOnExtraModel):
116135
download_url: str
117136
win: dict[str, WinGuestToolDef]
118137
other: OtherGuestToolDef
119138
installed: dict[str, InstalledGuestToolDef]
120139

121140

122-
class XOConfig(_StrictModel):
141+
class XOConfig(WarnOnExtraModel):
123142
cli: str
124143

125144

126-
class SSHConfig(_StrictModel):
145+
class SSHConfig(WarnOnExtraModel):
127146
pubkey: str
128147
output_max_lines: int
129148
ignore_banner: bool
130149

131150

132-
class LinstorConfig(_StrictModel):
151+
class LinstorConfig(WarnOnExtraModel):
133152
redundancy: int
134153

135154

136-
class NFSConfig(_StrictModel):
155+
class NFSConfig(WarnOnExtraModel):
137156
server: str | None = None
138157
serverpath: str | None = None
139158

140159

141-
class NFS4Config(_StrictModel):
160+
class NFS4Config(WarnOnExtraModel):
142161
server: str | None = None
143162
serverpath: str | None = None
144163
nfsversion: str | None = None
145164

146165

147-
class NFSISOConfig(_StrictModel):
166+
class NFSISOConfig(WarnOnExtraModel):
148167
location: str | None = None
149168

150169

151-
class CIFSISOConfig(_StrictModel):
170+
class CIFSISOConfig(WarnOnExtraModel):
152171
location: str | None = None
153172
username: str | None = None
154173
cifspassword: str | None = None
155174
type: str | None = None
156175
vers: str | None = None
157176

158177

159-
class CephFSConfig(_StrictModel):
178+
class CephFSConfig(WarnOnExtraModel):
160179
server: str | None = None
161180
serverpath: str | None = None
162181
options: str | None = None
163182

164183

165-
class MooseFSConfig(_StrictModel):
184+
class MooseFSConfig(WarnOnExtraModel):
166185
masterhost: str | None = None
167186
masterport: str | None = None
168187
rootpath: str | None = None
169188

170189

171-
class LVMoHBAConfig(_StrictModel):
190+
class LVMoHBAConfig(WarnOnExtraModel):
172191
SCSIid: str | None = None
173192

174193

175-
class LVMoISCSIConfig(_StrictModel):
194+
class LVMoISCSIConfig(WarnOnExtraModel):
176195
target: str | None = None
177196
port: str | None = None
178197
targetIQN: str | None = None
179198
SCSIid: str | None = None
180199

181200

182-
class StorageConfig(_StrictModel):
201+
class StorageConfig(WarnOnExtraModel):
183202
nfs: NFSConfig
184203
nfs4: NFS4Config
185204
nfs_iso: NFSISOConfig
@@ -191,17 +210,17 @@ class StorageConfig(_StrictModel):
191210
linstor: LinstorConfig
192211

193212

194-
class UpdateDefaults(_StrictModel):
213+
class UpdateDefaults(WarnOnExtraModel):
195214
repositories: list[str] = Field(default_factory=list)
196215
disabled_repositories: list[str] = Field(default_factory=list)
197216
hosting_pool: str | None = None
198217

199218

200-
class ToolsConfig(_StrictModel):
219+
class ToolsConfig(WarnOnExtraModel):
201220
update: UpdateDefaults = Field(default_factory=UpdateDefaults)
202221

203222

204-
class Config(_StrictModel):
223+
class Config(WarnOnExtraModel):
205224
objects_name_prefix: str | None
206225
dns_server: str
207226
host: HostConfig

tests/unit/test_config_loader.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44

5+
import logging
56
from pathlib import Path
67

78
from passlib.hash import sha512_crypt
@@ -24,6 +25,14 @@ def _full_config_dict() -> dict[str, Any]:
2425
return load_config().model_dump()
2526

2627

28+
def _config_warnings(caplog: pytest.LogCaptureFixture) -> list[str]:
29+
return [
30+
record.getMessage()
31+
for record in caplog.records
32+
if record.name == "lib.config_loader" and record.levelno >= logging.WARNING
33+
]
34+
35+
2736
def test_base_config_loads() -> None:
2837
cfg = load_config()
2938
assert cfg.host.default_user == "root"
@@ -42,34 +51,54 @@ def test_default_password_hash_matches_password() -> None:
4251
assert sha512_crypt.verify(cfg.host.default_password, cfg.host.default_password_hash)
4352

4453

45-
def test_unknown_section_rejected() -> None:
54+
def test_unknown_section_warned(caplog: pytest.LogCaptureFixture) -> None:
4655
data = _full_config_dict()
4756
data["not_a_section"] = 1
48-
with pytest.raises(ConfigError):
57+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
4958
_build_config(data)
59+
assert any("not_a_section" in m for m in _config_warnings(caplog))
5060

5161

52-
def test_unknown_key_rejected() -> None:
62+
def test_unknown_key_warned(caplog: pytest.LogCaptureFixture) -> None:
5363
data = _full_config_dict()
5464
data["host"]["defalt_password"] = "typo"
55-
with pytest.raises(ConfigError):
65+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
5666
_build_config(data)
67+
assert any("defalt_password" in m for m in _config_warnings(caplog))
5768

5869

59-
def test_unknown_host_override_key_rejected() -> None:
70+
def test_unknown_host_override_key_warned(caplog: pytest.LogCaptureFixture) -> None:
6071
data = _full_config_dict()
6172
data["hosts"]["1.2.3.4"] = {"pasword": "typo"}
62-
with pytest.raises(ConfigError):
73+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
6374
_build_config(data)
75+
assert any("pasword" in m for m in _config_warnings(caplog))
6476

6577

66-
def test_unknown_storage_key_rejected() -> None:
78+
def test_unknown_storage_key_warned(caplog: pytest.LogCaptureFixture) -> None:
6779
data = _full_config_dict()
6880
data["storage"]["lvmoiscsi"]["targetIQN"] = "ok"
6981
data["storage"]["lvmoiscsi"]["SCSIid"] = "ok"
7082
data["storage"]["lvmoiscsi"]["targetiqn"] = "typo"
71-
with pytest.raises(ConfigError):
83+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
84+
_build_config(data)
85+
assert any("targetiqn" in m for m in _config_warnings(caplog))
86+
87+
88+
def test_iso_alias_keys_not_warned(caplog: pytest.LogCaptureFixture) -> None:
89+
data = _full_config_dict()
90+
data["install"]["isos"]["definitions"]["83net"] = {"path": "x.iso", "net-url": "http://pxe/installers/xcp-ng/8.3"}
91+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
92+
_build_config(data)
93+
assert _config_warnings(caplog) == []
94+
95+
96+
def test_answerfiles_extra_keys_not_warned(caplog: pytest.LogCaptureFixture) -> None:
97+
data = _full_config_dict()
98+
data["install"]["answerfiles"]["INSTALL"]["mode"] = "upgrade"
99+
with caplog.at_level(logging.WARNING, logger="lib.config_loader"):
72100
_build_config(data)
101+
assert _config_warnings(caplog) == []
73102

74103

75104
def test_answerfiles_allow_extra_keys() -> None:

0 commit comments

Comments
 (0)