Let pushes go by an HTTPS token or an SSH deploy key
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s
The push credential is either a push-only account's token, handed to git through a credential helper that names environment variables, never values, or a deploy key in a short-lived ssh-agent. The remote URL follows the credential. Configured as gitea.push_user and push_token, or gitea.push_key. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
761ebdb857
commit
c931ac939b
15 files changed
+241
-81
No files matched your search
@@ -151,7 +151,7 @@ def build_offline(tmp_path: Path, origin: Path, monkeypatch: Any) -> Any:
|
||||
)
|
||||
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
|
||||
"nisse_dev.agent.GiteaClient.clone_url", lambda self, repo: str(origin)
|
||||
)
|
||||
return build_agent(config)
|
||||
|
||||
@@ -355,7 +355,7 @@ def test_tuning_reaches_every_component(
|
||||
)
|
||||
monkeypatch.setattr("nisse_dev.agent.MatrixTransport", lambda cfg: FakeTransport())
|
||||
monkeypatch.setattr(
|
||||
"nisse_dev.agent.GiteaClient.ssh_url", lambda self, repo: str(origin)
|
||||
"nisse_dev.agent.GiteaClient.clone_url", lambda self, repo: str(origin)
|
||||
)
|
||||
agent = build_agent(config)
|
||||
executor = agent.orchestrator._executor
|
||||
|
||||
@@ -351,3 +351,12 @@ def test_a_bad_build_host_port_is_reported() -> None:
|
||||
def test_the_push_key_is_optional_and_a_path() -> None:
|
||||
assert build().push_key is None
|
||||
assert build(gitea__push_key="~/k").push_key == Path("~/k").expanduser()
|
||||
|
||||
|
||||
def test_a_push_token_needs_its_user_and_excludes_a_key() -> None:
|
||||
config = build(gitea__push_user="pusher", gitea__push_token="${TEST_GITEA_TOKEN}")
|
||||
assert (config.push_user, config.push_token) == ("pusher", "gt")
|
||||
with pytest.raises(ConfigError, match="set both or neither"):
|
||||
build(gitea__push_user="pusher")
|
||||
with pytest.raises(ConfigError, match="not both"):
|
||||
build(gitea__push_user="p", gitea__push_token="t", gitea__push_key="~/k")
|
||||
+61
-10
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitError, GitRepo
|
||||
from nisse_dev.git import GitError, GitRepo, HTTPSToken, SSHKey
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -76,16 +76,16 @@ def test_commits_since_and_remote_sha_against_a_local_remote(
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, str]:
|
||||
def ssh_remote(ssh_host: SSHHost, tmp_path: Path) -> tuple[str, SSHKey]:
|
||||
"""A bare repository reached by git over SSH, and the key that may push
|
||||
to it: the forge, as the build VM sees it."""
|
||||
remote = bare_remote(tmp_path / "forge.git")
|
||||
url = f"ssh://{ssh_host.user}@127.0.0.1:{ssh_host.port}{remote}"
|
||||
return url, ssh_host._key.read_text()
|
||||
return url, SSHKey(ssh_host._key.read_text())
|
||||
|
||||
|
||||
def test_clone_and_push_with_the_key_store_no_credential(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
url, key = ssh_remote
|
||||
seed = GitRepo(ssh_host, tmp_path / "seed", known_hosts=str(tmp_path / "kh"))
|
||||
@@ -93,10 +93,10 @@ def test_clone_and_push_with_the_key_store_no_credential(
|
||||
seed.configure_identity("t", "t@example.test")
|
||||
(seed.root / "a.txt").write_text("a")
|
||||
seed.commit_all("first")
|
||||
seed.push(url, "main", key=key)
|
||||
seed.push(url, "main", credential=key)
|
||||
|
||||
clone = GitRepo(ssh_host, tmp_path / "clone", known_hosts=str(tmp_path / "kh"))
|
||||
clone.clone(url, key=key)
|
||||
clone.clone(url, credential=key)
|
||||
assert (clone.root / "a.txt").read_text() == "a"
|
||||
config = (clone.root / ".git" / "config").read_text()
|
||||
assert "PRIVATE KEY" not in config
|
||||
@@ -105,7 +105,7 @@ def test_clone_and_push_with_the_key_store_no_credential(
|
||||
|
||||
|
||||
def test_the_key_is_gone_when_the_command_ends(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
url, key = ssh_remote
|
||||
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
||||
@@ -114,12 +114,12 @@ def test_the_key_is_gone_when_the_command_ends(
|
||||
(repo.root / "a.txt").write_text("a")
|
||||
repo.commit_all("first")
|
||||
before = _agents()
|
||||
repo.push(url, "main", key=key)
|
||||
repo.push(url, "main", credential=key)
|
||||
assert _agents() == before # the throwaway ssh-agent was killed
|
||||
|
||||
|
||||
def test_a_failed_push_still_kills_its_agent(
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, str]
|
||||
ssh_host: SSHHost, tmp_path: Path, ssh_remote: tuple[str, SSHKey]
|
||||
) -> None:
|
||||
_, key = ssh_remote
|
||||
repo = GitRepo(ssh_host, tmp_path / "w", known_hosts=str(tmp_path / "kh"))
|
||||
@@ -127,7 +127,9 @@ def test_a_failed_push_still_kills_its_agent(
|
||||
before = _agents()
|
||||
with pytest.raises(GitError):
|
||||
repo.push(
|
||||
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git", "main", key=key
|
||||
f"ssh://nobody@127.0.0.1:{ssh_host.port}/nowhere.git",
|
||||
"main",
|
||||
credential=key,
|
||||
)
|
||||
assert _agents() == before
|
||||
|
||||
@@ -144,3 +146,52 @@ def test_a_remote_url_is_quoted_not_interpreted(repo: GitRepo, tmp_path: Path) -
|
||||
with pytest.raises(GitError):
|
||||
repo.push(f"x; touch {shlex.quote(str(marker))}", "main")
|
||||
assert not marker.exists()
|
||||
|
||||
|
||||
class RecordingHost:
|
||||
"""Captures what would be sent to the build VM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def execute(self, command: str, **kwargs):
|
||||
from nisse_dev.execution import Result
|
||||
|
||||
self.calls.append({"command": command, **kwargs})
|
||||
return Result(0, "", "")
|
||||
|
||||
|
||||
def test_an_https_token_travels_only_as_a_secret(tmp_path: Path) -> None:
|
||||
host = RecordingHost()
|
||||
repo = GitRepo(host, tmp_path) # type: ignore[arg-type]
|
||||
token = "tok-" + "9f3a" * 6
|
||||
repo.push(
|
||||
"https://gitea.test/o/r.git", "m1", credential=HTTPSToken("pusher", token)
|
||||
)
|
||||
[call] = host.calls
|
||||
assert token not in call["command"] # never on a command line
|
||||
assert "pusher" not in call["command"]
|
||||
assert call["secrets"] == {"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": token}
|
||||
# Git asks a helper that reads the environment, and any helper configured
|
||||
# on the VM is cleared first.
|
||||
assert "credential.helper= " in call["command"]
|
||||
assert "$NISSE_PUSH_TOKEN" in call["command"]
|
||||
assert "GIT_TERMINAL_PROMPT=0" in call["command"]
|
||||
|
||||
|
||||
def test_an_https_token_answers_git_through_the_helper(
|
||||
ssh_host: SSHHost, tmp_path: Path
|
||||
) -> None:
|
||||
# The helper as git runs it, on the build host: asked to "get", it
|
||||
# answers from the environment; asked anything else, nothing.
|
||||
from nisse_dev.git import _HELPER
|
||||
|
||||
body = _HELPER.removeprefix("!")
|
||||
asked = ssh_host.execute(
|
||||
f"{body} get",
|
||||
secrets={"NISSE_PUSH_USER": "pusher", "NISSE_PUSH_TOKEN": "tok-1234567"},
|
||||
)
|
||||
# Both travel as secrets, so both are masked in what comes back.
|
||||
assert asked.stdout == "username=[redacted]\npassword=[redacted]\n"
|
||||
stored = ssh_host.execute(f"{body} store", secrets={"NISSE_PUSH_TOKEN": "x"})
|
||||
assert stored.stdout == ""
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from nisse_dev.execution import SSHHost
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.git import GitRepo, HTTPSToken
|
||||
from nisse_dev.gitea import GiteaError, PullRequest
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.publish import OPEN_PR_INTENT, MilestonePublisher
|
||||
@@ -77,7 +77,7 @@ class FakeGit:
|
||||
return self.repo.commits_since(ref)
|
||||
|
||||
def remote_sha(
|
||||
self, remote_url: str, branch: str, *, key: str | None = None
|
||||
self, remote_url: str, branch: str, *, credential: object = None
|
||||
) -> str | None:
|
||||
return self.remote.get(branch)
|
||||
|
||||
@@ -90,7 +90,7 @@ class FakeGit:
|
||||
def commit_all(self, message: str) -> str | None:
|
||||
return self.repo.commit_all(message)
|
||||
|
||||
def push(self, remote_url: str, branch: str, *, key: str | None = None) -> None:
|
||||
def push(self, remote_url: str, branch: str, *, credential: object = None) -> None:
|
||||
self.pushed.append(branch)
|
||||
self.remote[branch] = self.repo.head_sha()
|
||||
|
||||
@@ -111,7 +111,7 @@ def build(store: StateStore, git: Any, client: Any) -> MilestonePublisher:
|
||||
"o/sandbox",
|
||||
git,
|
||||
remote_url="ssh://git@gitea.test/o/sandbox.git",
|
||||
push_key="KEY",
|
||||
push_credential=HTTPSToken("pusher", "tok"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user