Files

389 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""给 deploy/deploy.sh 的测试用的假 docker(tests/test_deploy_script.py):不碰本机的 docker,状态放一个 JSON 文件里。
只认 deploy.sh 用到的那些子命令和 inspect 格式,认不出的直接报错退出(测试就会失败),免得假的悄悄放过脚本的新用法。
每次调用记一行 JSON(argv)到 FAKE_DOCKER_LOG,测试按它核对先后顺序和参数。用完就删的容器(run --rm)里要做的事照做:
python -m app.ops 真跑(/data 换成挂进来的目录),chown 不做,mkdir、pg_dump 在目录里建文件,copy-to-postgres 按
FAKE_COPY_FAIL 成功或失败。
几个开关(环境变量):
FAKE_COPY_FAIL=dry|real copy-to-postgres 的核对(--dry-run)或正式导入失败
FAKE_PGDUMP_FAIL=1 pg_dump 失败
FAKE_PSQL_FAIL=1 docker exec … psql 失败
FAKE_UNHEALTHY_ENV=<变量名> 带这个环境变量(-e)起的容器健康检查不过
FAKE_UNHEALTHY_IMAGE=<镜像> 用这个镜像起的容器健康检查不过
FAKE_RESTARTING_IMAGE=<镜像> 用这个镜像起的容器一直在重启(restarting)
"""
from __future__ import annotations
import json
import os
import platform
import re
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
# 测试里 import 这个模块只为 host_arch,不设这两个变量
STATE = Path(os.environ.get("FAKE_DOCKER_STATE", "fake-docker-state.json"))
LOG = Path(os.environ.get("FAKE_DOCKER_LOG", "fake-docker-calls.jsonl"))
VALUE_OPTS = {
"--name", "--label", "--restart", "--network", "-e", "--env", "-v", "--volume", "-p", "--publish", "--env-file",
"--entrypoint", "--user", "-u", "--health-cmd", "--health-interval", "--health-timeout", "--health-retries",
"--health-start-period", "--stop-timeout", "--log-opt", "--security-opt", "--device", "--group-add", "--tmpfs",
}
FLAG_OPTS = {"-d", "--detach", "--rm", "-i", "-t", "-it"}
def host_arch() -> str:
machine = platform.machine().lower()
return {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(machine, machine)
def load() -> dict:
state = json.loads(STATE.read_text()) if STATE.exists() else {}
state.setdefault("images", {})
state.setdefault("containers", {})
# 网络、数据卷:名字 → 标签。测试里直接写列表(只有名字、没标签)的也认
for kind in ("networks", "volumes"):
value = state.get(kind) or {}
state[kind] = {name: {} for name in value} if isinstance(value, list) else value
return state
def save(state: dict) -> None:
STATE.write_text(json.dumps(state, ensure_ascii=False, indent=1))
def fail(message: str, code: int = 1) -> None:
print(message, file=sys.stderr)
sys.exit(code)
def unknown(argv: list[str]) -> None:
fail(f"fake docker: 认不出 {argv!r}", 99)
def image_info(state: dict, ref: str) -> dict | None:
return state["images"].get(ref)
def container_health(c: dict) -> str:
if c["state"] != "running":
return c.get("health", "")
bad_env = os.environ.get("FAKE_UNHEALTHY_ENV")
if bad_env and bad_env in c["env"]:
return "unhealthy"
if os.environ.get("FAKE_UNHEALTHY_IMAGE") and c["image"] == os.environ["FAKE_UNHEALTHY_IMAGE"]:
return "unhealthy"
return "healthy"
def fmt_container(c: dict, fmt: str) -> str:
if fmt == "{{.State.Status}}":
return c["state"]
if fmt == "{{.State.Running}}":
return "true" if c["state"] == "running" else "false"
if fmt == "{{.Config.Image}}":
return c["image"]
if ".Mounts" in fmt and (m := re.search(r'eq \.Destination "([^"]+)"', fmt)):
# .Source(绑定挂载的宿主机路径)和 .Name(数据卷名)这里存的是同一个值
return c["mounts"].get(m.group(1), "")
if ".State.Health" in fmt:
return container_health(c)
if ".Config.Labels" in fmt and (m := re.search(r'index \.Config\.Labels "([^"]+)"', fmt)):
return c["labels"].get(m.group(1), "")
if ".HostConfig.PortBindings" in fmt:
return "".join(p.rsplit(":", 2)[-2] for p in c["ports"])
raise KeyError(fmt)
def fmt_image(img: dict, fmt: str) -> str:
if fmt == "{{.Architecture}}":
return img["arch"]
if fmt == "{{.Os}}/{{.Architecture}}":
return f"linux/{img['arch']}"
if fmt == "{{.Config.User}}":
return img.get("user", "")
if '"agentlab.microvm"' in fmt:
return img.get("microvm", "0")
raise KeyError(fmt)
def parse_run(args: list[str]) -> tuple[dict, list[str]]:
opts: dict = {"env": {}, "labels": {}, "mounts": {}, "ports": [], "flags": set(), "volumes": []}
i = 0
while i < len(args):
a = args[i]
if a in FLAG_OPTS:
opts["flags"].add(a)
i += 1
continue
if a.startswith("-") and "=" in a and a.split("=", 1)[0] in VALUE_OPTS:
a, value = a.split("=", 1)
i += 1
elif a in VALUE_OPTS:
value = args[i + 1]
i += 2
elif a.startswith("-"):
unknown(["run", *args])
else:
break
if a in ("-e", "--env"):
key, _, val = value.partition("=")
opts["env"][key] = val
elif a == "--label":
key, _, val = value.partition("=")
opts["labels"][key] = val
elif a in ("-v", "--volume"):
src, dst = value.split(":")[:2]
opts["mounts"][dst] = src
if not src.startswith("/"):
opts["volumes"].append(src)
elif a in ("-p", "--publish"):
opts["ports"].append(value)
else:
opts[a] = value
return opts, args[i:]
def to_host(arg: str, data: str | None) -> str:
if data and (arg == "/data" or arg.startswith("/data/")):
return data + arg[len("/data"):]
return arg
def run_oneoff(state: dict, opts: dict, image: str, command: list[str]) -> int:
data = opts["mounts"].get("/data")
entry = opts.get("--entrypoint")
if entry == "python" and command[:2] == ["-m", "app.ops"]:
sys.path.insert(0, str(BACKEND))
from app import ops
rest = [to_host(a, data) for a in command[2:]]
if rest and rest[0] in ("postgres-secrets", "state", "backup-sqlite", "backup-dir") and "--data" not in rest:
rest[1:1] = ["--data", data or "/data"]
return ops.main(rest)
if entry == "python" and command[:3] == ["-m", "app.db", "copy-to-postgres"]:
which = "dry" if "--dry-run" in command else "real"
if os.environ.get("FAKE_COPY_FAIL") == which:
print("没有导入:fake 的失败", file=sys.stderr)
return 1
print("核对通过")
return 0
if entry == "chown":
return 0
if entry == "mkdir":
for a in command:
if not a.startswith("-"):
Path(to_host(a, data)).mkdir(parents=True, exist_ok=True)
return 0
if entry == "sh" and command[:1] == ["-c"] and 'pg_dump "$@"' in command[1]:
# sh -c '<脚本>' pg_dump <参数…>:照脚本里的 umask 建文件
umask = 0o077 if "umask 077" in command[1] else 0o022
if os.environ.get("FAKE_PGDUMP_FAIL"):
print("pg_dump: fake 的失败", file=sys.stderr)
return 1
args = command[3:]
out = Path(to_host(args[args.index("-f") + 1], data))
fd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o666 & ~umask)
with os.fdopen(fd, "wb") as fh:
fh.write(b"PGDMP fake")
return 0
unknown(["run", "--rm", image, *command])
return 99
def main(argv: list[str]) -> int:
with LOG.open("a") as fh:
fh.write(json.dumps(argv, ensure_ascii=False) + "\n")
state = load()
if not argv:
unknown(argv)
cmd, args = argv[0], argv[1:]
if cmd == "version":
print("27.0.0-fake")
return 0
if cmd == "image" and args[:1] == ["inspect"]:
fmt = None
rest = args[1:]
if rest[:1] == ["-f"]:
fmt, rest = rest[1], rest[2:]
img = image_info(state, rest[0])
if img is None:
fail(f"Error: No such image: {rest[0]}")
if fmt:
try:
print(fmt_image(img, fmt))
except KeyError:
unknown(argv)
else:
print("[{}]")
return 0
if cmd == "load":
tar = Path(args[args.index("-i") + 1])
for line in tar.read_text().splitlines():
ref, _, user = line.partition(" ")
if ref:
state["images"][ref] = {"arch": host_arch(), "user": user, "microvm": "0"}
print(f"Loaded image: {ref}")
save(state)
return 0
if cmd == "rmi":
for ref in args:
state["images"].pop(ref, None)
save(state)
return 0
if cmd == "container" and args[:1] == ["inspect"]:
return 0 if args[1] in state["containers"] else 1
if cmd == "inspect":
if args[:1] != ["-f"]:
unknown(argv)
fmt, name = args[1], args[2]
c = state["containers"].get(name)
if c is None:
fail(f"Error: No such object: {name}")
try:
print(fmt_container(c, fmt))
except KeyError:
unknown(argv)
return 0
if cmd == "run":
opts, rest = parse_run(args)
image, command = rest[0], rest[1:]
if image_info(state, image) is None:
fail(f"Unable to find image '{image}' locally", 125)
network = opts.get("--network")
if network and network not in ("none", "host", "bridge") and network not in state["networks"]:
fail(f"docker: Error response from daemon: network {network} not found.", 125)
if "--rm" in opts["flags"]:
return run_oneoff(state, opts, image, command)
name = opts.get("--name")
if not name or "-d" not in opts["flags"]:
unknown(argv)
if name in state["containers"]:
fail(f'docker: Error response from daemon: Conflict. The container name "/{name}" is already in use.', 125)
for vol in opts["volumes"]:
state["volumes"].setdefault(vol, {})
flapping = os.environ.get("FAKE_RESTARTING_IMAGE") == image
state["containers"][name] = {
"image": image, "state": "restarting" if flapping else "running", "env": opts["env"],
"labels": opts["labels"], "mounts": opts["mounts"], "ports": opts["ports"], "network": network or "bridge",
"restart": opts.get("--restart", "no"),
}
save(state)
print("f" * 64)
return 0
if cmd == "update":
name = args[-1]
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
for i, a in enumerate(args[:-1]):
if a.startswith("--restart="):
c["restart"] = a.split("=", 1)[1]
elif a == "--restart":
c["restart"] = args[i + 1]
save(state)
return 0
if cmd in ("start", "stop", "restart"):
names = [a for a in args if not a.startswith("-") and not a.isdigit()]
for name in names:
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
c["state"] = "exited" if cmd == "stop" else "running"
save(state)
return 0
if cmd == "rm":
force = "-f" in args
for name in (a for a in args if not a.startswith("-")):
c = state["containers"].get(name)
if c is None:
fail(f"Error response from daemon: No such container: {name}")
if c["state"] == "running" and not force:
fail(f"Error response from daemon: cannot remove container {name}: container is running")
del state["containers"][name]
save(state)
return 0
if cmd == "rename":
old, new = args
if old not in state["containers"] or new in state["containers"]:
fail(f"Error response from daemon: cannot rename {old} to {new}")
state["containers"][new] = state["containers"].pop(old)
save(state)
return 0
if cmd == "exec":
name, command = args[0], args[1:]
c = state["containers"].get(name)
if c is None or c["state"] != "running":
fail(f"Error response from daemon: container {name} is not running")
if command[:1] == ["psql"]:
return 1 if os.environ.get("FAKE_PSQL_FAIL") else 0
if command[:1] == ["python"]:
print("子进程(fake)")
return 0
if command[:1] == ["pg_isready"]:
return 0
unknown(argv)
if cmd == "logs":
print("fake 的日志")
return 0
if cmd == "port":
c = state["containers"].get(args[0])
if c is None:
return 1
for p in c["ports"]:
host_part = p.rsplit(":", 1)[0]
print(host_part)
return 0
if cmd in ("network", "volume"):
kind = cmd + "s"
sub, name = args[0], args[-1]
if sub == "inspect":
if name not in state[kind]:
return 1
if len(args) >= 3 and args[1] == "-f":
m = re.search(r'index \.Labels "([^"]+)"', args[2])
if not m:
unknown(argv)
print(state[kind][name].get(m.group(1), ""))
return 0
if sub == "create":
labels = dict(a.split("=", 1) for a in (args[i + 1] for i, x in enumerate(args) if x == "--label"))
if name in state[kind] and cmd == "network":
fail(f"network with name {name} already exists")
state[kind].setdefault(name, labels)
save(state)
return 0
if sub == "rm":
if cmd == "network" and any(c["network"] == name for c in state["containers"].values()):
fail(f"error while removing network: network {name} has active endpoints")
state[kind].pop(name, None)
save(state)
return 0
unknown(argv)
unknown(argv)
return 99
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))