Skip to content

Commit 4ffaabb

Browse files
authored
support adding resources via bulk config
updated app to support adding mutiple ip subnet and/or fqdn resources using special columns in bulk config section
1 parent f819733 commit 4ffaabb

6 files changed

Lines changed: 540 additions & 19 deletions

File tree

apps/ncx_self_provision/ncx_self_provision.py

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
- Persistent state tracking enables recovery after failures/reboots
2020
- Automatic retry with exponential backoff for transient API failures
2121
- Template-based bulk configuration with {{placeholder}} syntax
22-
- Special CSV columns: id, name, primary_lan_ip, desc, custom1/2, tags, disable_force_dns
22+
- Special CSV columns: id, name, primary_lan_ip, desc, custom1/2, tags, disable_force_dns, extra_ip_resources, extra_fqdn_resources
2323
- Template placeholders: any non-special CSV column as {{column_name}}
2424
- Global and per-device tagging with automatic merge and deduplication
2525
- DNS options: LAN as DNS with local domain, or custom DNS servers (primary/secondary)
@@ -77,6 +77,8 @@
7777
- cp_host_tags: Per-device CP host resource tags (optional)
7878
- wildcard_resource_tags: Per-device wildcard resource tags (optional)
7979
- disable_force_dns: Override global Force DNS (optional, 'true' to disable)
80+
- extra_ip_resources: Additional IP subnet resources (optional, pipe-separated entries)
81+
- extra_fqdn_resources: Additional FQDN resources (optional, pipe-separated entries)
8082
8183
Template placeholder columns:
8284
- Any non-special column can be used as {{column_name}} in config_template.json
@@ -325,6 +327,55 @@ def merge_tags(global_tags: str, csv_tags: str) -> str:
325327
return ','.join(unique_tags) if unique_tags else ''
326328

327329

