Skip to content

Commit e292e7b

Browse files
committed
test(devstack): add live e2e suite (read+write) against a real cloud
Builds on the read-only DevStack smoke harness with end-to-end tests that actually create and destroy resources, gated behind a `live` pytest marker so they're skipped by default and only run with `pytest -m live tests/devstack/`. Coverage breakdown (12 tests): * compute — flavor + keypair create/list/show/delete * network — network, subnet, security-group create/list/delete * volume — volume create/show/delete * object_store — container create/list/delete * dns — zone create/show/list/delete * key_manager — secret create/list/delete * identity — project + user create/show/list/delete * workflow — full server boot: flavor → keypair → security-group → network → subnet → server (--wait until ACTIVE) → reverse cleanup, catching cross-module integration bugs that isolated tests miss Infrastructure (`conftest.py`): * `live_invoke` — Click CliRunner against the live profile (default `devstack`, override via `ORCA_LIVE_PROFILE`) * `live_client` — authenticated `OrcaClient` for tests that need the service layer directly * `cleanup` — LIFO finaliser registry that runs even on failure, so a botched test can't leave orphaned resources behind * `live_name` — unique traceable resource names (`orca-live-<kind>-<uuid8>`) * `extract_uuid` — pulls IDs out of orca's human-readable create messages, recognising both canonical UUIDs (Nova/Cinder/Neutron) and bare 32-hex Keystone IDs Configuration: `pyproject.toml` registers the `live` marker and pins `addopts = -m 'not live'` so the default `pytest` invocation continues to run only the 2383 mock-based tests. Pre-condition: an orca profile pointing at a working cloud must exist in `~/.orca/config.yaml`. The cirros image fixture lazy-uploads from `/tmp/cirros.img` (or downloads from cirros-cloud.net) on first run and reuses the resulting `orca-live-cirros` image across runs.
1 parent 6b96de8 commit e292e7b

10 files changed

Lines changed: 586 additions & 0 deletions

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ warn_redundant_casts = true
8686

8787
[tool.pytest.ini_options]
8888
testpaths = ["tests"]
89+
addopts = "-m 'not live'"
90+
markers = [
91+
"live: end-to-end tests against a real OpenStack cloud (run with `pytest -m live tests/devstack/`)",
92+
]
8993

9094
[tool.coverage.run]
9195
source = ["orca_cli"]

