Viewing files at commit f2acb751aa96Commit detailsView latest

suites/robustness/robustness/adapters.py 240 lines · 8.8 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_dirty(self, repo: Path) -> tuple[Optional[bool], Result]:
164
        data, res = self.status_json(repo)
165
        if res.returncode != 0 or data is None:
166
            return None, res
167
        changes = data.get("working_changes", {}).get("changes", data.get("changes", []))
168
        in_progress = bool(data.get("merge_in_progress") or data.get("sync_in_progress")
169
                           or (data.get("progress_state") or {}).get("in_progress"))
170
        return bool(changes) or in_progress, res
171
172
173
class GitAdapter(Adapter):
174
    kind = "git"
175
    subject = "git"
176
177
    def env(self, extra: Optional[dict] = None) -> dict:
178
        env = subject_env(self.home)
179
        if extra:
180
            env.update(extra)
181
        return env
182
183
    def argv(self, op: str, **kw) -> list[str]:
184
        b = kw.get("bin") or self.bin
185
        if op == "commit":
186
            return [b, "commit", "-q", "--no-verify", "-m", "p4 commit"]
187
        if op == "push":
188
            return [b, "push", "-q", "origin", "HEAD"]
189
        if op == "pull_ff":
190
            return [b, "pull", "-q", "--ff-only"]
191
        if op in ("pull_diverged", "pull"):
192
            return [b, "pull", "-q", "--no-rebase", "--no-edit"]
193
        if op == "clone":
194
            return [b, "clone", "-q", "--branch", kw["branch"], kw["url"], str(kw["dest"])]
195
        if op == "switch":
196
            return [b, "switch", "-q", kw["target"]]
197
        if op == "merge_local":
198
            return [b, "merge", "-q", "--no-edit", OTHER_BRANCH]
199
        raise KeyError(op)
200
201
    def pre_argv(self, op: str) -> list[list[str]]:
202
        # Git's commit is add+commit; staging is untimed setup (not a crash target).
203
        return [[self.bin, "add", "-A"]] if op == "commit" else []
204
205
    def run(self, argv: list[str], cwd: Path, extra_env: Optional[dict] = None, timeout: float = 90) -> Result:
206
        return run(argv, cwd, self.env(extra_env), timeout=timeout)
207
208
    def must(self, argv: list[str], cwd: Path) -> Result:
209
        res = self.run(argv, cwd)
210
        if res.returncode != 0:
211
            raise RuntimeError(f"fixture command failed: {argv} rc={res.returncode}: {res.stderr[-400:]}")
212
        return res
213
214
    @property
215
    def oracle(self) -> GitOracle:
216
        return GitOracle(self.bin, self.env())
217
218
    def head(self, repo: Path) -> Optional[str]:
219
        return self.oracle.head(repo)
220
221
    def current_branch(self, repo: Path) -> Optional[str]:
222
        res = self.run([self.bin, "symbolic-ref", "-q", "--short", "HEAD"], repo)
223
        return res.stdout.strip() or None
224
225
    def tree(self, repo: Path, commit: Optional[str] = None) -> tuple[Optional[dict], str]:
226
        commit = commit or self.head(repo)
227
        if not commit:
228
            return None, "corrupt:no HEAD"
229
        return self.oracle.commit_tree(repo, commit)
230
231
    def status_dirty(self, repo: Path) -> tuple[Optional[bool], Result]:
232
        res = self.run([self.bin, "status", "--porcelain"], repo, timeout=60)
233
        if res.returncode != 0:
234
            return None, res
235
        in_progress = any((repo / ".git" / name).exists() for name in ("MERGE_HEAD", "rebase-merge", "rebase-apply"))
236
        return bool(res.stdout.strip()) or in_progress, res
237
238
239
def manifest(repo: Path) -> Optional[dict]:
240
    return worktree_manifest(repo)