Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/tsk-f2ttez-agent-versions-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Fixed
- Agent state versions API now parses auto-commit subjects that contain `|` by using a non-printable delimiter instead of the pipe character.
- Reverting to the current HEAD returns 200 `{"status": "noop"}` instead of 404; non-ancestor SHAs return 409, and dirty working trees return 409.
- SHA validation tightened to require at least 7 hex characters.
- SSH key files under `.ssh/` are excluded from agent state history via `.gitignore`.
- Deploy results now surface `versioning: false` and `versioning_error` when the state-repository setup fails.
- The auto-committer is installed as a systemd unit with `Restart=always` so it survives container reboots; nohup remains the fallback.
- The committer script now uses `git diff --name-only` for its change summary, discarding the fragile footer heuristic.
- The committer script checks Git command return codes and logs errors to stderr instead of silently swallowing them.
7 changes: 7 additions & 0 deletions changelog.d/tsk-fjmxzo-agent-state-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### Added
- Agent state versioning: a git repo is initialised inside each agent container at deploy time, with a `.gitignore` that excludes secrets and bulk artefacts, and commit identity set to the agent's own slug. An auto-committer script runs as a background loop inside the container, committing dirty trees on a fixed interval with a timestamp + changed-file-summary message (#tsk-fjmxzo).
- Controller API for agent state history: `GET /api/agents/{name}/versions` lists commits, `GET /api/agents/{name}/versions/{sha}/diff` returns the patch for a commit, and `POST /api/agents/{name}/versions/{sha}/revert` reverts the state repo to a prior commit (#tsk-fjmxzo).

### Fixed
- Fixed git_log to propagate Git-log failures to the route with RuntimeError, ensuring HTTP 409 when container is unreachable (tinyagentos/agent_git.py:84)
- Fixed git_revert to use a single git operation without leaving the index/dirty, preventing race condition with agent_committer (tinyagentos/agent_git.py:107)
6 changes: 6 additions & 0 deletions changelog.d/tsk-yn5gze-agent-versions-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Fixed

- Agent state version revert now restores the full snapshot at the target commit instead of inverting a single commit. `git_revert` runs `git revert --no-edit <sha>..HEAD` so the tree matches the requested commit's state, and the revert endpoint asserts `README.md` remains with `notes.txt` absent after the operation.
- `.taos/trace/` is now excluded from the agent state gitignore before the initial commit, preventing trace directory contents from being staged into git history.
- Remote agent container targets are persisted in the agent record and used for all version operations, so remote-deployed agents resolve to `<remote>:taos-agent-{name}` instead of the unqualified local name.
- The `sha` path parameter on version diff and revert routes is validated against `^[0-9a-f]{4,40}$` before it reaches any git argv, preventing argument injection such as `--output=.bashrc`.
100 changes: 100 additions & 0 deletions tests/test_agent_committer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Tests for the agent state auto-committer script."""
from __future__ import annotations

import importlib.util
import os
import subprocess
import sys

import pytest


_COMMITTER_PATH = (
os.path.dirname(__file__).replace("tests", "tinyagentos") + "/scripts/agent_committer.py"
)


def _load_committer(repo_path: str, interval: int = 1):
spec = importlib.util.spec_from_file_location("agent_committer", _COMMITTER_PATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
mod.REPO_PATH = repo_path
mod.INTERVAL = interval
return mod


def _init_repo(tmp_path):
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True, capture_output=True)


class TestAgentCommitter:
def test_commit_creates_commit_for_new_file(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n")
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
)

committer = _load_committer(str(tmp_path))
(tmp_path / "hello.txt").write_text("hello")
committer._commit()

rc, out, _ = committer._git("log", "--oneline")
assert rc == 0
assert "auto:" in out
stat = committer._git("show", "--stat", "HEAD")[1]
assert "hello.txt" in stat

def test_gitignored_secret_not_committed(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n")
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
)

committer = _load_committer(str(tmp_path))
(tmp_path / ".env").write_text("SECRET=abc")
(tmp_path / "token.rsa").write_text("key")
committer._commit()

_, log_out, _ = committer._git("log", "--all", "--stat")
assert ".env" not in log_out
assert "token.rsa" not in log_out

def test_no_commit_when_clean(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / ".gitignore").write_text("")
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
)

committer = _load_committer(str(tmp_path))
committer._commit()

rc, out, _ = committer._git("log", "--oneline")
assert rc == 0
lines = out.strip().splitlines()
assert len(lines) == 1
assert lines[0].endswith("initial")

def test_gitignored_ssh_key_not_committed(self, tmp_path):
_init_repo(tmp_path)
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n.ssh/\n")
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
)

committer = _load_committer(str(tmp_path))
(tmp_path / ".ssh").mkdir()
(tmp_path / ".ssh" / "id_rsa").write_text("fake-key")
committer._commit()

_, log_out, _ = committer._git("log", "--all", "--stat")
assert ".ssh" not in log_out
assert "id_rsa" not in log_out
76 changes: 76 additions & 0 deletions tests/test_deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,3 +1378,79 @@ async def test_dedicated_base_preferred_over_generic(self, tmp_path):
"hermes", {"taos-hermes-base", "taos-base"}, tmp_path
)
assert launch == "taos-hermes-base"


class TestGitInitAndCommitter:
@pytest.mark.asyncio
async def test_deploy_emits_git_init_step(self, tmp_path):
req = _req(data_dir=tmp_path)

async def mock_exec(name, cmd, **kwargs):
if "hostname -I" in " ".join(cmd):
return (0, "10.0.0.5")
return (0, "ok")

with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \
patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \
patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \
patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \
patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock) as mock_git_init, \
patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock) as mock_gitignore, \
patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock) as mock_git_config, \
patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock) as mock_git_commit:
mock_create.return_value = {"success": True, "name": "taos-agent-test"}
result = await deploy_agent(req)
assert result["success"] is True
assert "git_init" in result["steps"]
mock_git_init.assert_awaited_once_with("taos-agent-test")
mock_gitignore.assert_awaited_once_with("taos-agent-test")
mock_git_config.assert_awaited_once_with("taos-agent-test", "test", "test@taos.local")
mock_git_commit.assert_awaited_once()

@pytest.mark.asyncio
async def test_deploy_emits_committer_installed_step(self, tmp_path):
req = _req(data_dir=tmp_path)

async def mock_exec(name, cmd, **kwargs):
cmd_str = " ".join(cmd)
if "hostname -I" in cmd_str:
return (0, "10.0.0.5")
if "command -v systemctl" in cmd_str:
return (0, "yes")
if "systemctl is-active" in cmd_str:
return (0, "active")
return (0, "ok")

with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \
patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \
patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \
patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \
patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock), \
patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock):
mock_create.return_value = {"success": True, "name": "taos-agent-test"}
result = await deploy_agent(req)
assert result["success"] is True
assert "committer_installed" in result["steps"]

@pytest.mark.asyncio
async def test_deploy_reports_versioning_failure(self, tmp_path):
req = _req(data_dir=tmp_path)

async def mock_exec(name, cmd, **kwargs):
if "hostname -I" in " ".join(cmd):
return (0, "10.0.0.5")
return (0, "ok")

with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \
patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \
patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \
patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \
patch("tinyagentos.agent_git.git_init", side_effect=RuntimeError("no git")):
mock_create.return_value = {"success": True, "name": "taos-agent-test"}
result = await deploy_agent(req)
assert result["success"] is True
assert result["versioning"] is False
assert result["versioning_error"] is not None
assert "git_init" not in result.get("steps", [])
Loading
Loading