Compare commits

..

1 Commits

Author SHA1 Message Date
52aa351c60 Move to lazy imports and custom API client 2025-03-13 00:26:47 -04:00
16 changed files with 502 additions and 350 deletions

View File

@ -1 +1 @@
1.0.1
0.9.107

View File

@ -1,22 +1,5 @@
## PVC Changelog
###### [v1.0.1](https://github.com/parallelvirtualcluster/pvc/releases/tag/v1.0.1)
* [CLI Client] [Bugfix] Fix bug with DELETE endpoints returning invalid data
###### [v1.0](https://github.com/parallelvirtualcluster/pvc/releases/tag/v1.0)
**Announcement**: We are pleased to announce PVC 1.0! Functionally speaking, there are only a few minor improvements over the previous 0.9.107, but I believe it's finally time to call this a "1.0" release. Recently I have had much less opportunity to work on PVC as I would like, so some features are still not quite there, but those can arrive in future versions over time.
**Enhancement**: The PVC CLI has been made much more efficient in terms of imports, allowing it to run on much lower spec hardware (in my case, on a small SBC). It's still not perfect, but multi-second import times are no longer an issue. The CLI client has also been moved to a more modern build system in preparation for Debian 13 "Trixie".
* [Daemons] Add cluster name to outputs during startup
* [CLI Client] Translate domain UUIDs to names in full node detail output for better readability
* [CLI Client] Fix colouring bug for mirror state
* [CLI Client] Significantly improve import efficiency throughout the client to avoid long load times on slow hardware
* [CLI Client] Port build to pyproject.toml and increase Debuild compat to 13
* [API Daemon] Fix bug with RBD list update after VM rename
* [API Daemon] Fix bug/crash if Ceph volume stats are invalid/empty
###### [v0.9.107](https://github.com/parallelvirtualcluster/pvc/releases/tag/v0.9.107)
* [Worker Daemon] Fixes a bug where snapshot removal fails during autobackups

View File

@ -22,8 +22,7 @@ sed -i "s,version = \"${current_version}\",version = \"${new_version}\"," node-d
sed -i "s,version = \"${current_version}\",version = \"${new_version}\"," health-daemon/pvchealthd/Daemon.py
sed -i "s,version = \"${current_version}\",version = \"${new_version}\"," worker-daemon/pvcworkerd/Daemon.py
sed -i "s,version = \"${current_version}\",version = \"${new_version}\"," api-daemon/pvcapid/Daemon.py
sed -i "s,version = \"${current_version}\",version = \"${new_version}\"," client-cli/pyproject.toml
sed -i "s,VERSION = \"${current_version}\",VERSION = \"${new_version}\"," client-cli/pvc/cli/helpers.py
sed -i "s,version=\"${current_version}\",version=\"${new_version}\"," client-cli/setup.py
echo ${new_version} > .version
changelog_tmpdir=$( mktemp -d )
@ -48,7 +47,7 @@ echo -e "${deb_changelog_new}" >> ${deb_changelog_file}
echo -e "${deb_changelog_orig}" >> ${deb_changelog_file}
mv ${deb_changelog_file} debian/changelog
git add node-daemon/pvcnoded/Daemon.py health-daemon/pvchealthd/Daemon.py worker-daemon/pvcworkerd/Daemon.py api-daemon/pvcapid/Daemon.py client-cli/pvc/cli/helpers.py client-cli/pyproject.toml debian/changelog CHANGELOG.md .version
git add node-daemon/pvcnoded/Daemon.py health-daemon/pvchealthd/Daemon.py worker-daemon/pvcworkerd/Daemon.py api-daemon/pvcapid/Daemon.py client-cli/setup.py debian/changelog CHANGELOG.md .version
git commit -v
popd &>/dev/null

File diff suppressed because it is too large Load Diff

View File

@ -30,8 +30,6 @@ from yaml import load as yload
from yaml import SafeLoader
VERSION = "1.0.1"
DEFAULT_STORE_DATA = {"cfgfile": "/etc/pvc/pvc.conf"}
DEFAULT_STORE_FILENAME = "pvc.json"
DEFAULT_API_PREFIX = "/api/v1"

View File

