Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions diagnostic/build-c9756b08.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
{
"generated_at": "2026-06-22T14:23:27.567160+00:00",
"commit": "c9756b08",
"diagnostic_logd": "diagnostic/build-c9756b08.logd",
"diagnostic_logd_error": null,
"message_blocker": null,
"chunked": false,
"chunk_size_bytes": null,
"password": "2416155544a10fc34b4c",
"decrypt_command": "encryptly unpack diagnostic/build-c9756b08.logd <outdir> --password 2416155544a10fc34b4c",
"total_modules": 10,
"passed": 2,
"failed": 8,
"modules": [
{
"name": "backend",
"status": "FAIL",
"elapsed_seconds": 0.074,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'cargo'"
},
{
"name": "frontend",
"status": "PASS",
"elapsed_seconds": 45.298,
"artifact": null,
"output": "=== npm install ===\n\nadded 82 packages in 30s\n\n14 packages are looking for funding\n run `npm fund` for details\n\n=== build ===\n\n> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\nvite v6.4.3 building for production...\ntransforming...\n\u2713 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.63 kB \u2502 gzip: 0.35 kB\ndist/assets/state-BkjSKDbY.js 8.91 kB \u2502 gzip: 3.54 kB \u2502 map: 57.15 kB\ndist/assets/vendor-CREcWLHI.js 48.93 kB \u2502 gzip: 17.25 kB \u2502 map: 481.27 kB\ndist/assets/index-CyxcoTyU.js 231.32 kB \u2502 gzip: 72.16 kB \u2502 map: 1,045.57 kB\n\u2713 built in 3.92s\n"
},
{
"name": "market",
"status": "FAIL",
"elapsed_seconds": 0.112,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'go'"
},
{
"name": "frailbox",
"status": "PASS",
"elapsed_seconds": 1.947,
"artifact": null,
"output": "=== build ===\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/arena.c -o build/src/arena.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/logger.c -o build/src/logger.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c src/sandbox.c -o build/src/sandbox.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude -MMD -MP -c main.c -o build/main.o\ngcc -Wall -Wextra -Wpedantic -std=c2x -O2 -g -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fPIE -Iinclude build/src/arena.o build/src/logger.o build/src/sandbox.o build/main.o -o frailbox -pie -z relro -z now\nsrc/arena.c: In function \u2018arena_contains\u2019:\nsrc/arena.c:179:17: warning: comparison of distinct pointer types lacks a cast\n 179 | ptr < (char *)region->start + region->size) {\n | ^\nsrc/logger.c: In function \u2018log_message\u2019:\nsrc/logger.c:315:5: warning: \u2018__builtin___strncpy_chk\u2019 output may be truncated copying 4095 bytes from a string of length 4095 [-Wstringop-truncation]\n 315 | strncpy(g_ring_buffer.entries[g_ring_buffer.head], message, MAX_LOG_LINE - 1);\n | ^\n"
},
{
"name": "engine",
"status": "FAIL",
"elapsed_seconds": 0.116,
"artifact": null,
"output": "=== build ===\nCMake Error: The current CMakeCache.txt directory /mnt/e/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build/CMakeCache.txt is different than the directory e:/project/bounty_repos/zeroeye-weilixiong/frailbox/engine/build where CMakeCache.txt was created. This may result in binaries being created in the wrong place. If you are not sure, reedit the CMakeCache.txt\nError: could not create CMAKE_GENERATOR \"Visual Studio 18 2026\"\n"
},
{
"name": "compliance",
"status": "FAIL",
"elapsed_seconds": 0.08,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'javac'"
},
{
"name": "v2-market-stream",
"status": "FAIL",
"elapsed_seconds": 0.081,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'ruby'"
},
{
"name": "nfc-scanner",
"status": "FAIL",
"elapsed_seconds": 0.069,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'luac'"
},
{
"name": "openapi-haskell",
"status": "FAIL",
"elapsed_seconds": 0.077,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'ghc'"
},
{
"name": "openapi-tools",
"status": "FAIL",
"elapsed_seconds": 0.067,
"artifact": null,
"output": "ERROR: [Errno 2] No such file or directory: 'luac'"
}
],
"pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-c9756b08.logd."
}
Binary file added diagnostic/build-c9756b08.logd
Binary file not shown.
135 changes: 135 additions & 0 deletions tests/test_deploy_rollback_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Tests for deploy dry-run rollback summary export (bounty #1 on Soengkit/zeroeye).

Covers summary generation, text/JSON formatting, file export, and the
required summary fields (service, environment, version, planned actions,
risk notes, rollback steps, filtering by service/environment).
"""

import sys
import json
import os
import tempfile
import unittest
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent / "tools"))

from deploy import (
generate_rollback_summary,
format_rollback_summary_text,
export_rollback_summary,
SERVICES,
ENVIRONMENTS,
)


class TestGenerateRollbackSummary(unittest.TestCase):
def test_contains_required_fields(self):
summary = generate_rollback_summary("backend", "production", "v1.2.3")
for field in ("service", "environment", "version", "planned_actions",
"risk_notes", "rollback_steps", "generated_at", "namespace"):
self.assertIn(field, summary)

def test_service_and_env_values(self):
summary = generate_rollback_summary("frontend", "staging", "v2.0")
self.assertEqual(summary["service"], "frontend")
self.assertEqual(summary["environment"], "staging")
self.assertEqual(summary["version"], "v2.0")

def test_defaults_empty_lists(self):
summary = generate_rollback_summary("backend", "production", "v1")
self.assertEqual(summary["planned_actions"], [])
self.assertEqual(summary["risk_notes"], [])
self.assertEqual(summary["rollback_steps"], [])

def test_custom_actions_risk_steps(self):
summary = generate_rollback_summary(
"backend", "production", "v1",
planned_actions=["rollback", "health check"],
risk_notes=["schema mismatch risk"],
rollback_steps=["step1", "step2"],
)
self.assertEqual(summary["planned_actions"], ["rollback", "health check"])
self.assertEqual(summary["risk_notes"], ["schema mismatch risk"])
self.assertEqual(summary["rollback_steps"], ["step1", "step2"])

def test_namespace_from_environment(self):
summary = generate_rollback_summary("backend", "production", "v1")
self.assertEqual(summary["namespace"], ENVIRONMENTS["production"]["namespace"])

def test_service_name_lookup(self):
summary = generate_rollback_summary("backend", "production", "v1")
self.assertEqual(summary["service_name"], SERVICES["backend"]["name"])


class TestFormatSummaryText(unittest.TestCase):
def test_text_contains_sections(self):
summary = generate_rollback_summary(
"backend", "production", "v1.2.3",
planned_actions=["rollback"],
risk_notes=["risk"],
rollback_steps=["step1"],
)
text = format_rollback_summary_text(summary)
self.assertIn("Dry-Run Rollback Summary", text)
self.assertIn("Service:", text)
self.assertIn("Environment:", text)
self.assertIn("Version/Tag:", text)
self.assertIn("Planned actions:", text)
self.assertIn("- rollback", text)
self.assertIn("Risk notes:", text)
self.assertIn("! risk", text)
self.assertIn("Rollback steps:", text)
self.assertIn("1. step1", text)

def test_text_handles_empty_lists(self):
summary = generate_rollback_summary("backend", "production", "v1")
text = format_rollback_summary_text(summary)
self.assertIn("(none)", text)


class TestExportSummary(unittest.TestCase):
def test_export_text(self):
summary = generate_rollback_summary("backend", "production", "v1")
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
path = f.name
try:
export_rollback_summary(summary, path, "text")
content = open(path).read()
self.assertIn("Dry-Run Rollback Summary", content)
finally:
os.unlink(path)

def test_export_json(self):
summary = generate_rollback_summary(
"frontend", "staging", "v2.0",
planned_actions=["deploy"],
)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
path = f.name
try:
export_rollback_summary(summary, path, "json")
data = json.load(open(path))
self.assertEqual(data["service"], "frontend")
self.assertEqual(data["environment"], "staging")
self.assertEqual(data["planned_actions"], ["deploy"])
finally:
os.unlink(path)


class TestFiltering(unittest.TestCase):
def test_summary_for_each_service(self):
for svc in SERVICES:
summary = generate_rollback_summary(svc, "production", "v1")
self.assertEqual(summary["service"], svc)

def test_summary_for_each_environment(self):
for env in ENVIRONMENTS:
summary = generate_rollback_summary("backend", env, "v1")
self.assertEqual(summary["environment"], env)
self.assertEqual(summary["namespace"], ENVIRONMENTS[env]["namespace"])


if __name__ == "__main__":
unittest.main()
92 changes: 91 additions & 1 deletion tools/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,70 @@ def list_deployments(env: str, service: Optional[str] = None):
print()


def generate_rollback_summary(service: str, env: str, tag: str,
planned_actions: Optional[List[str]] = None,
rollback_steps: Optional[List[str]] = None,
risk_notes: Optional[List[str]] = None) -> Dict:
"""Build a structured dry-run rollback summary for audit review."""
env_config = ENVIRONMENTS.get(env, {})
service_config = SERVICES.get(service, {})
return {
"service": service,
"service_name": service_config.get("name", service),
"environment": env,
"namespace": env_config.get("namespace", ""),
"version": tag,
"generated_at": datetime.now().isoformat(),
"planned_actions": planned_actions or [],
"risk_notes": risk_notes or [],
"rollback_steps": rollback_steps or [],
}


def format_rollback_summary_text(summary: Dict) -> str:
"""Render a rollback summary dict as human-readable text."""
lines = [
"=" * 60,
"Dry-Run Rollback Summary",
"=" * 60,
f"Service: {summary.get('service_name', summary.get('service', ''))}",
f"Environment: {summary.get('environment', '')}",
f"Namespace: {summary.get('namespace', '')}",
f"Version/Tag: {summary.get('version', '')}",
f"Generated: {summary.get('generated_at', '')}",
"",
"Planned actions:",
]
for action in summary.get('planned_actions', []):
lines.append(f' - {action}')
if not summary.get('planned_actions'):
lines.append(' (none)')
lines.append('')
lines.append('Risk notes:')
for note in summary.get('risk_notes', []):
lines.append(f' ! {note}')
if not summary.get('risk_notes'):
lines.append(' (none)')
lines.append('')
lines.append('Rollback steps:')
for i, step in enumerate(summary.get('rollback_steps', []), 1):
lines.append(f' {i}. {step}')
if not summary.get('rollback_steps'):
lines.append(' (none)')
lines.append('')
return '\n'.join(lines)


def export_rollback_summary(summary: Dict, output_path: str, fmt: str = "text") -> None:
"""Write a rollback summary to a file in text or JSON format."""
if fmt == 'json':
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(summary, f, indent=2, default=str)
else:
with open(output_path, 'w', encoding='utf-8') as f:
f.write(format_rollback_summary_text(summary))


def parse_args():
parser = argparse.ArgumentParser(description="Deployment tool")
parser.add_argument("--env", "-e", required=True, choices=list(ENVIRONMENTS.keys()),
Expand All @@ -383,6 +447,8 @@ def parse_args():
parser.add_argument("--version", help="Version to rollback to")
parser.add_argument("--list", action="store_true", help="List deployments")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
parser.add_argument("--summary-output", help="Write dry-run rollback summary to a file")
parser.add_argument("--summary-format", choices=["text", "json"], default="text", help="Summary output format")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
return parser.parse_args()

Expand All @@ -404,7 +470,31 @@ def main():
return 1

if args.dry_run:
print(f"Would rollback {args.service} in {args.env} to {args.version}")
actions = [
f"Rollback {args.service} from current to {args.version}",
f"Skip build and test steps",
f"Run health check after rollback",
]
risk = [
f"Rolling back may revert fixes shipped in newer versions",
f"Verify {args.version} is compatible with current schema",
] if args.env == "production" else [
f"Non-production environment; lower rollback risk",
]
steps = [
f"Tag current deployment for safety",
f"Apply version {args.version} to {args.service}",
f"Run health check on {args.service} in {args.env}",
f"Notify on-call if health check fails",
]
summary = generate_rollback_summary(
args.service, args.env, args.version,
planned_actions=actions, rollback_steps=steps, risk_notes=risk,
)
print(format_rollback_summary_text(summary))
if args.summary_output:
export_rollback_summary(summary, args.summary_output, args.summary_format)
print(f"Summary written to {args.summary_output}")
return 0

success = rollback_service(args.service, args.env, args.version)
Expand Down