From 1abec083e73439b4760a75953412337a459ceb0a Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 13 Apr 2026 13:31:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(nanoclaude):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=20=E2=80=94=20Dockerfile=20+=20self-SSH=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EB=B6=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: infra/ 복사, openssh-client, healthcheck 추가 - requirements.txt: asyncssh, python-dotenv 추가 - core/ssh.py: INFRA_LOCAL_HOST 환경변수로 self-SSH 대신 로컬 실행 Co-Authored-By: Claude Opus 4.6 (1M context) --- infra/core/ssh.py | 21 +++++++++++++++++++++ nanoclaude/Dockerfile | 21 +++++++++++++++++++-- nanoclaude/requirements.txt | 2 ++ 3 files changed, 42 insertions(+), 2 deletions(-) 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