toolframe-core 0.6.2__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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"""Operator-only, read-only inventory of a stopped local application.
|
|
2
|
+
|
|
3
|
+
Keep :func:`stopped_application` open for the entire snapshot operation. The
|
|
4
|
+
serialized inventory describes files without configuration or credential values.
|
|
5
|
+
Protected environment locks can contain credentials and must remain private.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import re
|
|
15
|
+
import sqlite3
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import tomllib
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from urllib.parse import unquote, urlsplit
|
|
23
|
+
|
|
24
|
+
from packaging.markers import Marker
|
|
25
|
+
from packaging.specifiers import SpecifierSet
|
|
26
|
+
from packaging.tags import sys_tags
|
|
27
|
+
from packaging.utils import canonicalize_name, parse_wheel_filename
|
|
28
|
+
from packaging.version import Version
|
|
29
|
+
from toolframe_sdk import PluginError
|
|
30
|
+
|
|
31
|
+
from .config import Composition, ConfigFile, HostLock, validate_plugin_id
|
|
32
|
+
from .state import StateSettings
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DependencyWheel",
|
|
36
|
+
"EnvironmentLock",
|
|
37
|
+
"InventoryFile",
|
|
38
|
+
"RetainedKey",
|
|
39
|
+
"SQLiteStore",
|
|
40
|
+
"StoppedApplicationInventory",
|
|
41
|
+
"application_lock",
|
|
42
|
+
"stopped_application",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_HASH = re.compile(r"[a-f0-9]{64}")
|
|
46
|
+
_RUN = re.compile(r"[a-f0-9]{32}")
|
|
47
|
+
_DISTRIBUTION = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?")
|
|
48
|
+
_LOCAL_STORAGE = "storage-sqlite"
|
|
49
|
+
_LOCAL_STORAGE_FACTORY = "toolframe_storage_sqlite.plugin:SqliteProvider"
|
|
50
|
+
_LOCAL_PROVIDERS = {
|
|
51
|
+
"dashboard",
|
|
52
|
+
"mcp",
|
|
53
|
+
"llms",
|
|
54
|
+
"auth",
|
|
55
|
+
"dashboard-auth",
|
|
56
|
+
"mcp-auth",
|
|
57
|
+
"oidc-auth",
|
|
58
|
+
}
|
|
59
|
+
_OMIT = {
|
|
60
|
+
".toolframe/host.lock",
|
|
61
|
+
".toolframe/endpoint.json",
|
|
62
|
+
".toolframe/environments",
|
|
63
|
+
".toolframe/tmp",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class InventoryFile:
|
|
69
|
+
path: str
|
|
70
|
+
role: str
|
|
71
|
+
size: int
|
|
72
|
+
mode: int
|
|
73
|
+
sha256: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SQLiteStore:
|
|
78
|
+
path: str
|
|
79
|
+
owner: str | None
|
|
80
|
+
provider: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class RetainedKey:
|
|
85
|
+
path: str
|
|
86
|
+
owner: str | None
|
|
87
|
+
name: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class DependencyWheel:
|
|
92
|
+
environment_id: str
|
|
93
|
+
distribution: str
|
|
94
|
+
version: str
|
|
95
|
+
filename: str
|
|
96
|
+
sha256: str
|
|
97
|
+
path: str | None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class EnvironmentLock:
|
|
102
|
+
candidate_id: str
|
|
103
|
+
purpose: str
|
|
104
|
+
environment_id: str
|
|
105
|
+
sha256: str
|
|
106
|
+
python_version: str
|
|
107
|
+
python_implementation: str
|
|
108
|
+
platform_system: str
|
|
109
|
+
platform_machine: str
|
|
110
|
+
requires_python: str
|
|
111
|
+
lock: str = field(repr=False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class StoppedApplicationInventory:
|
|
116
|
+
home: Path
|
|
117
|
+
application_id: str
|
|
118
|
+
application_name: str
|
|
119
|
+
composition_revision: str
|
|
120
|
+
files: tuple[InventoryFile, ...]
|
|
121
|
+
stores: tuple[SQLiteStore, ...]
|
|
122
|
+
retained_keys: tuple[RetainedKey, ...]
|
|
123
|
+
dependency_wheels: tuple[DependencyWheel, ...]
|
|
124
|
+
environment_locks: tuple[EnvironmentLock, ...]
|
|
125
|
+
format_version: int = 1
|
|
126
|
+
status: str = "STOPPED"
|
|
127
|
+
|
|
128
|
+
def as_dict(self) -> dict:
|
|
129
|
+
"""Return manifest metadata with home-relative paths and no secret values."""
|
|
130
|
+
result = asdict(self)
|
|
131
|
+
result.pop("home")
|
|
132
|
+
# Arrays, rather than dataclass tuples, are part of the JSON contract.
|
|
133
|
+
for name in (
|
|
134
|
+
"files",
|
|
135
|
+
"stores",
|
|
136
|
+
"retained_keys",
|
|
137
|
+
"dependency_wheels",
|
|
138
|
+
"environment_locks",
|
|
139
|
+
):
|
|
140
|
+
result[name] = list(result[name])
|
|
141
|
+
for environment in result["environment_locks"]:
|
|
142
|
+
environment.pop("lock")
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fail(code="maintenance_layout_invalid", message="Application layout is invalid"):
|
|
147
|
+
raise PluginError(code, message) from None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _relative(value):
|
|
151
|
+
if (
|
|
152
|
+
not isinstance(value, str)
|
|
153
|
+
or not value
|
|
154
|
+
or "\\" in value
|
|
155
|
+
or PurePosixPath(value).is_absolute()
|
|
156
|
+
or any(part in {"", ".", ".."} for part in value.split("/"))
|
|
157
|
+
):
|
|
158
|
+
_fail(message="Application paths must be safe home-relative paths")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _path(home, value, *, required=True):
|
|
163
|
+
relative = _relative(value)
|
|
164
|
+
current = home
|
|
165
|
+
for part in PurePosixPath(relative).parts:
|
|
166
|
+
current = current / part
|
|
167
|
+
if current.is_symlink():
|
|
168
|
+
_fail(message="Symlinks are not supported in snapshot state")
|
|
169
|
+
if required and not current.is_file():
|
|
170
|
+
_fail(message="A required application file is missing")
|
|
171
|
+
return current
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _json(text):
|
|
175
|
+
return json.loads(text, parse_constant=lambda _: _fail())
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@contextmanager
|
|
179
|
+
def _database(home, relative):
|
|
180
|
+
path = _path(home, relative)
|
|
181
|
+
for suffix in ("-wal", "-journal"):
|
|
182
|
+
pending = _path(home, relative + suffix, required=False)
|
|
183
|
+
if pending.exists() and (not pending.is_file() or pending.stat().st_size):
|
|
184
|
+
_fail(
|
|
185
|
+
"maintenance_recovery_required",
|
|
186
|
+
"Cleanly stop SQLite and clear pending journals before maintenance",
|
|
187
|
+
)
|
|
188
|
+
# immutable avoids creating shared-memory/WAL files. Pending journals were
|
|
189
|
+
# explicitly refused above, so no committed pages can be silently omitted.
|
|
190
|
+
connection = sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True)
|
|
191
|
+
try:
|
|
192
|
+
connection.execute("PRAGMA query_only=ON")
|
|
193
|
+
if connection.execute("PRAGMA quick_check").fetchall() != [("ok",)]:
|
|
194
|
+
_fail(message="SQLite integrity validation failed")
|
|
195
|
+
yield connection
|
|
196
|
+
finally:
|
|
197
|
+
connection.close()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _processes():
|
|
201
|
+
try:
|
|
202
|
+
result = subprocess.run(
|
|
203
|
+
["/bin/ps", "-axo", "pid=,pgid=,command="],
|
|
204
|
+
check=True,
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=10,
|
|
208
|
+
)
|
|
209
|
+
rows = []
|
|
210
|
+
seen = set()
|
|
211
|
+
if result.returncode:
|
|
212
|
+
_fail("maintenance_ownership_unproven", "Process inventory failed")
|
|
213
|
+
for line in result.stdout.splitlines():
|
|
214
|
+
parts = line.split(None, 2)
|
|
215
|
+
if len(parts) != 3 or not all(part.isdigit() for part in parts[:2]):
|
|
216
|
+
_fail(
|
|
217
|
+
"maintenance_ownership_unproven", "Process inventory is incomplete"
|
|
218
|
+
)
|
|
219
|
+
pid, pgid = map(int, parts[:2])
|
|
220
|
+
if pid < 1 or pgid < 1 or pid in seen:
|
|
221
|
+
_fail("maintenance_ownership_unproven", "Process inventory is invalid")
|
|
222
|
+
seen.add(pid)
|
|
223
|
+
rows.append(parts[2])
|
|
224
|
+
if not rows:
|
|
225
|
+
_fail("maintenance_ownership_unproven", "Process inventory is unavailable")
|
|
226
|
+
return rows
|
|
227
|
+
except OSError, subprocess.SubprocessError, UnicodeError:
|
|
228
|
+
_fail(
|
|
229
|
+
"maintenance_ownership_unproven", "Cannot verify worker process ownership"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _ownership(metadata, plugins, composition):
|
|
234
|
+
run = metadata.get("host_run")
|
|
235
|
+
owners = metadata.get("worker_owners", {})
|
|
236
|
+
if (
|
|
237
|
+
not isinstance(run, str)
|
|
238
|
+
or not _RUN.fullmatch(run)
|
|
239
|
+
or not isinstance(owners, dict)
|
|
240
|
+
):
|
|
241
|
+
_fail(
|
|
242
|
+
"maintenance_ownership_unproven",
|
|
243
|
+
"Initialized worker ownership is unavailable",
|
|
244
|
+
)
|
|
245
|
+
# A stale entry is an unproven cleanup, even when its PID is absent. Recovery
|
|
246
|
+
# belongs to Core's lifecycle, not a snapshot reader.
|
|
247
|
+
if owners:
|
|
248
|
+
_fail(
|
|
249
|
+
"maintenance_ownership_unproven",
|
|
250
|
+
"Worker cleanup must be reconciled before maintenance",
|
|
251
|
+
)
|
|
252
|
+
runs = {run}
|
|
253
|
+
|
|
254
|
+
def observation(value, plugin):
|
|
255
|
+
if not isinstance(value, dict):
|
|
256
|
+
_fail("maintenance_ownership_unproven", "Worker observation is invalid")
|
|
257
|
+
if value == {}:
|
|
258
|
+
request = composition.plugins.get(plugin["id"])
|
|
259
|
+
if (
|
|
260
|
+
plugin["current_candidate"]
|
|
261
|
+
or plugin["previous_candidate"]
|
|
262
|
+
or (request is not None and request.enabled)
|
|
263
|
+
):
|
|
264
|
+
_fail(
|
|
265
|
+
"maintenance_ownership_unproven",
|
|
266
|
+
"A selected worker has no durable stop observation",
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
recorded = value.get("host_run")
|
|
270
|
+
if not isinstance(recorded, str) or not _RUN.fullmatch(recorded):
|
|
271
|
+
_fail("maintenance_ownership_unproven", "Worker observation is invalid")
|
|
272
|
+
runs.add(recorded)
|
|
273
|
+
if value.get("state") != "stopped":
|
|
274
|
+
_fail(
|
|
275
|
+
"maintenance_ownership_unproven",
|
|
276
|
+
"Worker stop was not durably confirmed",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
for plugin in plugins:
|
|
280
|
+
observation(_json(plugin["runtime"]), plugin)
|
|
281
|
+
for command in _processes():
|
|
282
|
+
if "toolframe_sdk.worker" in command and any(
|
|
283
|
+
re.search(r"(?:^|\s)--host-token\s+" + run + r"/", command) for run in runs
|
|
284
|
+
):
|
|
285
|
+
_fail(
|
|
286
|
+
"maintenance_ownership_unproven",
|
|
287
|
+
"An application worker is still present",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _artifact(home, relative, expected, artifacts):
|
|
292
|
+
if not isinstance(expected, str) or not _HASH.fullmatch(expected):
|
|
293
|
+
_fail(message="Immutable artifact checksum is missing")
|
|
294
|
+
relative = _relative(relative)
|
|
295
|
+
if not relative.startswith(f".toolframe/artifacts/{expected}/"):
|
|
296
|
+
_fail(message="Immutable artifacts must use the captured home layout")
|
|
297
|
+
path = _path(home, relative, required=False)
|
|
298
|
+
if not path.is_file():
|
|
299
|
+
_fail(
|
|
300
|
+
"maintenance_artifact_missing",
|
|
301
|
+
"Capture every required dependency wheel before maintenance",
|
|
302
|
+
)
|
|
303
|
+
with path.open("rb") as source:
|
|
304
|
+
checksum = hashlib.file_digest(source, "sha256").hexdigest()
|
|
305
|
+
if checksum != expected:
|
|
306
|
+
_fail(message="An immutable artifact checksum does not match")
|
|
307
|
+
artifacts[relative] = checksum
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _environment(home, value, artifacts, wheels):
|
|
311
|
+
if not isinstance(value, dict) or not isinstance(value.get("lock"), str):
|
|
312
|
+
_fail(message="A retained environment lock is missing")
|
|
313
|
+
identity = value.get("id")
|
|
314
|
+
if not isinstance(identity, str) or not _HASH.fullmatch(identity):
|
|
315
|
+
_fail(message="A retained environment identity is invalid")
|
|
316
|
+
if value.get("python") != f".toolframe/environments/{identity}/bin/python":
|
|
317
|
+
_fail(message="A retained environment path is invalid")
|
|
318
|
+
lock = tomllib.loads(value["lock"])
|
|
319
|
+
if lock.get("lock-version") != "1.0":
|
|
320
|
+
_fail(message="A retained environment lock format is unsupported")
|
|
321
|
+
compatibility = _compatibility(home, value, lock)
|
|
322
|
+
supported_tags = set(sys_tags())
|
|
323
|
+
packages = lock.get("packages")
|
|
324
|
+
if not isinstance(packages, list) or not packages:
|
|
325
|
+
_fail(message="A retained environment has no locked wheels")
|
|
326
|
+
for package in packages:
|
|
327
|
+
if not isinstance(package, dict) or any(
|
|
328
|
+
field in package for field in ("directory", "vcs")
|
|
329
|
+
):
|
|
330
|
+
_fail(
|
|
331
|
+
"maintenance_artifact_missing",
|
|
332
|
+
"Only fully captured wheel environments are supported",
|
|
333
|
+
)
|
|
334
|
+
direct = "archive" in package
|
|
335
|
+
if direct:
|
|
336
|
+
archive = package["archive"]
|
|
337
|
+
if (
|
|
338
|
+
not isinstance(archive, dict)
|
|
339
|
+
or any(field in package for field in ("wheels", "sdist"))
|
|
340
|
+
or "subdirectory" in archive
|
|
341
|
+
or ("path" in archive) == ("url" in archive)
|
|
342
|
+
):
|
|
343
|
+
_fail(message="A direct dependency wheel record is invalid")
|
|
344
|
+
wheel = dict(archive)
|
|
345
|
+
if "path" in archive:
|
|
346
|
+
if not isinstance(archive["path"], str) or not archive["path"]:
|
|
347
|
+
_fail(message="A direct dependency wheel path is invalid")
|
|
348
|
+
path = Path(archive["path"].replace("${TOOLFRAME_HOME}", str(home)))
|
|
349
|
+
# Restrict direct paths to captured source-home artifacts. The
|
|
350
|
+
# public lock stays exact, so restore consumers relocate paths.
|
|
351
|
+
if not path.is_absolute():
|
|
352
|
+
_fail(message="A direct dependency wheel path must be absolute")
|
|
353
|
+
wheel["url"] = path.as_uri()
|
|
354
|
+
package_wheels = [wheel]
|
|
355
|
+
else:
|
|
356
|
+
package_wheels = package.get("wheels")
|
|
357
|
+
if not isinstance(package_wheels, list) or not package_wheels:
|
|
358
|
+
_fail(
|
|
359
|
+
"maintenance_artifact_missing",
|
|
360
|
+
"Only fully captured wheel environments are supported",
|
|
361
|
+
)
|
|
362
|
+
marker = package.get("marker")
|
|
363
|
+
if marker is not None:
|
|
364
|
+
if not isinstance(marker, str):
|
|
365
|
+
_fail(message="A dependency marker is invalid")
|
|
366
|
+
if not Marker(marker).evaluate():
|
|
367
|
+
continue
|
|
368
|
+
if not isinstance(package.get("name"), str) or not isinstance(
|
|
369
|
+
package.get("version"), str
|
|
370
|
+
):
|
|
371
|
+
_fail(message="A dependency identity is missing")
|
|
372
|
+
if not _DISTRIBUTION.fullmatch(package["name"]):
|
|
373
|
+
_fail(message="A dependency identity is invalid")
|
|
374
|
+
version = Version(package["version"])
|
|
375
|
+
compatible_wheel = False
|
|
376
|
+
for wheel in package_wheels:
|
|
377
|
+
if (
|
|
378
|
+
not isinstance(wheel, dict)
|
|
379
|
+
or not isinstance(wheel.get("url"), str)
|
|
380
|
+
or not isinstance(wheel.get("hashes"), dict)
|
|
381
|
+
):
|
|
382
|
+
_fail(message="A dependency wheel record is invalid")
|
|
383
|
+
uri = urlsplit(wheel.get("url", "").replace("${TOOLFRAME_HOME}", str(home)))
|
|
384
|
+
checksum = wheel.get("hashes", {}).get("sha256")
|
|
385
|
+
filename = unquote(PurePosixPath(uri.path).name)
|
|
386
|
+
_relative(filename)
|
|
387
|
+
if "/" in filename:
|
|
388
|
+
_fail(message="A dependency wheel filename must be a basename")
|
|
389
|
+
if direct and not filename.endswith(".whl"):
|
|
390
|
+
_fail(
|
|
391
|
+
"maintenance_artifact_missing",
|
|
392
|
+
"Direct dependencies must be captured wheel archives",
|
|
393
|
+
)
|
|
394
|
+
wheel_name, wheel_version, _, tags = parse_wheel_filename(filename)
|
|
395
|
+
compatible_wheel |= bool(tags & supported_tags)
|
|
396
|
+
if (
|
|
397
|
+
wheel_name != canonicalize_name(package["name"])
|
|
398
|
+
or wheel_version != version
|
|
399
|
+
):
|
|
400
|
+
_fail(message="A dependency wheel differs from its locked package")
|
|
401
|
+
if (
|
|
402
|
+
not filename.endswith(".whl")
|
|
403
|
+
or not isinstance(checksum, str)
|
|
404
|
+
or not _HASH.fullmatch(checksum)
|
|
405
|
+
):
|
|
406
|
+
_fail(message="A dependency wheel must have a locked SHA256")
|
|
407
|
+
if uri.scheme == "file" and uri.netloc in ("", "localhost"):
|
|
408
|
+
try:
|
|
409
|
+
relative = Path(unquote(uri.path)).relative_to(home).as_posix()
|
|
410
|
+
except ValueError:
|
|
411
|
+
_fail(message="A dependency wheel escapes the application home")
|
|
412
|
+
elif uri.scheme == "https":
|
|
413
|
+
relative = f".toolframe/artifacts/{checksum}/{filename}"
|
|
414
|
+
if not _path(home, relative, required=False).exists():
|
|
415
|
+
relative = None
|
|
416
|
+
else:
|
|
417
|
+
_fail(
|
|
418
|
+
"maintenance_artifact_missing",
|
|
419
|
+
"A dependency wheel is not captured locally",
|
|
420
|
+
)
|
|
421
|
+
if relative is not None:
|
|
422
|
+
_artifact(home, relative, checksum, artifacts)
|
|
423
|
+
elif direct:
|
|
424
|
+
_fail(
|
|
425
|
+
"maintenance_artifact_missing",
|
|
426
|
+
"A direct dependency wheel is not captured locally",
|
|
427
|
+
)
|
|
428
|
+
item = DependencyWheel(
|
|
429
|
+
identity,
|
|
430
|
+
package["name"],
|
|
431
|
+
package["version"],
|
|
432
|
+
filename,
|
|
433
|
+
checksum,
|
|
434
|
+
relative,
|
|
435
|
+
)
|
|
436
|
+
wheels[(identity, filename, checksum)] = item
|
|
437
|
+
if not compatible_wheel:
|
|
438
|
+
_fail(
|
|
439
|
+
message="Recorded dependency wheels do not support the source inspection platform"
|
|
440
|
+
)
|
|
441
|
+
return compatibility
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _compatibility(home, environment, lock):
|
|
445
|
+
config = _path(home, f".toolframe/environments/{environment['id']}/pyvenv.cfg")
|
|
446
|
+
settings = {}
|
|
447
|
+
for line in config.read_text().splitlines():
|
|
448
|
+
if not line.strip():
|
|
449
|
+
continue
|
|
450
|
+
key, separator, value = line.partition("=")
|
|
451
|
+
key = key.strip()
|
|
452
|
+
if not separator or key in settings:
|
|
453
|
+
_fail(message="Source interpreter metadata is invalid")
|
|
454
|
+
settings[key] = value.strip()
|
|
455
|
+
implementation = settings.get("implementation")
|
|
456
|
+
inspector_version = Version(platform.python_version())
|
|
457
|
+
versions = []
|
|
458
|
+
for name in ("version_info", "version"):
|
|
459
|
+
if name not in settings:
|
|
460
|
+
continue
|
|
461
|
+
if not re.fullmatch(r"[0-9]+\.[0-9]+(?:\.[0-9]+)?", settings[name]):
|
|
462
|
+
_fail(message="Source interpreter version metadata is invalid")
|
|
463
|
+
recorded = Version(settings[name])
|
|
464
|
+
precision = len(recorded.release)
|
|
465
|
+
if (precision == 2 and recorded.release != inspector_version.release[:2]) or (
|
|
466
|
+
precision == 3 and recorded != inspector_version
|
|
467
|
+
):
|
|
468
|
+
_fail(message="Use a compatible source interpreter for offline maintenance")
|
|
469
|
+
versions.append(recorded)
|
|
470
|
+
if not versions or implementation != platform.python_implementation():
|
|
471
|
+
_fail(message="Use a compatible source interpreter for offline maintenance")
|
|
472
|
+
# uv can record only major/minor. Preserve that uncertainty in the public
|
|
473
|
+
# metadata; use a full recorded version when either field supplies one.
|
|
474
|
+
version = str(max(versions, key=lambda item: len(item.release)))
|
|
475
|
+
requires_python = lock.get("requires-python", "")
|
|
476
|
+
environments = lock.get("environments", [])
|
|
477
|
+
if (
|
|
478
|
+
not isinstance(requires_python, str)
|
|
479
|
+
or not isinstance(environments, list)
|
|
480
|
+
or any(not isinstance(marker, str) for marker in environments)
|
|
481
|
+
):
|
|
482
|
+
_fail(message="Environment compatibility markers are invalid")
|
|
483
|
+
if not SpecifierSet(requires_python).contains(inspector_version) or (
|
|
484
|
+
environments and not any(Marker(marker).evaluate() for marker in environments)
|
|
485
|
+
):
|
|
486
|
+
_fail(
|
|
487
|
+
message="The recorded lock does not support the source inspection platform"
|
|
488
|
+
)
|
|
489
|
+
return (
|
|
490
|
+
version,
|
|
491
|
+
implementation,
|
|
492
|
+
platform.system(),
|
|
493
|
+
platform.machine(),
|
|
494
|
+
requires_python,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _inventory(home):
|
|
499
|
+
try:
|
|
500
|
+
composition, revision = ConfigFile(home).read()
|
|
501
|
+
except PluginError:
|
|
502
|
+
_fail(message="Application composition is invalid")
|
|
503
|
+
lock = _json(_path(home, "toolframe.lock").read_text())
|
|
504
|
+
if (
|
|
505
|
+
not isinstance(lock, dict)
|
|
506
|
+
or lock.get("format") != 1
|
|
507
|
+
or not isinstance(lock.get("plugins"), dict)
|
|
508
|
+
):
|
|
509
|
+
_fail(message="Application lock format is unsupported")
|
|
510
|
+
with _database(home, "data/core.sqlite") as database:
|
|
511
|
+
database.row_factory = sqlite3.Row
|
|
512
|
+
if database.execute("PRAGMA user_version").fetchone()[0] not in (1, 2):
|
|
513
|
+
_fail(message="Initialized Core journal schema is unsupported")
|
|
514
|
+
metadata = {
|
|
515
|
+
row[0]: _json(row[1])
|
|
516
|
+
for row in database.execute("SELECT key,value FROM metadata")
|
|
517
|
+
}
|
|
518
|
+
plugins = [
|
|
519
|
+
dict(row) for row in database.execute("SELECT * FROM plugins ORDER BY id")
|
|
520
|
+
]
|
|
521
|
+
_ownership(metadata, plugins, composition)
|
|
522
|
+
if database.execute(
|
|
523
|
+
"SELECT 1 FROM operations WHERE status IN ('queued','running','cancelling') LIMIT 1"
|
|
524
|
+
).fetchone() or metadata.get("forward_repair"):
|
|
525
|
+
_fail(
|
|
526
|
+
"maintenance_recovery_required",
|
|
527
|
+
"Resolve interrupted management work before maintenance",
|
|
528
|
+
)
|
|
529
|
+
previous = metadata.get("last_valid_composition")
|
|
530
|
+
if (
|
|
531
|
+
not isinstance(previous, dict)
|
|
532
|
+
or previous.get("application", {}).get("id") != composition.application.id
|
|
533
|
+
):
|
|
534
|
+
_fail(message="Composition and initialized journal identities differ")
|
|
535
|
+
desired_revision = lock.get("desired_revision")
|
|
536
|
+
if (
|
|
537
|
+
not isinstance(desired_revision, str)
|
|
538
|
+
or not _HASH.fullmatch(desired_revision)
|
|
539
|
+
or desired_revision != metadata.get("desired_revision")
|
|
540
|
+
):
|
|
541
|
+
_fail(message="Composition lock and journal revisions differ")
|
|
542
|
+
if revision != desired_revision:
|
|
543
|
+
_fail(
|
|
544
|
+
"maintenance_recovery_required",
|
|
545
|
+
"Apply the current composition and cleanly stop Core before maintenance",
|
|
546
|
+
)
|
|
547
|
+
recorded = Composition.model_validate(previous)
|
|
548
|
+
effective = composition.model_copy(deep=True)
|
|
549
|
+
wheelhouse = os.environ.get("TOOLFRAME_WHEELHOUSE")
|
|
550
|
+
if wheelhouse and wheelhouse not in effective.packages.find_links:
|
|
551
|
+
effective.packages.find_links.append(wheelhouse)
|
|
552
|
+
if os.environ.get("TOOLFRAME_OFFLINE") == "1":
|
|
553
|
+
effective.packages.offline = True
|
|
554
|
+
if recorded.model_dump() != effective.model_dump():
|
|
555
|
+
_fail(
|
|
556
|
+
message="Recorded composition differs from current runtime configuration"
|
|
557
|
+
)
|
|
558
|
+
if set(lock["plugins"]) != set(composition.plugins):
|
|
559
|
+
_fail(message="Composition plugin coverage differs from its lock")
|
|
560
|
+
candidates = {}
|
|
561
|
+
for plugin in plugins:
|
|
562
|
+
validate_plugin_id(plugin["id"])
|
|
563
|
+
request = composition.plugins.get(plugin["id"])
|
|
564
|
+
if request is not None and (
|
|
565
|
+
not any(
|
|
566
|
+
plugin[selection]
|
|
567
|
+
for selection in (
|
|
568
|
+
"current_candidate",
|
|
569
|
+
"last_candidate",
|
|
570
|
+
"previous_candidate",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
or (request.enabled and not plugin["current_candidate"])
|
|
574
|
+
):
|
|
575
|
+
_fail(message="A configured plugin has no complete candidate selection")
|
|
576
|
+
for selection in (
|
|
577
|
+
"current_candidate",
|
|
578
|
+
"previous_candidate",
|
|
579
|
+
"last_candidate",
|
|
580
|
+
):
|
|
581
|
+
identifier = plugin[selection]
|
|
582
|
+
if identifier:
|
|
583
|
+
if not isinstance(identifier, str) or not _HASH.fullmatch(
|
|
584
|
+
identifier
|
|
585
|
+
):
|
|
586
|
+
_fail(message="A retained candidate identity is invalid")
|
|
587
|
+
row = database.execute(
|
|
588
|
+
"SELECT data FROM candidates WHERE id=? AND plugin_id=?",
|
|
589
|
+
(identifier, plugin["id"]),
|
|
590
|
+
).fetchone()
|
|
591
|
+
if row is None:
|
|
592
|
+
_fail(message="A retained candidate is missing")
|
|
593
|
+
candidate = _json(row[0])
|
|
594
|
+
if (
|
|
595
|
+
candidate.get("id") != identifier
|
|
596
|
+
or candidate.get("plugin_id") != plugin["id"]
|
|
597
|
+
):
|
|
598
|
+
_fail(message="A retained candidate identity is invalid")
|
|
599
|
+
candidates[identifier] = candidate
|
|
600
|
+
locked = lock["plugins"].get(plugin["id"], {}).get(selection)
|
|
601
|
+
if plugin["id"] in lock["plugins"] and bool(locked) != bool(identifier):
|
|
602
|
+
_fail(message="A locked candidate differs from the journal")
|
|
603
|
+
if locked and any(
|
|
604
|
+
locked.get(key) != candidate.get(key)
|
|
605
|
+
for key in (
|
|
606
|
+
"id",
|
|
607
|
+
"version",
|
|
608
|
+
"artifact",
|
|
609
|
+
"artifact_hash",
|
|
610
|
+
"source",
|
|
611
|
+
"environment",
|
|
612
|
+
)
|
|
613
|
+
):
|
|
614
|
+
_fail(message="A locked candidate differs from the journal")
|
|
615
|
+
if (
|
|
616
|
+
locked
|
|
617
|
+
and "ui_environment" in locked
|
|
618
|
+
and locked["ui_environment"] != candidate.get("ui_environment")
|
|
619
|
+
):
|
|
620
|
+
_fail(message="A locked UI environment differs from the journal")
|
|
621
|
+
if set(composition.plugins) - {plugin["id"] for plugin in plugins}:
|
|
622
|
+
_fail(message="A locked plugin is missing from the journal")
|
|
623
|
+
|
|
624
|
+
artifacts, definitions, wheels, environments = {}, {}, {}, []
|
|
625
|
+
for candidate in candidates.values():
|
|
626
|
+
definition = candidate["definition"]
|
|
627
|
+
if (
|
|
628
|
+
not isinstance(definition, dict)
|
|
629
|
+
or definition.get("id") != candidate["plugin_id"]
|
|
630
|
+
):
|
|
631
|
+
_fail(message="A retained plugin definition is invalid")
|
|
632
|
+
kind = definition.get("provider_kind")
|
|
633
|
+
if (
|
|
634
|
+
definition.get("runtime_validation") == "separate_application"
|
|
635
|
+
or (
|
|
636
|
+
kind == "storage"
|
|
637
|
+
and (
|
|
638
|
+
definition["id"] != _LOCAL_STORAGE
|
|
639
|
+
or definition.get("factory") != _LOCAL_STORAGE_FACTORY
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
or (kind is not None and kind not in _LOCAL_PROVIDERS | {"storage"})
|
|
643
|
+
):
|
|
644
|
+
_fail(
|
|
645
|
+
"maintenance_external_state",
|
|
646
|
+
"External or unknown provider-managed state cannot be captured by local maintenance",
|
|
647
|
+
)
|
|
648
|
+
definitions.setdefault(definition["id"], []).append(definition)
|
|
649
|
+
_artifact(home, candidate["artifact"], candidate["artifact_hash"], artifacts)
|
|
650
|
+
for purpose, environment_field in (
|
|
651
|
+
("backend", "environment"),
|
|
652
|
+
("ui", "ui_environment"),
|
|
653
|
+
):
|
|
654
|
+
if environment_field == "environment" or candidate.get(environment_field):
|
|
655
|
+
environment = candidate[environment_field]
|
|
656
|
+
compatibility = _environment(home, environment, artifacts, wheels)
|
|
657
|
+
environments.append(
|
|
658
|
+
EnvironmentLock(
|
|
659
|
+
candidate["id"],
|
|
660
|
+
purpose,
|
|
661
|
+
environment["id"],
|
|
662
|
+
hashlib.sha256(environment["lock"].encode()).hexdigest(),
|
|
663
|
+
*compatibility,
|
|
664
|
+
environment["lock"],
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
bindings = metadata.get("store_bindings", {})
|
|
669
|
+
if not isinstance(bindings, dict):
|
|
670
|
+
_fail(message="Retained store bindings are invalid")
|
|
671
|
+
stores = [SQLiteStore("data/core.sqlite", None, "core")]
|
|
672
|
+
keys = []
|
|
673
|
+
for owner, provider in bindings.items():
|
|
674
|
+
validate_plugin_id(owner)
|
|
675
|
+
if (
|
|
676
|
+
owner not in definitions
|
|
677
|
+
or provider != _LOCAL_STORAGE
|
|
678
|
+
or provider not in definitions
|
|
679
|
+
):
|
|
680
|
+
_fail(
|
|
681
|
+
"maintenance_external_state",
|
|
682
|
+
"A retained store has external or uncertain ownership",
|
|
683
|
+
)
|
|
684
|
+
relative = f"data/plugins/{owner}/data.sqlite"
|
|
685
|
+
with _database(home, relative) as database:
|
|
686
|
+
identity = database.execute(
|
|
687
|
+
"SELECT owner,application_id FROM _toolframe_identity"
|
|
688
|
+
).fetchall()
|
|
689
|
+
if identity != [(owner, composition.application.id)]:
|
|
690
|
+
_fail(message="A business store identity differs from the application")
|
|
691
|
+
stores.append(SQLiteStore(relative, owner, provider))
|
|
692
|
+
for owner, values in definitions.items():
|
|
693
|
+
for name in {
|
|
694
|
+
name
|
|
695
|
+
for definition in values
|
|
696
|
+
for name in definition.get("private_secrets", [])
|
|
697
|
+
}:
|
|
698
|
+
validate_plugin_id(name)
|
|
699
|
+
relative = f".toolframe/secrets/{owner}/{name}.key"
|
|
700
|
+
path = _path(home, relative, required=False)
|
|
701
|
+
if path.exists():
|
|
702
|
+
if not path.is_file() or path.stat().st_mode & 0o077:
|
|
703
|
+
_fail(
|
|
704
|
+
message="Retained private keys require owner-only regular files"
|
|
705
|
+
)
|
|
706
|
+
keys.append(RetainedKey(relative, owner, name))
|
|
707
|
+
elif owner in bindings:
|
|
708
|
+
_fail(message="A retained business store private key is missing")
|
|
709
|
+
|
|
710
|
+
roles = {"toolframe.toml": "composition", "toolframe.lock": "lock"}
|
|
711
|
+
roles.update(
|
|
712
|
+
{
|
|
713
|
+
item.path: "core_store" if item.owner is None else "business_store"
|
|
714
|
+
for item in stores
|
|
715
|
+
}
|
|
716
|
+
)
|
|
717
|
+
roles.update({path: "artifact" for path in artifacts})
|
|
718
|
+
roles.update({item.path: "retained_key" for item in keys})
|
|
719
|
+
files = []
|
|
720
|
+
omitted = _OMIT | {
|
|
721
|
+
store.path + suffix
|
|
722
|
+
for store in stores
|
|
723
|
+
for suffix in ("-shm", "-wal", "-journal")
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
def walk(directory):
|
|
727
|
+
for path in sorted(directory.iterdir()):
|
|
728
|
+
relative = path.relative_to(home).as_posix()
|
|
729
|
+
mode = path.lstat().st_mode
|
|
730
|
+
if stat.S_ISLNK(mode):
|
|
731
|
+
_fail(message="Symlinks are not supported in snapshot state")
|
|
732
|
+
parts = PurePosixPath(relative).parts
|
|
733
|
+
if (
|
|
734
|
+
len(parts) == 4
|
|
735
|
+
and parts[:2] == ("data", "plugins")
|
|
736
|
+
and parts[3]
|
|
737
|
+
in {
|
|
738
|
+
"data.sqlite",
|
|
739
|
+
"data.sqlite-wal",
|
|
740
|
+
"data.sqlite-journal",
|
|
741
|
+
"data.sqlite-shm",
|
|
742
|
+
}
|
|
743
|
+
and "/".join((*parts[:3], "data.sqlite")) not in roles
|
|
744
|
+
):
|
|
745
|
+
_fail(
|
|
746
|
+
"maintenance_external_state",
|
|
747
|
+
"A business store has no retained provider binding",
|
|
748
|
+
)
|
|
749
|
+
if relative in omitted or stat.S_ISSOCK(mode):
|
|
750
|
+
continue
|
|
751
|
+
if stat.S_ISDIR(mode):
|
|
752
|
+
walk(path)
|
|
753
|
+
elif stat.S_ISREG(mode):
|
|
754
|
+
role = roles.get(relative, "home_file")
|
|
755
|
+
if relative == ".toolframe/management.token" or relative.startswith(
|
|
756
|
+
".toolframe/secrets/"
|
|
757
|
+
):
|
|
758
|
+
role = "retained_key"
|
|
759
|
+
if mode & 0o077:
|
|
760
|
+
_fail(
|
|
761
|
+
message="Retained credentials require owner-only regular files"
|
|
762
|
+
)
|
|
763
|
+
if relative not in {key.path for key in keys}:
|
|
764
|
+
keys.append(
|
|
765
|
+
RetainedKey(
|
|
766
|
+
relative,
|
|
767
|
+
None,
|
|
768
|
+
"management"
|
|
769
|
+
if relative.endswith("management.token")
|
|
770
|
+
else path.stem,
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
files.append(
|
|
774
|
+
InventoryFile(
|
|
775
|
+
relative,
|
|
776
|
+
role,
|
|
777
|
+
path.stat().st_size,
|
|
778
|
+
stat.S_IMODE(mode),
|
|
779
|
+
artifacts.get(relative),
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
else:
|
|
783
|
+
_fail(message="Only regular in-home snapshot files are supported")
|
|
784
|
+
|
|
785
|
+
walk(home)
|
|
786
|
+
return StoppedApplicationInventory(
|
|
787
|
+
home,
|
|
788
|
+
composition.application.id,
|
|
789
|
+
composition.application.name,
|
|
790
|
+
revision,
|
|
791
|
+
tuple(files),
|
|
792
|
+
tuple(stores),
|
|
793
|
+
tuple(sorted(keys, key=lambda item: item.path)),
|
|
794
|
+
tuple(wheels[key] for key in sorted(wheels)),
|
|
795
|
+
tuple(environments),
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _home(home):
|
|
800
|
+
home = Path(os.path.abspath(Path(home).expanduser()))
|
|
801
|
+
if home.is_symlink() or not home.is_dir():
|
|
802
|
+
_fail(message="An existing regular application home is required")
|
|
803
|
+
return home.resolve()
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@contextmanager
|
|
807
|
+
def application_lock(home: str | Path):
|
|
808
|
+
"""Exclusively lock an existing home, without asserting worker/state safety.
|
|
809
|
+
|
|
810
|
+
Creates only HostLock's directory/PID file. A restore consumer may keep this
|
|
811
|
+
context open while moving its own new staging home and rebuilding runtimes.
|
|
812
|
+
Use ``stopped_application`` to validate and snapshot an initialized source.
|
|
813
|
+
"""
|
|
814
|
+
home = _home(home)
|
|
815
|
+
directory = _path(home, ".toolframe", required=False)
|
|
816
|
+
if directory.exists() and not directory.is_dir():
|
|
817
|
+
_fail(message="The application lock directory must be a regular directory")
|
|
818
|
+
lock_path = _path(home, ".toolframe/host.lock", required=False)
|
|
819
|
+
if lock_path.exists() and not lock_path.is_file():
|
|
820
|
+
_fail(message="The application lock must be a regular file")
|
|
821
|
+
lock = HostLock(home)
|
|
822
|
+
try:
|
|
823
|
+
try:
|
|
824
|
+
lock.acquire()
|
|
825
|
+
except OSError:
|
|
826
|
+
_fail(message="The application lock cannot be acquired")
|
|
827
|
+
yield None
|
|
828
|
+
finally:
|
|
829
|
+
lock.close()
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
@contextmanager
|
|
833
|
+
def stopped_application(home: str | Path):
|
|
834
|
+
"""Hold the real HostLock while inventorying and snapshotting a stopped home.
|
|
835
|
+
|
|
836
|
+
Raises ``PluginError`` for running, uncertain, external, incomplete or unsafe
|
|
837
|
+
layouts. This never starts workers, repairs state, or migrates databases.
|
|
838
|
+
``home`` must have completed a Core startup and clean shutdown.
|
|
839
|
+
"""
|
|
840
|
+
home = _home(home)
|
|
841
|
+
if StateSettings.from_env().backend != "sqlite":
|
|
842
|
+
_fail(
|
|
843
|
+
"maintenance_external_state",
|
|
844
|
+
"External Core state is unsupported by local maintenance",
|
|
845
|
+
)
|
|
846
|
+
for relative in (
|
|
847
|
+
"toolframe.toml",
|
|
848
|
+
"toolframe.lock",
|
|
849
|
+
"data/core.sqlite",
|
|
850
|
+
".toolframe/management.token",
|
|
851
|
+
):
|
|
852
|
+
_path(home, relative)
|
|
853
|
+
with application_lock(home):
|
|
854
|
+
try:
|
|
855
|
+
inventory = _inventory(home)
|
|
856
|
+
except (
|
|
857
|
+
OSError,
|
|
858
|
+
ValueError,
|
|
859
|
+
TypeError,
|
|
860
|
+
KeyError,
|
|
861
|
+
AttributeError,
|
|
862
|
+
sqlite3.Error,
|
|
863
|
+
RecursionError,
|
|
864
|
+
):
|
|
865
|
+
# Parser/driver errors can contain stored credentials; expose only
|
|
866
|
+
# the stable operator error, never persisted values or SQL rows.
|
|
867
|
+
_fail()
|
|
868
|
+
yield inventory
|