recipe-maintainer: public snapshot (secrets + deployment plans removed, single commit)
Sanitized single-commit public mirror of recipe-maintainer. - Removed test-ssh/.testenv (live creds); added test-ssh/.testenv.example placeholders. - Removed plans/ and planned-updates/ (deployment-planning docs) so no client/ deployment domains appear in the public repo. - All other secret stores were already gitignored. - docs.coopcloud.tech retained as a submodule (public upstream).
This commit is contained in:
166
lib/ssh.py
Normal file
166
lib/ssh.py
Normal file
@ -0,0 +1,166 @@
|
||||
"""SSH command runner for direct server operations.
|
||||
|
||||
Used for docker service ls, reading secrets from containers,
|
||||
checking service logs, and other operations that require
|
||||
server-side access bypassing abra.
|
||||
"""
|
||||
|
||||
from lib.abra import run, AbraResult
|
||||
|
||||
|
||||
def ssh_run(server: str, command: str, *,
|
||||
timeout: int = 30) -> AbraResult:
|
||||
"""Run a command on a remote server via SSH."""
|
||||
return run(f'ssh {server} "{command}"', check=False, timeout=timeout)
|
||||
|
||||
|
||||
def docker_service_ls(server: str, stack_prefix: str) -> list[dict]:
|
||||
"""List Docker services for a stack, parsed into dicts.
|
||||
|
||||
Returns list of {"name": str, "replicas": str, "image": str}.
|
||||
"""
|
||||
result = ssh_run(
|
||||
server,
|
||||
f"docker service ls --filter 'name={stack_prefix}_'"
|
||||
f" --format '{{{{.Name}}}}|{{{{.Replicas}}}}|{{{{.Image}}}}'",
|
||||
timeout=30,
|
||||
)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
return []
|
||||
|
||||
services = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 3:
|
||||
services.append({
|
||||
"name": parts[0],
|
||||
"replicas": parts[1],
|
||||
"image": parts[2],
|
||||
})
|
||||
return services
|
||||
|
||||
|
||||
def all_replicas_ready(server: str, stack_prefix: str) -> bool:
|
||||
"""Check if all Docker services have their desired replica count.
|
||||
|
||||
Returns True if every service shows N/N replicas (e.g. "1/1").
|
||||
Returns False if any service is not ready or no services found.
|
||||
"""
|
||||
result = ssh_run(
|
||||
server,
|
||||
f"docker service ls --filter 'name={stack_prefix}_'"
|
||||
f" --format '{{{{.Replicas}}}}'",
|
||||
timeout=30,
|
||||
)
|
||||
if not result.ok:
|
||||
return False
|
||||
|
||||
lines = [l.strip() for l in result.stdout.strip().split("\n") if l.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
|
||||
for replicas in lines:
|
||||
parts = replicas.split("/")
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
if parts[0] != parts[1]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def read_container_secrets(server: str, stack_prefix: str) -> dict[str, str]:
|
||||
"""Read all Docker secrets from running containers of a stack.
|
||||
|
||||
SSHes into the server, finds all containers for the stack,
|
||||
reads /run/secrets/ from each container, returns {name: value}.
|
||||
"""
|
||||
cmd = (
|
||||
f"containers=\\$(docker ps -q --filter 'name={stack_prefix}_' 2>/dev/null); "
|
||||
f"if [ -z \\\"\\$containers\\\" ]; then exit 0; fi; "
|
||||
f"for cid in \\$containers; do "
|
||||
f" files=\\$(docker exec \\$cid ls /run/secrets/ 2>/dev/null) || continue; "
|
||||
f" for f in \\$files; do "
|
||||
f" val=\\$(docker exec \\$cid cat /run/secrets/\\$f 2>/dev/null) || continue; "
|
||||
f" echo \\\"\\$f=\\$val\\\"; "
|
||||
f" done; "
|
||||
f"done"
|
||||
)
|
||||
result = ssh_run(server, cmd, timeout=60)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
return {}
|
||||
|
||||
secrets = {}
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if "=" in line:
|
||||
name, value = line.split("=", 1)
|
||||
if name not in secrets: # first occurrence wins
|
||||
secrets[name] = value
|
||||
return secrets
|
||||
|
||||
|
||||
def docker_service_logs(server: str, stack_name: str, service: str, *,
|
||||
since: str = "5m", tail: int = 100) -> str:
|
||||
"""Get Docker service logs via SSH (avoids abra app logs which hangs)."""
|
||||
result = ssh_run(
|
||||
server,
|
||||
f"docker service logs {stack_name}_{service} --since {since} --tail {tail} 2>&1",
|
||||
timeout=30,
|
||||
)
|
||||
return result.stdout if result.ok else ""
|
||||
|
||||
|
||||
def docker_secret_ls(server: str, stack_prefix: str) -> list[str]:
|
||||
"""List Docker secret names matching a stack prefix."""
|
||||
result = ssh_run(
|
||||
server,
|
||||
f"docker secret ls --filter 'name={stack_prefix}_' --format '{{{{.Name}}}}'",
|
||||
timeout=15,
|
||||
)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
return []
|
||||
return [l.strip() for l in result.stdout.strip().split("\n") if l.strip()]
|
||||
|
||||
|
||||
def docker_volume_ls(server: str, stack_prefix: str) -> list[str]:
|
||||
"""List Docker volume names for a stack.
|
||||
|
||||
Returns a list of volume names. Raises RuntimeError if the SSH
|
||||
command fails (a failed command is a real error, not "no volumes").
|
||||
"""
|
||||
result = ssh_run(
|
||||
server,
|
||||
f"docker volume ls --filter 'name={stack_prefix}_' --format '{{{{.Name}}}}'",
|
||||
timeout=15,
|
||||
)
|
||||
if not result.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to list volumes on {server}: {result.stderr.strip()}"
|
||||
)
|
||||
if not result.stdout.strip():
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
||||
|
||||
|
||||
def docker_ps(server: str, *, filter_name: str | None = None) -> list[dict]:
|
||||
"""List Docker containers on the server."""
|
||||
cmd = "docker ps --format '{{.Names}}|{{.Status}}|{{.Image}}'"
|
||||
if filter_name:
|
||||
cmd = f"docker ps --filter 'name={filter_name}' --format '{{{{.Names}}}}|{{{{.Status}}}}|{{{{.Image}}}}'"
|
||||
result = ssh_run(server, cmd, timeout=30)
|
||||
if not result.ok or not result.stdout.strip():
|
||||
return []
|
||||
|
||||
containers = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
parts = line.strip().split("|")
|
||||
if len(parts) >= 3:
|
||||
containers.append({
|
||||
"name": parts[0],
|
||||
"status": parts[1],
|
||||
"image": parts[2],
|
||||
})
|
||||
return containers
|
||||
Reference in New Issue
Block a user