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/client/auth.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Authentication manager for Proxmox VE API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from proxmox.client.exceptions import AuthError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthMethod(str, Enum):
|
|
15
|
+
PASSWORD = "password"
|
|
16
|
+
API_TOKEN = "api_token"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthManager:
|
|
20
|
+
"""Handles Proxmox authentication (password-based ticket or API token).
|
|
21
|
+
|
|
22
|
+
For password auth: acquires a ticket and CSRF token via
|
|
23
|
+
POST /api2/json/access/ticket, then injects Cookie + CSRFPreventionToken headers.
|
|
24
|
+
For API token auth: base64-encodes the token and sets an Authorization header.
|
|
25
|
+
|
|
26
|
+
Caches credentials in memory for the process lifetime.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
self._method: AuthMethod | None = None
|
|
31
|
+
self._ticket: str | None = None
|
|
32
|
+
self._csrf_token: str | None = None
|
|
33
|
+
self._auth_header: str | None = None
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Public API
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def authenticate_password(
|
|
40
|
+
self, base_url: str, username: str, password: str, *, verify: bool = True
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Acquire a ticket from Proxmox using username + password."""
|
|
43
|
+
url = f"{base_url}/api2/json/access/ticket"
|
|
44
|
+
try:
|
|
45
|
+
resp = httpx.post(
|
|
46
|
+
url,
|
|
47
|
+
data={"username": username, "password": password},
|
|
48
|
+
verify=verify,
|
|
49
|
+
timeout=30,
|
|
50
|
+
)
|
|
51
|
+
except httpx.RequestError as exc:
|
|
52
|
+
raise AuthError(f"Failed to reach Proxmox at {base_url}: {exc}") from exc
|
|
53
|
+
|
|
54
|
+
if resp.status_code != 200:
|
|
55
|
+
msg = resp.json().get("message", resp.text)
|
|
56
|
+
raise AuthError(f"Authentication failed: {msg}")
|
|
57
|
+
|
|
58
|
+
data = resp.json()["data"]
|
|
59
|
+
self._ticket = data["ticket"]
|
|
60
|
+
self._csrf_token = data["CSRFPreventionToken"]
|
|
61
|
+
self._method = AuthMethod.PASSWORD
|
|
62
|
+
self._auth_header = None
|
|
63
|
+
|
|
64
|
+
def set_api_token(self, user: str, token_id: str, secret: str) -> None:
|
|
65
|
+
"""Use a Proxmox API token for authentication."""
|
|
66
|
+
raw = f"{user}!{token_id}={secret}"
|
|
67
|
+
encoded = base64.b64encode(raw.encode()).decode()
|
|
68
|
+
self._auth_header = f"PVEAPIToken {encoded}"
|
|
69
|
+
self._method = AuthMethod.API_TOKEN
|
|
70
|
+
self._ticket = None
|
|
71
|
+
self._csrf_token = None
|
|
72
|
+
|
|
73
|
+
def get_headers(self) -> dict[str, str]:
|
|
74
|
+
"""Return the HTTP auth headers needed for the current auth method."""
|
|
75
|
+
if self._method == AuthMethod.API_TOKEN:
|
|
76
|
+
return {"Authorization": self._auth_header or ""}
|
|
77
|
+
|
|
78
|
+
if self._method == AuthMethod.PASSWORD:
|
|
79
|
+
headers: dict[str, str] = {}
|
|
80
|
+
if self._ticket:
|
|
81
|
+
headers["Cookie"] = f"PVEAuthCookie={self._ticket}"
|
|
82
|
+
if self._csrf_token:
|
|
83
|
+
headers["CSRFPreventionToken"] = self._csrf_token
|
|
84
|
+
return headers
|
|
85
|
+
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Helpers
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_authenticated(self) -> bool:
|
|
94
|
+
return self._method is not None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def method(self) -> AuthMethod | None:
|
|
98
|
+
return self._method
|
|
99
|
+
|
|
100
|
+
def clear(self) -> None:
|
|
101
|
+
"""Wipe cached credentials."""
|
|
102
|
+
self._method = None
|
|
103
|
+
self._ticket = None
|
|
104
|
+
self._csrf_token = None
|
|
105
|
+
self._auth_header = None
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, Any]:
|
|
108
|
+
"""Serialize for debugging (never exposes secrets)."""
|
|
109
|
+
return {
|
|
110
|
+
"method": self._method.value if self._method else None,
|
|
111
|
+
"has_ticket": self._ticket is not None,
|
|
112
|
+
"has_csrf": self._csrf_token is not None,
|
|
113
|
+
}
|
proxmox/client/client.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""HTTP client for the Proxmox VE REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from proxmox.client.auth import AuthManager
|
|
11
|
+
from proxmox.client.exceptions import AuthError, ProxmoxAPIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProxmoxClient:
|
|
15
|
+
"""Wraps httpx to talk to the Proxmox VE JSON API.
|
|
16
|
+
|
|
17
|
+
Handles:
|
|
18
|
+
- Base URL construction (prefixes /api2/json)
|
|
19
|
+
- Auth header injection
|
|
20
|
+
- Timeout control
|
|
21
|
+
- TLS verification toggle
|
|
22
|
+
- Retry on 5xx with exponential backoff
|
|
23
|
+
- Dry-run mode
|
|
24
|
+
- CSRF ticket auto-refresh on 401
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
auth_manager: AuthManager,
|
|
31
|
+
*,
|
|
32
|
+
timeout: int = 30,
|
|
33
|
+
verify_tls: bool = True,
|
|
34
|
+
dry_run: bool = False,
|
|
35
|
+
retries: int = 3,
|
|
36
|
+
verbose: bool = False,
|
|
37
|
+
):
|
|
38
|
+
# Normalise trailing slash
|
|
39
|
+
self._base_url = base_url.rstrip("/")
|
|
40
|
+
self._auth = auth_manager
|
|
41
|
+
self._timeout = timeout
|
|
42
|
+
self._verify_tls = verify_tls
|
|
43
|
+
self._dry_run = dry_run
|
|
44
|
+
self._max_retries = retries
|
|
45
|
+
self._verbose = verbose
|
|
46
|
+
self._username: str | None = None
|
|
47
|
+
self._password: str | None = None
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
# Public HTTP methods
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def request(
|
|
54
|
+
self,
|
|
55
|
+
method: str,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
params: dict[str, Any] | None = None,
|
|
59
|
+
data: dict[str, Any] | None = None,
|
|
60
|
+
) -> dict[str, Any] | list[Any]:
|
|
61
|
+
"""Send an HTTP request and unwrap the Proxmox JSON envelope.
|
|
62
|
+
|
|
63
|
+
Returns ``response.json()["data"]`` on success.
|
|
64
|
+
"""
|
|
65
|
+
path = self._normalise_path(path)
|
|
66
|
+
full_url = f"{self._base_url}/api2/json{path}"
|
|
67
|
+
|
|
68
|
+
self._debug(f"{method} {full_url}")
|
|
69
|
+
if data:
|
|
70
|
+
self._debug(f" body: {data}")
|
|
71
|
+
|
|
72
|
+
if self._dry_run:
|
|
73
|
+
self._print_dry_run(method, full_url, data)
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
return self._send_with_retry(method, full_url, params, data)
|
|
77
|
+
|
|
78
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any] | list[Any]:
|
|
79
|
+
return self.request("GET", path, params=params)
|
|
80
|
+
|
|
81
|
+
def post(
|
|
82
|
+
self, path: str, *, data: dict[str, Any] | None = None
|
|
83
|
+
) -> dict[str, Any] | list[Any]:
|
|
84
|
+
return self.request("POST", path, data=data)
|
|
85
|
+
|
|
86
|
+
def put(
|
|
87
|
+
self, path: str, *, data: dict[str, Any] | None = None
|
|
88
|
+
) -> dict[str, Any] | list[Any]:
|
|
89
|
+
return self.request("PUT", path, data=data)
|
|
90
|
+
|
|
91
|
+
def delete(
|
|
92
|
+
self, path: str, *, params: dict[str, Any] | None = None
|
|
93
|
+
) -> dict[str, Any] | list[Any]:
|
|
94
|
+
return self.request("DELETE", path, params=params)
|
|
95
|
+
|
|
96
|
+
def set_credentials(self, username: str, password: str) -> None:
|
|
97
|
+
"""Store credentials for lazy / auto-refresh authentication."""
|
|
98
|
+
self._username = username
|
|
99
|
+
self._password = password
|
|
100
|
+
|
|
101
|
+
def authenticate(self) -> None:
|
|
102
|
+
"""Explicitly authenticate the client (password method)."""
|
|
103
|
+
if self._username and self._password:
|
|
104
|
+
self._auth.authenticate_password(
|
|
105
|
+
self._base_url, self._username, self._password, verify=self._verify_tls
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Internals
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _normalise_path(path: str) -> str:
|
|
114
|
+
if not path.startswith("/"):
|
|
115
|
+
path = "/" + path
|
|
116
|
+
return path
|
|
117
|
+
|
|
118
|
+
def _send_with_retry(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
url: str,
|
|
122
|
+
params: dict[str, Any] | None,
|
|
123
|
+
data: dict[str, Any] | None,
|
|
124
|
+
) -> dict[str, Any] | list[Any]:
|
|
125
|
+
last_exc: Exception | None = None
|
|
126
|
+
|
|
127
|
+
for attempt in range(self._max_retries + 1):
|
|
128
|
+
try:
|
|
129
|
+
return self._send_one(method, url, params, data)
|
|
130
|
+
except AuthError:
|
|
131
|
+
raise # don't retry auth errors
|
|
132
|
+
except ProxmoxAPIError as exc:
|
|
133
|
+
if exc.status_code < 500:
|
|
134
|
+
raise
|
|
135
|
+
last_exc = exc
|
|
136
|
+
if attempt < self._max_retries:
|
|
137
|
+
delay = 2**attempt
|
|
138
|
+
self._debug(f" ⏳ retry {attempt + 1}/{self._max_retries} in {delay}s ...")
|
|
139
|
+
time.sleep(delay)
|
|
140
|
+
except httpx.TimeoutException as exc:
|
|
141
|
+
last_exc = exc
|
|
142
|
+
if attempt < self._max_retries:
|
|
143
|
+
delay = 2**attempt
|
|
144
|
+
self._debug(f" ⏳ timeout, retry {attempt + 1}/{self._max_retries} in {delay}s ...")
|
|
145
|
+
time.sleep(delay)
|
|
146
|
+
|
|
147
|
+
if isinstance(last_exc, ProxmoxAPIError):
|
|
148
|
+
raise last_exc
|
|
149
|
+
raise ProxmoxAPIError(0, {"message": str(last_exc)}, url)
|
|
150
|
+
|
|
151
|
+
def _send_one(
|
|
152
|
+
self,
|
|
153
|
+
method: str,
|
|
154
|
+
url: str,
|
|
155
|
+
params: dict[str, Any] | None,
|
|
156
|
+
data: dict[str, Any] | None,
|
|
157
|
+
) -> dict[str, Any] | list[Any]:
|
|
158
|
+
headers = self._auth.get_headers()
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
resp = httpx.request(
|
|
162
|
+
method=method,
|
|
163
|
+
url=url,
|
|
164
|
+
params=params,
|
|
165
|
+
data=data,
|
|
166
|
+
headers=headers,
|
|
167
|
+
timeout=self._timeout,
|
|
168
|
+
verify=self._verify_tls,
|
|
169
|
+
)
|
|
170
|
+
except httpx.TimeoutException:
|
|
171
|
+
raise
|
|
172
|
+
except httpx.RequestError as exc:
|
|
173
|
+
# Wrap connection / TLS errors
|
|
174
|
+
msg = str(exc)
|
|
175
|
+
if "SSL" in msg or "certificate" in msg.lower():
|
|
176
|
+
msg += "\nHint: use --insecure to skip TLS verification"
|
|
177
|
+
raise ProxmoxAPIError(0, {"message": msg}, url) from exc
|
|
178
|
+
|
|
179
|
+
self._debug(f" ← {resp.status_code}")
|
|
180
|
+
|
|
181
|
+
if resp.status_code == 401 and self._username and self._password:
|
|
182
|
+
# Auto-refresh ticket once
|
|
183
|
+
self._debug(" 🔄 re-authenticating ...")
|
|
184
|
+
self.authenticate()
|
|
185
|
+
headers = self._auth.get_headers()
|
|
186
|
+
resp = httpx.request(
|
|
187
|
+
method=method,
|
|
188
|
+
url=url,
|
|
189
|
+
params=params,
|
|
190
|
+
data=data,
|
|
191
|
+
headers=headers,
|
|
192
|
+
timeout=self._timeout,
|
|
193
|
+
verify=self._verify_tls,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not (200 <= resp.status_code < 300):
|
|
197
|
+
try:
|
|
198
|
+
body = resp.json()
|
|
199
|
+
except Exception:
|
|
200
|
+
body = {"message": resp.text}
|
|
201
|
+
raise ProxmoxAPIError(resp.status_code, body, url)
|
|
202
|
+
|
|
203
|
+
# Unwrap Proxmox JSON envelope
|
|
204
|
+
envelope = resp.json()
|
|
205
|
+
return envelope.get("data", envelope)
|
|
206
|
+
|
|
207
|
+
def _print_dry_run(self, method: str, url: str, data: dict[str, Any] | None) -> None:
|
|
208
|
+
print(f"{method} {url}")
|
|
209
|
+
print(f"Headers: {self._auth.get_headers()}")
|
|
210
|
+
if data:
|
|
211
|
+
print(f"Body: {data}")
|
|
212
|
+
|
|
213
|
+
def _debug(self, message: str) -> None:
|
|
214
|
+
if self._verbose:
|
|
215
|
+
import sys
|
|
216
|
+
|
|
217
|
+
print(f"[proxmox] {message}", file=sys.stderr)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Proxmox CLI exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ProxmoxError(Exception):
|
|
5
|
+
"""Base exception for proxmox CLI errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str):
|
|
8
|
+
self.message = message
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProxmoxAPIError(ProxmoxError):
|
|
13
|
+
"""Raised when the Proxmox API returns a non-2xx response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, body: dict | None = None, url: str = ""):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.body = body or {}
|
|
18
|
+
self.url = url
|
|
19
|
+
|
|
20
|
+
# Try to extract a meaningful error message from Proxmox response
|
|
21
|
+
msg = body.get("message", "") if body else ""
|
|
22
|
+
if not msg and isinstance(body, dict) and "errors" in body:
|
|
23
|
+
msg = str(body["errors"])
|
|
24
|
+
|
|
25
|
+
message = f"HTTP {status_code}"
|
|
26
|
+
if url:
|
|
27
|
+
message += f" on {url}"
|
|
28
|
+
if msg:
|
|
29
|
+
message += f": {msg}"
|
|
30
|
+
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthError(ProxmoxError):
|
|
35
|
+
"""Raised when authentication fails."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ConfigError(ProxmoxError):
|
|
39
|
+
"""Raised when configuration is missing or invalid."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(ProxmoxAPIError):
|
|
43
|
+
"""Raised when a resource is not found (404)."""
|
|
File without changes
|
proxmox/config/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Config file loader — reads / writes credentials.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from proxmox.client.exceptions import ConfigError
|
|
11
|
+
from proxmox.config.models import (
|
|
12
|
+
CREDENTIALS_FILE,
|
|
13
|
+
SYSTEM_CONFIG_DIR,
|
|
14
|
+
USER_CONFIG_DIR,
|
|
15
|
+
Credentials,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigLoader:
|
|
20
|
+
"""Loads and persists credentials to a JSON config file.
|
|
21
|
+
|
|
22
|
+
Priority:
|
|
23
|
+
1. ~/.config/proxmox-cli/credentials.json (user)
|
|
24
|
+
2. /etc/proxmox-cli/credentials.json (system)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, user_dir: Path | None = None, system_dir: Path | None = None):
|
|
28
|
+
self._user_dir = user_dir or USER_CONFIG_DIR
|
|
29
|
+
self._system_dir = system_dir or SYSTEM_CONFIG_DIR
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Public API
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def load(self) -> Credentials:
|
|
36
|
+
"""Load credentials from disk. Raises ConfigError if not found."""
|
|
37
|
+
path = self._find_file()
|
|
38
|
+
if path is None:
|
|
39
|
+
raise ConfigError(
|
|
40
|
+
"No credentials found. Run 'proxmox auth login' first, "
|
|
41
|
+
f"or place a {CREDENTIALS_FILE} in {self._user_dir} or {self._system_dir}."
|
|
42
|
+
)
|
|
43
|
+
return self._read(path)
|
|
44
|
+
|
|
45
|
+
def load_or_none(self) -> Credentials | None:
|
|
46
|
+
"""Load credentials, returning None if not found."""
|
|
47
|
+
try:
|
|
48
|
+
return self.load()
|
|
49
|
+
except ConfigError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def save(self, credentials: Credentials) -> Path:
|
|
53
|
+
"""Persist credentials to the user config directory.
|
|
54
|
+
|
|
55
|
+
Creates parent directories and sets restrictive permissions (0o600).
|
|
56
|
+
"""
|
|
57
|
+
self._user_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
path = self._user_dir / CREDENTIALS_FILE
|
|
59
|
+
|
|
60
|
+
data = credentials.model_dump(mode="json", exclude_none=True)
|
|
61
|
+
payload = json.dumps(data, indent=2)
|
|
62
|
+
path.write_text(payload)
|
|
63
|
+
|
|
64
|
+
# Restrict permissions: owner-read/write only
|
|
65
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
def clear(self) -> None:
|
|
69
|
+
"""Delete the user-level credentials file."""
|
|
70
|
+
path = self._user_dir / CREDENTIALS_FILE
|
|
71
|
+
if path.exists():
|
|
72
|
+
path.unlink()
|
|
73
|
+
|
|
74
|
+
def exists(self) -> bool:
|
|
75
|
+
"""Return True if a credentials file can be found."""
|
|
76
|
+
return self._find_file() is not None
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Internals
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _find_file(self) -> Path | None:
|
|
83
|
+
for base in (self._user_dir, self._system_dir):
|
|
84
|
+
p = base / CREDENTIALS_FILE
|
|
85
|
+
if p.exists():
|
|
86
|
+
return p
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def _read(self, path: Path) -> Credentials:
|
|
90
|
+
try:
|
|
91
|
+
raw = json.loads(path.read_text())
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
raise ConfigError(f"Invalid JSON in {path}: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
return Credentials(**raw)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
raise ConfigError(f"Invalid credentials in {path}: {exc}") from exc
|
proxmox/config/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pydantic models for configuration and credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthMethod(str, Enum):
|
|
12
|
+
PASSWORD = "password"
|
|
13
|
+
API_TOKEN = "api_token"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Credentials(BaseModel):
|
|
17
|
+
"""Persisted credentials for a single Proxmox endpoint."""
|
|
18
|
+
|
|
19
|
+
url: str = Field(..., description="Proxmox API URL, e.g. https://192.168.1.10:8006")
|
|
20
|
+
username: str = Field(..., description="Username, e.g. root@pam")
|
|
21
|
+
auth_method: AuthMethod = Field(..., description="Authentication method")
|
|
22
|
+
password: str | None = Field(None, description="Password (for password auth)")
|
|
23
|
+
api_token_id: str | None = Field(None, description="Token ID (for token auth)")
|
|
24
|
+
api_token_secret: str | None = Field(None, description="Token secret (for token auth)")
|
|
25
|
+
verify_tls: bool = Field(True, description="Whether to verify TLS certificates")
|
|
26
|
+
|
|
27
|
+
@field_validator("url")
|
|
28
|
+
@classmethod
|
|
29
|
+
def url_must_have_scheme(cls, v: str) -> str:
|
|
30
|
+
if not v.startswith(("http://", "https://")):
|
|
31
|
+
raise ValueError("URL must start with http:// or https://")
|
|
32
|
+
return v.rstrip("/")
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def validate_auth_fields(self):
|
|
36
|
+
if self.auth_method == AuthMethod.PASSWORD and not self.password:
|
|
37
|
+
raise ValueError("Password is required for password authentication")
|
|
38
|
+
if self.auth_method == AuthMethod.API_TOKEN:
|
|
39
|
+
if not self.api_token_id:
|
|
40
|
+
raise ValueError("API token ID is required for token authentication")
|
|
41
|
+
if not self.api_token_secret:
|
|
42
|
+
raise ValueError("API token secret is required for token authentication")
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Config file paths (XDG-compliant)
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
USER_CONFIG_DIR = Path.home() / ".config" / "proxmox-cli"
|
|
50
|
+
SYSTEM_CONFIG_DIR = Path("/etc/proxmox-cli")
|
|
51
|
+
CREDENTIALS_FILE = "credentials.json"
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Output formatter dispatcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from proxmox.output.json_fmt import format_json
|
|
8
|
+
from proxmox.output.table_fmt import format_table
|
|
9
|
+
from proxmox.output.yaml_fmt import format_yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def format_output(data: Any, fmt: str, *, columns: list[str] | None = None) -> str:
|
|
13
|
+
"""Dispatch to the appropriate formatter.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
data: The data to format (dict, list, etc.)
|
|
17
|
+
fmt: One of 'json', 'table', 'yaml'
|
|
18
|
+
columns: Optional column name override for table mode.
|
|
19
|
+
"""
|
|
20
|
+
if fmt == "json":
|
|
21
|
+
return format_json(data)
|
|
22
|
+
if fmt == "table":
|
|
23
|
+
return format_table(data, columns)
|
|
24
|
+
if fmt == "yaml":
|
|
25
|
+
return format_yaml(data)
|
|
26
|
+
return format_json(data)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""JSON output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_json(data: Any, indent: int = 2) -> str:
|
|
10
|
+
"""Render data as pretty-printed JSON."""
|
|
11
|
+
return json.dumps(data, indent=indent, ensure_ascii=False, default=str)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Table output formatter using rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_table(data: Any, columns: list[str] | None = None) -> str:
|
|
12
|
+
"""Render data as a rich-powered ASCII table.
|
|
13
|
+
|
|
14
|
+
- list[dict] → columnar table
|
|
15
|
+
- dict → key-value table
|
|
16
|
+
- other → plain string
|
|
17
|
+
"""
|
|
18
|
+
console = Console(force_terminal=True, color_system=None, width=120)
|
|
19
|
+
table = _build_table(data, columns)
|
|
20
|
+
with console.capture() as capture:
|
|
21
|
+
console.print(table)
|
|
22
|
+
return capture.get().rstrip()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_table(data: Any, columns: list[str] | None) -> Table:
|
|
26
|
+
if isinstance(data, list):
|
|
27
|
+
return _list_table(data, columns)
|
|
28
|
+
if isinstance(data, dict):
|
|
29
|
+
return _kv_table(data)
|
|
30
|
+
return _plain_table(data)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _list_table(items: list[dict], columns: list[str] | None) -> Table:
|
|
34
|
+
if not items:
|
|
35
|
+
return Table(title="No results")
|
|
36
|
+
|
|
37
|
+
# Auto-detect columns from first item keys if not specified
|
|
38
|
+
cols = columns or list(items[0].keys())
|
|
39
|
+
table = Table(show_header=True, header_style="bold")
|
|
40
|
+
for col in cols:
|
|
41
|
+
table.add_column(col, overflow="fold")
|
|
42
|
+
for item in items:
|
|
43
|
+
table.add_row(*[str(item.get(col, "")) for col in cols])
|
|
44
|
+
return table
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _kv_table(data: dict) -> Table:
|
|
48
|
+
table = Table(show_header=True, header_style="bold")
|
|
49
|
+
table.add_column("Key", style="dim")
|
|
50
|
+
table.add_column("Value")
|
|
51
|
+
for key, value in data.items():
|
|
52
|
+
if isinstance(value, (dict, list)):
|
|
53
|
+
import json
|
|
54
|
+
|
|
55
|
+
value = json.dumps(value, default=str)
|
|
56
|
+
table.add_row(str(key), str(value))
|
|
57
|
+
return table
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _plain_table(data: Any) -> Table:
|
|
61
|
+
table = Table()
|
|
62
|
+
table.add_column("Result")
|
|
63
|
+
table.add_row(str(data))
|
|
64
|
+
return table
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""YAML output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_yaml(data: Any) -> str:
|
|
11
|
+
"""Render data as YAML."""
|
|
12
|
+
return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
File without changes
|
proxmox/utils/helpers.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
proxmox/utils/logging.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Structured stderr logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def log_error(message: str) -> None:
|
|
9
|
+
"""Print an error message to stderr."""
|
|
10
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def log_info(message: str) -> None:
|
|
14
|
+
"""Print an info message to stderr."""
|
|
15
|
+
print(message, file=sys.stderr)
|