Viewing files at commit ef78687eef1aCommit detailsView latest

suites/robustness/robustness/adapters.py 272 lines · 10.7 KB · python
1
"""Subject adapters: the same robustness operations for Oak and Git.
2
3
Remotes: Oak uses a loopback ``oak serve`` on a fixed per-fixture port (the
4
client stores the remote URL, so every trial restarts serve on the same port
5
from a copied data dir). Git uses a local bare repository addressed by the
6
relative path ``../srv/remote.git`` (file transport), or the stdlib smart-HTTP
7
server behind the same fault proxy for network-phase cases.
8
"""
9
10
from __future__ import annotations
11
12
import json
13
import subprocess
14
import time
15
import urllib.error
16
import urllib.request
17
from dataclasses import dataclass, field
18
from pathlib import Path
19
from typing import Optional
20
21
from .procs import Result, Spawned, run, subject_env, wait_until
22
from .readers import GitOracle, OakGraph, oak_commit_tree, worktree_manifest
23
24
OPS = ("commit", "push", "pull_ff", "pull_diverged", "clone", "switch", "merge_local", "merge_hosted", "mount_finish")
25
TOKEN = "p4-loopback-token"
26
REPO_ID = "p4/robust"
27
OTHER_BRANCH = "p4side"
28
29
30
def payload(root: Path, tag: str, count: int = 200, start: int = 0, prefix: str = "f") -> None:
31
    for i in range(start, start + count):
32
        path = root / f"d{i % 10}" / f"{prefix}{i}.txt"
33
        path.parent.mkdir(parents=True, exist_ok=True)
34
        path.write_text((f"{tag} line for {prefix}{i}\n" * 40))
35
36
37
@dataclass
38
class Server:
39
    """Loopback oak serve bound to a fixed port (restartable on copied data)."""
40
41
    oak_bin: str
42
    data_dir: Path
43
    port: int
44
    log: Path
45
    spawned: Optional[Spawned] = None
46
47
    @property
48
    def url(self) -> str:
49
        return f"http://127.0.0.1:{self.port}"
50
51
    def start(self, port: Optional[int] = None) -> "Server":
52
        bind = port or self.port
53
        self.log.parent.mkdir(parents=True, exist_ok=True)
54
        handle = self.log.open("ab")
55
        proc = subprocess.Popen([self.oak_bin, "serve", "--dir", str(self.data_dir), "--port", str(bind), "--token", TOKEN],
56
                                stdout=handle, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
57
                                env=subject_env(self.log.parent / "serve-home"), start_new_session=True)
58
        self.spawned = Spawned(proc, self.log)
59
60
        def ready() -> bool:
61
            if proc.poll() is not None:
62
                raise RuntimeError(f"oak serve exited early (rc={proc.returncode}); see {self.log}")
63
            try:
64
                with urllib.request.urlopen(f"http://127.0.0.1:{bind}/api/capabilities", timeout=1) as r:
65
                    return r.status == 200
66
            except urllib.error.HTTPError:
67
                return True  # any HTTP answer means the listener is up
68
            except OSError:
69
                return False
70
71
        if not wait_until(ready, 15):
72
            self.stop()
73
            raise RuntimeError(f"oak serve not ready on {bind}")
74
        return self
75
76
    def stop(self) -> None:
77
        if self.spawned:
78
            self.spawned.stop()
79
            self.spawned = None
80
81
    def db(self) -> Path:
82
        owner, name = REPO_ID.split("/")
83
        return self.data_dir / owner / f"{name}.oakdb"
84
85
86
class Adapter:
87
    kind = "?"
88
    subject = "?"
89
90
    def __init__(self, binary: str, home: Path):
91
        self.bin = binary
92
        self.home = home
93
94
    def env(self, extra: Optional[dict] = None) -> dict:
95
        raise NotImplementedError
96
97
98
class OakAdapter(Adapter):
99
    kind = "oak"
100
    subject = "oak_installed"
101
102
    def env(self, extra: Optional[dict] = None) -> dict:
