Skip to content

Commit 75cdf6c

Browse files
authored
Merge pull request #4 from stackopshq/feat/server-clone-boot-mode
feat(server-clone): align boot-mode policy with server create
2 parents 4c71cd3 + 674949c commit 75cdf6c

2 files changed

Lines changed: 182 additions & 9 deletions

File tree

orca_cli/commands/server.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,18 +1442,34 @@ def server_bulk(ctx: click.Context, action: str, name_pattern: str | None,
14421442
@server.command("clone")
14431443
@click.argument("server_id", callback=validate_id)
14441444
@click.option("--name", required=True, help="Name for the cloned server.")
1445-
@click.option("--disk-size", type=int, default=None, help="Boot volume size in GB. Default: same as source.")
1445+
@click.option("--disk-size", type=int, default=None,
1446+
help="Boot volume size in GB (BFV only). Default: same as source.")
1447+
@click.option("--boot-from-image", is_flag=True,
1448+
help="Force boot from the image on the compute's local disk "
1449+
"(requires flavor disk > 0).")
1450+
@click.option("--boot-from-volume", is_flag=True,
1451+
help="Force boot from a Cinder volume created from the image.")
14461452
@click.pass_context
1447-
def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int | None) -> None:
1453+
def server_clone(
1454+
ctx: click.Context,
1455+
server_id: str,
1456+
name: str,
1457+
disk_size: int | None,
1458+
boot_from_image: bool,
1459+
boot_from_volume: bool,
1460+
) -> None:
14481461
"""Clone a server — recreate one with the same config.
14491462
1450-
Copies flavor, network, security groups, key pair, and boot
1451-
volume size from the source server into a new one.
1463+
Copies flavor, network, security groups, key pair, and image from
1464+
the source server into a new one. The clone's boot mode follows the
1465+
same policy as ``orca server create``: boot-from-image by default,
1466+
fallback to boot-from-volume only when the flavor has ``disk == 0``.
1467+
Override with ``--boot-from-image`` / ``--boot-from-volume``.
14521468
14531469
\b
14541470
Examples:
14551471
orca server clone <id> --name web-02
1456-
orca server clone <id> --name web-02 --disk-size 50
1472+
orca server clone <id> --name web-02 --boot-from-volume --disk-size 50
14571473
"""
14581474
client = ctx.find_object(OrcaContext).ensure_client()
14591475
service = ServerService(client)
@@ -1517,11 +1533,15 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
15171533
# Key pair
15181534
key_name = src.get("key_name")
15191535

