mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 17:36:24 +00:00
71a7550f48
Git servers (forge_hosts) become full credential owners: - an encrypted forge token (Fernet, HANDLER_SECRET_KEY) stored per server and never returned by the API (has_token flag only); used automatically by every project on that host and addressable as db:host:<hostname> — the reserved db: credential scheme is now live - a per-server ed25519 SSH deploy key: generated server-side, public half shown in the dashboard to paste into the forge, private half encrypted at rest and materialized 0600 only in the control container (GIT_SSH_COMMAND / core.sshCommand) Project registration gets a git-server mode: pick a registered server, type owner/name, and the API derives the remote (ssh when the server has a deploy key, https otherwise), computes root_dir under PROJECTS_ROOT, and enqueues a new 'sync' command the worker executes (clone, or ff-only pull). Spawn always pulls first, so runs start from the remote's latest state; POST /projects/:p/sync and 'handler sync' re-pull on demand. Schedules: recurring agent spawns (prefix, prompt, interval, role). The worker fires due schedules as ordinary queued spawn commands with timestamped agent names, so runs are fresh stateless agents and appear in the Activity audit trail; missed intervals collapse into one catch-up run. Dashboard: Git Servers pane shows the SSH public key (copy button) and takes a write-only token; Repositories gains the server-first add form and a Pull now button; new Schedules pane. Rebuilt static export. Also restores the missing frontend/lib (api client + format helpers) the components import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
207 lines
7.7 KiB
Python
207 lines
7.7 KiB
Python
"""Project registration in git-server mode: pick a registered server, type owner/name,
|
|
and Handler derives the remote + root_dir and enqueues the clone. The worker's ``sync``
|
|
command (and the reposync module under it) is exercised against the gitops mock seam."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from handler.control import gitops, reposync, worker
|
|
from handler.db import repository as repo
|
|
from handler.db.engine import get_engine
|
|
|
|
|
|
@pytest.fixture
|
|
def secret_key(env, monkeypatch):
|
|
from cryptography.fernet import Fernet
|
|
|
|
from handler import config
|
|
|
|
key = Fernet.generate_key().decode()
|
|
monkeypatch.setenv("HANDLER_SECRET_KEY", key)
|
|
config.get_settings.cache_clear()
|
|
return key
|
|
|
|
|
|
def _add_server(client, auth, hostname="github.com", **extra):
|
|
body = {"hostname": hostname, "forge_type": "github", **extra}
|
|
r = client.post("/hosts", json=body, headers=auth)
|
|
assert r.status_code == 201, r.text
|
|
return r.json()
|
|
|
|
|
|
def test_create_project_from_server_with_ssh_key(client, auth, env, secret_key):
|
|
_add_server(client, auth, generate_ssh_key=True)
|
|
r = client.post(
|
|
"/projects", json={"git_server": "github.com", "repo": "me/CoolProj"}, headers=auth
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
body = r.json()
|
|
# id derived from the repo name; root under PROJECTS_ROOT; ssh remote (a key exists).
|
|
assert body["id"] == "coolproj"
|
|
assert body["root_dir"] == os.path.join(str(env["tmp"] / "projects"), "coolproj")
|
|
assert body["git_remote"] == "git@github.com:me/CoolProj.git"
|
|
# The clone is enqueued for the worker.
|
|
assert body["sync_command_id"] is not None
|
|
cmd = client.get(f"/commands/{body['sync_command_id']}", headers=auth).json()
|
|
assert cmd["type"] == "sync"
|
|
assert cmd["project_id"] == "coolproj"
|
|
|
|
|
|
def test_create_project_from_server_https_without_key(client, auth, env):
|
|
_add_server(client, auth, hostname="git.corp", forge_type="gitea",
|
|
base_url="https://git.corp:8443")
|
|
r = client.post(
|
|
"/projects", json={"git_server": "git.corp", "repo": "me/repo", "id": "corp-repo"},
|
|
headers=auth,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
assert r.json()["git_remote"] == "https://git.corp:8443/me/repo.git"
|
|
assert r.json()["id"] == "corp-repo"
|
|
|
|
|
|
def test_create_project_unknown_server_404(client, auth, env):
|
|
r = client.post(
|
|
"/projects", json={"git_server": "nowhere.example", "repo": "a/b"}, headers=auth
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_create_project_bad_repo_422(client, auth, env):
|
|
r = client.post(
|
|
"/projects", json={"git_server": "github.com", "repo": "not-owner-name"}, headers=auth
|
|
)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_manual_mode_still_requires_id_and_root(client, auth, env):
|
|
r = client.post("/projects", json={"root_dir": "/tmp/x"}, headers=auth)
|
|
assert r.status_code == 422
|
|
r = client.post("/projects", json={"id": "x"}, headers=auth)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_sync_endpoint_enqueues(client, auth, env, tmp_path):
|
|
root = tmp_path / "proj"
|
|
root.mkdir()
|
|
client.post(
|
|
"/projects",
|
|
json={"id": "p1", "root_dir": str(root), "git_remote": "https://github.com/a/b.git"},
|
|
headers=auth,
|
|
)
|
|
r = client.post("/projects/p1/sync", headers=auth)
|
|
assert r.status_code == 202
|
|
assert r.json()["type"] == "sync"
|
|
|
|
|
|
def test_sync_endpoint_400_without_remote(client, auth, env, tmp_path):
|
|
root = tmp_path / "proj2"
|
|
root.mkdir()
|
|
client.post("/projects", json={"id": "p2", "root_dir": str(root)}, headers=auth)
|
|
r = client.post("/projects/p2/sync", headers=auth)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ------------------------------------------------------------------ worker sync command
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_sync_gitops(monkeypatch):
|
|
"""Fake the clone/pull side of the gitops seam."""
|
|
state = {"clone": [], "pull": [], "config": [], "repos": set(), "ok": True, "out": ""}
|
|
|
|
def is_repo(path):
|
|
return path in state["repos"]
|
|
|
|
def clone(remote, dest, env=None, config=None):
|
|
state["clone"].append({"remote": remote, "dest": dest, "env": env or {},
|
|
"config": config or []})
|
|
if state["ok"]:
|
|
state["repos"].add(dest)
|
|
return state["ok"], state["out"]
|
|
|
|
def pull_ff(cwd, env=None):
|
|
state["pull"].append({"cwd": cwd, "env": env or {}})
|
|
return state["ok"], state["out"]
|
|
|
|
def config_local(cwd, key, value):
|
|
state["config"].append({"cwd": cwd, "key": key, "value": value})
|
|
return True, ""
|
|
|
|
monkeypatch.setattr(gitops, "is_repo", is_repo)
|
|
monkeypatch.setattr(gitops, "clone", clone)
|
|
monkeypatch.setattr(gitops, "pull_ff", pull_ff)
|
|
monkeypatch.setattr(gitops, "config_local", config_local)
|
|
return state
|
|
|
|
|
|
def _register(conn, project_id="p", remote="https://github.com/me/repo.git", root="/tmp/r"):
|
|
return repo.create_project(conn, project_id, root_dir=root, git_remote=remote)
|
|
|
|
|
|
def test_cmd_sync_clones_then_pulls(env, fake_sync_gitops):
|
|
with get_engine().begin() as conn:
|
|
_register(conn)
|
|
command = repo.enqueue_command(conn, "sync", project_id="p")
|
|
|
|
result = worker.execute_command(command)
|
|
assert result["action"] == "cloned"
|
|
assert fake_sync_gitops["clone"][0]["remote"] == "https://github.com/me/repo.git"
|
|
|
|
result = worker.execute_command(command)
|
|
assert result["action"] == "pulled"
|
|
assert fake_sync_gitops["pull"][0]["cwd"] == "/tmp/r"
|
|
|
|
|
|
def test_cmd_sync_failure_is_command_error(env, fake_sync_gitops):
|
|
fake_sync_gitops["ok"] = False
|
|
fake_sync_gitops["out"] = "fatal: repository not found"
|
|
with get_engine().begin() as conn:
|
|
_register(conn)
|
|
command = repo.enqueue_command(conn, "sync", project_id="p")
|
|
with pytest.raises(worker.CommandError, match="repository not found"):
|
|
worker.execute_command(command)
|
|
|
|
|
|
def test_sync_uses_server_token_and_installs_helper(env, secret_key, fake_sync_gitops):
|
|
from handler import secretstore
|
|
|
|
with get_engine().begin() as conn:
|
|
repo.create_host(conn, "github.com", "github",
|
|
token_enc=secretstore.encrypt("srv-tok"))
|
|
project = _register(conn)
|
|
|
|
result = reposync.sync_project(project)
|
|
assert result["action"] == "cloned"
|
|
call = fake_sync_gitops["clone"][0]
|
|
# Token flows through the env (never argv/disk), helper is scoped to the host and
|
|
# persisted into the fresh clone for the agents that follow.
|
|
assert call["env"]["FORGE_TOKEN"] == "srv-tok"
|
|
assert call["env"]["GITHUB_TOKEN"] == "srv-tok"
|
|
assert any(k.startswith("credential.https://github.com") for k, _ in call["config"])
|
|
assert any(c["key"].startswith("credential.") for c in fake_sync_gitops["config"])
|
|
|
|
|
|
def test_sync_ssh_remote_uses_deploy_key(env, secret_key, fake_sync_gitops):
|
|
from handler import secretstore, sshkeys
|
|
|
|
private, public = sshkeys.generate_keypair("handler@github.com")
|
|
with get_engine().begin() as conn:
|
|
repo.create_host(conn, "github.com", "github", ssh_public_key=public,
|
|
ssh_private_key_enc=secretstore.encrypt(private))
|
|
project = _register(conn, remote="git@github.com:me/repo.git")
|
|
|
|
result = reposync.sync_project(project)
|
|
assert result["action"] == "cloned"
|
|
env_used = fake_sync_gitops["clone"][0]["env"]
|
|
assert "GIT_SSH_COMMAND" in env_used
|
|
assert "IdentitiesOnly=yes" in env_used["GIT_SSH_COMMAND"]
|
|
# The pinned key is persisted for the agents (core.sshCommand).
|
|
assert any(c["key"] == "core.sshCommand" for c in fake_sync_gitops["config"])
|
|
# And the key file was materialized 0600.
|
|
key_path = env_used["GIT_SSH_COMMAND"].split(" -i ", 1)[1].split(" ")[0]
|
|
with open(key_path) as fh:
|
|
assert "OPENSSH PRIVATE KEY" in fh.read()
|