Persist daemon resource runtime state
This commit is contained in:
+126
-1
@@ -1,9 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -122,10 +125,21 @@ class Plugin:
|
|||||||
|
|
||||||
|
|
||||||
class Resource:
|
class Resource:
|
||||||
def __init__(self, resource_id, plugin, config):
|
def __init__(
|
||||||
|
self,
|
||||||
|
resource_id,
|
||||||
|
plugin,
|
||||||
|
config,
|
||||||
|
state_path=None,
|
||||||
|
):
|
||||||
self.resource_id = resource_id
|
self.resource_id = resource_id
|
||||||
self.plugin = plugin
|
self.plugin = plugin
|
||||||
self.config = config
|
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
|
# Generic daemon-lifetime state storage. The daemon does not interpret
|
||||||
# plugin keys or values; applications may use this for transition
|
# plugin keys or values; applications may use this for transition
|
||||||
@@ -133,6 +147,101 @@ class Resource:
|
|||||||
self.runtime_state = {}
|
self.runtime_state = {}
|
||||||
|
|
||||||
self.plugin.validate_config(config)
|
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):
|
def describe(self):
|
||||||
return {
|
return {
|
||||||
@@ -154,6 +263,9 @@ class Registry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.resources = {}
|
self.resources = {}
|
||||||
|
self.state_dir = os.environ.get(
|
||||||
|
"MANAGED_CONTAINERD_STATE_DIR"
|
||||||
|
)
|
||||||
self._load_environment_resource()
|
self._load_environment_resource()
|
||||||
|
|
||||||
def _load_environment_resource(self):
|
def _load_environment_resource(self):
|
||||||
@@ -180,10 +292,21 @@ class Registry:
|
|||||||
for key in plugin.config_keys
|
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(
|
self.resources[resource_id] = Resource(
|
||||||
resource_id,
|
resource_id,
|
||||||
plugin,
|
plugin,
|
||||||
config,
|
config,
|
||||||
|
state_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_resources(self):
|
def list_resources(self):
|
||||||
@@ -213,6 +336,8 @@ class Registry:
|
|||||||
request_parameters,
|
request_parameters,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
resource.save_state()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"resource": resource_id,
|
"resource": resource_id,
|
||||||
|
|||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from registry import Registry
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_SOURCE = r"""
|
||||||
|
def notify(config, runtime_state, request_parameters):
|
||||||
|
runtime_state["postgresql"] = {
|
||||||
|
"notification": dict(request_parameters),
|
||||||
|
"reconnect_started_at": 123.4,
|
||||||
|
"state_changed_at": 567.8,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ocf_rc": 0,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise SystemExit("FAIL: " + message)
|
||||||
|
|
||||||
|
|
||||||
|
print("===== DAEMON STATE PERSISTENCE =====")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_root:
|
||||||
|
root = Path(temporary_root)
|
||||||
|
plugin_root = root / "plugins" / "fake"
|
||||||
|
plugin_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
(plugin_root / "manifest.json").write_text(
|
||||||
|
json.dumps({
|
||||||
|
"name": "fake",
|
||||||
|
"version": 1,
|
||||||
|
"module": "plugin.py",
|
||||||
|
"actions": ["notify"],
|
||||||
|
"required_config": [],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
(plugin_root / "plugin.py").write_text(PLUGIN_SOURCE)
|
||||||
|
|
||||||
|
state_dir = root / "state"
|
||||||
|
|
||||||
|
environment = {
|
||||||
|
"MANAGED_CONTAINERD_PLUGIN_ROOT": str(
|
||||||
|
root / "plugins"
|
||||||
|
),
|
||||||
|
"MANAGED_CONTAINERD_STATE_DIR": str(state_dir),
|
||||||
|
"RESOURCE_ID": "fake-resource",
|
||||||
|
"PLUGIN": "fake",
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters = {
|
||||||
|
"type": "pre",
|
||||||
|
"operation": "demote",
|
||||||
|
"promote_uname": "david-eve",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, environment, clear=False):
|
||||||
|
first = Registry()
|
||||||
|
result = first.execute(
|
||||||
|
"fake-resource",
|
||||||
|
"notify",
|
||||||
|
parameters,
|
||||||
|
)
|
||||||
|
|
||||||
|
require(
|
||||||
|
result["result"]["ocf_rc"] == 0,
|
||||||
|
"initial notify failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
files = list(state_dir.glob("*.json"))
|
||||||
|
require(
|
||||||
|
len(files) == 1,
|
||||||
|
"state file was not created",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = files[0].stat().st_mode & 0o777
|
||||||
|
require(
|
||||||
|
mode == 0o600,
|
||||||
|
"state file is not mode 0600",
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = json.loads(files[0].read_text())
|
||||||
|
require(
|
||||||
|
saved["postgresql"]["notification"]
|
||||||
|
== parameters,
|
||||||
|
"notification was not persisted",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
"reconnect_started_at"
|
||||||
|
not in saved["postgresql"],
|
||||||
|
"volatile reconnect state was persisted",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
"state_changed_at"
|
||||||
|
not in saved["postgresql"],
|
||||||
|
"volatile monotonic state was persisted",
|
||||||
|
)
|
||||||
|
print("atomic_state_write persisted")
|
||||||
|
|
||||||
|
second = Registry()
|
||||||
|
restored = second.resources[
|
||||||
|
"fake-resource"
|
||||||
|
].runtime_state
|
||||||
|
|
||||||
|
require(
|
||||||
|
restored["postgresql"]["notification"]
|
||||||
|
== parameters,
|
||||||
|
"notification was not restored",
|
||||||
|
)
|
||||||
|
print("restart_state_load restored")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("STATE_PERSISTENCE_TEST_PASS=1")
|
||||||
Reference in New Issue
Block a user