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
42 changes: 33 additions & 9 deletions orca_cli/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1442,18 +1442,34 @@ def server_bulk(ctx: click.Context, action: str, name_pattern: str | None,
@server.command("clone")
@click.argument("server_id", callback=validate_id)
@click.option("--name", required=True, help="Name for the cloned server.")
@click.option("--disk-size", type=int, default=None, help="Boot volume size in GB. Default: same as source.")
@click.option("--disk-size", type=int, default=None,
help="Boot volume size in GB (BFV only). Default: same as source.")
@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.pass_context
def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int | None) -> None:
def server_clone(
ctx: click.Context,
server_id: str,
name: str,
disk_size: int | None,
boot_from_image: bool,
boot_from_volume: bool,
) -> None:
"""Clone a server — recreate one with the same config.

Copies flavor, network, security groups, key pair, and boot
volume size from the source server into a new one.
Copies flavor, network, security groups, key pair, and image from
the source server into a new one. The clone's boot mode follows the
same policy as ``orca server create``: boot-from-image by default,
fallback to boot-from-volume only when the flavor has ``disk == 0``.
Override with ``--boot-from-image`` / ``--boot-from-volume``.

\b
Examples:
orca server clone <id> --name web-02
orca server clone <id> --name web-02 --disk-size 50
orca server clone <id> --name web-02 --boot-from-volume --disk-size 50
"""
client = ctx.find_object(OrcaContext).ensure_client()
service = ServerService(client)
Expand Down Expand Up @@ -1517,11 +1533,15 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
# Key pair
key_name = src.get("key_name")

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

# Build the new server
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 @@ -1530,8 +1550,9 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
"volume_size": src_disk,
"delete_on_termination": True,
}
],
}
]
else:
body["imageRef"] = image_id
if networks:
body["networks"] = networks
if security_groups:
Expand All @@ -1542,7 +1563,10 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
console.print(f"[bold]Cloning '{src_name}' → '{name}'[/bold]")
console.print(f" Flavor: {flavor_id}")
console.print(f" Image: {image_id}")
console.print(f" Disk: {src_disk} GB")
if use_bfv:
console.print(f" Disk: {src_disk} GB (boot volume)")
else:
console.print(" Boot: from image (flavor root disk)")
console.print(f" Key: {key_name or '—'}")
console.print(f" SGs: {', '.join(sg['name'] for sg in security_groups) or '—'}")
console.print(f" Nets: {len(networks)}")
Expand Down
149 changes: 149 additions & 0 deletions tests/test_server_clone_boot_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Tests for ``orca server clone`` boot-mode selection (ephemeral vs BFV)."""

from __future__ import annotations

from orca_cli.core.config import save_profile, set_active_profile

SRC_ID = "11112222-3333-4444-5555-666677778888"
IMG_ID = "55556666-7777-8888-9999-000011112222"
NET_ID = "44445555-6666-7777-8888-999900001111"
VOL_ID = "22223333-4444-5555-6666-777788889999"
FLAVOR_WITH_DISK = "flav-disk-20"
FLAVOR_DISKLESS = "flav-disk-0"


def _mock_clone_environment(mock_client, flavor_id, flavor_disk):
"""Wire a mock_client that serves the GETs needed by `server clone`."""
mock_client.compute_url = "https://nova.example.com/v2.1"
mock_client.volume_url = "https://cinder.example.com/v3"
state = {"posted": {}}

def _get(url, **kwargs):
if url.endswith(f"/flavors/{flavor_id}"):
return {"flavor": {"id": flavor_id, "disk": flavor_disk}}
if f"servers/{SRC_ID}/os-volume_attachments" in url:
return {"volumeAttachments": [
{"id": "att-1", "volumeId": VOL_ID, "device": "/dev/vda"},
]}
if f"servers/{SRC_ID}/os-interface" in url:
return {"interfaceAttachments": [
{"net_id": NET_ID, "port_id": "p", "fixed_ips": []},
]}
if f"servers/{SRC_ID}" in url:
return {"server": {
"id": SRC_ID, "name": "source",
"flavor": {"id": flavor_id},
"image": {"id": IMG_ID},
"security_groups": [{"name": "default"}],
"key_name": "k",
"addresses": {},
}}
if f"volumes/{VOL_ID}" in url:
return {"volume": {
"size": 20,
"volume_image_metadata": {"image_id": IMG_ID},
}}
return {}

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

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


class TestCloneAutoDetect:

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_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)

result = invoke(["server", "clone", SRC_ID, "--name", "dst"])

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_clone_environment(mock_client, FLAVOR_DISKLESS, 0)

result = invoke(["server", "clone", SRC_ID, "--name", "dst"])

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


class TestCloneExplicitFlags:

def test_boot_from_volume_override(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)

result = invoke(["server", "clone", SRC_ID, "--name", "dst",
"--boot-from-volume", "--disk-size", "50"])

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"] == 50
assert "imageRef" not in body

def test_boot_from_image_override(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)

result = invoke(["server", "clone", SRC_ID, "--name", "dst",
"--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_errors(
self, invoke, config_dir, mock_client, sample_profile,
):
save_profile("p", sample_profile)
set_active_profile("p")
state = _mock_clone_environment(mock_client, FLAVOR_DISKLESS, 0)

result = invoke(["server", "clone", SRC_ID, "--name", "dst",
"--boot-from-image"])

assert result.exit_code != 0
assert "disk=0" in result.output
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_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)

result = invoke(["server", "clone", SRC_ID, "--name", "dst",
"--boot-from-image", "--boot-from-volume"])

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