build v_1.0.1

This commit is contained in:
2026-07-21 15:05:52 +03:00
commit 6563d755d8
27 changed files with 1974 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "2.0.0"
+6
View File
@@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+763
View File
@@ -0,0 +1,763 @@
"""STAGE 1: collecting Neutron VPNaaS configuration into an audit JSON file,
plus the --from-audit path that reconstructs the same in-memory state from a
previously written audit file instead of re-querying OpenStack.
This module owns the audit JSON schema definition (to_audit_dict /
load_from_audit_json), so it's defined exactly once and shared by both the
write path (STEP 10) and the read path (--from-audit).
"""
from __future__ import annotations
import json
import os
import re
import sys
from datetime import datetime, timezone
from . import keystone_auth, neutron_client
from .models import MigrationState
from .neutron_client import NeutronClient
from .redact import redact_psk
from .sprut_client import SprutClient
from .tables import print_kv_table
_UUID_LIKE = re.compile(r"^[0-9a-fA-F-]{36}$")
def _dump(obj) -> str:
return json.dumps(obj, indent=2)
def _rule() -> None:
print("*" * 86)
# --------------------------------------------------------------------------
# STEP 1: read the input CSV and build the neutron -> advanced router map.
# A row's advanced_router_id may be blank -- that router will be created in
# Sprut later (STEP 3). Optional 3rd/4th columns (availability_zone,
# flavor) only matter for such rows; they're a hint for that creation,
# used instead of the interactive prompt when present.
# --------------------------------------------------------------------------
def read_input_csv(input_csv: str) -> tuple[dict[str, str], dict[str, dict]]:
print(f"Executing STEP 1: Reading config file {input_csv}")
mapping: dict[str, str] = {}
creation_hints: dict[str, dict] = {}
try:
handle = open(input_csv, encoding="utf-8")
except OSError as exc:
print(f"Error: could not open input file '{input_csv}': {exc}", file=sys.stderr)
sys.exit(1)
with handle:
for raw_line in handle:
line = raw_line.rstrip("\n").rstrip("\r")
fields = [f.strip() for f in line.split(",")]
neutron_router = fields[0] if fields else ""
advanced_router = fields[1] if len(fields) > 1 else ""
availability_zone = fields[2] if len(fields) > 2 else ""
flavor = fields[3] if len(fields) > 3 else ""
if not neutron_router:
continue
mapping[neutron_router] = advanced_router
if not advanced_router:
hint = {}
if availability_zone:
hint["availability_zone"] = availability_zone
if flavor:
hint["flavor"] = flavor
if hint:
creation_hints[neutron_router] = hint
print(
f"Neutron router '{neutron_router}' has no advanced router — "
"a DC Router will be created for it in Sprut.",
file=sys.stderr,
)
print("Reading values from config:")
print()
print_kv_table(
{k: (v or "<to be created>") for k, v in mapping.items()}, "Neutron router", "Advanced Router"
)
print()
print("STEP 1 complete (config read)")
_rule()
return mapping, creation_hints
# --------------------------------------------------------------------------
# STEP 2: verify every Neutron router from the CSV exists in OpenStack.
# --------------------------------------------------------------------------
def check_neutron_routers_exist(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 2: Checking Neutron routers")
listing = neutron_client.router_list(nc)
state.neutron_router_by_id = {entry["id"]: entry for entry in listing}
existing_ids = set(state.neutron_router_by_id)
for neutron_router_id in state.neutron_to_adv_router:
if neutron_router_id not in existing_ids:
# No stderr redirect here -- matches the bash original's
# (inconsistent, but preserved) use of stdout for this message.
print(f"Error: Neutron router ID {neutron_router_id} not found in OpenStack tenant.")
sys.exit(1)
print(f"Neutron router ID {neutron_router_id} exists in OpenStack tenant.")
print("STEP 2 complete (Neutron routers checked)")
_rule()
_DC_FLAVORS = ("basic", "standard", "advanced")
def _require_interactive(neutron_router_id: str, field_name: str) -> None:
if not sys.stdin.isatty():
print(
f"Error: router '{neutron_router_id}' needs a DC Router created but '{field_name}' "
"isn't set in the CSV and stdin is not interactive. Add it as a CSV column "
"(3rd=availability_zone, 4th=flavor) or run interactively.",
file=sys.stderr,
)
sys.exit(1)
def _resolve_az_flavor(neutron_router_id: str, hints: dict[str, dict]) -> tuple[str, str]:
hint = hints.get(neutron_router_id, {})
availability_zone = hint.get("availability_zone") or ""
if not availability_zone:
_require_interactive(neutron_router_id, "availability_zone")
while not availability_zone:
availability_zone = input(f"Router '{neutron_router_id}': enter availability_zone: ").strip()
flavor = hint.get("flavor") or ""
if flavor not in _DC_FLAVORS:
_require_interactive(neutron_router_id, "flavor")
while flavor not in _DC_FLAVORS:
flavor = input(f"Router '{neutron_router_id}': enter flavor ({'/'.join(_DC_FLAVORS)}): ").strip()
return availability_zone, flavor
def _find_internet_network(sprut_client: SprutClient) -> str:
"""Locates Sprut's public network (name "internet"/"Internet"), for
attaching a DC Router's public interface. Looked up once per run,
lazily, only when at least one router actually needs creation.
Deliberately does not resolve or send a subnet_id: Sprut rejects
dc_interface creation with subnet_id set for an external network
("Specifying subnet_id or ip_address for external network is
restricted", confirmed against the real API) -- it auto-assigns one.
"""
networks = sprut_client.get("/networks?limit=10000").get("networks", [])
matches = [n for n in networks if (n.get("name") or "").lower() == "internet"]
if not matches:
print("Error: no Sprut network named 'Internet' found (needed for the public interface).", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(
f"Warning: {len(matches)} Sprut networks named 'Internet' found -- "
f"using the first one ({matches[0].get('id')}).",
file=sys.stderr,
)
return matches[0]["id"]
def _find_or_create_dc_router(
neutron_router_id: str,
name: str,
description: str,
availability_zone: str,
flavor: str,
sprut_client: SprutClient,
existing_dc_routers_by_name: dict[str, str],
) -> str:
sprut_id = existing_dc_routers_by_name.get(name)
if sprut_id:
print(f"DC Router '{name}' already exists in Sprut (id {sprut_id}) -- reusing")
return sprut_id
print(f"Creating DC Router '{name}' in Sprut (az={availability_zone}, flavor={flavor}, enable_snat=true)")
request_body = {
"dc_router": {
"availability_zone": availability_zone,
"flavor": flavor,
"enable_snat": True,
"name": name,
"description": description,
}
}
response = sprut_client.post("/direct_connect/dc_routers", request_body)
sprut_id = (response.get("dc_router") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-{neutron_router_id}"
return sprut_id
def _find_or_create_dc_interface(
dc_router_id: str, network_id: str, name: str, sprut_client: SprutClient
) -> str:
existing = sprut_client.get("/direct_connect/dc_interfaces").get("dc_interfaces", [])
match = next(
(i for i in existing if i.get("dc_router_id") == dc_router_id and i.get("network_id") == network_id),
None,
)
if match:
print(f"Public interface already attached to DC Router {dc_router_id} (id {match.get('id')}) -- reusing")
return match["id"]
print(f"Attaching public interface (network 'Internet') to DC Router {dc_router_id}")
# subnet_id deliberately omitted -- Sprut rejects it for external
# networks (see _find_internet_network's docstring).
request_body = {
"dc_interface": {
"dc_router_id": dc_router_id,
"network_id": network_id,
"name": name,
}
}
response = sprut_client.post("/direct_connect/dc_interfaces", request_body)
sprut_id = (response.get("dc_interface") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-iface-{dc_router_id}"
return sprut_id
def _ensure_dc_router(
neutron_router_id: str,
name: str,
description: str,
availability_zone: str,
flavor: str,
sprut_client: SprutClient,
existing_dc_routers_by_name: dict[str, str],
internet_network_id: str,
) -> str:
"""Creates (or reuses, by name) a DC Router and attaches its public
interface. Shared by the fresh-collect path (STEP 3) and the
--from-audit path, which both need identical create-or-reuse logic."""
sprut_id = _find_or_create_dc_router(
neutron_router_id, name, description, availability_zone, flavor, sprut_client, existing_dc_routers_by_name
)
_find_or_create_dc_interface(sprut_id, internet_network_id, f"{name}-public", sprut_client)
existing_dc_routers_by_name[name] = sprut_id
return sprut_id
# --------------------------------------------------------------------------
# STEP 3: verify every advanced router exists in Sprut, using the Keystone
# token issued once up front by collect(). For CSV rows with a blank
# advanced_router_id, either creates the DC Router now (full run) or just
# records what it would need (--audit-only, which must not write to
# Sprut). Returns the SprutClient constructed with that token, reused by
# STAGE 2-4 (the token is never refreshed after this point).
# --------------------------------------------------------------------------
def check_advanced_routers_exist(
state: MigrationState, token: str, sprut_base_url: str, dry_run: bool, audit_only: bool
) -> SprutClient:
print("Executing STEP 3: Checking Advanced routers")
sprut_client = SprutClient(sprut_base_url, token, dry_run)
dc_routers = sprut_client.get("/direct_connect/dc_routers").get("dc_routers", [])
existing_ids = {entry["id"] for entry in dc_routers}
existing_by_name = {entry["name"]: entry["id"] for entry in dc_routers if entry.get("name")}
internet_network_id: str | None = None
for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items():
if advanced_router_id:
if advanced_router_id not in existing_ids:
print(f"Error: Advanced router ID {advanced_router_id} not found in SDN.")
sys.exit(1)
print(f"Advanced router ID {advanced_router_id} exists in SDN.")
continue
neutron_router = state.neutron_router_by_id.get(neutron_router_id, {})
name = neutron_router.get("name") or neutron_router_id
description = neutron_router.get("description") or ""
availability_zone, flavor = _resolve_az_flavor(neutron_router_id, state.router_creation_hints)
if audit_only:
state.pending_dc_router_by_neutron_id[neutron_router_id] = {
"name": name,
"description": description,
"availability_zone": availability_zone,
"flavor": flavor,
}
print(
f"Neutron router '{neutron_router_id}': no advanced router -- recorded as pending "
"DC Router creation in the audit (no Sprut writes in --audit-only)."
)
continue
if internet_network_id is None:
internet_network_id = _find_internet_network(sprut_client)
state.neutron_to_adv_router[neutron_router_id] = _ensure_dc_router(
neutron_router_id,
name,
description,
availability_zone,
flavor,
sprut_client,
existing_by_name,
internet_network_id,
)
print("STEP 3 complete (Advanced routers checked)")
_rule()
return sprut_client
def recheck_advanced_routers_from_audit(state: MigrationState, sprut_client: SprutClient) -> None:
"""Re-verifies advanced routers recorded in a --from-audit file still
exist in Sprut, since the audit may have been generated earlier. Also
creates any DC Router left pending by a prior --audit-only run, using
the name/description/az/flavor cached in that audit's
"pending_dc_router" block -- Neutron is never re-queried here."""
print("Issuing OpenStack token and re-checking advanced routers in SDN...")
dc_routers = sprut_client.get("/direct_connect/dc_routers").get("dc_routers", [])
existing_ids = {entry["id"] for entry in dc_routers}
existing_by_name = {entry["name"]: entry["id"] for entry in dc_routers if entry.get("name")}
internet_network_id: str | None = None
for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items():
if advanced_router_id:
if advanced_router_id not in existing_ids:
print(
f"Error: Advanced router ID {advanced_router_id} (from audit file) not found in SDN.",
file=sys.stderr,
)
sys.exit(1)
print(f"Advanced router ID {advanced_router_id} exists in SDN.")
continue
pending = state.pending_dc_router_by_neutron_id.get(neutron_router_id)
if not pending:
print(
f"Error: router '{neutron_router_id}' has no advanced_router_id and no "
"pending_dc_router info in the audit file -- file is incompatible or corrupted.",
file=sys.stderr,
)
sys.exit(1)
if internet_network_id is None:
internet_network_id = _find_internet_network(sprut_client)
state.neutron_to_adv_router[neutron_router_id] = _ensure_dc_router(
neutron_router_id,
pending["name"],
pending.get("description", ""),
pending["availability_zone"],
pending["flavor"],
sprut_client,
existing_by_name,
internet_network_id,
)
# --------------------------------------------------------------------------
# STEP 4-6: IPsec policies, IKE policies, Endpoint groups (project-wide).
# One list call each -- Neutron's list endpoints return full objects, so no
# per-item show call is needed (unlike the old CLI-based version).
# --------------------------------------------------------------------------
def collect_ipsec_policies(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 4: Collecting info about IPsec policies")
for details in neutron_client.ipsec_policy_list(nc):
state.neutron_ipsec_policy_by_id[details["id"]] = details
print("Stored IPsec policies:")
for policy_id, details in state.neutron_ipsec_policy_by_id.items():
print(f"Policy ID: {policy_id}")
print(f"Details: {_dump(details)}")
print()
print("STEP 4 complete: Info about IPsec policies stored")
_rule()
def collect_ike_policies(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 5: Collecting info about IKE policies")
for details in neutron_client.ike_policy_list(nc):
state.neutron_ike_policy_by_id[details["id"]] = details
print("Stored IKE policies:")
for policy_id, details in state.neutron_ike_policy_by_id.items():
print(f"Policy ID: {policy_id}")
print(f"Details: {_dump(details)}")
print()
print("STEP 5 complete: Info about IKE policies stored")
_rule()
def collect_endpoint_groups(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 6: Collecting info about Endpoint Groups")
for details in neutron_client.endpoint_group_list(nc):
eg_id = details["id"]
state.neutron_endpoint_group_by_id[eg_id] = details
if details.get("sdn") == "sprut":
print(f"Endpoint Group {eg_id} has sdn 'sprut' with the following network addresses:")
for address in details.get("endpoints") or []:
print(address)
print("Stored Endpoint Groups:")
for eg_id, details in state.neutron_endpoint_group_by_id.items():
print(f"Endpoint Group ID: {eg_id}")
print(f"Details: {_dump(details)}")
print()
print("STEP 6 complete: Info about Endpoint Groups stored")
_rule()
# --------------------------------------------------------------------------
# STEP 7: subnet id -> CIDR map, excluding ext-subnet* names.
# --------------------------------------------------------------------------
def collect_subnets(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 7: Creating a map of Subnet ID to Subnet Address")
for entry in neutron_client.subnet_list(nc):
name = entry.get("name") or ""
if name.startswith("ext-subnet"):
continue
state.subnet_id_to_subnet_address[entry["id"]] = entry.get("cidr")
print("Stored Subnet ID to Subnet Address mapping:")
print_kv_table(state.subnet_id_to_subnet_address, "Subnet ID", "Subnet Address", width1=36, width2=18)
print()
print("STEP 7 complete: Subnet ID to Subnet Address map created")
_rule()
# --------------------------------------------------------------------------
# STEP 8: router <-> vpn service maps. router_id_to_vpn_service_id is
# filtered to in-scope (CSV) routers; the reverse map is deliberately left
# project-wide since STEP 9 needs it to resolve every connection's owning
# router before in-scope narrowing happens.
# --------------------------------------------------------------------------
def collect_vpn_services(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 8: Collecting info about vpn services")
listing = neutron_client.vpn_service_list(nc)
for entry in listing:
router_id = entry["router_id"]
vpn_id = entry["id"]
state.router_id_to_vpn_service_id.setdefault(router_id, vpn_id)
state.neutron_vpn_service_by_id.setdefault(vpn_id, entry)
print("Remove router_ids that are not in config")
for router_id in list(state.router_id_to_vpn_service_id):
if router_id not in state.neutron_to_adv_router:
del state.router_id_to_vpn_service_id[router_id]
print_kv_table(state.router_id_to_vpn_service_id, "Router ID", "VPN service ID")
print("build backwards dictionary")
for entry in listing:
state.vpn_service_id_to_router_id[entry["id"]] = entry["router_id"]
print_kv_table(state.vpn_service_id_to_router_id, "VPN service ID", "Router ID")
print("STEP 8 complete (Router ID to VPN Service ID map built)")
_rule()
# --------------------------------------------------------------------------
# STEP 9: ipsec site connections + router -> connection-ids map.
# --------------------------------------------------------------------------
def collect_ipsec_site_connections(state: MigrationState, nc: NeutronClient) -> None:
print("Executing STEP 9: Collecting info about ipsec")
for details in neutron_client.ipsec_site_connection_list(nc):
conn_id = details["id"]
vpn_service_id = details.get("vpnservice_id")
router_id = state.vpn_service_id_to_router_id.get(vpn_service_id, "")
state.neutron_router_to_ipsec_ids.setdefault(router_id, []).append(conn_id)
state.neutron_connection_by_id[conn_id] = details
print("Stored IPsec connections:")
for conn_id, details in state.neutron_connection_by_id.items():
print(f"ID: {conn_id}")
print(f"Details: {_dump(redact_psk(details))}")
print()
print("Neutron Router to IPsec IDs mapping:")
for router_id, conn_ids in state.neutron_router_to_ipsec_ids.items():
print(f"Router ID: {router_id}")
print(f"IPsec IDs: {', '.join(conn_ids)}")
print("STEP 9 complete: Info about ipsec stored")
_rule()
# --------------------------------------------------------------------------
# In-scope computation: only routers/objects tied to the input CSV are
# actually created in Sprut by STAGE 3/4, even though STEP 4-9 collect
# project-wide (needed for matching against what Sprut already has).
# --------------------------------------------------------------------------
def compute_in_scope(state: MigrationState) -> None:
for neutron_router_id in state.neutron_to_adv_router:
for conn_id in state.neutron_router_to_ipsec_ids.get(neutron_router_id, []):
if not conn_id:
continue
state.in_scope_connection_ids.add(conn_id)
conn_raw = state.neutron_connection_by_id[conn_id]
ike_id = conn_raw.get("ikepolicy_id")
ipsec_id = conn_raw.get("ipsecpolicy_id")
local_eg_id = conn_raw.get("local_ep_group_id")
peer_eg_id = conn_raw.get("peer_ep_group_id")
if ike_id:
state.in_scope_ike_policy_ids.add(ike_id)
if ipsec_id:
state.in_scope_ipsec_policy_ids.add(ipsec_id)
if local_eg_id:
state.in_scope_endpoint_group_ids.add(local_eg_id)
if peer_eg_id:
state.in_scope_endpoint_group_ids.add(peer_eg_id)
print(f"In-scope IPsec site connections for this migration: {len(state.in_scope_connection_ids)}")
# --------------------------------------------------------------------------
# Endpoint-group helpers, shared by STEP 10 (audit assembly) and STAGE 3
# (endpoint-group compare&create).
# --------------------------------------------------------------------------
def resolve_endpoint_addresses(details: dict, subnet_map: dict[str, str]) -> list[str]:
name = details.get("name")
converted: list[str] = []
for endpoint in details.get("endpoints") or []:
if _UUID_LIKE.match(endpoint):
print(f"Converting subnet UUID {endpoint} in {name} endpoints", file=sys.stderr)
address = subnet_map.get(endpoint)
if address:
print(f"Converted to {address}", file=sys.stderr)
converted.append(address)
else:
print(f"Warning: Subnet ID {endpoint} not found in subnet_id_to_subnet_address map.", file=sys.stderr)
else:
converted.append(endpoint)
return converted
def build_endpoint_group_block(raw: dict | None, subnet_map: dict[str, str]) -> dict | None:
if not raw:
return None
resolved = resolve_endpoint_addresses(raw, subnet_map)
return {
"id": raw.get("id"),
"raw": raw,
"resolved_endpoints": resolved,
"already_migrated": raw.get("sdn") == "sprut",
}
# --------------------------------------------------------------------------
# STEP 10: assemble and write the audit JSON.
# --------------------------------------------------------------------------
def to_audit_dict(state: MigrationState, input_file: str) -> dict:
router_blocks = []
for neutron_router_id, advanced_router_id in state.neutron_to_adv_router.items():
vpn_id = state.router_id_to_vpn_service_id.get(neutron_router_id)
vpn_service_json = state.neutron_vpn_service_by_id.get(vpn_id) if vpn_id else None
connection_blocks = []
for conn_id in state.neutron_router_to_ipsec_ids.get(neutron_router_id, []):
if not conn_id:
continue
conn_raw = state.neutron_connection_by_id[conn_id]
ike_id = conn_raw.get("ikepolicy_id")
ipsec_id = conn_raw.get("ipsecpolicy_id")
local_eg_id = conn_raw.get("local_ep_group_id")
peer_eg_id = conn_raw.get("peer_ep_group_id")
ike_json = state.neutron_ike_policy_by_id.get(ike_id)
ipsec_json = state.neutron_ipsec_policy_by_id.get(ipsec_id)
local_eg_raw = state.neutron_endpoint_group_by_id.get(local_eg_id)
peer_eg_raw = state.neutron_endpoint_group_by_id.get(peer_eg_id)
connection_blocks.append(
{
"id": conn_raw.get("id"),
"raw": conn_raw,
"ike_policy": ike_json,
"ipsec_policy": ipsec_json,
"local_endpoint_group": build_endpoint_group_block(local_eg_raw, state.subnet_id_to_subnet_address),
"peer_endpoint_group": build_endpoint_group_block(peer_eg_raw, state.subnet_id_to_subnet_address),
}
)
router_blocks.append(
{
"neutron_router_id": neutron_router_id,
"advanced_router_id": advanced_router_id or None,
"pending_dc_router": state.pending_dc_router_by_neutron_id.get(neutron_router_id),
"vpn_service": vpn_service_json,
"ipsec_site_connections": connection_blocks,
}
)
return {
"audit_metadata": {
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"input_file": input_file,
"stage": "STAGE1_AUDIT",
},
"subnets": dict(state.subnet_id_to_subnet_address),
"routers": router_blocks,
}
def write_audit_json(data: dict, output_file: str) -> None:
tmp_path = f"{output_file}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
try:
with open(tmp_path, encoding="utf-8") as f:
json.load(f)
except (OSError, json.JSONDecodeError):
print("Error: failed to assemble valid JSON audit output", file=sys.stderr)
os.remove(tmp_path)
sys.exit(1)
os.replace(tmp_path, output_file)
os.chmod(output_file, 0o600)
print(f"Audit JSON written to {output_file} (permissions restricted to owner: contains PSK values)")
# --------------------------------------------------------------------------
# --from-audit: reconstruct MigrationState from a previously written audit
# file, without any OpenStack query. Pure (no network I/O) -- token
# reissuance and the Sprut recheck are the orchestration's job (cli.py),
# via recheck_advanced_routers_from_audit() above.
# --------------------------------------------------------------------------
def load_from_audit_json(audit_file: str) -> MigrationState:
print(f"Loading audit data from {audit_file} (skipping OpenStack collection)")
try:
with open(audit_file, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
print(f"Error: '{audit_file}' is not valid JSON.", file=sys.stderr)
sys.exit(1)
schema_stage = (data.get("audit_metadata") or {}).get("stage")
if schema_stage != "STAGE1_AUDIT":
print(
f"Error: '{audit_file}' does not look like a STAGE1 audit file "
"(audit_metadata.stage != STAGE1_AUDIT).",
file=sys.stderr,
)
sys.exit(1)
state = MigrationState()
for subnet_id, subnet_cidr in (data.get("subnets") or {}).items():
if not subnet_id:
continue
state.subnet_id_to_subnet_address[subnet_id] = subnet_cidr
for router_json in data.get("routers") or []:
neutron_router_id = router_json.get("neutron_router_id")
advanced_router_id = router_json.get("advanced_router_id") or ""
if not neutron_router_id:
continue
state.neutron_to_adv_router[neutron_router_id] = advanced_router_id
if not advanced_router_id:
pending = router_json.get("pending_dc_router")
if pending:
state.pending_dc_router_by_neutron_id[neutron_router_id] = pending
vpn_service_json = router_json.get("vpn_service")
if vpn_service_json:
vpn_service_id = vpn_service_json.get("id")
state.router_id_to_vpn_service_id[neutron_router_id] = vpn_service_id
for conn_json in router_json.get("ipsec_site_connections") or []:
conn_id = conn_json.get("id")
if not conn_id:
continue
state.neutron_connection_by_id[conn_id] = conn_json.get("raw")
state.in_scope_connection_ids.add(conn_id)
ike_json = conn_json.get("ike_policy")
if ike_json:
ike_id = ike_json.get("id")
state.neutron_ike_policy_by_id[ike_id] = ike_json
state.in_scope_ike_policy_ids.add(ike_id)
ipsec_json = conn_json.get("ipsec_policy")
if ipsec_json:
ipsec_id = ipsec_json.get("id")
state.neutron_ipsec_policy_by_id[ipsec_id] = ipsec_json
state.in_scope_ipsec_policy_ids.add(ipsec_id)
local_eg_json = conn_json.get("local_endpoint_group")
if local_eg_json:
local_eg_id = local_eg_json.get("id")
state.neutron_endpoint_group_by_id[local_eg_id] = local_eg_json.get("raw")
state.in_scope_endpoint_group_ids.add(local_eg_id)
peer_eg_json = conn_json.get("peer_endpoint_group")
if peer_eg_json:
peer_eg_id = peer_eg_json.get("id")
state.neutron_endpoint_group_by_id[peer_eg_id] = peer_eg_json.get("raw")
state.in_scope_endpoint_group_ids.add(peer_eg_id)
print(
f"Loaded from audit: {len(state.neutron_to_adv_router)} router(s), "
f"{len(state.in_scope_connection_ids)} in-scope IPsec site connection(s)."
)
return state
# --------------------------------------------------------------------------
# Full STAGE 1 orchestration (STEP 1-9 + in-scope computation). STEP 10
# (write) is invoked separately by the caller so --audit-only and the full
# run can share this without duplicating the write step.
# --------------------------------------------------------------------------
def collect(
input_csv: str, sprut_base_url: str, dry_run: bool, audit_only: bool
) -> tuple[MigrationState, SprutClient]:
state = MigrationState()
state.neutron_to_adv_router, state.router_creation_hints = read_input_csv(input_csv)
# Authenticate once, up front: STEP 2 now needs a Neutron token just as
# much as STEP 3 needs one for Sprut, since neither goes through the
# `openstack` CLI's own per-call auth resolution anymore.
token, catalog = keystone_auth.authenticate()
neutron_base_url = keystone_auth.resolve_endpoint(catalog, service_type="network")
nc = NeutronClient(neutron_base_url, token)
check_neutron_routers_exist(state, nc)
sprut_client = check_advanced_routers_exist(state, token, sprut_base_url, dry_run, audit_only)
collect_ipsec_policies(state, nc)
collect_ike_policies(state, nc)
collect_endpoint_groups(state, nc)
collect_subnets(state, nc)
collect_vpn_services(state, nc)
collect_ipsec_site_connections(state, nc)
compute_in_scope(state)
return state, sprut_client
+153
View File
@@ -0,0 +1,153 @@
"""Argument parsing and top-level orchestration of STAGE 1-4.
Mirrors the bash original's literal top-to-bottom execution order: banner
and the "STAGE 1: Collecting information" header print before anything
else, then argument parsing, then mode dispatch. The `openstack` CLI
binary check that used to run here is gone -- OpenStack access is now
direct Neutron/Keystone REST calls (`keystone_auth.py`, `neutron_client.py`),
so no external binary is required at all.
"""
from __future__ import annotations
import argparse
import os
import sys
from . import audit, keystone_auth, sync
from .constants import BANNER, SPRUT_API_BASE, USAGE
from .sprut_client import SprutClient
def print_banner() -> None:
print(BANNER)
def print_usage() -> None:
print(USAGE)
def _rule() -> None:
print("*" * 86)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--dry-run", action="store_true", dest="dry_run")
parser.add_argument("--audit-only", action="store_true", dest="audit_only")
parser.add_argument("--from-audit", dest="from_audit", default=None)
parser.add_argument("-h", "--help", action="store_true", dest="help")
parser.add_argument("positional", nargs="*")
args = parser.parse_args(argv)
if args.help:
print_usage()
sys.exit(0)
audit_only_seen = args.audit_only
from_audit_seen = args.from_audit is not None
if audit_only_seen and from_audit_seen:
print("Error: --audit-only and --from-audit are mutually exclusive.", file=sys.stderr)
sys.exit(1)
if from_audit_seen:
mode = "from-audit"
elif audit_only_seen:
mode = "audit-only"
else:
mode = "full"
input_csv = None
output_file = None
if mode == "from-audit":
if not args.from_audit:
print("Error: --from-audit requires a path to an audit JSON file.", file=sys.stderr)
print_usage()
sys.exit(1)
if not os.path.isfile(args.from_audit):
print(f"Error: audit file '{args.from_audit}' not found.", file=sys.stderr)
sys.exit(1)
else:
if not args.positional:
print("Error: No input file provided.")
print_usage()
sys.exit(1)
input_csv = args.positional[0]
if len(args.positional) > 1:
output_file = args.positional[1]
return argparse.Namespace(
mode=mode,
dry_run=args.dry_run,
from_audit=args.from_audit,
input_csv=input_csv,
output_file=output_file,
)
def _default_output_file() -> str:
from datetime import datetime
return f"vpnaas_audit_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
def main(argv: list[str] | None = None) -> int:
# Python buffers stdout in blocks (not line-by-line) whenever it isn't a
# TTY, while stderr stays unbuffered -- under redirection (e.g. `2>&1 |
# tee log`) that visibly reorders stdout/stderr lines relative to each
# other. Bash's `echo` has no such issue (each call is its own unbuffered
# write), so line-buffer stdout here to keep interleaving faithful.
sys.stdout.reconfigure(line_buffering=True)
print_banner()
print("{====================STAGE 1: Collecting information====================}")
print()
print()
args = parse_args(argv if argv is not None else sys.argv[1:])
if args.dry_run:
print("Running in --dry-run mode: no objects will be created in Sprut.")
if args.mode == "from-audit":
state = audit.load_from_audit_json(args.from_audit)
token, _catalog = keystone_auth.authenticate()
sprut_client = SprutClient(SPRUT_API_BASE, token, args.dry_run)
audit.recheck_advanced_routers_from_audit(state, sprut_client)
else:
state, sprut_client = audit.collect(
args.input_csv, SPRUT_API_BASE, args.dry_run, audit_only=(args.mode == "audit-only")
)
output_file = args.output_file or _default_output_file()
print(f"Executing STEP 10: Writing audit JSON to {output_file}")
audit_data = audit.to_audit_dict(state, args.input_csv)
audit.write_audit_json(audit_data, output_file)
print("STEP 10 complete: audit JSON exported")
_rule()
print("{====================STAGE 1: COMPLETE====================}")
print()
if args.mode == "audit-only":
print("Mode --audit-only: stopping after STAGE 1 (Sprut configuration stages skipped).")
return 0
print("{====================STAGE 2: Collecting Existing Sprut Objects====================}")
sprut_state = sync.collect_sprut_state(sprut_client)
print("STAGE 2 complete: Sprut objects collected")
_rule()
print("{====================STAGE 3: Comparing and Creating Missing Objects in Sprut====================}")
correspondence = sync.compare_and_create_all(state, sprut_client, sprut_state)
print("STAGE 3 complete: Missing Sprut objects created")
_rule()
print("{====================STAGE 4: Creating IPsec Site Connections in Sprut====================}")
sync.create_ipsec_site_connections(state, sprut_client, sprut_state, correspondence)
print("STAGE 4 complete: IPsec site connections created in Sprut")
_rule()
return 0
+51
View File
@@ -0,0 +1,51 @@
"""Fixed text constants: banner, usage text, Sprut API base URL. Reproduced
verbatim from the bash original (ipsec_migrator_v2.sh) except for the
invocation name, which is "python -m ipsec_migrator" here instead of "$0"
since this port has no console-script entry point.
"""
SPRUT_API_BASE = "https://infra.mail.ru:9696/v2.0"
PROG = "python -m ipsec_migrator"
BANNER = r"""
██╗██████╗ ███████╗███████╗ ██████╗ ██╗ ██╗██████╗ ███╗ ██╗
██║██╔══██╗██╔════╝██╔════╝██╔════╝ ██║ ██║██╔══██╗████╗ ██║
██║██████╔╝███████╗█████╗ ██║ ██║ ██║██████╔╝██╔██╗ ██║
██║██╔═══╝ ╚════██║██╔══╝ ██║ ╚██╗ ██╔╝██╔═══╝ ██║╚██╗██║
██║██║ ███████║███████╗╚██████╗ ╚████╔╝ ██║ ██║ ╚████║
╚═╝╚═╝ ╚══════╝╚══════╝ ╚═════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═══╝
███╗ ███╗██╗ ██████╗ ██████╗ █████╗ ████████╗██╗ ██████╗ ███╗ ██╗ ███████╗ ██████╗██████╗ ██╗██████╗ ████████╗
████╗ ████║██║██╔════╝ ██╔══██╗██╔══██╗╚══██╔══╝██║██╔═══██╗████╗ ██║ ██╔════╝██╔════╝██╔══██╗██║██╔══██╗╚══██╔══╝
██╔████╔██║██║██║ ███╗██████╔╝███████║ ██║ ██║██║ ██║██╔██╗ ██║ ███████╗██║ ██████╔╝██║██████╔╝ ██║
██║╚██╔╝██║██║██║ ██║██╔══██╗██╔══██║ ██║ ██║██║ ██║██║╚██╗██║ ╚════██║██║ ██╔══██╗██║██╔═══╝ ██║
██║ ╚═╝ ██║██║╚██████╔╝██║ ██║██║ ██║ ██║ ██║╚██████╔╝██║ ╚████║ ███████║╚██████╗██║ ██║██║██║ ██║
╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝
Input file format:
<Nautron_Router_UUID>,<DC_Router_UUID>,<AZ_Name>,<Flavor_Name>
XXXX6d56-b2dc-4c83-b96f-e4e28af6XXXX,,MS1,standard
where DC_Router_UUID - optional (script will create a new one (DC_Router) if UUID won't be provided)
"""
USAGE = f"""
Usage:
Prepare input CSV file then use option:
1. Full run (audit Neutron + configure Sprut):
{PROG} <input.csv> [output.json] [--dry-run]
2. Audit only (collect Neutron VPNaaS config, write JSON, skip Sprut entirely):
{PROG} --audit-only <input.csv> [output.json]
3. Configure Sprut from a previously generated audit JSON (no OpenStack re-query):
{PROG} --from-audit <audit.json> [--dry-run]
"""
+129
View File
@@ -0,0 +1,129 @@
"""Keystone v3 password authentication via `requests`, replacing the
`openstack token issue` subprocess call.
Mirrors the auth flow the `openstack` CLI itself performs when reading
`OS_*` environment variables: POST a project-scoped password auth request
to `{OS_AUTH_URL}/auth/tokens`, read the token back from the
`X-Subject-Token` response header, and keep the returned service catalog
around so the Neutron endpoint can be resolved from it (same as the CLI
does) instead of being hardcoded.
No `clouds.yaml` support -- the only auth pattern actually in use here
(`test_openrc.sh`) is password auth via `OS_*` env vars.
"""
from __future__ import annotations
import os
import sys
import requests
_REQUIRED = ("OS_AUTH_URL", "OS_USERNAME", "OS_PASSWORD")
def _env(name: str) -> str | None:
value = os.environ.get(name)
return value if value else None
def _user_domain() -> dict:
if domain_id := _env("OS_USER_DOMAIN_ID"):
return {"id": domain_id}
return {"name": _env("OS_USER_DOMAIN_NAME") or "Default"}
def _project_scope() -> dict:
if project_id := _env("OS_PROJECT_ID"):
return {"id": project_id}
project_name = _env("OS_PROJECT_NAME")
if not project_name:
print(
"Error: neither OS_PROJECT_ID nor OS_PROJECT_NAME is set.",
file=sys.stderr,
)
sys.exit(1)
if project_domain_id := _env("OS_PROJECT_DOMAIN_ID"):
domain = {"id": project_domain_id}
else:
domain = {"name": _env("OS_PROJECT_DOMAIN_NAME") or "Default"}
return {"name": project_name, "domain": domain}
def authenticate() -> tuple[str, list[dict]]:
"""Returns (token, service_catalog)."""
missing = [name for name in _REQUIRED if not _env(name)]
if missing:
print(f"Error: required environment variable(s) not set: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
auth_url = _env("OS_AUTH_URL").rstrip("/")
body = {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": _env("OS_USERNAME"),
"domain": _user_domain(),
"password": _env("OS_PASSWORD"),
}
},
},
"scope": {"project": _project_scope()},
}
}
try:
response = requests.post(f"{auth_url}/auth/tokens", json=body, timeout=30.0)
except requests.exceptions.RequestException as exc:
print(f"Error: Keystone authentication request failed: {exc}", file=sys.stderr)
sys.exit(1)
if not 200 <= response.status_code < 300:
print(f"Error: Keystone authentication failed (HTTP {response.status_code})", file=sys.stderr)
print(f"Response: {response.text}", file=sys.stderr)
sys.exit(1)
token = response.headers.get("X-Subject-Token")
if not token:
print("Error: Keystone response did not include X-Subject-Token.", file=sys.stderr)
sys.exit(1)
catalog = (response.json().get("token") or {}).get("catalog") or []
return token, catalog
def resolve_endpoint(catalog: list[dict], service_type: str = "network") -> str:
interface = _env("OS_INTERFACE") or "public"
region = _env("OS_REGION_NAME")
service = next((entry for entry in catalog if entry.get("type") == service_type), None)
if service is None:
print(f"Error: service type '{service_type}' not found in Keystone catalog.", file=sys.stderr)
sys.exit(1)
candidates = [ep for ep in service.get("endpoints", []) if ep.get("interface") == interface]
if region:
region_matches = [ep for ep in candidates if ep.get("region") == region]
if region_matches:
candidates = region_matches
if not candidates:
print(
f"Error: no '{interface}' endpoint for service type '{service_type}' in Keystone catalog"
+ (f" (region {region})" if region else ""),
file=sys.stderr,
)
sys.exit(1)
url = candidates[0]["url"].rstrip("/")
# Neutron's catalog entry is registered with the /v2.0 suffix already on
# some clouds and without it on others; neutron_client.py always adds
# /v2.0/... itself, so strip a pre-existing suffix here to avoid
# double-versioned URLs (.../v2.0/v2.0/routers).
if service_type == "network" and (url.endswith("/v2.0") or url.endswith("/v2")):
url = url.rsplit("/", 1)[0]
return url
+53
View File
@@ -0,0 +1,53 @@
"""In-memory state shared between STAGE 1 collection/loading and STAGE 3/4.
Replaces the bash original's `declare -gA` global associative-array soup
with one explicit, typed object. Populated either by audit.collect() (real
OpenStack/Sprut queries) or audit.load_from_audit_json() (parsing a
previously written audit file) -- both paths produce the same shape so
STAGE 3/4 don't need to know which one ran.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class MigrationState:
neutron_to_adv_router: dict[str, str] = field(default_factory=dict)
subnet_id_to_subnet_address: dict[str, str] = field(default_factory=dict)
# STEP 2: full Neutron router objects (id -> raw dict), so a router
# whose advanced_router_id is blank in the CSV can be auto-created in
# Sprut using this router's own name/description, without a second
# per-item show call.
neutron_router_by_id: dict[str, dict] = field(default_factory=dict)
# STEP 1: optional availability_zone/flavor CSV columns (3rd/4th),
# keyed by neutron_router_id, only present for rows whose
# advanced_router_id was blank.
router_creation_hints: dict[str, dict] = field(default_factory=dict)
# STEP 3 (collect()) or --from-audit load: resolved name/description/
# availability_zone/flavor for routers still awaiting DC Router
# creation. Populated either freshly (collect()) or from a previously
# written audit.json's "pending_dc_router" block (--from-audit).
pending_dc_router_by_neutron_id: dict[str, dict] = field(default_factory=dict)
neutron_ike_policy_by_id: dict[str, dict] = field(default_factory=dict)
neutron_ipsec_policy_by_id: dict[str, dict] = field(default_factory=dict)
neutron_endpoint_group_by_id: dict[str, dict] = field(default_factory=dict)
# STEP 8: filtered to in-scope (CSV) routers only.
router_id_to_vpn_service_id: dict[str, str] = field(default_factory=dict)
# STEP 8: reverse map, deliberately NOT filtered -- stays project-wide
# because STEP 9 needs it to resolve every connection's owning router
# before in-scope narrowing happens. Do not filter this one.
vpn_service_id_to_router_id: dict[str, str] = field(default_factory=dict)
neutron_vpn_service_by_id: dict[str, dict] = field(default_factory=dict)
# STEP 9: router id -> list of ipsec site connection ids it owns.
neutron_router_to_ipsec_ids: dict[str, list[str]] = field(default_factory=dict)
neutron_connection_by_id: dict[str, dict] = field(default_factory=dict)
in_scope_connection_ids: set[str] = field(default_factory=set)
in_scope_ike_policy_ids: set[str] = field(default_factory=set)
in_scope_ipsec_policy_ids: set[str] = field(default_factory=set)
in_scope_endpoint_group_ids: set[str] = field(default_factory=set)
+109
View File
@@ -0,0 +1,109 @@
"""REST client for the Neutron API, replacing the `openstack` CLI subprocess
wrapper (`openstack_client.py`, removed).
Mirrors `SprutClient`'s shape: a single `requests.Session`, token/base URL
fixed at construction, `.get()`/`.post()` raising a clear error and exiting
on a non-2xx response.
Every list function below does exactly one GET and returns the collection
unwrapped -- no per-item `show` call. Unlike the `openstack` CLI's table
output (which truncates to a handful of display columns unless `--long` is
given), Neutron's list endpoints return the full attribute set for every
item by default, so the per-item `show` calls the CLI-based version needed
are redundant here.
"""
from __future__ import annotations
import sys
from typing import Any
import requests
class NeutronClient:
def __init__(self, base_url: str, token: str, timeout: float = 30.0):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update({"X-Auth-Token": token})
def call(self, method: str, path: str, data: dict | None = None) -> Any:
url = f"{self.base_url}{path}"
try:
response = self._session.request(
method, url, json=data if data is not None else None, timeout=self.timeout
)
except requests.exceptions.RequestException as exc:
print(f"Error: Neutron API {method} {url} failed: {exc}", file=sys.stderr)
sys.exit(1)
if not 200 <= response.status_code < 300:
print(f"Error: Neutron API {method} {url} returned HTTP {response.status_code}", file=sys.stderr)
print(f"Response: {response.text}", file=sys.stderr)
sys.exit(1)
if not response.text:
return {}
return response.json()
def get(self, path: str) -> Any:
return self.call("GET", path)
def get_absolute(self, url: str) -> Any:
"""Like get(), but for an already-absolute URL (pagination "next"
links come back as full URLs, not paths relative to base_url)."""
try:
response = self._session.get(url, timeout=self.timeout)
except requests.exceptions.RequestException as exc:
print(f"Error: Neutron API GET {url} failed: {exc}", file=sys.stderr)
sys.exit(1)
if not 200 <= response.status_code < 300:
print(f"Error: Neutron API GET {url} returned HTTP {response.status_code}", file=sys.stderr)
print(f"Response: {response.text}", file=sys.stderr)
sys.exit(1)
return response.json() if response.text else {}
def list_all(self, path: str, collection_key: str) -> list[dict]:
"""GETs a collection and follows Neutron's pagination "next" links
(`<collection_key>_links` with `rel: next`) until exhausted. Plain
deployments without pagination enabled just return one page with no
`_links` key, so this is a no-op there."""
items: list[dict] = []
body = self.get(path)
while True:
items.extend(body.get(collection_key, []))
links = body.get(f"{collection_key}_links", [])
next_url = next((link["href"] for link in links if link.get("rel") == "next"), None)
if not next_url:
return items
body = self.get_absolute(next_url)
def router_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/routers", "routers")
def subnet_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/subnets", "subnets")
def ike_policy_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/vpn/ikepolicies", "ikepolicies")
def ipsec_policy_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/vpn/ipsecpolicies", "ipsecpolicies")
def endpoint_group_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/vpn/endpoint-groups", "endpoint_groups")
def vpn_service_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/vpn/vpnservices", "vpnservices")
def ipsec_site_connection_list(client: NeutronClient) -> list[dict]:
return client.list_all("/v2.0/vpn/ipsec-site-connections", "ipsec_site_connections")
+24
View File
@@ -0,0 +1,24 @@
"""PSK redaction for safe logging.
Walks any nested dict/list structure and blanks out the value of any dict
key literally named "psk" (Neutron's and Sprut's shared field name) when
that value is not null. Used only for logging -- the real value is always
used wherever the original object is read for actual API calls/storage.
"""
from __future__ import annotations
from typing import Any
_PSK_KEYS = {"psk"}
_REDACTED = "***REDACTED***"
def redact_psk(value: Any) -> Any:
if isinstance(value, dict):
return {
key: (_REDACTED if key in _PSK_KEYS and val is not None else redact_psk(val))
for key, val in value.items()
}
if isinstance(value, list):
return [redact_psk(item) for item in value]
return value
+75
View File
@@ -0,0 +1,75 @@
"""REST client for the Sprut SDN API.
Mirrors sprut_api_call() from the bash original. Token, dry-run flag and
base URL are fixed at construction (replacing the bash globals $token /
$DRY_RUN / $sprut_api_base read by every call site) and a single
requests.Session is reused across all calls for connection reuse -- a
behaviorally-neutral efficiency improvement over one curl process per call.
The only deliberate behavioral deviation from the bash original (confirmed
with the user): a 30s timeout is applied to every HTTP call. curl in the
original has no --max-time at all, so a hung Sprut endpoint would hang the
whole script indefinitely; a timeout here is treated exactly like a non-2xx
response (error message to stderr, exit 1), so no new failure mode is
introduced, just an upper bound on how long a stuck call can hang.
The Keystone token is never refreshed after construction -- this directly
carries over the "token not refreshed across STAGE 2-4" known limitation
from the bash original. Do not add retry-on-401 here.
"""
from __future__ import annotations
import json
import sys
import requests
from .redact import redact_psk
class SprutClient:
def __init__(self, base_url: str, token: str, dry_run: bool, timeout: float = 30.0):
self.base_url = base_url.rstrip("/")
self.token = token
self.dry_run = dry_run
self.timeout = timeout
self._session = requests.Session()
self._session.headers.update(
{
"Content-Type": "application/json",
"X-Auth-Token": token,
"X-SDN": "SPRUT",
}
)
def call(self, method: str, path: str, data: dict | None = None) -> dict:
url = f"{self.base_url}{path}"
if self.dry_run and method != "GET":
print(f"[DRY-RUN] Would {method} {url}", file=sys.stderr)
if data:
print(f"[DRY-RUN] Body: {json.dumps(redact_psk(data), indent=2)}", file=sys.stderr)
return {}
try:
response = self._session.request(
method, url, json=data if data is not None else None, timeout=self.timeout
)
except requests.exceptions.RequestException as exc:
print(f"Error: Sprut API {method} {url} failed: {exc}", file=sys.stderr)
sys.exit(1)
if not 200 <= response.status_code < 300:
print(f"Error: Sprut API {method} {url} returned HTTP {response.status_code}", file=sys.stderr)
print(f"Response: {response.text}", file=sys.stderr)
sys.exit(1)
if not response.text:
return {}
return response.json()
def get(self, path: str) -> dict:
return self.call("GET", path)
def post(self, path: str, data: dict) -> dict:
return self.call("POST", path, data)
+332
View File
@@ -0,0 +1,332 @@
"""STAGE 2-4: collecting existing Sprut objects, comparing/creating missing
IKE/IPsec policies, endpoint groups and VPN services, and finally creating
IPsec site connections.
STAGE 3's four compare_and_create_* functions each return a
neutron_id -> sprut_id correspondence dict (replacing the bash originals'
`declare -A neutron_to_sprut_*` globals). STAGE 4 looks these up by the
raw connection's referenced IDs to build its request body.
"""
from __future__ import annotations
import json
from . import audit
from .models import MigrationState
from .redact import redact_psk
from .sprut_client import SprutClient
from .tables import print_kv_table
def _dump(obj) -> str:
return json.dumps(obj, indent=2)
def _jq_r_str(value) -> str:
"""Mirrors `jq -r`: a JSON null stringifies to the 4-char string "null"
(not Python None/absence), a JSON boolean stringifies to lowercase
"true"/"false" (not Python's "True"/"False" -- confirmed against a real
tenant, where Neutron's ipsec site connection ".State" is a genuine JSON
boolean), everything else via str(). STAGE 4's request body in the bash
original is built entirely with `jq -n --arg`, which means every field
-- even ones that look numeric/boolean -- is sent to Sprut as a JSON
string. Do not "fix" this to native types."""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
# --------------------------------------------------------------------------
# STAGE 2: collect existing Sprut objects (always real GETs, even in
# dry-run -- dry-run only suppresses non-GET calls).
# --------------------------------------------------------------------------
def collect_sprut_state(sprut_client: SprutClient) -> dict:
print("Collecting IKE policies from Sprut...")
ike_policies = sprut_client.get("/vpn/ikepolicies")
print("IKE policies collected:")
print(_dump(ike_policies))
print()
print("Collecting IPsec policies from Sprut...")
ipsec_policies = sprut_client.get("/vpn/ipsecpolicies")
print("IPsec policies collected:")
print(_dump(ipsec_policies))
print()
print("Collecting Endpoint Groups from Sprut...")
endpoint_groups = sprut_client.get("/vpn/endpoint-groups")
print("Endpoint Groups collected:")
print(_dump(endpoint_groups))
print()
print("Collecting VPN services from Sprut...")
vpn_services = sprut_client.get("/vpn/vpnservices")
print("VPN services collected:")
print(_dump(vpn_services))
print()
print("Collecting IPsec site connections from Sprut...")
ipsec_site_connections = sprut_client.get("/vpn/ipsec-site-connections")
print("IPsec site connections collected:")
print(_dump(redact_psk(ipsec_site_connections)))
print()
return {
"ike_policies": ike_policies,
"ipsec_policies": ipsec_policies,
"endpoint_groups": endpoint_groups,
"vpn_services": vpn_services,
"ipsec_site_connections": ipsec_site_connections,
}
# --------------------------------------------------------------------------
# STAGE 3: compare & create missing objects. Each function is idempotent by
# whatever key Sprut exposes (name, router_id, or exact endpoints array).
# --------------------------------------------------------------------------
def compare_and_create_ike_policies(
state: MigrationState, sprut_client: SprutClient, sprut_ike_policies: dict
) -> dict[str, str]:
mapping: dict[str, str] = {}
for neutron_id in state.in_scope_ike_policy_ids:
neutron_policy = state.neutron_ike_policy_by_id[neutron_id]
name = neutron_policy.get("name")
sprut_id = next(
(p.get("id") for p in sprut_ike_policies.get("ikepolicies", []) if p.get("name") == name),
None,
)
if not sprut_id:
print(f"Creating IKE policy '{name}' in Sprut")
request_body = {
"ikepolicy": {
"name": neutron_policy.get("name"),
"phase1_negotiation_mode": neutron_policy.get("phase1_negotiation_mode"),
"auth_algorithm": neutron_policy.get("auth_algorithm"),
"encryption_algorithm": neutron_policy.get("encryption_algorithm"),
"pfs": neutron_policy.get("pfs"),
"lifetime": neutron_policy.get("lifetime"),
"ike_version": neutron_policy.get("ike_version"),
}
}
response = sprut_client.post("/vpn/ikepolicies", request_body)
sprut_id = (response.get("ikepolicy") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-{neutron_id}"
else:
print(f"IKE policy '{name}' already exists in Sprut")
mapping[neutron_id] = sprut_id
return mapping
def compare_and_create_ipsec_policies(
state: MigrationState, sprut_client: SprutClient, sprut_ipsec_policies: dict
) -> dict[str, str]:
mapping: dict[str, str] = {}
for neutron_id in state.in_scope_ipsec_policy_ids:
neutron_policy = state.neutron_ipsec_policy_by_id[neutron_id]
name = neutron_policy.get("name")
sprut_id = next(
(p.get("id") for p in sprut_ipsec_policies.get("ipsecpolicies", []) if p.get("name") == name),
None,
)
if not sprut_id:
print(f"Creating IPsec policy '{name}' in Sprut")
request_body = {
"ipsecpolicy": {
"name": neutron_policy.get("name"),
"transform_protocol": neutron_policy.get("transform_protocol"),
"auth_algorithm": neutron_policy.get("auth_algorithm"),
"encryption_algorithm": neutron_policy.get("encryption_algorithm"),
"encapsulation_mode": neutron_policy.get("encapsulation_mode"),
"pfs": neutron_policy.get("pfs"),
"lifetime": neutron_policy.get("lifetime"),
}
}
response = sprut_client.post("/vpn/ipsecpolicies", request_body)
sprut_id = (response.get("ipsecpolicy") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-{neutron_id}"
else:
print(f"IPsec policy '{name}' already exists in Sprut")
mapping[neutron_id] = sprut_id
return mapping
def compare_and_create_endpoint_groups(
state: MigrationState, sprut_client: SprutClient, sprut_endpoint_groups: dict
) -> dict[str, str]:
mapping: dict[str, str] = {}
for neutron_id in state.in_scope_endpoint_group_ids:
neutron_group = state.neutron_endpoint_group_by_id[neutron_id]
name = neutron_group.get("name")
converted = audit.resolve_endpoint_addresses(neutron_group, state.subnet_id_to_subnet_address)
print(f"Total converted UUIDs in endpoints: {', '.join(converted)}")
matching = next(
(g for g in sprut_endpoint_groups.get("endpoint_groups", []) if g.get("endpoints") == converted),
None,
)
sprut_id = matching.get("id") if matching else None
print(f"Comparing Neutron endpoint group '{name}' with endpoints: {', '.join(converted)}")
if matching:
print(f" -> Found corresponding Sprut endpoint group with endpoints: {matching.get('endpoints')}")
else:
print(" -> No corresponding Sprut endpoint group found for these endpoints")
if not sprut_id:
print(f"Creating Endpoint Group '{name}' in Sprut")
request_body = {"endpoint_group": {"name": name, "endpoints": converted, "type": "cidr"}}
print(f"Request body: {json.dumps(request_body)}")
response = sprut_client.post("/vpn/endpoint-groups", request_body)
sprut_id = (response.get("endpoint_group") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-{neutron_id}"
print(f"Created Sprut Endpoint group: {sprut_id}")
else:
print(f"Endpoint Group with matching endpoints already exists in Sprut with id {sprut_id}")
mapping[neutron_id] = sprut_id
print()
return mapping
def compare_and_create_vpn_services(
state: MigrationState, sprut_client: SprutClient, sprut_vpn_services: dict
) -> dict[str, str]:
mapping: dict[str, str] = {}
for router_id, vpn_service_id in state.router_id_to_vpn_service_id.items():
advanced_router_id = state.neutron_to_adv_router.get(router_id)
sprut_id = next(
(
s.get("id")
for s in sprut_vpn_services.get("vpnservices", [])
if s.get("router_id") == advanced_router_id
),
None,
)
if not sprut_id:
print(f"Creating VPN Service for router '{router_id}' in Sprut")
request_body = {"vpnservice": {"router_id": advanced_router_id, "admin_state_up": True}}
response = sprut_client.post("/vpn/vpnservices", request_body)
sprut_id = (response.get("vpnservice") or {}).get("id")
if sprut_client.dry_run and not sprut_id:
sprut_id = f"DRY-RUN-{router_id}"
else:
print(f"VPN Service for router '{router_id}' already exists in Sprut")
# Keyed by the neutron VPN-service id (not the router id) -- STAGE 4
# looks this map up by the vpn-service id pulled off each connection.
mapping[vpn_service_id] = sprut_id
return mapping
def compare_and_create_all(
state: MigrationState, sprut_client: SprutClient, sprut_state: dict
) -> dict[str, dict[str, str]]:
ike_map = compare_and_create_ike_policies(state, sprut_client, sprut_state["ike_policies"])
print_kv_table(ike_map, "Neutron IKE Policy ID", "Sprut IKE Policy ID", title="Neutron to Sprut IKE Policies")
ipsec_map = compare_and_create_ipsec_policies(state, sprut_client, sprut_state["ipsec_policies"])
print_kv_table(
ipsec_map, "Neutron IPsec Policy ID", "Sprut IPsec Policy ID", title="Neutron to Sprut IPsec Policies"
)
endpoint_group_map = compare_and_create_endpoint_groups(state, sprut_client, sprut_state["endpoint_groups"])
print_kv_table(
endpoint_group_map,
"Neutron Endpoint Group ID",
"Sprut Endpoint Group ID",
title="Neutron to Sprut Endpoint Groups",
)
vpn_service_map = compare_and_create_vpn_services(state, sprut_client, sprut_state["vpn_services"])
print_kv_table(
vpn_service_map, "Neutron VPN Service ID", "Sprut VPN Service ID", title="Neutron to Sprut VPN Services"
)
return {
"ike_policy": ike_map,
"ipsec_policy": ipsec_map,
"endpoint_group": endpoint_group_map,
"vpn_service": vpn_service_map,
}
# --------------------------------------------------------------------------
# STAGE 4: create IPsec site connections, idempotent by name.
# --------------------------------------------------------------------------
def create_ipsec_site_connections(
state: MigrationState,
sprut_client: SprutClient,
sprut_state: dict,
correspondence: dict[str, dict[str, str]],
) -> None:
sprut_connections = sprut_state["ipsec_site_connections"]
for conn_id in state.in_scope_connection_ids:
details = state.neutron_connection_by_id[conn_id]
name = details.get("name")
print(f"Processing IPsec site connection ID: {conn_id}")
print("IPsec site connection details:")
print(_dump(redact_psk(details)))
existing_id = next(
(
c.get("id")
for c in (sprut_connections.get("ipsec_site_connections") or [])
if c.get("name") == name
),
None,
)
if existing_id:
print(f"IPsec site connection '{name}' already exists in Sprut (id {existing_id}) — skipping")
print()
continue
sprut_ipsecpolicy_id = correspondence["ipsec_policy"].get(details.get("ipsecpolicy_id"))
sprut_ikepolicy_id = correspondence["ike_policy"].get(details.get("ikepolicy_id"))
sprut_local_ep_group_id = correspondence["endpoint_group"].get(details.get("local_ep_group_id"))
sprut_peer_ep_group_id = correspondence["endpoint_group"].get(details.get("peer_ep_group_id"))
sprut_vpn_service_id = correspondence["vpn_service"].get(details.get("vpnservice_id"))
# Every field below is stringified (jq --arg semantics in the bash
# original) -- do not switch mtu/admin_state_up to native types.
request_body = {
"ipsec_site_connection": {
"psk": _jq_r_str(details.get("psk")),
"initiator": _jq_r_str(details.get("initiator")),
"ipsecpolicy_id": _jq_r_str(sprut_ipsecpolicy_id),
"admin_state_up": _jq_r_str(details.get("admin_state_up")),
"mtu": _jq_r_str(details.get("mtu")),
"peer_ep_group_id": _jq_r_str(sprut_peer_ep_group_id),
"ikepolicy_id": _jq_r_str(sprut_ikepolicy_id),
"vpnservice_id": _jq_r_str(sprut_vpn_service_id),
"local_ep_group_id": _jq_r_str(sprut_local_ep_group_id),
"peer_address": _jq_r_str(details.get("peer_address")),
"peer_id": _jq_r_str(details.get("peer_id")),
"name": _jq_r_str(name),
}
}
print(f"Creating IPsec site connection '{name}' in Sprut")
print("Executing request with body:")
print(_dump(redact_psk(request_body)))
response = sprut_client.post("/vpn/ipsec-site-connections", request_body)
print("API response:")
print(_dump(redact_psk(response)))
print()
+43
View File
@@ -0,0 +1,43 @@
"""ASCII key/value table printing.
Replaces the bash original's ad-hoc `echo`/`printf` table blocks (STEP 1, 7,
8) and the nameref-based print_map_as_table() helper (STAGE 3) with a single
generic function. Python has no need for bash's nameref indirection -- the
dict is just passed directly.
The bash version used a Unicode combining-overline row as an ersatz bottom
border; that's purely decorative with no functional role, so it's replaced
here with a plain ASCII divider of matching visual weight -- a deliberate,
low-risk cosmetic judgment call, not a fidelity gap.
"""
from __future__ import annotations
from collections.abc import Iterable
def print_kv_table(
rows: dict[str, str] | Iterable[tuple[str, str]],
col1: str,
col2: str,
title: str | None = None,
width1: int = 36,
width2: int = 36,
) -> None:
items = list(rows.items()) if isinstance(rows, dict) else list(rows)
border = "=" * (width1 + width2 + 7)
if title:
print(border)
print(title)
print(border)
else:
print("_" * (width1 + width2 + 7))
print(f"| {col1:<{width1}} | {col2:<{width2}} |")
print("-" * (width1 + width2 + 7))
for key, value in items:
print(f"| {key:<{width1}} | {value:<{width2}} |")
print("-" * (width1 + width2 + 7))
if title:
print()