tests/devstack/conftest.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Shared fixtures for live e2e tests against a real OpenStack cloud.
2+
3+
Tests in this directory are marked ``@pytest.mark.live`` and excluded
4+
from the default ``pytest`` run (see ``pyproject.toml``). To execute::
5+
6+
pytest -m live tests/devstack/
7+
8+
A working orca profile named ``devstack`` (or whatever
9+
``ORCA_LIVE_PROFILE`` points to) must exist in ``~/.orca/config.yaml``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import os
15+
import re
16+
import uuid
17+
from collections.abc import Callable
18+
19+
import pytest
20+
from click.testing import CliRunner
21+
22+
from orca_cli.core.client import OrcaClient
23+
from orca_cli.core.config import load_config
24+
from orca_cli.main import cli
25+
26+
# ── Profile resolution & client ─────────────────────────────────────────
27+
28+
29+
@pytest.fixture(scope="session")
30+
def live_profile_name() -> str:
31+
return os.environ.get("ORCA_LIVE_PROFILE", "devstack")
32+
33+
34+
@pytest.fixture(scope="session")
35+
def live_config(live_profile_name: str) -> dict:
36+
try:
37+
return load_config(live_profile_name)
38+
except Exception as exc:
39+
pytest.skip(f"orca profile {live_profile_name!r} not loadable: {exc}")
40+
41+
42+
@pytest.fixture(scope="session")
43+
def live_client(live_config: dict) -> OrcaClient:
44+
"""Authenticated OrcaClient against the live cloud."""
45+
client = OrcaClient(live_config)
46+
client.authenticate()
47+
return client
48+
49+
50+
# ── CLI runner ───────────────────────────────────────────────────────────
51+
52+
53+
@pytest.fixture
54+
def live_invoke(live_profile_name: str):
55+
"""Invoke the orca CLI in-process against the live profile.
56+
57+
Returns a ``click.testing.Result`` — check ``result.exit_code`` and
58+
``result.output``.
59+
"""
60+
runner = CliRunner()
61+
62+
def _invoke(*args: str, input: str | None = None):
63+
return runner.invoke(
64+
cli,
65+
["-P", live_profile_name, *args],
66+
catch_exceptions=False,
67+
input=input,
68+
)
69+
70+
return _invoke
71+
72+
73+
# ── Cleanup tracker ──────────────────────────────────────────────────────
74+
75+
76+
@pytest.fixture
77+
def cleanup():
78+
"""LIFO cleanup callback registry that runs even on test failure.
79+
80+
Use it like::
81+
82+
def test_foo(live_client, cleanup):
83+
res = create_something(live_client)
84+
cleanup(lambda: delete_something(live_client, res["id"]))
85+
...
86+
87+
Callbacks run in reverse-registration order during teardown, and
88+
failures are logged (not re-raised) so a botched cleanup doesn't
89+
mask the original test result.
90+
"""
91+
callbacks: list[Callable[[], None]] = []
92+
93+
yield callbacks.append
94+
95+
for cb in reversed(callbacks):
96+
try:
97+
cb()
98+
except Exception as exc: # noqa: BLE001 - cleanup must be lenient
99+
print(f"[live-cleanup] {cb!r} failed: {exc}")
100+
101+
102+
# ── Naming helper ────────────────────────────────────────────────────────
103+
104+
105+
@pytest.fixture
106+
def live_name() -> Callable[[str], str]:
107+
"""Return a unique, traceable resource name (prefix + short uuid).
108+
109+
Lets cleanup-by-prefix work if a test crashes hard (Ctrl+C between
110+
creation and registration).
111+
"""
112+
def _name(prefix: str) -> str:
113+
return f"orca-live-{prefix}-{uuid.uuid4().hex[:8]}"
114+
return _name
115+
116+
117+
# ── Output parsing helpers ───────────────────────────────────────────────
118+
119+
120+
_ID_RE = re.compile(
121+
# Canonical UUID (Nova/Cinder/Neutron/Glance) and bare 32-hex (Keystone).
122+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}",
123+
re.IGNORECASE,
124+
)
125+
126+
127+
def extract_uuid(text: str) -> str:
128+
"""Pull the first resource ID out of a CLI message.
129+
130+
orca's create commands print messages like ``Volume 'foo' (uuid) created.``
131+
rather than emitting the ID via ``-f value``, so live tests parse the ID
132+
out of the human-readable output. Both canonical UUIDs (Nova/Cinder/...)
133+
and bare 32-hex IDs (Keystone) are recognised.
134+
"""
135+
match = _ID_RE.search(text)
136+
if not match:
137+
raise AssertionError(f"no resource ID found in output: {text!r}")
138+
return match.group(0)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Live e2e: Nova compute (flavors, keypairs)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tests.devstack.conftest import extract_uuid
8+
9+
pytestmark = pytest.mark.live
10+
11+
12+
def test_flavor_create_show_delete(live_invoke, cleanup, live_name):
13+
name = live_name("flavor")
14+
15+
res = live_invoke("flavor", "create", name,
16+
"--vcpus", "1", "--ram", "64", "--disk", "1")
17+
assert res.exit_code == 0, res.output
18+
flavor_id = extract_uuid(res.output)
19+
cleanup(lambda: live_invoke("flavor", "delete", flavor_id, "--yes"))
20+
21+
res = live_invoke("flavor", "list", "-f", "value", "-c", "ID")
22+
assert res.exit_code == 0
23+
assert flavor_id in res.output
24+
25+
res = live_invoke("flavor", "show", flavor_id, "-f", "value", "-c", "name")
26+
assert res.exit_code == 0
27+
assert name in res.output
28+
29+
30+
def test_keypair_create_delete(live_invoke, cleanup, live_name, tmp_path):
31+
name = live_name("kp")
32+
key_path = tmp_path / f"{name}.pem"
33+
34+
res = live_invoke("keypair", "create", name, "--save-to", str(key_path))
35+
assert res.exit_code == 0, res.output
36+
cleanup(lambda: live_invoke("keypair", "delete", name, "--yes"))
37+
38+
assert key_path.exists()
39+
assert key_path.stat().st_mode & 0o777 == 0o600
40+
41+
res = live_invoke("keypair", "list", "-f", "value", "-c", "Name")
42+
assert res.exit_code == 0
43+
assert name in res.output

