diff --git a/adopt-one.sh b/adopt-one.sh index 5ecb6fe..e7e1a7d 100644 --- a/adopt-one.sh +++ b/adopt-one.sh @@ -2,17 +2,27 @@ set -Eeuo pipefail usage() { - printf 'Usage: %s CLUSTER:VMID MANIFEST_SHA256\n' "${0##*/}" >&2 - printf 'Example: %s p3-cluster03:110 %s\n' "${0##*/}" "$(printf 'a%.0s' {1..64})" >&2 + printf 'Usage: %s CLUSTER:VMID MANIFEST_SHA256 [netN=IPv4 ...]\n' "${0##*/}" >&2 + printf 'Example: %s p3-cluster03:110 %s net0=192.0.2.10\n' \ + "${0##*/}" "$(printf 'a%.0s' {1..64})" >&2 } -if [[ $# -ne 2 ]]; then +if [[ $# -lt 2 ]]; then usage exit 2 fi proxmox_id=$1 manifest_sha256=$2 +shift 2 +network_ip_args=() +for network_ip in "$@"; do + if [[ ! $network_ip =~ ^net[0-9]+=([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then + printf 'adoption_stop=network_ip_must_be_net_device_equals_ipv4\n' >&2 + exit 2 + fi + network_ip_args+=(--network-ip "$network_ip") +done if [[ ! $proxmox_id =~ ^[^[:space:]:]+:[1-9][0-9]*$ ]]; then printf 'adoption_stop=proxmox_id_must_be_canonical_cluster_colon_vmid\n' >&2 @@ -75,6 +85,12 @@ printf 'deployed_revision=%s\n' "$head_revision" printf 'operator_wrapper_sha256=%s\n' "$wrapper_hash" printf 'adoption_source_attestation=PASS\n' +if (( ${#network_ip_args[@]} )); then + exec docker compose exec -T sync python backend/adopt_one.py \ + --proxmox-id "$proxmox_id" \ + --manifest-sha256 "$manifest_sha256" \ + "${network_ip_args[@]}" +fi exec docker compose exec -T sync python backend/adopt_one.py \ --proxmox-id "$proxmox_id" \ --manifest-sha256 "$manifest_sha256" diff --git a/backend/adopt_one.py b/backend/adopt_one.py index 55986c8..654454d 100644 --- a/backend/adopt_one.py +++ b/backend/adopt_one.py @@ -8,6 +8,7 @@ from __future__ import annotations import argparse +import ipaddress import json import re import sys @@ -58,6 +59,7 @@ class Target: cluster: str vmid: int manifest_sha256: str + network_ip_overrides: tuple[tuple[int, str], ...] = () class BoundedCloudStackClient: @@ -131,6 +133,11 @@ def list_virtual_machines(self, **params): **params, ) + def list_vlan_ip_ranges(self, *, networkid: str): + if not isinstance(networkid, str) or not networkid: + raise OperatorStop("cloudstack_network_id_is_required") + return self._delegate.list_vlan_ip_ranges(networkid=networkid) + def public_counts(self) -> dict: return { "deploy": self.deploy_calls, @@ -140,7 +147,37 @@ def public_counts(self) -> dict: } -def parse_target(proxmox_id: str, manifest_sha256: str) -> Target: +def _parse_network_ip_overrides(values: list[str] | None) -> tuple[tuple[int, str], ...]: + parsed: dict[int, str] = {} + seen_ips: set[str] = set() + for value in values or []: + match = re.fullmatch(r"net([0-9]+)=([^\s=]+)", value or "") + if match is None: + raise OperatorStop("network_ip_must_be_net_device_equals_ipv4") + device_id = int(match.group(1)) + if str(device_id) != match.group(1): + raise OperatorStop("network_ip_device_must_be_canonical") + try: + ip = ipaddress.ip_address(match.group(2)) + except ValueError as exc: + raise OperatorStop("network_ip_must_be_canonical_ipv4") from exc + canonical_ip = str(ip) + if ip.version != 4 or canonical_ip != match.group(2): + raise OperatorStop("network_ip_must_be_canonical_ipv4") + if device_id in parsed: + raise OperatorStop("network_ip_device_is_duplicate") + if canonical_ip in seen_ips: + raise OperatorStop("network_ip_is_duplicate") + parsed[device_id] = canonical_ip + seen_ips.add(canonical_ip) + return tuple(sorted(parsed.items())) + + +def parse_target( + proxmox_id: str, + manifest_sha256: str, + network_ip_values: list[str] | None = None, +) -> Target: if not isinstance(proxmox_id, str) or not re.fullmatch( r"[^\s:]+:[1-9][0-9]*", proxmox_id ): @@ -153,9 +190,17 @@ def parse_target(proxmox_id: str, manifest_sha256: str) -> Target: cluster=cluster, vmid=int(raw_vmid), manifest_sha256=manifest_sha256, + network_ip_overrides=_parse_network_ip_overrides(network_ip_values), ) +def _network_ip_override_requests(target: Target) -> list[app_main.NetworkIPOverride]: + return [ + app_main.NetworkIPOverride(device_id=device_id, ip=ip) + for device_id, ip in target.network_ip_overrides + ] + + def strict_job_status(result: object) -> int: if not isinstance(result, dict): raise OperatorStop("cloudstack_job_result_not_an_object") @@ -283,10 +328,21 @@ def _validate_new_candidate(catalog: dict, target: Target, *, executor_enabled: raise OperatorStop("candidate_inventory_not_current") candidate = _exact_candidate(catalog, target) blockers = set(candidate.get("blockers") or []) - expected_blockers = set() if executor_enabled else {"adoption_executor_not_enabled"} + unresolved_devices = { + int(match.group(1)) + for blocker in blockers + if (match := re.fullmatch(r"nic([0-9]+)_ip_unresolved", blocker)) + } + expected_blockers = { + f"nic{device_id}_ip_unresolved" for device_id in unresolved_devices + } + if not executor_enabled: + expected_blockers.add("adoption_executor_not_enabled") plan = candidate.get("adoption_plan") or {} if ( blockers != expected_blockers + or {device_id for device_id, _ip in target.network_ip_overrides} + != unresolved_devices or plan.get("manifest_sha256") != target.manifest_sha256 or not isinstance(plan.get("manifest"), dict) or not isinstance(plan.get("host"), dict) @@ -571,6 +627,7 @@ def renew_operator_authority() -> None: app_main.ReserveAdoptionClaimRequest( proxmox_id=target.proxmox_id, manifest_sha256=target.manifest_sha256, + network_ip_overrides=_network_ip_override_requests(target), ), None, ) @@ -589,7 +646,10 @@ def renew_operator_authority() -> None: ) app_main._execute_adoption_claim_under_authority( claim["id"], - app_main.ExecuteAdoptionClaimRequest(generation=claim["generation"]), + app_main.ExecuteAdoptionClaimRequest( + generation=claim["generation"], + network_ip_overrides=_network_ip_override_requests(target), + ), renew_operator_authority, ) else: @@ -661,6 +721,13 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--proxmox-id", required=True, help="Canonical cluster:VMID") parser.add_argument("--manifest-sha256", required=True) + parser.add_argument( + "--network-ip", + action="append", + default=[], + metavar="netN=IPv4", + help="Exact IPv4 for an unresolved NIC; repeat once per unresolved NIC", + ) parser.add_argument( "--timeout", @@ -679,7 +746,11 @@ def main(argv: list[str] | None = None) -> int: raise OperatorStop("timeout_out_of_range") if not 0.5 <= args.poll_seconds <= 30: raise OperatorStop("poll_seconds_out_of_range") - target = parse_target(args.proxmox_id, args.manifest_sha256) + target = parse_target( + args.proxmox_id, + args.manifest_sha256, + args.network_ip, + ) result = run_one( target, timeout_seconds=args.timeout, diff --git a/backend/adoption_executor.py b/backend/adoption_executor.py index 05efa06..64c8fb6 100644 --- a/backend/adoption_executor.py +++ b/backend/adoption_executor.py @@ -227,6 +227,18 @@ def validate_execution_plan(plan: dict, claim: AdoptionClaim) -> dict: "custom root disk size does not match claim manifest" ) + try: + claim_manifest = json.loads(claim.manifest_json) + except (TypeError, json.JSONDecodeError) as exc: + raise ExecutionInvalid("claim manifest is invalid") from exc + manifest_networks = ( + claim_manifest.get("networks") + if isinstance(claim_manifest, dict) + else None + ) + if not isinstance(manifest_networks, list) or len(manifest_networks) != len(networks): + raise ExecutionInvalid("claim manifest networks do not match execution plan") + seen_networks = set() seen_macs = set() for index, network in enumerate(networks): @@ -248,6 +260,30 @@ def validate_execution_plan(plan: dict, claim: AdoptionClaim) -> dict: raise ExecutionInvalid("invalid network IP") from exc if parsed_ip.version != 4: raise ExecutionInvalid("only IPv4 adoption networks are supported") + manifest_network = manifest_networks[index] + manifest_device = ( + manifest_network.get("device") + if isinstance(manifest_network, dict) + else None + ) + if manifest_device is None and isinstance(manifest_network, dict): + legacy_device_id = manifest_network.get("device_id") + if isinstance(legacy_device_id, int) and not isinstance( + legacy_device_id, bool + ): + manifest_device = f"net{legacy_device_id}" + if not isinstance(manifest_network, dict) or ( + manifest_device != f"net{index}" + or manifest_network.get("cloudstack_network_id") != network_id + or str(manifest_network.get("mac", "")).upper() != mac + or ( + manifest_network.get("ip") is not None + and manifest_network.get("ip") != ip + ) + ): + raise ExecutionInvalid( + "execution network identity does not match claim manifest" + ) if network_id in seen_networks or mac in seen_macs: raise ExecutionInvalid("duplicate network identity") seen_networks.add(network_id) diff --git a/backend/main.py b/backend/main.py index eb3eba5..87794ff 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ import ipaddress import json import logging +import re import secrets import threading from collections.abc import Callable @@ -924,7 +925,12 @@ def list_adoption_candidates(_: None = Depends(require_operator)): else: blockers.extend(policy_blockers) - if not blockers: + manifest_blockers = [ + blocker + for blocker in blockers + if re.fullmatch(r"nic[0-9]+_ip_unresolved", blocker) is None + ] + if not manifest_blockers: manifest = build_adoption_manifest( cluster=px.cluster, node=px.node, @@ -1029,9 +1035,15 @@ def list_adoption_candidates(_: None = Depends(require_operator)): session.close() +class NetworkIPOverride(BaseModel): + device_id: int = Field(ge=0, strict=True) + ip: str = Field(min_length=7, max_length=15) + + class ReserveAdoptionClaimRequest(BaseModel): proxmox_id: str manifest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + network_ip_overrides: list[NetworkIPOverride] = Field(default_factory=list) class BindAdoptionClaimRequest(BaseModel): @@ -1056,6 +1068,50 @@ class ActivateAdoptionClaimRequest(BaseModel): class ExecuteAdoptionClaimRequest(BaseModel): generation: int = Field(gt=0, strict=True) + network_ip_overrides: list[NetworkIPOverride] = Field(default_factory=list) + + +def _validated_network_ip_override_map( + candidate: dict, + overrides: list[NetworkIPOverride], +) -> dict[int, str]: + unresolved = { + int(match.group(1)) + for blocker in candidate.get("blockers") or [] + if (match := re.fullmatch(r"nic([0-9]+)_ip_unresolved", blocker)) + } + result: dict[int, str] = {} + seen_ips: set[str] = set() + for override in overrides: + if override.device_id in result: + raise ExecutionInvalid("duplicate operator network IP device") + try: + parsed = ipaddress.ip_address(override.ip) + except ValueError as exc: + raise ExecutionInvalid("operator network IP is invalid") from exc + if parsed.version != 4 or str(parsed) != override.ip: + raise ExecutionInvalid("operator network IP must be canonical IPv4") + if override.ip in seen_ips: + raise ExecutionInvalid("duplicate operator network IP") + seen_ips.add(override.ip) + result[override.device_id] = override.ip + + if set(result) != unresolved: + raise ExecutionInvalid( + "operator network IPs must exactly cover unresolved candidate NICs" + ) + + networks = (candidate.get("adoption_plan") or {}).get("networks") or [] + planned = { + network.get("device_id"): network + for network in networks + if isinstance(network, dict) + } + for device_id in unresolved: + network = planned.get(device_id) + if network is None or network.get("ip") is not None: + raise ExecutionInvalid("unresolved candidate network identity changed") + return result class ManagedOperationLeaseRequest(BindAdoptionClaimRequest): @@ -1088,8 +1144,13 @@ def create_adoption_claim( if len(candidates) != 1: raise HTTPException(404, "Current adoption candidate is not unique") candidate = candidates[0] - allowed_blockers = {"adoption_executor_not_enabled"} blockers = set(candidate.get("blockers") or []) + unresolved_blockers = { + blocker + for blocker in blockers + if re.fullmatch(r"nic[0-9]+_ip_unresolved", blocker) + } + allowed_blockers = {"adoption_executor_not_enabled"} | unresolved_blockers adoption_plan = candidate.get("adoption_plan") or {} manifest = adoption_plan.get("manifest") actual_hash = adoption_plan.get("manifest_sha256") @@ -1101,6 +1162,10 @@ def create_adoption_claim( "blockers": sorted(blockers), }, ) + try: + _validated_network_ip_override_map(candidate, req.network_ip_overrides) + except ExecutionInvalid as exc: + raise HTTPException(409, str(exc)) from exc if not secrets.compare_digest(req.manifest_sha256, actual_hash): raise HTTPException(409, "Candidate manifest changed before reservation") @@ -1149,7 +1214,57 @@ def list_adoption_claims(_: None = Depends(require_operator)): session.close() -def _build_execution_plan(candidate: dict, claim: AdoptionClaim) -> dict: +def _validate_operator_network_ips_live( + networks: list[dict], + overrides: dict[int, str], +) -> None: + if not overrides: + return + if not engine or not engine.cs_client: + raise ExecutionInvalid("CloudStack inventory is unavailable") + existing_ips = { + nic.get("ipaddress") + for vm in engine.cs_client.list_virtual_machines(details="all") + for nic in vm.get("nic") or [] + if isinstance(nic.get("ipaddress"), str) and nic.get("ipaddress") + } + ranges_by_network: dict[str, list[dict]] = {} + cloudstack_network_ids: set[str] = set() + for network in networks: + network_id = network.get("cloudstack_network_id") + if ( + network.get("device_id") in overrides + and network.get("ip_allocation", "cloudstack") == "cloudstack" + and isinstance(network_id, str) + ): + cloudstack_network_ids.add(network_id) + for network_id in cloudstack_network_ids: + ranges_by_network[network_id] = engine.cs_client.list_vlan_ip_ranges( + networkid=network_id + ) + + for network in networks: + device_id = network.get("device_id") + if device_id not in overrides: + continue + ip = overrides[device_id] + if ip in existing_ips: + raise ExecutionConflict("operator network IP already exists in CloudStack") + if network.get("ip_allocation", "cloudstack") == "cloudstack": + network_id = network.get("cloudstack_network_id") + if not isinstance(network_id, str) or not _ip_in_guest_ranges( + ip, ranges_by_network.get(network_id, []) + ): + raise ExecutionInvalid( + "operator network IP is outside the CloudStack guest range" + ) + + +def _build_execution_plan( + candidate: dict, + claim: AdoptionClaim, + network_ip_overrides: list[NetworkIPOverride] | None = None, +) -> dict: adoption_plan = candidate.get("adoption_plan") or {} manifest = adoption_plan.get("manifest") or {} host = adoption_plan.get("host") or {} @@ -1175,6 +1290,10 @@ def _build_execution_plan(candidate: dict, claim: AdoptionClaim) -> dict: range(len(ordered_networks)) ): raise ExecutionInvalid("candidate network devices are not contiguous") + override_map = _validated_network_ip_override_map( + candidate, network_ip_overrides or [] + ) + _validate_operator_network_ips_live(ordered_networks, override_map) return { "claim": { "id": claim.id, @@ -1208,7 +1327,9 @@ def _build_execution_plan(candidate: dict, claim: AdoptionClaim) -> dict: { "network_id": network.get("cloudstack_network_id"), "mac": network.get("mac"), - "ip": network.get("ip"), + "ip": override_map.get( + network["device_id"], network.get("ip") + ), "ip_allocation": network.get( "ip_allocation", "cloudstack" ), @@ -1260,7 +1381,12 @@ def _execute_adoption_claim_under_authority( raise HTTPException(409, "Current adoption candidate is not unique") candidate = candidates[0] blockers = sorted(set(candidate.get("blockers") or [])) - if blockers: + unresolved_blockers = { + blocker + for blocker in blockers + if re.fullmatch(r"nic[0-9]+_ip_unresolved", blocker) + } + if set(blockers) - unresolved_blockers: raise HTTPException( 409, { @@ -1268,12 +1394,22 @@ def _execute_adoption_claim_under_authority( "blockers": blockers, }, ) + try: + _validated_network_ip_override_map( + candidate, req.network_ip_overrides + ) + except ExecutionInvalid as exc: + raise HTTPException(409, str(exc)) from exc write_guard() execution = create_execution( session, claim_id=claim.id, generation=claim.generation, - plan=_build_execution_plan(candidate, claim), + plan=_build_execution_plan( + candidate, + claim, + req.network_ip_overrides, + ), write_guard=write_guard, ) execution_id = execution.id diff --git a/backend/tests/test_adopt_one.py b/backend/tests/test_adopt_one.py index 9b07b94..c8ffe72 100644 --- a/backend/tests/test_adopt_one.py +++ b/backend/tests/test_adopt_one.py @@ -22,6 +22,7 @@ def __init__(self): self.starts = [] self.destroys = [] self.inventory_calls = [] + self.ip_range_calls = [] def deploy_virtual_machine(self, **params): self.deploys.append(params) @@ -42,8 +43,26 @@ def list_virtual_machines(self, **params): self.inventory_calls.append(params) return [] + def list_vlan_ip_ranges(self, **params): + self.ip_range_calls.append(params) + return [] + class AdoptOneTests(unittest.TestCase): + def test_bounded_inventory_allows_only_network_scoped_ip_ranges(self): + delegate = FakeDelegate() + client = BoundedCloudStackClient( + delegate, + allow_deploy=False, + allow_start=False, + authority_guard=lambda: None, + ) + + self.assertEqual([], client.list_vlan_ip_ranges(networkid="network-1")) + self.assertEqual([{"networkid": "network-1"}], delegate.ip_range_calls) + with self.assertRaises(OperatorStop): + client.list_vlan_ip_ranges(networkid="") + def test_bounded_inventory_consumes_and_clamps_existing_bounds_once(self): delegate = FakeDelegate() client = BoundedCloudStackClient( @@ -80,6 +99,27 @@ def test_parse_target_requires_canonical_identity_and_hash(self): with self.subTest(hash=invalid_hash), self.assertRaises(OperatorStop): parse_target("p3:110", invalid_hash) + def test_parse_target_accepts_exact_network_ip_overrides(self): + digest = "a" * 64 + target = parse_target( + "p3-cluster03:110", + digest, + ["net2=192.0.2.12", "net0=192.0.2.10"], + ) + self.assertEqual( + ((0, "192.0.2.10"), (2, "192.0.2.12")), + target.network_ip_overrides, + ) + for values in ( + ["net00=192.0.2.10"], + ["net0=192.0.2.010"], + ["net0=2001:db8::1"], + ["net0=192.0.2.10", "net0=192.0.2.11"], + ["net0=192.0.2.10", "net1=192.0.2.10"], + ): + with self.subTest(values=values), self.assertRaises(OperatorStop): + parse_target("p3-cluster03:110", digest, values) + def test_strict_job_status_rejects_malformed_wire_values(self): for value in (True, False, "1", 3, -1, None, 1.0): with self.subTest(value=value), self.assertRaises(OperatorStop): diff --git a/backend/tests/test_adopt_one_wrapper.py b/backend/tests/test_adopt_one_wrapper.py index ac05dca..63dace8 100644 --- a/backend/tests/test_adopt_one_wrapper.py +++ b/backend/tests/test_adopt_one_wrapper.py @@ -74,6 +74,9 @@ def setUp(self): ;; esac done + for argument in "$@"; do + printf 'runner_argument=%s\n' "$argument" + done printf 'runner_invoked=PASS\n' """, ) @@ -86,7 +89,7 @@ def _write_executable(self, name, content): path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") path.chmod(0o755) - def _run(self, **environment): + def _run(self, *extra_args, **environment): env = dict(os.environ) env.update(environment) env["PATH"] = f"{self.bin}:{env['PATH']}" @@ -96,6 +99,7 @@ def _run(self, **environment): str(self.root / "adopt-one.sh"), "p3-cluster03:110", "a" * 64, + *extra_args, ], cwd=self.root, env=env, @@ -110,6 +114,12 @@ def test_exact_clean_origin_main_and_matching_container_pass(self): self.assertIn("adoption_source_attestation=PASS", result.stdout) self.assertIn("runner_invoked=PASS", result.stdout) + def test_network_ip_is_forwarded_as_exact_runner_argument(self): + result = self._run("net0=192.0.2.10") + self.assertEqual(0, result.returncode, result.stderr) + self.assertIn("runner_argument=--network-ip", result.stdout) + self.assertIn("runner_argument=net0=192.0.2.10", result.stdout) + def test_dirty_or_untracked_checkout_stops(self): result = self._run(FAKE_STATUS="?? backend/other.py\n") self.assertNotEqual(0, result.returncode) diff --git a/backend/tests/test_adoption_executor_api.py b/backend/tests/test_adoption_executor_api.py index 9010a30..16c9df5 100644 --- a/backend/tests/test_adoption_executor_api.py +++ b/backend/tests/test_adoption_executor_api.py @@ -49,6 +49,13 @@ def __init__(self): def list_virtual_machines(self, **kwargs): return list(self.vms) + def list_vlan_ip_ranges(self, **kwargs): + return [{ + "networkid": NETWORK_ID, + "startip": "10.0.0.2", + "endip": "10.0.0.254", + }] + def deploy_virtual_machine(self, **params): self.deploy_calls.append(params) return {"jobid": "deploy-job"} @@ -206,6 +213,71 @@ def test_external_ipam_omits_cloudstack_ip_allocation(self): params["iptonetworklist[0].mac"], ) + def test_unresolved_ip_requires_exact_operator_input_and_freezes_it(self): + self.manifest["networks"][0]["ip"] = None + self.manifest_json = json.dumps( + self.manifest, sort_keys=True, separators=(",", ":") + ) + self.digest = hashlib.sha256(self.manifest_json.encode()).hexdigest() + session = get_session() + try: + claim = session.get(app_main.AdoptionClaim, self.claim_id) + claim.manifest_json = self.manifest_json + claim.manifest_sha256 = self.digest + session.commit() + finally: + session.close() + + client = RouteCloudStack() + engine = Mock() + engine.cs_client = client + candidate = self.candidate(["nic0_ip_unresolved"]) + candidate["adoption_plan"]["networks"][0]["ip"] = None + planning = {"candidates": [candidate]} + + with ( + patch.object(app_main, "engine", engine), + patch.object(app_main, "list_adoption_candidates", return_value=planning), + self.assertRaises(HTTPException) as missing, + ): + app_main.execute_adoption_claim( + self.claim_id, + app_main.ExecuteAdoptionClaimRequest(generation=self.generation), + None, + ) + self.assertEqual(409, missing.exception.status_code) + self.assertEqual([], client.deploy_calls) + + request = app_main.ExecuteAdoptionClaimRequest( + generation=self.generation, + network_ip_overrides=[ + app_main.NetworkIPOverride(device_id=0, ip="10.0.0.115") + ], + ) + with ( + patch.object(app_main, "engine", engine), + patch.object(app_main, "list_adoption_candidates", return_value=planning), + ): + response = app_main.execute_adoption_claim( + self.claim_id, request, None + ) + + self.assertEqual("deploy_submitted", response["state"]) + self.assertEqual( + "10.0.0.115", + client.deploy_calls[0]["iptonetworklist[0].ip"], + ) + session = get_session() + try: + execution = session.query(AdoptionExecution).one() + persisted = json.loads(execution.plan_json) + self.assertEqual( + "10.0.0.115", + persisted["deployment"]["networks"][0]["ip"], + ) + finally: + session.close() + def test_execute_route_rejects_blockers_before_creating_execution(self): client = RouteCloudStack() engine = Mock() diff --git a/backend/tests/test_adoption_planning.py b/backend/tests/test_adoption_planning.py index 52195f7..4326695 100644 --- a/backend/tests/test_adoption_planning.py +++ b/backend/tests/test_adoption_planning.py @@ -22,7 +22,6 @@ from database import HostMapping, NetworkMapping, ProxmoxVM, get_session, init_db from sync_engine import SyncEngine - DOMAIN_ID = "6317d9b3-8c8d-11f0-9947-00505689d4e8" CUSTOM_OFFERING_ID = "518c4044-5347-4dea-a843-cf5a27cd2e88" DISK_OFFERING_ID = "8f52fab6-1599-4c1b-97fa-e6199e9ca891" @@ -605,6 +604,37 @@ def test_complete_candidate_gets_root_no_project_exact_read_only_plan(self): ) self.assertEqual(64, len(plan["manifest_sha256"])) + def test_unresolved_ip_keeps_exact_manifest_for_operator_input(self): + self._add_complete_candidate() + session = get_session() + try: + vm = session.query(ProxmoxVM).one() + networks = json.loads(vm.networks) + networks[0]["ip"] = None + vm.networks = json.dumps(networks) + session.commit() + finally: + session.close() + + engine = Mock() + engine._inventory_collection_ready = True + engine._nic_collection_ready = True + engine.cs_client = CatalogClient() + app_main.settings.adoption_policy = AdoptionPolicy( + enabled=True, + domain_id=DOMAIN_ID, + customized_service_offering_id=CUSTOM_OFFERING_ID, + ) + + with patch.object(app_main, "engine", engine): + row = app_main.list_adoption_candidates()["candidates"][0] + + self.assertIn("nic0_ip_unresolved", row["blockers"]) + plan = row["adoption_plan"] + self.assertIsNone(plan["networks"][0]["ip"]) + self.assertIsNone(plan["manifest"]["networks"][0]["ip"]) + self.assertEqual(64, len(plan["manifest_sha256"])) + def test_custom_root_disk_size_requires_one_integral_qemu_disk(self): self.assertEqual( 100,