proxcli 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxcli-0.2.0/.env.example +3 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/.gitignore +3 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/CHANGELOG.md +13 -1
- {proxcli-0.1.0 → proxcli-0.2.0}/PKG-INFO +4 -4
- {proxcli-0.1.0 → proxcli-0.2.0}/README.md +3 -3
- proxcli-0.2.0/TODO.md +85 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/container.py +3 -3
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/storage.py +22 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/vm.py +3 -3
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/client.py +65 -0
- proxcli-0.2.0/proxmox/utils/helpers.py +32 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/pyproject.toml +1 -1
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_client.py +93 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/uv.lock +2 -2
- proxcli-0.1.0/proxmox/utils/helpers.py +0 -14
- {proxcli-0.1.0 → proxcli-0.2.0}/.github/workflows/ci.yml +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/.python-version +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/PLAN.md +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/PROJECT.md +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/PROMPT.md +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/auth.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/cluster.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/main.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/node.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/tasks.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/auth.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/exceptions.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/config.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/models.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/formatter.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/json_fmt.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/table_fmt.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/yaml_fmt.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/utils/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/utils/logging.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/conftest.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_auth.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_cli/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_cli/test_main.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_config.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_integration/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_output/__init__.py +0 -0
- {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_output/test_formatter.py +0 -0
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-06-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `proxmox storage upload` command for uploading ISO, vztmpl, and import files to storage via multipart/form-data.
|
|
14
|
+
- `ProxmoxClient.upload()` method supporting file uploads with content type selection.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `proxmox vm create --vmid` and `proxmox container create --vmid` are now optional.
|
|
18
|
+
The next free VMID is auto-assigned via the `/cluster/nextid` API when omitted.
|
|
19
|
+
|
|
10
20
|
## [0.1.0] - 2026-06-20
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -26,4 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
26
36
|
- CSRF ticket auto-refresh on 401.
|
|
27
37
|
- AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
|
|
28
38
|
|
|
29
|
-
[0.
|
|
39
|
+
[0.2.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.0
|
|
40
|
+
[0.1.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.1
|
|
41
|
+
[0.1.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A CLI tool to interact with Proxmox VE nodes and clusters via the REST API
|
|
5
5
|
Author-email: Xabi Ezpeleta <xezpeleta@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -36,7 +36,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
|
36
36
|
uv tool install proxmox
|
|
37
37
|
|
|
38
38
|
# From Git
|
|
39
|
-
uv tool install git+https://github.com/xezpeleta/
|
|
39
|
+
uv tool install git+https://github.com/xezpeleta/proxcli.git
|
|
40
40
|
|
|
41
41
|
# From local checkout
|
|
42
42
|
uv tool install .
|
|
@@ -238,8 +238,8 @@ proxmox vm show 999 || echo "VM not found"
|
|
|
238
238
|
|
|
239
239
|
```bash
|
|
240
240
|
# Clone
|
|
241
|
-
git clone https://github.com/xezpeleta/
|
|
242
|
-
cd
|
|
241
|
+
git clone https://github.com/xezpeleta/proxcli.git
|
|
242
|
+
cd proxcli
|
|
243
243
|
|
|
244
244
|
# Install dev dependencies
|
|
245
245
|
uv sync
|
|
@@ -13,7 +13,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
|
|
13
13
|
uv tool install proxmox
|
|
14
14
|
|
|
15
15
|
# From Git
|
|
16
|
-
uv tool install git+https://github.com/xezpeleta/
|
|
16
|
+
uv tool install git+https://github.com/xezpeleta/proxcli.git
|
|
17
17
|
|
|
18
18
|
# From local checkout
|
|
19
19
|
uv tool install .
|
|
@@ -215,8 +215,8 @@ proxmox vm show 999 || echo "VM not found"
|
|
|
215
215
|
|
|
216
216
|
```bash
|
|
217
217
|
# Clone
|
|
218
|
-
git clone https://github.com/xezpeleta/
|
|
219
|
-
cd
|
|
218
|
+
git clone https://github.com/xezpeleta/proxcli.git
|
|
219
|
+
cd proxcli
|
|
220
220
|
|
|
221
221
|
# Install dev dependencies
|
|
222
222
|
uv sync
|
proxcli-0.2.0/TODO.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
Planned improvements for future releases. Items are roughly ordered by priority.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## v1.1 — Polish & Usability
|
|
8
|
+
|
|
9
|
+
- [ ] **Shell completions**
|
|
10
|
+
- Add `proxmox completion bash|zsh|fish` subcommand that emits a completion script. Use argparse's built-in completion or a lightweight generator. Makes tab-completion work for all subcommands and flags.
|
|
11
|
+
|
|
12
|
+
- [ ] **Streaming task logs (`--follow`)**
|
|
13
|
+
- `proxmox task log <upid>` that streams task output in real time (like `tail -f`). Requires httpx streaming support (already viable — httpx is the chosen HTTP client).
|
|
14
|
+
|
|
15
|
+
- [ ] **Startup time optimization**
|
|
16
|
+
- Current `proxmox --help` takes ~350ms. Lazy-load subcommand modules so only the requested resource's code is imported. Move `import rich`, `import yaml` inside formatter functions. Target: <200ms.
|
|
17
|
+
|
|
18
|
+
- [ ] **`--output table` column selection**
|
|
19
|
+
- Allow `proxmox vm list --output table --columns vmid,name,status,mem` to pick which columns appear in the table.
|
|
20
|
+
|
|
21
|
+
- [ ] **Color support in table output**
|
|
22
|
+
- Use `rich` styling for status values (green= running, red= stopped, yellow= suspended).
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## v1.2 — Resource Coverage
|
|
27
|
+
|
|
28
|
+
- [ ] **Backup (`vzdump`) management**
|
|
29
|
+
- `proxmox backup` subcommand: `list`, `create`, `show`, `delete`. Wrap `/nodes/{node}/vzdump` and `/nodes/{node}/storage/{storage}/content` for backup files.
|
|
30
|
+
|
|
31
|
+
- [ ] **User & permission management**
|
|
32
|
+
- `proxmox user` subcommand: `list`, `show`, `create`, `update`, `delete`. Wraps `/access/users`, `/access/acl`, `/access/roles`.
|
|
33
|
+
|
|
34
|
+
- [ ] **Pool management**
|
|
35
|
+
- `proxmox pool` subcommand: `list`, `show`, `create`, `update`, `delete`. Wraps `/pools`.
|
|
36
|
+
|
|
37
|
+
- [ ] **Network management**
|
|
38
|
+
- `proxmox network` subcommand: `list`, `show`, `update` for bridges, bonds, VLANs. Wraps `/nodes/{node}/network`.
|
|
39
|
+
|
|
40
|
+
- [ ] **Firewall management**
|
|
41
|
+
- `proxmox firewall` subcommand: `list`, `show`, `enable`, `disable`, `create rule`, `delete rule`. Wraps `/nodes/{node}/firewall`, `/cluster/firewall`.
|
|
42
|
+
|
|
43
|
+
- [ ] **SDN (Software-Defined Networking)**
|
|
44
|
+
- `proxmox sdn` subcommand: `zones`, `vnets`, `subnets`. Wraps `/cluster/sdn/*` endpoints.
|
|
45
|
+
|
|
46
|
+
- [ ] **HA (High Availability)**
|
|
47
|
+
- `proxmox ha` subcommand: `status`, `groups`, `resources`. Wraps `/cluster/ha/*` endpoints.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## v2.0 — Multi-Cluster & Advanced
|
|
52
|
+
|
|
53
|
+
- [ ] **Multi-profile / multi-cluster support**
|
|
54
|
+
- Support `--profile <name>` global flag to switch between multiple saved Proxmox endpoints. Config file format extended from single endpoint to a profiles dict. `proxmox auth login --profile homelab` and `proxmox auth login --profile work` coexist.
|
|
55
|
+
|
|
56
|
+
- [ ] **Batch / bulk operations**
|
|
57
|
+
- `proxmox vm start --all-on-node pve01` (start all VMs on a node). `proxmox vm snapshot --vmid 100,101,102` (apply to multiple IDs).
|
|
58
|
+
|
|
59
|
+
- [ ] **Config file templating**
|
|
60
|
+
- Ability to define VM/container specs in a YAML/JSON file and create from it: `proxmox vm create --file my-vm.yaml`.
|
|
61
|
+
|
|
62
|
+
- [ ] **Plugin system for custom commands**
|
|
63
|
+
- Allow users to extend the CLI with custom subcommands via a plugins directory.
|
|
64
|
+
|
|
65
|
+
- [ ] **Dry-run diff mode**
|
|
66
|
+
- `--dry-run` that shows what *would change* on the Proxmox side (e.g., before/after VM config diff) rather than just the HTTP request.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Ideas (not yet scheduled)
|
|
71
|
+
|
|
72
|
+
- [ ] **Interactive mode / TUI**
|
|
73
|
+
- A `proxmox tui` command that opens a terminal UI (like `htop` but for Proxmox resources). Low priority — the CLI is designed for automation first.
|
|
74
|
+
|
|
75
|
+
- [ ] **Webhook / event listener**
|
|
76
|
+
- Subscribe to Proxmox cluster events and pipe them to a webhook or stdout for external monitoring.
|
|
77
|
+
|
|
78
|
+
- [ ] **VM migration wizard**
|
|
79
|
+
- `proxmox vm migrate <vmid> --to <target-node>` with progress tracking and live migration support.
|
|
80
|
+
|
|
81
|
+
- [ ] **Proxmox Backup Server (PBS) integration**
|
|
82
|
+
- Separate subcommand or a companion tool (`proxbackup`?) for managing PBS instances via their API.
|
|
83
|
+
|
|
84
|
+
- [ ] **Terraform / Pulumi bridge**
|
|
85
|
+
- Export current Proxmox state as Terraform HCL or Pulumi Python/TypeScript, enabling import into IaC.
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
|
|
7
7
|
from proxmox.client.client import ProxmoxClient
|
|
8
|
-
from proxmox.utils.helpers import vmid_type
|
|
8
|
+
from proxmox.utils.helpers import resolve_vmid, vmid_type
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def register_container_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
@@ -27,7 +27,7 @@ def register_container_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
27
27
|
# --- container create ---
|
|
28
28
|
ct_create = ct_sub.add_parser("create", help="Create a new container")
|
|
29
29
|
ct_create.add_argument("--node", required=True, help="Target node")
|
|
30
|
-
ct_create.add_argument("--vmid", type=vmid_type,
|
|
30
|
+
ct_create.add_argument("--vmid", type=vmid_type, default=None, help="Container ID (auto-assigned if omitted)")
|
|
31
31
|
ct_create.add_argument("--ostemplate", required=True, help="OS template")
|
|
32
32
|
ct_create.add_argument("--storage", default=None, help="Storage for the container")
|
|
33
33
|
ct_create.add_argument("--memory", type=int, default=512, help="Memory in MB")
|
|
@@ -117,7 +117,7 @@ def _ct_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
|
117
117
|
|
|
118
118
|
def _ct_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
119
119
|
data: dict = {
|
|
120
|
-
"vmid": args.vmid,
|
|
120
|
+
"vmid": resolve_vmid(client, args.vmid),
|
|
121
121
|
"ostemplate": args.ostemplate,
|
|
122
122
|
"memory": args.memory,
|
|
123
123
|
"cores": args.cores,
|
|
@@ -28,6 +28,19 @@ def register_storage_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
28
28
|
st_content.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
29
29
|
st_content.set_defaults(func=_st_content)
|
|
30
30
|
|
|
31
|
+
# --- storage upload ---
|
|
32
|
+
st_upload = st_sub.add_parser("upload", help="Upload a file to storage")
|
|
33
|
+
st_upload.add_argument("--node", required=True, help="Target node")
|
|
34
|
+
st_upload.add_argument("--storage", required=True, help="Storage ID (e.g. 'local')")
|
|
35
|
+
st_upload.add_argument("--file", required=True, help="Path to the local file")
|
|
36
|
+
st_upload.add_argument(
|
|
37
|
+
"--content-type",
|
|
38
|
+
default="iso",
|
|
39
|
+
choices=["iso", "vztmpl", "import"],
|
|
40
|
+
help="Content type (default: iso)",
|
|
41
|
+
)
|
|
42
|
+
st_upload.set_defaults(func=_st_upload)
|
|
43
|
+
|
|
31
44
|
|
|
32
45
|
def _st_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
33
46
|
if args.node:
|
|
@@ -61,3 +74,12 @@ def _st_content(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
|
61
74
|
if not node:
|
|
62
75
|
return {"error": f"Could not determine node for storage '{args.storage_name}'"}
|
|
63
76
|
return client.get(f"/nodes/{node}/storage/{args.storage_name}/content")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _st_upload(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
80
|
+
return client.upload(
|
|
81
|
+
node=args.node,
|
|
82
|
+
storage=args.storage,
|
|
83
|
+
file_path=args.file,
|
|
84
|
+
content_type=args.content_type,
|
|
85
|
+
)
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
|
|
7
7
|
from proxmox.client.client import ProxmoxClient
|
|
8
|
-
from proxmox.utils.helpers import vmid_type
|
|
8
|
+
from proxmox.utils.helpers import resolve_vmid, vmid_type
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
@@ -27,7 +27,7 @@ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
|
27
27
|
# --- vm create ---
|
|
28
28
|
vm_create = vm_sub.add_parser("create", help="Create a new VM")
|
|
29
29
|
vm_create.add_argument("--node", required=True, help="Target node")
|
|
30
|
-
vm_create.add_argument("--vmid", type=vmid_type,
|
|
30
|
+
vm_create.add_argument("--vmid", type=vmid_type, default=None, help="VM ID (auto-assigned if omitted)")
|
|
31
31
|
vm_create.add_argument("--memory", type=int, required=True, help="Memory in MB")
|
|
32
32
|
vm_create.add_argument("--cores", type=int, default=1, help="CPU cores (default: 1)")
|
|
33
33
|
vm_create.add_argument("--net", default=None, help="Network config (e.g. model=virtio,bridge=vmbr0)")
|
|
@@ -141,7 +141,7 @@ def _vm_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
|
141
141
|
|
|
142
142
|
def _vm_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
143
143
|
data: dict = {
|
|
144
|
-
"vmid": args.vmid,
|
|
144
|
+
"vmid": resolve_vmid(client, args.vmid),
|
|
145
145
|
"memory": args.memory,
|
|
146
146
|
"cores": args.cores,
|
|
147
147
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import time
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -93,6 +94,70 @@ class ProxmoxClient:
|
|
|
93
94
|
) -> dict[str, Any] | list[Any]:
|
|
94
95
|
return self.request("DELETE", path, params=params)
|
|
95
96
|
|
|
97
|
+
def upload(
|
|
98
|
+
self,
|
|
99
|
+
node: str,
|
|
100
|
+
storage: str,
|
|
101
|
+
file_path: str,
|
|
102
|
+
*,
|
|
103
|
+
content_type: str = "iso",
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Upload a file to a storage via multipart/form-data.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
node: Target node name.
|
|
109
|
+
storage: Storage ID (e.g. 'local').
|
|
110
|
+
file_path: Path to the local file to upload.
|
|
111
|
+
content_type: Proxmox content type ('iso', 'vztmpl', 'import').
|
|
112
|
+
"""
|
|
113
|
+
path = f"/nodes/{node}/storage/{storage}/upload"
|
|
114
|
+
full_url = f"{self._base_url}/api2/json{path}"
|
|
115
|
+
|
|
116
|
+
if self._dry_run:
|
|
117
|
+
print(f"POST {full_url}")
|
|
118
|
+
print(f"Headers: {self._auth.get_headers()}")
|
|
119
|
+
print(f"File: {file_path} (content={content_type})")
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
if not os.path.isfile(file_path):
|
|
123
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
124
|
+
|
|
125
|
+
filename = os.path.basename(file_path)
|
|
126
|
+
file_size = os.path.getsize(file_path)
|
|
127
|
+
|
|
128
|
+
self._debug(f"POST {full_url}")
|
|
129
|
+
self._debug(f" file: {file_path} ({file_size} bytes)")
|
|
130
|
+
|
|
131
|
+
headers = self._auth.get_headers()
|
|
132
|
+
|
|
133
|
+
with open(file_path, "rb") as f:
|
|
134
|
+
try:
|
|
135
|
+
resp = httpx.post(
|
|
136
|
+
full_url,
|
|
137
|
+
files={"filename": (filename, f, "application/octet-stream")},
|
|
138
|
+
data={"content": content_type},
|
|
139
|
+
headers=headers,
|
|
140
|
+
timeout=self._timeout,
|
|
141
|
+
verify=self._verify_tls,
|
|
142
|
+
)
|
|
143
|
+
except httpx.RequestError as exc:
|
|
144
|
+
msg = str(exc)
|
|
145
|
+
if "SSL" in msg or "certificate" in msg.lower():
|
|
146
|
+
msg += "\nHint: use --insecure to skip TLS verification"
|
|
147
|
+
raise ProxmoxAPIError(0, {"message": msg}, full_url) from exc
|
|
148
|
+
|
|
149
|
+
self._debug(f" ← {resp.status_code}")
|
|
150
|
+
|
|
151
|
+
if not (200 <= resp.status_code < 300):
|
|
152
|
+
try:
|
|
153
|
+
body = resp.json()
|
|
154
|
+
except Exception:
|
|
155
|
+
body = {"message": resp.text}
|
|
156
|
+
raise ProxmoxAPIError(resp.status_code, body, full_url)
|
|
157
|
+
|
|
158
|
+
envelope = resp.json()
|
|
159
|
+
return envelope.get("data", envelope)
|
|
160
|
+
|
|
96
161
|
def set_credentials(self, username: str, password: str) -> None:
|
|
97
162
|
"""Store credentials for lazy / auto-refresh authentication."""
|
|
98
163
|
self._username = username
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from proxmox.client.client import ProxmoxClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def vmid_type(value: str) -> int:
|
|
12
|
+
"""Argparse type for validating VMID (positive integer)."""
|
|
13
|
+
try:
|
|
14
|
+
v = int(value)
|
|
15
|
+
except ValueError:
|
|
16
|
+
raise ValueError(f"Invalid VMID '{value}': must be an integer")
|
|
17
|
+
if v <= 0:
|
|
18
|
+
raise ValueError(f"Invalid VMID '{value}': must be positive")
|
|
19
|
+
return v
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_vmid(client: ProxmoxClient, vmid: int | None) -> int:
|
|
23
|
+
"""Return *vmid* if provided, otherwise fetch the next free VMID from the cluster."""
|
|
24
|
+
if vmid is not None and vmid > 0:
|
|
25
|
+
return vmid
|
|
26
|
+
result = client.get("/cluster/nextid")
|
|
27
|
+
if isinstance(result, dict):
|
|
28
|
+
# Proxmox returns {"data": "<nextid>"} or just "<nextid>" as string
|
|
29
|
+
raw = result.get("data", result)
|
|
30
|
+
if isinstance(raw, str):
|
|
31
|
+
return int(raw)
|
|
32
|
+
return int(result) if isinstance(result, (str, int)) else 0
|
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
from proxmox.client.auth import AuthManager
|
|
9
9
|
from proxmox.client.client import ProxmoxClient
|
|
10
10
|
from proxmox.client.exceptions import ProxmoxAPIError
|
|
11
|
+
from proxmox.utils.helpers import resolve_vmid
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class TestProxmoxClient:
|
|
@@ -119,3 +120,95 @@ class TestProxmoxClient:
|
|
|
119
120
|
client = ProxmoxClient("https://pve:8006/", AuthManager())
|
|
120
121
|
result = client.get("/nodes")
|
|
121
122
|
assert result == []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestResolveVmid:
|
|
126
|
+
def test_returns_provided_vmid(self, mock_httpx_client):
|
|
127
|
+
"""When a vmid is provided, return it without calling the API."""
|
|
128
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
129
|
+
result = resolve_vmid(client, 110)
|
|
130
|
+
assert result == 110
|
|
131
|
+
assert len(mock_httpx_client.get_requests()) == 0
|
|
132
|
+
|
|
133
|
+
def test_fetches_nextid_when_vmid_is_none(self, mock_httpx_client):
|
|
134
|
+
"""When vmid is None, call /cluster/nextid."""
|
|
135
|
+
mock_httpx_client.add_response(
|
|
136
|
+
url="https://pve:8006/api2/json/cluster/nextid",
|
|
137
|
+
json={"data": "102"},
|
|
138
|
+
)
|
|
139
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
140
|
+
result = resolve_vmid(client, None)
|
|
141
|
+
assert result == 102
|
|
142
|
+
assert len(mock_httpx_client.get_requests()) == 1
|
|
143
|
+
|
|
144
|
+
def test_fetches_nextid_when_vmid_is_zero(self, mock_httpx_client):
|
|
145
|
+
"""When vmid is 0, also fetch nextid (0 is not a valid VMID)."""
|
|
146
|
+
mock_httpx_client.add_response(
|
|
147
|
+
url="https://pve:8006/api2/json/cluster/nextid",
|
|
148
|
+
json={"data": "105"},
|
|
149
|
+
)
|
|
150
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
151
|
+
result = resolve_vmid(client, 0)
|
|
152
|
+
assert result == 105
|
|
153
|
+
|
|
154
|
+
def test_nextid_returns_integer(self, mock_httpx_client):
|
|
155
|
+
"""Some Proxmox versions return nextid as integer."""
|
|
156
|
+
mock_httpx_client.add_response(
|
|
157
|
+
url="https://pve:8006/api2/json/cluster/nextid",
|
|
158
|
+
json={"data": 108},
|
|
159
|
+
)
|
|
160
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
161
|
+
result = resolve_vmid(client, None)
|
|
162
|
+
assert result == 108
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestStorageUpload:
|
|
166
|
+
def test_upload_success(self, mock_httpx_client, tmp_path):
|
|
167
|
+
"""Upload sends multipart request and returns data."""
|
|
168
|
+
iso_file = tmp_path / "test.iso"
|
|
169
|
+
iso_file.write_bytes(b"fake iso content")
|
|
170
|
+
|
|
171
|
+
mock_httpx_client.add_response(
|
|
172
|
+
method="POST",
|
|
173
|
+
url="https://pve:8006/api2/json/nodes/pve01/storage/local/upload",
|
|
174
|
+
json={"data": "file test.iso uploaded"},
|
|
175
|
+
)
|
|
176
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
177
|
+
result = client.upload("pve01", "local", str(iso_file))
|
|
178
|
+
assert result == "file test.iso uploaded"
|
|
179
|
+
|
|
180
|
+
# Verify the request was multipart
|
|
181
|
+
requests = mock_httpx_client.get_requests()
|
|
182
|
+
assert len(requests) == 1
|
|
183
|
+
assert "multipart/form-data" in requests[0].headers.get("content-type", "")
|
|
184
|
+
|
|
185
|
+
def test_upload_dry_run(self, capsys):
|
|
186
|
+
"""Upload in dry-run mode prints request without executing."""
|
|
187
|
+
client = ProxmoxClient("https://pve:8006", AuthManager(), dry_run=True)
|
|
188
|
+
result = client.upload("pve01", "local", "/fake/path.iso")
|
|
189
|
+
assert result == {}
|
|
190
|
+
captured = capsys.readouterr()
|
|
191
|
+
assert "POST https://pve:8006/api2/json/nodes/pve01/storage/local/upload" in captured.out
|
|
192
|
+
assert "/fake/path.iso" in captured.out
|
|
193
|
+
|
|
194
|
+
def test_upload_file_not_found(self):
|
|
195
|
+
"""Upload raises FileNotFoundError for missing files."""
|
|
196
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
197
|
+
with pytest.raises(FileNotFoundError, match="File not found"):
|
|
198
|
+
client.upload("pve01", "local", "/nonexistent/file.iso")
|
|
199
|
+
|
|
200
|
+
def test_upload_error_response(self, mock_httpx_client, tmp_path):
|
|
201
|
+
"""Upload raises ProxmoxAPIError on non-2xx response."""
|
|
202
|
+
iso_file = tmp_path / "test.iso"
|
|
203
|
+
iso_file.write_bytes(b"data")
|
|
204
|
+
|
|
205
|
+
mock_httpx_client.add_response(
|
|
206
|
+
method="POST",
|
|
207
|
+
url="https://pve:8006/api2/json/nodes/pve01/storage/local/upload",
|
|
208
|
+
status_code=403,
|
|
209
|
+
json={"message": "permission denied"},
|
|
210
|
+
)
|
|
211
|
+
client = ProxmoxClient("https://pve:8006", AuthManager())
|
|
212
|
+
with pytest.raises(ProxmoxAPIError) as exc_info:
|
|
213
|
+
client.upload("pve01", "local", str(iso_file))
|
|
214
|
+
assert exc_info.value.status_code == 403
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
"""Shared helpers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def vmid_type(value: str) -> int:
|
|
7
|
-
"""Argparse type for validating VMID (positive integer)."""
|
|
8
|
-
try:
|
|
9
|
-
v = int(value)
|
|
10
|
-
except ValueError:
|
|
11
|
-
raise ValueError(f"Invalid VMID '{value}': must be an integer")
|
|
12
|
-
if v <= 0:
|
|
13
|
-
raise ValueError(f"Invalid VMID '{value}': must be positive")
|
|
14
|
-
return v
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|