feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""Opt-in live forced-SFTP chroot coverage; all SSH keys are generated under tmp_path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Backup, Execution, Repository
|
||||
from backup_tool.snapshot import verify_published_snapshot
|
||||
from backup_tool.worker import Worker
|
||||
from sqlalchemy import select
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PASSWORD = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.environ.get("BACKUP_TOOL_SSH_INTEGRATION") == "1"
|
||||
|
||||
|
||||
def _port() -> int:
|
||||
with socket.socket() as listener:
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
address = listener.getsockname()
|
||||
if not isinstance(address, tuple) or not isinstance(address[1], int):
|
||||
raise RuntimeError("could not allocate SSH fixture port")
|
||||
return address[1]
|
||||
|
||||
|
||||
def _wait(port: int) -> None:
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
raise AssertionError("SSHD fixture did not become reachable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sshd_fixture(tmp_path: Path):
|
||||
fixture = tmp_path / "fixture"
|
||||
host = fixture / "host"
|
||||
source = fixture / "source"
|
||||
host.mkdir(parents=True)
|
||||
source.mkdir()
|
||||
fixture.chmod(0o755)
|
||||
host.chmod(0o755)
|
||||
source.chmod(0o755)
|
||||
private = fixture / "client"
|
||||
for target in (host / "ssh_host_ed25519_key", private):
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(target)],
|
||||
check=True,
|
||||
)
|
||||
(host / "ssh_host_ed25519_key").chmod(0o644)
|
||||
(fixture / "authorized_keys").write_text(private.with_suffix(".pub").read_text())
|
||||
# Public keys are copied into a backup-owned 0600 tmpfs file at startup.
|
||||
(fixture / "authorized_keys").chmod(0o644)
|
||||
port = _port()
|
||||
environment = os.environ | {
|
||||
"SSH_FIXTURE_DIR": str(fixture),
|
||||
"SSH_FIXTURE_PORT": str(port),
|
||||
"COMPOSE_PROJECT_NAME": f"backup-tool-ssh-{os.getpid()}-{port}",
|
||||
}
|
||||
command = ["docker", "compose", "-f", "tests/compose.ssh.yaml"]
|
||||
try:
|
||||
subprocess.run([*command, "up", "--build", "-d"], cwd=ROOT, env=environment, check=True)
|
||||
_wait(port)
|
||||
host_key = " ".join(host.joinpath("ssh_host_ed25519_key.pub").read_text().split()[:2])
|
||||
yield {
|
||||
"port": port,
|
||||
"key": private,
|
||||
"host_key": host_key,
|
||||
"source": source,
|
||||
"env": environment,
|
||||
}
|
||||
finally:
|
||||
subprocess.run(
|
||||
[*command, "down", "--volumes", "--remove-orphans"],
|
||||
cwd=ROOT,
|
||||
env=environment,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
async def _login(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
response = await client.post("/api/v2/setup", json={"username": "admin", "password": PASSWORD})
|
||||
assert response.status_code == 201
|
||||
return {"X-CSRF-Token": client.cookies["backup_tool_csrf"]}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _enabled(), reason="set BACKUP_TOOL_SSH_INTEGRATION=1")
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_sftp_chroot_probe_backup_and_restore(app_client, sshd_fixture) -> None:
|
||||
client, settings = app_client
|
||||
source = sshd_fixture["source"]
|
||||
(source / "nested").mkdir()
|
||||
(source / "nested" / "hello.txt").write_text("hello ssh\n")
|
||||
headers = await _login(client)
|
||||
secret = await client.post(
|
||||
"/api/v2/admin/secrets",
|
||||
json={"purpose": "ssh_private_key", "value": sshd_fixture["key"].read_text()},
|
||||
headers=headers,
|
||||
)
|
||||
assert secret.status_code == 201
|
||||
remote = await client.post(
|
||||
"/api/v2/sources",
|
||||
json={
|
||||
"name": "ssh",
|
||||
"kind": "ssh",
|
||||
"private_key_secret_id": secret.json()["id"],
|
||||
"public_config": {
|
||||
"hostname": "127.0.0.1",
|
||||
"port": sshd_fixture["port"],
|
||||
"username": "backup",
|
||||
"host_key": sshd_fixture["host_key"],
|
||||
"root": "/",
|
||||
},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert remote.status_code == 201, remote.text
|
||||
assert (
|
||||
await client.post(f"/api/v2/sources/{remote.json()['id']}/probe", headers=headers)
|
||||
).json() == {"entry_count": 1}
|
||||
repository = await client.post(
|
||||
"/api/v2/repositories",
|
||||
json={"name": "repo", "relative_path": "ssh"},
|
||||
headers=headers,
|
||||
)
|
||||
job = await client.post(
|
||||
"/api/v2/jobs",
|
||||
json={
|
||||
"name": "ssh-job",
|
||||
"source_id": remote.json()["id"],
|
||||
"repository_id": repository.json()["id"],
|
||||
"requested_mode": "full",
|
||||
"exclusions": [],
|
||||
"retention": {},
|
||||
"enabled": True,
|
||||
"allow_empty": False,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
execution = await client.post(f"/api/v2/jobs/{job.json()['id']}/executions", headers=headers)
|
||||
worker = Worker(settings, owner="ssh-live")
|
||||
try:
|
||||
assert await worker.run_once()
|
||||
finally:
|
||||
await worker.engine.dispose()
|
||||
engine = create_engine(settings)
|
||||
try:
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
async with async_sessionmaker(engine, expire_on_commit=False)() as db:
|
||||
stored = await db.get(Execution, execution.json()["id"])
|
||||
backup = await db.scalar(
|
||||
select(Backup).where(Backup.execution_id == execution.json()["id"])
|
||||
)
|
||||
stored_repository = await db.get(Repository, repository.json()["id"])
|
||||
finally:
|
||||
await engine.dispose()
|
||||
assert stored is not None and stored.state == "committed", (
|
||||
stored.operator_message if stored else None
|
||||
)
|
||||
assert backup is not None and stored_repository is not None
|
||||
manifest = verify_published_snapshot(
|
||||
Path(stored_repository.root),
|
||||
Path(stored_repository.root) / "manifests" / f"{backup.manifest_id}.json",
|
||||
stored_repository.signing_public_key,
|
||||
)
|
||||
assert any(entry["path"] == "data/nested/hello.txt" for entry in manifest["entries"])
|
||||
destination = settings.restore_roots[0] / "ssh-restored"
|
||||
restore = await client.post(
|
||||
f"/api/v2/backups/{backup.id}/restores",
|
||||
json={
|
||||
"destination": str(destination),
|
||||
"selection": ["data/nested"],
|
||||
"overwrite_policy": "fail",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert restore.status_code == 202
|
||||
restore_worker = Worker(settings, owner="ssh-restore")
|
||||
try:
|
||||
assert await restore_worker.run_once()
|
||||
finally:
|
||||
await restore_worker.engine.dispose()
|
||||
assert (destination / "data" / "nested" / "hello.txt").read_text() == "hello ssh\n"
|
||||
Reference in New Issue
Block a user