Add normalized Gitea source metadata tool
This commit is contained in:
parent
33a414c095
commit
b519871a48
|
|
@ -140,6 +140,13 @@ _CAPABILITIES_TUPLE: Tuple[Capability, ...] = (
|
||||||
("authenticated_user", "gitea_read"), False, None, True, False, False,
|
("authenticated_user", "gitea_read"), False, None, True, False, False,
|
||||||
("git.vauco.no",), "internal_authoritative_only",
|
("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(
|
_capability(
|
||||||
"cloudbuild.read_status", "Read Cloud Build status",
|
"cloudbuild.read_status", "Read Cloud Build status",
|
||||||
"Read Cloud Build status in the approved VAUCO project.", "cloudbuild", RiskLevel.READ,
|
"Read Cloud Build status in the approved VAUCO project.", "cloudbuild", RiskLevel.READ,
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,21 @@ except Exception as e:
|
||||||
print(f"Failed to load provision_new_mcp_module: {e}")
|
print(f"Failed to load provision_new_mcp_module: {e}")
|
||||||
provision_new_mcp_module = None
|
provision_new_mcp_module = None
|
||||||
from emma_adapter import CanonicalEmma
|
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 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 email.mime.text import MIMEText
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Any, Optional, Dict, List
|
from typing import Any, Optional, Dict, List
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
import hashlib
|
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)
|
if "sha" in body: return await _gitea_put(f"/repos/{repo}/contents/{path}", body)
|
||||||
return await _gitea_post(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
|
# Memory Bank & Deployment Tools
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1256,6 +1316,18 @@ TOOLS = {
|
||||||
"list_customers": (list_customers, "List alle kunder (alias for get_state)", {}),
|
"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"}}}),
|
"run_terminal": (run_terminal, "Kjør terminalkommando på VM", {"type":"object","properties":{"command":{"type":"string"}}}),
|
||||||
# Gitea / VCS
|
# 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_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", {}),
|
"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"]}),
|
"get_file": (get_file, "Hent fil fra Gitea-repo", {"type":"object","properties":{"path":{"type":"string"}, "ref":{"type":"string"}, "repo":{"type":"string"}},"required":["path", "ref"]}),
|
||||||
|
|
|
||||||
145
opax-mcp/test_server_deployment.py
Normal file
145
opax-mcp/test_server_deployment.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue
Block a user