proxcli 0.1.0__py3-none-any.whl
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.1.0.dist-info/METADATA +262 -0
- proxcli-0.1.0.dist-info/RECORD +29 -0
- proxcli-0.1.0.dist-info/WHEEL +4 -0
- proxcli-0.1.0.dist-info/entry_points.txt +2 -0
- proxmox/__init__.py +0 -0
- proxmox/cli/__init__.py +0 -0
- proxmox/cli/auth.py +130 -0
- proxmox/cli/cluster.py +21 -0
- proxmox/cli/container.py +157 -0
- proxmox/cli/main.py +231 -0
- proxmox/cli/node.py +55 -0
- proxmox/cli/storage.py +63 -0
- proxmox/cli/tasks.py +65 -0
- proxmox/cli/vm.py +211 -0
- proxmox/client/__init__.py +0 -0
- proxmox/client/auth.py +113 -0
- proxmox/client/client.py +217 -0
- proxmox/client/exceptions.py +43 -0
- proxmox/config/__init__.py +0 -0
- proxmox/config/config.py +98 -0
- proxmox/config/models.py +51 -0
- proxmox/output/__init__.py +0 -0
- proxmox/output/formatter.py +26 -0
- proxmox/output/json_fmt.py +11 -0
- proxmox/output/table_fmt.py +64 -0
- proxmox/output/yaml_fmt.py +12 -0
- proxmox/utils/__init__.py +0 -0
- proxmox/utils/helpers.py +14 -0
- proxmox/utils/logging.py +15 -0
proxmox/cli/main.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Root CLI parser and main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from proxmox.client.auth import AuthManager
|
|
11
|
+
from proxmox.client.client import ProxmoxClient
|
|
12
|
+
from proxmox.client.exceptions import ConfigError, ProxmoxAPIError, ProxmoxError
|
|
13
|
+
from proxmox.config.config import ConfigLoader
|
|
14
|
+
from proxmox.config.models import AuthMethod as AuthMethodModel
|
|
15
|
+
from proxmox.config.models import Credentials
|
|
16
|
+
from proxmox.output.formatter import format_output
|
|
17
|
+
from proxmox.utils.logging import log_error
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_root_parser() -> argparse.ArgumentParser:
|
|
21
|
+
"""Build the root argument parser with global flags and subcommands."""
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
prog="proxmox",
|
|
24
|
+
description="CLI tool to interact with Proxmox VE via the REST API",
|
|
25
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Global flags
|
|
29
|
+
parser.add_argument("--url", help="Proxmox API URL (e.g. https://pve:8006)")
|
|
30
|
+
parser.add_argument("--username", help="Username (e.g. root@pam)")
|
|
31
|
+
parser.add_argument("--password", help="Password (for password auth)")
|
|
32
|
+
parser.add_argument("--password-stdin", action="store_true", help="Read password from stdin")
|
|
33
|
+
parser.add_argument("--api-token", help="API token in format: user!tokenid=secret")
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--output",
|
|
36
|
+
choices=["json", "table", "yaml"],
|
|
37
|
+
default="json",
|
|
38
|
+
help="Output format (default: json)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--dry-run", action="store_true", help="Print the API request without executing it"
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--insecure", action="store_true", help="Skip TLS certificate verification"
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--timeout", type=int, default=30, help="Request timeout in seconds (default: 30)"
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("--verbose", action="store_true", help="Enable debug output to stderr")
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--version", action="version", version="proxmox 0.1.0"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
subparsers = parser.add_subparsers(dest="resource", title="resources", required=False)
|
|
55
|
+
|
|
56
|
+
# Import and register subcommands
|
|
57
|
+
from proxmox.cli.auth import register_auth_parser
|
|
58
|
+
from proxmox.cli.cluster import register_cluster_parser
|
|
59
|
+
from proxmox.cli.container import register_container_parser
|
|
60
|
+
from proxmox.cli.node import register_node_parser
|
|
61
|
+
from proxmox.cli.storage import register_storage_parser
|
|
62
|
+
from proxmox.cli.tasks import register_task_parser
|
|
63
|
+
from proxmox.cli.vm import register_vm_parser
|
|
64
|
+
|
|
65
|
+
register_auth_parser(subparsers)
|
|
66
|
+
register_vm_parser(subparsers)
|
|
67
|
+
register_node_parser(subparsers)
|
|
68
|
+
register_container_parser(subparsers)
|
|
69
|
+
register_storage_parser(subparsers)
|
|
70
|
+
register_cluster_parser(subparsers)
|
|
71
|
+
register_task_parser(subparsers)
|
|
72
|
+
|
|
73
|
+
return parser
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _merge_config(args: argparse.Namespace) -> tuple[Credentials | None, dict[str, Any]]:
|
|
77
|
+
"""Merge config file, env vars, and CLI flags.
|
|
78
|
+
|
|
79
|
+
Returns (credentials, overrides) where overrides has keys: url, username, password,
|
|
80
|
+
api_token_id, api_token_secret, verify_tls. CLI flags win over env vars and config file.
|
|
81
|
+
"""
|
|
82
|
+
loader = ConfigLoader()
|
|
83
|
+
creds = loader.load_or_none()
|
|
84
|
+
|
|
85
|
+
overrides: dict[str, Any] = {
|
|
86
|
+
"url": None,
|
|
87
|
+
"username": None,
|
|
88
|
+
"password": None,
|
|
89
|
+
"api_token_id": None,
|
|
90
|
+
"api_token_secret": None,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Apply config file values first
|
|
94
|
+
if creds:
|
|
95
|
+
overrides["url"] = creds.url
|
|
96
|
+
overrides["username"] = creds.username
|
|
97
|
+
overrides["verify_tls"] = creds.verify_tls
|
|
98
|
+
if creds.auth_method == AuthMethodModel.PASSWORD:
|
|
99
|
+
overrides["password"] = creds.password
|
|
100
|
+
elif creds.auth_method == AuthMethodModel.API_TOKEN:
|
|
101
|
+
overrides["api_token_id"] = creds.api_token_id
|
|
102
|
+
overrides["api_token_secret"] = creds.api_token_secret
|
|
103
|
+
|
|
104
|
+
# Env var override
|
|
105
|
+
env_password = os.environ.get("PROXMOX_PASSWORD")
|
|
106
|
+
if env_password:
|
|
107
|
+
overrides["password"] = env_password
|
|
108
|
+
|
|
109
|
+
# CLI flag overrides (highest priority)
|
|
110
|
+
if args.url:
|
|
111
|
+
overrides["url"] = args.url
|
|
112
|
+
if args.username:
|
|
113
|
+
overrides["username"] = args.username
|
|
114
|
+
if args.password:
|
|
115
|
+
overrides["password"] = args.password
|
|
116
|
+
if args.password_stdin:
|
|
117
|
+
overrides["password"] = sys.stdin.readline().rstrip("\n")
|
|
118
|
+
if args.api_token:
|
|
119
|
+
parts = args.api_token.split("=", 1)
|
|
120
|
+
if len(parts) == 2:
|
|
121
|
+
user_token, secret = parts
|
|
122
|
+
if "!" in user_token:
|
|
123
|
+
user, token_id = user_token.split("!", 1)
|
|
124
|
+
overrides["username"] = user
|
|
125
|
+
overrides["api_token_id"] = token_id
|
|
126
|
+
overrides["api_token_secret"] = secret
|
|
127
|
+
else:
|
|
128
|
+
overrides["username"] = user_token
|
|
129
|
+
overrides["api_token_secret"] = secret
|
|
130
|
+
else:
|
|
131
|
+
log_error("Invalid --api-token format. Expected: user!tokenid=secret")
|
|
132
|
+
if args.insecure:
|
|
133
|
+
overrides["verify_tls"] = False
|
|
134
|
+
elif "verify_tls" not in overrides:
|
|
135
|
+
overrides["verify_tls"] = True
|
|
136
|
+
|
|
137
|
+
return creds, overrides
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_client(overrides: dict[str, Any], args: argparse.Namespace) -> ProxmoxClient:
|
|
141
|
+
"""Build a ProxmoxClient from merged config overrides."""
|
|
142
|
+
if not overrides["url"]:
|
|
143
|
+
raise ConfigError("No Proxmox URL configured. Use --url or run 'proxmox auth login'.")
|
|
144
|
+
|
|
145
|
+
auth_mgr = AuthManager()
|
|
146
|
+
|
|
147
|
+
# Don't authenticate if dry-run (no actual API calls will be made)
|
|
148
|
+
if not args.dry_run:
|
|
149
|
+
if overrides["api_token_id"] and overrides["api_token_secret"]:
|
|
150
|
+
auth_mgr.set_api_token(
|
|
151
|
+
overrides["username"] or "root@pam",
|
|
152
|
+
overrides["api_token_id"],
|
|
153
|
+
overrides["api_token_secret"],
|
|
154
|
+
)
|
|
155
|
+
elif overrides["password"]:
|
|
156
|
+
auth_mgr.authenticate_password(
|
|
157
|
+
overrides["url"],
|
|
158
|
+
overrides["username"] or "root@pam",
|
|
159
|
+
overrides["password"],
|
|
160
|
+
verify=overrides["verify_tls"],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
client = ProxmoxClient(
|
|
164
|
+
base_url=overrides["url"],
|
|
165
|
+
auth_manager=auth_mgr,
|
|
166
|
+
timeout=args.timeout,
|
|
167
|
+
verify_tls=overrides["verify_tls"],
|
|
168
|
+
dry_run=args.dry_run,
|
|
169
|
+
verbose=args.verbose,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Store for lazy re-auth
|
|
173
|
+
if overrides["password"]:
|
|
174
|
+
client.set_credentials(overrides["username"] or "root@pam", overrides["password"])
|
|
175
|
+
|
|
176
|
+
return client
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv: list[str] | None = None) -> None:
|
|
180
|
+
"""Main entry point."""
|
|
181
|
+
parser = build_root_parser()
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
|
|
184
|
+
# --help or no subcommand: just show help
|
|
185
|
+
if args.resource is None:
|
|
186
|
+
parser.print_help()
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
# auth status and clear don't need a client
|
|
191
|
+
if args.resource == "auth" and args.action in ("status", "clear"):
|
|
192
|
+
if hasattr(args, "func"):
|
|
193
|
+
result = args.func(args, None)
|
|
194
|
+
if result is not None:
|
|
195
|
+
output = format_output(result, args.output)
|
|
196
|
+
print(output)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
_, overrides = _merge_config(args)
|
|
200
|
+
client = _build_client(overrides, args)
|
|
201
|
+
|
|
202
|
+
# Each subcommand sets args.func during registration
|
|
203
|
+
if hasattr(args, "func"):
|
|
204
|
+
result = args.func(args, client)
|
|
205
|
+
if result is not None:
|
|
206
|
+
output = format_output(result, args.output)
|
|
207
|
+
print(output)
|
|
208
|
+
else:
|
|
209
|
+
# Subcommand registered but no func set (cli module not implemented yet)
|
|
210
|
+
log_error(f"Command 'proxmox {args.resource}' is not yet implemented.")
|
|
211
|
+
|
|
212
|
+
except ConfigError as exc:
|
|
213
|
+
log_error(str(exc))
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
except ProxmoxAPIError as exc:
|
|
216
|
+
if args.output == "json":
|
|
217
|
+
print(
|
|
218
|
+
format_output(
|
|
219
|
+
{"error": exc.message, "status_code": exc.status_code}, "json"
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
log_error(str(exc))
|
|
224
|
+
sys.exit(exc.status_code if exc.status_code > 0 else 1)
|
|
225
|
+
except ProxmoxError as exc:
|
|
226
|
+
log_error(str(exc))
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
proxmox/cli/node.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""`proxmox node` subcommand — node management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from proxmox.client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_node_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
11
|
+
"""Register the `proxmox node` subcommand tree."""
|
|
12
|
+
node_parser = subparsers.add_parser("node", help="Manage Proxmox nodes")
|
|
13
|
+
node_sub = node_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
14
|
+
|
|
15
|
+
# --- node list ---
|
|
16
|
+
node_list = node_sub.add_parser("list", help="List all nodes")
|
|
17
|
+
node_list.set_defaults(func=_node_list)
|
|
18
|
+
|
|
19
|
+
# --- node show ---
|
|
20
|
+
node_show = node_sub.add_parser("show", help="Show node details")
|
|
21
|
+
node_show.add_argument("node_name", help="Node name")
|
|
22
|
+
node_show.set_defaults(func=_node_show)
|
|
23
|
+
|
|
24
|
+
# --- node status ---
|
|
25
|
+
node_status = node_sub.add_parser("status", help="Show node status")
|
|
26
|
+
node_status.add_argument("node_name", nargs="?", help="Node name (omit for all nodes)")
|
|
27
|
+
node_status.set_defaults(func=_node_status)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _node_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
31
|
+
return client.get("/nodes")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _node_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
35
|
+
return client.get(f"/nodes/{args.node_name}/status")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _node_status(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
39
|
+
if args.node_name:
|
|
40
|
+
return client.get(f"/nodes/{args.node_name}/status")
|
|
41
|
+
# Return all nodes' statuses
|
|
42
|
+
nodes = client.get("/nodes")
|
|
43
|
+
if isinstance(nodes, list):
|
|
44
|
+
result = []
|
|
45
|
+
for n in nodes:
|
|
46
|
+
node_name = n.get("node") if isinstance(n, dict) else n
|
|
47
|
+
try:
|
|
48
|
+
status = client.get(f"/nodes/{node_name}/status")
|
|
49
|
+
if isinstance(status, dict):
|
|
50
|
+
status["node"] = node_name
|
|
51
|
+
result.append(status)
|
|
52
|
+
except Exception:
|
|
53
|
+
result.append({"node": node_name, "status": "error"})
|
|
54
|
+
return result
|
|
55
|
+
return nodes
|
proxmox/cli/storage.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`proxmox storage` subcommand — storage management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from proxmox.client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_storage_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
11
|
+
"""Register the `proxmox storage` subcommand tree."""
|
|
12
|
+
st_parser = subparsers.add_parser("storage", help="Manage Proxmox storage")
|
|
13
|
+
st_sub = st_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
14
|
+
|
|
15
|
+
# --- storage list ---
|
|
16
|
+
st_list = st_sub.add_parser("list", help="List all storages")
|
|
17
|
+
st_list.add_argument("--node", help="Filter by node name")
|
|
18
|
+
st_list.set_defaults(func=_st_list)
|
|
19
|
+
|
|
20
|
+
# --- storage show ---
|
|
21
|
+
st_show = st_sub.add_parser("show", help="Show storage details")
|
|
22
|
+
st_show.add_argument("storage_name", help="Storage name")
|
|
23
|
+
st_show.set_defaults(func=_st_show)
|
|
24
|
+
|
|
25
|
+
# --- storage content ---
|
|
26
|
+
st_content = st_sub.add_parser("content", help="List storage contents")
|
|
27
|
+
st_content.add_argument("storage_name", help="Storage name")
|
|
28
|
+
st_content.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
29
|
+
st_content.set_defaults(func=_st_content)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _st_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
33
|
+
if args.node:
|
|
34
|
+
return client.get(f"/nodes/{args.node}/storage")
|
|
35
|
+
return client.get("/storage")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _st_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
39
|
+
return client.get(f"/storage/{args.storage_name}/status")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resolve_storage_node(client: ProxmoxClient, storage: str) -> str | None:
|
|
43
|
+
"""Find which node a storage belongs to."""
|
|
44
|
+
try:
|
|
45
|
+
storages = client.get("/storage")
|
|
46
|
+
if isinstance(storages, list):
|
|
47
|
+
for s in storages:
|
|
48
|
+
if isinstance(s, dict) and s.get("storage") == storage:
|
|
49
|
+
return s.get("node")
|
|
50
|
+
elif isinstance(storages, dict):
|
|
51
|
+
for s in storages.get("data", []):
|
|
52
|
+
if isinstance(s, dict) and s.get("storage") == storage:
|
|
53
|
+
return s.get("node")
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _st_content(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
60
|
+
node = args.node or _resolve_storage_node(client, args.storage_name)
|
|
61
|
+
if not node:
|
|
62
|
+
return {"error": f"Could not determine node for storage '{args.storage_name}'"}
|
|
63
|
+
return client.get(f"/nodes/{node}/storage/{args.storage_name}/content")
|
proxmox/cli/tasks.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`proxmox task` subcommand — task management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from proxmox.client.client import ProxmoxClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_task_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
11
|
+
"""Register the `proxmox task` subcommand tree."""
|
|
12
|
+
task_parser = subparsers.add_parser("task", help="Manage Proxmox tasks/logs")
|
|
13
|
+
task_sub = task_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
14
|
+
|
|
15
|
+
# --- task list ---
|
|
16
|
+
task_list = task_sub.add_parser("list", help="List recent tasks")
|
|
17
|
+
task_list.add_argument("--node", help="Filter by node name")
|
|
18
|
+
task_list.set_defaults(func=_task_list)
|
|
19
|
+
|
|
20
|
+
# --- task show ---
|
|
21
|
+
task_show = task_sub.add_parser("show", help="Show task details")
|
|
22
|
+
task_show.add_argument("upid", help="Task UPID")
|
|
23
|
+
task_show.set_defaults(func=_task_show)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _extract_node_from_upid(upid: str) -> str | None:
|
|
27
|
+
"""Parse node name from a Proxmox UPID string: UPID:{node}:..."""
|
|
28
|
+
parts = upid.split(":")
|
|
29
|
+
if len(parts) >= 2:
|
|
30
|
+
return parts[1]
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _task_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
35
|
+
if args.node:
|
|
36
|
+
return client.get(f"/nodes/{args.node}/tasks")
|
|
37
|
+
# Iterate all nodes
|
|
38
|
+
nodes = client.get("/nodes")
|
|
39
|
+
if isinstance(nodes, dict):
|
|
40
|
+
nodes = nodes.get("data", [])
|
|
41
|
+
tasks: list[dict] = []
|
|
42
|
+
for n in (nodes if isinstance(nodes, list) else []):
|
|
43
|
+
node_name = n.get("node") if isinstance(n, dict) else n
|
|
44
|
+
try:
|
|
45
|
+
node_tasks = client.get(f"/nodes/{node_name}/tasks")
|
|
46
|
+
if isinstance(node_tasks, list):
|
|
47
|
+
for t in node_tasks:
|
|
48
|
+
if isinstance(t, dict):
|
|
49
|
+
t["_node"] = node_name
|
|
50
|
+
tasks.append(t)
|
|
51
|
+
elif isinstance(node_tasks, dict):
|
|
52
|
+
for t in node_tasks.get("data", []):
|
|
53
|
+
if isinstance(t, dict):
|
|
54
|
+
t["_node"] = node_name
|
|
55
|
+
tasks.append(t)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return tasks
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _task_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
62
|
+
node = _extract_node_from_upid(args.upid)
|
|
63
|
+
if not node:
|
|
64
|
+
return {"error": f"Could not extract node from UPID: {args.upid}"}
|
|
65
|
+
return client.get(f"/nodes/{node}/tasks/{args.upid}/status")
|
proxmox/cli/vm.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""`proxmox vm` subcommand — QEMU virtual machine management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from proxmox.client.client import ProxmoxClient
|
|
8
|
+
from proxmox.utils.helpers import vmid_type
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
12
|
+
"""Register the `proxmox vm` subcommand tree."""
|
|
13
|
+
vm_parser = subparsers.add_parser("vm", help="Manage QEMU virtual machines")
|
|
14
|
+
vm_sub = vm_parser.add_subparsers(dest="action", title="actions", required=True)
|
|
15
|
+
|
|
16
|
+
# --- vm list ---
|
|
17
|
+
vm_list = vm_sub.add_parser("list", help="List virtual machines")
|
|
18
|
+
vm_list.add_argument("--node", help="Filter by node name")
|
|
19
|
+
vm_list.set_defaults(func=_vm_list)
|
|
20
|
+
|
|
21
|
+
# --- vm show ---
|
|
22
|
+
vm_show = vm_sub.add_parser("show", help="Show VM details")
|
|
23
|
+
vm_show.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
24
|
+
vm_show.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
25
|
+
vm_show.set_defaults(func=_vm_show)
|
|
26
|
+
|
|
27
|
+
# --- vm create ---
|
|
28
|
+
vm_create = vm_sub.add_parser("create", help="Create a new VM")
|
|
29
|
+
vm_create.add_argument("--node", required=True, help="Target node")
|
|
30
|
+
vm_create.add_argument("--vmid", type=vmid_type, required=True, help="VM ID")
|
|
31
|
+
vm_create.add_argument("--memory", type=int, required=True, help="Memory in MB")
|
|
32
|
+
vm_create.add_argument("--cores", type=int, default=1, help="CPU cores (default: 1)")
|
|
33
|
+
vm_create.add_argument("--net", default=None, help="Network config (e.g. model=virtio,bridge=vmbr0)")
|
|
34
|
+
vm_create.add_argument("--storage", default=None, help="Storage for the VM disk")
|
|
35
|
+
vm_create.add_argument("--ostemplate", default=None, help="OS template/ISO")
|
|
36
|
+
vm_create.add_argument("--name", default=None, help="VM name")
|
|
37
|
+
vm_create.set_defaults(func=_vm_create)
|
|
38
|
+
|
|
39
|
+
# --- vm start ---
|
|
40
|
+
vm_start = vm_sub.add_parser("start", help="Start a VM")
|
|
41
|
+
vm_start.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
42
|
+
vm_start.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
43
|
+
vm_start.set_defaults(func=_vm_start)
|
|
44
|
+
|
|
45
|
+
# --- vm stop ---
|
|
46
|
+
vm_stop = vm_sub.add_parser("stop", help="Stop a VM")
|
|
47
|
+
vm_stop.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
48
|
+
vm_stop.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
49
|
+
vm_stop.set_defaults(func=_vm_stop)
|
|
50
|
+
|
|
51
|
+
# --- vm reboot ---
|
|
52
|
+
vm_reboot = vm_sub.add_parser("reboot", help="Reboot a VM")
|
|
53
|
+
vm_reboot.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
54
|
+
vm_reboot.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
55
|
+
vm_reboot.set_defaults(func=_vm_reboot)
|
|
56
|
+
|
|
57
|
+
# --- vm suspend ---
|
|
58
|
+
vm_suspend = vm_sub.add_parser("suspend", help="Suspend a VM")
|
|
59
|
+
vm_suspend.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
60
|
+
vm_suspend.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
61
|
+
vm_suspend.set_defaults(func=_vm_suspend)
|
|
62
|
+
|
|
63
|
+
# --- vm resume ---
|
|
64
|
+
vm_resume = vm_sub.add_parser("resume", help="Resume a VM")
|
|
65
|
+
vm_resume.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
66
|
+
vm_resume.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
67
|
+
vm_resume.set_defaults(func=_vm_resume)
|
|
68
|
+
|
|
69
|
+
# --- vm delete ---
|
|
70
|
+
vm_delete = vm_sub.add_parser("delete", help="Delete a VM")
|
|
71
|
+
vm_delete.add_argument("vmid", type=vmid_type, help="VM ID")
|
|
72
|
+
vm_delete.add_argument("--node", help="Node name (auto-detected if omitted)")
|
|
73
|
+
vm_delete.add_argument("--force", action="store_true", help="Force removal")
|
|
74
|
+
vm_delete.add_argument("--purge", action="store_true", help="Purge VM from all configurations")
|
|
75
|
+
vm_delete.set_defaults(func=_vm_delete)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Helpers
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _resolve_node(client: ProxmoxClient, node: str | None, vmid: int) -> str | None:
|
|
83
|
+
"""Resolve which node hosts a given VMID, unless node is already provided."""
|
|
84
|
+
if node:
|
|
85
|
+
return node
|
|
86
|
+
# Try cluster resources lookup
|
|
87
|
+
try:
|
|
88
|
+
resources = client.get("/cluster/resources", params={"type": "vm"})
|
|
89
|
+
if isinstance(resources, list):
|
|
90
|
+
for r in resources:
|
|
91
|
+
if r.get("vmid") == vmid:
|
|
92
|
+
return r.get("node")
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Command handlers
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def _vm_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
|
|
103
|
+
if args.node:
|
|
104
|
+
result = client.get(f"/nodes/{args.node}/qemu")
|
|
105
|
+
return result if isinstance(result, list) else result.get("data", result)
|
|
106
|
+
# All nodes: iterate
|
|
107
|
+
nodes = client.get("/nodes")
|
|
108
|
+
if isinstance(nodes, dict):
|
|
109
|
+
nodes = nodes.get("data", [])
|
|
110
|
+
vms: list[dict] = []
|
|
111
|
+
for n in (nodes if isinstance(nodes, list) else []):
|
|
112
|
+
node_name = n.get("node") if isinstance(n, dict) else n
|
|
113
|
+
try:
|
|
114
|
+
node_vms = client.get(f"/nodes/{node_name}/qemu")
|
|
115
|
+
if isinstance(node_vms, list):
|
|
116
|
+
for vm in node_vms:
|
|
117
|
+
if isinstance(vm, dict):
|
|
118
|
+
vm["_node"] = node_name
|
|
119
|
+
vms.append(vm)
|
|
120
|
+
elif isinstance(node_vms, dict):
|
|
121
|
+
for vm in node_vms.get("data", []):
|
|
122
|
+
if isinstance(vm, dict):
|
|
123
|
+
vm["_node"] = node_name
|
|
124
|
+
vms.append(vm)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return vms
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _vm_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
131
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
132
|
+
if not node:
|
|
133
|
+
return {"error": f"VM {args.vmid} not found on any node"}
|
|
134
|
+
resources = client.get(f"/nodes/{node}/qemu/{args.vmid}/status/current")
|
|
135
|
+
if isinstance(resources, dict):
|
|
136
|
+
resources["_node"] = node
|
|
137
|
+
else:
|
|
138
|
+
resources = {"data": resources, "_node": node}
|
|
139
|
+
return resources
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _vm_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
143
|
+
data: dict = {
|
|
144
|
+
"vmid": args.vmid,
|
|
145
|
+
"memory": args.memory,
|
|
146
|
+
"cores": args.cores,
|
|
147
|
+
}
|
|
148
|
+
if args.net:
|
|
149
|
+
data["net0"] = args.net
|
|
150
|
+
if args.name:
|
|
151
|
+
data["name"] = args.name
|
|
152
|
+
if args.ostemplate:
|
|
153
|
+
data["ostemplate"] = args.ostemplate
|
|
154
|
+
if args.storage:
|
|
155
|
+
data["storage"] = args.storage
|
|
156
|
+
|
|
157
|
+
result = client.post(f"/nodes/{args.node}/qemu", data=data)
|
|
158
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _vm_start(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
162
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
163
|
+
if not node:
|
|
164
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
165
|
+
result = client.post(f"/nodes/{node}/qemu/{args.vmid}/status/start")
|
|
166
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _vm_stop(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
170
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
171
|
+
if not node:
|
|
172
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
173
|
+
result = client.post(f"/nodes/{node}/qemu/{args.vmid}/status/stop")
|
|
174
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _vm_reboot(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
178
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
179
|
+
if not node:
|
|
180
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
181
|
+
result = client.post(f"/nodes/{node}/qemu/{args.vmid}/status/reboot")
|
|
182
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _vm_suspend(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
186
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
187
|
+
if not node:
|
|
188
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
189
|
+
result = client.post(f"/nodes/{node}/qemu/{args.vmid}/status/suspend")
|
|
190
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _vm_resume(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
194
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
195
|
+
if not node:
|
|
196
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
197
|
+
result = client.post(f"/nodes/{node}/qemu/{args.vmid}/status/resume")
|
|
198
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _vm_delete(args: argparse.Namespace, client: ProxmoxClient) -> dict:
|
|
202
|
+
node = _resolve_node(client, args.node, args.vmid)
|
|
203
|
+
if not node:
|
|
204
|
+
return {"error": f"VM {args.vmid} not found"}
|
|
205
|
+
params: dict = {}
|
|
206
|
+
if args.force:
|
|
207
|
+
params["force"] = 1
|
|
208
|
+
if args.purge:
|
|
209
|
+
params["purge"] = 1
|
|
210
|
+
result = client.delete(f"/nodes/{node}/qemu/{args.vmid}", params=params or None)
|
|
211
|
+
return result if isinstance(result, dict) else {"data": result}
|
|
File without changes
|