Skip to content

Commit b5c20f2

Browse files
committed
fix: require loaded configuration revision before control attach
1 parent 81c3e6b commit b5c20f2

4 files changed

Lines changed: 49 additions & 6 deletions

File tree

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2598,6 +2598,7 @@
25982598
# Apps.yaml validation schema
25992599
APPS_SCHEMA = {
26002600
"control_handoff": {"type": "dict"},
2601+
"saas_config_version": {"type": "integer", "min": 1},
26012602
"currency_symbols": {"type": "string|string_list"},
26022603
"db_enable": {"type": "boolean"},
26032604
"db_days": {"type": "integer"},

‎apps/predbat/control_handoff.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,14 @@ def _binding(self, data):
276276

277277
def attach(self):
278278
"""Claim the pre-enrolled epoch before any component can be constructed."""
279+
loaded_version = self.base.args.get("saas_config_version")
280+
if type(loaded_version) is not int or not 1 <= loaded_version <= 2147483647:
281+
raise ControlAttachFailed("Loaded controller configuration revision unavailable")
279282
started, data, status = self._request("attach")
280283
if status != 200 or not self._binding(data) or data.get("state") != "active":
281284
raise ControlAttachFailed("Controller service attach unavailable")
285+
if data["config_version"] != loaded_version:
286+
raise ControlAttachFailed("Loaded controller configuration revision changed")
282287
lease = data.get("lease_for_s")
283288
if type(lease) is not int or not 1 <= lease <= 90 or started + lease <= self._clock():
284289
raise ControlAttachFailed("Controller service attach lease invalid")

‎apps/predbat/tests/test_control_handoff.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ class ControlServiceTests(unittest.TestCase):
416416
def setUp(self):
417417
"""Use a deterministic monotonic clock and synthetic service credentials."""
418418
self.now = 100.0
419-
self.base = SimpleNamespace(args={"control_handoff": {"enabled": True}, "user_id": self.instance, "ge_cloud_direct": True, "ge_cloud_key": "synthetic-inverter-key"}, log=lambda *_: None)
419+
self.base = SimpleNamespace(args={"control_handoff": {"enabled": True}, "saas_config_version": 7, "user_id": self.instance, "ge_cloud_direct": True, "ge_cloud_key": "synthetic-inverter-key"}, log=lambda *_: None)
420420
self.service = ControlHandoffService(self.base, self.instance, self.boot, "https://service.invalid", "synthetic-service-key", clock=lambda: self.now)
421421

422422
def active(self, **changes):
@@ -437,6 +437,35 @@ def closing(self):
437437
data.pop("lease_for_s")
438438
return data
439439

440+
def test_loaded_revision_is_required_before_claiming_a_boot(self):
441+
"""An absent or malformed emitted revision cannot make a service request."""
442+
for value in (None, True, False, 0, -1, "7", 7.0, 2147483648):
443+
self.base.args["saas_config_version"] = value
444+
with patch("control_handoff.requests.post") as post, self.assertRaises(ControlAttachFailed):
445+
self.service.attach()
446+
post.assert_not_called()
447+
self.assertIsNone(self.service.gate)
448+
449+
def test_stale_loaded_revision_cannot_start_components(self):
450+
"""A server epoch at another revision leaves startup's sentinel sealed."""
451+
from datetime import datetime, timezone
452+
import predbat as predbat_module
453+
454+
self.base.update_time = lambda **_kwargs: None
455+
self.base.now_utc_real = datetime.now(timezone.utc)
456+
self.base.reset = lambda: None
457+
self.base.record_status = lambda *_args, **_kwargs: None
458+
environment = {"SUPABASE_URL": "https://service.invalid", "SUPABASE_KEY": "synthetic-service-key"}
459+
with patch.dict("os.environ", environment), patch("control_handoff.uuid.uuid4", return_value=self.boot), patch("control_handoff.requests.post", return_value=ServiceResponse(self.active(config_version=8))), patch(
460+
"control_handoff.ControlHandoffService.start"
461+
) as worker, patch.object(predbat_module, "Components") as components, self.assertRaises(ControlAttachFailed):
462+
predbat_module.PredBat.initialize(self.base)
463+
worker.assert_not_called()
464+
components.assert_not_called()
465+
self.assertTrue(self.base.control_write_gate.snapshot()["closed"])
466+
with self.assertRaises(ControlWriteBlocked):
467+
self.base.control_write_gate.admit("givenergy")
468+
440469
def test_attach_uses_service_credentials_and_exact_boot(self):
441470
"""Bind only the service-issued epoch and never follow credential redirects."""
442471
with patch("control_handoff.requests.post", return_value=ServiceResponse(self.active())) as post:

‎docs/control-write-drain-gate.md‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,19 @@ current engine lease and reservation; expired reports cannot be accepted.
7575
Shutdown and startup exceptions seal before stopping components, without a
7676
success report in cleanup.
7777

78-
Current engine arguments do not expose their loaded configuration revision.
79-
The worker pins the service-issued revision and fences all supported provider
80-
transports, but this is not evidence that local arguments applied that revision.
81-
The image wrapper must bind emitted configuration and exact inverter identity
82-
before any target readiness or write release is authorised.
78+
Enrolled engine arguments must include `saas_config_version`, emitted from the
79+
SaaS response revision by the operator or legacy image wrapper. Before claiming
80+
a boot, startup rejects an absent or malformed revision. After attaching, it
81+
requires that loaded revision to equal the service-issued revision before
82+
starting the worker or constructing components. Stale configuration therefore
83+
leaves startup's admission sentinel closed. Ordinary, unenrolled startup does
84+
not require this field.
85+
86+
This comparison binds the loaded revision number, not a configuration content
87+
hash or the physical inverter identity. Exact inverter identity and target
88+
readiness still require independent evidence before any write release is
89+
authorised. The paired operator and image-wrapper changes must be deployed
90+
before explicit enrollment; this feature remains disabled by default.
8391

8492
Enrollment requires the selected supported writer to be configured. Every actual
8593
`Components.initialize`, including restarts, rejects eligible unsupported inverter

0 commit comments

Comments
 (0)