@ -23,11 +23,11 @@ from ast import literal_eval
from click import echo, progressbar
from math import ceil
from os.path import getsize
from requests import get, post, put, patch, delete, Response
from requests.exceptions import ConnectionError
from time import time
from urllib3 import disable_warnings
from pvc.cli.helpers import VERSION
import socket
import json
import ssl
import base64
def format_bytes(size_bytes):
@ -140,14 +140,186 @@ class UploadProgressBar(object):
echo(self.end_message + self.end_suffix, nl=self.end_nl)
class ErrorResponse(Response):
def __init__(self, json_data, status_code, headers):
self.json_data = json_data
class Response:
"""Minimal Response class to replace requests.Response"""
def __init__(self, status_code, headers, content):
self.status_code = status_code
self.headers = headers
self.content = content
self._json = None
def json(self):
return self.json_data
if self._json is None:
try:
self._json = json.loads(self.content.decode('utf-8'))
except json.JSONDecodeError:
self._json = {}
return self._json
class ConnectionError(Exception):
"""Simple ConnectionError class to replace requests.exceptions.ConnectionError"""
pass
class ErrorResponse(Response):
def __init__(self, json_data, status_code, headers):
self.status_code = status_code
self.headers = headers
self._json = json_data
self.content = json.dumps(json_data).encode('utf-8') if json_data else b''
def json(self):
return self._json
def _parse_url(url):
"""Simple URL parser without using urllib"""
if '://' in url:
scheme, rest = url.split('://', 1)
else:
scheme = 'http'
rest = url
if '/' in rest:
host_port, path = rest.split('/', 1)
path = '/' + path
else:
host_port = rest
path = '/'
if ':' in host_port:
host, port_str = host_port.split(':', 1)
port = int(port_str)
else:
host = host_port
port = 443 if scheme == 'https' else 80
return scheme, host, port, path
def _encode_params(params):
"""Simple URL parameter encoder"""
if not params:
return ''
parts = []
for key, value in params.items():
if isinstance(value, bool):
value = str(value).lower()
elif value is None:
value = ''
else:
value = str(value)
parts.append(f"{key}={value}")
return '?' + '&'.join(parts)
def _make_request(method, url, headers=None, params=None, data=None, files=None, timeout=5, verify=True):
"""Simple HTTP client using sockets"""
headers = headers or {}
scheme, host, port, path = _parse_url(url)
# Add query parameters
path += _encode_params(params)
# Prepare body
body = None
if data is not None and files is None:
if isinstance(data, dict):
body = json.dumps(data).encode('utf-8')
if 'Content-Type' not in headers:
headers['Content-Type'] = 'application/json'
else:
body = data.encode('utf-8') if isinstance(data, str) else data
# Handle file uploads
if files:
boundary = f'----WebKitFormBoundary{int(time())}'
headers['Content-Type'] = f'multipart/form-data; boundary={boundary}'
body = b''
# Add form fields
if data:
for key, value in data.items():
body += f'--{boundary}\r\n'.encode()
body += f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode()
body += f'{value}\r\n'.encode()
# Add files
for key, file_tuple in files.items():
filename, fileobj, content_type = file_tuple
body += f'--{boundary}\r\n'.encode()
body += f'Content-Disposition: form-data; name="{key}"; filename="{filename}"\r\n'.encode()
body += f'Content-Type: {content_type}\r\n\r\n'.encode()
body += fileobj.read()
body += b'\r\n'
body += f'--{boundary}--\r\n'.encode()
# Create socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
# Handle SSL for HTTPS
if scheme == 'https':
context = ssl.create_default_context()
if not verify:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
s = context.wrap_socket(s, server_hostname=host)
# Connect
s.connect((host, port))
# Build request
request = f"{method} {path} HTTP/1.1\r\n"
request += f"Host: {host}\r\n"
for key, value in headers.items():
request += f"{key}: {value}\r\n"
if body:
request += f"Content-Length: {len(body)}\r\n"
request += "Connection: close\r\n\r\n"
# Send request
s.sendall(request.encode('utf-8'))
if body:
s.sendall(body)
# Read response
response_data = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
response_data += chunk
# Parse response
header_end = response_data.find(b'\r\n\r\n')
headers_raw = response_data[:header_end].decode('utf-8')
body_raw = response_data[header_end + 4:]
# Parse status code
status_line = headers_raw.split('\r\n')[0]
status_code = int(status_line.split(' ')[1])
# Parse headers
headers_dict = {}
for line in headers_raw.split('\r\n')[1:]:
if not line:
continue
key, value = line.split(':', 1)
headers_dict[key.strip()] = value.strip()
return Response(status_code, headers_dict, body_raw)
finally:
s.close()
def call_api(
@ -160,22 +332,18 @@ def call_api(
files=None,
):
# Set the connect timeout to 2 seconds but extremely long (48 hour) data timeout
timeout = (2.05, 172800)
timeout = 2.05
# Craft the URI
uri = "{}://{}{}{}".format(
config["api_scheme"], config["api_host"], config["api_prefix"], request_uri
)
# Add custom User-Agent header
headers["User-Agent"] = f"pvc-client-cli/{VERSION}"
# Craft the authentication header if required
if config["api_key"]:
headers["X-Api-Key"] = config["api_key"]
# Determine the request type and hit the API
disable_warnings()
try:
response = None
if operation == "get":
@ -183,61 +351,66 @@ def call_api(
for i in range(3):
failed = False
try:
response = get(
uri,
timeout=timeout,
headers=headers,
params=params,
data=data,
verify=config["verify_ssl"],
response = _make_request(
"GET",
uri,
headers=headers,
params=params,
data=data,
timeout=timeout,
verify=config["verify_ssl"]
)
if response.status_code in retry_on_code:
failed = True
continue
break
except ConnectionError:
except Exception:
failed = True
continue
if failed:
error = f"Code {response.status_code}" if response else "Timeout"
raise ConnectionError(f"Failed to connect after 3 tries ({error})")
if operation == "post":
response = post(
uri,
timeout=timeout,
headers=headers,
params=params,
data=data,
files=files,
verify=config["verify_ssl"],
elif operation == "post":
response = _make_request(
"POST",
uri,
headers=headers,
params=params,
data=data,
files=files,
timeout=timeout,
verify=config["verify_ssl"]
)
if operation == "put":
response = put(
uri,
timeout=timeout,
headers=headers,
params=params,
data=data,
files=files,
verify=config["verify_ssl"],
elif operation == "put":
response = _make_request(
"PUT",
uri,
headers=headers,
params=params,
data=data,
files=files,
timeout=timeout,
verify=config["verify_ssl"]
)
if operation == "patch":
response = patch(
uri,
timeout=timeout,
headers=headers,
params=params,
data=data,
verify=config["verify_ssl"],
elif operation == "patch":
response = _make_request(
"PATCH",
uri,
headers=headers,
params=params,
data=data,
timeout=timeout,
verify=config["verify_ssl"]
)
if operation == "delete":
response = delete(
uri,
timeout=timeout,
headers=headers,
params=params,
data=data,
verify=config["verify_ssl"],
elif operation == "delete":
response = _make_request(
"DELETE",
uri,
headers=headers,
params=params,
data=data,
timeout=timeout,
verify=config["verify_ssl"]
)
except Exception as e:
message = "Failed to connect to the API: {}".format(e)

View File

@ -0,0 +1,17 @@
"""
Lazy import mechanism for PVC CLI to reduce startup time
"""
class LazyModule:
"""
A proxy for a module that is loaded only when actually used
"""
def __init__(self, name):
self.name = name
self._module = None
def __getattr__(self, attr):
if self._module is None:
import importlib
self._module = importlib.import_module(self.name)
return getattr(self._module, attr)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pvc"
version = "1.0.1"
version = "0.9.107"
dependencies = [
"Click",
"PyYAML",

View File

@ -555,16 +555,9 @@ def getCephVolumes(zkhandler, pool):
def getVolumeInformation(zkhandler, pool, volume):
# Parse the stats data
volume_stats_raw = zkhandler.read(("volume.stats", f"{pool}/{volume}"))
try:
volume_stats = dict(json.loads(volume_stats_raw))
# Format the size to something nicer
volume_stats["size"] = format_bytes_tohuman(volume_stats["size"])
except Exception:
volume_stats = dict(
json.loads(
f'{"name":"{volume}","id":"","size":0,"objects":0,"order":0,"object_size":0,"snapshot_count":0,"block_name_prefix":"","format":0,"features":[],"op_features":[],"flags":[],"create_timestamp":"","access_timestamp":"","modify_timestamp":""}'
)
)
volume_stats = dict(json.loads(volume_stats_raw))
# Format the size to something nicer
volume_stats["size"] = format_bytes_tohuman(volume_stats["size"])
volume_information = {"name": volume, "pool": pool, "stats": volume_stats}
return volume_information

View File

@ -605,14 +605,12 @@ def rename_vm(zkhandler, domain, new_domain):
rbd_list.append(disk["name"].split("/")[1])
# Rename each volume in turn
rbd_list_new = []
for idx, rbd in enumerate(rbd_list):
rbd_new = re.sub(r"{}".format(domain), new_domain, rbd)
# Skip renaming if nothing changed
if rbd_new == rbd:
continue
ceph.rename_volume(zkhandler, pool_list[idx], rbd, rbd_new)
rbd_list_new.append(f"{pool_list[idx]}/{rbd_new}")
# Replace the name in the config
vm_config_new = (
@ -629,7 +627,6 @@ def rename_vm(zkhandler, domain, new_domain):
[
(("domain", dom_uuid), new_domain),
(("domain.xml", dom_uuid), vm_config_new),
(("domain.rbdlist", dom_uuid), ",".join(rbd_list_new)),
]
)

21
debian/changelog vendored
View File

@ -1,24 +1,3 @@
pvc (1.0.1-0) unstable; urgency=high
* [CLI Client] [Bugfix] Fix bug with DELETE endpoints returning invalid data
-- Joshua M. Boniface <joshua@boniface.me> Sat, 21 Jun 2025 12:40:33 -0400
pvc (1.0-0) unstable; urgency=high
**Announcement**: We are pleased to announce PVC 1.0! Functionally speaking, there are only a few minor improvements over the previous 0.9.107, but I believe it's finally time to call this a "1.0" release. Recently I have had much less opportunity to work on PVC as I would like, so some features are still not quite there, but those can arrive in future versions over time.
**Enhancement**: The PVC CLI has been made much more efficient in terms of imports, allowing it to run on much lower spec hardware (in my case, on a small SBC). It's still not perfect, but multi-second import times are no longer an issue. The CLI client has also been moved to a more modern build system in preparation for Debian 13 "Trixie".
* [Daemons] Add cluster name to outputs during startup
* [CLI Client] Translate domain UUIDs to names in full node detail output for better readability
* [CLI Client] Fix colouring bug for mirror state
* [CLI Client] Significantly improve import efficiency throughout the client to avoid long load times on slow hardware
* [CLI Client] Port build to pyproject.toml and increase Debuild compat to 13
* [API Daemon] Fix bug with RBD list update after VM rename
* [API Daemon] Fix bug/crash if Ceph volume stats are invalid/empty
-- Joshua M. Boniface <joshua@boniface.me> Thu, 05 Jun 2025 00:04:54 -0400
pvc (0.9.107-0) unstable; urgency=high
* [Worker Daemon] Fixes a bug where snapshot removal fails during autobackups

1
debian/control vendored
View File

@ -5,7 +5,6 @@ Maintainer: Joshua Boniface <joshua@boniface.me>
Standards-Version: 3.9.8
Homepage: https://www.boniface.me
X-Python3-Version: >= 3.7
Build-Depends: pybuild-plugin-pyproject, dh-python
Package: pvc-daemon-node
Architecture: all

2
debian/rules vendored
View File

@ -14,7 +14,7 @@ override_dh_python3:
override_dh_auto_clean:
find $(CURDIR) -name "__pycache__" -o -name ".pybuild" -exec rm -fr {} + || true
rm -r $(CURDIR)/client-cli/build || true
rm -r $(CURDIR)/client-cli/build
# If you need to rebuild the Sphinx documentation
# Add spinxdoc to the dh --with line

View File

@ -33,7 +33,7 @@ import os
import signal
# Daemon version
version = "1.0.1"
version = "0.9.107"
##########################################################

View File

@ -49,7 +49,7 @@ import re
import json
# Daemon version
version = "1.0.1"
version = "0.9.107"
##########################################################

View File

@ -58,7 +58,7 @@ from daemon_lib.automirror import (
)
# Daemon version
version = "1.0.1"
version = "0.9.107"
config = cfg.get_configuration()