Run deployment preflight through client image
This commit is contained in:
@@ -14,7 +14,8 @@ It can therefore be used unchanged on cache and archive nodes.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Run it on the Docker host being checked.
|
- Run it on the Docker host being checked.
|
||||||
- Use Python 3.11 or newer (the script uses the standard-library `tomllib`).
|
- Use Python 3.8 or newer. The script reads the mounted configuration through
|
||||||
|
the client image, so it does not depend on the host Python TOML library.
|
||||||
- Have Docker CLI access to the daemon.
|
- Have Docker CLI access to the daemon.
|
||||||
- Have the checkout containing the script available on that host, or copy only
|
- Have the checkout containing the script available on that host, or copy only
|
||||||
this script into the node's compose directory.
|
this script into the node's compose directory.
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import os
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -121,6 +120,33 @@ def run_client_check(container: str, config: str, flag: str) -> None:
|
|||||||
print(result.stdout.strip())
|
print(result.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def mounted_config(container: str, config: str) -> dict[str, Any]:
|
||||||
|
"""Read normalized non-secret config through the client image itself."""
|
||||||
|
|
||||||
|
program = '''import json,sys
|
||||||
|
from pathlib import Path
|
||||||
|
from archive_clients.config import ClientConfig
|
||||||
|
config=ClientConfig.load(Path(sys.argv[1]))
|
||||||
|
value={"shared_token_file":str(config.shared_token_file)}
|
||||||
|
value["qbittorrent"]={"api_root":str(config.qbittorrent.api_root),"local_root":str(config.qbittorrent.local_root),"password_file":str(config.qbittorrent.password_file)}
|
||||||
|
value["syncthing"]={"api_root":str(config.syncthing.api_root),"local_root":str(config.syncthing.local_root),"api_key_file":str(config.syncthing.api_key_file),"local_path_overrides":{str(api):str(local) for api,local in config.syncthing.local_path_overrides}}
|
||||||
|
print(json.dumps(value,sort_keys=True))'''
|
||||||
|
result = subprocess.run(
|
||||||
|
["docker", "exec", container, "python", "-c", program, config],
|
||||||
|
check=False, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
detail = result.stderr.strip() or result.stdout.strip() or "failed"
|
||||||
|
raise CheckFailure(f"cannot load mounted client config: {detail}")
|
||||||
|
try:
|
||||||
|
value = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise CheckFailure("mounted client config output is invalid") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise CheckFailure("mounted client config is invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def run_service_check(container: str, config: str) -> None:
|
def run_service_check(container: str, config: str) -> None:
|
||||||
program = '''import json,sys
|
program = '''import json,sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -155,13 +181,17 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
help="client.toml path inside the client container")
|
help="client.toml path inside the client container")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
with args.client_config.open("rb") as source:
|
|
||||||
config = tomllib.load(source)
|
|
||||||
qb = config["qbittorrent"]
|
|
||||||
sync = config["syncthing"]
|
|
||||||
client = docker_inspect(args.client_container)
|
client = docker_inspect(args.client_container)
|
||||||
syncthing = docker_inspect(args.syncthing_container)
|
syncthing = docker_inspect(args.syncthing_container)
|
||||||
qbittorrent = docker_inspect(args.qbittorrent_container)
|
qbittorrent = docker_inspect(args.qbittorrent_container)
|
||||||
|
configured_host_path = map_path(client, args.container_config).source
|
||||||
|
if configured_host_path.resolve(strict=False) != args.client_config.resolve(strict=False):
|
||||||
|
raise CheckFailure(
|
||||||
|
"--client-config does not match the file mounted into the client"
|
||||||
|
)
|
||||||
|
config = mounted_config(args.client_container, args.container_config)
|
||||||
|
qb = config["qbittorrent"]
|
||||||
|
sync = config["syncthing"]
|
||||||
|
|
||||||
client_qb = map_path(client, qb["local_root"])
|
client_qb = map_path(client, qb["local_root"])
|
||||||
client_route = map_path(client, route_local_path(sync))
|
client_route = map_path(client, route_local_path(sync))
|
||||||
@@ -185,7 +215,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
require_regular_secret(map_path(client, service[key]).source)
|
require_regular_secret(map_path(client, service[key]).source)
|
||||||
run_client_check(args.client_container, args.container_config, "--check-config")
|
run_client_check(args.client_container, args.container_config, "--check-config")
|
||||||
run_service_check(args.client_container, args.container_config)
|
run_service_check(args.client_container, args.container_config)
|
||||||
except (CheckFailure, KeyError, OSError, tomllib.TOMLDecodeError) as exc:
|
except (CheckFailure, KeyError, OSError) as exc:
|
||||||
print(f"preflight failed: {exc}", file=sys.stderr)
|
print(f"preflight failed: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print("preflight passed: bind mappings, secrets, filesystem capabilities, and local APIs are healthy")
|
print("preflight passed: bind mappings, secrets, filesystem capabilities, and local APIs are healthy")
|
||||||
|
|||||||
Reference in New Issue
Block a user