File size: 5,705 Bytes
63f6453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
"""Komut çalıştırıcı: kodu yerel süreçte ya da izole Docker konteynerinde koşar.

Ajanın derleyici ve test koşucusuna erişimi yalnızca bu katman üzerinden olur;
böylece LLM'in ürettiği kod hiçbir zaman ana sürece doğrudan dokunmaz.
"""
from __future__ import annotations

import os
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path

from ..config import settings


@dataclass
class ExecResult:
    returncode: int
    stdout: str
    stderr: str
    duration_s: float
    timed_out: bool = False

    @property
    def combined(self) -> str:
        return (self.stdout + ("\n" + self.stderr if self.stderr else "")).strip()


class Executor:
    """Soyut çalıştırıcı. `node_toolchain` ve `java_lib`, çalışma ortamı içindeki yollardır."""

    mode = "abstract"
    node_toolchain: str
    java_lib: str

    def run(self, cmd: str, workdir: Path, timeout: int, image: str) -> ExecResult:  # pragma: no cover
        raise NotImplementedError

    def workpath(self, workdir: Path) -> str:
        """Çalışma dizininin, komutun çalıştığı ortamdaki yolu."""
        raise NotImplementedError


class LocalExecutor(Executor):
    mode = "local"

    def __init__(self) -> None:
        self.node_toolchain = str(settings.node_toolchain)
        self.java_lib = str(settings.java_lib_dir)

    def workpath(self, workdir: Path) -> str:
        return str(workdir)

    def run(self, cmd: str, workdir: Path, timeout: int, image: str = "") -> ExecResult:
        env = dict(os.environ)
        env.update({"CI": "1", "NO_COLOR": "1", "FORCE_COLOR": "0", "PYTHONDONTWRITEBYTECODE": "1"})
        env["NODE_PATH"] = str(Path(self.node_toolchain) / "node_modules")
        start = time.time()
        proc = subprocess.Popen(
            ["bash", "-lc", cmd], cwd=workdir, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, env=env, start_new_session=True,
        )
        try:
            out, err = proc.communicate(timeout=timeout)
            return ExecResult(proc.returncode, out, err, time.time() - start)
        except subprocess.TimeoutExpired:
            try:
                os.killpg(proc.pid, signal.SIGKILL)
            except ProcessLookupError:
                pass
            out, err = proc.communicate()
            return ExecResult(-9, out or "", (err or "") + "\n[zaman aşımı]", time.time() - start, True)


class DockerExecutor(Executor):
    """Her komut için ağ erişimi kapalı, bellek/CPU sınırlı geçici konteyner."""

    mode = "docker"

    def __init__(self) -> None:
        self.node_toolchain = "/opt/toolchain"
        self.java_lib = "/opt/java-lib"
        # Backend bir konteynerde çalışıyorsa çalışma dosyaları sandbox'a iki yoldan biriyle ulaşır:
        #  - WORK_VOLUME: backend ile paylaşılan adlandırılmış Docker birimi (her işletim sisteminde çalışır)
        #  - HOST_WORK_ROOT: aynı dizinin host'taki yolu (bind mount)
        self.work_volume = os.environ.get("WORK_VOLUME")
        self.host_work_root = os.environ.get("HOST_WORK_ROOT")

    def workpath(self, workdir: Path) -> str:
        return str(workdir.resolve()) if self.work_volume else "/work"

    def _mount_args(self, workdir: Path) -> list[str]:
        if self.work_volume:
            root = str(settings.work_root.resolve())
            return ["-v", f"{self.work_volume}:{root}", "-w", str(workdir.resolve())]
        return ["-v", f"{self._host_path(workdir)}:/work", "-w", "/work"]

    def _host_path(self, workdir: Path) -> str:
        if self.host_work_root:
            rel = workdir.resolve().relative_to(settings.work_root.resolve())
            return str(Path(self.host_work_root) / rel)
        return str(workdir.resolve())

    @staticmethod
    def _open_permissions(workdir: Path) -> None:
        # Sandbox konteyneri ayrıcalıksız kullanıcıyla çalışır; çalışma dizinine yazabilmeli.
        for p in [workdir, *workdir.rglob("*")]:
            try:
                p.chmod(0o777 if p.is_dir() else 0o666)
            except OSError:
                pass

    def run(self, cmd: str, workdir: Path, timeout: int, image: str) -> ExecResult:
        self._open_permissions(workdir)
        name = f"lta-{workdir.name}-{int(time.time() * 1000) % 100000}"
        docker_cmd = [
            "docker", "run", "--rm", "--name", name, "--network", "none",
            "--memory", settings.docker_memory, "--cpus", settings.docker_cpus,
            "--pids-limit", "256", "--security-opt", "no-new-privileges",
            *self._mount_args(workdir),
            "-e", "CI=1", "-e", "NO_COLOR=1",
            image, "bash", "-lc", cmd,
        ]
        start = time.time()
        try:
            p = subprocess.run(docker_cmd, capture_output=True, text=True, timeout=timeout + 15)
            return ExecResult(p.returncode, p.stdout, p.stderr, time.time() - start)
        except subprocess.TimeoutExpired as e:
            subprocess.run(["docker", "kill", name], capture_output=True)
            return ExecResult(-9, (e.stdout or b"").decode(errors="ignore") if isinstance(e.stdout, bytes) else (e.stdout or ""),
                              "[zaman aşımı]", time.time() - start, True)


_executor: Executor | None = None


def get_executor() -> Executor:
    global _executor
    if _executor is None:
        if settings.sandbox_mode == "docker":
            if not shutil.which("docker"):
                raise RuntimeError("SANDBOX_MODE=docker fakat docker komutu bulunamadı")
            _executor = DockerExecutor()
        else:
            _executor = LocalExecutor()
    return _executor