- Добавлен molecule docker create playbook (create.yml + tasks/create_network.yml) с правкой tmpfs: словарь из molecule-plugins приводится к списку строк для community.docker.docker_container; сценарии копируют playbook и задают provisioner.playbooks.create. - Для systemd-платформ tmpfs задаётся списком строк вместо mounts. - В опциях ОС — run_platform (каноническая архитектура после build); в TestHostSpec и hosts теста передаётся platform в molecule/docker_container, чтобы на ARM не падал /sbin/init из-за amd64 без --platform. - Страницы роли (просмотр и создание): одна dashboard-карточка на всю ширину, вкладки Role details / Role file catalog в
395 lines
15 KiB
Python
395 lines
15 KiB
Python
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections.abc import Generator
|
|
from pathlib import Path
|
|
|
|
# Collections required by Molecule docker driver are baked into the runner image (Dockerfile); skip Galaxy during runs.
|
|
_MOLECULE_DEPENDENCY = {"name": "galaxy", "enabled": False}
|
|
# Docker targets often lack a usable ~/.ansible; use RAM-backed tmp in target containers.
|
|
_MOLECULE_REMOTE_TMP = "/dev/shm/.ansible-remote-tmp"
|
|
_MOLECULE_DOCKER_PLAYBOOK_SRC = Path(__file__).resolve().parent / "molecule_docker_playbook"
|
|
|
|
|
|
def _molecule_runtime_path() -> str:
|
|
"""Ensure `docker` from /usr/local/bin is visible to molecule and ansible-playbook children."""
|
|
prefix = "/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin"
|
|
tail = (os.environ.get("PATH") or "").strip()
|
|
return f"{prefix}:{tail}" if tail else prefix
|
|
|
|
|
|
def _molecule_provisioner_env() -> dict[str, str]:
|
|
return {"PATH": _molecule_runtime_path(), "ANSIBLE_REMOTE_TMP": _MOLECULE_REMOTE_TMP}
|
|
|
|
|
|
def _molecule_group_vars(extra_vars: dict | None) -> dict:
|
|
"""Inject stable transport vars for community.docker while preserving user extra vars."""
|
|
base = dict(extra_vars or {})
|
|
# docker connection may default to a non-writable user inside the target image
|
|
base.setdefault("ansible_user", "root")
|
|
base.setdefault("ansible_remote_tmp", _MOLECULE_REMOTE_TMP)
|
|
return base
|
|
|
|
|
|
def _platform_command(host: dict) -> str | list[str]:
|
|
raw = str(host.get("command", "tail -f /dev/null") or "").strip()
|
|
if not raw:
|
|
raw = "tail -f /dev/null"
|
|
try:
|
|
parts = shlex.split(raw)
|
|
except Exception: # noqa: BLE001
|
|
return raw
|
|
return parts if len(parts) > 1 else (parts[0] if parts else raw)
|
|
|
|
|
|
def _platform_systemd_overrides(host: dict) -> dict:
|
|
if not bool(host.get("systemd")):
|
|
return {}
|
|
# Systemd-in-Docker needs cgroup rw + writable /run. molecule-plugins may merge Podman-style dict tmpfs;
|
|
# vendored provisioner create.yml converts that mapping to docker_container's list-of-strings format.
|
|
return {
|
|
"tmpfs": [
|
|
"/run:rw,noexec,nosuid,size=65536k",
|
|
"/run/lock:rw,noexec,nosuid,size=65536k",
|
|
],
|
|
"volumes": ["/sys/fs/cgroup:/sys/fs/cgroup:rw"],
|
|
"cgroupns_mode": "host",
|
|
}
|
|
|
|
|
|
def run_ansible_playbook(
|
|
playbook_yaml: str,
|
|
inventory_text: str,
|
|
extra_vars: dict,
|
|
) -> Generator[str, None, int]:
|
|
with tempfile.TemporaryDirectory(prefix="roleforge-") as temp_dir:
|
|
playbook_path = os.path.join(temp_dir, "playbook.yml")
|
|
inventory_path = os.path.join(temp_dir, "inventory.ini")
|
|
with open(playbook_path, "w", encoding="utf-8") as f:
|
|
f.write(playbook_yaml)
|
|
with open(inventory_path, "w", encoding="utf-8") as f:
|
|
f.write(inventory_text)
|
|
|
|
command = [
|
|
"ansible-playbook",
|
|
playbook_path,
|
|
"-i",
|
|
inventory_path,
|
|
"--extra-vars",
|
|
json.dumps(extra_vars),
|
|
]
|
|
env = os.environ.copy()
|
|
env["ANSIBLE_FORCE_COLOR"] = "1"
|
|
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
env=env,
|
|
)
|
|
|
|
assert process.stdout is not None
|
|
for line in process.stdout:
|
|
yield line.rstrip("\n")
|
|
return_code = process.wait()
|
|
yield f"__RETURN_CODE__:{return_code}"
|
|
|
|
|
|
def _molecule_ansible_cfg_text() -> str:
|
|
return (
|
|
"[defaults]\n"
|
|
"stdout_callback = ansible.builtin.default\n"
|
|
"remote_user = root\n"
|
|
f"remote_tmp = {_MOLECULE_REMOTE_TMP}\n"
|
|
"pipelining = True\n"
|
|
"\n"
|
|
"[callback_default]\n"
|
|
"result_format = yaml\n"
|
|
)
|
|
|
|
|
|
def _write_molecule_ansible_cfgs(project_root: Path, scenario_dir: Path) -> None:
|
|
"""Write ansible.cfg at project root and under the scenario (Molecule cwd varies)."""
|
|
text = _molecule_ansible_cfg_text()
|
|
(project_root / "ansible.cfg").write_text(text, encoding="utf-8")
|
|
(scenario_dir / "ansible.cfg").write_text(text, encoding="utf-8")
|
|
|
|
|
|
def _install_molecule_docker_playbook(scenario_dir: Path) -> None:
|
|
"""Vendor molecule-plugins docker create playbook (+tasks/) into the scenario; tmpfs line patched."""
|
|
src_root = _MOLECULE_DOCKER_PLAYBOOK_SRC
|
|
for path in src_root.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
rel = path.relative_to(src_root)
|
|
dest = scenario_dir / rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(path, dest)
|
|
|
|
|
|
def _run_streaming(command: list[str], cwd: str, env: dict[str, str]) -> Generator[str, None, int]:
|
|
process = subprocess.Popen(
|
|
command,
|
|
cwd=cwd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
env=env,
|
|
)
|
|
assert process.stdout is not None
|
|
for line in process.stdout:
|
|
text = line.rstrip("\n")
|
|
# Molecule docker plugin noise: keep logs focused on actionable output.
|
|
if "Driver docker does not provide a schema." in text:
|
|
continue
|
|
yield text
|
|
code = process.wait()
|
|
yield f"__RETURN_CODE__:{code}"
|
|
|
|
|
|
def _run_molecule_step(command: list[str], cwd: str, env: dict[str, str]) -> Generator[str, None, int]:
|
|
"""Stream a molecule step and return early when it fails."""
|
|
rc = 1
|
|
for line in _run_streaming(command=command, cwd=cwd, env=env):
|
|
if line.startswith("__RETURN_CODE__:"):
|
|
try:
|
|
rc = int(line.split(":", 1)[1])
|
|
except Exception: # noqa: BLE001
|
|
rc = 1
|
|
yield line
|
|
return rc
|
|
|
|
|
|
def _dump_container_debug(name: str, tail: int = 200) -> Generator[str, None, None]:
|
|
"""Emit inspect/status and recent logs for a docker container into test output."""
|
|
inspect_proc = subprocess.run(
|
|
["docker", "inspect", "-f", "{{.State.Status}}|{{.State.ExitCode}}|{{.State.Error}}", name],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if inspect_proc.returncode == 0:
|
|
yield f"[roleforge] container state ({name}): {inspect_proc.stdout.strip()}"
|
|
else:
|
|
yield f"[roleforge] container inspect failed ({name})"
|
|
if inspect_proc.stderr:
|
|
for line in inspect_proc.stderr.splitlines():
|
|
yield line
|
|
return
|
|
|
|
logs_proc = subprocess.run(
|
|
["docker", "logs", f"--tail={tail}", name],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if logs_proc.returncode != 0:
|
|
yield f"[roleforge] container logs unavailable ({name})"
|
|
if logs_proc.stderr:
|
|
for line in logs_proc.stderr.splitlines():
|
|
yield line
|
|
return
|
|
yield f"[roleforge] container logs ({name}) begin"
|
|
body = (logs_proc.stdout or "").splitlines()
|
|
for line in body:
|
|
yield line
|
|
yield f"[roleforge] container logs ({name}) end"
|
|
|
|
|
|
def _docker_tmp_preflight(hosts: list[dict]) -> Generator[str, None, bool]:
|
|
"""Prepare ansible tmp dir in Molecule target containers before converge."""
|
|
ok = True
|
|
for host in hosts:
|
|
name = str(host.get("name") or "").strip()
|
|
if not name:
|
|
continue
|
|
state = subprocess.run(
|
|
["docker", "inspect", "-f", "{{.State.Running}}", name],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if state.returncode != 0:
|
|
ok = False
|
|
yield f"[roleforge] preflight failed ({name}): cannot inspect container"
|
|
if state.stderr:
|
|
for line in state.stderr.splitlines():
|
|
yield line
|
|
continue
|
|
if state.stdout.strip().lower() != "true":
|
|
start = subprocess.run(["docker", "start", name], capture_output=True, text=True)
|
|
if start.returncode != 0:
|
|
ok = False
|
|
yield f"[roleforge] preflight failed ({name}): container is stopped and cannot be started"
|
|
if start.stderr:
|
|
for line in start.stderr.splitlines():
|
|
yield line
|
|
yield from _dump_container_debug(name)
|
|
continue
|
|
yield f"[roleforge] preflight: started stopped container {name}"
|
|
|
|
cmd = [
|
|
"docker",
|
|
"exec",
|
|
name,
|
|
"sh",
|
|
"-lc",
|
|
"mkdir -p /tmp/.ansible-remote-tmp && chmod 1777 /tmp /tmp/.ansible-remote-tmp",
|
|
]
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.returncode == 0:
|
|
yield f"[roleforge] preflight ok: {name}"
|
|
continue
|
|
ok = False
|
|
yield f"[roleforge] preflight failed ({name}), exit={proc.returncode}"
|
|
if proc.stdout:
|
|
for line in proc.stdout.splitlines():
|
|
yield line
|
|
if proc.stderr:
|
|
for line in proc.stderr.splitlines():
|
|
yield line
|
|
yield from _dump_container_debug(name)
|
|
return ok
|
|
|
|
|
|
def run_molecule_test_for_playbook(
|
|
playbook_yaml: str,
|
|
hosts: list[dict],
|
|
extra_vars: dict,
|
|
) -> Generator[str, None, int]:
|
|
hosts = hosts or [{"name": "test-node-1", "groups": ["all"]}]
|
|
with tempfile.TemporaryDirectory(prefix="roleforge-molecule-") as temp_dir:
|
|
root = Path(temp_dir)
|
|
scenario_dir = root / "molecule" / "default"
|
|
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
_write_molecule_ansible_cfgs(root, scenario_dir)
|
|
_install_molecule_docker_playbook(scenario_dir)
|
|
platforms = []
|
|
for host in hosts:
|
|
item = {
|
|
"name": host["name"],
|
|
"image": host.get("image", "roleforge-backend:latest"),
|
|
"command": _platform_command(host),
|
|
"user": "root",
|
|
"privileged": host.get("privileged", True),
|
|
"pre_build_image": True,
|
|
}
|
|
item.update(_platform_systemd_overrides(host))
|
|
plat = str(host.get("platform") or "").strip()
|
|
if plat:
|
|
item["platform"] = plat
|
|
platforms.append(item)
|
|
|
|
molecule_config = {
|
|
"dependency": _MOLECULE_DEPENDENCY,
|
|
"driver": {"name": "docker"},
|
|
"platforms": platforms,
|
|
"provisioner": {
|
|
"name": "ansible",
|
|
"env": _molecule_provisioner_env(),
|
|
"playbooks": {"create": "create.yml", "converge": "converge.yml"},
|
|
"inventory": {"group_vars": {"all": _molecule_group_vars(extra_vars)}},
|
|
},
|
|
"scenario": {"name": "default", "converge_sequence": ["converge"]},
|
|
}
|
|
(scenario_dir / "molecule.yml").write_text(
|
|
json.dumps(molecule_config, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
(scenario_dir / "converge.yml").write_text(playbook_yaml, encoding="utf-8")
|
|
env = os.environ.copy()
|
|
env["PATH"] = _molecule_runtime_path()
|
|
env["ANSIBLE_REMOTE_TMP"] = _MOLECULE_REMOTE_TMP
|
|
env["ANSIBLE_FORCE_COLOR"] = "1"
|
|
create_command = ["molecule", "create", "-s", "default"]
|
|
create_rc = yield from _run_molecule_step(command=create_command, cwd=str(root), env=env)
|
|
if create_rc != 0:
|
|
return
|
|
preflight_ok = yield from _docker_tmp_preflight(hosts)
|
|
if not preflight_ok:
|
|
yield "__RETURN_CODE__:1"
|
|
return
|
|
# No extra `-v` on ansible-playbook — verbosity prints full result dicts (JSON blobs) on skip/ok.
|
|
converge_command = ["molecule", "converge", "-s", "default"]
|
|
yield from _run_streaming(command=converge_command, cwd=str(root), env=env)
|
|
|
|
|
|
def run_molecule_test_for_role(
|
|
role_name: str,
|
|
role_tasks_yaml: str,
|
|
hosts: list[dict],
|
|
extra_vars: dict,
|
|
) -> Generator[str, None, int]:
|
|
hosts = hosts or [{"name": "test-node-1", "groups": ["all"]}]
|
|
with tempfile.TemporaryDirectory(prefix="roleforge-molecule-role-") as temp_dir:
|
|
root = Path(temp_dir)
|
|
roles_dir = root / "roles" / role_name / "tasks"
|
|
roles_dir.mkdir(parents=True, exist_ok=True)
|
|
(roles_dir / "main.yml").write_text(role_tasks_yaml, encoding="utf-8")
|
|
|
|
scenario_dir = root / "molecule" / "default"
|
|
scenario_dir.mkdir(parents=True, exist_ok=True)
|
|
_write_molecule_ansible_cfgs(root, scenario_dir)
|
|
_install_molecule_docker_playbook(scenario_dir)
|
|
converge = (
|
|
"- hosts: all\n"
|
|
" gather_facts: true\n"
|
|
" pre_tasks:\n"
|
|
" - name: Prepare Ansible remote tmp\n"
|
|
" raw: mkdir -p /tmp/.ansible-remote-tmp && chmod 1777 /tmp /tmp/.ansible-remote-tmp\n"
|
|
" roles:\n"
|
|
f" - role: {role_name}\n"
|
|
)
|
|
(scenario_dir / "converge.yml").write_text(converge, encoding="utf-8")
|
|
platforms = []
|
|
for host in hosts:
|
|
item = {
|
|
"name": host["name"],
|
|
"image": host.get("image", "roleforge-backend:latest"),
|
|
"command": _platform_command(host),
|
|
"user": "root",
|
|
"privileged": host.get("privileged", True),
|
|
"pre_build_image": True,
|
|
}
|
|
item.update(_platform_systemd_overrides(host))
|
|
plat = str(host.get("platform") or "").strip()
|
|
if plat:
|
|
item["platform"] = plat
|
|
platforms.append(item)
|
|
molecule_config = {
|
|
"dependency": _MOLECULE_DEPENDENCY,
|
|
"driver": {"name": "docker"},
|
|
"platforms": platforms,
|
|
"provisioner": {
|
|
"name": "ansible",
|
|
"env": _molecule_provisioner_env(),
|
|
"playbooks": {"create": "create.yml", "converge": "converge.yml"},
|
|
"inventory": {"group_vars": {"all": _molecule_group_vars(extra_vars)}},
|
|
},
|
|
"scenario": {"name": "default", "converge_sequence": ["converge"]},
|
|
}
|
|
(scenario_dir / "molecule.yml").write_text(
|
|
json.dumps(molecule_config, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
env = os.environ.copy()
|
|
env["PATH"] = _molecule_runtime_path()
|
|
env["ANSIBLE_REMOTE_TMP"] = _MOLECULE_REMOTE_TMP
|
|
env["ANSIBLE_FORCE_COLOR"] = "1"
|
|
env["ANSIBLE_ROLES_PATH"] = str(root / "roles")
|
|
create_command = ["molecule", "create", "-s", "default"]
|
|
create_rc = yield from _run_molecule_step(command=create_command, cwd=str(root), env=env)
|
|
if create_rc != 0:
|
|
return
|
|
preflight_ok = yield from _docker_tmp_preflight(hosts)
|
|
if not preflight_ok:
|
|
yield "__RETURN_CODE__:1"
|
|
return
|
|
converge_command = ["molecule", "converge", "-s", "default"]
|
|
yield from _run_streaming(command=converge_command, cwd=str(root), env=env)
|