Skip to content

Commit b903273

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 cc37fcb commit b903273

2 files changed

Lines changed: 90 additions & 40 deletions

File tree

lib/config_loader.py

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,58 @@
11
from __future__ import annotations
22

33
import argparse
4+
import logging
45
import os
56
import tomllib
67
from pathlib import Path
78

8-
from pydantic import BaseModel, Field, field_validator
9+
from pydantic import BaseModel, Field, field_validator, model_validator
910

1011
from lib.passwords import hash_password
1112
from lib.sizes import parse_size
1213
from lib.typing import ConfigDict, JSONType
1314

1415
from typing import overload
1516

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

1922

20-
class _StrictModel(BaseModel):
21-
"""Reject unknown keys at load time (extra="forbid")."""
22-
model_config = {"extra": "forbid"}
23+
class WarnOnExtraModel(BaseModel):
24+
"""Drop unknown config keys, warning about them instead of failing (extra="ignore")."""
25+
model_config = {"extra": "ignore"}
26+
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
2344

2445

2546
REPO_ROOT = Path(__file__).resolve().parent.parent
2647

2748

28-
class HostConfig(_StrictModel):
49+
class HostConfig(WarnOnExtraModel):
2950
default_user: str
3051
default_password: str
3152
default_password_hash: str = ""
3253

3354

34-
class HostOverride(_StrictModel):
55+
class HostOverride(WarnOnExtraModel):
3556
user: str | None = None
3657
password: str | None = None
3758
skip_xo_config: bool | None = None
@@ -40,25 +61,25 @@ class HostOverride(_StrictModel):
4061
hosting_pool: str | None = None
4162

4263

43-
class NetworkConfig(_StrictModel):
64+
class NetworkConfig(WarnOnExtraModel):
4465
mgmt: str
4566
free_nics: list[str]
4667

4768

48-
class PXEConfig(_StrictModel):
69+
class PXEConfig(WarnOnExtraModel):
4970
config_server: str
5071
arp_server: str
5172

5273

53-
class VMConfig(_StrictModel):
74+
class VMConfig(WarnOnExtraModel):
5475
def_url: str
5576
cache_imported: bool
5677
default_sr: str
5778
images: dict[str, str]
5879
equivalents: dict[str, str]
5980

6081

61-
class IsoImageDef(_StrictModel):
82+
class IsoImageDef(WarnOnExtraModel):
6283
path: str
6384
net_url: str | None = Field(default=None, alias="net-url")
6485
net_only: bool | None = Field(default=None, alias="net-only")
@@ -67,26 +88,26 @@ class IsoImageDef(_StrictModel):
6788
model_config = {"populate_by_name": True}
6889

6990

70-
class InstallIsosConfig(_StrictModel):
91+
class InstallIsosConfig(WarnOnExtraModel):
7192
base_url: str
7293
cache_dir: str
7394
definitions: dict[str, IsoImageDef]
7495

7596

76-
class AnswerFileDef(_StrictModel):
97+
class AnswerFileDef(WarnOnExtraModel):
7798
model_config = {"extra": "allow"}
7899

79100
TAG: str
80101
CONTENTS: str | list[AnswerFileDef] | None = None
81102

82103

83-
class InstallConfig(_StrictModel):
104+
class InstallConfig(WarnOnExtraModel):
84105
answerfiles: dict[str, AnswerFileDef]
85106
isos: InstallIsosConfig
86107
iso_remaster: str = ""
87108

88109

89-
class WinGuestToolDef(_StrictModel):
110+
class WinGuestToolDef(WarnOnExtraModel):
90111
name: str
91112
download: bool
92113
package: str
@@ -95,12 +116,12 @@ class WinGuestToolDef(_StrictModel):
95116
onboard_family: str | None = None
96117

97118

98-
class OtherGuestToolDef(_StrictModel):
119+
class OtherGuestToolDef(WarnOnExtraModel):
99120
name: str
100121
download: bool
101122

102123

