Add staged MCP release imports
This commit is contained in:
@@ -29,7 +29,8 @@ class OperatorTests(unittest.TestCase):
|
|||||||
root = Path(self.temporary.name)
|
root = Path(self.temporary.name)
|
||||||
self.repo, self.stack = root / "repository", root / "stack"
|
self.repo, self.stack = root / "repository", root / "stack"
|
||||||
self.models, self.state = root / "models", root / "state"
|
self.models, self.state = root / "models", root / "state"
|
||||||
for path in (self.repo, self.stack, self.models, self.state):
|
self.staging = root / "staging"
|
||||||
|
for path in (self.repo, self.stack, self.models, self.state, self.staging):
|
||||||
path.mkdir()
|
path.mkdir()
|
||||||
(self.repo / ".git").mkdir()
|
(self.repo / ".git").mkdir()
|
||||||
(self.repo / "docs").mkdir()
|
(self.repo / "docs").mkdir()
|
||||||
@@ -40,6 +41,7 @@ class OperatorTests(unittest.TestCase):
|
|||||||
self.module.STACK = self.stack.resolve()
|
self.module.STACK = self.stack.resolve()
|
||||||
self.module.MODELS = self.models.resolve()
|
self.module.MODELS = self.models.resolve()
|
||||||
self.module.STATE = self.state.resolve()
|
self.module.STATE = self.state.resolve()
|
||||||
|
self.module.STAGING_ROOTS = (self.staging.resolve(),)
|
||||||
self.module.audit = lambda *args, **kwargs: None
|
self.module.audit = lambda *args, **kwargs: None
|
||||||
self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": "ok"}
|
self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": "ok"}
|
||||||
|
|
||||||
@@ -124,6 +126,40 @@ class OperatorTests(unittest.TestCase):
|
|||||||
self.assertEqual(payload["paths"], ["docs/test.md"])
|
self.assertEqual(payload["paths"], ["docs/test.md"])
|
||||||
self.assertIn("ONE MCP RELEASE", preview)
|
self.assertIn("ONE MCP RELEASE", preview)
|
||||||
|
|
||||||
|
def test_mcp_release_imports_reviewed_staging_file_by_sha(self):
|
||||||
|
source = self.staging / "server.py"
|
||||||
|
source.write_text("print('reviewed')\n", encoding="utf-8")
|
||||||
|
digest = self.module.sha(source.read_bytes())
|
||||||
|
payload, preview = self.module.normalise_operation("mcp_release", {
|
||||||
|
"imports": [{"source": str(source), "path": "server.py", "expected_source_sha256": digest}],
|
||||||
|
"services": ["mcp-example"], "message": "Import reviewed MCP source",
|
||||||
|
"checks": ["operator-tests"], "create_recovery": False, "hermes_sync": True,
|
||||||
|
})
|
||||||
|
self.assertEqual(payload["files"][0]["content"], "print('reviewed')\n")
|
||||||
|
self.assertTrue(payload["hermes_sync"])
|
||||||
|
self.assertIn(f"sha256={digest}", preview)
|
||||||
|
|
||||||
|
def test_staged_import_rejects_wrong_sha_and_unapproved_path(self):
|
||||||
|
source = self.staging / "server.py"
|
||||||
|
source.write_text("safe\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.module.staged_file({"source": str(source), "path": "server.py", "expected_source_sha256": "0" * 64})
|
||||||
|
outside = Path(self.temporary.name) / "outside.py"
|
||||||
|
outside.write_text("unsafe\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
self.module.staged_file({"source": str(outside), "path": "server.py", "expected_source_sha256": self.module.sha(outside.read_bytes())})
|
||||||
|
|
||||||
|
def test_search_source_has_python_fallback_without_rg(self):
|
||||||
|
(self.repo / "docs" / "needle.md").write_text("alpha\nneedle here\nomega\n", encoding="utf-8")
|
||||||
|
original = self.module.shutil.which
|
||||||
|
self.module.shutil.which = lambda name: None
|
||||||
|
try:
|
||||||
|
result = self.module.search_source({"query": "needle"})
|
||||||
|
finally:
|
||||||
|
self.module.shutil.which = original
|
||||||
|
self.assertEqual(result["engine"], "python")
|
||||||
|
self.assertIn("docs/needle.md:2:needle here", result["matches"])
|
||||||
|
|
||||||
def test_structured_power_operations_do_not_exist(self):
|
def test_structured_power_operations_do_not_exist(self):
|
||||||
self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS)
|
self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS)
|
||||||
for operation in ("shutdown", "reboot", "ssh", "network", "command"):
|
for operation in ("shutdown", "reboot", "ssh", "network", "command"):
|
||||||
|
|||||||
@@ -358,11 +358,14 @@ sind serverseitig blockiert.
|
|||||||
Ein separater allgemeiner Shell-MCP wird nicht benötigt; die breite Fähigkeit
|
Ein separater allgemeiner Shell-MCP wird nicht benötigt; die breite Fähigkeit
|
||||||
ist portabel im Athena Operator auf VPN-Port 8202 enthalten.
|
ist portabel im Athena Operator auf VPN-Port 8202 enthalten.
|
||||||
|
|
||||||
Seit Operator 2.2 werden kleine Änderungen als SHA-geschützte Unified Diffs
|
Seit Operator 2.3 werden kleine Änderungen als SHA-geschützte Unified Diffs
|
||||||
über `patch_update` übertragen. `mcp_release` fasst den üblichen vollständigen
|
über `patch_update` übertragen. `mcp_release` fasst den üblichen vollständigen
|
||||||
MCP-Ablauf in einem bestätigten Auftrag zusammen: Patch, Tests, benannter
|
MCP-Ablauf in einem bestätigten Auftrag zusammen: Patch, Tests, benannter
|
||||||
Deploy, OpenWebUI-Sync, selektiver Git-Publish und Recovery. Damit muss das
|
Deploy, OpenWebUI-/Hermes-Sync, selektiver Git-Publish und Recovery. Geprüfte
|
||||||
Modell keine kompletten Compose- oder Installationsdateien rekonstruieren.
|
Staging-Dateien werden per Pfad und SHA importiert. Damit muss das Modell weder
|
||||||
|
lange MCP-Quellen noch komplette Compose- oder Installationsdateien
|
||||||
|
rekonstruieren. Die Quellensuche besitzt einen Python-Fallback, falls `rg` im
|
||||||
|
Executor-Image fehlt.
|
||||||
|
|
||||||
## Bekannte Probleme des alten Hosts
|
## Bekannte Probleme des alten Hosts
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ Der verbindliche Ablauf für dauerhafte Änderungen lautet:
|
|||||||
vorbereiten und nach separater Benutzerfreigabe ausführen. Es bündelt
|
vorbereiten und nach separater Benutzerfreigabe ausführen. Es bündelt
|
||||||
Prüfungen, benannten Compose-Deploy, OpenWebUI-Sync, selektiven Git-Publish
|
Prüfungen, benannten Compose-Deploy, OpenWebUI-Sync, selektiven Git-Publish
|
||||||
und Recovery.
|
und Recovery.
|
||||||
|
Bereits geprüfte lange Quelldateien werden mit `imports` plus exakter
|
||||||
|
SHA-256-Prüfsumme aus einem freigegebenen Staging-Verzeichnis übernommen;
|
||||||
|
sie werden nicht als Chattext oder Full-File-Payload nachgebaut. Änderungen
|
||||||
|
an `platform/hermes/config.yaml` verwenden `hermes_sync: true`. Für rein
|
||||||
|
interne MCPs wird WireGuard nicht geändert.
|
||||||
3. Einzeloperationen `run_checks`, `compose_deploy`, `git_publish` und
|
3. Einzeloperationen `run_checks`, `compose_deploy`, `git_publish` und
|
||||||
`recovery` nur für Diagnose oder bewusst partielle Wartung verwenden.
|
`recovery` nur für Diagnose oder bewusst partielle Wartung verwenden.
|
||||||
Fremde Dirty-Worktree-Dateien bleiben unberührt.
|
Fremde Dirty-Worktree-Dateien bleiben unberührt.
|
||||||
|
|||||||
@@ -171,5 +171,12 @@ Dateiersatz. Ein normaler MCP-Release erfolgt über `mcp_release`, das den
|
|||||||
versionierten Gesamtweg von Patch und Tests bis Deploy, Client-Sync, selektivem
|
versionierten Gesamtweg von Patch und Tests bis Deploy, Client-Sync, selektivem
|
||||||
Git-Publish und Recovery kapselt.
|
Git-Publish und Recovery kapselt.
|
||||||
|
|
||||||
|
Seit Operator 2.3 übernimmt `mcp_release.imports` bereits geprüfte UTF-8-Dateien
|
||||||
|
aus freigegebenen Staging-Verzeichnissen anhand ihrer SHA-256-Prüfsumme. Das
|
||||||
|
Modell muss lange vorbereitete MCP-Quellen weder erneut lesen noch im Chat
|
||||||
|
rekonstruieren. `hermes_sync: true` verteilt eine geänderte verwaltete
|
||||||
|
Hermes-Konfiguration ohne Containerneustart. Ein interner MCP benötigt keinen
|
||||||
|
neuen VPN-Port: Hermes und OpenWebUI erreichen ihn per Docker-DNS im Toolnetz.
|
||||||
|
|
||||||
Secrets unter `/etc/mike-ai` werden ausschließlich verschlüsselt gesichert und
|
Secrets unter `/etc/mike-ai` werden ausschließlich verschlüsselt gesichert und
|
||||||
gehören nie in Git, ein Wissensdokument oder einen Modellkontext.
|
gehören nie in Git, ein Wissensdokument oder einen Modellkontext.
|
||||||
|
|||||||
@@ -107,8 +107,12 @@ Arbeitsweg vorgesehen: `patch_update` ändert kleine Stellen als SHA-geschützte
|
|||||||
Unified Diff im kanonischen Working Tree und in der ausgerollten Kopie;
|
Unified Diff im kanonischen Working Tree und in der ausgerollten Kopie;
|
||||||
`file_update` ist neuen oder vollständig ersetzten Dateien vorbehalten. Für
|
`file_update` ist neuen oder vollständig ersetzten Dateien vorbehalten. Für
|
||||||
einen vollständigen MCP-Lifecycle bündelt `mcp_release` Patch, Tests, benannten
|
einen vollständigen MCP-Lifecycle bündelt `mcp_release` Patch, Tests, benannten
|
||||||
Deploy, OpenWebUI-Sync, selektiven Git-Publish und Recovery in einem bestätigten
|
Deploy, OpenWebUI-/Hermes-Sync, selektiven Git-Publish und Recovery in einem
|
||||||
Ablauf. Vollständige Compose-Dateien oder Base64-Kopien sind dafür unnötig.
|
bestätigten Ablauf. Bereits geprüfte Staging-Dateien müssen über `imports` mit
|
||||||
|
exakter SHA-256-Prüfsumme übernommen werden; ihr Inhalt wird nicht erneut
|
||||||
|
erzeugt. Vollständige Compose-Dateien oder Base64-Kopien sind unnötig. Interne
|
||||||
|
MCPs verwenden Docker-DNS und benötigen ohne ausdrücklichen Auftrag weder einen
|
||||||
|
VPN-Port noch eine WireGuard-Änderung.
|
||||||
`run_checks` prüft, `compose_deploy`
|
`run_checks` prüft, `compose_deploy`
|
||||||
rollt nur benannte Dienste aus, `git_publish` veröffentlicht nur ausdrücklich
|
rollt nur benannte Dienste aus, `git_publish` veröffentlicht nur ausdrücklich
|
||||||
ausgewählte Pfade und `recovery` erneuert den Recovery-Koffer. Lege niemals
|
ausgewählte Pfade und `recovery` erneuert den Recovery-Koffer. Lege niemals
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ description: Operate and extend the Athena AI platform safely.
|
|||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
hermes:
|
hermes:
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
author: Michael Roll, Hermes Agent
|
author: Michael Roll, Hermes Agent
|
||||||
platforms: [linux, macos, windows]
|
platforms: [linux, macos, windows]
|
||||||
tags: [athena, operations, docker, mcp, models, recovery]
|
tags: [athena, operations, docker, mcp, models, recovery]
|
||||||
@@ -86,6 +86,34 @@ repository and use live measurements only as evidence of current state.
|
|||||||
recovery bundle, then check maintenance status.
|
recovery bundle, then check maintenance status.
|
||||||
Completion: source commit, deployed state, docs, and recovery agree.
|
Completion: source commit, deployed state, docs, and recovery agree.
|
||||||
|
|
||||||
|
## Compact MCP Release Recipe
|
||||||
|
|
||||||
|
Use this route for a new self-written MCP. Do not rediscover the platform file
|
||||||
|
by file.
|
||||||
|
|
||||||
|
1. Inspect Athena once and check the service catalogue so an existing backend
|
||||||
|
is reused rather than duplicated.
|
||||||
|
2. Treat an already reviewed artifact in an approved staging directory as an
|
||||||
|
input. Calculate its SHA once and pass it to `mcp_release.imports`; never
|
||||||
|
reproduce a long staged source file in chat or a `file_update` payload.
|
||||||
|
3. Patch only the actual integration sources: `platform/mcp/compose.yaml`, the
|
||||||
|
managed Hermes config in `platform/hermes/config.yaml`, OpenWebUI's
|
||||||
|
versioned connector sync/seed, the env example, tests and relevant docs.
|
||||||
|
4. Set `hermes_sync: true` when the managed Hermes MCP list changes and
|
||||||
|
`openwebui_sync: true` when OpenWebUI's connector list changes. Neither sync
|
||||||
|
restarts Hermes, Router, Qwen, WireGuard, or the complete stack.
|
||||||
|
5. Do not add a VPN port or edit WireGuard for an ordinary in-stack MCP. Hermes
|
||||||
|
and OpenWebUI use Docker DNS on the private tool network. Add external VPN
|
||||||
|
publication only when the user explicitly asks for access by outside MCP
|
||||||
|
clients.
|
||||||
|
6. One `mcp_release` should import/patch, test, deploy only the named MCP,
|
||||||
|
synchronize clients, publish selected paths and create recovery. Then verify
|
||||||
|
handshake plus one bounded non-writing function.
|
||||||
|
|
||||||
|
A tool-call budget that ends "at a checkpoint" means: report a compact status,
|
||||||
|
then continue the same approved task with a fresh budget. It does not mean
|
||||||
|
abandon the requested implementation after reconnaissance.
|
||||||
|
|
||||||
## Persistent Versus Temporary Work
|
## Persistent Versus Temporary Work
|
||||||
|
|
||||||
- "Use" a missing helper for one task: place it in a task-specific temporary
|
- "Use" a missing helper for one task: place it in a task-specific temporary
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ TOOLS = [
|
|||||||
"request or copy an SSH key. "
|
"request or copy an SSH key. "
|
||||||
"patch_update payload: {files:[{path,patch,expected_sha256?}]} using standard unified diffs; "
|
"patch_update payload: {files:[{path,patch,expected_sha256?}]} using standard unified diffs; "
|
||||||
"file_update payload: {files:[{path,content,expected_sha256?}]}; "
|
"file_update payload: {files:[{path,content,expected_sha256?}]}; "
|
||||||
"mcp_release payload: {files:[patch entries],services,checks?,message,paths?,build?,"
|
"mcp_release payload: {files?:[patch entries],imports?:[{source,path,expected_source_sha256,"
|
||||||
"openwebui_sync?,create_recovery?,recovery_label?}. It applies drift-protected patches, runs checks, "
|
"expected_target_sha256?}],services,checks?,message,paths?,build?,openwebui_sync?,hermes_sync?,"
|
||||||
|
"create_recovery?,recovery_label?}. imports copies already reviewed UTF-8 staging files by exact SHA "
|
||||||
|
"from an approved staging root, so never reproduce a long staged source file in a payload. It applies "
|
||||||
|
"drift-protected patches/imports, runs checks, "
|
||||||
"deploys only named MCP services, syncs OpenWebUI, publishes only selected paths and creates recovery. "
|
"deploys only named MCP services, syncs OpenWebUI, publishes only selected paths and creates recovery. "
|
||||||
"Use mcp_release instead of manually chaining all those operations. run_checks: "
|
"Use mcp_release instead of manually chaining all those operations. run_checks: "
|
||||||
"{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; "
|
"{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; "
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
VERSION = "2.2.0"
|
VERSION = "2.3.0"
|
||||||
STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve()
|
STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve()
|
||||||
REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", "/data/mike-ai-operator/repository")).resolve()
|
REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", "/data/mike-ai-operator/repository")).resolve()
|
||||||
STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve()
|
STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve()
|
||||||
@@ -39,6 +39,14 @@ MAX_FILE_BYTES = 1_000_000
|
|||||||
MAX_FILES = 24
|
MAX_FILES = 24
|
||||||
MAX_OUTPUT = 30_000
|
MAX_OUTPUT = 30_000
|
||||||
LOCK = threading.RLock()
|
LOCK = threading.RLock()
|
||||||
|
STAGING_ROOTS = tuple(
|
||||||
|
Path(value).resolve()
|
||||||
|
for value in os.environ.get(
|
||||||
|
"ATHENA_OPERATOR_STAGING_ROOTS",
|
||||||
|
"/data/mike-ai-operator/staging:/data/hermes/deemix-mcp-build",
|
||||||
|
).split(":")
|
||||||
|
if value
|
||||||
|
)
|
||||||
|
|
||||||
SAFE_PATH = re.compile(r"^[A-Za-z0-9_.+/-]{1,240}$")
|
SAFE_PATH = re.compile(r"^[A-Za-z0-9_.+/-]{1,240}$")
|
||||||
SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
|
SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,100}$")
|
||||||
@@ -296,8 +304,59 @@ def search_source(arguments: dict[str, Any]) -> dict[str, Any]:
|
|||||||
query = str(arguments.get("query", ""))
|
query = str(arguments.get("query", ""))
|
||||||
if not query or len(query) > 200 or any(x in query for x in ("\x00", "\n", "\r")):
|
if not query or len(query) > 200 or any(x in query for x in ("\x00", "\n", "\r")):
|
||||||
raise ValueError("invalid query")
|
raise ValueError("invalid query")
|
||||||
result = run(["rg", "-n", "--hidden", "--glob", "!.git/**", "--", query, "."], cwd=REPOSITORY, timeout=20)
|
if shutil.which("rg"):
|
||||||
return {"query": query, "matches": result["output"], "exit_code": result["exit_code"]}
|
result = run(["rg", "-n", "--hidden", "--glob", "!.git/**", "--", query, "."], cwd=REPOSITORY, timeout=20)
|
||||||
|
return {"query": query, "matches": result["output"], "exit_code": result["exit_code"], "engine": "rg"}
|
||||||
|
try:
|
||||||
|
pattern = re.compile(query)
|
||||||
|
except re.error as exc:
|
||||||
|
raise ValueError(f"invalid search expression: {exc}") from exc
|
||||||
|
matches: list[str] = []
|
||||||
|
for path in sorted(REPOSITORY.rglob("*")):
|
||||||
|
if not path.is_file() or ".git" in path.parts or path.stat().st_size > MAX_FILE_BYTES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lines = path.read_text(encoding="utf-8", errors="strict").splitlines()
|
||||||
|
except (UnicodeDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
relative = path.relative_to(REPOSITORY)
|
||||||
|
for number, line in enumerate(lines, 1):
|
||||||
|
if pattern.search(line):
|
||||||
|
matches.append(f"{relative}:{number}:{line}")
|
||||||
|
if len(matches) >= 200:
|
||||||
|
return {"query": query, "matches": "\n".join(matches), "exit_code": 0, "engine": "python", "truncated": True}
|
||||||
|
return {"query": query, "matches": "\n".join(matches), "exit_code": 0 if matches else 1, "engine": "python", "truncated": False}
|
||||||
|
|
||||||
|
|
||||||
|
def staged_file(item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
source = Path(str(item.get("source", "")))
|
||||||
|
if not source.is_absolute() or not source.is_file():
|
||||||
|
raise FileNotFoundError("staged source file not found")
|
||||||
|
resolved = source.resolve()
|
||||||
|
if not any(resolved.is_relative_to(root) for root in STAGING_ROOTS):
|
||||||
|
raise PermissionError("staged source is outside approved staging roots")
|
||||||
|
raw = resolved.read_bytes()
|
||||||
|
if len(raw) > MAX_FILE_BYTES:
|
||||||
|
raise ValueError("staged source exceeds limit")
|
||||||
|
expected = str(item.get("expected_source_sha256", ""))
|
||||||
|
actual = sha(raw)
|
||||||
|
if not expected or expected != actual:
|
||||||
|
raise RuntimeError("staged source checksum is missing or does not match")
|
||||||
|
try:
|
||||||
|
content = raw.decode("utf-8", errors="strict")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("staged source must be UTF-8 text") from exc
|
||||||
|
relative = safe_relative(str(item.get("path", "")))
|
||||||
|
target = source_file(REPOSITORY, relative)
|
||||||
|
before = target.read_text(encoding="utf-8", errors="strict") if target.is_file() else ""
|
||||||
|
before_sha = sha(before.encode())
|
||||||
|
expected_target = str(item.get("expected_target_sha256", ""))
|
||||||
|
if expected_target and expected_target != before_sha:
|
||||||
|
raise RuntimeError(f"source drift for {relative}")
|
||||||
|
return {
|
||||||
|
"path": str(relative), "content": content, "before_sha256": before_sha,
|
||||||
|
"after_sha256": actual, "staged_source": str(resolved),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def terminal(arguments: dict[str, Any]) -> dict[str, Any]:
|
def terminal(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
@@ -362,7 +421,19 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
|||||||
files, preview = normalise_patches(payload.get("files"))
|
files, preview = normalise_patches(payload.get("files"))
|
||||||
return {"files": files}, preview
|
return {"files": files}, preview
|
||||||
if operation == "mcp_release":
|
if operation == "mcp_release":
|
||||||
files, patch_preview = normalise_patches(payload.get("files"))
|
patch_items = payload.get("files") or []
|
||||||
|
import_items = payload.get("imports") or []
|
||||||
|
if not isinstance(patch_items, list) or not isinstance(import_items, list):
|
||||||
|
raise ValueError("files and imports must be lists")
|
||||||
|
if not patch_items and not import_items:
|
||||||
|
raise ValueError("mcp_release requires at least one patch or staged import")
|
||||||
|
files, patch_preview = normalise_patches(patch_items) if patch_items else ([], "")
|
||||||
|
imports = [staged_file(item) for item in import_items]
|
||||||
|
files.extend(imports)
|
||||||
|
import_preview = "\n".join(
|
||||||
|
f"IMPORT {item['staged_source']} -> {item['path']} sha256={item['after_sha256']}"
|
||||||
|
for item in imports
|
||||||
|
)
|
||||||
checks = payload.get("checks") or ["operator-tests", "compose-mcp"]
|
checks = payload.get("checks") or ["operator-tests", "compose-mcp"]
|
||||||
if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks):
|
if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks):
|
||||||
raise ValueError("unknown release check suite")
|
raise ValueError("unknown release check suite")
|
||||||
@@ -386,14 +457,17 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
|||||||
"files": files, "checks": checks, "compose_file": compose_file,
|
"files": files, "checks": checks, "compose_file": compose_file,
|
||||||
"services": [str(x) for x in services], "build": bool(payload.get("build", True)),
|
"services": [str(x) for x in services], "build": bool(payload.get("build", True)),
|
||||||
"openwebui_sync": bool(payload.get("openwebui_sync", True)),
|
"openwebui_sync": bool(payload.get("openwebui_sync", True)),
|
||||||
|
"hermes_sync": bool(payload.get("hermes_sync", False)),
|
||||||
"message": message, "paths": selected,
|
"message": message, "paths": selected,
|
||||||
"create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label,
|
"create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label,
|
||||||
}
|
}
|
||||||
preview = (
|
preview = (
|
||||||
f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n"
|
f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n"
|
||||||
f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n"
|
f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n"
|
||||||
|
f"Hermes sync: {normal['hermes_sync']}\n"
|
||||||
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n"
|
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n"
|
||||||
f"Recovery: {normal['create_recovery']} ({label})\n\n{patch_preview}"
|
f"Recovery: {normal['create_recovery']} ({label})\n\n"
|
||||||
|
+ "\n".join(part for part in (import_preview, patch_preview) if part)
|
||||||
)
|
)
|
||||||
return normal, preview
|
return normal, preview
|
||||||
if operation == "run_checks":
|
if operation == "run_checks":
|
||||||
@@ -579,6 +653,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
|||||||
published = False
|
published = False
|
||||||
deployed = False
|
deployed = False
|
||||||
synced = False
|
synced = False
|
||||||
|
hermes_synced = False
|
||||||
try:
|
try:
|
||||||
changed = apply_files(payload["files"], backup)
|
changed = apply_files(payload["files"], backup)
|
||||||
checks = []
|
checks = []
|
||||||
@@ -595,10 +670,14 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
|||||||
if payload["openwebui_sync"]:
|
if payload["openwebui_sync"]:
|
||||||
sync = run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800, check=True)
|
sync = run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800, check=True)
|
||||||
synced = True
|
synced = True
|
||||||
|
hermes_sync = None
|
||||||
|
if payload["hermes_sync"]:
|
||||||
|
hermes_sync = run(["bash", str(STACK / "platform/hermes/install-hermes.sh")], cwd=STACK, timeout=1800, check=True)
|
||||||
|
hermes_synced = True
|
||||||
publication = publish_paths(payload["message"], payload["paths"])
|
publication = publish_paths(payload["message"], payload["paths"])
|
||||||
published = True
|
published = True
|
||||||
recovery = perform_recovery(payload["recovery_label"]) if payload["create_recovery"] else None
|
recovery = perform_recovery(payload["recovery_label"]) if payload["create_recovery"] else None
|
||||||
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "publication": publication, "recovery": recovery, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
|
return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "hermes_sync": hermes_sync, "publication": publication, "recovery": recovery, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])}
|
||||||
except Exception:
|
except Exception:
|
||||||
if not published:
|
if not published:
|
||||||
restore_files(payload["files"], backup)
|
restore_files(payload["files"], backup)
|
||||||
@@ -608,6 +687,8 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
|||||||
run(rollback_argv, cwd=STACK, timeout=3600)
|
run(rollback_argv, cwd=STACK, timeout=3600)
|
||||||
if synced:
|
if synced:
|
||||||
run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800)
|
run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800)
|
||||||
|
if hermes_synced:
|
||||||
|
run(["bash", str(STACK / "platform/hermes/install-hermes.sh")], cwd=STACK, timeout=1800)
|
||||||
raise
|
raise
|
||||||
return start_job(ticket, operation, release)
|
return start_job(ticket, operation, release)
|
||||||
if operation == "run_checks":
|
if operation == "run_checks":
|
||||||
|
|||||||
Reference in New Issue
Block a user