graphite-code 0.3.0__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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
"""Isolated SQLite authority storage for provider lifecycle observations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import sqlite3
|
|
9
|
+
import stat
|
|
10
|
+
from contextlib import closing, contextmanager, suppress
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Final
|
|
15
|
+
|
|
16
|
+
from .lifecycle import (
|
|
17
|
+
LifecycleReasonCode,
|
|
18
|
+
ProviderLifecycleEvent,
|
|
19
|
+
ProviderLifecycleState,
|
|
20
|
+
ProviderRuntimeIdentity,
|
|
21
|
+
LifecycleProviderId,
|
|
22
|
+
RuntimeKind,
|
|
23
|
+
)
|
|
24
|
+
from .storage import (
|
|
25
|
+
BUSY_TIMEOUT_MS,
|
|
26
|
+
_secure_file,
|
|
27
|
+
_secure_repository_directory,
|
|
28
|
+
_is_reparse_point,
|
|
29
|
+
_selected_root,
|
|
30
|
+
_validate_database_file,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
LIFECYCLE_SCHEMA_VERSION: Final = "2"
|
|
34
|
+
MAX_EVENT_PAGE_SIZE: Final = 100
|
|
35
|
+
MAX_INVALIDATION_TARGETS: Final = 128
|
|
36
|
+
|
|
37
|
+
_PROVIDER_VALUES = "'claude-code','codex','ollama','openrouter','zai'"
|
|
38
|
+
_RUNTIME_VALUES = "'local-cli','local-http','remote-https'"
|
|
39
|
+
_STATE_VALUES = (
|
|
40
|
+
"'discovered','compatible','verification_required','active','incompatible','unavailable'"
|
|
41
|
+
)
|
|
42
|
+
_HEX_64_GLOB = "length({column}) = 64 AND {column} NOT GLOB '*[^0-9a-f]*'"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LifecycleStorageError(RuntimeError):
|
|
46
|
+
"""A stable, path-free lifecycle persistence failure."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code: str) -> None:
|
|
49
|
+
self.code = code
|
|
50
|
+
super().__init__(code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _translate_database_error(exc: sqlite3.Error) -> LifecycleStorageError:
|
|
54
|
+
message = str(exc).casefold()
|
|
55
|
+
if "locked" in message or "busy" in message:
|
|
56
|
+
return LifecycleStorageError("lifecycle_storage_locked")
|
|
57
|
+
if (
|
|
58
|
+
"database disk image is malformed" in message
|
|
59
|
+
or "not a database" in message
|
|
60
|
+
or "file is encrypted" in message
|
|
61
|
+
):
|
|
62
|
+
return LifecycleStorageError("lifecycle_storage_corrupt")
|
|
63
|
+
return LifecycleStorageError("lifecycle_storage_unavailable")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _digest(value: object, code: str) -> str:
|
|
67
|
+
if (
|
|
68
|
+
not isinstance(value, str)
|
|
69
|
+
or len(value) != 64
|
|
70
|
+
or any(character not in "0123456789abcdef" for character in value)
|
|
71
|
+
):
|
|
72
|
+
raise ValueError(code)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _identifier(value: object, code: str) -> str:
|
|
77
|
+
if (
|
|
78
|
+
not isinstance(value, str)
|
|
79
|
+
or not value
|
|
80
|
+
or len(value) > 256
|
|
81
|
+
or any(not (character.isascii() and (character.isalnum() or character in "._:-")) for character in value)
|
|
82
|
+
):
|
|
83
|
+
raise ValueError(code)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _timestamp(value: object, code: str) -> int:
|
|
88
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 10**12:
|
|
89
|
+
raise ValueError(code)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _canonical_json(value: dict[str, object]) -> str:
|
|
94
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _payload_hash(payload: str) -> str:
|
|
98
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _identity_from_json(payload: str) -> ProviderRuntimeIdentity:
|
|
102
|
+
try:
|
|
103
|
+
value = json.loads(payload)
|
|
104
|
+
if not isinstance(value, dict) or set(value) != set(ProviderRuntimeIdentity._public_fields):
|
|
105
|
+
raise ValueError
|
|
106
|
+
return ProviderRuntimeIdentity(
|
|
107
|
+
provider=value["provider"],
|
|
108
|
+
runtime_kind=value["runtime_kind"],
|
|
109
|
+
version=value["version"],
|
|
110
|
+
runtime_digest=value["runtime_digest"],
|
|
111
|
+
model_identity_digest=value["model_identity_digest"],
|
|
112
|
+
routing_policy_digest=value["routing_policy_digest"],
|
|
113
|
+
capabilities=tuple(value["capabilities"]),
|
|
114
|
+
policy_version=value["policy_version"],
|
|
115
|
+
observed_at=value["observed_at"],
|
|
116
|
+
)
|
|
117
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
118
|
+
raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _event_from_json(payload: str) -> ProviderLifecycleEvent:
|
|
122
|
+
try:
|
|
123
|
+
value = json.loads(payload)
|
|
124
|
+
if not isinstance(value, dict) or set(value) != set(ProviderLifecycleEvent._public_fields):
|
|
125
|
+
raise ValueError
|
|
126
|
+
return ProviderLifecycleEvent(**value)
|
|
127
|
+
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
128
|
+
raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class CurrentLifecycleObservation:
|
|
133
|
+
boundary_digest: str
|
|
134
|
+
provider: LifecycleProviderId
|
|
135
|
+
runtime_kind: RuntimeKind
|
|
136
|
+
identity: ProviderRuntimeIdentity | None
|
|
137
|
+
state: ProviderLifecycleState
|
|
138
|
+
policy_version: str
|
|
139
|
+
observed_at: int
|
|
140
|
+
updated_at: int
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class LifecycleInvalidationRecord:
|
|
145
|
+
invalidation_id: str
|
|
146
|
+
boundary_digest: str
|
|
147
|
+
previous_identity_digest: str
|
|
148
|
+
current_identity_digest: str
|
|
149
|
+
target_kind: str
|
|
150
|
+
target_id: str
|
|
151
|
+
reason: LifecycleReasonCode
|
|
152
|
+
invalidated_at: int
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
_SCHEMA = (
|
|
156
|
+
"CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
|
157
|
+
f"""CREATE TABLE IF NOT EXISTS current_observations (
|
|
158
|
+
boundary_digest TEXT PRIMARY KEY CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
|
|
159
|
+
provider TEXT NOT NULL CHECK(provider IN ({_PROVIDER_VALUES})),
|
|
160
|
+
runtime_kind TEXT NOT NULL CHECK(runtime_kind IN ({_RUNTIME_VALUES})),
|
|
161
|
+
identity_digest TEXT CHECK(
|
|
162
|
+
identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='identity_digest')})
|
|
163
|
+
),
|
|
164
|
+
state TEXT NOT NULL CHECK(state IN ({_STATE_VALUES})),
|
|
165
|
+
policy_version TEXT NOT NULL,
|
|
166
|
+
identity_json TEXT CHECK(identity_json IS NULL OR length(identity_json) <= 8192),
|
|
167
|
+
observed_at INTEGER NOT NULL CHECK(observed_at >= 0),
|
|
168
|
+
updated_at INTEGER NOT NULL CHECK(updated_at >= observed_at),
|
|
169
|
+
CHECK(
|
|
170
|
+
(provider IN ('claude-code','codex') AND runtime_kind='local-cli')
|
|
171
|
+
OR (provider='ollama' AND runtime_kind='local-http')
|
|
172
|
+
OR (provider IN ('openrouter','zai') AND runtime_kind='remote-https')
|
|
173
|
+
),
|
|
174
|
+
CHECK(state='unavailable' OR (identity_digest IS NOT NULL AND identity_json IS NOT NULL))
|
|
175
|
+
)""",
|
|
176
|
+
f"""CREATE TABLE IF NOT EXISTS lifecycle_events (
|
|
177
|
+
event_id TEXT PRIMARY KEY,
|
|
178
|
+
boundary_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
|
|
179
|
+
provider TEXT NOT NULL CHECK(provider IN ({_PROVIDER_VALUES})),
|
|
180
|
+
runtime_kind TEXT NOT NULL CHECK(runtime_kind IN ({_RUNTIME_VALUES})),
|
|
181
|
+
previous_identity_digest TEXT CHECK(
|
|
182
|
+
previous_identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='previous_identity_digest')})
|
|
183
|
+
),
|
|
184
|
+
current_identity_digest TEXT CHECK(
|
|
185
|
+
current_identity_digest IS NULL OR ({_HEX_64_GLOB.format(column='current_identity_digest')})
|
|
186
|
+
),
|
|
187
|
+
previous_state TEXT CHECK(previous_state IS NULL OR previous_state IN ({_STATE_VALUES})),
|
|
188
|
+
current_state TEXT NOT NULL CHECK(current_state IN ({_STATE_VALUES})),
|
|
189
|
+
reason TEXT NOT NULL,
|
|
190
|
+
policy_version TEXT NOT NULL,
|
|
191
|
+
occurred_at INTEGER NOT NULL CHECK(occurred_at >= 0),
|
|
192
|
+
payload_json TEXT NOT NULL CHECK(length(payload_json) <= 8192),
|
|
193
|
+
payload_hash TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='payload_hash')}),
|
|
194
|
+
CHECK(
|
|
195
|
+
(provider IN ('claude-code','codex') AND runtime_kind='local-cli')
|
|
196
|
+
OR (provider='ollama' AND runtime_kind='local-http')
|
|
197
|
+
OR (provider IN ('openrouter','zai') AND runtime_kind='remote-https')
|
|
198
|
+
)
|
|
199
|
+
)""",
|
|
200
|
+
"CREATE INDEX IF NOT EXISTS lifecycle_events_boundary_time_idx ON lifecycle_events(boundary_digest, occurred_at, event_id)",
|
|
201
|
+
f"""CREATE TABLE IF NOT EXISTS authority_invalidations (
|
|
202
|
+
invalidation_id TEXT PRIMARY KEY CHECK({_HEX_64_GLOB.format(column='invalidation_id')}),
|
|
203
|
+
boundary_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='boundary_digest')}),
|
|
204
|
+
previous_identity_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='previous_identity_digest')}),
|
|
205
|
+
current_identity_digest TEXT NOT NULL CHECK({_HEX_64_GLOB.format(column='current_identity_digest')}),
|
|
206
|
+
target_kind TEXT NOT NULL CHECK(target_kind IN ('capability_snapshot','approval')),
|
|
207
|
+
target_id TEXT NOT NULL,
|
|
208
|
+
reason TEXT NOT NULL,
|
|
209
|
+
invalidated_at INTEGER NOT NULL CHECK(invalidated_at >= 0)
|
|
210
|
+
)""",
|
|
211
|
+
"CREATE INDEX IF NOT EXISTS authority_invalidations_boundary_time_idx ON authority_invalidations(boundary_digest, invalidated_at, invalidation_id)",
|
|
212
|
+
"""CREATE TRIGGER IF NOT EXISTS lifecycle_event_update_guard
|
|
213
|
+
BEFORE UPDATE ON lifecycle_events BEGIN
|
|
214
|
+
SELECT RAISE(ABORT, 'lifecycle_event_immutable');
|
|
215
|
+
END""",
|
|
216
|
+
"""CREATE TRIGGER IF NOT EXISTS lifecycle_event_delete_guard
|
|
217
|
+
BEFORE DELETE ON lifecycle_events BEGIN
|
|
218
|
+
SELECT RAISE(ABORT, 'lifecycle_event_immutable');
|
|
219
|
+
END""",
|
|
220
|
+
"""CREATE TRIGGER IF NOT EXISTS lifecycle_invalidation_update_guard
|
|
221
|
+
BEFORE UPDATE ON authority_invalidations BEGIN
|
|
222
|
+
SELECT RAISE(ABORT, 'lifecycle_invalidation_immutable');
|
|
223
|
+
END""",
|
|
224
|
+
"""CREATE TRIGGER IF NOT EXISTS lifecycle_invalidation_delete_guard
|
|
225
|
+
BEFORE DELETE ON authority_invalidations BEGIN
|
|
226
|
+
SELECT RAISE(ABORT, 'lifecycle_invalidation_immutable');
|
|
227
|
+
END""",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class LifecycleStore:
|
|
232
|
+
"""Repository-scoped provider lifecycle authority and sanitized evidence."""
|
|
233
|
+
|
|
234
|
+
def __init__(self, repository_root: Path, *, busy_timeout_ms: int = BUSY_TIMEOUT_MS) -> None:
|
|
235
|
+
self.root = _selected_root(repository_root)
|
|
236
|
+
if isinstance(busy_timeout_ms, bool) or not 100 <= busy_timeout_ms <= 30_000:
|
|
237
|
+
raise ValueError("busy_timeout_invalid")
|
|
238
|
+
self.busy_timeout_ms = busy_timeout_ms
|
|
239
|
+
self.path = self.root / ".graphite" / "routing" / "provider-lifecycle.sqlite3"
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def backup_path(self) -> Path:
|
|
243
|
+
return self.path.parent / "backups" / "provider-lifecycle-schema-v1.sqlite3"
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def backup_marker_path(self) -> Path:
|
|
247
|
+
return self.path.parent / "backups" / "provider-lifecycle-schema-v1.sha256.json"
|
|
248
|
+
|
|
249
|
+
def _connect(self) -> sqlite3.Connection:
|
|
250
|
+
# The handle is opened first and configured second, so a failure in the
|
|
251
|
+
# configuration leaves a live handle that the caller never receives --
|
|
252
|
+
# no `try/finally` upstream can reach it, and only the collector ever
|
|
253
|
+
# frees it. `PRAGMA journal_mode = WAL` is the realistic trigger: it
|
|
254
|
+
# takes a lock, so it is what answers "database is locked" when another
|
|
255
|
+
# connection holds one.
|
|
256
|
+
#
|
|
257
|
+
# This is the `with connection:` defect one level lower. There the
|
|
258
|
+
# transaction ended and the handle stayed open; here the error path drops
|
|
259
|
+
# the reference before anyone can own it. Both leave Windows unable to
|
|
260
|
+
# unlink the database, and both do it on the RECOVERY path.
|
|
261
|
+
connection: sqlite3.Connection | None = None
|
|
262
|
+
try:
|
|
263
|
+
connection = sqlite3.connect(
|
|
264
|
+
self.path,
|
|
265
|
+
timeout=self.busy_timeout_ms / 1_000,
|
|
266
|
+
isolation_level=None,
|
|
267
|
+
)
|
|
268
|
+
connection.row_factory = sqlite3.Row
|
|
269
|
+
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
|
|
270
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
271
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
272
|
+
return connection
|
|
273
|
+
except sqlite3.Error as exc:
|
|
274
|
+
if connection is not None:
|
|
275
|
+
# Suppressed, not swallowed: a close that also fails must not
|
|
276
|
+
# replace the diagnosis the caller is about to receive.
|
|
277
|
+
with suppress(sqlite3.Error):
|
|
278
|
+
connection.close()
|
|
279
|
+
raise _translate_database_error(exc) from exc
|
|
280
|
+
|
|
281
|
+
@contextmanager
|
|
282
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
283
|
+
"""End the transaction like `with connection:` does, then actually CLOSE it.
|
|
284
|
+
|
|
285
|
+
`sqlite3.Connection.__exit__` commits or rolls back and leaves the handle
|
|
286
|
+
OPEN -- a long-standing trap in that API. `with self._connection() as c:`
|
|
287
|
+
therefore released nothing, and 51 sites across this package used it.
|
|
288
|
+
Measured: 934 `ResourceWarning: unclosed database` in one suite run.
|
|
289
|
+
|
|
290
|
+
Refcounting hides it: the local dies when the method returns and CPython
|
|
291
|
+
finalizes immediately, which is why the suite was green. It stops hiding
|
|
292
|
+
the moment anything else holds a frame alive -- a traceback, a debugger,
|
|
293
|
+
a coverage tracer. Under `pytest --cov` the v3->v4 and v4->v5 rollback
|
|
294
|
+
drills failed with `PermissionError: [WinError 32]`: Windows will not
|
|
295
|
+
unlink a file with an open handle, and rollback REPLACES the live
|
|
296
|
+
database. The recovery path was depending on collector timing.
|
|
297
|
+
|
|
298
|
+
The methods that manage their own transaction keep using `_connect` with
|
|
299
|
+
an explicit `try/finally: connection.close()` -- they were already
|
|
300
|
+
correct, and wrapping them here would add a second commit they did not
|
|
301
|
+
ask for.
|
|
302
|
+
"""
|
|
303
|
+
connection = self._connect()
|
|
304
|
+
try:
|
|
305
|
+
with connection:
|
|
306
|
+
yield connection
|
|
307
|
+
finally:
|
|
308
|
+
connection.close()
|
|
309
|
+
|
|
310
|
+
def _connect_readonly(self) -> sqlite3.Connection:
|
|
311
|
+
"""Open existing lifecycle authority without creating or mutating repository state."""
|
|
312
|
+
current = self.root
|
|
313
|
+
try:
|
|
314
|
+
for part in self.path.parent.relative_to(self.root).parts:
|
|
315
|
+
current /= part
|
|
316
|
+
metadata = current.lstat()
|
|
317
|
+
if (
|
|
318
|
+
not stat.S_ISDIR(metadata.st_mode)
|
|
319
|
+
or stat.S_ISLNK(metadata.st_mode)
|
|
320
|
+
or _is_reparse_point(metadata)
|
|
321
|
+
):
|
|
322
|
+
raise LifecycleStorageError("lifecycle_storage_path_invalid")
|
|
323
|
+
metadata = self.path.lstat()
|
|
324
|
+
if (
|
|
325
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
326
|
+
or stat.S_ISLNK(metadata.st_mode)
|
|
327
|
+
or _is_reparse_point(metadata)
|
|
328
|
+
):
|
|
329
|
+
raise LifecycleStorageError("lifecycle_storage_path_invalid")
|
|
330
|
+
except FileNotFoundError as exc:
|
|
331
|
+
raise LifecycleStorageError("lifecycle_storage_missing") from exc
|
|
332
|
+
except LifecycleStorageError:
|
|
333
|
+
raise
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
raise LifecycleStorageError("lifecycle_storage_unavailable") from exc
|
|
336
|
+
connection: sqlite3.Connection | None = None
|
|
337
|
+
try:
|
|
338
|
+
connection = sqlite3.connect(
|
|
339
|
+
f"{self.path.as_uri()}?mode=ro",
|
|
340
|
+
uri=True,
|
|
341
|
+
timeout=self.busy_timeout_ms / 1_000,
|
|
342
|
+
isolation_level=None,
|
|
343
|
+
)
|
|
344
|
+
connection.row_factory = sqlite3.Row
|
|
345
|
+
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
|
|
346
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
347
|
+
connection.execute("PRAGMA query_only = ON")
|
|
348
|
+
version = connection.execute(
|
|
349
|
+
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
350
|
+
).fetchone()
|
|
351
|
+
if version is None or version[0] != LIFECYCLE_SCHEMA_VERSION:
|
|
352
|
+
raise LifecycleStorageError("lifecycle_schema_unsupported")
|
|
353
|
+
self._validate_schema(connection)
|
|
354
|
+
self._validate_integrity(connection)
|
|
355
|
+
return connection
|
|
356
|
+
except LifecycleStorageError:
|
|
357
|
+
if connection is not None:
|
|
358
|
+
connection.close()
|
|
359
|
+
raise
|
|
360
|
+
except sqlite3.Error as exc:
|
|
361
|
+
if connection is not None:
|
|
362
|
+
connection.close()
|
|
363
|
+
raise _translate_database_error(exc) from exc
|
|
364
|
+
|
|
365
|
+
def initialize(self) -> None:
|
|
366
|
+
_secure_repository_directory(self.root, self.path.parent)
|
|
367
|
+
_validate_database_file(self.path)
|
|
368
|
+
connection: sqlite3.Connection | None = None
|
|
369
|
+
try:
|
|
370
|
+
connection = self._connect()
|
|
371
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
372
|
+
connection.execute(_SCHEMA[0])
|
|
373
|
+
# Rebuild the provider-CHECK tables in place before the version gate
|
|
374
|
+
# reads the schema version, so an existing v1 store is at "2" by the
|
|
375
|
+
# time the gate and validators run.
|
|
376
|
+
self._migrate_v1_to_v2_zai(connection)
|
|
377
|
+
version = connection.execute(
|
|
378
|
+
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
379
|
+
).fetchone()
|
|
380
|
+
if version is not None and version[0] != LIFECYCLE_SCHEMA_VERSION:
|
|
381
|
+
raise LifecycleStorageError("lifecycle_schema_unsupported")
|
|
382
|
+
if version is not None:
|
|
383
|
+
self._validate_schema(connection)
|
|
384
|
+
for statement in _SCHEMA[1:]:
|
|
385
|
+
connection.execute(statement)
|
|
386
|
+
connection.execute(
|
|
387
|
+
"""INSERT INTO schema_meta(key,value) VALUES('schema_version',?)
|
|
388
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value""",
|
|
389
|
+
(LIFECYCLE_SCHEMA_VERSION,),
|
|
390
|
+
)
|
|
391
|
+
connection.commit()
|
|
392
|
+
self._validate_integrity(connection)
|
|
393
|
+
except LifecycleStorageError:
|
|
394
|
+
if connection is not None and connection.in_transaction:
|
|
395
|
+
connection.rollback()
|
|
396
|
+
raise
|
|
397
|
+
except sqlite3.Error as exc:
|
|
398
|
+
if connection is not None and connection.in_transaction:
|
|
399
|
+
connection.rollback()
|
|
400
|
+
raise _translate_database_error(exc) from exc
|
|
401
|
+
finally:
|
|
402
|
+
if connection is not None:
|
|
403
|
+
connection.close()
|
|
404
|
+
_validate_database_file(self.path)
|
|
405
|
+
_secure_file(self.path)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _migrate_v1_to_v2_zai(connection: sqlite3.Connection) -> None:
|
|
409
|
+
"""Rebuild the provider-CHECK tables in place so an existing v1 store admits zai.
|
|
410
|
+
|
|
411
|
+
Runs on the caller's connection INSIDE ``initialize()``'s open
|
|
412
|
+
``BEGIN IMMEDIATE`` transaction and before the schema-version gate. There
|
|
413
|
+
is no second connection (which would deadlock on the write lock), no
|
|
414
|
+
``foreign_keys`` toggle (this store has no foreign keys), and no
|
|
415
|
+
independent commit/rollback: ``initialize()``'s existing commit finalizes
|
|
416
|
+
the rebuild and its ``except`` rolls the whole thing back, which also
|
|
417
|
+
provides the quarantine-on-mismatch path for free.
|
|
418
|
+
"""
|
|
419
|
+
row = connection.execute(
|
|
420
|
+
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
421
|
+
).fetchone()
|
|
422
|
+
if row is None or row[0] != "1":
|
|
423
|
+
# Fresh installs (None) and already-migrated stores ("2") get the
|
|
424
|
+
# widened CHECK straight from _SCHEMA; unknown versions fall through
|
|
425
|
+
# to initialize()'s version gate.
|
|
426
|
+
return
|
|
427
|
+
rebuilds = (
|
|
428
|
+
(
|
|
429
|
+
"current_observations",
|
|
430
|
+
_SCHEMA[1],
|
|
431
|
+
"boundary_digest,provider,runtime_kind,identity_digest,state,"
|
|
432
|
+
"policy_version,identity_json,observed_at,updated_at",
|
|
433
|
+
),
|
|
434
|
+
(
|
|
435
|
+
"lifecycle_events",
|
|
436
|
+
_SCHEMA[2],
|
|
437
|
+
"event_id,boundary_digest,provider,runtime_kind,"
|
|
438
|
+
"previous_identity_digest,current_identity_digest,previous_state,"
|
|
439
|
+
"current_state,reason,policy_version,occurred_at,payload_json,payload_hash",
|
|
440
|
+
),
|
|
441
|
+
)
|
|
442
|
+
try:
|
|
443
|
+
for table, create_sql, columns in rebuilds:
|
|
444
|
+
rebuilt = create_sql.replace(
|
|
445
|
+
f"CREATE TABLE IF NOT EXISTS {table} ",
|
|
446
|
+
f"CREATE TABLE IF NOT EXISTS {table}__v2 ",
|
|
447
|
+
1,
|
|
448
|
+
)
|
|
449
|
+
if rebuilt == create_sql:
|
|
450
|
+
raise LifecycleStorageError("lifecycle_migration_failed")
|
|
451
|
+
connection.execute(rebuilt)
|
|
452
|
+
connection.execute(
|
|
453
|
+
f"INSERT INTO {table}__v2 ({columns}) SELECT {columns} FROM {table}"
|
|
454
|
+
)
|
|
455
|
+
before = connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
456
|
+
after = connection.execute(
|
|
457
|
+
f"SELECT COUNT(*) FROM {table}__v2"
|
|
458
|
+
).fetchone()[0]
|
|
459
|
+
if before != after:
|
|
460
|
+
raise LifecycleStorageError("lifecycle_migration_quarantined")
|
|
461
|
+
connection.execute(f"DROP TABLE {table}")
|
|
462
|
+
connection.execute(f"ALTER TABLE {table}__v2 RENAME TO {table}")
|
|
463
|
+
if connection.execute("PRAGMA foreign_key_check").fetchall():
|
|
464
|
+
raise LifecycleStorageError("lifecycle_migration_quarantined")
|
|
465
|
+
integrity = connection.execute("PRAGMA integrity_check").fetchone()
|
|
466
|
+
if integrity is None or integrity[0] != "ok":
|
|
467
|
+
raise LifecycleStorageError("lifecycle_migration_quarantined")
|
|
468
|
+
connection.execute(
|
|
469
|
+
"""INSERT INTO schema_meta(key,value) VALUES('schema_version','2')
|
|
470
|
+
ON CONFLICT(key) DO UPDATE SET value=excluded.value"""
|
|
471
|
+
)
|
|
472
|
+
except LifecycleStorageError:
|
|
473
|
+
raise
|
|
474
|
+
except sqlite3.Error as exc:
|
|
475
|
+
raise LifecycleStorageError("lifecycle_migration_failed") from exc
|
|
476
|
+
|
|
477
|
+
@staticmethod
|
|
478
|
+
def _validate_schema(connection: sqlite3.Connection) -> None:
|
|
479
|
+
expected = {
|
|
480
|
+
"current_observations": {
|
|
481
|
+
"boundary_digest", "provider", "runtime_kind", "identity_digest",
|
|
482
|
+
"state", "policy_version", "identity_json", "observed_at", "updated_at",
|
|
483
|
+
},
|
|
484
|
+
"lifecycle_events": {
|
|
485
|
+
"event_id", "boundary_digest", "provider", "runtime_kind",
|
|
486
|
+
"previous_identity_digest", "current_identity_digest", "previous_state",
|
|
487
|
+
"current_state", "reason", "policy_version", "occurred_at",
|
|
488
|
+
"payload_json", "payload_hash",
|
|
489
|
+
},
|
|
490
|
+
"authority_invalidations": {
|
|
491
|
+
"invalidation_id", "boundary_digest", "previous_identity_digest",
|
|
492
|
+
"current_identity_digest", "target_kind", "target_id", "reason",
|
|
493
|
+
"invalidated_at",
|
|
494
|
+
},
|
|
495
|
+
}
|
|
496
|
+
actual = {
|
|
497
|
+
str(row[0])
|
|
498
|
+
for row in connection.execute(
|
|
499
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if not set(expected) <= actual:
|
|
503
|
+
raise LifecycleStorageError("lifecycle_rollback_required")
|
|
504
|
+
for table, required_columns in expected.items():
|
|
505
|
+
columns = {
|
|
506
|
+
str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")
|
|
507
|
+
}
|
|
508
|
+
if not required_columns <= columns:
|
|
509
|
+
raise LifecycleStorageError("lifecycle_rollback_required")
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def _validate_integrity(connection: sqlite3.Connection) -> None:
|
|
513
|
+
integrity = connection.execute("PRAGMA integrity_check").fetchone()
|
|
514
|
+
foreign_keys = connection.execute("PRAGMA foreign_key_check").fetchall()
|
|
515
|
+
if integrity is None or integrity[0] != "ok" or foreign_keys:
|
|
516
|
+
raise LifecycleStorageError("lifecycle_storage_corrupt")
|
|
517
|
+
|
|
518
|
+
def integrity_check(self) -> str:
|
|
519
|
+
try:
|
|
520
|
+
with self._connection() as connection:
|
|
521
|
+
self._validate_integrity(connection)
|
|
522
|
+
except LifecycleStorageError:
|
|
523
|
+
raise
|
|
524
|
+
except sqlite3.Error as exc:
|
|
525
|
+
raise _translate_database_error(exc) from exc
|
|
526
|
+
return "ok"
|
|
527
|
+
|
|
528
|
+
def pragma_state(self) -> dict[str, int | str]:
|
|
529
|
+
try:
|
|
530
|
+
with self._connection() as connection:
|
|
531
|
+
return {
|
|
532
|
+
"foreign_keys": int(connection.execute("PRAGMA foreign_keys").fetchone()[0]),
|
|
533
|
+
"journal_mode": str(connection.execute("PRAGMA journal_mode").fetchone()[0]).casefold(),
|
|
534
|
+
"busy_timeout": int(connection.execute("PRAGMA busy_timeout").fetchone()[0]),
|
|
535
|
+
}
|
|
536
|
+
except sqlite3.Error as exc:
|
|
537
|
+
raise _translate_database_error(exc) from exc
|
|
538
|
+
|
|
539
|
+
def record_transition(
|
|
540
|
+
self,
|
|
541
|
+
boundary_digest: str,
|
|
542
|
+
identity: ProviderRuntimeIdentity | None,
|
|
543
|
+
event: ProviderLifecycleEvent,
|
|
544
|
+
) -> bool:
|
|
545
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
546
|
+
if identity is not None and not isinstance(identity, ProviderRuntimeIdentity):
|
|
547
|
+
raise ValueError("lifecycle_record_invalid")
|
|
548
|
+
if not isinstance(event, ProviderLifecycleEvent):
|
|
549
|
+
raise ValueError("lifecycle_record_invalid")
|
|
550
|
+
event_payload = _canonical_json(event.to_dict())
|
|
551
|
+
event_hash = _payload_hash(event_payload)
|
|
552
|
+
identity_payload = None if identity is None else _canonical_json(identity.to_dict())
|
|
553
|
+
connection: sqlite3.Connection | None = None
|
|
554
|
+
try:
|
|
555
|
+
connection = self._connect()
|
|
556
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
557
|
+
existing_event = connection.execute(
|
|
558
|
+
"SELECT boundary_digest,payload_hash FROM lifecycle_events WHERE event_id=?",
|
|
559
|
+
(event.event_id,),
|
|
560
|
+
).fetchone()
|
|
561
|
+
if existing_event is not None:
|
|
562
|
+
if existing_event["boundary_digest"] != boundary or existing_event["payload_hash"] != event_hash:
|
|
563
|
+
raise LifecycleStorageError("lifecycle_event_changed")
|
|
564
|
+
connection.rollback()
|
|
565
|
+
return False
|
|
566
|
+
if identity is None:
|
|
567
|
+
if (
|
|
568
|
+
event.current_state is not ProviderLifecycleState.UNAVAILABLE
|
|
569
|
+
or event.current_identity_digest is not None
|
|
570
|
+
):
|
|
571
|
+
raise LifecycleStorageError("lifecycle_identity_mismatch")
|
|
572
|
+
else:
|
|
573
|
+
if (
|
|
574
|
+
event.provider is not identity.provider
|
|
575
|
+
or event.runtime_kind is not identity.runtime_kind
|
|
576
|
+
or event.current_identity_digest != identity.digest
|
|
577
|
+
):
|
|
578
|
+
raise LifecycleStorageError("lifecycle_identity_mismatch")
|
|
579
|
+
if event.policy_version != identity.policy_version:
|
|
580
|
+
raise LifecycleStorageError("lifecycle_policy_mismatch")
|
|
581
|
+
if event.occurred_at < identity.observed_at:
|
|
582
|
+
raise LifecycleStorageError("lifecycle_observation_time_mismatch")
|
|
583
|
+
current = connection.execute(
|
|
584
|
+
"""SELECT provider,runtime_kind,identity_digest,state,updated_at
|
|
585
|
+
FROM current_observations WHERE boundary_digest=?""",
|
|
586
|
+
(boundary,),
|
|
587
|
+
).fetchone()
|
|
588
|
+
if current is None:
|
|
589
|
+
if event.previous_identity_digest is not None or event.previous_state is not None:
|
|
590
|
+
raise LifecycleStorageError("lifecycle_transition_stale")
|
|
591
|
+
elif (
|
|
592
|
+
current["provider"] != event.provider.value
|
|
593
|
+
or current["runtime_kind"] != event.runtime_kind.value
|
|
594
|
+
or current["identity_digest"] != event.previous_identity_digest
|
|
595
|
+
or current["state"] != event.previous_state.value
|
|
596
|
+
):
|
|
597
|
+
raise LifecycleStorageError("lifecycle_transition_stale")
|
|
598
|
+
elif event.occurred_at < int(current["updated_at"]):
|
|
599
|
+
raise LifecycleStorageError("lifecycle_transition_stale")
|
|
600
|
+
connection.execute(
|
|
601
|
+
"""INSERT INTO lifecycle_events(
|
|
602
|
+
event_id,boundary_digest,provider,runtime_kind,
|
|
603
|
+
previous_identity_digest,current_identity_digest,
|
|
604
|
+
previous_state,current_state,reason,policy_version,
|
|
605
|
+
occurred_at,payload_json,payload_hash
|
|
606
|
+
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
607
|
+
(
|
|
608
|
+
event.event_id,
|
|
609
|
+
boundary,
|
|
610
|
+
event.provider.value,
|
|
611
|
+
event.runtime_kind.value,
|
|
612
|
+
event.previous_identity_digest,
|
|
613
|
+
event.current_identity_digest,
|
|
614
|
+
None if event.previous_state is None else event.previous_state.value,
|
|
615
|
+
event.current_state.value,
|
|
616
|
+
event.reason.value,
|
|
617
|
+
event.policy_version,
|
|
618
|
+
event.occurred_at,
|
|
619
|
+
event_payload,
|
|
620
|
+
event_hash,
|
|
621
|
+
),
|
|
622
|
+
)
|
|
623
|
+
connection.execute(
|
|
624
|
+
"""INSERT INTO current_observations(
|
|
625
|
+
boundary_digest,provider,runtime_kind,identity_digest,state,
|
|
626
|
+
policy_version,identity_json,observed_at,updated_at
|
|
627
|
+
) VALUES(?,?,?,?,?,?,?,?,?)
|
|
628
|
+
ON CONFLICT(boundary_digest) DO UPDATE SET
|
|
629
|
+
provider=excluded.provider,
|
|
630
|
+
runtime_kind=excluded.runtime_kind,
|
|
631
|
+
identity_digest=excluded.identity_digest,
|
|
632
|
+
state=excluded.state,
|
|
633
|
+
policy_version=excluded.policy_version,
|
|
634
|
+
identity_json=excluded.identity_json,
|
|
635
|
+
observed_at=excluded.observed_at,
|
|
636
|
+
updated_at=excluded.updated_at""",
|
|
637
|
+
(
|
|
638
|
+
boundary,
|
|
639
|
+
event.provider.value,
|
|
640
|
+
event.runtime_kind.value,
|
|
641
|
+
None if identity is None else identity.digest,
|
|
642
|
+
event.current_state.value,
|
|
643
|
+
event.policy_version,
|
|
644
|
+
identity_payload,
|
|
645
|
+
event.occurred_at if identity is None else identity.observed_at,
|
|
646
|
+
event.occurred_at,
|
|
647
|
+
),
|
|
648
|
+
)
|
|
649
|
+
connection.commit()
|
|
650
|
+
return True
|
|
651
|
+
except LifecycleStorageError:
|
|
652
|
+
if connection is not None and connection.in_transaction:
|
|
653
|
+
connection.rollback()
|
|
654
|
+
raise
|
|
655
|
+
except sqlite3.Error as exc:
|
|
656
|
+
if connection is not None and connection.in_transaction:
|
|
657
|
+
connection.rollback()
|
|
658
|
+
raise _translate_database_error(exc) from exc
|
|
659
|
+
finally:
|
|
660
|
+
if connection is not None:
|
|
661
|
+
connection.close()
|
|
662
|
+
|
|
663
|
+
def current_observation(
|
|
664
|
+
self, boundary_digest: str
|
|
665
|
+
) -> CurrentLifecycleObservation | None:
|
|
666
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
667
|
+
try:
|
|
668
|
+
with self._connection() as connection:
|
|
669
|
+
row = connection.execute(
|
|
670
|
+
"""SELECT boundary_digest,provider,runtime_kind,identity_digest,
|
|
671
|
+
identity_json,state,policy_version,observed_at,updated_at
|
|
672
|
+
FROM current_observations
|
|
673
|
+
WHERE boundary_digest=?""",
|
|
674
|
+
(boundary,),
|
|
675
|
+
).fetchone()
|
|
676
|
+
except sqlite3.Error as exc:
|
|
677
|
+
raise _translate_database_error(exc) from exc
|
|
678
|
+
if row is None:
|
|
679
|
+
return None
|
|
680
|
+
return self._observation_from_row(row)
|
|
681
|
+
|
|
682
|
+
@staticmethod
|
|
683
|
+
def _observation_from_row(row: sqlite3.Row) -> CurrentLifecycleObservation:
|
|
684
|
+
try:
|
|
685
|
+
identity_payload = row["identity_json"]
|
|
686
|
+
identity = None if identity_payload is None else _identity_from_json(identity_payload)
|
|
687
|
+
observation = CurrentLifecycleObservation(
|
|
688
|
+
boundary_digest=_digest(row["boundary_digest"], "lifecycle_storage_corrupt"),
|
|
689
|
+
provider=LifecycleProviderId(row["provider"]),
|
|
690
|
+
runtime_kind=RuntimeKind(row["runtime_kind"]),
|
|
691
|
+
identity=identity,
|
|
692
|
+
state=ProviderLifecycleState(row["state"]),
|
|
693
|
+
policy_version=str(row["policy_version"]),
|
|
694
|
+
observed_at=int(row["observed_at"]),
|
|
695
|
+
updated_at=int(row["updated_at"]),
|
|
696
|
+
)
|
|
697
|
+
if observation.updated_at < observation.observed_at:
|
|
698
|
+
raise ValueError
|
|
699
|
+
if identity is None:
|
|
700
|
+
if (
|
|
701
|
+
row["identity_digest"] is not None
|
|
702
|
+
or observation.state is not ProviderLifecycleState.UNAVAILABLE
|
|
703
|
+
):
|
|
704
|
+
raise ValueError
|
|
705
|
+
elif (
|
|
706
|
+
row["identity_digest"] != identity.digest
|
|
707
|
+
or observation.provider is not identity.provider
|
|
708
|
+
or observation.runtime_kind is not identity.runtime_kind
|
|
709
|
+
or observation.policy_version != identity.policy_version
|
|
710
|
+
or observation.observed_at != identity.observed_at
|
|
711
|
+
):
|
|
712
|
+
raise ValueError
|
|
713
|
+
return observation
|
|
714
|
+
except (TypeError, ValueError) as exc:
|
|
715
|
+
raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
|
|
716
|
+
|
|
717
|
+
def read_current_observation(
|
|
718
|
+
self, boundary_digest: str
|
|
719
|
+
) -> CurrentLifecycleObservation | None:
|
|
720
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
721
|
+
try:
|
|
722
|
+
with closing(self._connect_readonly()) as connection:
|
|
723
|
+
row = connection.execute(
|
|
724
|
+
"""SELECT boundary_digest,provider,runtime_kind,identity_digest,
|
|
725
|
+
identity_json,state,policy_version,observed_at,updated_at
|
|
726
|
+
FROM current_observations
|
|
727
|
+
WHERE boundary_digest=?""",
|
|
728
|
+
(boundary,),
|
|
729
|
+
).fetchone()
|
|
730
|
+
except LifecycleStorageError:
|
|
731
|
+
raise
|
|
732
|
+
except sqlite3.Error as exc:
|
|
733
|
+
raise _translate_database_error(exc) from exc
|
|
734
|
+
return None if row is None else self._observation_from_row(row)
|
|
735
|
+
|
|
736
|
+
def read_observations(
|
|
737
|
+
self, *, limit: int = MAX_EVENT_PAGE_SIZE
|
|
738
|
+
) -> tuple[CurrentLifecycleObservation, ...]:
|
|
739
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
|
|
740
|
+
raise ValueError("observations_limit_invalid")
|
|
741
|
+
try:
|
|
742
|
+
with closing(self._connect_readonly()) as connection:
|
|
743
|
+
rows = connection.execute(
|
|
744
|
+
"""SELECT boundary_digest,provider,runtime_kind,identity_digest,
|
|
745
|
+
identity_json,state,policy_version,observed_at,updated_at
|
|
746
|
+
FROM current_observations
|
|
747
|
+
ORDER BY updated_at DESC,boundary_digest LIMIT ?""",
|
|
748
|
+
(limit,),
|
|
749
|
+
).fetchall()
|
|
750
|
+
except LifecycleStorageError:
|
|
751
|
+
raise
|
|
752
|
+
except sqlite3.Error as exc:
|
|
753
|
+
raise _translate_database_error(exc) from exc
|
|
754
|
+
return tuple(self._observation_from_row(row) for row in rows)
|
|
755
|
+
|
|
756
|
+
def read_events(
|
|
757
|
+
self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
|
|
758
|
+
) -> tuple[ProviderLifecycleEvent, ...]:
|
|
759
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
760
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
|
|
761
|
+
raise ValueError("events_limit_invalid")
|
|
762
|
+
try:
|
|
763
|
+
with closing(self._connect_readonly()) as connection:
|
|
764
|
+
rows = connection.execute(
|
|
765
|
+
"""SELECT payload_json FROM lifecycle_events
|
|
766
|
+
WHERE boundary_digest=? ORDER BY occurred_at DESC,event_id DESC LIMIT ?""",
|
|
767
|
+
(boundary, limit),
|
|
768
|
+
).fetchall()
|
|
769
|
+
except LifecycleStorageError:
|
|
770
|
+
raise
|
|
771
|
+
except sqlite3.Error as exc:
|
|
772
|
+
raise _translate_database_error(exc) from exc
|
|
773
|
+
return tuple(reversed([_event_from_json(row["payload_json"]) for row in rows]))
|
|
774
|
+
|
|
775
|
+
def events(
|
|
776
|
+
self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
|
|
777
|
+
) -> tuple[ProviderLifecycleEvent, ...]:
|
|
778
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
779
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
|
|
780
|
+
raise ValueError("events_limit_invalid")
|
|
781
|
+
try:
|
|
782
|
+
with self._connection() as connection:
|
|
783
|
+
rows = connection.execute(
|
|
784
|
+
"""SELECT payload_json FROM lifecycle_events
|
|
785
|
+
WHERE boundary_digest=? ORDER BY occurred_at DESC,event_id DESC LIMIT ?""",
|
|
786
|
+
(boundary, limit),
|
|
787
|
+
).fetchall()
|
|
788
|
+
except sqlite3.Error as exc:
|
|
789
|
+
raise _translate_database_error(exc) from exc
|
|
790
|
+
return tuple(reversed([_event_from_json(row["payload_json"]) for row in rows]))
|
|
791
|
+
|
|
792
|
+
def record_invalidations(
|
|
793
|
+
self,
|
|
794
|
+
*,
|
|
795
|
+
boundary_digest: str,
|
|
796
|
+
previous_identity_digest: str,
|
|
797
|
+
current_identity_digest: str,
|
|
798
|
+
capability_snapshot_digests: tuple[str, ...],
|
|
799
|
+
approval_ids: tuple[str, ...],
|
|
800
|
+
reason: LifecycleReasonCode,
|
|
801
|
+
invalidated_at: int,
|
|
802
|
+
) -> int:
|
|
803
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
804
|
+
previous = _digest(previous_identity_digest, "previous_identity_digest_invalid")
|
|
805
|
+
current = _digest(current_identity_digest, "current_identity_digest_invalid")
|
|
806
|
+
try:
|
|
807
|
+
normalized_reason = LifecycleReasonCode(reason)
|
|
808
|
+
except (TypeError, ValueError) as exc:
|
|
809
|
+
raise ValueError("lifecycle_reason_invalid") from exc
|
|
810
|
+
observed = _timestamp(invalidated_at, "invalidated_at_invalid")
|
|
811
|
+
if not isinstance(capability_snapshot_digests, (tuple, list)) or not isinstance(
|
|
812
|
+
approval_ids, (tuple, list)
|
|
813
|
+
):
|
|
814
|
+
raise ValueError("invalidation_targets_invalid")
|
|
815
|
+
if len(capability_snapshot_digests) + len(approval_ids) > MAX_INVALIDATION_TARGETS:
|
|
816
|
+
raise ValueError("invalidation_targets_invalid")
|
|
817
|
+
targets = [
|
|
818
|
+
("capability_snapshot", _digest(value, "capability_snapshot_digest_invalid"))
|
|
819
|
+
for value in capability_snapshot_digests
|
|
820
|
+
]
|
|
821
|
+
targets.extend(("approval", _identifier(value, "approval_id_invalid")) for value in approval_ids)
|
|
822
|
+
if len(set(targets)) != len(targets):
|
|
823
|
+
raise ValueError("invalidation_targets_invalid")
|
|
824
|
+
inserted = 0
|
|
825
|
+
connection: sqlite3.Connection | None = None
|
|
826
|
+
try:
|
|
827
|
+
connection = self._connect()
|
|
828
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
829
|
+
for target_kind, target_id in sorted(targets):
|
|
830
|
+
payload = {
|
|
831
|
+
"boundary_digest": boundary,
|
|
832
|
+
"previous_identity_digest": previous,
|
|
833
|
+
"current_identity_digest": current,
|
|
834
|
+
"target_kind": target_kind,
|
|
835
|
+
"target_id": target_id,
|
|
836
|
+
"reason": normalized_reason.value,
|
|
837
|
+
"invalidated_at": observed,
|
|
838
|
+
}
|
|
839
|
+
invalidation_id = _payload_hash(_canonical_json(payload))
|
|
840
|
+
cursor = connection.execute(
|
|
841
|
+
"""INSERT OR IGNORE INTO authority_invalidations(
|
|
842
|
+
invalidation_id,boundary_digest,previous_identity_digest,
|
|
843
|
+
current_identity_digest,target_kind,target_id,reason,invalidated_at
|
|
844
|
+
) VALUES(?,?,?,?,?,?,?,?)""",
|
|
845
|
+
(
|
|
846
|
+
invalidation_id,
|
|
847
|
+
boundary,
|
|
848
|
+
previous,
|
|
849
|
+
current,
|
|
850
|
+
target_kind,
|
|
851
|
+
target_id,
|
|
852
|
+
normalized_reason.value,
|
|
853
|
+
observed,
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
inserted += cursor.rowcount
|
|
857
|
+
connection.commit()
|
|
858
|
+
return inserted
|
|
859
|
+
except sqlite3.Error as exc:
|
|
860
|
+
if connection is not None and connection.in_transaction:
|
|
861
|
+
connection.rollback()
|
|
862
|
+
raise _translate_database_error(exc) from exc
|
|
863
|
+
finally:
|
|
864
|
+
if connection is not None:
|
|
865
|
+
connection.close()
|
|
866
|
+
|
|
867
|
+
def invalidations(
|
|
868
|
+
self, boundary_digest: str, *, limit: int = MAX_EVENT_PAGE_SIZE
|
|
869
|
+
) -> tuple[LifecycleInvalidationRecord, ...]:
|
|
870
|
+
boundary = _digest(boundary_digest, "lifecycle_boundary_invalid")
|
|
871
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= MAX_EVENT_PAGE_SIZE:
|
|
872
|
+
raise ValueError("invalidations_limit_invalid")
|
|
873
|
+
try:
|
|
874
|
+
with self._connection() as connection:
|
|
875
|
+
rows = connection.execute(
|
|
876
|
+
"""SELECT * FROM authority_invalidations WHERE boundary_digest=?
|
|
877
|
+
ORDER BY invalidated_at,invalidation_id LIMIT ?""",
|
|
878
|
+
(boundary, limit),
|
|
879
|
+
).fetchall()
|
|
880
|
+
except sqlite3.Error as exc:
|
|
881
|
+
raise _translate_database_error(exc) from exc
|
|
882
|
+
try:
|
|
883
|
+
return tuple(
|
|
884
|
+
LifecycleInvalidationRecord(
|
|
885
|
+
invalidation_id=row["invalidation_id"],
|
|
886
|
+
boundary_digest=row["boundary_digest"],
|
|
887
|
+
previous_identity_digest=row["previous_identity_digest"],
|
|
888
|
+
current_identity_digest=row["current_identity_digest"],
|
|
889
|
+
target_kind=row["target_kind"],
|
|
890
|
+
target_id=row["target_id"],
|
|
891
|
+
reason=LifecycleReasonCode(row["reason"]),
|
|
892
|
+
invalidated_at=int(row["invalidated_at"]),
|
|
893
|
+
)
|
|
894
|
+
for row in rows
|
|
895
|
+
)
|
|
896
|
+
except (TypeError, ValueError) as exc:
|
|
897
|
+
raise LifecycleStorageError("lifecycle_storage_corrupt") from exc
|
|
898
|
+
|
|
899
|
+
@staticmethod
|
|
900
|
+
def _file_sha256(path: Path) -> str:
|
|
901
|
+
digest = hashlib.sha256()
|
|
902
|
+
try:
|
|
903
|
+
with path.open("rb") as stream:
|
|
904
|
+
while chunk := stream.read(1024 * 1024):
|
|
905
|
+
digest.update(chunk)
|
|
906
|
+
except OSError as exc:
|
|
907
|
+
raise LifecycleStorageError("lifecycle_backup_failed") from exc
|
|
908
|
+
return digest.hexdigest()
|
|
909
|
+
|
|
910
|
+
@staticmethod
|
|
911
|
+
def _atomic_private_write(path: Path, payload: bytes) -> None:
|
|
912
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
913
|
+
descriptor: int | None = None
|
|
914
|
+
try:
|
|
915
|
+
descriptor = os.open(
|
|
916
|
+
temporary,
|
|
917
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
|
|
918
|
+
0o600,
|
|
919
|
+
)
|
|
920
|
+
written = os.write(descriptor, payload)
|
|
921
|
+
if written != len(payload):
|
|
922
|
+
raise LifecycleStorageError("lifecycle_backup_failed")
|
|
923
|
+
os.fsync(descriptor)
|
|
924
|
+
os.close(descriptor)
|
|
925
|
+
descriptor = None
|
|
926
|
+
os.replace(temporary, path)
|
|
927
|
+
_secure_file(path)
|
|
928
|
+
except LifecycleStorageError:
|
|
929
|
+
raise
|
|
930
|
+
except OSError as exc:
|
|
931
|
+
raise LifecycleStorageError("lifecycle_backup_failed") from exc
|
|
932
|
+
finally:
|
|
933
|
+
if descriptor is not None:
|
|
934
|
+
os.close(descriptor)
|
|
935
|
+
try:
|
|
936
|
+
temporary.unlink(missing_ok=True)
|
|
937
|
+
except OSError:
|
|
938
|
+
pass
|
|
939
|
+
|
|
940
|
+
def create_verified_backup(self) -> tuple[Path, Path]:
|
|
941
|
+
backup = self.backup_path
|
|
942
|
+
marker = self.backup_marker_path
|
|
943
|
+
_secure_repository_directory(self.root, backup.parent)
|
|
944
|
+
temporary = backup.with_name(f".{backup.name}.{secrets.token_hex(8)}.tmp")
|
|
945
|
+
try:
|
|
946
|
+
_validate_database_file(backup)
|
|
947
|
+
with closing(
|
|
948
|
+
sqlite3.connect(self.path, timeout=self.busy_timeout_ms / 1_000)
|
|
949
|
+
) as source:
|
|
950
|
+
with closing(sqlite3.connect(temporary)) as destination:
|
|
951
|
+
source.backup(destination)
|
|
952
|
+
integrity = destination.execute("PRAGMA integrity_check").fetchone()
|
|
953
|
+
version = destination.execute(
|
|
954
|
+
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
955
|
+
).fetchone()
|
|
956
|
+
if integrity != ("ok",) or version != (LIFECYCLE_SCHEMA_VERSION,):
|
|
957
|
+
raise LifecycleStorageError("lifecycle_backup_failed")
|
|
958
|
+
_secure_file(temporary)
|
|
959
|
+
os.replace(temporary, backup)
|
|
960
|
+
_secure_file(backup)
|
|
961
|
+
evidence = _canonical_json(
|
|
962
|
+
{
|
|
963
|
+
"backup_sha256": self._file_sha256(backup),
|
|
964
|
+
"schema_version": LIFECYCLE_SCHEMA_VERSION,
|
|
965
|
+
}
|
|
966
|
+
).encode("utf-8")
|
|
967
|
+
self._atomic_private_write(marker, evidence)
|
|
968
|
+
return backup, marker
|
|
969
|
+
except LifecycleStorageError:
|
|
970
|
+
raise
|
|
971
|
+
except (OSError, sqlite3.Error) as exc:
|
|
972
|
+
raise LifecycleStorageError("lifecycle_backup_failed") from exc
|
|
973
|
+
finally:
|
|
974
|
+
try:
|
|
975
|
+
temporary.unlink(missing_ok=True)
|
|
976
|
+
except OSError:
|
|
977
|
+
pass
|