330+
def parse_multi_resource_field(field_value: str) -> List[Dict[str, str]]:
331+
"""Parse a CSV cell containing one or more resource definitions.
332+
333+
Each resource is separated by a pipe character (|). Parameters within
334+
each resource are separated by semicolons in key=value format.
335+
336+
Format for IP subnet resources:
337+
name=<name>;ip=<cidr>[;tags=<tag1:tag2>][;protocols=<TCP:UDP>][;port_ranges=<80:8000-8080>]
338+
339+
Format for FQDN resources:
340+
name=<name>;domain=<fqdn>[;tags=<tag1:tag2>][;protocols=<TCP:UDP>][;port_ranges=<80:8000-8080>]
341+
342+
Multiple resources in one cell:
343+
name=res1;ip=10.0.1.0/24;tags=lan|name=res2;ip=10.0.2.0/24;tags=branch
344+
345+
Args:
346+
field_value: Raw cell value from CSV.
347+
348+
Returns:
349+
List[Dict[str, str]]: List of parsed resource parameter dictionaries.
350+
Returns empty list if field_value is empty or None.
351+
352+
"""
353+
if not field_value or not field_value.strip():
354+
return []
355+
356+
resources = []
357+
entries = field_value.split('|')
358+
359+
for entry in entries:
360+
entry = entry.strip()
361+
if not entry:
362+
continue
363+
364+
params = {}
365+
parts = entry.split(';')
366+
for part in parts:
367+
part = part.strip()
368+
if '=' not in part:
369+
continue
370+
key, value = part.split('=', 1)
371+
params[key.strip()] = value.strip()
372+
373+
if params:
374+
resources.append(params)
375+
376+
return resources
377+
378+
328379
def log_progress(step: int, total: int, message: str) -> None:
329380
"""Log progress with step counter.
330381
@@ -1040,12 +1091,176 @@ def create_exchange_site_resources(n3_client: ncm.NcmClientv3,
10401091
if isinstance(result, str) and result.startswith('ERROR'):
10411092
cp.log(f"ERROR creating wildcard resource: {result}")
10421093

1094+
# Create additional IP subnet resources from CSV (optional)
1095+
if csv_row and csv_row.get('extra_ip_resources'):
1096+
create_additional_ip_subnet_resources(n3_client, site_id, csv_row)
1097+
1098+
# Create additional FQDN resources from CSV (optional)
1099+
if csv_row and csv_row.get('extra_fqdn_resources'):
1100+
create_additional_fqdn_resources(n3_client, site_id, csv_row)
1101+
10431102
set_state(STATE_RESOURCES, 'complete')
10441103
except Exception as e:
10451104
cp.log(f"ERROR creating exchange site resources: {e}")
10461105
raise
10471106

10481107

1108+
def create_additional_ip_subnet_resources(
1109+
n3_client: ncm.NcmClientv3,
1110+
site_id: str,
1111+
csv_row: Dict[str, str]
1112+
) -> None:
1113+
"""Create additional exchange IP subnet resources from CSV field.
1114+
1115+
Parses the 'extra_ip_resources' CSV column and creates one
1116+
exchange_ipsubnet_resources entry per defined resource.
1117+
1118+
CSV cell format (pipe-separated entries, semicolon key=value params):
1119+
name=<name>;ip=<cidr>[;tags=<t1:t2>][;protocols=<TCP:UDP>][;port_ranges=<80:8000-8080>]
1120+
1121+
Example cell value:
1122+
name=servers;ip=10.0.1.0/24;tags=dc:internal|name=iot;ip=10.0.2.0/24
1123+
1124+
Args:
1125+
n3_client: NCM v3 API client.
1126+
site_id: Exchange site ID to attach resources to.
1127+
csv_row: Matched CSV row containing the extra_ip_resources field.
1128+
1129+
"""
1130+
field_value = csv_row.get('extra_ip_resources', '')
1131+
resources = parse_multi_resource_field(field_value)
1132+
1133+
if not resources:
1134+
return
1135+
1136+
cp.log(f"Creating {len(resources)} additional IP subnet resource(s)")
1137+
1138+
for idx, params in enumerate(resources, start=1):
1139+
resource_name = params.get('name', '')
1140+
ip_cidr = params.get('ip', '')
1141+
1142+
if not resource_name or not ip_cidr:
1143+
cp.log(
1144+
f"WARNING: Skipping extra IP subnet entry {idx} - "
1145+
f"'name' and 'ip' are required "
1146+
f"(format: name=<name>;ip=<cidr>)"
1147+
)
1148+
continue
1149+
1150+
kwargs = {}
1151+
1152+
# Parse optional tags (colon-separated within the value)
1153+
tags_str = params.get('tags', '')
1154+
if tags_str:
1155+
kwargs['tags'] = [t.strip() for t in tags_str.split(':') if t.strip()]
1156+
1157+
# Parse optional protocols
1158+
protocols_str = params.get('protocols', '')
1159+
if protocols_str:
1160+
kwargs['protocols'] = [p.strip() for p in protocols_str.split(':') if p.strip()]
1161+
1162+
# Parse optional port_ranges
1163+
port_ranges_str = params.get('port_ranges', '')
1164+
if port_ranges_str:
1165+
kwargs['port_ranges'] = [p.strip() for p in port_ranges_str.split(':') if p.strip()]
1166+
1167+
try:
1168+
cp.log(f" Creating IP subnet resource: {resource_name} ({ip_cidr})")
1169+
result = retry_on_failure(
1170+
n3_client.create_exchange_resource,
1171+
resource_name=resource_name,
1172+
resource_type='exchange_ipsubnet_resources',
1173+
site_id=site_id,
1174+
ip=ip_cidr,
1175+
**kwargs
1176+
)
1177+
if isinstance(result, str) and result.startswith('ERROR'):
1178+
cp.log(f" ERROR creating IP subnet resource '{resource_name}': {result}")
1179+
else:
1180+
cp.log(f" Successfully created IP subnet resource: {resource_name}")
1181+
time.sleep(2)
1182+
except Exception as e:
1183+
cp.log(f" ERROR creating IP subnet resource '{resource_name}': {e}")
1184+
1185+
1186+
def create_additional_fqdn_resources(
1187+
n3_client: ncm.NcmClientv3,
1188+
site_id: str,
1189+
csv_row: Dict[str, str]
1190+
) -> None:
1191+
"""Create additional exchange FQDN resources from CSV field.
1192+
1193+
Parses the 'extra_fqdn_resources' CSV column and creates one
1194+
exchange_fqdn_resources entry per defined resource.
1195+
1196+
CSV cell format (pipe-separated entries, semicolon key=value params):
1197+
name=<name>;domain=<fqdn>[;tags=<t1:t2>][;protocols=<TCP:UDP>][;port_ranges=<80:443>]
1198+
1199+
Example cell value:
1200+
name=app1;domain=app1.site.local;tags=web|name=api;domain=api.site.local;protocols=TCP;port_ranges=443:8443
1201+
1202+
Args:
1203+
n3_client: NCM v3 API client.
1204+
site_id: Exchange site ID to attach resources to.
1205+
csv_row: Matched CSV row containing the extra_fqdn_resources field.
1206+
1207+
"""
1208+
field_value = csv_row.get('extra_fqdn_resources', '')
1209+
resources = parse_multi_resource_field(field_value)
1210+
1211+
if not resources:
1212+
return
1213+
1214+
cp.log(f"Creating {len(resources)} additional FQDN resource(s)")
1215+
1216+
for idx, params in enumerate(resources, start=1):
1217+
resource_name = params.get('name', '')
1218+
domain = params.get('domain', '')
1219+
1220+
if not resource_name or not domain:
1221+
cp.log(
1222+
f"WARNING: Skipping extra FQDN entry {idx} - "
1223+
f"'name' and 'domain' are required "
1224+
f"(format: name=<name>;domain=<fqdn>)"
1225+
)
1226+
continue
1227+
1228+
kwargs = {}
1229+
1230+
# Parse optional tags (colon-separated within the value)
1231+
tags_str = params.get('tags', '')
1232+
if tags_str:
1233+
kwargs['tags'] = [t.strip() for t in tags_str.split(':') if t.strip()]
1234+
1235+
# Parse optional protocols
1236+
protocols_str = params.get('protocols', '')
1237+
if protocols_str:
1238+
kwargs['protocols'] = [p.strip() for p in protocols_str.split(':') if p.strip()]
1239+
1240+
# Parse optional port_ranges
1241+
port_ranges_str = params.get('port_ranges', '')
1242+
if port_ranges_str:
1243+
kwargs['port_ranges'] = [p.strip() for p in port_ranges_str.split(':') if p.strip()]
1244+
1245+
try:
1246+
cp.log(f" Creating FQDN resource: {resource_name} ({domain})")
1247+
result = retry_on_failure(
1248+
n3_client.create_exchange_resource,
1249+
resource_name=resource_name,
1250+
resource_type='exchange_fqdn_resources',
1251+
site_id=site_id,
1252+
domain=domain,
1253+
**kwargs
1254+
)
1255+
if isinstance(result, str) and result.startswith('ERROR'):
1256+
cp.log(f" ERROR creating FQDN resource '{resource_name}': {result}")
1257+
else:
1258+
cp.log(f" Successfully created FQDN resource: {resource_name}")
1259+
time.sleep(2)
1260+
except Exception as e:
1261+
cp.log(f" ERROR creating FQDN resource '{resource_name}': {e}")
1262+
1263+
10491264
def check_vpn_tunnel_status() -> bool:
10501265
"""Check if VPN tunnel is up.
10511266

0 commit comments

Comments
 (0)