Skip to content

Commit cda7443

Browse files
authored
Merge pull request #151 from reismarcelo/main
Updated sdwan_config_builder with native support for Pydantic 2.6.x
2 parents 305cf29 + aebe853 commit cda7443

File tree

8 files changed

+110
-107
lines changed

8 files changed

+110
-107
lines changed

config/config.example.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
global_config:
33
# Different cloud have different restrictions on the characters allowed in
4-
# the tags. AWS is the most permisive, GCP is the most restrictive, Azure is
4+
# the tags. AWS is the most permissive, GCP is the most restrictive, Azure is
55
# somewhere in the middle. If you use all lowercase letters and numbers, you
66
# should be fine.
77
common_tags:

sdwan_config_builder/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Install config builder:
3535
Validate that config builder is installed:
3636
```
3737
(venv) % sdwan_config_build --version
38-
SDWAN Config Builder Tool Version 0.7
38+
SDWAN Config Builder Tool Version 0.9
3939
```
4040

4141
## Running
@@ -45,7 +45,7 @@ should be saved. By default sdwan_config_build looks for a 'metadata.yaml' file
4545
The CONFIG_BUILDER_METADATA environment variable can be used to specify a custom location for the metadata file.
4646

4747
```
48-
(venv) % % sdwan_config_build --help
48+
(venv) % sdwan_config_build --help
4949
usage: sdwan_config_build [-h] [--version] {render,export,schema} ...
5050
5151
SDWAN Config Builder Tool

sdwan_config_builder/requirements.txt

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
pydantic>=2
2-
PyYAML>=6.0
1+
pydantic>=2.6
2+
pydantic-settings>=2.2.1
3+
PyYAML>=6.0.1
34
Jinja2>=3.1
45
passlib>=1.7.4
56
sshpubkeys>=3.3
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__copyright__ = "Copyright (c) 2022-2023 Cisco Systems, Inc. and/or its affiliates"
2-
__version__ = "0.8.2"
1+
__copyright__ = "Copyright (c) 2022-2024 Cisco Systems, Inc. and/or its affiliates"
2+
__version__ = "0.9.0"

sdwan_config_builder/src/sdwan_config_builder/commands.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import logging
3+
import json
34
from typing import Union
45
from pathlib import Path
56
from ipaddress import IPv4Interface, IPv4Network
@@ -93,7 +94,7 @@ def render_cmd(cli_args: argparse.Namespace) -> None:
9394
'ipv4_subnet_host': ipv4_subnet_host_filter,
9495
}
9596
jinja_env.filters.update(custom_filters)
96-
jinja_env.globals = config_obj.dict(by_alias=True)
97+
jinja_env.globals = config_obj.model_dump(by_alias=True)
9798

9899
for jinja_target in app_config.targets_config.jinja_renderer.targets:
99100
try:
@@ -128,7 +129,7 @@ def export_cmd(cli_args: argparse.Namespace) -> None:
128129
try:
129130
config_obj = load_yaml(ConfigModel, 'config', app_config.loader_config.top_level_config)
130131
with open(cli_args.file, 'w') as export_file:
131-
export_file.write(config_obj.json(by_alias=True, indent=2))
132+
export_file.write(config_obj.model_dump_json(by_alias=True, indent=2))
132133

133134
logger.info(f"Exported source configuration as '{cli_args.file}'")
134135

@@ -143,6 +144,6 @@ def schema_cmd(cli_args: argparse.Namespace) -> None:
143144
:return: None
144145
"""
145146
with open(cli_args.file, 'w') as schema_file:
146-
schema_file.write(ConfigModel.schema_json(indent=2))
147+
schema_file.write(json.dumps(ConfigModel.model_json_schema(), indent=2))
147148

148149
logger.info(f"Saved configuration schema as '{cli_args.file}'")

sdwan_config_builder/src/sdwan_config_builder/loader/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
import yaml
44
from typing import Any, TypeVar, Type, Union, List
5-
from pydantic.v1 import BaseModel, ValidationError
5+
from pydantic import BaseModel, ValidationError
66
from .models import ConfigModel
77

88

sdwan_config_builder/src/sdwan_config_builder/loader/models.py

+83-83
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from functools import partial
22
from secrets import token_urlsafe
3-
from typing import List, Dict, Any, Optional, Iterable, Union
3+
from typing import List, Dict, Optional, Iterable, Union
4+
from typing_extensions import Annotated
45
from enum import Enum
56
from pathlib import Path
67
from ipaddress import IPv4Network, IPv6Network, IPv4Interface, IPv4Address
7-
from pydantic.v1 import BaseModel, BaseSettings, Field, validator, root_validator, constr, conint
8+
from pydantic import field_validator, field_serializer, model_validator, Field, ConfigDict, BaseModel, ValidationInfo
9+
from pydantic_settings import SettingsConfigDict, BaseSettings
810
from passlib.hash import sha512_crypt
911
from sshpubkeys import SSHKey, InvalidKeyError
1012
from .validators import (formatted_string, unique_system_ip, constrained_cidr, cidr_subnet, subnet_interface,
@@ -58,20 +60,23 @@ class GlobalConfigModel(BaseSettings):
5860
"""
5961
GlobalConfigModel is a special config block as field values can use environment variables as their default value
6062
"""
61-
home_dir: str = Field(..., env='HOME')
62-
project_root: str = Field(..., env='PROJ_ROOT')
63+
model_config = SettingsConfigDict(case_sensitive=True)
64+
65+
home_dir: Annotated[str, Field(validation_alias='HOME')]
66+
project_root: Annotated[str, Field(validation_alias='PROJ_ROOT')]
6367
common_tags: Dict[str, str] = None
6468
ubuntu_image: str
65-
ssh_public_key_file: str = Field(None, description='Can use python format string syntax to reference other '
66-
'previous fields in this model')
67-
ssh_public_key: Optional[str] = None
68-
ssh_public_key_fp: Optional[str] = None
69+
ssh_public_key_file: Annotated[Optional[str], Field(description='Can use python format string syntax to reference '
70+
'other previous fields in this model')] = None
71+
ssh_public_key: Annotated[Optional[str], Field(validate_default=True)] = None
72+
ssh_public_key_fp: Annotated[Optional[str], Field(validate_default=True)] = None
6973

70-
_validate_formatted_strings = validator('ssh_public_key_file', allow_reuse=True)(formatted_string)
74+
_validate_formatted_strings = field_validator('ssh_public_key_file')(formatted_string)
7175

72-
@validator('ssh_public_key', always=True)
73-
def resolve_ssh_public_key(cls, v, values: Dict[str, Any]):
74-
pub_key = resolve_ssh_public_key(v, values.get('ssh_public_key_file'))
76+
@field_validator('ssh_public_key')
77+
@classmethod
78+
def resolve_ssh_public_key(cls, v, info: ValidationInfo):
79+
pub_key = resolve_ssh_public_key(v, info.data.get('ssh_public_key_file'))
7580
try:
7681
ssh_key = SSHKey(pub_key, strict=True)
7782
ssh_key.parse()
@@ -82,10 +87,11 @@ def resolve_ssh_public_key(cls, v, values: Dict[str, Any]):
8287

8388
return pub_key
8489

85-
@validator('ssh_public_key_fp', always=True)
86-
def resolve_ssh_public_key_fp(cls, v: Union[str, None], values: Dict[str, Any]) -> str:
90+
@field_validator('ssh_public_key_fp')
91+
@classmethod
92+
def resolve_ssh_public_key_fp(cls, v: Union[str, None], info: ValidationInfo) -> str:
8793
if v is None:
88-
pub_key = values.get('ssh_public_key')
94+
pub_key = info.data.get('ssh_public_key')
8995
if pub_key is None:
9096
raise ValueError("Field 'ssh_public_key' or 'ssh_public_key_file' not present")
9197
ssh_key = SSHKey(pub_key, strict=True)
@@ -96,19 +102,17 @@ def resolve_ssh_public_key_fp(cls, v: Union[str, None], values: Dict[str, Any])
96102

97103
return fp
98104

99-
@validator('project_root')
105+
@field_validator('project_root')
106+
@classmethod
100107
def resolve_project_root(cls, v: str) -> str:
101108
return str(Path(v).resolve())
102109

103-
class Config:
104-
case_sensitive = True
105-
106110

107111
#
108112
# infra providers block
109113
#
110114
class InfraProviderConfigModel(BaseModel):
111-
ntp_server: constr(regex=r'^[a-zA-Z0-9.-]+$')
115+
ntp_server: Annotated[str, Field(pattern=r'^[a-zA-Z0-9.-]+$')]
112116

113117

114118
class GCPConfigModel(InfraProviderConfigModel):
@@ -133,57 +137,57 @@ class InfraProvidersModel(BaseModel):
133137
#
134138

135139
class ControllerCommonInfraModel(BaseModel):
140+
model_config = ConfigDict(use_enum_values=True)
141+
136142
provider: InfraProviderControllerOptionsEnum
137143
region: str
138-
dns_domain: constr(regex=r'^[a-zA-Z0-9.-]+$') = Field(
139-
'', description="If set, add A records for control plane element external addresses in AWS Route 53")
140-
sw_version: constr(regex=r'^\d+(?:\.\d+)+$')
144+
dns_domain: Annotated[str, Field(description="If set, add A records for control plane element external "
145+
"addresses in AWS Route 53", pattern=r'^[a-zA-Z0-9.-]+$')] = ''
146+
sw_version: Annotated[str, Field(pattern=r'^\d+(?:\.\d+)+$')]
141147
cloud_init_format: CloudInitEnum = CloudInitEnum.v1
142148

143-
class Config:
144-
use_enum_values = True
145-
146149

147150
class ControllerCommonConfigModel(BaseModel):
148151
organization_name: str
149-
site_id: conint(ge=0, le=4294967295)
152+
site_id: Annotated[int, Field(ge=0, le=4294967295)]
150153
acl_ingress_ipv4: List[IPv4Network]
151154
acl_ingress_ipv6: List[IPv6Network]
152155
cidr: IPv4Network
153156
vpn0_gateway: IPv4Address
154157

155-
@validator('acl_ingress_ipv4', 'acl_ingress_ipv6')
156-
def acl_str(cls, v: Iterable[IPv4Network]) -> str:
158+
@field_serializer('acl_ingress_ipv4', 'acl_ingress_ipv6')
159+
def acl_str(self, v: Iterable[IPv4Network]) -> str:
157160
return ', '.join(f'"{entry}"' for entry in v)
158161

159-
_validate_cidr = validator('cidr', allow_reuse=True)(constrained_cidr(max_length=23))
162+
_validate_cidr = field_validator('cidr')(constrained_cidr(max_length=23))
160163

161164

162165
class CertAuthModel(BaseModel):
163166
passphrase: str = Field(default_factory=partial(token_urlsafe, 15))
164167
cert_dir: str
165-
ca_cert: str = '{cert_dir}/myCA.pem'
168+
ca_cert: Annotated[str, Field(validate_default=True)] = '{cert_dir}/myCA.pem'
166169

167-
_validate_formatted_strings = validator('ca_cert', always=True, allow_reuse=True)(formatted_string)
170+
_validate_formatted_strings = field_validator('ca_cert')(formatted_string)
168171

169172

170173
class ControllerConfigModel(BaseModel):
171174
system_ip: IPv4Address
172175
vpn0_interface_ipv4: IPv4Interface
173176

174177
# Validators
175-
_validate_system_ip = validator('system_ip', allow_reuse=True)(unique_system_ip)
178+
_validate_system_ip = field_validator('system_ip')(unique_system_ip)
176179

177180

178181
class VmanageConfigModel(ControllerConfigModel):
179182
username: str = 'admin'
180183
password: str = Field(default_factory=partial(token_urlsafe, 12))
181-
password_hashed: Optional[str] = None
184+
password_hashed: Annotated[Optional[str], Field(validate_default=True)] = None
182185

183-
@validator('password_hashed', always=True)
184-
def hash_password(cls, v: Union[str, None], values: Dict[str, Any]) -> str:
186+
@field_validator('password_hashed')
187+
@classmethod
188+
def hash_password(cls, v: Union[str, None], info: ValidationInfo) -> str:
185189
if v is None:
186-
clear_password = values.get('password')
190+
clear_password = info.data.get('password')
187191
if clear_password is None:
188192
raise ValueError("Field 'password' is not present")
189193
# Using 'openssl passwd -6' recipe
@@ -227,78 +231,74 @@ class InfraVmwareModel(BaseModel):
227231

228232

229233
class EdgeInfraModel(ComputeInstanceModel):
234+
model_config = ConfigDict(use_enum_values=True)
235+
230236
provider: InfraProviderOptionsEnum
231-
region: Optional[str] = None
232-
zone: Optional[str] = None
233-
sw_version: constr(regex=r'^\d+(?:\.\d+)+')
237+
region: Annotated[Optional[str], Field(validate_default=True)] = None
238+
zone: Annotated[Optional[str], Field(validate_default=True)] = None
239+
sw_version: Annotated[str, Field(pattern=r'^\d+(?:\.\d+)+')]
234240
cloud_init_format: CloudInitEnum = CloudInitEnum.v1
235241
sdwan_model: str
236242
sdwan_uuid: str
237-
vmware: Optional[InfraVmwareModel] = None
238-
239-
@validator('region', always=True)
240-
def region_validate(cls, v, values: Dict[str, Any]):
241-
if v is None and values['provider'] != InfraProviderOptionsEnum.vmware:
242-
raise ValueError(f"{values['provider']} provider requires 'region' to be defined")
243-
if v is not None and values['provider'] == InfraProviderOptionsEnum.vmware:
243+
vmware: Annotated[Optional[InfraVmwareModel], Field(validate_default=True)] = None
244+
245+
@field_validator('region')
246+
@classmethod
247+
def region_validate(cls, v, info: ValidationInfo):
248+
if v is None and info.data['provider'] != InfraProviderOptionsEnum.vmware:
249+
raise ValueError(f"{info.data['provider']} provider requires 'region' to be defined")
250+
if v is not None and info.data['provider'] == InfraProviderOptionsEnum.vmware:
244251
raise ValueError(f"'region' is not allowed when provider is {InfraProviderOptionsEnum.vmware}")
245252

246253
return v
247254

248-
@validator('zone', always=True)
249-
def zone_validate(cls, v, values: Dict[str, Any]):
250-
if v is None and values['provider'] == InfraProviderOptionsEnum.gcp:
255+
@field_validator('zone')
256+
@classmethod
257+
def zone_validate(cls, v, info: ValidationInfo):
258+
if v is None and info.data['provider'] == InfraProviderOptionsEnum.gcp:
251259
raise ValueError("GCP requires zone to be defined")
252260

253261
return v
254262

255-
@validator('vmware', always=True)
256-
def vmware_section(cls, v, values: Dict[str, Any]):
257-
if v is None and values['provider'] == InfraProviderOptionsEnum.vmware:
263+
@field_validator('vmware')
264+
@classmethod
265+
def vmware_section(cls, v, info: ValidationInfo):
266+
if v is None and info.data['provider'] == InfraProviderOptionsEnum.vmware:
258267
raise ValueError(f"{InfraProviderOptionsEnum.vmware} provider requires 'vmware' section to be defined")
259-
if v is not None and values['provider'] != InfraProviderOptionsEnum.vmware:
268+
if v is not None and info.data['provider'] != InfraProviderOptionsEnum.vmware:
260269
raise ValueError(f"'vmware' section is only allowed when provider is {InfraProviderOptionsEnum.vmware}")
261270

262271
return v
263272

264-
@root_validator
265-
def instance_type_validate(cls, values: Dict[str, Any]):
266-
if values['instance_type'] is None and values['provider'] != InfraProviderOptionsEnum.vmware:
267-
raise ValueError(f"{values['provider']} provider requires 'instance_type' to be defined")
268-
if values['instance_type'] is not None and values['provider'] == InfraProviderOptionsEnum.vmware:
273+
@model_validator(mode='after')
274+
def instance_type_validate(self) -> 'EdgeInfraModel':
275+
if self.instance_type is None and self.provider != InfraProviderOptionsEnum.vmware:
276+
raise ValueError(f"{self.provider} provider requires 'instance_type' to be defined")
277+
if self.instance_type is not None and self.provider == InfraProviderOptionsEnum.vmware:
269278
raise ValueError(f"'instance_type' is not allowed when provider is {InfraProviderOptionsEnum.vmware}")
270279

271-
return values
272-
273-
class Config:
274-
use_enum_values = True
280+
return self
275281

276282

277283
class EdgeConfigModel(BaseModel):
278-
site_id: conint(ge=0, le=4294967295)
284+
site_id: Annotated[int, Field(ge=0, le=4294967295)]
279285
system_ip: IPv4Address
280286
cidr: Optional[IPv4Network] = None
281-
vpn0_range: Optional[IPv4Network] = None
282-
vpn0_interface_ipv4: Optional[IPv4Interface] = None
283-
vpn0_gateway: Optional[IPv4Address] = None
284-
vpn1_range: Optional[IPv4Network] = None
285-
vpn1_interface_ipv4: Optional[IPv4Interface] = None
287+
vpn0_range: Annotated[Optional[IPv4Network], Field(validate_default=True)] = None
288+
vpn0_interface_ipv4: Annotated[Optional[IPv4Interface], Field(validate_default=True)] = None
289+
vpn0_gateway: Annotated[Optional[IPv4Address], Field(validate_default=True)] = None
290+
vpn1_range: Annotated[Optional[IPv4Network], Field(validate_default=True)] = None
291+
vpn1_interface_ipv4: Annotated[Optional[IPv4Interface], Field(validate_default=True)] = None
286292

287293
# Validators
288-
_validate_system_ip = validator('system_ip', allow_reuse=True)(unique_system_ip)
289-
_validate_cidr = validator('cidr', allow_reuse=True)(constrained_cidr(max_length=23))
290-
_validate_vpn_range = validator('vpn0_range', 'vpn1_range', always=True, allow_reuse=True)(
291-
cidr_subnet(cidr_field='cidr', prefix_len=24)
292-
)
293-
_validate_vpn0_ipv4 = validator('vpn0_interface_ipv4', always=True, allow_reuse=True)(
294-
subnet_interface(subnet_field='vpn0_range', host_index=10)
295-
)
296-
_validate_vpn1_ipv4 = validator('vpn1_interface_ipv4', always=True, allow_reuse=True)(
297-
subnet_interface(subnet_field='vpn1_range', host_index=10)
298-
)
299-
_validate_vpn0_gw = validator('vpn0_gateway', always=True, allow_reuse=True)(
300-
subnet_address(subnet_field='vpn0_range', host_index=0)
301-
)
294+
_validate_system_ip = field_validator('system_ip')(unique_system_ip)
295+
_validate_cidr = field_validator('cidr')(constrained_cidr(max_length=23))
296+
_validate_vpn_range = field_validator('vpn0_range', 'vpn1_range')(cidr_subnet(cidr_field='cidr', prefix_len=24))
297+
_validate_vpn0_ipv4 = field_validator('vpn0_interface_ipv4')(subnet_interface(subnet_field='vpn0_range',
298+
host_index=10))
299+
_validate_vpn1_ipv4 = field_validator('vpn1_interface_ipv4')(subnet_interface(subnet_field='vpn1_range',
300+
host_index=10))
301+
_validate_vpn0_gw = field_validator('vpn0_gateway')(subnet_address(subnet_field='vpn0_range', host_index=0))
302302

303303

304304
class EdgeModel(BaseModel):

0 commit comments

Comments
 (0)