Files
sunbeam-charms/charms/sunbeam-clusterd/src/clusterd.py
T
Guillaume Boutry a72d0c5a10 Update certificates endpoint after microcluster update
The certificates endpoint changed when migrating to microcluster TLS.

Change-Id: I6aa2159b1dd25935e257dc804d255b044f0be21f
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
2024-10-02 13:51:13 +02:00

193 lines
6.6 KiB
Python

# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Clusterd client talking over unix socket."""
import json
import logging
from pathlib import (
Path,
)
from urllib.parse import (
quote,
)
import requests
import requests_unixsocket
from requests_unixsocket import (
DEFAULT_SCHEME,
)
logger = logging.getLogger(__name__)
class ClusterdUnavailableError(Exception):
"""Raised when the cluster is unavailable."""
class ClusterdClient:
"""A client for interacting with the remote client API."""
def __init__(self, socket_path: Path):
self._socket_path = socket_path
self._session = requests.sessions.Session()
self._session.mount(
requests_unixsocket.DEFAULT_SCHEME,
requests_unixsocket.UnixAdapter(),
)
def _request(self, method: str, path: str, **kwargs) -> dict:
if path.startswith("/"):
path = path[1:]
netloc = quote(str(self._socket_path), safe="")
url = f"{DEFAULT_SCHEME}{netloc}/{path}"
try:
logging.debug("[%s] %s, args=%s", method, url, kwargs)
response = self._session.request(method=method, url=url, **kwargs)
logging.debug("Response(%s) = %s", response, response.text)
except requests.exceptions.ConnectionError as e:
msg = str(e)
if "FileNotFoundError" in msg:
raise ClusterdUnavailableError(
"Sunbeam Cluster socket not found, is clusterd running ?"
" Check with 'snap services openstack.clusterd'",
) from e
raise ClusterdUnavailableError(msg)
except requests.exceptions.HTTPError as e:
if e.response is not None:
logger.debug(
f"HTTPError: {e.response.status_code}, {e.response.text}"
)
if e.response.status_code == 503:
raise ClusterdUnavailableError(str(e)) from e
raise e
response.raise_for_status()
return response.json()
def _get(self, path, **kwargs):
kwargs.setdefault("allow_redirects", True)
return self._request("get", path, **kwargs)
def _post(self, path, data=None, json=None, **kwargs):
return self._request("post", path, data=data, json=json, **kwargs)
def _put(self, path, data=None, json=None, **kwargs):
return self._request("put", path, data=data, json=json, **kwargs)
def _delete(self, path, **kwargs):
return self._request("delete", path, **kwargs)
def ready(self) -> bool:
"""Is the cluster ready."""
try:
self._get("/core/1.0/ready")
except ClusterdUnavailableError:
return False
return True
def shutdown(self):
"""Shutdown local clusterd."""
try:
self._post("/core/control/shutdown")
except requests.exceptions.HTTPError as e:
if e.response is None:
raise e
is_500 = e.response.status_code == 500
is_closed_anyway = (
"but connection was closed anyway" in e.response.text
)
if is_500 and is_closed_anyway:
logger.debug("Clusterd shutdown")
return
raise e
def bootstrap(self, name: str, address: str):
"""Bootstrap clusterd."""
data = {"bootstrap": True, "address": address, "name": name}
self._post("/core/control", data=json.dumps(data))
def join(self, name: str, address: str, token: str) -> None:
"""Join node to the micro cluster.
Verified the token with the list of saved tokens and
joins the node with the given name and address.
"""
data = {"join_token": token, "address": address, "name": name}
self._post("core/control", data=json.dumps(data))
def get_members(self) -> list[dict]:
"""Get cluster members."""
cluster = self._get("/core/1.0/cluster")["metadata"]
return cluster
def get_member(self, name) -> dict:
"""Get cluster member."""
for member in self.get_members():
if member["name"] == name:
return member
raise ValueError(f"Member {name} not found")
def remove_node(
self,
name: str,
force: bool = False,
allow_not_found: bool = True,
):
"""Delete node."""
int_force = 1 if force else 0
try:
self._delete(f"/core/1.0/cluster/{name}?force={int_force}")
except requests.exceptions.HTTPError as e:
if e.response is None:
raise e
if e.response.status_code == 404 and allow_not_found:
logger.debug(f"Node {name} not found")
return
is_500 = e.response.status_code == 500
remote_not_found = is_500 and (
"No remote exists with the given name" in e.response.text
)
no_dqlite_member = (
is_500
and "No dqlite cluster member exists with the given name"
in e.response.text
)
delete_with_url = (
is_500 and f"cluster/1.0/cluster/{name}" in e.response.text
)
not_found = remote_not_found or no_dqlite_member or delete_with_url
if not_found and allow_not_found:
logger.debug(f"Node {name} not found")
return
raise e
def generate_token(self, name: str) -> str:
"""Generate token for the node.
Generate a new token for the node with name.
"""
data = {"name": name}
result = self._post("/core/control/tokens", data=json.dumps(data))
return str(result["metadata"])
def set_certs(self, ca: str, cert: str, key: str):
"""Configure cluster certificates.
The CA is not set in the cluster certificates, but in the config endpoint.
This is because we don't want microcluster to go full CA-mode.
"""
self._put("/1.0/config/cluster-ca", data=ca)
data = {"cert": cert, "key": key}
self._put("/core/1.0/cluster/certificates/cluster", json=data)