Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions adopt-one.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
79 changes: 75 additions & 4 deletions backend/adopt_one.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import argparse
import ipaddress
import json
import re
import sys
Expand Down Expand Up @@ -58,6 +59,7 @@ class Target:
cluster: str
vmid: int
manifest_sha256: str
network_ip_overrides: tuple[tuple[int, str], ...] = ()


class BoundedCloudStackClient:
Expand Down Expand Up @@ -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,
Expand All @@ -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
):
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions backend/adoption_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
Loading