Add compact Athena MCP release workflow
This commit is contained in:
@@ -85,6 +85,45 @@ class OperatorTests(unittest.TestCase):
|
||||
with self.assertRaises(RuntimeError):
|
||||
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):
|
||||
self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS)
|
||||
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
|
||||
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
|
||||
|
||||
- Systempartition vollständig gefüllt
|
||||
|
||||
@@ -91,16 +91,16 @@ anfordern oder kopieren.
|
||||
|
||||
Der verbindliche Ablauf für dauerhafte Änderungen lautet:
|
||||
|
||||
1. `athena_operator_prepare` und nach separater Benutzerfreigabe
|
||||
`athena_operator_execute` mit `file_update`; dies schreibt dieselben
|
||||
ausgewählten Dateien driftgeschützt in den kanonischen Working Tree und die
|
||||
ausgerollte Kopie.
|
||||
2. Prüfungen über die Operation `run_checks` ausführen.
|
||||
3. Nur betroffene Dienste über `compose_deploy` ausrollen.
|
||||
4. Ausschließlich die ausdrücklich angegebenen geänderten Pfade mit
|
||||
`git_publish` committen und pushen. Fremde Dirty-Worktree-Dateien bleiben
|
||||
unberührt.
|
||||
5. Mit `recovery` einen neuen Recovery-Koffer erzeugen und prüfen.
|
||||
1. Kleine Änderungen mit `patch_update` als SHA-geschützten Unified Diff
|
||||
vorbereiten. `file_update` ist neuen oder vollständig ersetzten Dateien
|
||||
vorbehalten.
|
||||
2. Für einen normalen MCP-Lifecycle bevorzugt ein einziges `mcp_release`
|
||||
vorbereiten und nach separater Benutzerfreigabe ausführen. Es bündelt
|
||||
Prüfungen, benannten Compose-Deploy, OpenWebUI-Sync, selektiven Git-Publish
|
||||
und Recovery.
|
||||
3. Einzeloperationen `run_checks`, `compose_deploy`, `git_publish` und
|
||||
`recovery` nur für Diagnose oder bewusst partielle Wartung verwenden.
|
||||
Fremde Dirty-Worktree-Dateien bleiben unberührt.
|
||||
|
||||
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
|
||||
|
||||
@@ -166,5 +166,10 @@ geändert. Die Abweichung wird benannt und zuerst geklärt.
|
||||
/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
|
||||
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
|
||||
offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für
|
||||
dauerhafte Änderungen ist ausschließlich der strukturierte Athena-Operator-
|
||||
Arbeitsweg vorgesehen: `file_update` ändert driftgeschützt den kanonischen
|
||||
Working Tree und die ausgerollte Kopie, `run_checks` prüft, `compose_deploy`
|
||||
Arbeitsweg vorgesehen: `patch_update` ändert kleine Stellen als SHA-geschützten
|
||||
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
|
||||
ausgewählte Pfade und `recovery` erneuert den Recovery-Koffer. Lege niemals
|
||||
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
|
||||
container. Prefer `athena_operator_prepare`; show its full preview and stop
|
||||
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 canonical worktree and its deploy credentials.
|
||||
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,
|
||||
and one bounded functional test. A running container alone is not proof.
|
||||
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
|
||||
recovery bundle, then check maintenance status.
|
||||
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 healthy container can still expose the wrong model, route, or tool set.
|
||||
- `/opt/mike-ai/stack` is deployed source, not automatically the canonical Git
|
||||
worktree. Complete durable changes through Operator operations `file_update`,
|
||||
`run_checks`, `compose_deploy`, `git_publish`, and `recovery`.
|
||||
worktree. Use `patch_update` for compact edits and `mcp_release` for the
|
||||
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
|
||||
session is expected.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
VERSION = "2.0.0"
|
||||
VERSION = "2.2.0"
|
||||
SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock")
|
||||
|
||||
if hasattr(sys.stdin, "reconfigure"):
|
||||
@@ -84,12 +84,18 @@ TOOLS = [
|
||||
"description": (
|
||||
"PREPARE a state-changing Athena operation. This never changes state. It returns a "
|
||||
"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 "
|
||||
"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 "
|
||||
"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]}; "
|
||||
"compose_deploy: {compose_file,services,build}; container_action: {action,containers}; "
|
||||
"openwebui_sync: {}; "
|
||||
@@ -99,7 +105,7 @@ TOOLS = [
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"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"},
|
||||
},
|
||||
"required": ["operation", "payload"], "additionalProperties": False,
|
||||
|
||||
@@ -191,7 +191,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
environment:
|
||||
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||
|
||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
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()
|
||||
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()
|
||||
@@ -48,9 +48,11 @@ PROTECTED_CONTAINERS = {
|
||||
"mike-ai-wireguard-gateway",
|
||||
}
|
||||
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",
|
||||
}
|
||||
|
||||
HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
||||
ALLOWED_CHECKS = {
|
||||
"operator-tests": ["python3", "dev/test_athena_operator.py"],
|
||||
"openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"],
|
||||
@@ -134,6 +136,85 @@ def sha(data: bytes) -> str:
|
||||
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:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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)})
|
||||
previews.append(compact(diff))
|
||||
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":
|
||||
checks = payload.get("checks") or []
|
||||
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."}
|
||||
|
||||
|
||||
def apply_files(files: list[dict[str, Any]], backup: Path) -> list[str]:
|
||||
for item in files:
|
||||
relative = safe_relative(item["path"])
|
||||
current = source_file(REPOSITORY, relative)
|
||||
current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"")
|
||||
if current_sha != item["before_sha256"]:
|
||||
raise RuntimeError(f"source drift after preview: {relative}")
|
||||
for item in files:
|
||||
sync_file(safe_relative(item["path"]), item["content"], backup)
|
||||
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 == "file_update":
|
||||
if operation in {"file_update", "patch_update"}:
|
||||
backup = STATE / "backups" / f"{now()}-{ticket}"
|
||||
for item in payload["files"]:
|
||||
relative = safe_relative(item["path"])
|
||||
current = source_file(REPOSITORY, relative)
|
||||
current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"")
|
||||
if current_sha != item["before_sha256"]:
|
||||
raise RuntimeError(f"source drift after preview: {relative}")
|
||||
for item in payload["files"]:
|
||||
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)}
|
||||
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":
|
||||
return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]}
|
||||
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()
|
||||
if current_status != payload["reviewed_status"]:
|
||||
raise RuntimeError("selected repository paths changed after the Git publish preview")
|
||||
run(["git", "add", "--", *selected], cwd=REPOSITORY, check=True)
|
||||
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}
|
||||
return publish_paths(payload["message"], selected)
|
||||
if operation == "model_download":
|
||||
def download():
|
||||
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)
|
||||
if operation == "recovery":
|
||||
def recovery():
|
||||
label = 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 perform_recovery(payload["label"])
|
||||
return start_job(ticket, operation, recovery)
|
||||
raise AssertionError(operation)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user