103
        env = subject_env(self.home, {"OAK_API_KEY": TOKEN, "OAK_AUTHOR": "p4"})
104
        if extra:
105
            env.update(extra)
106
        return env
107
108
    # commands -----------------------------------------------------------------
109
    def argv(self, op: str, **kw) -> list[str]:
110
        b = kw.get("bin") or self.bin
111
        if op == "commit":
112
            return [b, "commit"]
113
        if op == "push":
114
            return [b, "push"]
115
        if op in ("pull_ff", "pull_diverged", "pull"):
116
            return [b, "pull"]
117
        if op == "clone":
118
            return [b, "clone", REPO_ID, str(kw["dest"]), "-r", kw["url"], "--branch", kw["branch"]]
119
        if op == "switch":
120
            return [b, "switch", kw["target"]]
121
        if op == "merge_hosted":
122
            return [b, "merge", "--json"]
123
        raise KeyError(op)
124
125
    def pre_argv(self, op: str) -> list[list[str]]:
126
        return []
127
128
    def run(self, argv: list[str], cwd: Path, extra_env: Optional[dict] = None, timeout: float = 90) -> Result:
129
        return run(argv, cwd, self.env(extra_env), timeout=timeout)
130
131
    # fixture ------------------------------------------------------------------
132
    def status_json(self, repo: Path) -> tuple[Optional[dict], Result]:
133
        res = self.run([self.bin, "status", "--json"], repo, timeout=60)
134
        try:
135
            return json.loads(res.stdout), res
136
        except (ValueError, TypeError):
137
            return None, res
138
139
    def must(self, argv: list[str], cwd: Path) -> Result:
140
        res = self.run(argv, cwd)
141
        if res.returncode != 0:
142
            raise RuntimeError(f"fixture command failed: {argv} rc={res.returncode}: {res.stderr[-400:]}")
143
        return res
144
145
    def db(self, repo: Path) -> Path:
146
        return repo / ".oak" / "oak.db"
147
148
    # independent observations ------------------------------------------------
149
    def local_graph(self, repo: Path) -> OakGraph:
150
        return OakGraph(self.db(repo))
151
152
    def head(self, repo: Path) -> Optional[str]:
153
        with self.local_graph(repo) as g:
154
            return g.metadata("head")
155
156
    def current_branch(self, repo: Path) -> Optional[str]:
157
        with self.local_graph(repo) as g:
158
            return g.metadata("current_branch")
159
160
    def tree(self, repo: Path, commit: Optional[str] = None) -> tuple[Optional[dict], str]:
161
        return oak_commit_tree(self.db(repo), commit)
162
163
    def status_state(self, repo: Path) -> tuple[Optional[bool], bool, Result]:
164
        """(lists uncommitted changes | None if status refused, operation in progress, result)."""
165
        data, res = self.status_json(repo)
166
        if res.returncode != 0 or data is None:
167
            return None, False, res
168
        changes = data.get("working_changes", {}).get("changes", data.get("changes", []))
169
        in_progress = bool(data.get("merge_in_progress") or data.get("sync_in_progress")
170
                           or (data.get("progress_state") or {}).get("in_progress"))
171
        return bool(changes), in_progress, res
172
173
    def status_dirty(self, repo: Path) -> tuple[Optional[bool], Result]:
174
        changes, in_progress, res = self.status_state(repo)
175
        return (None if changes is None else changes or in_progress), res
176
177
178
class GitAdapter(Adapter):
179
    kind = "git"
180
    subject = "git"
181
182
    def env(self, extra: Optional[dict] = None) -> dict:
183
        env = subject_env(self.home)
184
        if extra:
185
            env.update(extra)
186
        return env
187
188
    def argv(self, op: str, **kw) -> list[str]:
189
        b = kw.get("bin") or self.bin
190
        if op == "commit":
191
            return [b, "commit", "-q", "--no-verify", "-m", "p4 commit"]
192
        if op == "push":
193
            return [b, "push", "-q", "origin", "HEAD"]
194
        if op == "pull_ff":
