Files
backup-docker-to-local/src/baudolo/backup/docker.py
T

143 lines
4.3 KiB
Python
Raw Normal View History

from __future__ import annotations
from typing import TYPE_CHECKING
from .shell import BackupError, execute_shell_command
if TYPE_CHECKING:
from collections.abc import Sequence
def docker_exec_argv(
container: str,
argv: Sequence[str],
*,
interactive: bool = False,
forward_env: Sequence[str] = (),
) -> list[str]:
"""The argv that runs *argv* inside *container*.
Args:
container: the container to run in.
argv: the command, already split.
interactive: keep stdin open, for a command that is fed a dump.
forward_env: names of environment variables to hand to the container.
Passed as bare ``-e NAME``, so docker copies the value out of this
process's own environment; spelling ``-e NAME=value`` instead would
publish a secret in the host's process list.
Returns:
The argv list.
"""
forwarded = [arg for name in forward_env for arg in ("-e", name)]
return [
"docker",
"exec",
*(["-i"] if interactive else []),
*forwarded,
container,
*argv,
]
def get_image_info(container: str) -> str:
return execute_shell_command(
["docker", "inspect", "--format", "{{.Config.Image}}", container]
)[0]
def image_id(container: str) -> str:
"""The container's image ID, identical for every replica of one image."""
return execute_shell_command(
["docker", "inspect", "--format", "{{.Image}}", container]
)[0].strip()
def has_tool(container: str, tool: str) -> bool:
"""Whether *tool* runs inside the container.
Executes the binary rather than asking a shell for it: a distroless image
has no shell, and `sh -c 'command -v'` would answer "absent" for every
tool it ships.
"""
try:
execute_shell_command(docker_exec_argv(container, [tool, "--version"]))
except BackupError:
return False
return True
def docker_volume_names() -> list[str]:
return execute_shell_command(["docker", "volume", "ls", "--format", "{{.Name}}"])
def containers_using_volume(volume_name: str) -> list[str]:
return execute_shell_command(
[
"docker",
"ps",
"--filter",
f"volume={volume_name}",
"--format",
"{{.Names}}",
]
)
def is_swarm_task(container: str) -> bool:
"""Swarm-managed task containers must never be stopped or started
manually: the orchestrator replaces the stopped task and a later
`docker start` fails on the detached overlay network. A container that
vanished between listing and inspect (--rm one-shots, task-history GC)
counts as not stoppable instead of aborting the whole backup run; if the
container still exists the inspect failure re-raises, so a broken daemon
keeps failing the run loudly instead of silently skipping the stop."""
try:
out = execute_shell_command(
[
"docker",
"inspect",
"--format",
'{{index .Config.Labels "com.docker.swarm.task.id"}}',
container,
]
)
except BackupError:
still_listed = execute_shell_command(
[
"docker",
"ps",
"-a",
"--filter",
f"name=^{container}$",
"--format",
"{{.Names}}",
]
)
if still_listed and still_listed[0].strip():
raise
return True
return bool(out and out[0].strip())
def filter_stoppable(containers: list[str]) -> list[str]:
"""Containers baudolo may stop/start itself (everything but swarm tasks)."""
stoppable = []
for container in containers:
if is_swarm_task(container):
print(
f"Skipping stop/start for swarm task container '{container}'.",
flush=True,
)
continue
stoppable.append(container)
return stoppable
def change_containers_status(containers: list[str], status: str) -> None:
"""Stop or start a list of containers."""
if not containers:
print(f"No containers to {status}.", flush=True)
return
print(f"{status.capitalize()} containers: {' '.join(containers)}...", flush=True)
execute_shell_command(["docker", status, *containers])