498 lines
13 KiB
Python
Executable File
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)
|