diff --git a/docker/sidecar/main.py b/docker/sidecar/main.py index 1953c51..3149fb2 100644 --- a/docker/sidecar/main.py +++ b/docker/sidecar/main.py @@ -32,6 +32,8 @@ MAIN_PROCESS_NAME = os.getenv("MAIN_PROCESS_NAME", "") # Version from build arg (set via Dockerfile ARG -> ENV) VERSION = os.getenv("VERSION", "0.0.0-dev") +# Network isolation mode - when true, disables network-dependent features (e.g., Go module proxy) +NETWORK_ISOLATED = os.getenv("NETWORK_ISOLATED", "false").lower() in ("true", "1", "yes") class ExecuteRequest(BaseModel): @@ -186,6 +188,37 @@ def get_container_env(pid: int) -> dict[str, str]: return {} +def apply_network_isolation_overrides(env: dict[str, str], language: str) -> dict[str, str]: + """Apply environment overrides when network isolation is enabled. + + When pods are network-isolated (egress blocked), certain language runtimes + need configuration changes to work offline. This function modifies the + environment to enable offline/air-gapped operation. + + Args: + env: The container environment dictionary (will be modified in place) + language: The language being executed + + Returns: + The modified environment dictionary + """ + if not NETWORK_ISOLATED: + return env + + # Go: Disable module proxy and checksum database for offline operation + if language in ("go",): + env["GOPROXY"] = "off" + env["GOSUMDB"] = "off" + print(f"[EXECUTE] Network isolation: overriding GOPROXY=off, GOSUMDB=off", flush=True) + + # Future: Add overrides for other languages as needed + # - Rust: CARGO_NET_OFFLINE=true + # - npm/Node: npm_config_offline=true + # - pip/Python: PIP_NO_INDEX=1 + + return env + + def get_language_command( language: str, code: str, working_dir: str, container_env: dict[str, str] ) -> tuple[list[str], Path | None]: @@ -284,6 +317,9 @@ async def execute_via_nsenter(request: ExecuteRequest) -> ExecuteResponse: # eliminating config drift between Dockerfiles and sidecar code container_env = get_container_env(main_pid) + # Apply network isolation overrides if enabled + container_env = apply_network_isolation_overrides(container_env, LANGUAGE) + # Get the command for this language (this writes code to a temp file) cmd, temp_file = get_language_command( LANGUAGE, request.code, request.working_dir, container_env @@ -316,18 +352,22 @@ async def execute_via_nsenter(request: ExecuteRequest) -> ExecuteResponse: "--", ] + cmd - # Debug logging - print(f"[EXECUTE] main_pid={main_pid}, language={LANGUAGE}") - print(f"[EXECUTE] container_env PATH={container_env.get('PATH', 'NOT SET')}") - print(f"[EXECUTE] nsenter_cmd={nsenter_cmd}") + # Debug logging - use flush=True to ensure output before container termination + print(f"[EXECUTE] main_pid={main_pid}, language={LANGUAGE}", flush=True) + print(f"[EXECUTE] container_env PATH={container_env.get('PATH', 'NOT SET')}", flush=True) + print(f"[EXECUTE] nsenter_cmd={nsenter_cmd}", flush=True) + if temp_file: + print(f"[EXECUTE] code_file={temp_file}, exists={temp_file.exists()}, size={temp_file.stat().st_size if temp_file.exists() else 0}", flush=True) try: + print(f"[EXECUTE] Creating subprocess...", flush=True) proc = await asyncio.create_subprocess_exec( *nsenter_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=request.working_dir, ) + print(f"[EXECUTE] Subprocess created, pid={proc.pid}, waiting for completion (timeout={request.timeout}s)...", flush=True) try: stdout, stderr = await asyncio.wait_for( @@ -335,6 +375,7 @@ async def execute_via_nsenter(request: ExecuteRequest) -> ExecuteResponse: timeout=request.timeout, ) except TimeoutError: + print(f"[EXECUTE] TIMEOUT after {request.timeout}s, killing process pid={proc.pid}", flush=True) proc.kill() await proc.wait() return ExecuteResponse( @@ -350,11 +391,11 @@ async def execute_via_nsenter(request: ExecuteRequest) -> ExecuteResponse: stderr_str = stderr.decode("utf-8", errors="replace")[:MAX_OUTPUT_SIZE] # Debug logging - print(f"[EXECUTE] exit_code={proc.returncode}, stdout_len={len(stdout_str)}, stderr_len={len(stderr_str)}") + print(f"[EXECUTE] exit_code={proc.returncode}, stdout_len={len(stdout_str)}, stderr_len={len(stderr_str)}", flush=True) if stdout_str: - print(f"[EXECUTE] stdout preview: {stdout_str[:500]!r}") + print(f"[EXECUTE] stdout preview: {stdout_str[:500]!r}", flush=True) if stderr_str: - print(f"[EXECUTE] stderr preview: {stderr_str[:500]!r}") + print(f"[EXECUTE] stderr preview: {stderr_str[:500]!r}", flush=True) return ExecuteResponse( exit_code=proc.returncode or 0, @@ -364,6 +405,8 @@ async def execute_via_nsenter(request: ExecuteRequest) -> ExecuteResponse: ) except Exception as e: + print(f"[EXECUTE] EXCEPTION: {type(e).__name__}: {e}", flush=True) + print(f"[EXECUTE] Traceback: {traceback.format_exc()}", flush=True) return ExecuteResponse( exit_code=1, stdout="", diff --git a/helm-deployments/kubecoderun/templates/configmap.yaml b/helm-deployments/kubecoderun/templates/configmap.yaml index 6fea506..a20df80 100644 --- a/helm-deployments/kubecoderun/templates/configmap.yaml +++ b/helm-deployments/kubecoderun/templates/configmap.yaml @@ -100,6 +100,145 @@ data: {{- $defaultImage = printf "%s-%s:%s" $registry "d" $defaultTag }} LANG_IMAGE_D: {{ .Values.execution.languages.d.image | default $defaultImage | quote }} + # Per-language resource limits (falls back to sidecar defaults) + # These control user code execution resources (code runs in sidecar's cgroup) + {{- $defaultCpuLimit := .Values.execution.sidecar.resources.limits.cpu }} + {{- $defaultMemoryLimit := .Values.execution.sidecar.resources.limits.memory }} + {{- $defaultCpuRequest := .Values.execution.sidecar.resources.requests.cpu }} + {{- $defaultMemoryRequest := .Values.execution.sidecar.resources.requests.memory }} + {{- with .Values.execution.languages.python.resources }} + LANG_CPU_LIMIT_PY: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_PY: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_PY: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_PY: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_PY: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_PY: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_PY: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_PY: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.javascript.resources }} + LANG_CPU_LIMIT_JS: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_JS: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_JS: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_JS: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_JS: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_JS: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_JS: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_JS: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.typescript.resources }} + LANG_CPU_LIMIT_TS: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_TS: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_TS: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_TS: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_TS: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_TS: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_TS: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_TS: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.go.resources }} + LANG_CPU_LIMIT_GO: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_GO: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_GO: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_GO: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_GO: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_GO: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_GO: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_GO: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.java.resources }} + LANG_CPU_LIMIT_JAVA: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_JAVA: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_JAVA: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_JAVA: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_JAVA: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_JAVA: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_JAVA: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_JAVA: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.rust.resources }} + LANG_CPU_LIMIT_RS: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_RS: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_RS: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_RS: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_RS: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_RS: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_RS: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_RS: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.c.resources }} + LANG_CPU_LIMIT_C: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_C: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_C: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_C: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_C: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_C: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_C: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_C: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.cpp.resources }} + LANG_CPU_LIMIT_CPP: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_CPP: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_CPP: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_CPP: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_CPP: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_CPP: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_CPP: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_CPP: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.php.resources }} + LANG_CPU_LIMIT_PHP: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_PHP: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_PHP: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_PHP: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_PHP: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_PHP: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_PHP: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_PHP: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.r.resources }} + LANG_CPU_LIMIT_R: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_R: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_R: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_R: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_R: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_R: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_R: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_R: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.fortran.resources }} + LANG_CPU_LIMIT_F90: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_F90: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_F90: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_F90: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_F90: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_F90: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_F90: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_F90: {{ $defaultMemoryRequest | quote }} + {{- end }} + {{- with .Values.execution.languages.d.resources }} + LANG_CPU_LIMIT_D: {{ .limits.cpu | default $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_D: {{ .limits.memory | default $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_D: {{ .requests.cpu | default $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_D: {{ .requests.memory | default $defaultMemoryRequest | quote }} + {{- else }} + LANG_CPU_LIMIT_D: {{ $defaultCpuLimit | quote }} + LANG_MEMORY_LIMIT_D: {{ $defaultMemoryLimit | quote }} + LANG_CPU_REQUEST_D: {{ $defaultCpuRequest | quote }} + LANG_MEMORY_REQUEST_D: {{ $defaultMemoryRequest | quote }} + {{- end }} + # Execution Limits MAX_EXECUTION_TIME: {{ .Values.execution.maxExecutionTime | quote }} MAX_MEMORY_MB: {{ .Values.resourceLimits.maxMemoryMb | quote }} diff --git a/helm-deployments/kubecoderun/values.yaml b/helm-deployments/kubecoderun/values.yaml index a64a979..e06bbd6 100644 --- a/helm-deployments/kubecoderun/values.yaml +++ b/helm-deployments/kubecoderun/values.yaml @@ -210,15 +210,26 @@ execution: # poolSize = 0: use Jobs (cold start) # Images default to {imageRegistry}-{language}:{imageTag or appVersion} # Set image: to override with a custom image for a specific language + # Set resources: to override CPU/memory limits for specific languages + # (falls back to sidecar.resources if not specified) languages: python: poolSize: 5 + # resources: (uses sidecar.resources defaults) javascript: poolSize: 2 typescript: poolSize: 0 go: poolSize: 0 + # Go compilation is CPU-intensive; consider increasing resources: + # resources: + # limits: + # cpu: "2" + # memory: "1Gi" + # requests: + # cpu: "500m" + # memory: "512Mi" java: poolSize: 0 rust: diff --git a/src/config/__init__.py b/src/config/__init__.py index caf7251..aee6bfa 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -579,6 +579,8 @@ def get_pool_configs(self): from ..services.kubernetes.models import PoolConfig configs = [] + languages = ["py", "js", "ts", "go", "java", "c", "cpp", "php", "rs", "r", "f90", "d"] + pool_sizes = { "py": self.pod_pool_py, "js": self.pod_pool_js, @@ -594,26 +596,20 @@ def get_pool_configs(self): "d": self.pod_pool_d, } - # Per-language image overrides from environment (LANG_IMAGE_) - # Falls back to auto-generated registry/tag pattern if not set - image_overrides = { - "py": os.getenv("LANG_IMAGE_PY"), - "js": os.getenv("LANG_IMAGE_JS"), - "ts": os.getenv("LANG_IMAGE_TS"), - "go": os.getenv("LANG_IMAGE_GO"), - "java": os.getenv("LANG_IMAGE_JAVA"), - "c": os.getenv("LANG_IMAGE_C"), - "cpp": os.getenv("LANG_IMAGE_CPP"), - "php": os.getenv("LANG_IMAGE_PHP"), - "rs": os.getenv("LANG_IMAGE_RS"), - "r": os.getenv("LANG_IMAGE_R"), - "f90": os.getenv("LANG_IMAGE_F90"), - "d": os.getenv("LANG_IMAGE_D"), - } + for lang in languages: + lang_upper = lang.upper() + pool_size = pool_sizes[lang] + + # Per-language image override (LANG_IMAGE_) + image = os.getenv(f"LANG_IMAGE_{lang_upper}") or self.kubernetes.get_image_for_language(lang) + + # Per-language resource limits (LANG_CPU_LIMIT_, etc.) + # Falls back to global sidecar defaults + sidecar_cpu_limit = os.getenv(f"LANG_CPU_LIMIT_{lang_upper}") or self.k8s_sidecar_cpu_limit + sidecar_memory_limit = os.getenv(f"LANG_MEMORY_LIMIT_{lang_upper}") or self.k8s_sidecar_memory_limit + sidecar_cpu_request = os.getenv(f"LANG_CPU_REQUEST_{lang_upper}") or self.k8s_sidecar_cpu_request + sidecar_memory_request = os.getenv(f"LANG_MEMORY_REQUEST_{lang_upper}") or self.k8s_sidecar_memory_request - for lang, pool_size in pool_sizes.items(): - # Use explicit image override if set, otherwise auto-generate - image = image_overrides.get(lang) or self.kubernetes.get_image_for_language(lang) configs.append( PoolConfig( language=lang, @@ -622,12 +618,13 @@ def get_pool_configs(self): sidecar_image=self.k8s_sidecar_image, cpu_limit=self.k8s_cpu_limit, memory_limit=self.k8s_memory_limit, - sidecar_cpu_limit=self.k8s_sidecar_cpu_limit, - sidecar_memory_limit=self.k8s_sidecar_memory_limit, - sidecar_cpu_request=self.k8s_sidecar_cpu_request, - sidecar_memory_request=self.k8s_sidecar_memory_request, + sidecar_cpu_limit=sidecar_cpu_limit, + sidecar_memory_limit=sidecar_memory_limit, + sidecar_cpu_request=sidecar_cpu_request, + sidecar_memory_request=sidecar_memory_request, image_pull_policy=self.k8s_image_pull_policy, seccomp_profile_type=self.k8s_seccomp_profile_type, + network_isolated=self.enable_network_isolation, ) ) diff --git a/src/main.py b/src/main.py index 59b3d07..d1d46dd 100644 --- a/src/main.py +++ b/src/main.py @@ -152,6 +152,7 @@ async def lifespan(app: FastAPI): default_cpu_request=settings.k8s_cpu_request, default_memory_request=settings.k8s_memory_request, seccomp_profile_type=settings.k8s_seccomp_profile_type, + network_isolated=settings.enable_network_isolation, ) await kubernetes_manager.start() diff --git a/src/services/kubernetes/client.py b/src/services/kubernetes/client.py index 606b0cf..a313dc9 100644 --- a/src/services/kubernetes/client.py +++ b/src/services/kubernetes/client.py @@ -192,6 +192,7 @@ def create_pod_manifest( sidecar_cpu_request: str = "100m", sidecar_memory_request: str = "256Mi", seccomp_profile_type: str = "RuntimeDefault", + network_isolated: bool = False, ) -> client.V1Pod: """Create a Pod manifest for code execution. @@ -304,6 +305,7 @@ def create_pod_manifest( client.V1EnvVar(name="LANGUAGE", value=language), client.V1EnvVar(name="WORKING_DIR", value="/mnt/data"), client.V1EnvVar(name="SIDECAR_PORT", value=str(sidecar_port)), + client.V1EnvVar(name="NETWORK_ISOLATED", value=str(network_isolated).lower()), ], readiness_probe=client.V1Probe( http_get=client.V1HTTPGetAction(path="/ready", port=sidecar_port), diff --git a/src/services/kubernetes/job_executor.py b/src/services/kubernetes/job_executor.py index 350aadf..d69d700 100644 --- a/src/services/kubernetes/job_executor.py +++ b/src/services/kubernetes/job_executor.py @@ -124,6 +124,7 @@ async def create_job( sidecar_cpu_request=spec.sidecar_cpu_request, sidecar_memory_request=spec.sidecar_memory_request, seccomp_profile_type=spec.seccomp_profile_type, + network_isolated=spec.network_isolated, ttl_seconds_after_finished=self.ttl_seconds_after_finished, active_deadline_seconds=self.active_deadline_seconds, ) diff --git a/src/services/kubernetes/manager.py b/src/services/kubernetes/manager.py index 223aec2..3690b66 100644 --- a/src/services/kubernetes/manager.py +++ b/src/services/kubernetes/manager.py @@ -47,6 +47,7 @@ def __init__( default_cpu_request: str = "100m", default_memory_request: str = "128Mi", seccomp_profile_type: str = "RuntimeDefault", + network_isolated: bool = False, ): """Initialize the Kubernetes manager. @@ -59,6 +60,7 @@ def __init__( default_cpu_request: Default CPU request for pods default_memory_request: Default memory request for pods seccomp_profile_type: Seccomp profile type (RuntimeDefault, Unconfined, Localhost) + network_isolated: Whether network isolation is enabled (disables network-dependent features) """ self.namespace = namespace or get_current_namespace() self.sidecar_image = sidecar_image @@ -67,6 +69,7 @@ def __init__( self.default_cpu_request = default_cpu_request self.default_memory_request = default_memory_request self.seccomp_profile_type = seccomp_profile_type + self.network_isolated = network_isolated # Pool manager for warm pods self._pool_manager = PodPoolManager( @@ -276,6 +279,7 @@ async def execute_code( cpu_request=self.default_cpu_request, memory_request=self.default_memory_request, seccomp_profile_type=self.seccomp_profile_type, + network_isolated=self.network_isolated, ) result = await self._job_executor.execute_with_job( diff --git a/src/services/kubernetes/models.py b/src/services/kubernetes/models.py index 4a56e8c..f9db8c4 100644 --- a/src/services/kubernetes/models.py +++ b/src/services/kubernetes/models.py @@ -122,6 +122,9 @@ class PodSpec: sidecar_image: str = "aronmuon/kubecoderun-sidecar:latest" sidecar_port: int = 8080 + # Network isolation mode - disables network-dependent features (e.g., Go module proxy) + network_isolated: bool = False + @dataclass class PoolConfig: @@ -148,6 +151,9 @@ class PoolConfig: # Seccomp profile type (RuntimeDefault, Unconfined, Localhost) seccomp_profile_type: str = "RuntimeDefault" + # Network isolation mode - disables network-dependent features (e.g., Go module proxy) + network_isolated: bool = False + @property def uses_pool(self) -> bool: """Whether this language uses a warm pod pool.""" diff --git a/src/services/kubernetes/pool.py b/src/services/kubernetes/pool.py index 02316dc..98fb572 100644 --- a/src/services/kubernetes/pool.py +++ b/src/services/kubernetes/pool.py @@ -186,6 +186,7 @@ async def _create_warm_pod(self) -> PooledPod | None: sidecar_cpu_request=self.config.sidecar_cpu_request, sidecar_memory_request=self.config.sidecar_memory_request, seccomp_profile_type=self.config.seccomp_profile_type, + network_isolated=self.config.network_isolated, ) try: diff --git a/tests/unit/test_kubernetes_client.py b/tests/unit/test_kubernetes_client.py index 1723eea..35ef7b3 100644 --- a/tests/unit/test_kubernetes_client.py +++ b/tests/unit/test_kubernetes_client.py @@ -459,3 +459,53 @@ def test_create_pod_manifest_seccomp_profile_propagates(self): ) assert pod.spec.security_context.seccomp_profile.type == profile_type + + def test_create_pod_manifest_network_isolated_false(self): + """Test pod manifest with network_isolated=False.""" + pod = client.create_pod_manifest( + name="test-pod", + namespace="test-ns", + main_image="python:3.12", + sidecar_image="sidecar:latest", + language="python", + labels={"app": "test"}, + network_isolated=False, + ) + + sidecar = next(c for c in pod.spec.containers if c.name == "sidecar") + env_dict = {e.name: e.value for e in sidecar.env} + assert "NETWORK_ISOLATED" in env_dict + assert env_dict["NETWORK_ISOLATED"] == "false" + + def test_create_pod_manifest_network_isolated_true(self): + """Test pod manifest with network_isolated=True.""" + pod = client.create_pod_manifest( + name="test-pod", + namespace="test-ns", + main_image="go:1.22", + sidecar_image="sidecar:latest", + language="go", + labels={"app": "test"}, + network_isolated=True, + ) + + sidecar = next(c for c in pod.spec.containers if c.name == "sidecar") + env_dict = {e.name: e.value for e in sidecar.env} + assert "NETWORK_ISOLATED" in env_dict + assert env_dict["NETWORK_ISOLATED"] == "true" + + def test_create_pod_manifest_network_isolated_default(self): + """Test pod manifest defaults network_isolated to False.""" + pod = client.create_pod_manifest( + name="test-pod", + namespace="test-ns", + main_image="python:3.12", + sidecar_image="sidecar:latest", + language="python", + labels={"app": "test"}, + ) + + sidecar = next(c for c in pod.spec.containers if c.name == "sidecar") + env_dict = {e.name: e.value for e in sidecar.env} + assert "NETWORK_ISOLATED" in env_dict + assert env_dict["NETWORK_ISOLATED"] == "false" diff --git a/tests/unit/test_kubernetes_manager.py b/tests/unit/test_kubernetes_manager.py index 3a9fac7..3011e72 100644 --- a/tests/unit/test_kubernetes_manager.py +++ b/tests/unit/test_kubernetes_manager.py @@ -103,6 +103,23 @@ def test_init_with_pool_configs(self): call_kwargs = mock_pool_cls.call_args[1] assert call_kwargs["configs"] == configs + def test_init_with_network_isolated(self): + """Test initialization with network_isolated parameter.""" + with patch("src.services.kubernetes.manager.PodPoolManager"): + with patch("src.services.kubernetes.manager.JobExecutor"): + manager = KubernetesManager(network_isolated=True) + + assert manager.network_isolated is True + + def test_init_network_isolated_default_false(self): + """Test that network_isolated defaults to False.""" + with patch("src.services.kubernetes.manager.get_current_namespace", return_value="default-ns"): + with patch("src.services.kubernetes.manager.PodPoolManager"): + with patch("src.services.kubernetes.manager.JobExecutor"): + manager = KubernetesManager() + + assert manager.network_isolated is False + class TestStart: """Tests for start method.""" diff --git a/tests/unit/test_pool.py b/tests/unit/test_pool.py index b12266d..c182fd1 100644 --- a/tests/unit/test_pool.py +++ b/tests/unit/test_pool.py @@ -63,6 +63,39 @@ def pooled_pod(pod_handle): ) +class TestPoolConfig: + """Tests for PoolConfig dataclass.""" + + def test_pool_config_default_network_isolated(self): + """Test that network_isolated defaults to False.""" + config = PoolConfig( + language="python", + image="python:3.12", + pool_size=5, + ) + assert config.network_isolated is False + + def test_pool_config_with_network_isolated_true(self): + """Test creating PoolConfig with network_isolated=True.""" + config = PoolConfig( + language="go", + image="golang:1.22", + pool_size=2, + network_isolated=True, + ) + assert config.network_isolated is True + + def test_pool_config_with_network_isolated_false(self): + """Test creating PoolConfig with explicit network_isolated=False.""" + config = PoolConfig( + language="python", + image="python:3.12", + pool_size=3, + network_isolated=False, + ) + assert config.network_isolated is False + + class TestPodPoolInit: """Tests for PodPool initialization.""" @@ -1062,3 +1095,145 @@ async def test_execute_with_state_and_state_errors(self, pod_pool, pod_handle): result = await pod_pool.execute(pod_handle, "x = 1", capture_state=True) assert result.state_errors == ["Warning: large object skipped"] + + +class TestPoolConfigResources: + """Tests for PoolConfig per-language resource configuration.""" + + def test_pool_config_default_sidecar_resources(self): + """Test PoolConfig has default sidecar resource values.""" + config = PoolConfig( + language="python", + image="python:3.12", + pool_size=5, + ) + assert config.sidecar_cpu_limit == "500m" + assert config.sidecar_memory_limit == "512Mi" + assert config.sidecar_cpu_request == "100m" + assert config.sidecar_memory_request == "256Mi" + + def test_pool_config_custom_sidecar_resources(self): + """Test PoolConfig accepts custom sidecar resource values.""" + config = PoolConfig( + language="go", + image="golang:1.22", + pool_size=2, + sidecar_cpu_limit="2", + sidecar_memory_limit="1Gi", + sidecar_cpu_request="500m", + sidecar_memory_request="512Mi", + ) + assert config.sidecar_cpu_limit == "2" + assert config.sidecar_memory_limit == "1Gi" + assert config.sidecar_cpu_request == "500m" + assert config.sidecar_memory_request == "512Mi" + + def test_pool_config_partial_sidecar_resource_override(self): + """Test PoolConfig allows partial sidecar resource overrides.""" + config = PoolConfig( + language="java", + image="openjdk:21", + pool_size=1, + sidecar_cpu_limit="4", # Only override CPU limit + # Other values use defaults + ) + assert config.sidecar_cpu_limit == "4" + assert config.sidecar_memory_limit == "512Mi" # Default + assert config.sidecar_cpu_request == "100m" # Default + assert config.sidecar_memory_request == "256Mi" # Default + + +class TestSettingsPerLanguageResources: + """Tests for Settings.get_pool_configs with per-language resources.""" + + def test_get_pool_configs_uses_env_var_resources(self): + """Test get_pool_configs reads per-language resources from env vars.""" + import os + + from src.config import Settings + + env_vars = { + "LANG_CPU_LIMIT_GO": "2", + "LANG_MEMORY_LIMIT_GO": "1Gi", + "LANG_CPU_REQUEST_GO": "500m", + "LANG_MEMORY_REQUEST_GO": "512Mi", + } + + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + configs = settings.get_pool_configs() + + go_config = next(c for c in configs if c.language == "go") + assert go_config.sidecar_cpu_limit == "2" + assert go_config.sidecar_memory_limit == "1Gi" + assert go_config.sidecar_cpu_request == "500m" + assert go_config.sidecar_memory_request == "512Mi" + + def test_get_pool_configs_falls_back_to_global_sidecar_defaults(self): + """Test get_pool_configs falls back to sidecar defaults when no env vars.""" + import os + + from src.config import Settings + + # Clear any per-language env vars + env_vars_to_clear = [ + "LANG_CPU_LIMIT_PY", + "LANG_MEMORY_LIMIT_PY", + "LANG_CPU_REQUEST_PY", + "LANG_MEMORY_REQUEST_PY", + ] + clean_env = {k: "" for k in env_vars_to_clear} + + with patch.dict(os.environ, clean_env, clear=False): + # Force empty values to trigger fallback + for key in env_vars_to_clear: + os.environ.pop(key, None) + + settings = Settings( + k8s_sidecar_cpu_limit="750m", + k8s_sidecar_memory_limit="768Mi", + k8s_sidecar_cpu_request="200m", + k8s_sidecar_memory_request="384Mi", + ) + configs = settings.get_pool_configs() + + py_config = next(c for c in configs if c.language == "py") + assert py_config.sidecar_cpu_limit == "750m" + assert py_config.sidecar_memory_limit == "768Mi" + assert py_config.sidecar_cpu_request == "200m" + assert py_config.sidecar_memory_request == "384Mi" + + def test_get_pool_configs_different_resources_per_language(self): + """Test get_pool_configs supports different resources for each language.""" + import os + + from src.config import Settings + + env_vars = { + "LANG_CPU_LIMIT_PY": "500m", + "LANG_MEMORY_LIMIT_PY": "512Mi", + "LANG_CPU_LIMIT_GO": "2", + "LANG_MEMORY_LIMIT_GO": "2Gi", + "LANG_CPU_LIMIT_RS": "4", + "LANG_MEMORY_LIMIT_RS": "4Gi", + } + + with patch.dict(os.environ, env_vars, clear=False): + settings = Settings() + configs = settings.get_pool_configs() + + py_config = next(c for c in configs if c.language == "py") + go_config = next(c for c in configs if c.language == "go") + rs_config = next(c for c in configs if c.language == "rs") + + # Python - smaller resources + assert py_config.sidecar_cpu_limit == "500m" + assert py_config.sidecar_memory_limit == "512Mi" + + # Go - medium resources + assert go_config.sidecar_cpu_limit == "2" + assert go_config.sidecar_memory_limit == "2Gi" + + # Rust - larger resources + assert rs_config.sidecar_cpu_limit == "4" + assert rs_config.sidecar_memory_limit == "4Gi" diff --git a/tests/unit/test_sidecar_network_isolation.py b/tests/unit/test_sidecar_network_isolation.py new file mode 100644 index 0000000..6a8bbf7 --- /dev/null +++ b/tests/unit/test_sidecar_network_isolation.py @@ -0,0 +1,179 @@ +"""Tests for sidecar network isolation functionality. + +These tests verify that the network isolation overrides are correctly applied +to environment variables for languages that require network access (e.g., Go). +""" + +import pytest + + +def apply_network_isolation_overrides(env: dict[str, str], language: str, network_isolated: bool) -> dict[str, str]: + """Replicate the network isolation logic from sidecar for testing. + + This mirrors apply_network_isolation_overrides() from docker/sidecar/main.py + """ + if not network_isolated: + return env + + # Go: Disable module proxy and checksum database for offline operation + if language in ("go",): + env["GOPROXY"] = "off" + env["GOSUMDB"] = "off" + + return env + + +class TestNetworkIsolationOverrides: + """Tests for apply_network_isolation_overrides function.""" + + def test_no_override_when_not_isolated(self): + """Environment should not be modified when network isolation is disabled.""" + env = { + "PATH": "/usr/local/go/bin:/usr/bin", + "GOPROXY": "https://proxy.golang.org,direct", + "GOSUMDB": "sum.golang.org", + } + original_goproxy = env["GOPROXY"] + original_gosumdb = env["GOSUMDB"] + + result = apply_network_isolation_overrides(env, "go", network_isolated=False) + + assert result["GOPROXY"] == original_goproxy + assert result["GOSUMDB"] == original_gosumdb + + def test_go_override_when_isolated(self): + """Go environment variables should be set to 'off' when isolated.""" + env = { + "PATH": "/usr/local/go/bin:/usr/bin", + "GOPROXY": "https://proxy.golang.org,direct", + "GOSUMDB": "sum.golang.org", + "GOCACHE": "/mnt/data/go-build", + } + + result = apply_network_isolation_overrides(env, "go", network_isolated=True) + + assert result["GOPROXY"] == "off" + assert result["GOSUMDB"] == "off" + # Other env vars should be preserved + assert result["GOCACHE"] == "/mnt/data/go-build" + assert result["PATH"] == "/usr/local/go/bin:/usr/bin" + + def test_python_not_affected(self): + """Python environment should not be affected by network isolation.""" + env = { + "PATH": "/usr/local/bin:/usr/bin", + "PYTHONPATH": "/app", + } + original_env = env.copy() + + result = apply_network_isolation_overrides(env, "python", network_isolated=True) + + assert result == original_env + + def test_javascript_not_affected(self): + """JavaScript environment should not be affected by network isolation.""" + env = { + "PATH": "/usr/local/bin:/usr/bin", + "NODE_ENV": "production", + } + original_env = env.copy() + + result = apply_network_isolation_overrides(env, "js", network_isolated=True) + + assert result == original_env + + def test_go_env_created_if_missing(self): + """Go proxy vars should be set even if not present in original env.""" + env = { + "PATH": "/usr/local/go/bin:/usr/bin", + } + + result = apply_network_isolation_overrides(env, "go", network_isolated=True) + + assert result["GOPROXY"] == "off" + assert result["GOSUMDB"] == "off" + + def test_preserves_other_go_env_vars(self): + """Other Go environment variables should be preserved.""" + env = { + "PATH": "/usr/local/go/bin:/usr/bin", + "GOPROXY": "https://proxy.golang.org,direct", + "GOSUMDB": "sum.golang.org", + "GO111MODULE": "on", + "GOCACHE": "/mnt/data/go-build", + "GOMODCACHE": "/go/pkg/mod", + } + + result = apply_network_isolation_overrides(env, "go", network_isolated=True) + + assert result["GOPROXY"] == "off" + assert result["GOSUMDB"] == "off" + assert result["GO111MODULE"] == "on" + assert result["GOCACHE"] == "/mnt/data/go-build" + assert result["GOMODCACHE"] == "/go/pkg/mod" + + +class TestNetworkIsolationEnvParsing: + """Tests for NETWORK_ISOLATED environment variable parsing.""" + + @pytest.mark.parametrize( + "env_value,expected", + [ + ("true", True), + ("True", True), + ("TRUE", True), + ("1", True), + ("yes", True), + ("Yes", True), + ("false", False), + ("False", False), + ("0", False), + ("no", False), + ("", False), + ("invalid", False), + ], + ) + def test_network_isolated_parsing(self, env_value: str, expected: bool): + """Test various string values for NETWORK_ISOLATED env var parsing.""" + # Replicate the parsing logic from sidecar + parsed = env_value.lower() in ("true", "1", "yes") + assert parsed == expected + + +class TestLanguageSpecificBehavior: + """Tests for language-specific network isolation behavior.""" + + def test_all_supported_languages(self): + """Test network isolation behavior for all supported languages.""" + base_env = {"PATH": "/usr/bin"} + languages_affected = ["go"] + languages_not_affected = [ + "python", + "py", + "javascript", + "js", + "typescript", + "ts", + "rust", + "rs", + "java", + "c", + "cpp", + "php", + "r", + "fortran", + "f90", + "d", + "dlang", + ] + + for lang in languages_affected: + env = base_env.copy() + result = apply_network_isolation_overrides(env, lang, network_isolated=True) + assert "GOPROXY" in result, f"{lang} should have GOPROXY set" + assert result["GOPROXY"] == "off", f"{lang} should have GOPROXY=off" + + for lang in languages_not_affected: + env = base_env.copy() + result = apply_network_isolation_overrides(env, lang, network_isolated=True) + assert "GOPROXY" not in result, f"{lang} should not have GOPROXY set"