Files

498 lines
13 KiB
Python
Executable File

#!/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)