195
            return [b, "pull", "-q", "--ff-only"]
196
        if op in ("pull_diverged", "pull"):
197
            return [b, "pull", "-q", "--no-rebase", "--no-edit"]
198
        if op == "clone":
199
            return [b, "clone", "-q", "--branch", kw["branch"], kw["url"], str(kw["dest"])]
200
        if op == "switch":
201
            return [b, "switch", "-q", kw["target"]]
202
        if op == "merge_local":
203
            return [b, "merge", "-q", "--no-edit", OTHER_BRANCH]
204
        raise KeyError(op)
205
206
    def pre_argv(self, op: str) -> list[list[str]]:
207
        # Git's commit is add+commit; staging is untimed setup (not a crash target).
208
        return [[self.bin, "add", "-A"]] if op == "commit" else []
209
210
    def run(self, argv: list[str], cwd: Path, extra_env: Optional[dict] = None, timeout: float = 90) -> Result:
211
        return run(argv, cwd, self.env(extra_env), timeout=timeout)
212
213
    def must(self, argv: list[str], cwd: Path) -> Result:
214
        res = self.run(argv, cwd)
215
        if res.returncode != 0:
216
            raise RuntimeError(f"fixture command failed: {argv} rc={res.returncode}: {res.stderr[-400:]}")
217
        return res
218
219
    @property
220
    def oracle(self) -> GitOracle:
221
        return GitOracle(self.bin, self.env())
222
223
    def head(self, repo: Path) -> Optional[str]:
224
        return self.oracle.head(repo)
225
226
    def current_branch(self, repo: Path) -> Optional[str]:
227
        res = self.run([self.bin, "symbolic-ref", "-q", "--short", "HEAD"], repo)
228
        return res.stdout.strip() or None
229
230
    def tree(self, repo: Path, commit: Optional[str] = None) -> tuple[Optional[dict], str]:
231
        commit = commit or self.head(repo)
232
        if not commit:
233
            return None, "corrupt:no HEAD"
234
        return self.oracle.commit_tree(repo, commit)
235
236
    def status_state(self, repo: Path) -> tuple[Optional[bool], bool, Result]:
237
        res = self.run([self.bin, "status", "--porcelain"], repo, timeout=60)
238
        if res.returncode != 0:
239
            return None, False, res
240
        # A crashed git can leave a stale lock that makes `git commit` refuse ("remove the file
241
        # manually") until the user intervenes, so the torn state cannot be committed as-is.
242
        in_progress = (any((repo / ".git" / name).exists()
243
                           for name in ("MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"))
244
                       or bool(self.blocking_locks(repo)))
245
        return bool(res.stdout.strip()), in_progress, res
246
247
    @staticmethod
248
    def blocking_locks(repo: Path) -> list[str]:
249
        """Stale git lock files that make the next `git commit` refuse ("remove the file manually").
250
251
        index.lock (index write), HEAD.lock (HEAD reflog/update; left by a switch killed between
252
        its renames) and the lock of the ref HEAD points to (refs/heads/<current>.lock; left by a
253
        killed commit/merge/pull). Other *.lock files (config.lock after a killed clone, locks of
254
        other refs, packed-refs.lock, ORIG_HEAD.lock) do not stop a commit - verified against real
255
        git in tests - so a torn worktree next to them can still be committed as user work."""
256
        gitdir = repo / ".git"
257
        candidates = ["index.lock", "HEAD.lock"]
258
        try:
259
            head = (gitdir / "HEAD").read_text().strip()
260
        except OSError:
261
            head = ""
262
        if head.startswith("ref: refs/"):
263
            candidates.append(head[len("ref: "):] + ".lock")
264
        return [name for name in candidates if (gitdir / name).is_file()]
265
266
    def status_dirty(self, repo: Path) -> tuple[Optional[bool], Result]:
267
        changes, in_progress, res = self.status_state(repo)
268
        return (None if changes is None else changes or in_progress), res
269
270
271
def manifest(repo: Path) -> Optional[dict]:
272
    return worktree_manifest(repo)