cf-runtime 0.1.8__py3-none-any.whl
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.
- cf_runtime/__init__.py +74 -0
- cf_runtime/bootstrap.py +376 -0
- cf_runtime/domain.py +25 -0
- cf_runtime/installed_seed.py +57 -0
- cf_runtime/lifecycle.py +457 -0
- cf_runtime/locking.py +64 -0
- cf_runtime/managed_tools.py +373 -0
- cf_runtime/manifest.py +464 -0
- cf_runtime/paths.py +257 -0
- cf_runtime/semantics/package.trig +36 -0
- cf_runtime/semantics.py +392 -0
- cf_runtime/target.py +137 -0
- cf_runtime/teardown.py +237 -0
- cf_runtime-0.1.8.dist-info/METADATA +35 -0
- cf_runtime-0.1.8.dist-info/RECORD +17 -0
- cf_runtime-0.1.8.dist-info/WHEEL +4 -0
- cf_runtime-0.1.8.dist-info/entry_points.txt +5 -0
cf_runtime/lifecycle.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Provider-owned development runtime lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import signal
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Callable
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from .domain import RuntimeId
|
|
20
|
+
from .locking import LifecycleLock
|
|
21
|
+
from .manifest import (DEFAULT_RUNTIME_VERSION, DEFAULT_TOOL_VERSIONS, RuntimeManifest,
|
|
22
|
+
development_manifest, validate_manifest_pair)
|
|
23
|
+
from .paths import CogniflowHome
|
|
24
|
+
from .target import RuntimeIdentity, resolve_runtime_target
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _atomic_json(path: Path, payload: dict[str, object]) -> None:
|
|
28
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
29
|
+
try:
|
|
30
|
+
with temporary.open("w", encoding="utf-8", newline="\n") as stream:
|
|
31
|
+
stream.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
|
|
32
|
+
stream.flush()
|
|
33
|
+
os.fsync(stream.fileno())
|
|
34
|
+
os.replace(temporary, path)
|
|
35
|
+
finally:
|
|
36
|
+
temporary.unlink(missing_ok=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _post(url: str, *, data: bytes, content_type: str, timeout: float, accept: str | None = None) -> bytes:
|
|
40
|
+
headers = {"Content-Type": content_type}
|
|
41
|
+
if accept:
|
|
42
|
+
headers["Accept"] = accept
|
|
43
|
+
request = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
44
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
45
|
+
return response.read()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _form(url: str, fields: dict[str, str], timeout: float, accept: str | None = None) -> bytes:
|
|
49
|
+
return _post(url, data=urllib.parse.urlencode(fields).encode(),
|
|
50
|
+
content_type="application/x-www-form-urlencoded", timeout=timeout, accept=accept)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def port_open(host: str, port: int) -> bool:
|
|
54
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
|
|
55
|
+
client.settimeout(0.2)
|
|
56
|
+
return client.connect_ex((host, port)) == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def process_running(pid: int) -> bool:
|
|
60
|
+
if pid <= 0:
|
|
61
|
+
return False
|
|
62
|
+
if os.name == "nt":
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
errors="replace",
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
return result.returncode == 0 and f'"{pid}"' in result.stdout
|
|
72
|
+
try:
|
|
73
|
+
os.kill(pid, 0)
|
|
74
|
+
return True
|
|
75
|
+
except PermissionError:
|
|
76
|
+
return True
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def process_command_line(pid: int) -> str | None:
|
|
82
|
+
if os.name != "nt":
|
|
83
|
+
try:
|
|
84
|
+
return (Path("/proc") / str(pid) / "cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
command = ["powershell", "-NoProfile", "-Command",
|
|
88
|
+
f"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine"]
|
|
89
|
+
try:
|
|
90
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
91
|
+
except OSError:
|
|
92
|
+
return None
|
|
93
|
+
return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def terminate_process(pid: int) -> None:
|
|
97
|
+
if os.name == "nt":
|
|
98
|
+
result = subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True, text=True, check=False)
|
|
99
|
+
if result.returncode:
|
|
100
|
+
result = subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, check=False)
|
|
101
|
+
if result.returncode:
|
|
102
|
+
raise RuntimeError((result.stderr or result.stdout or "taskkill failed").strip())
|
|
103
|
+
else:
|
|
104
|
+
os.kill(pid, signal.SIGTERM)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DevelopmentRuntime:
|
|
108
|
+
def __init__(self, home: CogniflowHome, *, lock_timeout: float = 0.0,
|
|
109
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> None:
|
|
110
|
+
self.home = home
|
|
111
|
+
self.paths = home.runtime(RuntimeId.DEVELOPMENT)
|
|
112
|
+
self.lock_timeout = lock_timeout
|
|
113
|
+
self._popen = popen
|
|
114
|
+
|
|
115
|
+
def _lock(self) -> LifecycleLock:
|
|
116
|
+
return LifecycleLock(self.paths.lifecycle_lock, self.lock_timeout)
|
|
117
|
+
|
|
118
|
+
def prepare(self, source_root: Path, semantic_seed: Path) -> dict[str, object]:
|
|
119
|
+
with self._lock():
|
|
120
|
+
return self._prepare(source_root, semantic_seed)
|
|
121
|
+
|
|
122
|
+
def _prepare(self, source_root: Path, semantic_seed: Path) -> dict[str, object]:
|
|
123
|
+
source = source_root.expanduser().resolve(strict=False)
|
|
124
|
+
seed = semantic_seed.expanduser().resolve(strict=False)
|
|
125
|
+
if not source.is_dir():
|
|
126
|
+
raise ValueError("source_root must be an existing directory")
|
|
127
|
+
if not seed.is_file():
|
|
128
|
+
raise ValueError("semantic_seed must be an existing file")
|
|
129
|
+
revision = hashlib.sha256(seed.read_bytes()).hexdigest()
|
|
130
|
+
runtime_version = DEFAULT_RUNTIME_VERSION
|
|
131
|
+
tools = DEFAULT_TOOL_VERSIONS
|
|
132
|
+
command = None
|
|
133
|
+
if self.paths.manifest.is_file():
|
|
134
|
+
current = RuntimeManifest.read(self.paths.manifest)
|
|
135
|
+
if current.source_root != source:
|
|
136
|
+
raise ValueError("development manifest belongs to another source root")
|
|
137
|
+
runtime_version, tools, command = current.runtime_version, current.tool_versions, current.mcp_command
|
|
138
|
+
manifest = development_manifest(self.home, source_root=source, runtime_version=runtime_version,
|
|
139
|
+
semantic_revision=revision, tool_versions=tools,
|
|
140
|
+
**({"mcp_command": command} if command is not None else {}))
|
|
141
|
+
production_path = self.home.runtime(RuntimeId.PRODUCTION).manifest
|
|
142
|
+
if production_path.is_file():
|
|
143
|
+
validate_manifest_pair(manifest, RuntimeManifest.read(production_path))
|
|
144
|
+
self.paths.ensure_directories()
|
|
145
|
+
manifest.write(self.paths.manifest)
|
|
146
|
+
self.paths.identity.unlink(missing_ok=True)
|
|
147
|
+
return {"runtime_id": "development", "manifest": str(self.paths.manifest),
|
|
148
|
+
"semantic_revision": revision}
|
|
149
|
+
|
|
150
|
+
def start(self, source_root: Path, semantic_seed: Path, *, timeout: float) -> dict[str, object]:
|
|
151
|
+
with self._lock():
|
|
152
|
+
self._prepare(source_root, semantic_seed)
|
|
153
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
154
|
+
started_pid: int | None = None
|
|
155
|
+
cleanup_errors: list[str] = []
|
|
156
|
+
try:
|
|
157
|
+
if port_open(manifest.fuseki_bind, manifest.fuseki_port):
|
|
158
|
+
self._verified_state(manifest)
|
|
159
|
+
else:
|
|
160
|
+
started_pid = self._start_process(manifest, timeout)
|
|
161
|
+
self._write_state(manifest, started_pid)
|
|
162
|
+
self._load_semantics(manifest, semantic_seed.resolve(), timeout)
|
|
163
|
+
RuntimeIdentity.from_manifest(manifest).write(self.paths.identity, self.home)
|
|
164
|
+
self._ready(manifest, timeout)
|
|
165
|
+
resolve_runtime_target(self.home, RuntimeId.DEVELOPMENT)
|
|
166
|
+
except Exception as error:
|
|
167
|
+
for name, cleanup in (
|
|
168
|
+
("identity cleanup", lambda: self.paths.identity.unlink(missing_ok=True)),
|
|
169
|
+
("process cleanup", lambda: self._terminate_and_wait(started_pid, timeout=min(timeout, 5.0)) if started_pid is not None else None),
|
|
170
|
+
("lifecycle cleanup", lambda: self.paths.lifecycle_state.unlink(missing_ok=True) if started_pid is not None else None),
|
|
171
|
+
):
|
|
172
|
+
try:
|
|
173
|
+
cleanup()
|
|
174
|
+
except Exception as cleanup_error:
|
|
175
|
+
cleanup_errors.append(f"{name}: {cleanup_error}")
|
|
176
|
+
if cleanup_errors:
|
|
177
|
+
error.add_note("cleanup also failed: " + "; ".join(cleanup_errors))
|
|
178
|
+
raise
|
|
179
|
+
return {"runtime_id": "development", "status": "ready", "pid": self._state_pid(),
|
|
180
|
+
"started": started_pid is not None, "manifest": str(self.paths.manifest)}
|
|
181
|
+
|
|
182
|
+
def status(self) -> dict[str, object]:
|
|
183
|
+
with self._lock():
|
|
184
|
+
manifest_exists = self.paths.manifest.is_file()
|
|
185
|
+
running = False
|
|
186
|
+
ready = False
|
|
187
|
+
pid = None
|
|
188
|
+
if manifest_exists:
|
|
189
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
190
|
+
try:
|
|
191
|
+
pid = self._verified_state(manifest)
|
|
192
|
+
running = True
|
|
193
|
+
self._ready(manifest, 1.0)
|
|
194
|
+
ready = RuntimeIdentity.read(self.paths.identity) == RuntimeIdentity.from_manifest(manifest)
|
|
195
|
+
except (OSError, ValueError, RuntimeError):
|
|
196
|
+
pass
|
|
197
|
+
return {"runtime_id": "development", "status": "ready" if ready else "running" if running else "stopped",
|
|
198
|
+
"manifest_exists": manifest_exists, "running": running, "ready": ready, "pid": pid,
|
|
199
|
+
"manifest": str(self.paths.manifest)}
|
|
200
|
+
|
|
201
|
+
def stop(self, *, timeout: float) -> dict[str, object]:
|
|
202
|
+
with self._lock():
|
|
203
|
+
self.paths.identity.unlink(missing_ok=True)
|
|
204
|
+
if not self.paths.lifecycle_state.is_file():
|
|
205
|
+
return {"runtime_id": "development", "status": "not_running", "pid": None}
|
|
206
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
207
|
+
pid = self._verified_state(manifest, allow_stale=True)
|
|
208
|
+
if not process_running(pid):
|
|
209
|
+
self.paths.lifecycle_state.unlink(missing_ok=True)
|
|
210
|
+
return {"runtime_id": "development", "status": "stale_removed", "pid": pid}
|
|
211
|
+
self._terminate_and_wait(pid, timeout)
|
|
212
|
+
self.paths.lifecycle_state.unlink(missing_ok=True)
|
|
213
|
+
return {"runtime_id": "development", "status": "stopped", "pid": pid}
|
|
214
|
+
|
|
215
|
+
def _start_process(self, manifest: RuntimeManifest, timeout: float) -> int:
|
|
216
|
+
java_name = "java.exe" if os.name == "nt" else "java"
|
|
217
|
+
java = self.home.java_install(manifest.tool_versions["java"]) / "bin" / java_name
|
|
218
|
+
fuseki = self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"
|
|
219
|
+
if not java.is_file() or not fuseki.is_file():
|
|
220
|
+
raise ValueError("managed Java or Fuseki installation is missing")
|
|
221
|
+
database = str(self.paths.fuseki_database).replace("\\", "/").replace('"', '\\"')
|
|
222
|
+
dataset = manifest.fuseki_dataset.replace('"', '\\"')
|
|
223
|
+
config = ("@prefix fuseki: <http://jena.apache.org/fuseki#> .\n"
|
|
224
|
+
"@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .\n"
|
|
225
|
+
"@prefix tdb2: <http://jena.apache.org/2016/tdb#> .\n"
|
|
226
|
+
f'<#service> rdf:type fuseki:Service ; fuseki:name "{dataset}" ; '
|
|
227
|
+
'fuseki:serviceQuery "query" ; fuseki:serviceUpdate "update" ; '
|
|
228
|
+
'fuseki:serviceReadWriteGraphStore "data" ; fuseki:dataset <#dataset> .\n'
|
|
229
|
+
f'<#dataset> rdf:type tdb2:DatasetTDB2 ; tdb2:location "{database}" .\n')
|
|
230
|
+
temporary = self.paths.fuseki_server_config.with_suffix(".tmp")
|
|
231
|
+
temporary.write_text(config, encoding="utf-8", newline="\n")
|
|
232
|
+
os.replace(temporary, self.paths.fuseki_server_config)
|
|
233
|
+
command = [str(java), "-jar", str(fuseki), "--localhost", f"--port={manifest.fuseki_port}",
|
|
234
|
+
f"--config={self.paths.fuseki_server_config}"]
|
|
235
|
+
environment = os.environ.copy()
|
|
236
|
+
environment.update({"JAVA_HOME": str(java.parent.parent), "FUSEKI_BASE": str(self.paths.fuseki_config),
|
|
237
|
+
"TMPDIR": str(self.paths.temp), "TEMP": str(self.paths.temp)})
|
|
238
|
+
with (self.paths.logs / "fuseki.out.log").open("ab") as out, (self.paths.logs / "fuseki.err.log").open("ab") as err:
|
|
239
|
+
process = self._popen(command, cwd=fuseki.parent, env=environment, stdout=out, stderr=err)
|
|
240
|
+
deadline = time.monotonic() + timeout
|
|
241
|
+
while time.monotonic() < deadline:
|
|
242
|
+
if process.poll() is not None:
|
|
243
|
+
raise RuntimeError(f"Fuseki exited before readiness with code {process.returncode}")
|
|
244
|
+
try:
|
|
245
|
+
self._ready(manifest, min(1.0, timeout))
|
|
246
|
+
return process.pid
|
|
247
|
+
except Exception:
|
|
248
|
+
time.sleep(0.05)
|
|
249
|
+
error = TimeoutError("timed out waiting for managed Fuseki")
|
|
250
|
+
try:
|
|
251
|
+
self._terminate_and_wait(process.pid, timeout=min(max(timeout, 0.001), 5.0))
|
|
252
|
+
except Exception as cleanup_error:
|
|
253
|
+
error.add_note(f"cleanup also failed: {cleanup_error}")
|
|
254
|
+
raise error
|
|
255
|
+
|
|
256
|
+
def _load_semantics(self, manifest: RuntimeManifest, seed: Path, timeout: float) -> None:
|
|
257
|
+
base = f"http://{manifest.fuseki_bind}:{manifest.fuseki_port}/{manifest.fuseki_dataset}"
|
|
258
|
+
_form(base + "/update", {"update": "CLEAR ALL"}, timeout)
|
|
259
|
+
_post(base + "/data", data=seed.read_bytes(), content_type="application/trig", timeout=timeout)
|
|
260
|
+
body = _form(base + "/query", {"query": "ASK { GRAPH ?g { ?s ?p ?o } }"}, timeout,
|
|
261
|
+
accept="application/sparql-results+json")
|
|
262
|
+
if json.loads(body.decode()).get("boolean") is not True:
|
|
263
|
+
raise RuntimeError("semantic seed readiness verification failed")
|
|
264
|
+
|
|
265
|
+
def _ready(self, manifest: RuntimeManifest, timeout: float) -> None:
|
|
266
|
+
endpoint = f"http://{manifest.fuseki_bind}:{manifest.fuseki_port}/{manifest.fuseki_dataset}/query"
|
|
267
|
+
_form(endpoint, {"query": "ASK { ?s ?p ?o }"}, timeout)
|
|
268
|
+
|
|
269
|
+
def _write_state(self, manifest: RuntimeManifest, pid: int) -> None:
|
|
270
|
+
_atomic_json(self.paths.lifecycle_state, {
|
|
271
|
+
"runtime_id": "development", "environment_kind": "development", "lifecycle_owner": "cf-runtime",
|
|
272
|
+
"source_root": str(manifest.source_root), "runtime_root": str(self.paths.runtime_root),
|
|
273
|
+
"fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
|
|
274
|
+
"fuseki_port": manifest.fuseki_port, "fuseki_pid": pid,
|
|
275
|
+
"started_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
def _state_pid(self) -> int:
|
|
279
|
+
return int(json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))["fuseki_pid"])
|
|
280
|
+
|
|
281
|
+
def _verified_state(self, manifest: RuntimeManifest, *, allow_stale: bool = False) -> int:
|
|
282
|
+
try:
|
|
283
|
+
state = json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))
|
|
284
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
285
|
+
raise ValueError("development port has no valid provider lifecycle state") from error
|
|
286
|
+
expected = {"runtime_id": "development", "environment_kind": "development", "lifecycle_owner": "cf-runtime",
|
|
287
|
+
"source_root": str(manifest.source_root), "runtime_root": str(self.paths.runtime_root),
|
|
288
|
+
"fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
|
|
289
|
+
"fuseki_port": manifest.fuseki_port}
|
|
290
|
+
if (not isinstance(state, dict) or set(state) != {*expected, "fuseki_pid", "started_at"}
|
|
291
|
+
or any(state.get(key) != value for key, value in expected.items())
|
|
292
|
+
or not isinstance(state.get("started_at"), str) or not state["started_at"]):
|
|
293
|
+
raise ValueError("lifecycle state is not owned by the canonical development runtime")
|
|
294
|
+
pid = state.get("fuseki_pid")
|
|
295
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
|
296
|
+
raise ValueError("lifecycle state has no valid PID")
|
|
297
|
+
if allow_stale and not process_running(pid):
|
|
298
|
+
return pid
|
|
299
|
+
command = process_command_line(pid)
|
|
300
|
+
tokens = ("fuseki", str(self.paths.fuseki_server_config), str(manifest.fuseki_port), "--localhost")
|
|
301
|
+
if not process_running(pid) or command is None or not all(t.lower().replace("\\", "/") in command.lower().replace("\\", "/") for t in tokens):
|
|
302
|
+
raise ValueError("provider ownership of the Fuseki process could not be verified")
|
|
303
|
+
return pid
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _terminate_and_wait(pid: int, timeout: float) -> None:
|
|
307
|
+
terminate_process(pid)
|
|
308
|
+
deadline = time.monotonic() + timeout
|
|
309
|
+
while process_running(pid) and time.monotonic() < deadline:
|
|
310
|
+
time.sleep(0.05)
|
|
311
|
+
if process_running(pid):
|
|
312
|
+
raise TimeoutError(f"PID {pid} did not stop")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ProductionRuntime(DevelopmentRuntime):
|
|
316
|
+
"""Lifecycle owner for the installed production composition.
|
|
317
|
+
|
|
318
|
+
Production has no source-tree seed input: the seed is produced by the
|
|
319
|
+
target interpreter and published below the production data root.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def __init__(self, home: CogniflowHome, *, lock_timeout: float = 0.0,
|
|
323
|
+
popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen) -> None:
|
|
324
|
+
super().__init__(home, lock_timeout=lock_timeout, popen=popen)
|
|
325
|
+
self.paths = home.runtime(RuntimeId.PRODUCTION)
|
|
326
|
+
|
|
327
|
+
def _seed(self, manifest: RuntimeManifest) -> Path:
|
|
328
|
+
seed = self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"
|
|
329
|
+
seed.parent.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
command = [str(manifest.python_executable), "-m", "cf_runtime.installed_seed", "--output", str(seed)]
|
|
331
|
+
environment = {"PYTHONNOUSERSITE": "1", "PYTHONDONTWRITEBYTECODE": "1",
|
|
332
|
+
"PATH": str(Path(manifest.python_executable).parent),
|
|
333
|
+
"PYTHONUNBUFFERED": "1"}
|
|
334
|
+
result = subprocess.run(command, cwd=str(manifest.installation_root), env=environment,
|
|
335
|
+
capture_output=True, text=True, check=False, timeout=30)
|
|
336
|
+
if result.returncode != 0:
|
|
337
|
+
raise RuntimeError((result.stderr or result.stdout or "installed seed construction failed").strip())
|
|
338
|
+
expected = manifest.semantic_revision
|
|
339
|
+
actual = hashlib.sha256(seed.read_bytes()).hexdigest()
|
|
340
|
+
if actual != expected:
|
|
341
|
+
raise ValueError("installed semantic seed does not match the production manifest")
|
|
342
|
+
return seed
|
|
343
|
+
|
|
344
|
+
def start(self, *, timeout: float = 30.0) -> dict[str, object]:
|
|
345
|
+
with self._lock():
|
|
346
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
347
|
+
if manifest.runtime_id is not RuntimeId.PRODUCTION:
|
|
348
|
+
raise ValueError("production lifecycle requires a production manifest")
|
|
349
|
+
seed = self._seed(manifest)
|
|
350
|
+
started_pid: int | None = None
|
|
351
|
+
try:
|
|
352
|
+
if port_open(manifest.fuseki_bind, manifest.fuseki_port):
|
|
353
|
+
pid = self._verified_state(manifest)
|
|
354
|
+
self._ready(manifest, timeout)
|
|
355
|
+
else:
|
|
356
|
+
pid = self._start_process(manifest, timeout)
|
|
357
|
+
started_pid = pid
|
|
358
|
+
self._write_state(manifest, pid)
|
|
359
|
+
self._load_semantics(manifest, seed, timeout)
|
|
360
|
+
self._ready(manifest, timeout)
|
|
361
|
+
RuntimeIdentity.from_manifest(manifest).write(self.paths.identity, self.home)
|
|
362
|
+
resolve_runtime_target(self.home, RuntimeId.PRODUCTION)
|
|
363
|
+
except Exception as error:
|
|
364
|
+
cleanup_errors: list[str] = []
|
|
365
|
+
for name, cleanup in (
|
|
366
|
+
("identity cleanup", lambda: self.paths.identity.unlink(missing_ok=True)),
|
|
367
|
+
("process cleanup", lambda: self._terminate_and_wait(started_pid, min(timeout, 5.0)) if started_pid is not None else None),
|
|
368
|
+
("lifecycle cleanup", lambda: self.paths.lifecycle_state.unlink(missing_ok=True) if started_pid is not None else None),
|
|
369
|
+
):
|
|
370
|
+
try:
|
|
371
|
+
cleanup()
|
|
372
|
+
except Exception as cleanup_error:
|
|
373
|
+
cleanup_errors.append(f"{name}: {cleanup_error}")
|
|
374
|
+
if cleanup_errors:
|
|
375
|
+
error.add_note("cleanup also failed: " + "; ".join(cleanup_errors))
|
|
376
|
+
raise
|
|
377
|
+
return {"runtime_id": "production", "status": "ready", "pid": pid,
|
|
378
|
+
"started": started_pid is not None, "manifest": str(self.paths.manifest),
|
|
379
|
+
"semantic_revision": manifest.semantic_revision, "seed": str(seed)}
|
|
380
|
+
|
|
381
|
+
def status(self) -> dict[str, object]:
|
|
382
|
+
with self._lock():
|
|
383
|
+
exists = self.paths.manifest.is_file()
|
|
384
|
+
running = ready = False
|
|
385
|
+
pid = None
|
|
386
|
+
if exists:
|
|
387
|
+
try:
|
|
388
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
389
|
+
pid = self._verified_state(manifest)
|
|
390
|
+
running = True
|
|
391
|
+
self._ready(manifest, 1.0)
|
|
392
|
+
ready = RuntimeIdentity.read(self.paths.identity) == RuntimeIdentity.from_manifest(manifest)
|
|
393
|
+
except (OSError, ValueError, RuntimeError):
|
|
394
|
+
pass
|
|
395
|
+
return {"runtime_id": "production", "status": "ready" if ready else "running" if running else "stopped",
|
|
396
|
+
"manifest_exists": exists, "running": running, "ready": ready, "pid": pid,
|
|
397
|
+
"manifest": str(self.paths.manifest),
|
|
398
|
+
"semantic_revision": RuntimeManifest.read(self.paths.manifest).semantic_revision if exists else None}
|
|
399
|
+
|
|
400
|
+
def stop(self, *, timeout: float = 5.0) -> dict[str, object]:
|
|
401
|
+
with self._lock():
|
|
402
|
+
self.paths.identity.unlink(missing_ok=True)
|
|
403
|
+
if not self.paths.lifecycle_state.is_file():
|
|
404
|
+
return {"runtime_id": "production", "status": "not_running", "pid": None}
|
|
405
|
+
manifest = RuntimeManifest.read(self.paths.manifest)
|
|
406
|
+
pid = self._verified_state(manifest, allow_stale=True)
|
|
407
|
+
if process_running(pid):
|
|
408
|
+
self._terminate_and_wait(pid, timeout)
|
|
409
|
+
self.paths.lifecycle_state.unlink(missing_ok=True)
|
|
410
|
+
if port_open(manifest.fuseki_bind, manifest.fuseki_port):
|
|
411
|
+
raise RuntimeError("production Fuseki port remains in use")
|
|
412
|
+
return {"runtime_id": "production", "status": "stopped", "pid": pid}
|
|
413
|
+
|
|
414
|
+
def _write_state(self, manifest: RuntimeManifest, pid: int) -> None:
|
|
415
|
+
seed = self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"
|
|
416
|
+
java = self.home.java_install(manifest.tool_versions["java"]) / "bin" / ("java.exe" if os.name == "nt" else "java")
|
|
417
|
+
fuseki = self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"
|
|
418
|
+
_atomic_json(self.paths.lifecycle_state, {
|
|
419
|
+
"runtime_id": "production", "environment_kind": "production", "lifecycle_owner": "cf-runtime",
|
|
420
|
+
"runtime_root": str(self.paths.runtime_root), "manifest": str(self.paths.manifest),
|
|
421
|
+
"fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
|
|
422
|
+
"fuseki_port": manifest.fuseki_port, "fuseki_pid": pid,
|
|
423
|
+
"semantic_revision": manifest.semantic_revision,
|
|
424
|
+
"installation_root": str(manifest.installation_root), "environment_root": str(manifest.environment_root),
|
|
425
|
+
"target_python": str(manifest.python_executable), "java_executable": str(java),
|
|
426
|
+
"fuseki_jar": str(fuseki), "seed_path": str(seed),
|
|
427
|
+
"seed_sha256": hashlib.sha256(seed.read_bytes()).hexdigest() if seed.is_file() else "",
|
|
428
|
+
"started_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
def _verified_state(self, manifest: RuntimeManifest, *, allow_stale: bool = False) -> int:
|
|
432
|
+
try:
|
|
433
|
+
state = json.loads(self.paths.lifecycle_state.read_text(encoding="utf-8"))
|
|
434
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
435
|
+
raise ValueError("production port has no valid provider lifecycle state") from error
|
|
436
|
+
expected = {"runtime_id": "production", "environment_kind": "production", "lifecycle_owner": "cf-runtime",
|
|
437
|
+
"runtime_root": str(self.paths.runtime_root), "manifest": str(self.paths.manifest),
|
|
438
|
+
"fuseki_config": str(self.paths.fuseki_server_config), "fuseki_database": str(self.paths.fuseki_database),
|
|
439
|
+
"fuseki_port": manifest.fuseki_port, "semantic_revision": manifest.semantic_revision,
|
|
440
|
+
"installation_root": str(manifest.installation_root), "environment_root": str(manifest.environment_root),
|
|
441
|
+
"target_python": str(manifest.python_executable),
|
|
442
|
+
"java_executable": str(self.home.java_install(manifest.tool_versions["java"]) / "bin" / ("java.exe" if os.name == "nt" else "java")),
|
|
443
|
+
"fuseki_jar": str(self.home.fuseki_install(manifest.tool_versions["fuseki"]) / "fuseki-server.jar"),
|
|
444
|
+
"seed_path": str(self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig"),
|
|
445
|
+
"seed_sha256": hashlib.sha256((self.home.root / "data" / "production" / "semantic-seed" / "cogniflow.trig").read_bytes()).hexdigest()}
|
|
446
|
+
if not isinstance(state, dict) or set(state) != {*expected, "fuseki_pid", "started_at"} or any(state.get(k) != v for k, v in expected.items()):
|
|
447
|
+
raise ValueError("lifecycle state is not owned by the canonical production runtime")
|
|
448
|
+
pid = state.get("fuseki_pid")
|
|
449
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
|
450
|
+
raise ValueError("production lifecycle state has no valid PID")
|
|
451
|
+
if allow_stale and not process_running(pid):
|
|
452
|
+
return pid
|
|
453
|
+
command = process_command_line(pid)
|
|
454
|
+
tokens = ("fuseki", str(self.paths.fuseki_server_config), str(manifest.fuseki_port), "--localhost")
|
|
455
|
+
if not process_running(pid) or command is None or not all(t.lower().replace("\\", "/") in command.lower().replace("\\", "/") for t in tokens):
|
|
456
|
+
raise ValueError("provider ownership of the production Fuseki process could not be verified")
|
|
457
|
+
return pid
|
cf_runtime/locking.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Cross-platform exclusive lifecycle lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LifecycleLockedError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LifecycleLock:
|
|
15
|
+
def __init__(self, path: Path, timeout: float) -> None:
|
|
16
|
+
self.path = path
|
|
17
|
+
self.timeout = timeout
|
|
18
|
+
self._stream = None
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> LifecycleLock:
|
|
21
|
+
if self.timeout < 0:
|
|
22
|
+
raise ValueError("lock timeout cannot be negative")
|
|
23
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self._stream = self.path.open("a+b")
|
|
25
|
+
self._stream.seek(0, os.SEEK_END)
|
|
26
|
+
if self._stream.tell() == 0:
|
|
27
|
+
self._stream.write(b"0")
|
|
28
|
+
self._stream.flush()
|
|
29
|
+
deadline = time.monotonic() + self.timeout
|
|
30
|
+
while True:
|
|
31
|
+
try:
|
|
32
|
+
self._acquire()
|
|
33
|
+
return self
|
|
34
|
+
except (BlockingIOError, OSError):
|
|
35
|
+
if time.monotonic() >= deadline:
|
|
36
|
+
self._stream.close()
|
|
37
|
+
self._stream = None
|
|
38
|
+
raise LifecycleLockedError("development lifecycle is locked")
|
|
39
|
+
time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
|
|
40
|
+
|
|
41
|
+
def _acquire(self) -> None:
|
|
42
|
+
assert self._stream is not None
|
|
43
|
+
if os.name == "nt":
|
|
44
|
+
import msvcrt
|
|
45
|
+
self._stream.seek(0)
|
|
46
|
+
msvcrt.locking(self._stream.fileno(), msvcrt.LK_NBLCK, 1)
|
|
47
|
+
else:
|
|
48
|
+
import fcntl
|
|
49
|
+
fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
50
|
+
|
|
51
|
+
def __exit__(self, _type: object, _value: object, _traceback: object) -> None:
|
|
52
|
+
if self._stream is None:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
if os.name == "nt":
|
|
56
|
+
import msvcrt
|
|
57
|
+
self._stream.seek(0)
|
|
58
|
+
msvcrt.locking(self._stream.fileno(), msvcrt.LK_UNLCK, 1)
|
|
59
|
+
else:
|
|
60
|
+
import fcntl
|
|
61
|
+
fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN)
|
|
62
|
+
finally:
|
|
63
|
+
self._stream.close()
|
|
64
|
+
self._stream = None
|