103-
class InstalledGuestToolDef(_StrictModel):
124+
class InstalledGuestToolDef(WarnOnExtraModel):
104125
type: str | None = None
105126
path: str | None = None
106127
package: str | None = None
@@ -110,74 +131,74 @@ class InstalledGuestToolDef(_StrictModel):
110131
onboarding_phase: str | None = None
111132

112133

113-
class GuestToolsConfig(_StrictModel):
134+
class GuestToolsConfig(WarnOnExtraModel):
114135
download_url: str
115136
win: dict[str, WinGuestToolDef]
116137
other: OtherGuestToolDef
117138
installed: dict[str, InstalledGuestToolDef]
118139

119140

120-
class XOConfig(_StrictModel):
141+
class XOConfig(WarnOnExtraModel):
121142
cli: str
122143

123144

124-
class SSHConfig(_StrictModel):
145+
class SSHConfig(WarnOnExtraModel):
125146
pubkey: str
126147
output_max_lines: int
127148
ignore_banner: bool
128149

129150

130-
class LinstorConfig(_StrictModel):
151+
class LinstorConfig(WarnOnExtraModel):
131152
redundancy: int
132153

133154

134-
class NFSConfig(_StrictModel):
155+
class NFSConfig(WarnOnExtraModel):
135156
server: str | None = None
136157
serverpath: str | None = None
137158

138159

139-
class NFS4Config(_StrictModel):
160+
class NFS4Config(WarnOnExtraModel):
140161
server: str | None = None
141162
serverpath: str | None = None
142163
nfsversion: str | None = None
143164

144165

145-
class NFSISOConfig(_StrictModel):
166+
class NFSISOConfig(WarnOnExtraModel):
146167
location: str | None = None
147168

148169

149-
class CIFSISOConfig(_StrictModel):
170+
class CIFSISOConfig(WarnOnExtraModel):
150171
location: str | None = None
151172
username: str | None = None
152173
cifspassword: str | None = None
153174
type: str | None = None
154175
vers: str | None = None
155176

156177

157-
class CephFSConfig(_StrictModel):
178+
class CephFSConfig(WarnOnExtraModel):
158179
server: str | None = None
159180
serverpath: str | None = None
160181
options: str | None = None
161182

162183

163-
class MooseFSConfig(_StrictModel):
184+
class MooseFSConfig(WarnOnExtraModel):
164185
masterhost: str | None = None
165186
masterport: str | None = None
166187
rootpath: str | None = None
167188

168189

169-
class LVMoHBAConfig(_StrictModel):
190+
class LVMoHBAConfig(WarnOnExtraModel):
170191
SCSIid: str | None = None
171192

172193

173-
class LVMoISCSIConfig(_StrictModel):
194+
class LVMoISCSIConfig(WarnOnExtraModel):
174195
target: str | None = None
175196
port: str | None = None
176197
targetIQN: str | None = None
177198
SCSIid: str | None = None
178199

179200

180-
class StorageConfig(_StrictModel):
201+
class StorageConfig(WarnOnExtraModel):
181202
nfs: NFSConfig
182203
nfs4: NFS4Config
183204
nfs_iso: NFSISOConfig
@@ -189,17 +210,17 @@ class StorageConfig(_StrictModel):
189210
linstor: LinstorConfig
190211

191212

192-
class UpdateDefaults(_StrictModel):
213+
class UpdateDefaults(WarnOnExtraModel):
193214
repositories: list[str] = Field(default_factory=list)
194215
disabled_repositories: list[str] = Field(default_factory=list)
195216
hosting_pool: str | None = None
196217

197218

198-
class ToolsConfig(_StrictModel):
219+
class ToolsConfig(WarnOnExtraModel):
199220
update: UpdateDefaults = Field(default_factory=UpdateDefaults)
200221

201222

202-
class Config(_StrictModel):
223+
class Config(WarnOnExtraModel):
203224
objects_name_prefix: str | None
204225
dns_server: str
205226
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)