Skip to content
Merged
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
75 changes: 70 additions & 5 deletions orca_cli/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,51 @@
from orca_cli.core.output import console, output_options, print_detail, print_list
from orca_cli.core.validators import validate_id
from orca_cli.core.waiter import wait_for_resource
from orca_cli.services.compute import ComputeService
from orca_cli.services.image import ImageService
from orca_cli.services.server import ServerService
from orca_cli.services.volume import VolumeService


def _resolve_boot_mode(
client,
flavor_id: str,
boot_from_image: bool,
boot_from_volume: bool,
) -> bool:
"""Return True if the new server must boot from a Cinder volume.

Precedence: explicit --boot-from-volume > explicit --boot-from-image >
auto. Auto falls back to BFV only when the flavor has no local root
disk (``disk == 0``), which is the single case where Nova itself
refuses a boot-from-image.
"""
if boot_from_image and boot_from_volume:
raise click.UsageError(
"--boot-from-image and --boot-from-volume are mutually exclusive."
)
if boot_from_volume:
return True
try:
flavor = ComputeService(client).get_flavor(flavor_id)
except Exception:
# If we can't inspect the flavor (transient Nova error or auth gap),
# respect the user's explicit flag; otherwise fall back to BFV, which
# works on every deployment including disk=0 flavors.
return not boot_from_image
disk = int(flavor.get("disk") or 0)
if boot_from_image:
if disk == 0:
raise click.UsageError(
f"Flavor {flavor_id} has disk=0 — boot-from-image is not "
"supported on this flavor. Drop --boot-from-image or use "
"--boot-from-volume explicitly."
)
return False
# Auto: boot-from-image by default when the flavor can carry a root disk.
return disk == 0


@click.group()
@click.pass_context
def server(ctx: click.Context) -> None:
Expand Down Expand Up @@ -280,6 +320,11 @@ def server_show(ctx: click.Context, server_id: str, output_format: str, columns:
help="SSH key pair name (see 'orca keypair list').")
@click.option("--security-group", "security_groups", multiple=True, shell_complete=complete_security_groups,
help="Security group name (repeatable).")
@click.option("--boot-from-image", is_flag=True,
help="Force boot from the image on the compute's local disk "
"(requires flavor disk > 0).")
@click.option("--boot-from-volume", is_flag=True,
help="Force boot from a Cinder volume created from the image.")
@click.option("--wait", is_flag=True, help="Wait until the server reaches ACTIVE status.")
@click.option("--interactive", "-i", is_flag=True,
help="Step-by-step wizard — browse images, flavors, and networks interactively.")
Expand All @@ -293,10 +338,18 @@ def server_create(
network_id: str | None,
key_name: str | None,
security_groups: tuple[str, ...],
boot_from_image: bool,
boot_from_volume: bool,
wait: bool,
interactive: bool,
) -> None:
"""Create a new server (boot from volume).
"""Create a new server.

By default the server boots from the image directly on the compute's
local disk (flavor ``disk`` field). Flavors declared with ``disk=0``
fall back to boot-from-volume automatically since Nova cannot boot
them otherwise. Use ``--boot-from-image`` or ``--boot-from-volume``
to override the auto-detection.

\b
Non-interactive example:
Expand Down Expand Up @@ -384,10 +437,16 @@ def server_create(
"Use -i / --interactive for the guided wizard."
)

use_bfv = _resolve_boot_mode(
client, flavor_id or "", boot_from_image, boot_from_volume,
)

body: dict = {
"name": name,
"flavorRef": flavor_id,
"block_device_mapping_v2": [
}
if use_bfv:
body["block_device_mapping_v2"] = [
{
"boot_index": 0,
"uuid": image_id,
Expand All @@ -396,8 +455,11 @@ def server_create(
"volume_size": disk_size,
"delete_on_termination": True,
}
],
}
]
else:
# Boot-from-image: Nova sizes the root disk from the flavor, so
# --disk-size is ignored here (the flag remains valid for BFV).
body["imageRef"] = image_id