tests/devstack/test_live_dns.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Live e2e: Designate DNS zones."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
7+
import pytest
8+
9+
from tests.devstack.conftest import extract_uuid
10+
11+
pytestmark = pytest.mark.live
12+
13+
14+
def test_zone_create_show_delete(live_invoke, cleanup):
15+
# DNS zones must be FQDN ending with a dot. Generate a unique one.
16+
domain = f"orca-live-{uuid.uuid4().hex[:8]}.example.com."
17+
18+
res = live_invoke("zone", "create", domain,
19+
"--email", "[email protected]")
20+
assert res.exit_code == 0, res.output
21+
zone_id = extract_uuid(res.output)
22+
cleanup(lambda: live_invoke("zone", "delete", zone_id, "--yes"))
23+
24+
res = live_invoke("zone", "list", "-f", "value", "-c", "ID")
25+
assert res.exit_code == 0
26+
assert zone_id in res.output
27+
28+
res = live_invoke("zone", "show", zone_id, "-f", "value", "-c", "name")
29+
assert res.exit_code == 0
30+
assert domain in res.output
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Live e2e: Keystone identity (project, user)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tests.devstack.conftest import extract_uuid
8+
9+
pytestmark = pytest.mark.live
10+
11+
12+
def test_project_create_show_delete(live_invoke, cleanup, live_name):
13+
name = live_name("proj")
14+
15+
res = live_invoke("project", "create", name,
16+
"--description", "live test project")
17+
assert res.exit_code == 0, res.output
18+
project_id = extract_uuid(res.output)
19+
cleanup(lambda: live_invoke("project", "delete", project_id, "--yes"))
20+
21+
res = live_invoke("project", "list", "-f", "value", "-c", "ID")
22+
assert res.exit_code == 0
23+
assert project_id in res.output
24+
25+
res = live_invoke("project", "show", project_id, "-f", "value", "-c", "name")
26+
assert res.exit_code == 0
27+
assert name in res.output
28+
29+
30+
def test_user_create_show_delete(live_invoke, cleanup, live_name):
31+
name = live_name("user")
32+
33+
res = live_invoke("user", "create", name, "--password", "pw")
34+
assert res.exit_code == 0, res.output
35+
user_id = extract_uuid(res.output)
36+
cleanup(lambda: live_invoke("user", "delete", user_id, "--yes"))
37+
38+
res = live_invoke("user", "list", "-f", "value", "-c", "ID")
39+
assert res.exit_code == 0
40+
assert user_id in res.output
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Live e2e: Barbican secrets."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
pytestmark = pytest.mark.live
8+
9+
10+
def test_secret_create_list_delete(live_invoke, cleanup, live_name):
11+
name = live_name("secret")
12+
13+
res = live_invoke("secret", "create", name,
14+
"--payload", "live-test-payload",
15+
"--secret-type", "passphrase")
16+
assert res.exit_code == 0, res.output
17+
18+
# Barbican exposes secrets via a "secret_ref" URL whose last segment is the
19+
# secret UUID; orca's create message includes the full ref. List by name.
20+
res = live_invoke("secret", "list", "-f", "value", "-c", "Name", "-c", "Secret href")
21+
assert res.exit_code == 0
22+
assert name in res.output
23+
24+
# Find the secret ref so we can clean up by ID.
25+
line = next((line for line in res.output.splitlines() if name in line), "")
26+
secret_ref = line.split()[-1]
27+
cleanup(lambda: live_invoke("secret", "delete", secret_ref, "--yes"))
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Live e2e: Neutron networks, subnets, security groups."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tests.devstack.conftest import extract_uuid
8+
9+
pytestmark = pytest.mark.live
10+
11+
12+
def test_network_create_show_delete(live_invoke, cleanup, live_name):
13+
name = live_name("net")
14+
15+
res = live_invoke("network", "create", name)
16+
assert res.exit_code == 0, res.output
17+
net_id = extract_uuid(res.output)
18+
cleanup(lambda: live_invoke("network", "delete", net_id, "--yes"))
19+
20+
res = live_invoke("network", "list", "-f", "value", "-c", "ID")
21+
assert res.exit_code == 0
22+
assert net_id in res.output
23+
24+
25+
def test_subnet_create_delete_on_new_network(live_invoke, cleanup, live_name):
26+
net_name = live_name("net")
27+
sub_name = live_name("sub")
28+
29+
res = live_invoke("network", "create", net_name)
30+
assert res.exit_code == 0, res.output
31+
net_id = extract_uuid(res.output)
32+
cleanup(lambda: live_invoke("network", "delete", net_id, "--yes"))
33+
34+
res = live_invoke("network", "subnet", "create", sub_name,
35+
"--network-id", net_id, "--cidr", "10.99.0.0/24")
36+
assert res.exit_code == 0, res.output
37+
sub_id = extract_uuid(res.output)
38+
cleanup(lambda: live_invoke("network", "subnet", "delete", sub_id, "--yes"))
39+
40+
res = live_invoke("network", "subnet", "list", "-f", "value", "-c", "ID")
41+
assert res.exit_code == 0
42+
assert sub_id in res.output
43+
44+
45+
def test_security_group_create_delete(live_invoke, cleanup, live_name):
46+
name = live_name("sg")
47+
48+
res = live_invoke("security-group", "create", name,
49+
"--description", "live test sg")
50+
assert res.exit_code == 0, res.output
51+
sg_id = extract_uuid(res.output)
52+
cleanup(lambda: live_invoke("security-group", "delete", sg_id, "--yes"))
53+
54+
res = live_invoke("security-group", "list", "-f", "value", "-c", "ID")
55+
assert res.exit_code == 0
56+
assert sg_id in res.output
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Live e2e: Swift object-store containers."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
pytestmark = pytest.mark.live
8+
9+
10+
def test_container_create_list_delete(live_invoke, cleanup, live_name):
11+
name = live_name("cont")
12+
13+
res = live_invoke("container", "create", name)
14+
assert res.exit_code == 0, res.output
15+
cleanup(lambda: live_invoke("container", "delete", name, "--yes"))
16+
17+
res = live_invoke("container", "list", "-f", "value", "-c", "Name")
18+
assert res.exit_code == 0
19+
assert name in res.output

tests/devstack/test_live_volume.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Live e2e: Cinder block-storage."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tests.devstack.conftest import extract_uuid
8+
9+
pytestmark = pytest.mark.live
10+
11+
12+
def test_volume_create_show_delete(live_invoke, cleanup, live_name):
13+
name = live_name("vol")
14+
15+
res = live_invoke("volume", "create", "--name", name, "--size", "1")
16+
assert res.exit_code == 0, res.output
17+
vol_id = extract_uuid(res.output)
18+
cleanup(lambda: live_invoke("volume", "delete", vol_id, "--yes"))
19+
20+
res = live_invoke("volume", "list", "-f", "value", "-c", "ID")
21+
assert res.exit_code == 0
22+
assert vol_id in res.output
23+
24+
res = live_invoke("volume", "show", vol_id, "-f", "value", "-c", "name")
25+
assert res.exit_code == 0
26+
assert name in res.output

0 commit comments

Comments
 (0)