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

94 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 03:42 +0000

1""" 

2exec_runner.py — engine/env command execution primitives. 

3 

4Two execution paths (mechanic A, locked): 

5 

6- Engine container: the command runs in the engine's own container via 

7 `subprocess.run(["sh", "-c", command])` (see `run_engine_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 — callers decide its meaning. 

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# Platform-owned sidecar that runs StepFunction entrypoints in-cluster. Its 

47# name is fixed (not slugged from an env name) and carries no engine secrets. 

48STEP_FUNCTION_CONTAINER = "stepfunction-runner" 

49 

50 

51class ExecError(RuntimeError): 

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

53 

54 

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

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

57 

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

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

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

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

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

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

64 """ 

65 lower = name.lower() 

66 parts: list[str] = [] 

67 last_dash = False 

68 for ch in lower: 

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

70 parts.append(ch) 

71 last_dash = False 

72 elif not last_dash: 

73 parts.append("-") 

74 last_dash = True 

75 slug = "".join(parts) 

76 slug = slug.strip("-") 

77 if len(slug) > 64: 

78 slug = slug[:64] 

79 slug = slug.strip("-") 

80 return slug or "-" 

81 

82 

83def run_engine_command( 

84 command: str, 

85 *, 

86 timeout: float | None = None, 

87 cwd: str | None = None, 

88 stdin: str | None = None, 

89) -> dict: 

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

91 

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

93 optional ``cwd`` sets the working directory; the optional ``stdin`` is fed 

94 to the command as UTF-8 text. Both default to None — the original 

95 behaviour (inherit cwd, no stdin) is unchanged. 

96 """ 

97 try: 

98 proc = subprocess.run( 

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

100 capture_output=True, 

101 encoding="utf-8", 

102 errors="replace", 

103 timeout=timeout, 

104 cwd=cwd, 

105 input=stdin, 

106 ) 

107 except subprocess.TimeoutExpired as exc: 

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

109 return { 

110 "exit_code": proc.returncode, 

111 "stdout": proc.stdout, 

112 "stderr": proc.stderr, 

113 } 

114 

115 

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

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

118 try: 

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

120 except OSError as exc: 

121 raise ExecError( 

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

123 "set the namespace explicitly when constructing KubernetesExec" 

124 ) from exc 

125 if not namespace: 

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

127 return namespace 

128 

129 

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

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

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

133 if ws.is_open(): 

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

135 raw = ws.read_channel(_EXEC_STATUS_CHANNEL) 

136 if not raw: 

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

138 try: 

139 status = yaml.safe_load(raw) 

140 except yaml.YAMLError as exc: 

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

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

143 return 0 

144 message = "" 

145 if isinstance(status, dict): 

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

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

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

149 message = cause["message"] 

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

151 if m: 

152 return int(m.group(1)) 

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

154 

155 

156class KubernetesExec: 

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

158 

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

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

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

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

163 """ 

164 

165 def __init__( 

166 self, 

167 *, 

168 api: Any = None, 

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

170 pod_name: str | None = None, 

171 namespace: str | None = None, 

172 ) -> None: 

173 self._api = api 

174 self._stream = stream_fn 

175 self._pod_name = pod_name 

176 self._namespace = namespace 

177 

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

179 return self._exec_in_container(slugify(env_name), command) 

180 

181 def run_step_function(self, command: str) -> dict: 

182 """Run a StepFunction entrypoint in the fixed platform sidecar. 

183 

184 The container name is not derived from an env name and is not checked 

185 against the pod's envs; no environment or secret is forwarded. The 

186 entrypoint reads its params through a file redirect the caller embeds 

187 in ``command`` — no stdin channel is opened (the K8s exec v4 client has 

188 no half-close and could stall a reader-to-EOF entrypoint). 

189 """ 

190 return self._exec_in_container(STEP_FUNCTION_CONTAINER, command) 

191 

192 def _exec_in_container(self, container: str, command: str) -> dict: 

193 if self._stream is None: 

194 self._connect() 

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

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

197 ws = self._stream( 

198 self._api.connect_get_namespaced_pod_exec, 

199 name=pod, 

200 namespace=namespace, 

201 container=container, 

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

203 stderr=True, 

204 stdin=False, 

205 stdout=True, 

206 tty=False, 

207 _preload_content=False, 

208 ) 

209 ws.run_forever(timeout=None) 

210 return { 

211 "exit_code": _parse_exec_status(ws), 

212 "stdout": ws.read_stdout(), 

213 "stderr": ws.read_stderr(), 

214 } 

215 

216 def _connect(self) -> None: 

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

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

219 raise ExecError( 

220 "env commands require cluster execution: KUBERNETES_SERVICE_HOST " 

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

222 ) 

223 from kubernetes import client as k8s_client, config 

224 

225 config.load_incluster_config() 

226 self._api = k8s_client.CoreV1Api() 

227 from kubernetes.stream import stream as k8s_stream 

228 

229 self._stream = k8s_stream