| 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)
|