Coverage for core/langgraph/exec_runner.py: 90%

98 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 02:20 +0000

1""" 

2exec_runner.py — command execution for manifest `command` steps. 

3 

4Two execution paths (mechanic A, locked): 

5 

6- Engine container (default): the command runs in the engine's own 

7 container via `subprocess.run(["sh", "-c", command])`. 

8- Env container: the command runs inside the pod's env container through 

9 the Kubernetes exec API — pod name from hostname (pod identity, M4), 

10 container = slug(env name) (ADR-0018, identical to the API's 

11 `git.SlugRepoName`), command `sh -c <command>`. In-cluster config is used 

12 when KUBERNETES_SERVICE_HOST is set; outside a cluster (local Docker 

13 Compose mode) env execution fails with a clear error — no silent fallback 

14 (ADR-0012). 

15 

16Result shape for both paths: 

17 {"exit_code": int, "stdout": str, "stderr": str} 

18 

19A non-zero exit code is a result, not a failure — LLM steps consume it. 

20""" 

21from __future__ import annotations 

22 

23import logging 

24import os 

25import re 

26import socket 

27import subprocess 

28from pathlib import Path 

29from typing import Any, Callable 

30 

31import yaml 

32 

33log = logging.getLogger(__name__) 

34 

35# Kubernetes exec v4 protocol channels (kubernetes.stream.ws_client): 

36# 0 stdin, 1 stdout, 2 stderr, 3 error/status channel. 

37_EXEC_STATUS_CHANNEL = 3 

38 

39_NAMESPACE_FILE = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" 

40 

41# ADR-0024 D2: env-container commands run in the workspace root as the 

42# default working directory — the sh -c command carries a cd prefix. The 

43# path must match the API's workspace mount point (/project, ADR-0024 D1). 

44WORKSPACE_ROOT = "/project" 

45 

46 

47class ExecError(RuntimeError): 

48 """Hard execution error (ADR-0012) — never silently defaulted.""" 

49 

50 

51def slugify(name: str) -> str: 

52 """ADR-0018 slug, byte-identical to the API's `git.SlugRepoName`. 

53 

54 Semantics (copied exactly from api/internal/git/service.go): lowercase; 

55 each run of characters outside [a-z0-9] collapses to a single "-"; the 

56 result is trimmed of leading/trailing "-" before truncation (so a 

57 "-"-prefixed long name does not lose content to the hyphen) and again 

58 after truncation (a cut can reintroduce a trailing hyphen); empty or 

59 symbol-only input yields "-", never an error. 

60 """ 

61 lower = name.lower() 

62 parts: list[str] = [] 

63 last_dash = False 

64 for ch in lower: 

65 if ("a" <= ch <= "z") or ("0" <= ch <= "9"): 

66 parts.append(ch) 

67 last_dash = False 

68 elif not last_dash: 

69 parts.append("-") 

70 last_dash = True 

71 slug = "".join(parts) 

72 slug = slug.strip("-") 

73 if len(slug) > 64: 

74 slug = slug[:64] 

75 slug = slug.strip("-") 

76 return slug or "-" 

77 

78 

79def run_engine_command(command: str, *, timeout: float | None = None) -> dict: 

80 """Run a command in the engine container via subprocess (`sh -c`). 

81 

82 Output is captured with UTF-8 (replace errors) for determinism. 

83 """ 

84 try: 

85 proc = subprocess.run( 

86 ["sh", "-c", command], 

87 capture_output=True, 

88 encoding="utf-8", 

89 errors="replace", 

90 timeout=timeout, 

91 ) 

92 except subprocess.TimeoutExpired as exc: 

93 raise ExecError(f"command timed out after {timeout}s: {command!r}") from exc 

94 return { 

95 "exit_code": proc.returncode, 

96 "stdout": proc.stdout, 

97 "stderr": proc.stderr, 

98 } 

99 

100 

101def _read_namespace_file(path: Path) -> str: 

102 """Read the pod namespace from the serviceaccount token directory.""" 

103 try: 

104 namespace = path.read_text(encoding="utf-8").strip() 

105 except OSError as exc: 

106 raise ExecError( 

107 f"cannot determine pod namespace ({path}): {exc} — " 

108 "set the namespace explicitly when constructing KubernetesExec" 

109 ) from exc 

110 if not namespace: 

111 raise ExecError(f"pod namespace file {path} is empty") 

