diff --git a/infra/core/ssh.py b/infra/core/ssh.py index 7537153..2f4ab9d 100644 --- a/infra/core/ssh.py +++ b/infra/core/ssh.py @@ -5,17 +5,24 @@ Provides run_command() which handles: - Password auth + sudo (company NAS) - Timeout / retry - Structured error classification +- Local execution for self-host (INFRA_LOCAL_HOST env) """ from __future__ import annotations import asyncio +import os from datetime import datetime, timezone import asyncssh from ..config import HostConfig, SSH_TIMEOUT, CMD_TIMEOUT, MAX_RETRIES +# If set, commands targeting this host use run_local() instead of SSH. +# Avoids self-SSH issues in Docker containers (Tailscale routing, overhead). +# Example: INFRA_LOCAL_HOST=gpu +_LOCAL_HOST = os.getenv("INFRA_LOCAL_HOST", "") + class SSHError(Exception): """Typed SSH error with error_type classification.""" @@ -45,6 +52,15 @@ async def _connect(host: HostConfig) -> asyncssh.SSHClientConnection: return await asyncssh.connect(**kwargs) +def _is_local_host(host: HostConfig) -> bool: + """Check if this host should use local execution instead of SSH.""" + if not _LOCAL_HOST: + return False + from ..config import HOSTS + local_cfg = HOSTS.get(_LOCAL_HOST) + return local_cfg is not None and host.ip == local_cfg.ip + + async def run_command( host: HostConfig, command: str, @@ -53,9 +69,14 @@ async def run_command( ) -> tuple[str, str]: """Run a command on remote host. Returns (stdout, stderr). + If host matches INFRA_LOCAL_HOST, runs locally instead of SSH. For NAS with sudo: wraps command with sudo using password via stdin. Raises SSHError with typed error_type on failure. """ + # Local execution for self-host + if _is_local_host(host): + return await run_local(command, timeout=timeout) + if use_sudo and host.needs_sudo and host.password: # Pipe password to sudo via stdin command = f"echo '{host.password}' | sudo -S {command}" diff --git a/nanoclaude/Dockerfile b/nanoclaude/Dockerfile index 5e3216e..dbb8192 100644 --- a/nanoclaude/Dockerfile +++ b/nanoclaude/Dockerfile @@ -1,12 +1,29 @@ FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl openssh-client \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /app -COPY requirements.txt . +# Install nanoclaude dependencies +COPY nanoclaude/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt -COPY . . +# Copy nanoclaude app +COPY nanoclaude/ ./ + +# Copy infra/ for infra_tool integration +COPY infra/ ./infra/ + +# Ensure infra has its own dependencies (pydantic, asyncssh already in requirements) +# Python path: /app is WORKDIR, so "from infra.core..." and "from tools..." both work + +RUN mkdir -p /app/data EXPOSE 8100 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -f http://localhost:8100/health || exit 1 + CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8100"] diff --git a/nanoclaude/requirements.txt b/nanoclaude/requirements.txt index 7fae30d..5df2392 100644 --- a/nanoclaude/requirements.txt +++ b/nanoclaude/requirements.txt @@ -6,3 +6,5 @@ aiosqlite==0.20.0 python-multipart==0.0.9 caldav>=1.3.0 vobject>=0.9.8 +asyncssh>=2.22.0 +python-dotenv>=1.0.0