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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
# Changelog

## Unreleased
## 3.0.0

### Breaking Changes

- **`ApiResponse`** is the union `ApiSuccess[T] | ApiFailure`. `ApiSuccess` always carries its `payload`, typed per endpoint (for example `ApiResponse[list[VmSummary]]` from `get_vms`); `ApiFailure` carries `status`, `error` and `timed_out`. `if not res:` narrows to `ApiFailure`. Code that constructs `ApiResponse(...)` constructs `ApiSuccess(payload=...)` or `ApiFailure(...)` instead.
- **Response validation**: each endpoint's answer is validated against its shape (`VmSummary`, `NetworkIface`, `NodeStorage`, `StorageVolume`, `NodeVersion`, `TaskStatus`, `HypervisorNode`, `StorageConfig`, all exported). An answer missing a required field, or with `data: null` where a value is due, is an `ApiFailure` naming the field. Fields the shapes do not name are kept.

### Changed

- **Build backend**: the package builds with `uv_build`. The wheel's contents are unchanged.
- **Discovery**: a hypervisor node whose VMs cannot be listed is left out of `discover_nodes`, rather than treated as running none.
- **`download_iso`** sends `verify-certificates` as `0`/`1`. The Ansible `iso_info` module downloads through it.

### Fixed

- **TUI**: adding a hypervisor whose connection times out reports the timeout instead of validating it.
- **CLI**: `arcane-mage ping` reports "Authentication failed" for a rejected token.

## 2.3.0

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ This installs the library, the CLI and TUI extras, and the dev tools.
```bash
uv run ruff check
uv run ruff format
uv run ty check
uv run pytest
```

All three must be clean.
All four must be clean.

## Pull requests

Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,19 @@ if creds:

# Use as an async context manager
async with api:
version = await api.get_api_version("pve1")
vms = await api.get_vms("pve1")
res = await api.get_vms("pve1")

# A failed call is an ApiFailure (status, error, timed_out); past this check
# it is an ApiSuccess whose payload has the endpoint's type, here list[VmSummary].
if not res:
print(f"Unable to list VMs: {res.error}")
else:
for vm in res.payload:
print(vm["vmid"], vm.get("name"), vm["status"])
```

Every call validates Proxmox's answer against the endpoint's shape. An answer missing a field arcane-mage relies on comes back as an `ApiFailure` saying which field; fields arcane-mage does not name are kept as Proxmox sent them.

#### Provisioning Nodes

```python
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,14 @@ async def run_iso_info(params):
if existing:
return True, iso_name, False, ""

# Download via Proxmox API (direct POST without verify-certificates param)
# URL format: https://images.runonflux.io/arcane/releases/<build>/FluxLive-<build>.iso
build_id = iso_name.replace("FluxLive-", "").replace(".iso", "")
download_url = f"https://images.runonflux.io/arcane/releases/{build_id}/{iso_name}"

endpoint = f"nodes/{params['node']}/storage/{params['storage_iso']}/download-url"
data = {"content": "iso", "filename": iso_name, "url": download_url}

res = await api._do_post(endpoint, data=data)
res = await api.download_iso(params["node"], download_url, iso_name, params["storage_iso"])

if not res:
error_detail = f"status={res.status} error={res.error}" if res else "no response"
return False, iso_name, False, f"Failed to initiate ISO download: {error_detail}"
return False, iso_name, False, f"Failed to initiate ISO download: status={res.status} error={res.error}"

