Add compact Athena MCP release workflow
This commit is contained in:
1 parent
ac725416b8
commit
662913f8ab
9 files changed
+307
-70
No files matched your search
@@ -85,6 +85,45 @@ class OperatorTests(unittest.TestCase):
|
|||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
|
self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
|
||||||
|
|
||||||
|
def test_patch_update_is_compact_and_updates_both_trees(self):
|
||||||
|
before = self.module.sha((self.repo / "docs" / "test.md").read_bytes())
|
||||||
|
proposal = self.module.prepare({
|
||||||
|
"operation": "patch_update",
|
||||||
|
"payload": {"files": [{
|
||||||
|
"path": "docs/test.md", "expected_sha256": before,
|
||||||
|
"patch": "--- a/docs/test.md\n+++ b/docs/test.md\n@@ -1 +1 @@\n-before\n+after\n",
|
||||||
|
}]},
|
||||||
|
})
|
||||||
|
self.assertIn("+after", proposal["preview"])
|
||||||
|
result = self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]})
|
||||||
|
self.assertEqual(result["operation"], "patch_update")
|
||||||
|
self.assertEqual((self.repo / "docs" / "test.md").read_text(), "after\n")
|
||||||
|
self.assertEqual((self.stack / "docs" / "test.md").read_text(), "after\n")
|
||||||
|
|
||||||
|
def test_patch_update_rejects_wrong_context_and_drift(self):
|
||||||
|
before = self.module.sha((self.repo / "docs" / "test.md").read_bytes())
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.module.normalise_operation("patch_update", {"files": [{
|
||||||
|
"path": "docs/test.md", "expected_sha256": before,
|
||||||
|
"patch": "@@ -1 +1 @@\n-wrong\n+after\n",
|
||||||
|
}]})
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.module.normalise_operation("patch_update", {"files": [{
|
||||||
|
"path": "docs/test.md", "expected_sha256": "0" * 64,
|
||||||
|
"patch": "@@ -1 +1 @@\n-before\n+after\n",
|
||||||
|
}]})
|
||||||
|
|
||||||
|
def test_mcp_release_normalises_one_complete_workflow(self):
|
||||||
|
before = self.module.sha((self.repo / "docs" / "test.md").read_bytes())
|
||||||
|
payload, preview = self.module.normalise_operation("mcp_release", {
|
||||||
|
"files": [{"path": "docs/test.md", "expected_sha256": before, "patch": "@@ -1 +1 @@\n-before\n+after\n"}],
|
||||||
|
"services": ["mcp-example"], "message": "Add example MCP service",
|
||||||
|
"checks": ["operator-tests"], "create_recovery": False,
|
||||||
|
})
|
||||||
|
self.assertEqual(payload["services"], ["mcp-example"])
|
||||||
|
self.assertEqual(payload["paths"], ["docs/test.md"])
|
||||||
|
self.assertIn("ONE MCP RELEASE", preview)
|
||||||
|
|
||||||
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,6 +358,12 @@ 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
|
||||||
|
über `patch_update` übertragen. `mcp_release` fasst den üblichen vollständigen
|
||||||
|
MCP-Ablauf in einem bestätigten Auftrag zusammen: Patch, Tests, benannter
|
||||||
|
Deploy, OpenWebUI-Sync, selektiver Git-Publish und Recovery. Damit muss das
|
||||||
|
Modell keine kompletten Compose- oder Installationsdateien rekonstruieren.
|
||||||
|
|
||||||
## Bekannte Probleme des alten Hosts
|
## Bekannte Probleme des alten Hosts
|
||||||
|
|
||||||
- Systempartition vollständig gefüllt
|
- Systempartition vollständig gefüllt
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ anfordern oder kopieren.
|
|||||||
|
|
||||||
Der verbindliche Ablauf für dauerhafte Änderungen lautet:
|
Der verbindliche Ablauf für dauerhafte Änderungen lautet:
|
||||||
|
|
||||||
1. `athena_operator_prepare` und nach separater Benutzerfreigabe
|
1. Kleine Änderungen mit `patch_update` als SHA-geschützten Unified Diff
|
||||||
`athena_operator_execute` mit `file_update`; dies schreibt dieselben
|
vorbereiten. `file_update` ist neuen oder vollständig ersetzten Dateien
|
||||||
ausgewählten Dateien driftgeschützt in den kanonischen Working Tree und die
|
vorbehalten.
|
||||||
ausgerollte Kopie.
|
2. Für einen normalen MCP-Lifecycle bevorzugt ein einziges `mcp_release`
|
||||||
2. Prüfungen über die Operation `run_checks` ausführen.
|
vorbereiten und nach separater Benutzerfreigabe ausführen. Es bündelt
|
||||||
3. Nur betroffene Dienste über `compose_deploy` ausrollen.
|
Prüfungen, benannten Compose-Deploy, OpenWebUI-Sync, selektiven Git-Publish
|
||||||
4. Ausschließlich die ausdrücklich angegebenen geänderten Pfade mit
|
und Recovery.
|
||||||
`git_publish` committen und pushen. Fremde Dirty-Worktree-Dateien bleiben
|
3. Einzeloperationen `run_checks`, `compose_deploy`, `git_publish` und
|
||||||
unberührt.
|
`recovery` nur für Diagnose oder bewusst partielle Wartung verwenden.
|
||||||
5. Mit `recovery` einen neuen Recovery-Koffer erzeugen und prüfen.
|
Fremde Dirty-Worktree-Dateien bleiben unberührt.
|
||||||
|
|
||||||
Das allgemeine Terminal ist weder Ersatz für diesen Ablauf noch ein Weg zu
|
Das allgemeine Terminal ist weder Ersatz für diesen Ablauf noch ein Weg zu
|
||||||
Git-Schlüsseln. `/data/mike-ai-operator/repository` muss aus der
|
Git-Schlüsseln. `/data/mike-ai-operator/repository` muss aus der
|
||||||
|
|||||||
@@ -166,5 +166,10 @@ geändert. Die Abweichung wird benannt und zuerst geklärt.
|
|||||||
/var/lib/docker/volumes Docker-Volumes, darunter OpenWebUI-Daten
|
/var/lib/docker/volumes Docker-Volumes, darunter OpenWebUI-Daten
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Kleine Quelländerungen erfolgen über `patch_update` statt als vollständiger
|
||||||
|
Dateiersatz. Ein normaler MCP-Release erfolgt über `mcp_release`, das den
|
||||||
|
versionierten Gesamtweg von Patch und Tests bis Deploy, Client-Sync, selektivem
|
||||||
|
Git-Publish und Recovery kapselt.
|
||||||
|
|
||||||
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.
|
||||||
@@ -103,8 +103,13 @@ Operator verwaltete Working Tree liegt unter
|
|||||||
Tree; `.mike-ai-source-commit` bezeichnet den ausgerollten Stand. Der
|
Tree; `.mike-ai-source-commit` bezeichnet den ausgerollten Stand. Der
|
||||||
offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für
|
offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für
|
||||||
dauerhafte Änderungen ist ausschließlich der strukturierte Athena-Operator-
|
dauerhafte Änderungen ist ausschließlich der strukturierte Athena-Operator-
|
||||||
Arbeitsweg vorgesehen: `file_update` ändert driftgeschützt den kanonischen
|
Arbeitsweg vorgesehen: `patch_update` ändert kleine Stellen als SHA-geschützten
|
||||||
Working Tree und die ausgerollte Kopie, `run_checks` prüft, `compose_deploy`
|
Unified Diff im kanonischen Working Tree und in der ausgerollten Kopie;
|
||||||
|
`file_update` ist neuen oder vollständig ersetzten Dateien vorbehalten. Für
|
||||||
|
einen vollständigen MCP-Lifecycle bündelt `mcp_release` Patch, Tests, benannten
|
||||||
|
Deploy, OpenWebUI-Sync, selektiven Git-Publish und Recovery in einem bestätigten
|
||||||
|
Ablauf. Vollständige Compose-Dateien oder Base64-Kopien sind dafür unnötig.
|
||||||
|
`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
|
||||||
einen zweiten Clone in der Sandbox an und fordere oder kopiere keinen
|
einen zweiten Clone in der Sandbox an und fordere oder kopiere keinen
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ repository and use live measurements only as evidence of current state.
|
|||||||
5. **Change the source of truth.** Modify repository sources, not only a live
|
5. **Change the source of truth.** Modify repository sources, not only a live
|
||||||
container. Prefer `athena_operator_prepare`; show its full preview and stop
|
container. Prefer `athena_operator_prepare`; show its full preview and stop
|
||||||
for the exact user confirmation before `athena_operator_execute`.
|
for the exact user confirmation before `athena_operator_execute`.
|
||||||
Use `file_update` for the exact source paths. Never clone the repository in
|
Prefer `patch_update` with a small unified diff and the current file SHA for
|
||||||
|
ordinary edits. Use `file_update` only for new files or intentional complete
|
||||||
|
replacements. Never clone the repository in
|
||||||
the Hermes sandbox and never request or copy an SSH key; the Operator owns
|
the Hermes sandbox and never request or copy an SSH key; the Operator owns
|
||||||
the canonical worktree and its deploy credentials.
|
the canonical worktree and its deploy credentials.
|
||||||
Completion: the approved ticket matches the intended content.
|
Completion: the approved ticket matches the intended content.
|
||||||
@@ -75,7 +77,11 @@ repository and use live measurements only as evidence of current state.
|
|||||||
7. **Verify behavior.** Run syntax/config checks, focused tests, service health,
|
7. **Verify behavior.** Run syntax/config checks, focused tests, service health,
|
||||||
and one bounded functional test. A running container alone is not proof.
|
and one bounded functional test. A running container alone is not proof.
|
||||||
Completion: expected behavior and rollback path are both verified.
|
Completion: expected behavior and rollback path are both verified.
|
||||||
8. **Close the maintenance loop.** Update relevant docs, publish only the
|
8. **Close the maintenance loop.** For a normal MCP delivery, prefer one
|
||||||
|
confirmed `mcp_release`; it applies the reviewed patches, runs checks,
|
||||||
|
deploys only named services, synchronizes OpenWebUI, publishes selected
|
||||||
|
paths and creates recovery. Use separate operations only for diagnosis or a
|
||||||
|
deliberately partial workflow. Otherwise update relevant docs, publish only the
|
||||||
explicitly selected changed paths with `git_publish`, create a newer
|
explicitly selected changed paths with `git_publish`, create a newer
|
||||||
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.
|
||||||
@@ -106,8 +112,9 @@ repository and use live measurements only as evidence of current state.
|
|||||||
- A profile switch can terminate active generation and invalidate prompt cache.
|
- A profile switch can terminate active generation and invalidate prompt cache.
|
||||||
- A healthy container can still expose the wrong model, route, or tool set.
|
- A healthy container can still expose the wrong model, route, or tool set.
|
||||||
- `/opt/mike-ai/stack` is deployed source, not automatically the canonical Git
|
- `/opt/mike-ai/stack` is deployed source, not automatically the canonical Git
|
||||||
worktree. Complete durable changes through Operator operations `file_update`,
|
worktree. Use `patch_update` for compact edits and `mcp_release` for the
|
||||||
`run_checks`, `compose_deploy`, `git_publish`, and `recovery`.
|
complete MCP lifecycle. Do not reconstruct whole Compose or installer files
|
||||||
|
for a small change.
|
||||||
- New skills are loaded at the next Hermes session; absence in the current
|
- New skills are loaded at the next Hermes session; absence in the current
|
||||||
session is expected.
|
session is expected.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import sys
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
VERSION = "2.0.0"
|
VERSION = "2.2.0"
|
||||||
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
|
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
|
||||||
|
|
||||||
if hasattr(sys.stdin, "reconfigure"):
|
if hasattr(sys.stdin, "reconfigure"):
|
||||||
@@ -84,12 +84,18 @@ TOOLS = [
|
|||||||
"description": (
|
"description": (
|
||||||
"PREPARE a state-changing Athena operation. This never changes state. It returns a "
|
"PREPARE a state-changing Athena operation. This never changes state. It returns a "
|
||||||
"content-bound ticket, exact preview and confirmation phrase. Supported operations: "
|
"content-bound ticket, exact preview and confirmation phrase. Supported operations: "
|
||||||
"file_update (write repository and deployed stack files), run_checks, compose_deploy, "
|
"patch_update (preferred for small changes), file_update (only for new or fully replaced files), "
|
||||||
|
"mcp_release (preferred one-ticket end-to-end MCP delivery), run_checks, compose_deploy, "
|
||||||
"container_action, openwebui_sync, git_publish, model_download, benchmark, recovery. Show the complete "
|
"container_action, openwebui_sync, git_publish, model_download, benchmark, recovery. Show the complete "
|
||||||
"preview to the user and stop. Never execute in the same autonomous tool sequence. This is the "
|
"preview to the user and stop. Never execute in the same autonomous tool sequence. This is the "
|
||||||
"supported durable source and Git path: never clone the repository inside a sandbox and never "
|
"supported durable source and Git path: never clone the repository inside a sandbox and never "
|
||||||
"request or copy an SSH key. "
|
"request or copy an SSH key. "
|
||||||
"file_update payload: {files:[{path,content,expected_sha256?}]}; run_checks: "
|
"patch_update payload: {files:[{path,patch,expected_sha256?}]} using standard unified diffs; "
|
||||||
|
"file_update payload: {files:[{path,content,expected_sha256?}]}; "
|
||||||
|
"mcp_release payload: {files:[patch entries],services,checks?,message,paths?,build?,"
|
||||||
|
"openwebui_sync?,create_recovery?,recovery_label?}. It applies drift-protected patches, runs checks, "
|
||||||
|
"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: "
|
||||||
"{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; "
|
"{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; "
|
||||||
"compose_deploy: {compose_file,services,build}; container_action: {action,containers}; "
|
"compose_deploy: {compose_file,services,build}; container_action: {action,containers}; "
|
||||||
"openwebui_sync: {}; "
|
"openwebui_sync: {}; "
|
||||||
@@ -99,7 +105,7 @@ TOOLS = [
|
|||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"operation": {"type": "string", "enum": ["file_update", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]},
|
"operation": {"type": "string", "enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]},
|
||||||
"payload": {"type": "object"},
|
"payload": {"type": "object"},
|
||||||
},
|
},
|
||||||
"required": ["operation", "payload"], "additionalProperties": False,
|
"required": ["operation", "payload"], "additionalProperties": False,
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.athena-operator
|
dockerfile: Dockerfile.athena-operator
|
||||||
image: mike-ai/mcp-athena-operator:2.1.0
|
image: mike-ai/mcp-athena-operator:2.2.0
|
||||||
container_name: mike-ai-mcp-athena-operator
|
container_name: mike-ai-mcp-athena-operator
|
||||||
environment:
|
environment:
|
||||||
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
VERSION = "2.0.0"
|
VERSION = "2.2.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()
|
||||||
@@ -48,9 +48,11 @@ PROTECTED_CONTAINERS = {
|
|||||||
"mike-ai-wireguard-gateway",
|
"mike-ai-wireguard-gateway",
|
||||||
}
|
}
|
||||||
ALLOWED_OPERATIONS = {
|
ALLOWED_OPERATIONS = {
|
||||||
"file_update", "run_checks", "compose_deploy", "container_action",
|
"file_update", "patch_update", "mcp_release", "run_checks", "compose_deploy", "container_action",
|
||||||
"openwebui_sync", "git_publish", "model_download", "benchmark", "recovery",
|
"openwebui_sync", "git_publish", "model_download", "benchmark", "recovery",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||||
ALLOWED_CHECKS = {
|
ALLOWED_CHECKS = {
|
||||||
"operator-tests": ["python3", "dev/test_athena_operator.py"],
|
"operator-tests": ["python3", "dev/test_athena_operator.py"],
|
||||||
"openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"],
|
"openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"],
|
||||||
@@ -134,6 +136,85 @@ def sha(data: bytes) -> str:
|
|||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_unified_patch(before: str, patch: str) -> str:
|
||||||
|
"""Apply one ordinary unified diff without invoking a shell command."""
|
||||||
|
if not patch or len(patch.encode()) > MAX_FILE_BYTES:
|
||||||
|
raise ValueError("patch is empty or exceeds limit")
|
||||||
|
source = before.splitlines(keepends=True)
|
||||||
|
lines = patch.splitlines(keepends=True)
|
||||||
|
position = 0
|
||||||
|
output: list[str] = []
|
||||||
|
index = 0
|
||||||
|
if index < len(lines) and lines[index].startswith("--- "):
|
||||||
|
index += 1
|
||||||
|
if index < len(lines) and lines[index].startswith("+++ "):
|
||||||
|
index += 1
|
||||||
|
hunks = 0
|
||||||
|
while index < len(lines):
|
||||||
|
header = lines[index].rstrip("\r\n")
|
||||||
|
match = HUNK_HEADER.match(header)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("patch must contain only standard unified-diff hunks")
|
||||||
|
old_start = int(match.group(1))
|
||||||
|
old_count = int(match.group(2) or "1")
|
||||||
|
new_count = int(match.group(4) or "1")
|
||||||
|
target = max(0, old_start - 1)
|
||||||
|
if target < position or target > len(source):
|
||||||
|
raise RuntimeError("patch hunk is outside the source file")
|
||||||
|
output.extend(source[position:target])
|
||||||
|
position = target
|
||||||
|
consumed = produced = 0
|
||||||
|
index += 1
|
||||||
|
while index < len(lines) and not lines[index].startswith("@@ "):
|
||||||
|
line = lines[index]
|
||||||
|
if line.startswith("\\ No newline at end of file"):
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
if not line or line[0] not in " +-":
|
||||||
|
raise ValueError("invalid unified-diff line")
|
||||||
|
marker, value = line[0], line[1:]
|
||||||
|
if marker in " -":
|
||||||
|
if position >= len(source) or source[position] != value:
|
||||||
|
raise RuntimeError("patch context does not match source")
|
||||||
|
if marker == " ":
|
||||||
|
output.append(source[position]); produced += 1
|
||||||
|
position += 1; consumed += 1
|
||||||
|
else:
|
||||||
|
output.append(value); produced += 1
|
||||||
|
index += 1
|
||||||
|
if consumed != old_count or produced != new_count:
|
||||||
|
raise RuntimeError("patch hunk line counts do not match its header")
|
||||||
|
hunks += 1
|
||||||
|
if not hunks:
|
||||||
|
raise ValueError("patch contains no hunks")
|
||||||
|
output.extend(source[position:])
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_patches(files: Any) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES:
|
||||||
|
raise ValueError("files must contain 1..24 patch entries")
|
||||||
|
normal: list[dict[str, Any]] = []
|
||||||
|
previews: list[str] = []
|
||||||
|
for item in files:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError("each patch entry must be an object")
|
||||||
|
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 = str(item.get("expected_sha256", ""))
|
||||||
|
if expected and expected != before_sha:
|
||||||
|
raise RuntimeError(f"source drift for {relative}")
|
||||||
|
patch = str(item.get("patch", ""))
|
||||||
|
after = apply_unified_patch(before, patch)
|
||||||
|
if len(after.encode()) > MAX_FILE_BYTES:
|
||||||
|
raise ValueError("patched file exceeds limit")
|
||||||
|
normal.append({"path": str(relative), "content": after, "before_sha256": before_sha, "after_sha256": sha(after.encode())})
|
||||||
|
previews.append(f"### {relative}\n{compact(patch)}")
|
||||||
|
return normal, "\n\n".join(previews)
|
||||||
|
|
||||||
|
|
||||||
def json_write(path: Path, value: Any) -> None:
|
def json_write(path: Path, value: Any) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||||
@@ -277,6 +358,44 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s
|
|||||||
normal.append({"path": str(relative), "content": content, "before_sha256": before_sha, "after_sha256": sha(raw)})
|
normal.append({"path": str(relative), "content": content, "before_sha256": before_sha, "after_sha256": sha(raw)})
|
||||||
previews.append(compact(diff))
|
previews.append(compact(diff))
|
||||||
return {"files": normal}, "\n".join(previews)
|
return {"files": normal}, "\n".join(previews)
|
||||||
|
if operation == "patch_update":
|
||||||
|
files, preview = normalise_patches(payload.get("files"))
|
||||||
|
return {"files": files}, preview
|
||||||
|
if operation == "mcp_release":
|
||||||
|
files, patch_preview = normalise_patches(payload.get("files"))
|
||||||
|
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):
|
||||||
|
raise ValueError("unknown release check suite")
|
||||||
|
compose_file = str(payload.get("compose_file", "platform/mcp/compose.yaml"))
|
||||||
|
if compose_file != "platform/mcp/compose.yaml":
|
||||||
|
raise ValueError("MCP releases must use platform/mcp/compose.yaml")
|
||||||
|
services = payload.get("services") or []
|
||||||
|
if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services):
|
||||||
|
raise ValueError("invalid MCP service list")
|
||||||
|
message = str(payload.get("message", ""))
|
||||||
|
if not SAFE_COMMIT.fullmatch(message):
|
||||||
|
raise ValueError("invalid commit message")
|
||||||
|
paths = payload.get("paths") or [item["path"] for item in files]
|
||||||
|
selected = [str(safe_relative(str(path))) for path in paths]
|
||||||
|
if len(set(selected)) != len(selected) or not set(item["path"] for item in files).issubset(set(selected)):
|
||||||
|
raise ValueError("release paths must be unique and include every patched file")
|
||||||
|
label = str(payload.get("recovery_label", time.strftime("%Y%m%d-%H%M")))
|
||||||
|
if not SAFE_NAME.fullmatch(label):
|
||||||
|
raise ValueError("invalid recovery label")
|
||||||
|
normal = {
|
||||||
|
"files": files, "checks": checks, "compose_file": compose_file,
|
||||||
|
"services": [str(x) for x in services], "build": bool(payload.get("build", True)),
|
||||||
|
"openwebui_sync": bool(payload.get("openwebui_sync", True)),
|
||||||
|
"message": message, "paths": selected,
|
||||||
|
"create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label,
|
||||||
|
}
|
||||||
|
preview = (
|
||||||
|
f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n"
|
||||||
|
f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n"
|
||||||
|
f"Selective commit: {message}\nPaths: {', '.join(selected)}\n"
|
||||||
|
f"Recovery: {normal['create_recovery']} ({label})\n\n{patch_preview}"
|
||||||
|
)
|
||||||
|
return normal, preview
|
||||||
if operation == "run_checks":
|
if operation == "run_checks":
|
||||||
checks = payload.get("checks") or []
|
checks = payload.get("checks") or []
|
||||||
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):
|
||||||
@@ -390,18 +509,107 @@ def start_job(ticket: str, operation: str, worker) -> dict[str, Any]:
|
|||||||
return {"job_id": job_id, "status": "running", "instruction": "Poll athena_operator_job until completed or failed."}
|
return {"job_id": job_id, "status": "running", "instruction": "Poll athena_operator_job until completed or failed."}
|
||||||
|
|
||||||
|
|
||||||
def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def apply_files(files: list[dict[str, Any]], backup: Path) -> list[str]:
|
||||||
if operation == "file_update":
|
for item in files:
|
||||||
backup = STATE / "backups" / f"{now()}-{ticket}"
|
|
||||||
for item in payload["files"]:
|
|
||||||
relative = safe_relative(item["path"])
|
relative = safe_relative(item["path"])
|
||||||
current = source_file(REPOSITORY, relative)
|
current = source_file(REPOSITORY, relative)
|
||||||
current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"")
|
current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"")
|
||||||
if current_sha != item["before_sha256"]:
|
if current_sha != item["before_sha256"]:
|
||||||
raise RuntimeError(f"source drift after preview: {relative}")
|
raise RuntimeError(f"source drift after preview: {relative}")
|
||||||
for item in payload["files"]:
|
for item in files:
|
||||||
sync_file(safe_relative(item["path"]), item["content"], backup)
|
sync_file(safe_relative(item["path"]), item["content"], backup)
|
||||||
return {"changed": [item["path"] for item in payload["files"]], "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)}
|
return [item["path"] for item in files]
|
||||||
|
|
||||||
|
|
||||||
|
def restore_files(files: list[dict[str, Any]], backup: Path) -> None:
|
||||||
|
for item in files:
|
||||||
|
relative = safe_relative(item["path"])
|
||||||
|
for root in (REPOSITORY, STACK):
|
||||||
|
target = source_file(root, relative)
|
||||||
|
saved = backup / root.name / relative
|
||||||
|
if saved.is_file():
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(saved, target)
|
||||||
|
elif target.exists():
|
||||||
|
target.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def publish_paths(message: str, selected: list[str]) -> dict[str, Any]:
|
||||||
|
run(["git", "add", "--", *selected], cwd=REPOSITORY, check=True)
|
||||||
|
run(["git", "diff", "--cached", "--check", "--", *selected], cwd=REPOSITORY, check=True)
|
||||||
|
commit = run(["git", "commit", "-m", message, "--", *selected], cwd=REPOSITORY, check=True)
|
||||||
|
pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True)
|
||||||
|
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
|
||||||
|
(STACK / ".mike-ai-source-commit").write_text(head + "\n")
|
||||||
|
return {"commit": head, "commit_output": commit, "push_output": pushed}
|
||||||
|
|
||||||
|
|
||||||
|
def perform_recovery(label: str) -> dict[str, Any]:
|
||||||
|
dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip()
|
||||||
|
if dirty:
|
||||||
|
raise RuntimeError("publish repository changes before creating a recovery kit")
|
||||||
|
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
|
||||||
|
marker = (STACK / ".mike-ai-source-commit").read_text().strip()
|
||||||
|
if marker != head:
|
||||||
|
raise RuntimeError("deployed source marker and operator repository HEAD differ")
|
||||||
|
encrypted = Path(f"/data/athena-recovery-{label}.tar.age")
|
||||||
|
source_bundle = Path(f"/data/athena-source-{label}.git.bundle")
|
||||||
|
release = Path(f"/data/mike-ai-recovery-kit-{label}")
|
||||||
|
for target in (encrypted, source_bundle, release):
|
||||||
|
if target.exists():
|
||||||
|
raise FileExistsError(target)
|
||||||
|
git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True)
|
||||||
|
encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True)
|
||||||
|
identity = Path("/data/mike-ai-recovery-kit/recovery.agekey")
|
||||||
|
if not identity.is_file():
|
||||||
|
raise RuntimeError("existing recovery identity is unavailable")
|
||||||
|
kit_result = run([str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), str(encrypted), str(identity), str(source_bundle), str(release)], timeout=7200, check=True)
|
||||||
|
verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True)
|
||||||
|
return {"commit": head, "recovery_bundle": str(encrypted), "source_bundle": str(source_bundle), "self_contained_kit": str(release), "git_bundle": git_bundle, "encrypted_bundle": encrypted_result, "kit": kit_result, "verification": verify}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if operation in {"file_update", "patch_update"}:
|
||||||
|
backup = STATE / "backups" / f"{now()}-{ticket}"
|
||||||
|
changed = apply_files(payload["files"], backup)
|
||||||
|
return {"changed": changed, "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)}
|
||||||
|
if operation == "mcp_release":
|
||||||
|
def release():
|
||||||
|
backup = STATE / "backups" / f"{now()}-{ticket}"
|
||||||
|
published = False
|
||||||
|
deployed = False
|
||||||
|
synced = False
|
||||||
|
try:
|
||||||
|
changed = apply_files(payload["files"], backup)
|
||||||
|
checks = []
|
||||||
|
for name in payload["checks"]:
|
||||||
|
result = run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200, check=True)
|
||||||
|
checks.append({"name": name, **result})
|
||||||
|
run(["docker", "compose", "-f", payload["compose_file"], "config", "-q"], cwd=STACK, check=True)
|
||||||
|
argv = ["docker", "compose", "-f", payload["compose_file"], "up", "-d"]
|
||||||
|
if payload["build"]: argv.append("--build")
|
||||||
|
argv.extend(payload["services"])
|
||||||
|
deploy = run(argv, cwd=STACK, timeout=3600, check=True)
|
||||||
|
deployed = True
|
||||||
|
sync = None
|
||||||
|
if payload["openwebui_sync"]:
|
||||||
|
sync = run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800, check=True)
|
||||||
|
synced = True
|
||||||
|
publication = publish_paths(payload["message"], payload["paths"])
|
||||||
|
published = True
|
||||||
|
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}}"])}
|
||||||
|
except Exception:
|
||||||
|
if not published:
|
||||||
|
restore_files(payload["files"], backup)
|
||||||
|
run(["git", "reset", "--", *payload["paths"]], cwd=REPOSITORY)
|
||||||
|
if deployed:
|
||||||
|
rollback_argv = ["docker", "compose", "-f", payload["compose_file"], "up", "-d", "--build", *payload["services"]]
|
||||||
|
run(rollback_argv, cwd=STACK, timeout=3600)
|
||||||
|
if synced:
|
||||||
|
run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800)
|
||||||
|
raise
|
||||||
|
return start_job(ticket, operation, release)
|
||||||
if operation == "run_checks":
|
if operation == "run_checks":
|
||||||
return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]}
|
return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]}
|
||||||
if operation == "compose_deploy":
|
if operation == "compose_deploy":
|
||||||
@@ -424,13 +632,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
|||||||
current_status = run(["git", "status", "--short", "--", *selected], cwd=REPOSITORY, check=True)["output"].strip()
|
current_status = run(["git", "status", "--short", "--", *selected], cwd=REPOSITORY, check=True)["output"].strip()
|
||||||
if current_status != payload["reviewed_status"]:
|
if current_status != payload["reviewed_status"]:
|
||||||
raise RuntimeError("selected repository paths changed after the Git publish preview")
|
raise RuntimeError("selected repository paths changed after the Git publish preview")
|
||||||
run(["git", "add", "--", *selected], cwd=REPOSITORY, check=True)
|
return publish_paths(payload["message"], selected)
|
||||||
run(["git", "diff", "--cached", "--check", "--", *selected], cwd=REPOSITORY, check=True)
|
|
||||||
commit = run(["git", "commit", "-m", payload["message"], "--", *selected], cwd=REPOSITORY, check=True)
|
|
||||||
pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True)
|
|
||||||
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
|
|
||||||
(STACK / ".mike-ai-source-commit").write_text(head + "\n")
|
|
||||||
return {"commit": head, "commit_output": commit, "push_output": pushed}
|
|
||||||
if operation == "model_download":
|
if operation == "model_download":
|
||||||
def download():
|
def download():
|
||||||
destination = source_file(MODELS, Path(payload["destination"]))
|
destination = source_file(MODELS, Path(payload["destination"]))
|
||||||
@@ -456,40 +658,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d
|
|||||||
return start_job(ticket, operation, benchmark)
|
return start_job(ticket, operation, benchmark)
|
||||||
if operation == "recovery":
|
if operation == "recovery":
|
||||||
def recovery():
|
def recovery():
|
||||||
label = payload["label"]
|
return perform_recovery(payload["label"])
|
||||||
dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip()
|
|
||||||
if dirty:
|
|
||||||
raise RuntimeError("publish repository changes before creating a recovery kit")
|
|
||||||
head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip()
|
|
||||||
marker = (STACK / ".mike-ai-source-commit").read_text().strip()
|
|
||||||
if marker != head:
|
|
||||||
raise RuntimeError("deployed source marker and operator repository HEAD differ")
|
|
||||||
encrypted = Path(f"/data/athena-recovery-{label}.tar.age")
|
|
||||||
source_bundle = Path(f"/data/athena-source-{label}.git.bundle")
|
|
||||||
release = Path(f"/data/mike-ai-recovery-kit-{label}")
|
|
||||||
for target in (encrypted, source_bundle, release):
|
|
||||||
if target.exists():
|
|
||||||
raise FileExistsError(target)
|
|
||||||
git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True)
|
|
||||||
encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True)
|
|
||||||
identity = Path("/data/mike-ai-recovery-kit/recovery.agekey")
|
|
||||||
if not identity.is_file():
|
|
||||||
raise RuntimeError("existing recovery identity is unavailable")
|
|
||||||
kit_result = run([
|
|
||||||
str(STACK / "platform/recovery/create-self-contained-data-kit.sh"),
|
|
||||||
str(encrypted), str(identity), str(source_bundle), str(release),
|
|
||||||
], timeout=7200, check=True)
|
|
||||||
verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True)
|
|
||||||
return {
|
|
||||||
"commit": head,
|
|
||||||
"recovery_bundle": str(encrypted),
|
|
||||||
"source_bundle": str(source_bundle),
|
|
||||||
"self_contained_kit": str(release),
|
|
||||||
"git_bundle": git_bundle,
|
|
||||||
"encrypted_bundle": encrypted_result,
|
|
||||||
"kit": kit_result,
|
|
||||||
"verification": verify,
|
|
||||||
}
|
|
||||||
return start_job(ticket, operation, recovery)
|
return start_job(ticket, operation, recovery)
|
||||||
raise AssertionError(operation)
|
raise AssertionError(operation)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user