|
19 | 19 | - Persistent state tracking enables recovery after failures/reboots |
20 | 20 | - Automatic retry with exponential backoff for transient API failures |
21 | 21 | - 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 |
23 | 23 | - Template placeholders: any non-special CSV column as {{column_name}} |
24 | 24 | - Global and per-device tagging with automatic merge and deduplication |
25 | 25 | - DNS options: LAN as DNS with local domain, or custom DNS servers (primary/secondary) |
|
77 | 77 | - cp_host_tags: Per-device CP host resource tags (optional) |
78 | 78 | - wildcard_resource_tags: Per-device wildcard resource tags (optional) |
79 | 79 | - 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) |
80 | 82 | |
81 | 83 | Template placeholder columns: |
82 | 84 | - 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: |
325 | 327 | return ','.join(unique_tags) if unique_tags else '' |
326 | 328 |
|
327 | 329 |
|
| 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 | + |
328 | 379 | def log_progress(step: int, total: int, message: str) -> None: |
329 | 380 | """Log progress with step counter. |
330 | 381 |
|
@@ -1040,12 +1091,176 @@ def create_exchange_site_resources(n3_client: ncm.NcmClientv3, |
1040 | 1091 | if isinstance(result, str) and result.startswith('ERROR'): |
1041 | 1092 | cp.log(f"ERROR creating wildcard resource: {result}") |
1042 | 1093 |
|
| 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 | + |
1043 | 1102 | set_state(STATE_RESOURCES, 'complete') |
1044 | 1103 | except Exception as e: |
1045 | 1104 | cp.log(f"ERROR creating exchange site resources: {e}") |
1046 | 1105 | raise |
1047 | 1106 |
|
1048 | 1107 |
|
| 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 | + |
1049 | 1264 | def check_vpn_tunnel_status() -> bool: |
1050 | 1265 | """Check if VPN tunnel is up. |
1051 | 1266 | |
|
0 commit comments