363 lines
9.3 KiB
Python
363 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import copy
|
|
import hashlib
|
|
import importlib.util
|
|
import inspect
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
|
|
class RegistryError(Exception):
|
|
pass
|
|
|
|
|
|
class Plugin:
|
|
def __init__(self, plugin_root, name):
|
|
self.name = name
|
|
self.path = Path(plugin_root) / name
|
|
self.manifest_path = self.path / "manifest.json"
|
|
|
|
if not self.manifest_path.is_file():
|
|
raise RegistryError(
|
|
f"plugin manifest not found: {self.manifest_path}"
|
|
)
|
|
|
|
with self.manifest_path.open("r", encoding="utf-8") as f:
|
|
self.manifest = json.load(f)
|
|
|
|
module_name = self.manifest.get("module", "plugin.py")
|
|
module_path = self.path / module_name
|
|
|
|
if not module_path.is_file():
|
|
raise RegistryError(
|
|
f"plugin module not found: {module_path}"
|
|
)
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"managed_containerd_plugin_{name}",
|
|
module_path,
|
|
)
|
|
|
|
if spec is None or spec.loader is None:
|
|
raise RegistryError(
|
|
f"unable to load plugin: {name}"
|
|
)
|
|
|
|
self.module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(self.module)
|
|
|
|
@property
|
|
def actions(self):
|
|
return list(self.manifest.get("actions", []))
|
|
|
|
@property
|
|
def required_config(self):
|
|
return list(
|
|
self.manifest.get("required_config", [])
|
|
)
|
|
|
|
@property
|
|
def optional_config(self):
|
|
return list(
|
|
self.manifest.get("optional_config", [])
|
|
)
|
|
|
|
@property
|
|
def config_keys(self):
|
|
return list(dict.fromkeys(
|
|
self.required_config + self.optional_config
|
|
))
|
|
|
|
def validate_config(self, config):
|
|
missing = [
|
|
key
|
|
for key in self.required_config
|
|
if not config.get(key)
|
|
]
|
|
|
|
if missing:
|
|
raise RegistryError(
|
|
"missing required configuration: "
|
|
+ ", ".join(missing)
|
|
)
|
|
|
|
plugin_validator = getattr(
|
|
self.module,
|
|
"validate_config",
|
|
None,
|
|
)
|
|
|
|
if plugin_validator is not None:
|
|
try:
|
|
plugin_validator(config)
|
|
except (TypeError, ValueError) as exc:
|
|
raise RegistryError(
|
|
f"invalid plugin configuration: {exc}"
|
|
) from exc
|
|
|
|
def execute(
|
|
self,
|
|
action,
|
|
config,
|
|
runtime_state,
|
|
request_parameters=None,
|
|
):
|
|
if action not in self.actions:
|
|
raise RegistryError(
|
|
f"plugin {self.name} does not support action {action}"
|
|
)
|
|
|
|
handler_name = action.replace(".", "_")
|
|
handler = getattr(self.module, handler_name, None)
|
|
|
|
if handler is None:
|
|
raise RegistryError(
|
|
f"plugin {self.name} advertises {action} "
|
|
"but does not implement it"
|
|
)
|
|
|
|
# Backward-compatible generic state injection. Existing one-argument
|
|
# plugins continue to receive only config; state-aware plugins can
|
|
# declare a second positional parameter named runtime_state.
|
|
handler_parameters = list(
|
|
inspect.signature(handler).parameters.values()
|
|
)
|
|
|
|
if len(handler_parameters) >= 3:
|
|
return handler(
|
|
config,
|
|
runtime_state,
|
|
request_parameters or {},
|
|
)
|
|
|
|
if len(handler_parameters) >= 2:
|
|
return handler(config, runtime_state)
|
|
|
|
return handler(config)
|
|
|
|
|
|
class Resource:
|
|
def __init__(
|
|
self,
|
|
resource_id,
|
|
plugin,
|
|
config,
|
|
state_path=None,
|
|
):
|
|
self.resource_id = resource_id
|
|
self.plugin = plugin
|
|
self.config = config
|
|
self.state_path = (
|
|
Path(state_path)
|
|
if state_path is not None
|
|
else None
|
|
)
|
|
|
|
# Generic daemon-lifetime state storage. The daemon does not interpret
|
|
# plugin keys or values; applications may use this for transition
|
|
# history, grace windows, caches, or other resource-local state.
|
|
self.runtime_state = {}
|
|
|
|
self.plugin.validate_config(config)
|
|
self.load_state()
|
|
|
|
def load_state(self):
|
|
if self.state_path is None:
|
|
return
|
|
|
|
if not self.state_path.exists():
|
|
return
|
|
|
|
try:
|
|
with self.state_path.open(
|
|
"r",
|
|
encoding="utf-8",
|
|
) as stream:
|
|
state = json.load(stream)
|
|
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise RegistryError(
|
|
f"unable to load resource state: {self.resource_id}"
|
|
) from exc
|
|
|
|
if not isinstance(state, dict):
|
|
raise RegistryError(
|
|
f"resource state is not an object: {self.resource_id}"
|
|
)
|
|
|
|
self.runtime_state = state
|
|
|
|
# Monotonic timestamps cannot survive a daemon restart.
|
|
postgresql_state = self.runtime_state.get("postgresql")
|
|
|
|
if isinstance(postgresql_state, dict):
|
|
postgresql_state.pop(
|
|
"reconnect_started_at",
|
|
None,
|
|
)
|
|
postgresql_state.pop(
|
|
"state_changed_at",
|
|
None,
|
|
)
|
|
|
|
def persistent_state(self):
|
|
state = copy.deepcopy(self.runtime_state)
|
|
postgresql_state = state.get("postgresql")
|
|
|
|
if isinstance(postgresql_state, dict):
|
|
postgresql_state.pop(
|
|
"reconnect_started_at",
|
|
None,
|
|
)
|
|
postgresql_state.pop(
|
|
"state_changed_at",
|
|
None,
|
|
)
|
|
|
|
return state
|
|
|
|
def save_state(self):
|
|
if self.state_path is None:
|
|
return
|
|
|
|
self.state_path.parent.mkdir(
|
|
parents=True,
|
|
exist_ok=True,
|
|
)
|
|
|
|
temporary_path = None
|
|
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=self.state_path.parent,
|
|
prefix=f".{self.state_path.name}.",
|
|
delete=False,
|
|
) as stream:
|
|
temporary_path = Path(stream.name)
|
|
json.dump(
|
|
self.persistent_state(),
|
|
stream,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
)
|
|
stream.write("\n")
|
|
|
|
os.chmod(temporary_path, 0o600)
|
|
os.replace(temporary_path, self.state_path)
|
|
|
|
except OSError as exc:
|
|
if temporary_path is not None:
|
|
temporary_path.unlink(missing_ok=True)
|
|
|
|
raise RegistryError(
|
|
f"unable to save resource state: {self.resource_id}"
|
|
) from exc
|
|
|
|
def describe(self):
|
|
return {
|
|
"resource": self.resource_id,
|
|
"plugin": self.plugin.name,
|
|
"plugin_version": self.plugin.manifest.get(
|
|
"version"
|
|
),
|
|
"actions": self.plugin.actions,
|
|
"configured": True,
|
|
}
|
|
|
|
|
|
class Registry:
|
|
def __init__(self):
|
|
self.plugin_root = os.environ.get(
|
|
"MANAGED_CONTAINERD_PLUGIN_ROOT",
|
|
"/opt/managed-containerd/plugins",
|
|
)
|
|
|
|
self.resources = {}
|
|
self.state_dir = os.environ.get(
|
|
"MANAGED_CONTAINERD_STATE_DIR"
|
|
)
|
|
self._load_environment_resource()
|
|
|
|
def _load_environment_resource(self):
|
|
resource_id = os.environ.get("RESOURCE_ID")
|
|
plugin_name = os.environ.get("PLUGIN")
|
|
|
|
if not resource_id:
|
|
raise RegistryError(
|
|
"RESOURCE_ID is not configured"
|
|
)
|
|
|
|
if not plugin_name:
|
|
raise RegistryError(
|
|
"PLUGIN is not configured"
|
|
)
|
|
|
|
plugin = Plugin(
|
|
self.plugin_root,
|
|
plugin_name,
|
|
)
|
|
|
|
config = {
|
|
key: os.environ.get(key)
|
|
for key in plugin.config_keys
|
|
}
|
|
|
|
state_path = None
|
|
|
|
if self.state_dir:
|
|
state_name = hashlib.sha256(
|
|
resource_id.encode("utf-8")
|
|
).hexdigest() + ".json"
|
|
state_path = (
|
|
Path(self.state_dir) / state_name
|
|
)
|
|
|
|
self.resources[resource_id] = Resource(
|
|
resource_id,
|
|
plugin,
|
|
config,
|
|
state_path,
|
|
)
|
|
|
|
def list_resources(self):
|
|
return {
|
|
resource_id: resource.describe()
|
|
for resource_id, resource
|
|
in self.resources.items()
|
|
}
|
|
|
|
def execute(
|
|
self,
|
|
resource_id,
|
|
action,
|
|
request_parameters=None,
|
|
):
|
|
resource = self.resources.get(resource_id)
|
|
|
|
if resource is None:
|
|
raise RegistryError(
|
|
f"unknown resource: {resource_id}"
|
|
)
|
|
|
|
result = resource.plugin.execute(
|
|
action,
|
|
resource.config,
|
|
resource.runtime_state,
|
|
request_parameters,
|
|
)
|
|
|
|
resource.save_state()
|
|
|
|
return {
|
|
"ok": True,
|
|
"resource": resource_id,
|
|
"plugin": resource.plugin.name,
|
|
"action": action,
|
|
"result": result,
|
|
}
|