if network_id:
body["networks"] = [{"uuid": network_id}]
Expand All @@ -415,7 +477,10 @@ def server_create(
console.print("\n[bold green]Server created successfully![/bold green]")
console.print(f" [cyan]ID:[/cyan] {srv_id}")
console.print(f" [cyan]Name:[/cyan] {name}")
console.print(f" [cyan]Disk:[/cyan] {disk_size} GB (boot volume)")
if use_bfv:
console.print(f" [cyan]Disk:[/cyan] {disk_size} GB (boot volume)")
else:
console.print(" [cyan]Boot:[/cyan] from image (flavor root disk)")
if admin_pass:
console.print(f" [cyan]Password:[/cyan] {admin_pass}")

Expand Down
213 changes: 213 additions & 0 deletions tests/test_server_create_boot_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""Tests for ``orca server create`` boot-mode selection (ephemeral vs BFV)."""

from __future__ import annotations

from orca_cli.core.config import save_profile, set_active_profile

IMG_ID = "55556666-7777-8888-9999-000011112222"
FLAVOR_WITH_DISK = "flav-disk-20"
FLAVOR_DISKLESS = "flav-disk-0"


def _mock_create_with_flavors(mock_client, flavors):
"""Wire a mock_client that answers GET /flavors/<id> and POST /servers."""
mock_client.compute_url = "https://nova.example.com/v2.1"
state = {"posted": {}}

def _get(url, **kwargs):
for fid, body in flavors.items():
if url.endswith(f"/flavors/{fid}"):
return {"flavor": {"id": fid, **body}}
return {}

def _post(url, **kwargs):
state["posted"]["url"] = url
state["posted"]["body"] = kwargs.get("json", {}).get("server", {})
return {"server": {"id": "new-srv", "adminPass": "s3cr3t"}}

mock_client.get = _get
mock_client.post = _post
return state


# ── auto-detection ──────────────────────────────────────────────────────────

class TestAutoDetect:

def test_flavor_with_disk_defaults_to_boot_from_image(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_WITH_DISK: {"disk": 20}},
)

result = invoke(["server", "create",
"--name", "vm1",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
assert body["imageRef"] == IMG_ID
assert "block_device_mapping_v2" not in body
assert "from image" in result.output.lower()

def test_diskless_flavor_falls_back_to_bfv(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_DISKLESS: {"disk": 0}},
)

result = invoke(["server", "create",
"--name", "vm2",
"--flavor", FLAVOR_DISKLESS,
"--image", IMG_ID,
"--disk-size", "30"])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
bdm = body["block_device_mapping_v2"][0]
assert bdm["destination_type"] == "volume"
assert bdm["volume_size"] == 30
assert "imageRef" not in body


# ── explicit flags ──────────────────────────────────────────────────────────

class TestExplicitFlags:

def test_boot_from_volume_overrides_disk_flavor(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_WITH_DISK: {"disk": 20}},
)

result = invoke(["server", "create",
"--name", "vm3",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID,
"--boot-from-volume",
"--disk-size", "40"])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
assert "block_device_mapping_v2" in body
assert body["block_device_mapping_v2"][0]["volume_size"] == 40
assert "imageRef" not in body

def test_boot_from_image_on_flavor_with_disk(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_WITH_DISK: {"disk": 20}},
)

result = invoke(["server", "create",
"--name", "vm4",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID,
"--boot-from-image"])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
assert body["imageRef"] == IMG_ID
assert "block_device_mapping_v2" not in body

def test_boot_from_image_on_diskless_flavor_errors(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_DISKLESS: {"disk": 0}},
)

result = invoke(["server", "create",
"--name", "vm5",
"--flavor", FLAVOR_DISKLESS,
"--image", IMG_ID,
"--boot-from-image"])

assert result.exit_code != 0
assert "disk=0" in result.output
# The POST must not fire once we refuse the request.
assert "body" not in state["posted"]

def test_mutual_exclusion(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(
mock_client, {FLAVOR_WITH_DISK: {"disk": 20}},
)

result = invoke(["server", "create",
"--name", "vm6",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID,
"--boot-from-image",
"--boot-from-volume"])

assert result.exit_code != 0
assert "mutually exclusive" in result.output
assert "body" not in state["posted"]


# ── fallback when flavor lookup fails ───────────────────────────────────────

class TestFlavorLookupFailure:

def test_unreachable_flavor_respects_boot_from_image_flag(
self, invoke, config_dir, mock_client, sample_profile, monkeypatch,
):
"""When GET /flavors/<id> errors, the explicit --boot-from-image is honored."""
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(mock_client, {})

def _get_fails(url, **kwargs):
raise RuntimeError("neutron down")
mock_client.get = _get_fails

result = invoke(["server", "create",
"--name", "vm7",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID,
"--boot-from-image"])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
assert body.get("imageRef") == IMG_ID
assert "block_device_mapping_v2" not in body

def test_unreachable_flavor_defaults_to_bfv_when_unspecified(
self, invoke, config_dir, mock_client, sample_profile,
):
"""Without an explicit flag, an unknown flavor is safest as BFV."""
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_create_with_flavors(mock_client, {})

def _get_fails(url, **kwargs):
raise RuntimeError("boom")
mock_client.get = _get_fails

result = invoke(["server", "create",
"--name", "vm8",
"--flavor", FLAVOR_WITH_DISK,
"--image", IMG_ID])

assert result.exit_code == 0, result.output
body = state["posted"]["body"]
assert "block_device_mapping_v2" in body
Loading