Persist daemon resource runtime state

This commit is contained in:
Lyncoln
2026-09-17 16:27:56 -04:00
parent 972232332f
commit 263c03aa32
2 changed files with 248 additions and 1 deletions
+126 -1
View File
@@ -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,
+122
View File
@@ -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")