FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
+497
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class FBTermError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class FBTermBusy(FBTermError):
|
||||
pass
|
||||
|
||||
|
||||
class FBTerm:
|
||||
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
||||
|
||||
def __init__(self, root, session, title):
|
||||
self.root = Path(root)
|
||||
self.session = session
|
||||
self.title = title
|
||||
|
||||
self.bin = self.root / "bin"
|
||||
self.jobs = self.root / "jobs"
|
||||
|
||||
self.runner = self.bin / "fbterm-run"
|
||||
self.shell = self.bin / "fbterm-shell"
|
||||
self.lock_path = self.root / "controller.lock"
|
||||
|
||||
@staticmethod
|
||||
def _run(args, **kwargs):
|
||||
return subprocess.run(
|
||||
args,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def has_session(self):
|
||||
p = self._run(
|
||||
["tmux", "has-session", "-t", self.session]
|
||||
)
|
||||
return p.returncode == 0
|
||||
|
||||
def ensure(self, visible=True):
|
||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
self.jobs.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
if not self.has_session():
|
||||
env = os.environ.copy()
|
||||
env["FBTERM_ROOT"] = str(self.root)
|
||||
env["FBTERM_SESSION"] = self.session
|
||||
env["PATH"] = str(self.bin) + os.pathsep + env.get("PATH", "")
|
||||
|
||||
p = subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
self.session,
|
||||
str(self.shell),
|
||||
],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if p.returncode != 0:
|
||||
raise FBTermError(
|
||||
"tmux new-session fallo: " + p.stderr.strip()
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"set-environment",
|
||||
"-t",
|
||||
self.session,
|
||||
"FBTERM_ROOT",
|
||||
str(self.root),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"set-environment",
|
||||
"-t",
|
||||
self.session,
|
||||
"FBTERM_SESSION",
|
||||
self.session,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"set-option",
|
||||
"-t",
|
||||
self.session,
|
||||
"allow-rename",
|
||||
"off",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"set-option",
|
||||
"-t",
|
||||
self.session,
|
||||
"set-titles",
|
||||
"off",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"send-keys",
|
||||
"-t",
|
||||
self.session + ":0.0",
|
||||
(
|
||||
"export FBTERM_ROOT="
|
||||
+ subprocess.list2cmdline([str(self.root)])
|
||||
+ "; export FBTERM_SESSION="
|
||||
+ subprocess.list2cmdline([self.session])
|
||||
+ "; export PATH="
|
||||
+ subprocess.list2cmdline(
|
||||
[str(self.bin) + ":$PATH"]
|
||||
)
|
||||
),
|
||||
"C-m",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if visible and not self.visible_window():
|
||||
subprocess.Popen(
|
||||
[
|
||||
"gnome-terminal",
|
||||
"--title=" + self.title,
|
||||
"--",
|
||||
"bash",
|
||||
"-lc",
|
||||
(
|
||||
"exec tmux attach-session -r -t "
|
||||
+ subprocess.list2cmdline([self.session])
|
||||
),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
for _ in range(40):
|
||||
if self.visible_window():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
return self.status()
|
||||
|
||||
def visible_window(self):
|
||||
p = self._run(["wmctrl", "-l"])
|
||||
return (
|
||||
p.returncode == 0
|
||||
and self.title in p.stdout
|
||||
)
|
||||
|
||||
def status(self):
|
||||
if not self.has_session():
|
||||
return {
|
||||
"session": False,
|
||||
"visible": False,
|
||||
"pane_pid": None,
|
||||
"pane_tty": None,
|
||||
"pane_command": None,
|
||||
"clients": 0,
|
||||
"readonly_clients": 0,
|
||||
}
|
||||
|
||||
fmt = (
|
||||
"#{pane_pid}|#{pane_tty}|"
|
||||
"#{pane_current_command}"
|
||||
)
|
||||
|
||||
p = self._run(
|
||||
[
|
||||
"tmux",
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
self.session + ":0.0",
|
||||
fmt,
|
||||
]
|
||||
)
|
||||
|
||||
parts = p.stdout.strip().split("|", 2)
|
||||
|
||||
pane_pid = parts[0] if len(parts) > 0 else ""
|
||||
pane_tty = parts[1] if len(parts) > 1 else ""
|
||||
pane_command = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
clients = self._run(
|
||||
[
|
||||
"tmux",
|
||||
"list-clients",
|
||||
"-t",
|
||||
self.session,
|
||||
"-F",
|
||||
"#{client_readonly}",
|
||||
]
|
||||
)
|
||||
|
||||
values = [
|
||||
x.strip()
|
||||
for x in clients.stdout.splitlines()
|
||||
if x.strip()
|
||||
]
|
||||
|
||||
readonly = sum(
|
||||
1
|
||||
for x in values
|
||||
if x in ("1", "on", "true")
|
||||
)
|
||||
|
||||
return {
|
||||
"session": True,
|
||||
"visible": self.visible_window(),
|
||||
"pane_pid": pane_pid,
|
||||
"pane_tty": pane_tty,
|
||||
"pane_command": pane_command,
|
||||
"clients": len(values),
|
||||
"readonly_clients": readonly,
|
||||
}
|
||||
|
||||
def execute(self, job_id, command, timeout_s=120):
|
||||
if not self.JOB_RE.fullmatch(job_id):
|
||||
raise FBTermError("JOB_ID invalido")
|
||||
|
||||
if not isinstance(command, str) or not command:
|
||||
raise FBTermError("command vacio")
|
||||
|
||||
timeout_s = int(timeout_s)
|
||||
|
||||
if timeout_s < 1 or timeout_s > 3600:
|
||||
raise FBTermError("timeout fuera de rango")
|
||||
|
||||
self.ensure(visible=True)
|
||||
|
||||
lock_fd = os.open(
|
||||
self.lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600,
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(
|
||||
lock_fd,
|
||||
fcntl.LOCK_EX | fcntl.LOCK_NB,
|
||||
)
|
||||
except BlockingIOError:
|
||||
raise FBTermBusy("FBTERM ocupado")
|
||||
|
||||
# R2: fail-closed si existe evidencia durable incompleta.
|
||||
# La reconciliacion conserva esos directorios fuera de jobs.
|
||||
incomplete_jobs = []
|
||||
|
||||
try:
|
||||
for prior in self.jobs.iterdir():
|
||||
if (
|
||||
prior.is_dir()
|
||||
and (
|
||||
not (prior / "DONE").exists()
|
||||
or not (prior / "result.env").exists()
|
||||
)
|
||||
):
|
||||
incomplete_jobs.append(prior.name)
|
||||
except OSError as exc:
|
||||
raise FBTermError(
|
||||
"no se pudo auditar jobs previos: " + str(exc)
|
||||
)
|
||||
|
||||
if incomplete_jobs:
|
||||
sample = ", ".join(sorted(incomplete_jobs)[:3])
|
||||
raise FBTermBusy(
|
||||
"FBTERM tiene job incompleto: " + sample
|
||||
)
|
||||
|
||||
job = self.jobs / job_id
|
||||
|
||||
if job.exists():
|
||||
raise FBTermError("JOB_ID ya existe")
|
||||
|
||||
job.mkdir(mode=0o700)
|
||||
|
||||
command_file = job / "command.bash"
|
||||
command_file.write_text(
|
||||
command,
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(command_file, 0o600)
|
||||
|
||||
(job / "timeout.seconds").write_text(
|
||||
str(timeout_s) + "\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
|
||||
expected_command_sha = hashlib.sha256(
|
||||
command.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# R2 idle gate: prompt.ready existe solo cuando el shell
|
||||
# persistente esta realmente esperando el siguiente comando.
|
||||
ready_path = self.jobs.parent / "prompt.ready"
|
||||
ready_deadline = time.monotonic() + 3.0
|
||||
|
||||
while time.monotonic() < ready_deadline:
|
||||
if ready_path.exists():
|
||||
break
|
||||
time.sleep(0.02)
|
||||
else:
|
||||
raise FBTermBusy(
|
||||
"FBTERM pane no esta en prompt"
|
||||
)
|
||||
|
||||
try:
|
||||
ready_path.unlink()
|
||||
except FileNotFoundError:
|
||||
raise FBTermBusy(
|
||||
"FBTERM prompt cambio antes del envio"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise FBTermError(
|
||||
"no se pudo reservar prompt: " + str(exc)
|
||||
)
|
||||
|
||||
send = self._run(
|
||||
[
|
||||
"tmux",
|
||||
"send-keys",
|
||||
"-t",
|
||||
self.session + ":0.0",
|
||||
"fbterm-run " + job_id,
|
||||
"C-m",
|
||||
]
|
||||
)
|
||||
|
||||
if send.returncode != 0:
|
||||
raise FBTermError(
|
||||
"tmux send-keys fallo: "
|
||||
+ send.stderr.strip()
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + timeout_s + 8
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
if (job / "DONE").exists():
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not (job / "DONE").exists():
|
||||
raise FBTermError(
|
||||
"resultado durable no aparecio"
|
||||
)
|
||||
|
||||
# R2: no liberar el lock hasta que bash haya vuelto realmente
|
||||
# al prompt. fbterm-shell actualiza prompt.ready mediante
|
||||
# PROMPT_COMMAND despues de que fbterm-run haya terminado.
|
||||
done_path = job / "DONE"
|
||||
prompt_deadline = time.monotonic() + 5.0
|
||||
prompt_ready = False
|
||||
|
||||
while time.monotonic() < prompt_deadline:
|
||||
try:
|
||||
if (
|
||||
ready_path.exists()
|
||||
and ready_path.stat().st_mtime_ns
|
||||
> done_path.stat().st_mtime_ns
|
||||
):
|
||||
prompt_ready = True
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
time.sleep(0.02)
|
||||
|
||||
if not prompt_ready:
|
||||
raise FBTermError(
|
||||
"prompt durable no aparecio"
|
||||
)
|
||||
|
||||
result = {}
|
||||
|
||||
for line in (job / "result.env").read_text(
|
||||
encoding="utf-8"
|
||||
).splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
result[k] = v
|
||||
|
||||
result["EXPECTED_COMMAND_SHA256"] = (
|
||||
expected_command_sha
|
||||
)
|
||||
|
||||
result["COMMAND_HASH_GATE"] = (
|
||||
"PASS"
|
||||
if result.get("COMMAND_SHA256")
|
||||
== expected_command_sha
|
||||
else "FAIL"
|
||||
)
|
||||
|
||||
result["STDOUT_PATH"] = str(
|
||||
job / "stdout.bin"
|
||||
)
|
||||
|
||||
result["STDERR_PATH"] = str(
|
||||
job / "stderr.bin"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def file_text(path):
|
||||
return Path(path).read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
root = os.environ["FBTERM_ROOT"]
|
||||
session = os.environ["FBTERM_SESSION"]
|
||||
title = os.environ["FBTERM_TITLE"]
|
||||
|
||||
term = FBTerm(root, session, title)
|
||||
|
||||
action = sys.argv[1]
|
||||
|
||||
if action == "ensure":
|
||||
print(json.dumps(term.ensure(), sort_keys=True))
|
||||
|
||||
elif action == "status":
|
||||
print(json.dumps(term.status(), sort_keys=True))
|
||||
|
||||
elif action == "exec":
|
||||
job_id = sys.argv[2]
|
||||
timeout_s = int(sys.argv[3])
|
||||
command_path = sys.argv[4]
|
||||
|
||||
try:
|
||||
r = term.execute(
|
||||
job_id,
|
||||
file_text(command_path),
|
||||
timeout_s,
|
||||
)
|
||||
print(json.dumps(r, sort_keys=True))
|
||||
except FBTermBusy as e:
|
||||
print(json.dumps({
|
||||
"ERROR": "BUSY",
|
||||
"MESSAGE": str(e),
|
||||
}))
|
||||
sys.exit(75)
|
||||
|
||||
else:
|
||||
raise SystemExit(64)
|
||||
Reference in new issue
Block a user