1252 lines
44 KiB
Python
1252 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# zkhandler.py - Secure versioned ZooKeeper updates
|
|
# Part of the Parallel Virtual Cluster (PVC) system
|
|
#
|
|
# Copyright (C) 2018-2024 Joshua M. Boniface <joshua@boniface.me>
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, version 3.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
###############################################################################
|
|
|
|
import asyncio
|
|
import os
|
|
import time
|
|
import uuid
|
|
import json
|
|
import re
|
|
from functools import wraps
|
|
from kazoo.client import KazooClient, KazooState
|
|
from kazoo.exceptions import NoNodeError
|
|
|
|
|
|
DEFAULT_ROOT_PATH = "/usr/share/pvc"
|
|
SCHEMA_PATH = "daemon_lib/migrations/versions"
|
|
|
|
|
|
#
|
|
# Function decorators
|
|
#
|
|
class ZKConnection(object):
|
|
"""
|
|
Decorates a function with a Zookeeper connection before and after the main call.
|
|
|
|
The decorated function must accept the `zkhandler` argument as its first argument, and
|
|
then use this to access the connection.
|
|
"""
|
|
|
|
def __init__(self, config):
|
|
self.config = config
|
|
|
|
def __call__(self, function):
|
|
if not callable(function):
|
|
return
|
|
|
|
@wraps(function)
|
|
def connection(*args, **kwargs):
|
|
zkhandler = ZKHandler(self.config)
|
|
zkhandler.connect()
|
|
schema_version = zkhandler.read("base.schema.version")
|
|
if schema_version is None:
|
|
schema_version = 0
|
|
zkhandler.schema.load(schema_version, quiet=True)
|
|
|
|
try:
|
|
ret = function(zkhandler, *args, **kwargs)
|
|
finally:
|
|
zkhandler.disconnect()
|
|
del zkhandler
|
|
|
|
return ret
|
|
|
|
return connection
|
|
|
|
|
|
#
|
|
# Exceptions
|
|
#
|
|
class ZKConnectionException(Exception):
|
|
"""
|
|
A exception when connecting to the cluster
|
|
"""
|
|
|
|
def __init__(self, zkhandler, error=None):
|
|
if error is not None:
|
|
self.message = "Failed to connect to Zookeeper at {}: {}".format(
|
|
zkhandler.coordinators(), error
|
|
)
|
|
else:
|
|
self.message = "Failed to connect to Zookeeper at {}".format(
|
|
zkhandler.coordinators()
|
|
)
|
|
zkhandler.disconnect()
|
|
|
|
def __str__(self):
|
|
return str(self.message)
|
|
|
|
|
|
#
|
|
# Handler class
|
|
#
|
|
class ZKHandler(object):
|
|
def __init__(self, config, logger=None):
|
|
"""
|
|
Initialize an instance of the ZKHandler class with config
|
|
|
|
A zk_conn object will be created but not started
|
|
|
|
A ZKSchema instance will be created
|
|
"""
|
|
self.encoding = "utf8"
|
|
self.coordinators = config["coordinators"]
|
|
self.logger = logger
|
|
self.zk_conn = KazooClient(hosts=self.coordinators)
|
|
self._schema = ZKSchema()
|
|
|
|
#
|
|
# Class meta-functions
|
|
#
|
|
def coordinators(self):
|
|
return str(self.coordinators)
|
|
|
|
def log(self, message, state=""):
|
|
if self.logger is not None:
|
|
self.logger.out(message, state)
|
|
else:
|
|
print(message)
|
|
|
|
#
|
|
# Properties
|
|
#
|
|
@property
|
|
def schema(self):
|
|
return self._schema
|
|
|
|
#
|
|
# State/connection management
|
|
#
|
|
def listener(self, state):
|
|
"""
|
|
Listen for KazooState changes and log accordingly.
|
|
|
|
This function does not do anything except for log the state, and Kazoo handles the rest.
|
|
"""
|
|
if state == KazooState.CONNECTED:
|
|
self.log("Connection to Zookeeper resumed", state="o")
|
|
else:
|
|
self.log(
|
|
"Connection to Zookeeper lost with state {}".format(state), state="w"
|
|
)
|
|
|
|
def connect(self, persistent=False):
|
|
"""
|
|
Start the zk_conn object and connect to the cluster
|
|
"""
|
|
try:
|
|
self.zk_conn.start()
|
|
if persistent:
|
|
self.log("Connection to Zookeeper started", state="o")
|
|
self.zk_conn.add_listener(self.listener)
|
|
except Exception as e:
|
|
raise ZKConnectionException(self, e)
|
|
|
|
def disconnect(self, persistent=False):
|
|
"""
|
|
Stop and close the zk_conn object and disconnect from the cluster
|
|
|
|
The class instance may be reused later (avoids persistent connections)
|
|
"""
|
|
self.zk_conn.stop()
|
|
self.zk_conn.close()
|
|
if persistent:
|
|
self.log("Connection to Zookeeper terminated", state="o")
|
|
|
|
#
|
|
# Schema helper actions
|
|
#
|
|
def get_schema_path(self, key):
|
|
"""
|
|
Get the Zookeeper path for {key} from the current schema based on its format.
|
|
|
|
If {key} is a tuple of length 2, it's treated as a path plus an item instance of that path (e.g. a node, a VM, etc.).
|
|
|
|
If {key} is a tuple of length 4, it is treated as a path plus an item instance, as well as another item instance of the subpath.
|
|
|
|
If {key} is just a string, it's treated as a lone path (mostly used for the 'base' schema group.
|
|
|
|
Otherwise, returns None since this is not a valid key.
|
|
|
|
This function also handles the special case where a string that looks like an existing path (i.e. starts with '/') is passed;
|
|
in that case it will silently return the same path back. This was mostly a migration functionality and is deprecated.
|
|
"""
|
|
if isinstance(key, tuple):
|
|
# This is a key tuple with both an ipath and an item
|
|
if len(key) == 2:
|
|
# 2-length normal tuple
|
|
ipath, item = key
|
|
elif len(key) == 4:
|
|
# 4-length sub-level tuple
|
|
ipath, item, sub_ipath, sub_item = key
|
|
return self.schema.path(ipath, item=item) + self.schema.path(
|
|
sub_ipath, item=sub_item
|
|
)
|
|
else:
|
|
# This is an invalid key
|
|
return None
|
|
elif isinstance(key, str):
|
|
# This is a key string with just an ipath
|
|
ipath = key
|
|
item = None
|
|
|
|
# This is a raw key path, used by backup/restore functionality
|
|
if re.match(r"^/", ipath):
|
|
return ipath
|
|
else:
|
|
# This is an invalid key
|
|
return None
|
|
|
|
return self.schema.path(ipath, item=item)
|
|
|
|
#
|
|
# Key Actions
|
|
#
|
|
def exists(self, key):
|
|
"""
|
|
Check if a key exists
|
|
"""
|
|
path = self.get_schema_path(key)
|
|
if path is None:
|
|
# This path is invalid, this is likely due to missing schema entries, so return False
|
|
return False
|
|
|
|
stat = self.zk_conn.exists(path)
|
|
if stat:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def read(self, key):
|
|
"""
|
|
Read data from a key
|
|
"""
|
|
try:
|
|
path = self.get_schema_path(key)
|
|
if path is None:
|
|
# This path is invalid; this is likely due to missing schema entries, so return None
|
|
return None
|
|
|
|
res = self.zk_conn.get(path)
|
|
return res[0].decode(self.encoding)
|
|
except NoNodeError:
|
|
return None
|
|
|
|
async def read_async(self, key):
|
|
"""
|
|
Read data from a key asynchronously
|
|
"""
|
|
try:
|
|
path = self.get_schema_path(key)
|
|
if path is None:
|
|
# This path is invalid; this is likely due to missing schema entries, so return None
|
|
return None
|
|
|
|
val = self.zk_conn.get_async(path)
|
|
data = val.get()
|
|
return data[0].decode(self.encoding)
|
|
except NoNodeError:
|
|
return None
|
|
|
|
async def _read_many(self, keys):
|
|
"""
|
|
Async runner for read_many
|
|
"""
|
|
res = await asyncio.gather(*(self.read_async(key) for key in keys))
|
|
return tuple(res)
|
|
|
|
def read_many(self, keys):
|
|
"""
|
|
Read data from several keys, asynchronously. Returns a tuple of all key values once all
|
|
reads are complete.
|
|
"""
|
|
return asyncio.run(self._read_many(keys))
|
|
|
|
def write(self, kvpairs):
|
|
"""
|
|
Create or update one or more keys' data
|
|
"""
|
|
if type(kvpairs) is not list:
|
|
self.log("ZKHandler error: Key-value sequence is not a list", state="e")
|
|
return False
|
|
|
|
transaction = self.zk_conn.transaction()
|
|
|
|
for kvpair in kvpairs:
|
|
if type(kvpair) is not tuple:
|
|
self.log(
|
|
"ZKHandler error: Key-value pair '{}' is not a tuple".format(
|
|
kvpair
|
|
),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
key = kvpair[0]
|
|
value = kvpair[1]
|
|
|
|
path = self.get_schema_path(key)
|
|
if path is None:
|
|
# This path is invalid; this is likely due to missing schema entries, so continue
|
|
continue
|
|
|
|
if not self.exists(key):
|
|
# Creating a new key
|
|
transaction.create(path, str(value).encode(self.encoding))
|
|
|
|
else:
|
|
# Updating an existing key
|
|
data = self.zk_conn.get(path)
|
|
version = data[1].version
|
|
|
|
# Validate the expected version after the execution
|
|
new_version = version + 1
|
|
|
|
# Update the data
|
|
transaction.set_data(path, str(value).encode(self.encoding))
|
|
|
|
# Check the data
|
|
try:
|
|
transaction.check(path, new_version)
|
|
except TypeError:
|
|
self.log(
|
|
"ZKHandler error: Key '{}' does not match expected version".format(
|
|
path
|
|
),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
try:
|
|
transaction.commit()
|
|
return True
|
|
except Exception as e:
|
|
self.log(
|
|
"ZKHandler error: Failed to commit transaction: {}".format(e), state="e"
|
|
)
|
|
return False
|
|
|
|
def delete(self, keys, recursive=True):
|
|
"""
|
|
Delete a key or list of keys (defaults to recursive)
|
|
"""
|
|
if type(keys) is not list:
|
|
keys = [keys]
|
|
|
|
for key in keys:
|
|
if self.exists(key):
|
|
try:
|
|
path = self.get_schema_path(key)
|
|
self.zk_conn.delete(path, recursive=recursive)
|
|
except Exception as e:
|
|
self.log(
|
|
"ZKHandler error: Failed to delete key {}: {}".format(path, e),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
def children(self, key):
|
|
"""
|
|
Lists all children of a key
|
|
"""
|
|
try:
|
|
path = self.get_schema_path(key)
|
|
if path is None:
|
|
raise NoNodeError
|
|
|
|
return self.zk_conn.get_children(path)
|
|
except NoNodeError:
|
|
# This path is invalid; this is likely due to missing schema entries, so return None
|
|
return None
|
|
|
|
def rename(self, kkpairs):
|
|
"""
|
|
Rename one or more keys to a new value
|
|
"""
|
|
if type(kkpairs) is not list:
|
|
self.log("ZKHandler error: Key-key sequence is not a list", state="e")
|
|
return False
|
|
|
|
transaction = self.zk_conn.transaction()
|
|
|
|
def rename_element(transaction, source_path, destination_path):
|
|
data = self.zk_conn.get(source_path)[0]
|
|
transaction.create(destination_path, data)
|
|
|
|
if self.children(source_path):
|
|
for child_path in self.children(source_path):
|
|
child_source_path = "{}/{}".format(source_path, child_path)
|
|
child_destination_path = "{}/{}".format(
|
|
destination_path, child_path
|
|
)
|
|
rename_element(
|
|
transaction, child_source_path, child_destination_path
|
|
)
|
|
|
|
transaction.delete(source_path)
|
|
|
|
for kkpair in kkpairs:
|
|
if type(kkpair) is not tuple:
|
|
self.log(
|
|
"ZKHandler error: Key-key pair '{}' is not a tuple".format(kkpair),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
source_key = kkpair[0]
|
|
source_path = self.get_schema_path(source_key)
|
|
if source_path is None:
|
|
# This path is invalid; this is likely due to missing schema entries, so continue
|
|
continue
|
|
|
|
destination_key = kkpair[1]
|
|
destination_path = self.get_schema_path(destination_key)
|
|
if destination_path is None:
|
|
# This path is invalid; this is likely due to missing schema entries, so continue
|
|
continue
|
|
|
|
if not self.exists(source_key):
|
|
self.log(
|
|
"ZKHander error: Source key '{}' does not exist".format(
|
|
source_path
|
|
),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
if self.exists(destination_key):
|
|
self.log(
|
|
"ZKHander error: Destination key '{}' already exists".format(
|
|
destination_path
|
|
),
|
|
state="e",
|
|
)
|
|
return False
|
|
|
|
rename_element(transaction, source_path, destination_path)
|
|
|
|
try:
|
|
transaction.commit()
|
|
return True
|
|
except Exception as e:
|
|
self.log(
|
|
"ZKHandler error: Failed to commit transaction: {}".format(e), state="e"
|
|
)
|
|
return False
|
|
|
|
#
|
|
# Lock actions
|
|
#
|
|
def readlock(self, key):
|
|
"""
|
|
Acquires a read lock on a key
|
|
"""
|
|
count = 1
|
|
lock = None
|
|
|
|
path = self.get_schema_path(key)
|
|
|
|
while True:
|
|
try:
|
|
lock_id = str(uuid.uuid1())
|
|
lock = self.zk_conn.ReadLock(path, lock_id)
|
|
break
|
|
except NoNodeError:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire read lock on nonexistent path {}".format(
|
|
path
|
|
),
|
|
state="e",
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
if count > 5:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire read lock after 5 tries: {}".format(
|
|
e
|
|
),
|
|
state="e",
|
|
)
|
|
break
|
|
else:
|
|
time.sleep(0.5)
|
|
count += 1
|
|
continue
|
|
|
|
return lock
|
|
|
|
def writelock(self, key):
|
|
"""
|
|
Acquires a write lock on a key
|
|
"""
|
|
count = 1
|
|
lock = None
|
|
|
|
path = self.get_schema_path(key)
|
|
|
|
while True:
|
|
try:
|
|
lock_id = str(uuid.uuid1())
|
|
lock = self.zk_conn.WriteLock(path, lock_id)
|
|
break
|
|
except NoNodeError:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire write lock on nonexistent path {}".format(
|
|
path
|
|
),
|
|
state="e",
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
if count > 5:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire write lock after 5 tries: {}".format(
|
|
e
|
|
),
|
|
state="e",
|
|
)
|
|
break
|
|
else:
|
|
time.sleep(0.5)
|
|
count += 1
|
|
continue
|
|
|
|
return lock
|
|
|
|
def exclusivelock(self, key):
|
|
"""
|
|
Acquires an exclusive lock on a key
|
|
"""
|
|
count = 1
|
|
lock = None
|
|
|
|
path = self.get_schema_path(key)
|
|
|
|
while True:
|
|
try:
|
|
lock_id = str(uuid.uuid1())
|
|
lock = self.zk_conn.Lock(path, lock_id)
|
|
break
|
|
except NoNodeError:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire exclusive lock on nonexistent path {}".format(
|
|
path
|
|
),
|
|
state="e",
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
if count > 5:
|
|
self.log(
|
|
"ZKHandler warning: Failed to acquire exclusive lock after 5 tries: {}".format(
|
|
e
|
|
),
|
|
state="e",
|
|
)
|
|
break
|
|
else:
|
|
time.sleep(0.5)
|
|
count += 1
|
|
continue
|
|
|
|
return lock
|
|
|
|
|
|
#
|
|
# Schema classes
|
|
#
|
|
class ZKSchema(object):
|
|
# Current version
|
|
_version = 15
|
|
|
|
# Root for doing nested keys
|
|
_schema_root = ""
|
|
|
|
# Primary schema definition for the current version
|
|
_schema = {
|
|
"version": f"{_version}",
|
|
"root": f"{_schema_root}",
|
|
# Base schema defining core keys; this is all that is initialized on cluster init()
|
|
"base": {
|
|
"root": f"{_schema_root}",
|
|
"schema": f"{_schema_root}/schema",
|
|
"schema.version": f"{_schema_root}/schema/version",
|
|
"config": f"{_schema_root}/config",
|
|
"config.maintenance": f"{_schema_root}/config/maintenance",
|
|
"config.fence_lock": f"{_schema_root}/config/fence_lock",
|
|
"config.primary_node": f"{_schema_root}/config/primary_node",
|
|
"config.primary_node.sync_lock": f"{_schema_root}/config/primary_node/sync_lock",
|
|
"config.upstream_ip": f"{_schema_root}/config/upstream_ip",
|
|
"config.migration_target_selector": f"{_schema_root}/config/migration_target_selector",
|
|
"logs": f"{_schema_root}/logs",
|
|
"faults": f"{_schema_root}/faults",
|
|
"node": f"{_schema_root}/nodes",
|
|
"domain": f"{_schema_root}/domains",
|
|
"network": f"{_schema_root}/networks",
|
|
"storage": f"{_schema_root}/ceph",
|
|
"storage.health": f"{_schema_root}/ceph/health",
|
|
"storage.util": f"{_schema_root}/ceph/util",
|
|
"osd": f"{_schema_root}/ceph/osds",
|
|
"pool": f"{_schema_root}/ceph/pools",
|
|
"volume": f"{_schema_root}/ceph/volumes",
|
|
"snapshot": f"{_schema_root}/ceph/snapshots",
|
|
},
|
|
# The schema of an individual logs entry (/logs/{node_name})
|
|
"logs": {
|
|
"node": "", # The root key
|
|
"messages": "/messages",
|
|
},
|
|
# The schema of an individual logs entry (/logs/{id})
|
|
"faults": {
|
|
"id": "", # The root key
|
|
"last_time": "/last_time",
|
|
"first_time": "/first_time",
|
|
"ack_time": "/ack_time",
|
|
"status": "/status",
|
|
"delta": "/delta",
|
|
"message": "/message",
|
|
},
|
|
# The schema of an individual node entry (/nodes/{node_name})
|
|
"node": {
|
|
"name": "", # The root key
|
|
"keepalive": "/keepalive",
|
|
"mode": "/daemonmode",
|
|
"data.active_schema": "/activeschema",
|
|
"data.latest_schema": "/latestschema",
|
|
"data.static": "/staticdata",
|
|
"data.pvc_version": "/pvcversion",
|
|
"running_domains": "/runningdomains",
|
|
"count.provisioned_domains": "/domainscount",
|
|
"count.networks": "/networkscount",
|
|
"state.daemon": "/daemonstate",
|
|
"state.router": "/routerstate",
|
|
"state.domain": "/domainstate",
|
|
"cpu.load": "/cpuload",
|
|
"vcpu.allocated": "/vcpualloc",
|
|
"memory.total": "/memtotal",
|
|
"memory.used": "/memused",
|
|
"memory.free": "/memfree",
|
|
"memory.allocated": "/memalloc",
|
|
"memory.provisioned": "/memprov",
|
|
"ipmi.hostname": "/ipmihostname",
|
|
"ipmi.username": "/ipmiusername",
|
|
"ipmi.password": "/ipmipassword",
|
|
"sriov": "/sriov",
|
|
"sriov.pf": "/sriov/pf",
|
|
"sriov.vf": "/sriov/vf",
|
|
"monitoring.plugins": "/monitoring_plugins",
|
|
"monitoring.data": "/monitoring_data",
|
|
"monitoring.health": "/monitoring_health",
|
|
"network.stats": "/network_stats",
|
|
},
|
|
# The schema of an individual monitoring plugin data entry (/nodes/{node_name}/monitoring_data/{plugin})
|
|
"monitoring_plugin": {
|
|
"name": "", # The root key
|
|
"last_run": "/last_run",
|
|
"health_delta": "/health_delta",
|
|
"message": "/message",
|
|
"data": "/data",
|
|
"runtime": "/runtime",
|
|
},
|
|
# The schema of an individual SR-IOV PF entry (/nodes/{node_name}/sriov/pf/{pf})
|
|
"sriov_pf": {
|
|
"phy": "",
|
|
"mtu": "/mtu",
|
|
"vfcount": "/vfcount",
|
|
}, # The root key
|
|
# The schema of an individual SR-IOV VF entry (/nodes/{node_name}/sriov/vf/{vf})
|
|
"sriov_vf": {
|
|
"phy": "", # The root key
|
|
"pf": "/pf",
|
|
"mtu": "/mtu",
|
|
"mac": "/mac",
|
|
"phy_mac": "/phy_mac",
|
|
"config": "/config",
|
|
"config.vlan_id": "/config/vlan_id",
|
|
"config.vlan_qos": "/config/vlan_qos",
|
|
"config.tx_rate_min": "/config/tx_rate_min",
|
|
"config.tx_rate_max": "/config/tx_rate_max",
|
|
"config.spoof_check": "/config/spoof_check",
|
|
"config.link_state": "/config/link_state",
|
|
"config.trust": "/config/trust",
|
|
"config.query_rss": "/config/query_rss",
|
|
"pci": "/pci",
|
|
"pci.domain": "/pci/domain",
|
|
"pci.bus": "/pci/bus",
|
|
"pci.slot": "/pci/slot",
|
|
"pci.function": "/pci/function",
|
|
"used": "/used",
|
|
"used_by": "/used_by",
|
|
},
|
|
# The schema of an individual domain entry (/domains/{domain_uuid})
|
|
"domain": {
|
|
"name": "", # The root key
|
|
"xml": "/xml",
|
|
"state": "/state",
|
|
"profile": "/profile",
|
|
"stats": "/stats",
|
|
"node": "/node",
|
|
"last_node": "/lastnode",
|
|
"failed_reason": "/failedreason",
|
|
"storage.volumes": "/rbdlist",
|
|
"console.log": "/consolelog",
|
|
"console.vnc": "/vnc",
|
|
"meta.autostart": "/node_autostart",
|
|
"meta.migrate_method": "/migration_method",
|
|
"meta.migrate_max_downtime": "/migration_max_downtime",
|
|
"meta.node_selector": "/node_selector",
|
|
"meta.node_limit": "/node_limit",
|
|
"meta.tags": "/tags",
|
|
"migrate.sync_lock": "/migrate_sync_lock",
|
|
"snapshots": "/snapshots",
|
|
},
|
|
# The schema of an individual domain tag entry (/domains/{domain}/tags/{tag})
|
|
"tag": {
|
|
"name": "", # The root key
|
|
"type": "/type",
|
|
"protected": "/protected",
|
|
},
|
|
# The schema of an individual domain snapshot entry (/domains/{domain}/snapshots/{snapshot})
|
|
"domain_snapshot": {
|
|
"name": "", # The root key
|
|
"timestamp": "/timestamp",
|
|
"xml": "/xml",
|
|
"rbd_snapshots": "/rbdsnaplist",
|
|
},
|
|
# The schema of an individual network entry (/networks/{vni})
|
|
"network": {
|
|
"vni": "", # The root key
|
|
"type": "/nettype",
|
|
"mtu": "/mtu",
|
|
"rule": "/firewall_rules",
|
|
"rule.in": "/firewall_rules/in",
|
|
"rule.out": "/firewall_rules/out",
|
|
"nameservers": "/name_servers",
|
|
"domain": "/domain",
|
|
"reservation": "/dhcp4_reservations",
|
|
"lease": "/dhcp4_leases",
|
|
"ip4.gateway": "/ip4_gateway",
|
|
"ip4.network": "/ip4_network",
|
|
"ip4.dhcp": "/dhcp4_flag",
|
|
"ip4.dhcp_start": "/dhcp4_start",
|
|
"ip4.dhcp_end": "/dhcp4_end",
|
|
"ip6.gateway": "/ip6_gateway",
|
|
"ip6.network": "/ip6_network",
|
|
"ip6.dhcp": "/dhcp6_flag",
|
|
},
|
|
# The schema of an individual network DHCP(v4) reservation entry (/networks/{vni}/dhcp4_reservations/{mac})
|
|
"reservation": {
|
|
"mac": "", # The root key
|
|
"ip": "/ipaddr",
|
|
"hostname": "/hostname",
|
|
},
|
|
# The schema of an individual network DHCP(v4) lease entry (/networks/{vni}/dhcp4_leases/{mac})
|
|
"lease": {
|
|
"mac": "", # The root key
|
|
"ip": "/ipaddr",
|
|
"hostname": "/hostname",
|
|
"expiry": "/expiry",
|
|
"client_id": "/clientid",
|
|
},
|
|
# The schema for an individual network ACL entry (/networks/{vni}/firewall_rules/(in|out)/{acl}
|
|
"rule": {
|
|
"description": "",
|
|
"rule": "/rule",
|
|
"order": "/order",
|
|
}, # The root key
|
|
# The schema of an individual OSD entry (/ceph/osds/{osd_id})
|
|
"osd": {
|
|
"id": "", # The root key
|
|
"node": "/node",
|
|
"device": "/device",
|
|
"db_device": "/db_device",
|
|
"fsid": "/fsid",
|
|
"ofsid": "/fsid/osd",
|
|
"cfsid": "/fsid/cluster",
|
|
"lvm": "/lvm",
|
|
"vg": "/lvm/vg",
|
|
"lv": "/lvm/lv",
|
|
"is_split": "/is_split",
|
|
"stats": "/stats",
|
|
},
|
|
# The schema of an individual pool entry (/ceph/pools/{pool_name})
|
|
"pool": {
|
|
"name": "",
|
|
"pgs": "/pgs",
|
|
"tier": "/tier",
|
|
"stats": "/stats",
|
|
}, # The root key
|
|
# The schema of an individual volume entry (/ceph/volumes/{pool_name}/{volume_name})
|
|
"volume": {
|
|
"name": "",
|
|
"stats": "/stats",
|
|
}, # The root key
|
|
# The schema of an individual snapshot entry (/ceph/volumes/{pool_name}/{volume_name}/{snapshot_name})
|
|
"snapshot": {
|
|
"name": "",
|
|
"stats": "/stats",
|
|
}, # The root key
|
|
}
|
|
|
|
# Properties
|
|
@property
|
|
def schema_root(self):
|
|
return self._schema_root
|
|
|
|
@schema_root.setter
|
|
def schema_root(self, schema_root):
|
|
self._schema_root = schema_root
|
|
|
|
@property
|
|
def version(self):
|
|
return int(self._version)
|
|
|
|
@version.setter
|
|
def version(self, version):
|
|
self._version = int(version)
|
|
|
|
@property
|
|
def schema(self):
|
|
return self._schema
|
|
|
|
@schema.setter
|
|
def schema(self, schema):
|
|
self._schema = schema
|
|
|
|
def __init__(self, root_path=DEFAULT_ROOT_PATH):
|
|
self.schema_path = f"{root_path}/{SCHEMA_PATH}"
|
|
|
|
def __repr__(self):
|
|
return f"ZKSchema({self.version})"
|
|
|
|
def __lt__(self, other):
|
|
if self.version < other.version:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def __le__(self, other):
|
|
if self.version <= other.version:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def __gt__(self, other):
|
|
if self.version > other.version:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def __ge__(self, other):
|
|
if self.version >= other.version:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
def __eq__(self, other):
|
|
if self.version == other.version:
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
# Load the schema of a given version from a file
|
|
def load(self, version, quiet=False):
|
|
if not quiet:
|
|
print(f"Loading schema version {version}")
|
|
|
|
with open(f"{self.schema_path}/{version}.json", "r") as sfh:
|
|
self.schema = json.load(sfh)
|
|
self.version = self.schema.get("version")
|
|
|
|
# Get key paths
|
|
def path(self, ipath, item=None):
|
|
itype, *ipath = ipath.split(".")
|
|
|
|
if item is None:
|
|
return self.schema.get(itype).get(".".join(ipath))
|
|
else:
|
|
base_path = self.schema.get("base").get(itype, None)
|
|
if base_path is None:
|
|
# This should only really happen for second-layer key types where the helper functions join them together
|
|
base_path = ""
|
|
|
|
if not ipath:
|
|
# This is a root path
|
|
return f"{base_path}/{item}"
|
|
|
|
sub_path = self.schema.get(itype).get(".".join(ipath))
|
|
if sub_path is None:
|
|
# We didn't find the path we're looking for, so we don't want to do anything
|
|
return None
|
|
|
|
return f"{base_path}/{item}{sub_path}"
|
|
|
|
# Get keys of a schema location
|
|
def keys(self, itype=None):
|
|
if itype is None:
|
|
return list(self.schema.get("base").keys())
|
|
else:
|
|
return list(self.schema.get(itype).keys())
|
|
|
|
# Get the active version of a cluster's schema
|
|
def get_version(self, zkhandler):
|
|
try:
|
|
current_version = zkhandler.read(self.path("base.schema.version"))
|
|
except NoNodeError:
|
|
current_version = 0
|
|
return current_version
|
|
|
|
# Validate an active schema against a Zookeeper cluster
|
|
def validate(self, zkhandler, logger=None):
|
|
result = True
|
|
|
|
# Walk the entire tree checking our schema
|
|
for elem in ["base"]:
|
|
for key in self.keys(elem):
|
|
kpath = f"{elem}.{key}"
|
|
if not zkhandler.zk_conn.exists(self.path(kpath)):
|
|
if logger is not None:
|
|
logger.out(f"Key not found: {self.path(kpath)}", state="w")
|
|
result = False
|
|
|
|
for elem in ["node", "domain", "network", "osd", "pool"]:
|
|
# First read all the subelements of the key class
|
|
for child in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
if logger is not None:
|
|
logger.out(
|
|
f"Key not found: {self.path(kpath, child)}", state="w"
|
|
)
|
|
result = False
|
|
|
|
# Continue for child keys under network (reservation, acl)
|
|
if elem in ["network"] and ikey in [
|
|
"reservation",
|
|
"rule.in",
|
|
"rule.out",
|
|
]:
|
|
if ikey in ["rule.in", "rule.out"]:
|
|
sikey = "rule"
|
|
else:
|
|
sikey = ikey
|
|
npath = self.path(f"{elem}.{ikey}", child)
|
|
for nchild in zkhandler.zk_conn.get_children(npath):
|
|
nkpath = f"{npath}/{nchild}"
|
|
for esikey in self.keys(sikey):
|
|
nkipath = f"{nkpath}/{esikey}"
|
|
if not zkhandler.zk_conn.exists(nkipath):
|
|
result = False
|
|
|
|
# One might expect child keys under node (specifically, sriov.pf, sriov.vf,
|
|
# monitoring.data) to be managed here as well, but those are created
|
|
# automatically every time pvcnoded started and thus never need to be validated
|
|
# or applied.
|
|
|
|
# These two have several children layers that must be parsed through
|
|
for elem in ["volume"]:
|
|
# First read all the subelements of the key class (pool layer)
|
|
for pchild in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# Finally read all the subelements of the key class (volume layer)
|
|
for vchild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}"
|
|
):
|
|
child = f"{pchild}/{vchild}"
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
if logger is not None:
|
|
logger.out(
|
|
f"Key not found: {self.path(kpath, child)}",
|
|
state="w",
|
|
)
|
|
result = False
|
|
|
|
for elem in ["snapshot"]:
|
|
# First read all the subelements of the key class (pool layer)
|
|
for pchild in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# Next read all the subelements of the key class (volume layer)
|
|
for vchild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}"
|
|
):
|
|
# Finally read all the subelements of the key class (volume layer)
|
|
for schild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}/{vchild}"
|
|
):
|
|
child = f"{pchild}/{vchild}/{schild}"
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
if logger is not None:
|
|
logger.out(
|
|
f"Key not found: {self.path(kpath, child)}",
|
|
state="w",
|
|
)
|
|
result = False
|
|
|
|
return result
|
|
|
|
# Apply the current schema to the cluster
|
|
def apply(self, zkhandler):
|
|
# Walk the entire tree checking our schema
|
|
for elem in ["base"]:
|
|
for key in self.keys(elem):
|
|
kpath = f"{elem}.{key}"
|
|
if not zkhandler.zk_conn.exists(self.path(kpath)):
|
|
# Ensure that we create base.schema.version with the current valid version value
|
|
if kpath == "base.schema.version":
|
|
data = str(self.version)
|
|
else:
|
|
data = ""
|
|
zkhandler.zk_conn.create(
|
|
self.path(kpath), data.encode(zkhandler.encoding)
|
|
)
|
|
|
|
for elem in ["node", "domain", "network", "osd", "pool"]:
|
|
# First read all the subelements of the key class
|
|
for child in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
if elem == "osd" and ikey == "is_split":
|
|
default_data = "False"
|
|
elif elem == "pool" and ikey == "tier":
|
|
default_data = "default"
|
|
elif elem == "domain" and ikey == "meta.migrate_max_downtime":
|
|
default_data = "300"
|
|
else:
|
|
default_data = ""
|
|
zkhandler.zk_conn.create(
|
|
self.path(kpath, child),
|
|
default_data.encode(zkhandler.encoding),
|
|
)
|
|
|
|
# Continue for child keys under network (reservation, acl)
|
|
if elem in ["network"] and ikey in [
|
|
"reservation",
|
|
"rule.in",
|
|
"rule.out",
|
|
]:
|
|
if ikey in ["rule.in", "rule.out"]:
|
|
sikey = "rule"
|
|
else:
|
|
sikey = ikey
|
|
npath = self.path(f"{elem}.{ikey}", child)
|
|
for nchild in zkhandler.zk_conn.get_children(npath):
|
|
nkpath = f"{npath}/{nchild}"
|
|
for esikey in self.keys(sikey):
|
|
nkipath = f"{nkpath}/{esikey}"
|
|
if not zkhandler.zk_conn.exists(nkipath):
|
|
zkhandler.zk_conn.create(
|
|
nkipath, "".encode(zkhandler.encoding)
|
|
)
|
|
|
|
# One might expect child keys under node (specifically, sriov.pf and sriov.vf) to be
|
|
# managed here as well, but those are created automatically every time pvcnoded starts
|
|
# and thus never need to be validated or applied.
|
|
|
|
# These two have several children layers that must be parsed through
|
|
for elem in ["volume"]:
|
|
# First read all the subelements of the key class (pool layer)
|
|
for pchild in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# Finally read all the subelements of the key class (volume layer)
|
|
for vchild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}"
|
|
):
|
|
child = f"{pchild}/{vchild}"
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
zkhandler.zk_conn.create(
|
|
self.path(kpath, child), "".encode(zkhandler.encoding)
|
|
)
|
|
|
|
for elem in ["snapshot"]:
|
|
# First read all the subelements of the key class (pool layer)
|
|
for pchild in zkhandler.zk_conn.get_children(self.path(f"base.{elem}")):
|
|
# Next read all the subelements of the key class (volume layer)
|
|
for vchild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}"
|
|
):
|
|
# Finally read all the subelements of the key class (volume layer)
|
|
for schild in zkhandler.zk_conn.get_children(
|
|
self.path(f"base.{elem}") + f"/{pchild}/{vchild}"
|
|
):
|
|
child = f"{pchild}/{vchild}/{schild}"
|
|
# For each key in the schema for that particular elem
|
|
for ikey in self.keys(elem):
|
|
kpath = f"{elem}.{ikey}"
|
|
# Validate that the key exists for that child
|
|
if not zkhandler.zk_conn.exists(self.path(kpath, child)):
|
|
zkhandler.zk_conn.create(
|
|
self.path(kpath, child),
|
|
"".encode(zkhandler.encoding),
|
|
)
|
|
|
|
# Migrate key diffs
|
|
def run_migrate(self, zkhandler, changes):
|
|
diff_add = changes["add"]
|
|
diff_remove = changes["remove"]
|
|
diff_rename = changes["rename"]
|
|
add_tasks = list()
|
|
for key in diff_add.keys():
|
|
add_tasks.append((diff_add[key], ""))
|
|
remove_tasks = list()
|
|
for key in diff_remove.keys():
|
|
remove_tasks.append(diff_remove[key])
|
|
rename_tasks = list()
|
|
for key in diff_rename.keys():
|
|
rename_tasks.append((diff_rename[key]["from"], diff_rename[key]["to"]))
|
|
|
|
zkhandler.write(add_tasks)
|
|
zkhandler.delete(remove_tasks)
|
|
zkhandler.rename(rename_tasks)
|
|
|
|
# Migrate from older to newer schema
|
|
def migrate(self, zkhandler, new_version):
|
|
# Determine the versions in between
|
|
versions = self.find_all(start=self.version, end=new_version)
|
|
if versions is None:
|
|
return
|
|
|
|
for version in versions:
|
|
# Create a new schema at that version
|
|
zkschema_new = ZKSchema()
|
|
zkschema_new.load(version)
|
|
# Get a list of changes
|
|
changes = ZKSchema.key_diff(self, zkschema_new)
|
|
# Apply those changes
|
|
self.run_migrate(zkhandler, changes)
|
|
|
|
# Rollback from newer to older schema
|
|
def rollback(self, zkhandler, old_version):
|
|
# Determine the versions in between
|
|
versions = self.find_all(start=old_version - 1, end=self.version - 1)
|
|
if versions is None:
|
|
return
|
|
|
|
versions.reverse()
|
|
|
|
for version in versions:
|
|
# Create a new schema at that version
|
|
zkschema_old = ZKSchema()
|
|
zkschema_old.load(version)
|
|
# Get a list of changes
|
|
changes = ZKSchema.key_diff(self, zkschema_old)
|
|
# Apply those changes
|
|
self.run_migrate(zkhandler, changes)
|
|
|
|
# Write the latest schema to a file
|
|
def write(self):
|
|
schema_file = f"{self.schema_path}/{self._version}.json"
|
|
with open(schema_file, "w") as sfh:
|
|
json.dump(self._schema, sfh)
|
|
|
|
@classmethod
|
|
def key_diff(cls, schema_a, schema_b):
|
|
# schema_a = current
|
|
# schema_b = new
|
|
|
|
diff_add = dict()
|
|
diff_remove = dict()
|
|
diff_rename = dict()
|
|
|
|
# Parse through each core element
|
|
for elem in [
|
|
"base",
|
|
"node",
|
|
"domain",
|
|
"network",
|
|
"osd",
|
|
"pool",
|
|
"volume",
|
|
"snapshot",
|
|
]:
|
|
set_a = set(schema_a.keys(elem))
|
|
set_b = set(schema_b.keys(elem))
|
|
diff_keys = set_a ^ set_b
|
|
|
|
for item in diff_keys:
|
|
elem_item = f"{elem}.{item}"
|
|
if item not in schema_a.keys(elem) and item in schema_b.keys(elem):
|
|
diff_add[elem_item] = schema_b.path(elem_item)
|
|
if item in schema_a.keys(elem) and item not in schema_b.keys(elem):
|
|
diff_remove[elem_item] = schema_a.path(elem_item)
|
|
|
|
for item in set_b:
|
|
elem_item = f"{elem}.{item}"
|
|
if (
|
|
schema_a.path(elem_item) is not None
|
|
and schema_b.path(elem_item) is not None
|
|
and schema_a.path(elem_item) != schema_b.path(elem_item)
|
|
):
|
|
diff_rename[elem_item] = {
|
|
"from": schema_a.path(elem_item),
|
|
"to": schema_b.path(elem_item),
|
|
}
|
|
|
|
return {"add": diff_add, "remove": diff_remove, "rename": diff_rename}
|
|
|
|
# Static methods for reading information from the files
|
|
def find_all(self, start=0, end=None):
|
|
versions = list()
|
|
for version in os.listdir(self.schema_path):
|
|
sequence_id = int(version.split(".")[0])
|
|
if end is None:
|
|
if sequence_id > start:
|
|
versions.append(sequence_id)
|
|
else:
|
|
if sequence_id > start and sequence_id <= end:
|
|
versions.append(sequence_id)
|
|
if len(versions) > 0:
|
|
return versions
|
|
else:
|
|
return None
|
|
|
|
def find_latest(self):
|
|
latest_version = 0
|
|
for version in os.listdir(self.schema_path):
|
|
sequence_id = int(version.split(".")[0])
|
|
if sequence_id > latest_version:
|
|
latest_version = sequence_id
|
|
return latest_version
|
|
|
|
# Load in the schema of the current cluster
|
|
@classmethod
|
|
def load_current(cls, zkhandler):
|
|
new_instance = cls()
|
|
version = new_instance.get_version(zkhandler)
|
|
new_instance.load(version)
|
|
return new_instance
|