piphi-runtime-kit-python 0.4.2__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/PKG-INFO +1 -1
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/pyproject.toml +1 -1
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/__init__.py +27 -1
- piphi_runtime_kit_python-0.4.4/src/piphi_runtime_kit_python/runtime/config_sync.py +462 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/health.py +1 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/schemas.py +22 -0
- piphi_runtime_kit_python-0.4.4/tests/test_config_sync.py +332 -0
- piphi_runtime_kit_python-0.4.2/src/piphi_runtime_kit_python/runtime/config_sync.py +0 -157
- piphi_runtime_kit_python-0.4.2/tests/test_config_sync.py +0 -74
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/LICENSE +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/README.md +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/adapters/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/adapters/fastapi.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/fastapi.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/auth.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/configuration.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/context.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/discovery.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/dispatch.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/errors.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/events.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/identity.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/lifespan.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/mqtt.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/registry.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/starter.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/state.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/tasks.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/src/piphi_runtime_kit_python/runtime/telemetry.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/__init__.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_fastapi_adapter.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_auth.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_auth_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_config_sync_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_configuration.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_configuration_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_delivery_errors.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_discovery.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_discovery_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_dispatch.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_dispatch_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_errors_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_events.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_events_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_health.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_identity.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_lifespan.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_mqtt.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_mqtt_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_registry.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_registry_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_starter.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_support_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_tasks.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_telemetry.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_runtime_telemetry_matrix.py +0 -0
- {piphi_runtime_kit_python-0.4.2 → piphi_runtime_kit_python-0.4.4}/tests/test_schemas_matrix.py +0 -0
|
@@ -8,7 +8,22 @@ from .runtime.auth import (
|
|
|
8
8
|
format_runtime_auth_sync_log,
|
|
9
9
|
mask_token,
|
|
10
10
|
)
|
|
11
|
-
from .runtime.config_sync import
|
|
11
|
+
from .runtime.config_sync import (
|
|
12
|
+
CORE_BASE_URL_ENV_NAME,
|
|
13
|
+
DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME,
|
|
14
|
+
DEFAULT_RUNTIME_VOLUME_DIR,
|
|
15
|
+
RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME,
|
|
16
|
+
ConfigSyncCoordinator,
|
|
17
|
+
RuntimeConfigRehydrateResult,
|
|
18
|
+
build_runtime_config_snapshot_from_core_rows,
|
|
19
|
+
build_sync_response,
|
|
20
|
+
fetch_core_runtime_config_snapshot,
|
|
21
|
+
load_runtime_config_snapshot,
|
|
22
|
+
reconcile_config_ids,
|
|
23
|
+
rehydrate_runtime_configs,
|
|
24
|
+
resolve_core_base_url,
|
|
25
|
+
resolve_runtime_config_snapshot_path,
|
|
26
|
+
)
|
|
12
27
|
from .runtime.configuration import (
|
|
13
28
|
build_config_apply_response,
|
|
14
29
|
build_config_remove_response,
|
|
@@ -109,6 +124,10 @@ __all__ = [
|
|
|
109
124
|
"CoreUnavailableError",
|
|
110
125
|
"CoreUnexpectedResponseError",
|
|
111
126
|
"DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS",
|
|
127
|
+
"CORE_BASE_URL_ENV_NAME",
|
|
128
|
+
"DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME",
|
|
129
|
+
"DEFAULT_RUNTIME_VOLUME_DIR",
|
|
130
|
+
"RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME",
|
|
112
131
|
"DEFAULT_RUNTIME_CONTAINER_ID_ENV_NAME",
|
|
113
132
|
"DEFAULT_RUNTIME_INTERNAL_TOKEN_ENV_NAME",
|
|
114
133
|
"RUNTIME_CONTAINER_ID_HEADER_NAME",
|
|
@@ -127,6 +146,7 @@ __all__ = [
|
|
|
127
146
|
"RuntimeConfig",
|
|
128
147
|
"RuntimeConfigApplyResponse",
|
|
129
148
|
"RuntimeConfigRemoveResponse",
|
|
149
|
+
"RuntimeConfigRehydrateResult",
|
|
130
150
|
"RuntimeConfigSnapshot",
|
|
131
151
|
"RuntimeConfigSyncResponse",
|
|
132
152
|
"RuntimeContext",
|
|
@@ -143,6 +163,7 @@ __all__ = [
|
|
|
143
163
|
"EventTransport",
|
|
144
164
|
"IntegrationEventIngestResponse",
|
|
145
165
|
"IntegrationEventListResponse",
|
|
166
|
+
"build_runtime_config_snapshot_from_core_rows",
|
|
146
167
|
"build_discovery_response",
|
|
147
168
|
"classify_core_delivery_error",
|
|
148
169
|
"build_core_event_payload",
|
|
@@ -165,10 +186,12 @@ __all__ = [
|
|
|
165
186
|
"dispatch_event_delivery",
|
|
166
187
|
"dispatch_telemetry_delivery",
|
|
167
188
|
"extract_runtime_auth_headers",
|
|
189
|
+
"fetch_core_runtime_config_snapshot",
|
|
168
190
|
"format_config_apply_log",
|
|
169
191
|
"format_discovery_attempt_log",
|
|
170
192
|
"format_event_log",
|
|
171
193
|
"format_runtime_auth_sync_log",
|
|
194
|
+
"load_runtime_config_snapshot",
|
|
172
195
|
"mask_token",
|
|
173
196
|
"MqttBrokerConfig",
|
|
174
197
|
"MqttJsonClient",
|
|
@@ -176,6 +199,9 @@ __all__ = [
|
|
|
176
199
|
"normalize_event_payload",
|
|
177
200
|
"redact_config_secrets",
|
|
178
201
|
"reconcile_config_ids",
|
|
202
|
+
"rehydrate_runtime_configs",
|
|
203
|
+
"resolve_core_base_url",
|
|
204
|
+
"resolve_runtime_config_snapshot_path",
|
|
179
205
|
"runtime_lifespan",
|
|
180
206
|
"schedule_event_delivery",
|
|
181
207
|
"schedule_telemetry_delivery",
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Helpers for basic config-sync orchestration and generation tracking."""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ..schemas import RuntimeConfig, RuntimeConfigSnapshot, RuntimeConfigSyncResponse
|
|
14
|
+
from .auth import build_runtime_auth_headers
|
|
15
|
+
from .context import RuntimeContext
|
|
16
|
+
from .lifespan import DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS
|
|
17
|
+
from .state import RuntimeProcessState
|
|
18
|
+
|
|
19
|
+
TConfig = TypeVar("TConfig", bound=RuntimeConfig)
|
|
20
|
+
|
|
21
|
+
CORE_BASE_URL_ENV_NAME = "PIPHI_CORE_BASE_URL"
|
|
22
|
+
RUNTIME_CONTAINER_ID_ENV_NAME = "PIPHI_CONTAINER_ID"
|
|
23
|
+
RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME = "PIPHI_CONFIG_SNAPSHOT_PATH"
|
|
24
|
+
DEFAULT_RUNTIME_VOLUME_DIR = "/.piphinetwork"
|
|
25
|
+
DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME = "configs.json"
|
|
26
|
+
CORE_RUNTIME_CONFIG_FETCH_PATH = (
|
|
27
|
+
"/api/v2/integrations/config/fetch/all/by/container/internal"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
SnapshotApplyCallable = Callable[[Any], Awaitable[Any]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_core_base_url(default: str | None = None) -> str | None:
|
|
35
|
+
"""Resolve Core's base URL from the managed-runtime environment."""
|
|
36
|
+
value = (os.getenv(CORE_BASE_URL_ENV_NAME) or "").strip().rstrip("/")
|
|
37
|
+
if value:
|
|
38
|
+
return value
|
|
39
|
+
return default.rstrip("/") if isinstance(default, str) and default.strip() else default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_runtime_config_snapshot_path(
|
|
43
|
+
*,
|
|
44
|
+
container_id: str | None = None,
|
|
45
|
+
volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Resolve the local config snapshot path mounted by PiPhi Core."""
|
|
48
|
+
explicit_path = (os.getenv(RUNTIME_CONFIG_SNAPSHOT_PATH_ENV_NAME) or "").strip()
|
|
49
|
+
if explicit_path:
|
|
50
|
+
return Path(explicit_path)
|
|
51
|
+
|
|
52
|
+
resolved_container_id = (
|
|
53
|
+
str(container_id or "").strip()
|
|
54
|
+
or (os.getenv(RUNTIME_CONTAINER_ID_ENV_NAME) or "").strip()
|
|
55
|
+
)
|
|
56
|
+
if resolved_container_id:
|
|
57
|
+
return Path(volume_dir).joinpath(f"{resolved_container_id}.json")
|
|
58
|
+
return Path(volume_dir).joinpath(DEFAULT_RUNTIME_CONFIG_SNAPSHOT_FILENAME)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_runtime_config_snapshot(
|
|
62
|
+
*,
|
|
63
|
+
path: str | Path | None = None,
|
|
64
|
+
container_id: str | None = None,
|
|
65
|
+
volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
|
|
66
|
+
) -> RuntimeConfigSnapshot | None:
|
|
67
|
+
"""Load Core's last known config snapshot from the mounted runtime volume.
|
|
68
|
+
|
|
69
|
+
Missing, unreadable, or invalid snapshots return ``None`` so integrations can
|
|
70
|
+
treat snapshot rehydration as a safe startup fallback rather than a crash
|
|
71
|
+
trigger.
|
|
72
|
+
"""
|
|
73
|
+
snapshot_path = (
|
|
74
|
+
Path(path)
|
|
75
|
+
if path is not None
|
|
76
|
+
else resolve_runtime_config_snapshot_path(
|
|
77
|
+
container_id=container_id,
|
|
78
|
+
volume_dir=volume_dir,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
raw_payload = snapshot_path.read_text(encoding="utf-8")
|
|
83
|
+
except (FileNotFoundError, OSError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(raw_payload)
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return None
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return RuntimeConfigSnapshot.model_validate(payload)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(slots=True)
|
|
100
|
+
class RuntimeConfigRehydrateResult:
|
|
101
|
+
"""Describe what happened during runtime startup config rehydration."""
|
|
102
|
+
|
|
103
|
+
snapshot_found: bool = False
|
|
104
|
+
snapshot_applied: bool = False
|
|
105
|
+
snapshot_config_count: int = 0
|
|
106
|
+
snapshot_generation: int | None = None
|
|
107
|
+
core_attempted: bool = False
|
|
108
|
+
core_applied: bool = False
|
|
109
|
+
core_config_count: int = 0
|
|
110
|
+
core_generation: int | None = None
|
|
111
|
+
core_error: str | None = None
|
|
112
|
+
missing_runtime_auth: bool = False
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def applied_any(self) -> bool:
|
|
116
|
+
"""Return whether any startup source applied configs."""
|
|
117
|
+
return self.snapshot_applied or self.core_applied
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _model_validate_or_create(model_type: type[Any], payload: dict[str, Any]) -> Any:
|
|
121
|
+
validator = getattr(model_type, "model_validate", None)
|
|
122
|
+
if callable(validator):
|
|
123
|
+
return validator(payload)
|
|
124
|
+
return model_type(**payload)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _snapshot_validate_or_create(model_type: type[Any], payload: dict[str, Any]) -> Any:
|
|
128
|
+
validator = getattr(model_type, "model_validate", None)
|
|
129
|
+
if callable(validator):
|
|
130
|
+
return validator(payload)
|
|
131
|
+
return model_type(**payload)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _convert_snapshot_configs(
|
|
135
|
+
snapshot: RuntimeConfigSnapshot,
|
|
136
|
+
*,
|
|
137
|
+
config_model: type[TConfig],
|
|
138
|
+
snapshot_model: type[Any],
|
|
139
|
+
reason: str,
|
|
140
|
+
) -> Any:
|
|
141
|
+
configs = [
|
|
142
|
+
_model_validate_or_create(config_model, config.model_dump())
|
|
143
|
+
for config in snapshot.configs
|
|
144
|
+
]
|
|
145
|
+
return _snapshot_validate_or_create(
|
|
146
|
+
snapshot_model,
|
|
147
|
+
{
|
|
148
|
+
"schema_version": snapshot.schema_version,
|
|
149
|
+
"container_id": snapshot.container_id,
|
|
150
|
+
"integration_id": snapshot.integration_id,
|
|
151
|
+
"driver_pid": snapshot.driver_pid,
|
|
152
|
+
"reason": snapshot.reason or reason,
|
|
153
|
+
"generation": snapshot.generation,
|
|
154
|
+
"updated_at": snapshot.updated_at,
|
|
155
|
+
"configs": configs,
|
|
156
|
+
"deleted_config_ids": snapshot.deleted_config_ids,
|
|
157
|
+
"config_hash": snapshot.config_hash,
|
|
158
|
+
"internal_token": snapshot.internal_token,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_runtime_config_snapshot_from_core_rows(
|
|
164
|
+
rows: list[dict[str, Any]],
|
|
165
|
+
*,
|
|
166
|
+
container_id: str,
|
|
167
|
+
config_model: type[TConfig] = RuntimeConfig,
|
|
168
|
+
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
169
|
+
reason: str = "startup_rehydrate",
|
|
170
|
+
) -> Any:
|
|
171
|
+
"""Build a runtime config snapshot from Core's internal config rows.
|
|
172
|
+
|
|
173
|
+
Core returns database-shaped rows where the integration config lives under
|
|
174
|
+
``config_data``. This helper turns those rows into the same snapshot shape
|
|
175
|
+
integrations receive from the normal config-sync endpoint.
|
|
176
|
+
"""
|
|
177
|
+
configs: list[Any] = []
|
|
178
|
+
for row in rows:
|
|
179
|
+
if not isinstance(row, dict):
|
|
180
|
+
continue
|
|
181
|
+
config_data = row.get("config_data")
|
|
182
|
+
if not isinstance(config_data, dict):
|
|
183
|
+
continue
|
|
184
|
+
payload = {**config_data}
|
|
185
|
+
payload["container_id"] = payload.get("container_id") or container_id
|
|
186
|
+
configs.append(_model_validate_or_create(config_model, payload))
|
|
187
|
+
|
|
188
|
+
return _snapshot_validate_or_create(
|
|
189
|
+
snapshot_model,
|
|
190
|
+
{
|
|
191
|
+
"container_id": container_id,
|
|
192
|
+
"reason": reason,
|
|
193
|
+
"configs": configs,
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def fetch_core_runtime_config_snapshot(
|
|
199
|
+
*,
|
|
200
|
+
runtime_context: RuntimeContext,
|
|
201
|
+
client: httpx.AsyncClient,
|
|
202
|
+
core_base_url: str | None = None,
|
|
203
|
+
config_model: type[TConfig] = RuntimeConfig,
|
|
204
|
+
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
205
|
+
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
206
|
+
reason: str = "startup_rehydrate",
|
|
207
|
+
) -> Any | None:
|
|
208
|
+
"""Fetch the latest runtime configs from Core using managed-runtime auth."""
|
|
209
|
+
container_id, internal_token = runtime_context.auth.resolve()
|
|
210
|
+
if not container_id or not internal_token:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
base_url = resolve_core_base_url(core_base_url) or resolve_core_base_url(
|
|
214
|
+
"http://127.0.0.1:31419"
|
|
215
|
+
)
|
|
216
|
+
if not base_url:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
response = await client.get(
|
|
220
|
+
f"{base_url}{CORE_RUNTIME_CONFIG_FETCH_PATH}",
|
|
221
|
+
params={"container_id": container_id},
|
|
222
|
+
headers=build_runtime_auth_headers(
|
|
223
|
+
container_id=container_id,
|
|
224
|
+
internal_token=internal_token,
|
|
225
|
+
),
|
|
226
|
+
timeout=timeout_seconds,
|
|
227
|
+
)
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
|
|
230
|
+
rows = response.json()
|
|
231
|
+
if not rows:
|
|
232
|
+
return None
|
|
233
|
+
if not isinstance(rows, list):
|
|
234
|
+
raise ValueError("Core runtime config response must be a list.")
|
|
235
|
+
|
|
236
|
+
return build_runtime_config_snapshot_from_core_rows(
|
|
237
|
+
rows,
|
|
238
|
+
container_id=container_id,
|
|
239
|
+
config_model=config_model,
|
|
240
|
+
snapshot_model=snapshot_model,
|
|
241
|
+
reason=reason,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def rehydrate_runtime_configs(
|
|
246
|
+
*,
|
|
247
|
+
runtime_context: RuntimeContext,
|
|
248
|
+
client: httpx.AsyncClient,
|
|
249
|
+
apply_snapshot: SnapshotApplyCallable,
|
|
250
|
+
config_model: type[TConfig] = RuntimeConfig,
|
|
251
|
+
snapshot_model: type[Any] = RuntimeConfigSnapshot,
|
|
252
|
+
core_base_url: str | None = None,
|
|
253
|
+
timeout_seconds: float = DEFAULT_CORE_CLIENT_TIMEOUT_SECONDS,
|
|
254
|
+
snapshot_path: str | Path | None = None,
|
|
255
|
+
snapshot_volume_dir: str | Path = DEFAULT_RUNTIME_VOLUME_DIR,
|
|
256
|
+
snapshot_reason: str = "startup_snapshot_rehydrate",
|
|
257
|
+
core_reason: str = "startup_rehydrate",
|
|
258
|
+
raise_core_errors: bool = False,
|
|
259
|
+
) -> RuntimeConfigRehydrateResult:
|
|
260
|
+
"""Rehydrate runtime configs from the mounted snapshot and then Core.
|
|
261
|
+
|
|
262
|
+
Integration authors provide only their config model and apply callback. The
|
|
263
|
+
SDK handles startup ordering, managed-runtime auth, Core request headers,
|
|
264
|
+
local snapshot fallback, and safe offline behavior.
|
|
265
|
+
"""
|
|
266
|
+
result = RuntimeConfigRehydrateResult()
|
|
267
|
+
container_id = runtime_context.auth.container_id
|
|
268
|
+
|
|
269
|
+
cached_snapshot = load_runtime_config_snapshot(
|
|
270
|
+
path=snapshot_path,
|
|
271
|
+
container_id=container_id,
|
|
272
|
+
volume_dir=snapshot_volume_dir,
|
|
273
|
+
)
|
|
274
|
+
if cached_snapshot is not None:
|
|
275
|
+
result.snapshot_found = True
|
|
276
|
+
snapshot = _convert_snapshot_configs(
|
|
277
|
+
cached_snapshot,
|
|
278
|
+
config_model=config_model,
|
|
279
|
+
snapshot_model=snapshot_model,
|
|
280
|
+
reason=snapshot_reason,
|
|
281
|
+
)
|
|
282
|
+
await apply_snapshot(snapshot)
|
|
283
|
+
result.snapshot_applied = True
|
|
284
|
+
result.snapshot_config_count = len(getattr(snapshot, "configs", []) or [])
|
|
285
|
+
result.snapshot_generation = getattr(snapshot, "generation", None)
|
|
286
|
+
|
|
287
|
+
resolved_container_id, resolved_internal_token = runtime_context.auth.resolve()
|
|
288
|
+
if not resolved_container_id or not resolved_internal_token:
|
|
289
|
+
result.missing_runtime_auth = True
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
result.core_attempted = True
|
|
293
|
+
try:
|
|
294
|
+
core_snapshot = await fetch_core_runtime_config_snapshot(
|
|
295
|
+
runtime_context=runtime_context,
|
|
296
|
+
client=client,
|
|
297
|
+
core_base_url=core_base_url,
|
|
298
|
+
config_model=config_model,
|
|
299
|
+
snapshot_model=snapshot_model,
|
|
300
|
+
timeout_seconds=timeout_seconds,
|
|
301
|
+
reason=core_reason,
|
|
302
|
+
)
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
result.core_error = f"{type(exc).__name__}: {exc}"
|
|
305
|
+
if raise_core_errors:
|
|
306
|
+
raise
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
if core_snapshot is None:
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
await apply_snapshot(core_snapshot)
|
|
313
|
+
result.core_applied = True
|
|
314
|
+
result.core_config_count = len(getattr(core_snapshot, "configs", []) or [])
|
|
315
|
+
result.core_generation = getattr(core_snapshot, "generation", None)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def reconcile_config_ids(
|
|
320
|
+
*,
|
|
321
|
+
active_ids: list[str],
|
|
322
|
+
incoming_ids: list[str],
|
|
323
|
+
) -> tuple[list[str], list[str]]:
|
|
324
|
+
"""Return removed ids and unchanged ids from two config-id lists."""
|
|
325
|
+
active_set = set(active_ids)
|
|
326
|
+
incoming_set = set(incoming_ids)
|
|
327
|
+
removed_ids = sorted(active_set - incoming_set)
|
|
328
|
+
unchanged_ids = sorted(active_set & incoming_set)
|
|
329
|
+
return removed_ids, unchanged_ids
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_sync_response(
|
|
333
|
+
*,
|
|
334
|
+
status: str,
|
|
335
|
+
container_id: str,
|
|
336
|
+
reason: str | None = None,
|
|
337
|
+
generation: int | None = None,
|
|
338
|
+
applied: list[str] | None = None,
|
|
339
|
+
removed: list[str] | None = None,
|
|
340
|
+
active_config_ids: list[str] | None = None,
|
|
341
|
+
metadata: dict | None = None,
|
|
342
|
+
) -> RuntimeConfigSyncResponse:
|
|
343
|
+
"""Build a standard PiPhi config-sync response payload."""
|
|
344
|
+
return RuntimeConfigSyncResponse(
|
|
345
|
+
status=status,
|
|
346
|
+
container_id=container_id,
|
|
347
|
+
reason=reason,
|
|
348
|
+
generation=generation,
|
|
349
|
+
applied=applied or [],
|
|
350
|
+
removed=removed or [],
|
|
351
|
+
active_config_ids=active_config_ids or [],
|
|
352
|
+
metadata=metadata or {},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(slots=True)
|
|
357
|
+
class ConfigSyncCoordinator:
|
|
358
|
+
"""Track config generations and common sync response patterns.
|
|
359
|
+
|
|
360
|
+
This class intentionally stays small. It helps integrations decide whether
|
|
361
|
+
an incoming snapshot is stale and keeps generation tracking in one place.
|
|
362
|
+
"""
|
|
363
|
+
process_state: RuntimeProcessState
|
|
364
|
+
|
|
365
|
+
def get_current_generation(self) -> int | None:
|
|
366
|
+
"""Return the latest applied config generation for the process."""
|
|
367
|
+
return self.process_state.current_generation
|
|
368
|
+
|
|
369
|
+
def mark_generation(self, generation: int | None) -> int | None:
|
|
370
|
+
"""Persist a new generation and return the effective stored value."""
|
|
371
|
+
if generation is not None:
|
|
372
|
+
self.process_state.current_generation = int(generation)
|
|
373
|
+
return self.process_state.current_generation
|
|
374
|
+
|
|
375
|
+
def is_stale(self, snapshot: RuntimeConfigSnapshot) -> bool:
|
|
376
|
+
"""Return whether an incoming snapshot is older than the current state."""
|
|
377
|
+
incoming_generation = snapshot.generation
|
|
378
|
+
current_generation = self.process_state.current_generation
|
|
379
|
+
return bool(
|
|
380
|
+
incoming_generation is not None
|
|
381
|
+
and current_generation is not None
|
|
382
|
+
and int(incoming_generation) < int(current_generation)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def build_stale_response(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
snapshot: RuntimeConfigSnapshot,
|
|
389
|
+
active_config_ids: list[str],
|
|
390
|
+
) -> RuntimeConfigSyncResponse:
|
|
391
|
+
"""Build the standard response for an ignored stale snapshot."""
|
|
392
|
+
return build_sync_response(
|
|
393
|
+
status="stale_ignored",
|
|
394
|
+
container_id=snapshot.container_id,
|
|
395
|
+
reason=snapshot.reason,
|
|
396
|
+
generation=self.process_state.current_generation,
|
|
397
|
+
active_config_ids=active_config_ids,
|
|
398
|
+
metadata={
|
|
399
|
+
"stale_generation_ignored": True,
|
|
400
|
+
"incoming_generation": snapshot.generation,
|
|
401
|
+
"current_generation": self.process_state.current_generation,
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
async def apply_snapshot(
|
|
406
|
+
self,
|
|
407
|
+
*,
|
|
408
|
+
snapshot: RuntimeConfigSnapshot,
|
|
409
|
+
active_config_ids: list[str],
|
|
410
|
+
apply_config: Callable[[RuntimeConfig], Awaitable[None]],
|
|
411
|
+
remove_config: Callable[[str], Awaitable[bool]],
|
|
412
|
+
get_active_config_ids: Callable[[], list[str]] | None = None,
|
|
413
|
+
) -> RuntimeConfigSyncResponse:
|
|
414
|
+
"""Apply a config snapshot using caller-provided apply/remove callbacks.
|
|
415
|
+
|
|
416
|
+
This keeps the integration-specific logic in the callbacks while the SDK
|
|
417
|
+
handles the generic PiPhi sync flow: stale detection, diffing, generation
|
|
418
|
+
tracking, and standard response payloads.
|
|
419
|
+
"""
|
|
420
|
+
if self.is_stale(snapshot):
|
|
421
|
+
return self.build_stale_response(
|
|
422
|
+
snapshot=snapshot,
|
|
423
|
+
active_config_ids=active_config_ids,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
incoming_ids = [config.id for config in snapshot.configs]
|
|
427
|
+
removed_ids, _unchanged_ids = reconcile_config_ids(
|
|
428
|
+
active_ids=active_config_ids,
|
|
429
|
+
incoming_ids=incoming_ids,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
removed: list[str] = []
|
|
433
|
+
applied: list[str] = []
|
|
434
|
+
|
|
435
|
+
for config_id in removed_ids:
|
|
436
|
+
was_removed = await remove_config(config_id)
|
|
437
|
+
if was_removed:
|
|
438
|
+
removed.append(config_id)
|
|
439
|
+
|
|
440
|
+
for config in snapshot.configs:
|
|
441
|
+
await apply_config(config)
|
|
442
|
+
applied.append(config.id)
|
|
443
|
+
|
|
444
|
+
current_generation = self.mark_generation(snapshot.generation)
|
|
445
|
+
next_active_config_ids = (
|
|
446
|
+
get_active_config_ids() if get_active_config_ids is not None else incoming_ids
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return build_sync_response(
|
|
450
|
+
status="synced",
|
|
451
|
+
container_id=snapshot.container_id,
|
|
452
|
+
reason=snapshot.reason,
|
|
453
|
+
generation=current_generation,
|
|
454
|
+
applied=applied,
|
|
455
|
+
removed=removed,
|
|
456
|
+
active_config_ids=next_active_config_ids,
|
|
457
|
+
metadata={
|
|
458
|
+
"applied_count": len(applied),
|
|
459
|
+
"removed_count": len(removed),
|
|
460
|
+
"current_generation": current_generation,
|
|
461
|
+
},
|
|
462
|
+
)
|
|
@@ -15,6 +15,7 @@ def _build_runtime_snapshot(runtime_context: RuntimeContext) -> dict[str, Any]:
|
|
|
15
15
|
"core_auth_present": bool(runtime_context.auth.internal_token),
|
|
16
16
|
"core_http_client_bound": process_state.core_http_client is not None,
|
|
17
17
|
"current_generation": process_state.current_generation,
|
|
18
|
+
"config_generation": process_state.current_generation,
|
|
18
19
|
"pending_task_count": len(
|
|
19
20
|
[task for task in process_state.background_tasks if not task.done()]
|
|
20
21
|
),
|
|
@@ -35,12 +35,34 @@ class RuntimeConfig(BaseModel):
|
|
|
35
35
|
|
|
36
36
|
class RuntimeConfigSnapshot(BaseModel):
|
|
37
37
|
"""Snapshot payload sent from PiPhi Core to an integration runtime."""
|
|
38
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
39
|
+
|
|
40
|
+
schema_version: int | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
validation_alias=AliasChoices("schema_version", "schemaVersion"),
|
|
43
|
+
)
|
|
38
44
|
container_id: str
|
|
39
45
|
integration_id: str | None = None
|
|
40
46
|
driver_pid: int | None = None
|
|
41
47
|
reason: str | None = None
|
|
42
48
|
generation: int | None = None
|
|
49
|
+
updated_at: str | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
validation_alias=AliasChoices("updated_at", "updatedAt"),
|
|
52
|
+
)
|
|
43
53
|
configs: list[RuntimeConfig] = Field(default_factory=list)
|
|
54
|
+
deleted_config_ids: list[str] = Field(
|
|
55
|
+
default_factory=list,
|
|
56
|
+
validation_alias=AliasChoices("deleted_config_ids", "deletedConfigIds"),
|
|
57
|
+
)
|
|
58
|
+
config_hash: str | None = Field(
|
|
59
|
+
default=None,
|
|
60
|
+
validation_alias=AliasChoices("config_hash", "configHash"),
|
|
61
|
+
)
|
|
62
|
+
internal_token: str | None = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
validation_alias=AliasChoices("internal_token", "internalToken"),
|
|
65
|
+
)
|
|
44
66
|
|
|
45
67
|
|
|
46
68
|
class RuntimeConfigSyncResponse(BaseModel):
|