1536+
use_bfv = _resolve_boot_mode(client, flavor_id, boot_from_image, boot_from_volume)
1537+
15201538
# Build the new server
15211539
body: dict = {
15221540
"name": name,
15231541
"flavorRef": flavor_id,
1524-
"block_device_mapping_v2": [
1542+
}
1543+
if use_bfv:
1544+
body["block_device_mapping_v2"] = [
15251545
{
15261546
"boot_index": 0,
15271547
"uuid": image_id,
@@ -1530,8 +1550,9 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
15301550
"volume_size": src_disk,
15311551
"delete_on_termination": True,
15321552
}
1533-
],
1534-
}
1553+
]
1554+
else:
1555+
body["imageRef"] = image_id
15351556
if networks:
15361557
body["networks"] = networks
15371558
if security_groups:
@@ -1542,7 +1563,10 @@ def server_clone(ctx: click.Context, server_id: str, name: str, disk_size: int |
15421563
console.print(f"[bold]Cloning '{src_name}' → '{name}'[/bold]")
15431564
console.print(f" Flavor: {flavor_id}")
15441565
console.print(f" Image: {image_id}")
1545-
console.print(f" Disk: {src_disk} GB")
1566+
if use_bfv:
1567+
console.print(f" Disk: {src_disk} GB (boot volume)")
1568+
else:
1569+
console.print(" Boot: from image (flavor root disk)")
15461570
console.print(f" Key: {key_name or '—'}")
15471571
console.print(f" SGs: {', '.join(sg['name'] for sg in security_groups) or '—'}")
15481572
console.print(f" Nets: {len(networks)}")
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Tests for ``orca server clone`` boot-mode selection (ephemeral vs BFV)."""
2+
3+
from __future__ import annotations
4+
5+
from orca_cli.core.config import save_profile, set_active_profile
6+
7+
SRC_ID = "11112222-3333-4444-5555-666677778888"
8+
IMG_ID = "55556666-7777-8888-9999-000011112222"
9+
NET_ID = "44445555-6666-7777-8888-999900001111"
10+
VOL_ID = "22223333-4444-5555-6666-777788889999"
11+
FLAVOR_WITH_DISK = "flav-disk-20"
12+
FLAVOR_DISKLESS = "flav-disk-0"
13+
14+
15+
def _mock_clone_environment(mock_client, flavor_id, flavor_disk):
16+
"""Wire a mock_client that serves the GETs needed by `server clone`."""
17+
mock_client.compute_url = "https://nova.example.com/v2.1"
18+
mock_client.volume_url = "https://cinder.example.com/v3"
19+
state = {"posted": {}}
20+
21+
def _get(url, **kwargs):
22+
if url.endswith(f"/flavors/{flavor_id}"):
23+
return {"flavor": {"id": flavor_id, "disk": flavor_disk}}
24+
if f"servers/{SRC_ID}/os-volume_attachments" in url:
25+
return {"volumeAttachments": [
26+
{"id": "att-1", "volumeId": VOL_ID, "device": "/dev/vda"},
27+
]}
28+
if f"servers/{SRC_ID}/os-interface" in url:
29+
return {"interfaceAttachments": [
30+
{"net_id": NET_ID, "port_id": "p", "fixed_ips": []},
31+
]}
32+
if f"servers/{SRC_ID}" in url:
33+
return {"server": {
34+
"id": SRC_ID, "name": "source",
35+
"flavor": {"id": flavor_id},
36+
"image": {"id": IMG_ID},
37+
"security_groups": [{"name": "default"}],
38+
"key_name": "k",
39+
"addresses": {},
40+
}}
41+
if f"volumes/{VOL_ID}" in url:
42+
return {"volume": {
43+
"size": 20,
44+
"volume_image_metadata": {"image_id": IMG_ID},
45+
}}
46+
return {}
47+
48+
def _post(url, **kwargs):
49+
state["posted"]["url"] = url
50+
state["posted"]["body"] = kwargs.get("json", {}).get("server", {})
51+
return {"server": {"id": "clone-1"}}
52+
53+
mock_client.get = _get
54+
mock_client.post = _post
55+
return state
56+
57+
58+
class TestCloneAutoDetect:
59+
60+
def test_flavor_with_disk_defaults_to_boot_from_image(
61+
self, invoke, config_dir, mock_client, sample_profile,
62+
):
63+
save_profile("p", sample_profile)
64+
set_active_profile("p")
65+
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)
66+
67+
result = invoke(["server", "clone", SRC_ID, "--name", "dst"])
68+
69+
assert result.exit_code == 0, result.output
70+
body = state["posted"]["body"]
71+
assert body["imageRef"] == IMG_ID
72+
assert "block_device_mapping_v2" not in body
73+
assert "from image" in result.output.lower()
74+
75+
def test_diskless_flavor_falls_back_to_bfv(
76+
self, invoke, config_dir, mock_client, sample_profile,
77+
):
78+
save_profile("p", sample_profile)
79+
set_active_profile("p")
80+
state = _mock_clone_environment(mock_client, FLAVOR_DISKLESS, 0)
81+
82+
result = invoke(["server", "clone", SRC_ID, "--name", "dst"])
83+
84+
assert result.exit_code == 0, result.output
85+
body = state["posted"]["body"]
86+
assert "block_device_mapping_v2" in body
87+
assert "imageRef" not in body
88+
89+
90+
class TestCloneExplicitFlags:
91+
92+
def test_boot_from_volume_override(
93+
self, invoke, config_dir, mock_client, sample_profile,
94+
):
95+
save_profile("p", sample_profile)
96+
set_active_profile("p")
97+
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)
98+
99+
result = invoke(["server", "clone", SRC_ID, "--name", "dst",
100+
"--boot-from-volume", "--disk-size", "50"])
101+
102+
assert result.exit_code == 0, result.output
103+
body = state["posted"]["body"]
104+
assert "block_device_mapping_v2" in body
105+
assert body["block_device_mapping_v2"][0]["volume_size"] == 50
106+
assert "imageRef" not in body
107+
108+
def test_boot_from_image_override(
109+
self, invoke, config_dir, mock_client, sample_profile,
110+
):
111+
save_profile("p", sample_profile)
112+
set_active_profile("p")
113+
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)
114+
115+
result = invoke(["server", "clone", SRC_ID, "--name", "dst",
116+
"--boot-from-image"])
117+
118+
assert result.exit_code == 0, result.output
119+
body = state["posted"]["body"]
120+
assert body["imageRef"] == IMG_ID
121+
assert "block_device_mapping_v2" not in body
122+
123+
def test_boot_from_image_on_diskless_errors(
124+
self, invoke, config_dir, mock_client, sample_profile,
125+
):
126+
save_profile("p", sample_profile)
127+
set_active_profile("p")
128+
state = _mock_clone_environment(mock_client, FLAVOR_DISKLESS, 0)
129+
130+
result = invoke(["server", "clone", SRC_ID, "--name", "dst",
131+
"--boot-from-image"])
132+
133+
assert result.exit_code != 0
134+
assert "disk=0" in result.output
135+
assert "body" not in state["posted"]
136+
137+
def test_mutual_exclusion(
138+
self, invoke, config_dir, mock_client, sample_profile,
139+
):
140+
save_profile("p", sample_profile)
141+
set_active_profile("p")
142+
state = _mock_clone_environment(mock_client, FLAVOR_WITH_DISK, 20)
143+
144+
result = invoke(["server", "clone", SRC_ID, "--name", "dst",
145+
"--boot-from-image", "--boot-from-volume"])
146+
147+
assert result.exit_code != 0
148+
assert "mutually exclusive" in result.output
149+
assert "body" not in state["posted"]

0 commit comments

Comments
 (0)