From b519871a48afb7c9ad554ea162760d7a509f890c Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Sun, 20 Sep 2026 22:32:11 +0000 Subject: [PATCH] Add normalized Gitea source metadata tool --- opax-mcp/capability_registry.py | 7 ++ opax-mcp/server.py | 74 ++++++++++++++- opax-mcp/test_server_deployment.py | 145 +++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 opax-mcp/test_server_deployment.py diff --git a/opax-mcp/capability_registry.py b/opax-mcp/capability_registry.py index d304daa..ad67137 100644 --- a/opax-mcp/capability_registry.py +++ b/opax-mcp/capability_registry.py @@ -140,6 +140,13 @@ _CAPABILITIES_TUPLE: Tuple[Capability, ...] = ( ("authenticated_user", "gitea_read"), False, None, True, False, False, ("git.vauco.no",), "internal_authoritative_only", ), + _capability( + "gitea.fetch_normalized_source_meta", "Fetch Normalized Source Metadata", + "Resolves a Git ref, fetches the source archive, normalizes it, and returns deterministic build metadata.", + "gitea", RiskLevel.READ, Availability.PLANNED, "opax-mcp", "fetch_and_normalize_source", + ("authenticated_user", "gitea_read"), False, None, True, False, False, + ("git.vauco.no",), "internal_authoritative_only", + ), _capability( "cloudbuild.read_status", "Read Cloud Build status", "Read Cloud Build status in the approved VAUCO project.", "cloudbuild", RiskLevel.READ, diff --git a/opax-mcp/server.py b/opax-mcp/server.py index a90d0df..5b6b34c 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -58,12 +58,21 @@ except Exception as e: print(f"Failed to load provision_new_mcp_module: {e}") provision_new_mcp_module = None from emma_adapter import CanonicalEmma -from gitea_handler import handle_list_repo_files, list_allowed_namespace_repositories, handle_get_file_content +from gitea_handler import ( + handle_list_repo_files, + list_allowed_namespace_repositories, + handle_get_file_content, + resolve_branch_to_commit_sha, + download_repo_archive, +) from capability_bridge import build_capability_system_context +from deployment_policy import get_deployment_target +from deployment_source import normalize_gitea_archive from email.mime.text import MIMEText from datetime import datetime, timezone, timedelta from typing import Any, Optional, Dict, List import logging +import re from pydantic import BaseModel, Field import hashlib @@ -977,6 +986,57 @@ async def push_file(p): if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body) return await _gitea_post(f"/repos/{repo}/contents/{path}", body) + +_SHA_RE = re.compile(r"^[0-9a-f]{40}$") + +async def fetch_and_normalize_source(p: dict) -> dict: + """ + Resolves a service and Git ref, fetches the source archive, normalizes it, + and returns deterministic build metadata. + """ + service_key = p.get("service_key") + ref = p.get("ref") + + if not isinstance(service_key, str) or not service_key: + raise ValueError("Missing or invalid 'service_key'") + if not isinstance(ref, str) or not ref: + raise ValueError("Missing or invalid 'ref'") + + target_policy = get_deployment_target(service_key) + repo_id = target_policy["repository"] + required_paths = target_policy["required_source_paths"] + + requested_ref_lower = ref.lower() + if _SHA_RE.fullmatch(requested_ref_lower): + resolved_commit_sha = requested_ref_lower + else: + resolved_commit_sha = await resolve_branch_to_commit_sha( + branch_name=ref, + repo_id=repo_id, + gitea_url=GITEA_URL, + ) + + archive_bytes = await download_repo_archive( + commit_sha=resolved_commit_sha, + repo_id=repo_id, + gitea_url=GITEA_URL, + ) + + _normalized_bytes, manifest = normalize_gitea_archive( + archive_bytes=archive_bytes, + required_paths=required_paths, + ) + + return { + "service_key": service_key, + "repository": repo_id, + "requested_ref": ref, + "resolved_commit_sha": resolved_commit_sha, + "sha256": manifest["sha256"], + "source_bytes": manifest["source_bytes"], + "wrapper_directory_stripped": manifest["wrapper_directory_stripped"], + } + # --------------------------------------------------------------------------- # Memory Bank & Deployment Tools # --------------------------------------------------------------------------- @@ -1256,6 +1316,18 @@ TOOLS = { "list_customers": (list_customers, "List alle kunder (alias for get_state)", {}), "run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}), # Gitea / VCS + "fetch_and_normalize_source": ( + fetch_and_normalize_source, + "Fetches and normalizes a repository source archive, returning build metadata.", + { + "type": "object", + "properties": { + "service_key": {"type": "string"}, + "ref": {"type": "string"}, + }, + "required": ["service_key", "ref"], + }, + ), "list_gitea_repositories": (list_gitea_repositories, "Lists repositories in the approved 'chris' namespace. Listing does not grant read, build, or deploy authority.", {}), "list_commits": (list_commits, "List siste commits i Gitea-repo", {}), "get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}), diff --git a/opax-mcp/test_server_deployment.py b/opax-mcp/test_server_deployment.py new file mode 100644 index 0000000..f76d30d --- /dev/null +++ b/opax-mcp/test_server_deployment.py @@ -0,0 +1,145 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch +import sys +from pathlib import Path +import httpx + +# Add opax-mcp to path to allow direct import of 'server' +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "opax-mcp")) + +# Stub unrelated dependencies before importing server, matching existing tests +sys.modules['emma_adapter'] = MagicMock() + +# Now that the path and stubs are set, import the modules under test +from server import fetch_and_normalize_source +from deployment_source import SourceArtifactError + +class TestFetchAndNormalizeSource(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + self.repo_id = "owner/repo" + self.required_paths = ("cloudbuild.deploy.yaml",) + self.resolved_sha = "b" * 40 + self.mock_policy = { + "repository": self.repo_id, + "required_source_paths": self.required_paths, + } + self.mock_manifest = { + "sha256": "normalized_sha256_hash", + "source_bytes": 54321, + "wrapper_directory_stripped": True, + } + # Patch GITEA_URL as it's used directly by the handler + self.gitea_url_patch = patch('server.GITEA_URL', 'https://gitea.test') + self.gitea_url_patch.start() + + def tearDown(self): + self.gitea_url_patch.stop() + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock) + @patch('server.get_deployment_target') + async def test_branch_ref_success(self, m_get_target, m_resolve_sha, m_download, m_normalize): + m_get_target.return_value = self.mock_policy + m_resolve_sha.return_value = self.resolved_sha + m_download.return_value = b"archive data" + m_normalize.return_value = (b"normalized data", self.mock_manifest) + + result = await fetch_and_normalize_source({"service_key": "test-svc", "ref": "main"}) + + m_get_target.assert_called_once_with("test-svc") + m_resolve_sha.assert_called_once_with(branch_name="main", repo_id=self.repo_id, gitea_url='https://gitea.test') + m_download.assert_awaited_once_with(commit_sha=self.resolved_sha, repo_id=self.repo_id, gitea_url='https://gitea.test') + m_normalize.assert_called_once_with(archive_bytes=b"archive data", required_paths=self.required_paths) + self.assertEqual(result["resolved_commit_sha"], self.resolved_sha) + self.assertEqual(result["requested_ref"], "main") + self.assertEqual(result["sha256"], self.mock_manifest["sha256"]) + self.assertEqual(set(result.keys()), { + "service_key", "repository", "requested_ref", "resolved_commit_sha", + "sha256", "source_bytes", "wrapper_directory_stripped" + }) + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock) + @patch('server.get_deployment_target') + async def test_full_sha_success(self, m_get_target, m_resolve_sha, m_download, m_normalize): + m_get_target.return_value = self.mock_policy + m_download.return_value = b"archive data" + m_normalize.return_value = (b"normalized data", self.mock_manifest) + + uppercase_sha = self.resolved_sha.upper() + result = await fetch_and_normalize_source({"service_key": "test-svc", "ref": uppercase_sha}) + + m_resolve_sha.assert_not_called() + m_download.assert_awaited_once_with(commit_sha=self.resolved_sha, repo_id=self.repo_id, gitea_url='https://gitea.test') + self.assertEqual(result["resolved_commit_sha"], self.resolved_sha) + self.assertEqual(result["requested_ref"], uppercase_sha) + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock) + @patch('server.get_deployment_target') + async def test_invalid_service_key_fails_before_dependencies(self, m_get_target, m_resolve_sha, m_download, m_normalize): + for invalid_key in ["", None, 123]: + with self.subTest(key=invalid_key): + with self.assertRaisesRegex(ValueError, "Missing or invalid 'service_key'"): + await fetch_and_normalize_source({"service_key": invalid_key, "ref": "main"}) + m_get_target.assert_not_called() + m_resolve_sha.assert_not_called() + m_download.assert_not_called() + m_normalize.assert_not_called() + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock) + @patch('server.get_deployment_target') + async def test_invalid_ref_fails_before_dependencies(self, m_get_target, m_resolve_sha, m_download, m_normalize): + for invalid_ref in ["", None, 123]: + with self.subTest(ref=invalid_ref): + with self.assertRaisesRegex(ValueError, "Missing or invalid 'ref'"): + await fetch_and_normalize_source({"service_key": "test-svc", "ref": invalid_ref}) + m_get_target.assert_not_called() + m_resolve_sha.assert_not_called() + m_download.assert_not_called() + m_normalize.assert_not_called() + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.resolve_branch_to_commit_sha', new_callable=AsyncMock) + @patch('server.get_deployment_target', side_effect=ValueError("Unknown service")) + async def test_unknown_service_propagates_before_gitea_calls(self, m_get_target, m_resolve_sha, m_download, m_normalize): + with self.assertRaisesRegex(ValueError, "Unknown service"): + await fetch_and_normalize_source({"service_key": "unknown", "ref": "main"}) + m_resolve_sha.assert_not_called() + m_download.assert_not_called() + m_normalize.assert_not_called() + + @patch('server.normalize_gitea_archive') + @patch('server.download_repo_archive', new_callable=AsyncMock, side_effect=httpx.ReadTimeout("Timeout")) + @patch('server.get_deployment_target') + async def test_archive_download_error_propagates(self, m_get_target, m_download, m_normalize): + m_get_target.return_value = self.mock_policy + with self.assertRaises(httpx.ReadTimeout): + await fetch_and_normalize_source({"service_key": "test-svc", "ref": self.resolved_sha}) + m_download.assert_awaited_once() + m_normalize.assert_not_called() + + @patch('server.normalize_gitea_archive', side_effect=SourceArtifactError("Normalization failed")) + @patch('server.download_repo_archive', new_callable=AsyncMock) + @patch('server.get_deployment_target') + async def test_normalization_error_propagates(self, m_get_target, m_download, m_normalize): + m_get_target.return_value = self.mock_policy + m_download.return_value = b"archive data" + with self.assertRaisesRegex(SourceArtifactError, "Normalization failed"): + await fetch_and_normalize_source({"service_key": "test-svc", "ref": self.resolved_sha}) + m_download.assert_awaited_once() + m_normalize.assert_called_once_with( + archive_bytes=b"archive data", + required_paths=self.required_paths, + ) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file