112 return namespace 

113 

114 

115def _parse_exec_status(ws: Any) -> int: 

116 """Map the Kubernetes exec status (error channel, v4 protocol) to an exit 

117 code. Missing or uninterpretable status is a hard error (ADR-0012).""" 

118 if ws.is_open(): 

119 raise ExecError("Kubernetes exec stream is still open — the command did not terminate") 

120 raw = ws.read_channel(_EXEC_STATUS_CHANNEL) 

121 if not raw: 

122 raise ExecError("Kubernetes exec returned no status on the error channel — exit code unknown") 

123 try: 

124 status = yaml.safe_load(raw) 

125 except yaml.YAMLError as exc: 

126 raise ExecError(f"Kubernetes exec status is not valid YAML: {raw!r}") from exc 

127 if isinstance(status, dict) and status.get("status") == "Success": 

128 return 0 

129 message = "" 

130 if isinstance(status, dict): 

131 details = status.get("details") or {} 

132 for cause in details.get("causes") or []: 

133 if isinstance(cause, dict) and cause.get("message"): 

134 message = cause["message"] 

135 m = re.search(r"(\d+)\s*$", message) 

136 if m: 

137 return int(m.group(1)) 

138 raise ExecError(f"Kubernetes exec status could not be interpreted: {raw!r}") 

139 

140 

141class KubernetesExec: 

142 """Executes a command in one of the pod's env containers (mechanic A). 

143 

144 The pod is the engine's own pod: pod name from hostname, container name 

145 from slug(env name). In-cluster configuration is loaded lazily; outside a 

146 cluster (no KUBERNETES_SERVICE_HOST) the run fails with a clear error. 

147 Tests inject a fake api + stream function to stay hermetic. 

148 """ 

149 

150 def __init__( 

151 self, 

152 *, 

153 api: Any = None, 

154 stream_fn: Callable[..., Any] | None = None, 

155 pod_name: str | None = None, 

156 namespace: str | None = None, 

157 ) -> None: 

158 self._api = api 

159 self._stream = stream_fn 

160 self._pod_name = pod_name 

161 self._namespace = namespace 

162 

163 def run(self, command: str, env_name: str) -> dict: 

164 if self._stream is None: 

165 self._connect() 

166 pod = self._pod_name or socket.gethostname() 

167 namespace = self._namespace or _read_namespace_file(Path(_NAMESPACE_FILE)) 

168 container = slugify(env_name) 

169 ws = self._stream( 

170 self._api.connect_get_namespaced_pod_exec, 

171 name=pod, 

172 namespace=namespace, 

173 container=container, 

174 command=["sh", "-c", f"cd {WORKSPACE_ROOT} && {command}"], 

175 stderr=True, 

176 stdin=False, 

177 stdout=True, 

178 tty=False, 

179 _preload_content=False, 

180 ) 

181 ws.run_forever(timeout=None) 

182 return { 

183 "exit_code": _parse_exec_status(ws), 

184 "stdout": ws.read_stdout(), 

185 "stderr": ws.read_stderr(), 

186 } 

187 

188 def _connect(self) -> None: 

189 """Load the in-cluster configuration and build the exec stream.""" 

190 if not os.getenv("KUBERNETES_SERVICE_HOST"): 

191 raise ExecError( 

192 "env commands require cluster execution: KUBERNETES_SERVICE_HOST " 

193 "is not set — the engine is not running inside a Kubernetes pod" 

194 ) 

195 from kubernetes import client as k8s_client, config 

196 

197 config.load_incluster_config() 

198 self._api = k8s_client.CoreV1Api() 

199 from kubernetes.stream import stream as k8s_stream 

200 

201 self._stream = k8s_stream 

202 

203 

204class CommandExecutor: 

205 """Runs manifest commands: engine container by default (subprocess) or an 

206 env container via Kubernetes exec (mechanic A).""" 

207 

208 def __init__(self, kubernetes_exec: KubernetesExec | None = None) -> None: 

209 self._kubernetes_exec = kubernetes_exec 

210 

211 def run_in_engine(self, command: str, *, timeout: float | None = None) -> dict: 

212 return run_engine_command(command, timeout=timeout) 

213 

214 def run_in_env(self, command: str, env_name: str) -> dict: 

215 k8s = self._kubernetes_exec or KubernetesExec() 

216 return k8s.run(command, env_name)