# Wait for download task
ok = await api.wait_for_task(res.payload, params["node"], max_wait_s=600)
Expand Down
11 changes: 9 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "arcane-mage"
version = "2.3.0"
version = "3.0.0"
description = "Automation helper for Fluxnodes"
readme = "README.md"
authors = [
Expand Down Expand Up @@ -39,8 +39,11 @@ requires = ["uv_build>=0.13.3,<0.14"]
build-backend = "uv_build"

[dependency-groups]
ansible = [
"ansible-core>=2.21.5",
]
dev = [
"aioresponses>=0.7.8",
"pyrage-stubs>=1.4.0",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"ruff>=0.17.2",
Expand All @@ -50,6 +53,10 @@ dev = [
"types-pyyaml>=6.0.12.20250915",
]

[tool.uv]
# The Ansible collection runs inside Ansible; ansible-core is here so ty can check it.
default-groups = ["dev", "ansible"]

[tool.ruff]
target-version = "py313"
line-length = 120
Expand Down
28 changes: 27 additions & 1 deletion src/arcane_mage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,30 @@
)
from .password import HashedPassword
from .provisioner import TIER_CONFIG, HypervisorDiscovery, Provisioner, get_latest_iso_version
from .proxmox import ApiResponse, ParsedToken, ParsedUserPass, ProxmoxApi, ResolvedConnection
from .proxmox import (
ApiFailure,
ApiResponse,
ApiSuccess,
HypervisorNode,
NetworkIface,
NodeStorage,
NodeVersion,
ParsedToken,
ParsedUserPass,
ProxmoxApi,
ResolvedConnection,
StorageConfig,
StorageVolume,
TaskStatus,
VmSummary,
)

__all__ = [
"TIER_CONFIG",
"AddressConfig",
"ApiFailure",
"ApiResponse",
"ApiSuccess",
"ArcaneCreatorConfig",
"ArcaneOsConfig",
"ArcaneOsConfigGroup",
Expand All @@ -36,17 +54,25 @@
"Hypervisor",
"HypervisorConfig",
"HypervisorDiscovery",
"HypervisorNode",
"Identifier",
"Identity",
"InstallerConfig",
"MetricsAppConfig",
"NetworkConfig",
"NetworkIface",
"NodeStorage",
"NodeVersion",
"Notifications",
"ParsedToken",
"ParsedUserPass",
"Provisioner",
"ProxmoxApi",
"ResolvedConnection",
"StorageConfig",
"StorageVolume",
"SystemConfig",
"TaskStatus",
"VmSummary",
"get_latest_iso_version",
]
36 changes: 20 additions & 16 deletions src/arcane_mage/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
import sys
from importlib.metadata import version as get_version
from pathlib import Path
from typing import TypedDict
from urllib.parse import urlparse

from rich.console import Console
from rich.panel import Panel
from rich.table import Table

from .models import ArcaneCreatorConfig, ArcaneOsConfigGroup
from .proxmox import ProxmoxApi, ResolvedConnection
from .proxmox import ProxmoxApi, ResolvedConnection, VmSummary

app = typer.Typer(
name="arcane-mage",
Expand Down Expand Up @@ -96,8 +97,8 @@ async def _backfill_hypervisor_names(creator_config: ArcaneCreatorConfig) -> Non

async with api:
res = await api.get_hypervisor_nodes()
if res and isinstance(res.payload, list):
names = [n.get("node", "") for n in res.payload if n.get("node")]
if res:
names = [n["node"] for n in res.payload]
if names:
hyper.name = names[0] if len(names) == 1 else ",".join(sorted(names))
dirty = True
Expand Down Expand Up @@ -171,6 +172,12 @@ def _resolve_connection(
return ResolvedConnection(resolved_url, parsed)


class _Check(TypedDict):
check: str
ok: bool
message: str


def _load_configs(config: str) -> ArcaneOsConfigGroup:
configs = ArcaneOsConfigGroup.from_fs(Path(config))
if not configs:
Expand Down Expand Up @@ -310,9 +317,6 @@ def deprovision(
if use_json and not force:
raise CliError("--json requires --force for destructive operations")

if not direct_vm and not config:
config = "fluxnodes.yaml"

if direct_vm and config:
raise CliError("Cannot specify --config with --vm-name or --vm-id")

Expand All @@ -330,21 +334,21 @@ async def run_direct():
provisioner = Provisioner(api)

res = await api.get_hypervisor_nodes()
if not res or not isinstance(res.payload, list):
if not res:
raise CliError("Unable to discover hypervisor nodes")

hv_nodes = [n.get("node") for n in res.payload if n.get("node")]
hv_nodes = [n["node"] for n in res.payload]
if node_filter:
hv_nodes = [n for n in hv_nodes if n == node_filter]

found_vm: dict | None = None
found_vm: VmSummary | None = None
found_node: str | None = None
for hv_node in hv_nodes:
vms_res = await api.get_vms(hv_node)
if not vms_res:
continue
for v in vms_res.payload:
if vm_id is not None and v.get("vmid") == vm_id:
if vm_id is not None and v["vmid"] == vm_id:
found_vm = v
found_node = hv_node
break
Expand Down Expand Up @@ -411,7 +415,7 @@ def callback(ok: bool, msg: str, steps: list[dict[str, object]] = steps):
return

try:
configs = _load_configs(config)
configs = _load_configs(config or "fluxnodes.yaml")
except CliError as e:
_handle_error(e, use_json)

Expand Down Expand Up @@ -534,7 +538,7 @@ async def run():
all_ok = False
continue

checks = []
checks: list[_Check] = []

api_ok, api_msg = await provisioner.validate_api_version(hyper.node)
checks.append(
Expand Down Expand Up @@ -630,17 +634,17 @@ async def run():
res = await api.get_hypervisor_nodes()

if not res:
if res and res.unauthorized:
if res.unauthorized:
raise CliError("Authentication failed")
raise CliError(f"Unable to connect to {conn.url}")

nodes = [n.get("node", "unknown") for n in res.payload] if isinstance(res.payload, list) else []
nodes = [n["node"] for n in res.payload]

version = None
if nodes:
ver_res = await api.get_api_version(nodes[0])
if ver_res and ver_res.payload:
version = ver_res.payload.get("version")
if ver_res:
version = ver_res.payload["version"]

data = {
"url": conn.url,
Expand Down
22 changes: 15 additions & 7 deletions src/arcane_mage/arcane_mage.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from collections.abc import Callable
from functools import partial
from pathlib import Path
from typing import ClassVar
Expand Down Expand Up @@ -29,7 +30,7 @@
class ArcaneMage(App):
CSS_PATH = "arcane_mage.tcss"
AUTO_FOCUS = None
SCREENS: ClassVar[dict[str, type[Screen]]] = {
SCREENS: ClassVar[dict[str, Callable[[], Screen]]] = {
"welcome": WelcomeScreen,
}

Expand Down Expand Up @@ -64,7 +65,7 @@ def hypervisor_callback(self, hypervisor: HypervisorConfig | None) -> None:
screen = self.get_screen("welcome-proxmox", WelcomeScreenProxmox)
screen.validate_hypervisors(hypervisor)

def del_hypervisor_callback(self, hypervisor: HypervisorConfig, delete: bool) -> None:
def del_hypervisor_callback(self, hypervisor: HypervisorConfig, delete: bool | None) -> None:
if not delete:
return

Expand Down Expand Up @@ -150,9 +151,12 @@ def on_del_hypervisor(self, event: WelcomeScreenProxmox.DelHypervisor) -> None:
def on_provision_node(self, event: WelcomeScreenProxmox.ProvisionNode) -> None:
self.push_screen(
ProvisionNodeScreen(event.fluxnode),
self.provision_node_callback,
self.provision_configured_node,
)

async def provision_configured_node(self, fluxnode: ArcaneOsConfig | None) -> None:
await self.provision_node_callback(fluxnode)

@on(WelcomeScreenProxmox.ProvisionAllNodes)
def on_provision_all_nodes(self, event: WelcomeScreenProxmox.ProvisionAllNodes) -> None:
self.provision_all_nodes(event.delay)
Expand Down Expand Up @@ -188,8 +192,10 @@ async def provision_all_nodes(self, delay: int) -> None:

needs_pop = len(provisionable_nodes) > 1

if delay and configured_node != provisionable_nodes.last:
worker = self.screen.show_delay(delay)
info_screen = self.screen

if delay and configured_node != provisionable_nodes.last and isinstance(info_screen, ProvisioningInfoScreen):
worker = info_screen.show_delay(delay)

try:
await worker.wait()
Expand Down Expand Up @@ -218,8 +224,10 @@ async def provision_all_nodes(self, delay: int) -> None:
if not result:
break

if delay and not last:
worker = self.screen.show_delay(delay)
info_screen = self.screen

if delay and not last and isinstance(info_screen, ProvisioningInfoScreen):
worker = info_screen.show_delay(delay)

try:
await worker.wait()
Expand Down
15 changes: 5 additions & 10 deletions src/arcane_mage/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,28 +82,24 @@ async def exec_binary(
if len(cmd) == 0:
raise ChildProcessError("Cannot execute empty cmd")

extra_params = {}

if cwd is not None:
extra_params["cwd"] = cwd
preexec_fn: Callable[[], None] | None = None

if user is not None:
try:
pw_record = pwd.getpwnam(user)
except KeyError as e:
raise ChildProcessError("User not found") from e

extra_params["preexec_fn"] = demote(pw_record.pw_uid, pw_record.pw_gid)

if env:
extra_params["env"] = env
preexec_fn = demote(pw_record.pw_uid, pw_record.pw_gid)

try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
**extra_params,
cwd=cwd,
preexec_fn=preexec_fn,
env=env or None,
)
except FileNotFoundError as e:
raise ChildProcessError(f"Binary not found: {cmd[0]}, is it installed?") from e
Expand Down Expand Up @@ -169,7 +165,6 @@ async def do_http(
json=data,
ssl=verify_ssl,
) as resp:
resp: ClientResponse # type: ignore
if resp.status in [429, 500, 502, 503, 504]:
log.debug("bad response: %s", resp.status)
if remaining or not max_tries:
Expand Down
Loading