PRD 0003: Bottle Backend abstraction #5
@@ -15,12 +15,12 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator, Sequence
|
||||||
|
|
||||||
from ... import pipelock
|
from ... import pipelock
|
||||||
from ... import ssh as ssh_mod
|
|
||||||
from ...env_resolve import env_resolve
|
from ...env_resolve import env_resolve
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
|
from ...manifest import SshEntry
|
||||||
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
|
from .. import BottleBackend, BottleCleanupPlan, BottlePlan, BottleSpec
|
||||||
from . import network as network_mod
|
from . import network as network_mod
|
||||||
from . import util as docker_mod
|
from . import util as docker_mod
|
||||||
@@ -88,7 +88,7 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
if agent.skills:
|
if agent.skills:
|
||||||
self.validate_skills(list(agent.skills))
|
self.validate_skills(list(agent.skills))
|
||||||
if bottle.ssh:
|
if bottle.ssh:
|
||||||
ssh_mod.ssh_validate_entries(bottle.ssh)
|
self.validate_ssh_entries(bottle.ssh)
|
||||||
|
|
||||||
env_file = stage_dir / "agent.env"
|
env_file = stage_dir / "agent.env"
|
||||||
args_file = stage_dir / "docker-args"
|
args_file = stage_dir / "docker-args"
|
||||||
@@ -353,16 +353,198 @@ class DockerBottleBackend(BottleBackend):
|
|||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
|
||||||
|
"""Each entry's IdentityFile must exist on the host (after
|
||||||
|
expanding leading ~). Host and IdentityFile shape are already
|
||||||
|
enforced by Manifest validation. Called from `prepare` before
|
||||||
|
the y/N so the user doesn't get prompted for a plan with a
|
||||||
|
missing key."""
|
||||||
|
for entry in entries:
|
||||||
|
key = self._expand_tilde(entry.IdentityFile)
|
||||||
|
if not os.path.isfile(key):
|
||||||
|
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
||||||
|
|
||||||
def provision_ssh(self, plan: BottlePlan, target: str) -> None:
|
def provision_ssh(self, plan: BottlePlan, target: str) -> None:
|
||||||
"""If the bottle has SSH entries, set up the in-container
|
"""Set up SSH in the container so node can authenticate using
|
||||||
ssh-agent and config so node can authenticate without ever
|
each entry's key without the key file being readable by node.
|
||||||
seeing the key bytes. No-op when the bottle has no SSH."""
|
No-op when the bottle has no SSH entries.
|
||||||
|
|
||||||
|
Isolation strategy:
|
||||||
|
- Keys live at /root/.claude-bottle-keys/ (mode 700,
|
||||||
|
root-owned). /root is mode 700 in node:22-slim, so node
|
||||||
|
(uid 1000) can't even traverse in.
|
||||||
|
- ssh-agent runs as root, listening on
|
||||||
|
/run/claude-bottle-agent.sock. Each key is loaded with
|
||||||
|
ssh-add, then deleted; the bytes now live only in the
|
||||||
|
agent process's memory.
|
||||||
|
- ssh-agent's SO_PEERCRED-based UID match rejects every
|
||||||
|
connection whose peer euid is neither 0 nor the agent's.
|
||||||
|
To bridge that, a root-owned socat forwarder listens on
|
||||||
|
/run/claude-bottle-agent-public.sock (mode 666) and
|
||||||
|
proxies bytes to the real agent socket.
|
||||||
|
- node can't ptrace root-owned agent or socat, so
|
||||||
|
/proc/<pid>/mem is off-limits and key bytes never leave
|
||||||
|
root-owned memory.
|
||||||
|
- ~/.ssh/config in node's home points each Host at the
|
||||||
|
public socket via IdentityAgent.
|
||||||
|
|
||||||
|
Why an in-container agent (not bind-mounted from host):
|
||||||
|
Docker Desktop on macOS does not forward Unix-domain socket
|
||||||
|
connect() across the VM boundary — connect() returns
|
||||||
|
ENOTSUP. Running ssh-agent inside the container sidesteps
|
||||||
|
that entirely.
|
||||||
|
|
||||||
|
Limitation: keys must be passphrase-less. ssh-add prompts on
|
||||||
|
/dev/tty for passphrases, but our docker exec has no TTY."""
|
||||||
assert isinstance(plan, DockerBottlePlan)
|
assert isinstance(plan, DockerBottlePlan)
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||||
if not bottle.ssh:
|
if not bottle.ssh:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
container = target
|
||||||
proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug)
|
proxy_host_port = pipelock.pipelock_proxy_host_port(plan.slug)
|
||||||
ssh_mod.ssh_setup(target, plan.stage_dir, proxy_host_port, bottle.ssh)
|
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
||||||
|
container_ssh = f"{container_home}/.ssh"
|
||||||
|
agent_socket = "/run/claude-bottle-agent.sock"
|
||||||
|
public_socket = "/run/claude-bottle-agent-public.sock"
|
||||||
|
keys_dir = "/root/.claude-bottle-keys"
|
||||||
|
|
||||||
|
# ~/.ssh for node (700, owned by node).
|
||||||
|
self._docker_exec_root(container, ["mkdir", "-p", container_ssh])
|
||||||
|
self._docker_exec_root(container, ["chown", "node:node", container_ssh])
|
||||||
|
self._docker_exec_root(container, ["chmod", "700", container_ssh])
|
||||||
|
|
||||||
|
# /root/.claude-bottle-keys for root (700, root-owned).
|
||||||
|
self._docker_exec_root(container, ["mkdir", "-p", keys_dir])
|
||||||
|
self._docker_exec_root(container, ["chown", "root:root", keys_dir])
|
||||||
|
self._docker_exec_root(container, ["chmod", "700", keys_dir])
|
||||||
|
|
||||||
|
config_file = plan.stage_dir / "ssh_config"
|
||||||
|
known_hosts_file = plan.stage_dir / "ssh_known_hosts"
|
||||||
|
config_file.write_text("")
|
||||||
|
config_file.chmod(0o600)
|
||||||
|
known_hosts_file.write_text("")
|
||||||
|
known_hosts_file.chmod(0o600)
|
||||||
|
|
||||||
|
proxy_host, _, proxy_port = proxy_host_port.partition(":")
|
||||||
|
|
||||||
|
container_key_paths: list[str] = []
|
||||||
|
for entry in bottle.ssh:
|
||||||
|
name = entry.Host
|
||||||
|
key = self._expand_tilde(entry.IdentityFile)
|
||||||
|
hostname = entry.Hostname
|
||||||
|
user = entry.User
|
||||||
|
port = entry.Port
|
||||||
|
known_host_key = entry.KnownHostKey
|
||||||
|
|
||||||
|
key_basename = os.path.basename(key)
|
||||||
|
container_key_path = f"{keys_dir}/{key_basename}"
|
||||||
|
|
||||||
|
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", key, f"{container}:{container_key_path}"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
self._docker_exec_root(container, ["chown", "root:root", container_key_path])
|
||||||
|
self._docker_exec_root(container, ["chmod", "600", container_key_path])
|
||||||
|
|
||||||
|
container_key_paths.append(container_key_path)
|
||||||
|
|
||||||
|
# ProxyCommand tunnels SSH through pipelock via HTTP
|
||||||
|
# CONNECT. %h / %p expand to this block's HostName /
|
||||||
|
# Port. socat's PROXY: mode does CONNECT host:port to
|
||||||
|
# the proxy.
|
||||||
|
block = (
|
||||||
|
f"Host {name}\n"
|
||||||
|
f" HostName {hostname}\n"
|
||||||
|
f" User {user}\n"
|
||||||
|
f" Port {port}\n"
|
||||||
|
f" IdentityAgent {public_socket}\n"
|
||||||
|
f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n"
|
||||||
|
f"\n"
|
||||||
|
)
|
||||||
|
with config_file.open("a") as f:
|
||||||
|
f.write(block)
|
||||||
|
|
||||||
|
if known_host_key:
|
||||||
|
entries_to_write: list[str] = []
|
||||||
|
if port == "22":
|
||||||
|
entries_to_write.append(f"{name} {known_host_key}\n")
|
||||||
|
if hostname != name:
|
||||||
|
entries_to_write.append(f"{hostname} {known_host_key}\n")
|
||||||
|
else:
|
||||||
|
entries_to_write.append(f"[{name}]:{port} {known_host_key}\n")
|
||||||
|
if hostname != name:
|
||||||
|
entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n")
|
||||||
|
with known_hosts_file.open("a") as f:
|
||||||
|
for e in entries_to_write:
|
||||||
|
f.write(e)
|
||||||
|
|
||||||
|
# Boot the agent, load each key, delete the key files, then
|
||||||
|
# start the root-owned socat forwarder. One docker exec so the
|
||||||
|
# whole sequence is atomic.
|
||||||
|
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
|
||||||
|
setup_lines = [
|
||||||
|
"set -eu",
|
||||||
|
f"ssh-agent -a {agent_socket} >/dev/null",
|
||||||
|
]
|
||||||
|
for kp in container_key_paths:
|
||||||
|
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
|
||||||
|
setup_lines.append(f"rm -f {kp}")
|
||||||
|
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
|
||||||
|
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
|
||||||
|
setup_lines.append(
|
||||||
|
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
|
||||||
|
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
|
||||||
|
)
|
||||||
|
# Wait briefly for the forwarder to bind.
|
||||||
|
setup_lines.extend([
|
||||||
|
"i=0",
|
||||||
|
"while [ $i -lt 20 ]; do",
|
||||||
|
f" [ -S {public_socket} ] && break",
|
||||||
|
" i=$((i + 1))",
|
||||||
|
" sleep 0.1",
|
||||||
|
"done",
|
||||||
|
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
|
||||||
|
])
|
||||||
|
setup_script = "\n".join(setup_lines) + "\n"
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
info(f"writing {container_ssh}/config")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
|
||||||
|
self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
|
||||||
|
|
||||||
|
if known_hosts_file.stat().st_size > 0:
|
||||||
|
info(f"writing {container_ssh}/known_hosts")
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
self._docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
|
||||||
|
self._docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
|
||||||
|
|
||||||
|
def _docker_exec_root(self, container: str, argv: list[str]) -> None:
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "exec", "-u", "0", container, *argv],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _expand_tilde(self, path: str) -> str:
|
||||||
|
if path.startswith("~"):
|
||||||
|
home = os.environ.get("HOME", "")
|
||||||
|
return home + path[1:]
|
||||||
|
return path
|
||||||
|
|
||||||
def provision_git(self, plan: BottlePlan, target: str) -> None:
|
def provision_git(self, plan: BottlePlan, target: str) -> None:
|
||||||
"""If --cwd was set and the host cwd has a .git directory, copy
|
"""If --cwd was set and the host cwd has a .git directory, copy
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
"""SSH helpers. Validates ssh entries from claude-bottle.json, then sets
|
|
||||||
up SSH inside the container via a root-owned ssh-agent so the `node`
|
|
||||||
user can use the keys for SSH but cannot read the key bytes.
|
|
||||||
|
|
||||||
Why an in-container agent (not bind-mounted from host): Docker Desktop
|
|
||||||
on macOS does not forward Unix-domain socket connect() across the VM
|
|
||||||
boundary — connect() returns ENOTSUP. Running ssh-agent inside the
|
|
||||||
container sidesteps that entirely.
|
|
||||||
|
|
||||||
Isolation:
|
|
||||||
- Keys live at /root/.claude-bottle-keys/ (mode 700, root-owned).
|
|
||||||
/root is mode 700 in node:22-slim, so node (uid 1000) can't even
|
|
||||||
traverse in.
|
|
||||||
- ssh-agent runs as root, listening on /run/claude-bottle-agent.sock.
|
|
||||||
Each key is loaded with ssh-add, then deleted; the bytes now live
|
|
||||||
only in the agent process's memory.
|
|
||||||
- ssh-agent's SO_PEERCRED-based UID match rejects every connection
|
|
||||||
whose peer euid is neither 0 nor the agent's. To bridge that, a
|
|
||||||
root-owned socat forwarder listens on
|
|
||||||
/run/claude-bottle-agent-public.sock (mode 666) and proxies bytes
|
|
||||||
to the real agent socket.
|
|
||||||
- node can't ptrace root-owned agent or socat, so /proc/<pid>/mem is
|
|
||||||
off-limits and key bytes never leave root-owned memory.
|
|
||||||
- ~/.ssh/config in node's home points each Host at the public socket
|
|
||||||
via IdentityAgent.
|
|
||||||
|
|
||||||
Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty
|
|
||||||
for passphrases, but our docker exec has no TTY.
|
|
||||||
|
|
||||||
Each ssh entry has keys: Host, IdentityFile, Hostname, User, Port
|
|
||||||
(required); KnownHostKey (optional).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from .log import die, info
|
|
||||||
from .manifest import SshEntry
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_validate_entries(entries: Sequence[SshEntry]) -> None:
|
|
||||||
"""The IdentityFile must exist on the host (after expanding leading ~).
|
|
||||||
Host and IdentityFile shape are already enforced by Manifest validation."""
|
|
||||||
for entry in entries:
|
|
||||||
key = _expand_tilde(entry.IdentityFile)
|
|
||||||
if not os.path.isfile(key):
|
|
||||||
die(f"ssh key file not found for host '{entry.Host}': {key}")
|
|
||||||
|
|
||||||
|
|
||||||
def ssh_setup(
|
|
||||||
container: str,
|
|
||||||
stage_dir: Path,
|
|
||||||
proxy_host_port: str,
|
|
||||||
entries: Sequence[SshEntry],
|
|
||||||
) -> None:
|
|
||||||
"""Set up SSH in the container so node can authenticate using each
|
|
||||||
entry's key without the key file being readable by node."""
|
|
||||||
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
|
|
||||||
container_ssh = f"{container_home}/.ssh"
|
|
||||||
agent_socket = "/run/claude-bottle-agent.sock"
|
|
||||||
public_socket = "/run/claude-bottle-agent-public.sock"
|
|
||||||
keys_dir = "/root/.claude-bottle-keys"
|
|
||||||
|
|
||||||
# ~/.ssh for node (700, owned by node).
|
|
||||||
_docker_exec_root(container, ["mkdir", "-p", container_ssh])
|
|
||||||
_docker_exec_root(container, ["chown", "node:node", container_ssh])
|
|
||||||
_docker_exec_root(container, ["chmod", "700", container_ssh])
|
|
||||||
|
|
||||||
# /root/.claude-bottle-keys for root (700, root-owned).
|
|
||||||
_docker_exec_root(container, ["mkdir", "-p", keys_dir])
|
|
||||||
_docker_exec_root(container, ["chown", "root:root", keys_dir])
|
|
||||||
_docker_exec_root(container, ["chmod", "700", keys_dir])
|
|
||||||
|
|
||||||
config_file = stage_dir / "ssh_config"
|
|
||||||
known_hosts_file = stage_dir / "ssh_known_hosts"
|
|
||||||
config_file.write_text("")
|
|
||||||
config_file.chmod(0o600)
|
|
||||||
known_hosts_file.write_text("")
|
|
||||||
known_hosts_file.chmod(0o600)
|
|
||||||
|
|
||||||
proxy_host, _, proxy_port = proxy_host_port.partition(":")
|
|
||||||
|
|
||||||
container_key_paths: list[str] = []
|
|
||||||
for entry in entries:
|
|
||||||
name = entry.Host
|
|
||||||
key = _expand_tilde(entry.IdentityFile)
|
|
||||||
hostname = entry.Hostname
|
|
||||||
user = entry.User
|
|
||||||
port = entry.Port
|
|
||||||
known_host_key = entry.KnownHostKey
|
|
||||||
|
|
||||||
key_basename = os.path.basename(key)
|
|
||||||
container_key_path = f"{keys_dir}/{key_basename}"
|
|
||||||
|
|
||||||
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", key, f"{container}:{container_key_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_docker_exec_root(container, ["chown", "root:root", container_key_path])
|
|
||||||
_docker_exec_root(container, ["chmod", "600", container_key_path])
|
|
||||||
|
|
||||||
container_key_paths.append(container_key_path)
|
|
||||||
|
|
||||||
# ProxyCommand tunnels SSH through pipelock via HTTP CONNECT.
|
|
||||||
# %h / %p expand to this block's HostName / Port. socat's
|
|
||||||
# PROXY: mode does CONNECT host:port to the proxy.
|
|
||||||
block = (
|
|
||||||
f"Host {name}\n"
|
|
||||||
f" HostName {hostname}\n"
|
|
||||||
f" User {user}\n"
|
|
||||||
f" Port {port}\n"
|
|
||||||
f" IdentityAgent {public_socket}\n"
|
|
||||||
f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n"
|
|
||||||
f"\n"
|
|
||||||
)
|
|
||||||
with config_file.open("a") as f:
|
|
||||||
f.write(block)
|
|
||||||
|
|
||||||
if known_host_key:
|
|
||||||
entries_to_write: list[str] = []
|
|
||||||
if port == "22":
|
|
||||||
entries_to_write.append(f"{name} {known_host_key}\n")
|
|
||||||
if hostname != name:
|
|
||||||
entries_to_write.append(f"{hostname} {known_host_key}\n")
|
|
||||||
else:
|
|
||||||
entries_to_write.append(f"[{name}]:{port} {known_host_key}\n")
|
|
||||||
if hostname != name:
|
|
||||||
entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n")
|
|
||||||
with known_hosts_file.open("a") as f:
|
|
||||||
for e in entries_to_write:
|
|
||||||
f.write(e)
|
|
||||||
|
|
||||||
# Boot the agent, load each key, delete the key files, then start
|
|
||||||
# the root-owned socat forwarder. One docker exec so the whole
|
|
||||||
# sequence is atomic.
|
|
||||||
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
|
|
||||||
setup_lines = [
|
|
||||||
"set -eu",
|
|
||||||
f"ssh-agent -a {agent_socket} >/dev/null",
|
|
||||||
]
|
|
||||||
for kp in container_key_paths:
|
|
||||||
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
|
|
||||||
setup_lines.append(f"rm -f {kp}")
|
|
||||||
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
|
|
||||||
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
|
|
||||||
setup_lines.append(
|
|
||||||
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
|
|
||||||
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
|
|
||||||
)
|
|
||||||
# Wait briefly for the forwarder to bind.
|
|
||||||
setup_lines.extend([
|
|
||||||
"i=0",
|
|
||||||
"while [ $i -lt 20 ]; do",
|
|
||||||
f" [ -S {public_socket} ] && break",
|
|
||||||
" i=$((i + 1))",
|
|
||||||
" sleep 0.1",
|
|
||||||
"done",
|
|
||||||
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
|
|
||||||
])
|
|
||||||
setup_script = "\n".join(setup_lines) + "\n"
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
info(f"writing {container_ssh}/config")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
|
|
||||||
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
|
|
||||||
|
|
||||||
if known_hosts_file.stat().st_size > 0:
|
|
||||||
info(f"writing {container_ssh}/known_hosts")
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
|
|
||||||
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
|
|
||||||
|
|
||||||
|
|
||||||
def _docker_exec_root(container: str, argv: list[str]) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["docker", "exec", "-u", "0", container, *argv],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _expand_tilde(path: str) -> str:
|
|
||||||
if path.startswith("~"):
|
|
||||||
home = os.environ.get("HOME", "")
|
|
||||||
return home + path[1:]
|
|
||||||
return path
|
|
||||||
Reference in New Issue
Block a user