forkcell 0.1.0a0__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.
- forkcell/__init__.py +7 -0
- forkcell/api.py +191 -0
- forkcell/checkpoint.py +148 -0
- forkcell/cli.py +3676 -0
- forkcell/native.py +618 -0
- forkcell/overlay.py +363 -0
- forkcell/volume.py +567 -0
- forkcell-0.1.0a0.dist-info/METADATA +216 -0
- forkcell-0.1.0a0.dist-info/RECORD +14 -0
- forkcell-0.1.0a0.dist-info/WHEEL +5 -0
- forkcell-0.1.0a0.dist-info/entry_points.txt +2 -0
- forkcell-0.1.0a0.dist-info/licenses/LICENSE +51 -0
- forkcell-0.1.0a0.dist-info/licenses/NOTICE +12 -0
- forkcell-0.1.0a0.dist-info/top_level.txt +1 -0
forkcell/__init__.py
ADDED
forkcell/api.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable, Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ForkCellCommandError(RuntimeError):
|
|
14
|
+
def __init__(self, args: list[str], returncode: int, stdout: str, stderr: str) -> None:
|
|
15
|
+
self.args_list = args
|
|
16
|
+
self.returncode = returncode
|
|
17
|
+
self.stdout = stdout
|
|
18
|
+
self.stderr = stderr
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"forkcell command failed ({returncode}): {' '.join(args)}\nstdout:\n{stdout}\nstderr:\n{stderr}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CommandResult:
|
|
26
|
+
args: list[str]
|
|
27
|
+
returncode: int
|
|
28
|
+
stdout: str
|
|
29
|
+
stderr: str
|
|
30
|
+
json: dict[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _extract_json_object(text: str) -> dict[str, Any]:
|
|
34
|
+
decoder = json.JSONDecoder()
|
|
35
|
+
for index, char in enumerate(text):
|
|
36
|
+
if char != "{":
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
value, end = decoder.raw_decode(text[index:])
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
continue
|
|
42
|
+
if isinstance(value, dict) and not text[index + end :].strip():
|
|
43
|
+
return value
|
|
44
|
+
raise ValueError("missing JSON object in command output")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _normalize_command(command: str | Iterable[str]) -> list[str]:
|
|
48
|
+
if isinstance(command, str):
|
|
49
|
+
return ["sh", "-lc", command]
|
|
50
|
+
return [str(part) for part in command]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
LEGACY_BACKEND_ALIASES = {
|
|
54
|
+
"openshell-native-overlay": "native-overlay",
|
|
55
|
+
"openshell-layer-clone": "layer-clone",
|
|
56
|
+
"openshell-volume": "volume-delta",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_backend(backend: str) -> str:
|
|
61
|
+
return LEGACY_BACKEND_ALIASES.get(backend, backend)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ForkCellClient:
|
|
65
|
+
"""Small Python facade for agent-style ForkCell workflows.
|
|
66
|
+
|
|
67
|
+
The facade intentionally shells out to `python -m forkcell.cli` so the API
|
|
68
|
+
and CLI share the same state, receipts, and review artifacts during the
|
|
69
|
+
current governed-runtime integration.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*,
|
|
75
|
+
root: str | Path | None = None,
|
|
76
|
+
python: str | None = None,
|
|
77
|
+
env: Mapping[str, str] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.root = Path(root or Path.cwd()).resolve()
|
|
80
|
+
self.python = python or sys.executable
|
|
81
|
+
self.env = dict(os.environ)
|
|
82
|
+
if env:
|
|
83
|
+
self.env.update(env)
|
|
84
|
+
|
|
85
|
+
def cli(self, args: list[str], *, check: bool = True) -> CommandResult:
|
|
86
|
+
full_args = [self.python, "-m", "forkcell.cli", *args]
|
|
87
|
+
proc = subprocess.run(full_args, cwd=self.root, env=self.env, text=True, capture_output=True)
|
|
88
|
+
parsed: dict[str, Any] = {}
|
|
89
|
+
output = proc.stdout.strip()
|
|
90
|
+
if output:
|
|
91
|
+
try:
|
|
92
|
+
parsed = _extract_json_object(output)
|
|
93
|
+
except ValueError:
|
|
94
|
+
parsed = {}
|
|
95
|
+
if check and proc.returncode != 0:
|
|
96
|
+
raise ForkCellCommandError(full_args, proc.returncode, proc.stdout, proc.stderr)
|
|
97
|
+
return CommandResult(
|
|
98
|
+
args=full_args,
|
|
99
|
+
returncode=proc.returncode,
|
|
100
|
+
stdout=proc.stdout,
|
|
101
|
+
stderr=proc.stderr,
|
|
102
|
+
json=parsed,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def create_native_cell(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
source: str | Path,
|
|
109
|
+
name: str | None = None,
|
|
110
|
+
backend: str = "native-overlay",
|
|
111
|
+
) -> "ForkCellSandbox":
|
|
112
|
+
cell = name or f"fc-api-{uuid.uuid4().hex[:8]}"
|
|
113
|
+
self.cli(["native", "init", cell, "--from", str(Path(source).resolve())])
|
|
114
|
+
return ForkCellSandbox(client=self, name=cell, backend=_normalize_backend(backend))
|
|
115
|
+
|
|
116
|
+
def native_cell(self, name: str, *, backend: str = "native-overlay") -> "ForkCellSandbox":
|
|
117
|
+
return ForkCellSandbox(client=self, name=name, backend=_normalize_backend(backend))
|
|
118
|
+
|
|
119
|
+
def review_status(self) -> dict[str, Any]:
|
|
120
|
+
return self.cli(["review", "status", "--format", "json"]).json
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class ForkCellSandbox:
|
|
125
|
+
client: ForkCellClient
|
|
126
|
+
name: str
|
|
127
|
+
backend: str = "native-overlay"
|
|
128
|
+
auto_delete: bool = True
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
self.backend = _normalize_backend(self.backend)
|
|
132
|
+
|
|
133
|
+
def __enter__(self) -> "ForkCellSandbox":
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
137
|
+
if self.auto_delete:
|
|
138
|
+
self.delete(check=False)
|
|
139
|
+
|
|
140
|
+
def status(self) -> dict[str, Any]:
|
|
141
|
+
return self.client.cli(["native", "status", self.name]).json
|
|
142
|
+
|
|
143
|
+
def checkpoint(self, *, name: str | None = None) -> dict[str, Any]:
|
|
144
|
+
args = ["native", "checkpoint", self.name]
|
|
145
|
+
if name:
|
|
146
|
+
args.extend(["--name", name])
|
|
147
|
+
return self.client.cli(args).json
|
|
148
|
+
|
|
149
|
+
def restore(self, checkpoint: str | None = None) -> dict[str, Any]:
|
|
150
|
+
args = ["native", "restore", self.name]
|
|
151
|
+
if checkpoint:
|
|
152
|
+
args.append(checkpoint)
|
|
153
|
+
return self.client.cli(args).json
|
|
154
|
+
|
|
155
|
+
def run(
|
|
156
|
+
self,
|
|
157
|
+
command: str | Iterable[str],
|
|
158
|
+
*,
|
|
159
|
+
checkpoint_before: bool = False,
|
|
160
|
+
checkpoint_name: str | None = None,
|
|
161
|
+
restore_on_fail: bool = False,
|
|
162
|
+
policy: str | Path | None = None,
|
|
163
|
+
logs_since: str = "5m",
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
if self.backend == "native-overlay":
|
|
166
|
+
args = ["native", "run"]
|
|
167
|
+
elif self.backend == "layer-clone":
|
|
168
|
+
args = ["native", "run-layer"]
|
|
169
|
+
else:
|
|
170
|
+
args = ["run", self.name, "--backend", self.backend]
|
|
171
|
+
if checkpoint_before:
|
|
172
|
+
args.append("--checkpoint-before")
|
|
173
|
+
if checkpoint_name:
|
|
174
|
+
args.extend(["--checkpoint-name", checkpoint_name])
|
|
175
|
+
if restore_on_fail:
|
|
176
|
+
args.append("--restore-on-fail")
|
|
177
|
+
if policy:
|
|
178
|
+
args.extend(["--policy", str(policy)])
|
|
179
|
+
if logs_since:
|
|
180
|
+
args.extend(["--logs-since", logs_since])
|
|
181
|
+
if self.backend in {"native-overlay", "layer-clone"}:
|
|
182
|
+
args.append(self.name)
|
|
183
|
+
args.extend(["--", *_normalize_command(command)])
|
|
184
|
+
run = self.client.cli(args).json
|
|
185
|
+
receipt = Path(run.get("receipt", ""))
|
|
186
|
+
if receipt.exists():
|
|
187
|
+
return json.loads(receipt.read_text())
|
|
188
|
+
return run
|
|
189
|
+
|
|
190
|
+
def delete(self, *, check: bool = True) -> dict[str, Any]:
|
|
191
|
+
return self.client.cli(["native", "delete", self.name], check=check).json
|
forkcell/checkpoint.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shlex
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenShellRunner(Protocol):
|
|
13
|
+
def __call__(self, args: list[str], *, check: bool = False) -> Any:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CheckpointArtifact:
|
|
19
|
+
path: Path
|
|
20
|
+
sha256: str
|
|
21
|
+
metrics: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256_file(path: Path) -> str:
|
|
25
|
+
h = hashlib.sha256()
|
|
26
|
+
with path.open("rb") as f:
|
|
27
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
28
|
+
h.update(chunk)
|
|
29
|
+
return h.hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def artifact_name(checkpoint_id: str) -> str:
|
|
33
|
+
return f"{checkpoint_id}.tgz"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def remote_path(workspace: str, name: str) -> str:
|
|
37
|
+
return f"{workspace.rstrip('/')}/{name}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckpointProvider(Protocol):
|
|
41
|
+
provider_name: str
|
|
42
|
+
|
|
43
|
+
def create(self, *, cell_name: str, workspace: str, checkpoint_id: str) -> CheckpointArtifact:
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def restore(self, *, cell_name: str, workspace: str, artifact: Path) -> dict[str, Any]:
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OpenShellTarFullProvider:
|
|
51
|
+
"""Portable baseline provider: full workspace tar over OpenShell upload/download."""
|
|
52
|
+
|
|
53
|
+
provider_name = "openshell-tar-full"
|
|
54
|
+
|
|
55
|
+
def __init__(self, *, openshell: OpenShellRunner, checkpoint_dir: Path) -> None:
|
|
56
|
+
self._openshell = openshell
|
|
57
|
+
self._checkpoint_dir = checkpoint_dir
|
|
58
|
+
|
|
59
|
+
def create(self, *, cell_name: str, workspace: str, checkpoint_id: str) -> CheckpointArtifact:
|
|
60
|
+
started = time.perf_counter()
|
|
61
|
+
before_stats = self._workspace_stats(cell_name, workspace)
|
|
62
|
+
remote_name = f".forkcell_{artifact_name(checkpoint_id)}"
|
|
63
|
+
remote_tar = remote_path(workspace, remote_name)
|
|
64
|
+
local_tar = self._checkpoint_dir / artifact_name(checkpoint_id)
|
|
65
|
+
local_tar.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
workspace_q = shlex.quote(workspace)
|
|
68
|
+
remote_name_q = shlex.quote(remote_name)
|
|
69
|
+
remote_tar_q = shlex.quote(remote_tar)
|
|
70
|
+
# GNU tar may return 1 when files change while archiving; keep that as degraded success.
|
|
71
|
+
tar_script = (
|
|
72
|
+
f"tar -C {workspace_q} --exclude {remote_name_q} -czf {remote_tar_q} .; "
|
|
73
|
+
"rc=$?; [ $rc -le 1 ] || exit $rc"
|
|
74
|
+
)
|
|
75
|
+
self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", tar_script], check=True)
|
|
76
|
+
try:
|
|
77
|
+
self._openshell(["sandbox", "download", cell_name, remote_tar, str(local_tar)], check=True)
|
|
78
|
+
finally:
|
|
79
|
+
self._openshell(["sandbox", "exec", "--name", cell_name, "rm", "-f", remote_tar], check=False)
|
|
80
|
+
|
|
81
|
+
duration_ms = int(round((time.perf_counter() - started) * 1000))
|
|
82
|
+
metrics: dict[str, Any] = {
|
|
83
|
+
"provider": self.provider_name,
|
|
84
|
+
"operation": "checkpoint_create",
|
|
85
|
+
"duration_ms": duration_ms,
|
|
86
|
+
"workspace": workspace,
|
|
87
|
+
"workspace_file_count": before_stats.get("file_count"),
|
|
88
|
+
"workspace_bytes": before_stats.get("bytes"),
|
|
89
|
+
"artifact_bytes": local_tar.stat().st_size,
|
|
90
|
+
"local_artifact": str(local_tar),
|
|
91
|
+
"full_archive": True,
|
|
92
|
+
}
|
|
93
|
+
if before_stats.get("error"):
|
|
94
|
+
metrics["workspace_stats_error"] = before_stats["error"]
|
|
95
|
+
return CheckpointArtifact(path=local_tar, sha256=sha256_file(local_tar), metrics=metrics)
|
|
96
|
+
|
|
97
|
+
def restore(self, *, cell_name: str, workspace: str, artifact: Path) -> dict[str, Any]:
|
|
98
|
+
started = time.perf_counter()
|
|
99
|
+
if not artifact.exists():
|
|
100
|
+
raise FileNotFoundError(str(artifact))
|
|
101
|
+
|
|
102
|
+
remote_name = f".forkcell_restore_{artifact.name}"
|
|
103
|
+
remote_tar = remote_path(workspace, remote_name)
|
|
104
|
+
self._openshell(["sandbox", "upload", cell_name, str(artifact), remote_tar], check=True)
|
|
105
|
+
|
|
106
|
+
workspace_q = shlex.quote(workspace)
|
|
107
|
+
remote_name_q = shlex.quote(remote_name)
|
|
108
|
+
remote_tar_q = shlex.quote(remote_tar)
|
|
109
|
+
cleanup_script = (
|
|
110
|
+
f"set -e; mkdir -p {workspace_q}; "
|
|
111
|
+
f"find {workspace_q} -mindepth 1 -maxdepth 1 ! -name {remote_name_q} -exec rm -rf {{}} +; "
|
|
112
|
+
f"tar -C {workspace_q} -xzf {remote_tar_q}; rm -f {remote_tar_q}"
|
|
113
|
+
)
|
|
114
|
+
self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", cleanup_script], check=True)
|
|
115
|
+
|
|
116
|
+
after_stats = self._workspace_stats(cell_name, workspace)
|
|
117
|
+
duration_ms = int(round((time.perf_counter() - started) * 1000))
|
|
118
|
+
metrics: dict[str, Any] = {
|
|
119
|
+
"provider": self.provider_name,
|
|
120
|
+
"operation": "checkpoint_restore",
|
|
121
|
+
"duration_ms": duration_ms,
|
|
122
|
+
"workspace": workspace,
|
|
123
|
+
"workspace_file_count_after": after_stats.get("file_count"),
|
|
124
|
+
"workspace_bytes_after": after_stats.get("bytes"),
|
|
125
|
+
"artifact_bytes": artifact.stat().st_size,
|
|
126
|
+
"local_artifact": str(artifact),
|
|
127
|
+
"full_archive": True,
|
|
128
|
+
}
|
|
129
|
+
if after_stats.get("error"):
|
|
130
|
+
metrics["workspace_stats_error"] = after_stats["error"]
|
|
131
|
+
return metrics
|
|
132
|
+
|
|
133
|
+
def _workspace_stats(self, cell_name: str, workspace: str) -> dict[str, Any]:
|
|
134
|
+
workspace_q = shlex.quote(workspace)
|
|
135
|
+
script = (
|
|
136
|
+
f"cd {workspace_q}; "
|
|
137
|
+
"files=$(find . -mindepth 1 -type f | wc -l | tr -d ' '); "
|
|
138
|
+
"bytes=$(find . -mindepth 1 -type f -exec wc -c {} + 2>/dev/null "
|
|
139
|
+
"| awk '{s += $1} END {print s + 0}'); "
|
|
140
|
+
"printf '{\"file_count\":%s,\"bytes\":%s}\\n' \"$files\" \"$bytes\""
|
|
141
|
+
)
|
|
142
|
+
result = self._openshell(["sandbox", "exec", "--name", cell_name, "sh", "-lc", script], check=False)
|
|
143
|
+
if result.returncode != 0:
|
|
144
|
+
return {"file_count": None, "bytes": None, "error": (result.stderr or result.stdout)[-500:]}
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(result.stdout.strip().splitlines()[-1])
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
return {"file_count": None, "bytes": None, "error": f"parse_failed: {exc}"}
|