mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Plan 75 §G1, the remaining 33 files. Every raw subprocess call site in
backend/app that can go through a door now does. What is left is what
should be:
8 app/utils/system.py the doors themselves
8 database_service mysqldump|gzip, gunzip|mysql, pg_dump|gzip,
gunzip|psql — a pipe is two processes
7 build/docker/db_sync/ftp streaming output, gzip pipes
1 log_service tail -f
2 serverkit-analytics sibling repo; see below
Extensions were migrated in BOTH trees. builtin-extensions/ is the source
and backend/app/plugins/ the live copy, compared by normalised hash;
test_builtin_extension_drift stays green. serverkit-email has both sides
tracked, serverkit-ftp and serverkit-cloud-provision only the source.
serverkit-analytics (2 calls) is deliberately untouched: it has no source
in builtin-extensions/ at all, and backend/app/plugins/serverkit-analytics
is a gitignored materialised copy. Editing it would be a change that
cannot be committed and is overwritten on the next sync — that fix belongs
in the serverkit-analytics repo.
Exit-code semantics are preserved wherever they carry meaning, with a
comment at each site, because they are the thing a mechanical migration
destroys: clamscan 1 = INFECTED, grype 1 = vulnerabilities FOUND, yara
1 = matches, cron_runner 127 = never launched. Those read
result['returncode'], never result['success'].
run_checked gained two things real call sites needed: it yields when the
caller directs the streams itself (writing straight to a log file), rather
than adding capture_output on top and raising ValueError; and text/errors
pass through, so site_importers/ssh keeps decoding with errors='replace'
instead of gaining a UnicodeDecodeError on a bad byte.
A regression this migration introduced, caught by the suite and fixed:
install_profile_service._docker_usable() is deliberately TRI-STATE —
None means the probe could not run, because inside LXC the docker client
is usually present while the daemon never started, and callers "must not
treat a broken probe as proof of absence". Writing it as
run_checked(...)['success'] turned an exec failure into False, i.e. "docker
is not usable" — a fact nobody established. That is the §A violation this
plan exists to remove, reintroduced by the migration meant to prevent it.
test_probe_honesty_doctor caught it. Every other run_checked(...)['success']
caller was then audited; the rest are genuinely two-state, and their
originals also returned False on a missing binary.
Seven more slips of the same shape were caught before running by an AST
check for `x = run_checked(...)` followed by `x.stdout/.stderr/.returncode`
— a dict read as an object, which raises only on the failure path, so
happy-path tests stay green.
api_controller_boundary_baseline.json: pure deletion. Routing api/docker.py
through DockerService removed the last 6 subprocess crossings, so the
`subprocess` category is gone from the API layer entirely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
598 lines
22 KiB
Python
598 lines
22 KiB
Python
"""Git Deployment Service - Handles automatic deployments from git webhooks."""
|
|
|
|
import os
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Dict, Optional
|
|
|
|
from app.utils.system import run_checked
|
|
from app.utils.git_security import git_argv, git_env, validate_ref_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GitDeployService:
|
|
"""Service for managing git-based deployments."""
|
|
|
|
@classmethod
|
|
def deploy(cls, app_id: int, webhook_id: int = None, commit_sha: str = None,
|
|
commit_message: str = None, branch: str = None,
|
|
triggered_by: str = 'webhook') -> Dict:
|
|
"""
|
|
Deploy an application by pulling latest code and restarting.
|
|
|
|
Args:
|
|
app_id: Application ID to deploy
|
|
webhook_id: Optional webhook that triggered this deployment
|
|
commit_sha: Git commit SHA
|
|
commit_message: Git commit message
|
|
branch: Git branch name
|
|
triggered_by: How deployment was triggered ('webhook', 'manual', 'rollback')
|
|
|
|
Returns:
|
|
Dict with deployment result
|
|
"""
|
|
from app import db
|
|
from app.models import Application, GitWebhook, GitDeployment
|
|
from app.services.docker_service import DockerService
|
|
|
|
# query_active: a webhook can still fire for a deleted app — this pulls
|
|
# code and rebuilds/restarts containers, so a tombstone must not deploy.
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return {'success': False, 'error': 'Application not found'}
|
|
|
|
webhook = GitWebhook.query.get(webhook_id) if webhook_id else None
|
|
|
|
# Get next version number
|
|
last_deployment = GitDeployment.query.filter_by(app_id=app_id)\
|
|
.order_by(GitDeployment.version.desc()).first()
|
|
next_version = (last_deployment.version + 1) if last_deployment else 1
|
|
|
|
# Create deployment record
|
|
deployment = GitDeployment(
|
|
app_id=app_id,
|
|
webhook_id=webhook_id,
|
|
version=next_version,
|
|
commit_sha=commit_sha,
|
|
commit_message=commit_message,
|
|
branch=branch or (webhook.source_branch if webhook else 'main'),
|
|
triggered_by=triggered_by,
|
|
status='running',
|
|
started_at=datetime.utcnow()
|
|
)
|
|
|
|
db.session.add(deployment)
|
|
db.session.commit()
|
|
|
|
try:
|
|
# Create snapshot for rollback
|
|
snapshot = cls._create_snapshot(app)
|
|
deployment.snapshot_data = json.dumps(snapshot)
|
|
|
|
# Also capture an immutable *config* snapshot (env keys + masked
|
|
# values, domains, image, volumes, build method) for the deployment
|
|
# timeline / config diff. Best-effort: never block a deploy. Note we
|
|
# don't link a Deployment FK here — this path uses GitDeployment.
|
|
try:
|
|
from app.services.configuration_service import ConfigurationService
|
|
ConfigurationService.create_snapshot(app)
|
|
except Exception as cfg_err:
|
|
logger.warning('Config snapshot capture failed (git deploy): %s', cfg_err)
|
|
|
|
logs = []
|
|
|
|
# Step 1: Pre-deployment script
|
|
if webhook and webhook.pre_deploy_script:
|
|
logs.append("=== Running pre-deployment script ===")
|
|
pre_result = cls._run_script(webhook.pre_deploy_script, app.root_path)
|
|
deployment.pre_script_output = pre_result.get('output', '')
|
|
logs.append(pre_result.get('output', ''))
|
|
|
|
if not pre_result.get('success'):
|
|
raise Exception(f"Pre-deployment script failed: {pre_result.get('error')}")
|
|
|
|
# Step 2: Git pull
|
|
logs.append("\n=== Pulling latest code ===")
|
|
pull_result = cls._git_pull(app.root_path, branch or (webhook.source_branch if webhook else 'main'))
|
|
logs.append(pull_result.get('output', ''))
|
|
|
|
if not pull_result.get('success'):
|
|
raise Exception(f"Git pull failed: {pull_result.get('error')}")
|
|
|
|
# Step 3: Restart application
|
|
logs.append("\n=== Restarting application ===")
|
|
|
|
if webhook and webhook.zero_downtime:
|
|
restart_result = cls._zero_downtime_restart(app)
|
|
else:
|
|
restart_result = cls._standard_restart(app)
|
|
|
|
deployment.deploy_output = '\n'.join(logs) + '\n' + restart_result.get('output', '')
|
|
|
|
if not restart_result.get('success'):
|
|
raise Exception(f"Restart failed: {restart_result.get('error')}")
|
|
|
|
# Step 4: Post-deployment script
|
|
if webhook and webhook.post_deploy_script:
|
|
logs.append("\n=== Running post-deployment script ===")
|
|
post_result = cls._run_script(webhook.post_deploy_script, app.root_path)
|
|
deployment.post_script_output = post_result.get('output', '')
|
|
|
|
if not post_result.get('success'):
|
|
# Post-script failure is logged but doesn't fail deployment
|
|
logs.append(f"Warning: Post-deployment script failed: {post_result.get('error')}")
|
|
|
|
# Success
|
|
deployment.status = 'success'
|
|
deployment.completed_at = datetime.utcnow()
|
|
deployment.duration_seconds = int(
|
|
(deployment.completed_at - deployment.started_at).total_seconds()
|
|
)
|
|
|
|
# Update app status
|
|
app.status = 'running'
|
|
app.last_deployed_at = datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': True,
|
|
'deployment_id': deployment.id,
|
|
'version': deployment.version,
|
|
'duration': deployment.duration_seconds,
|
|
'message': f'Deployed version {deployment.version} successfully'
|
|
}
|
|
|
|
except Exception as e:
|
|
deployment.status = 'failed'
|
|
deployment.error_message = str(e)
|
|
deployment.completed_at = datetime.utcnow()
|
|
deployment.duration_seconds = int(
|
|
(deployment.completed_at - deployment.started_at).total_seconds()
|
|
)
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': False,
|
|
'deployment_id': deployment.id,
|
|
'version': deployment.version,
|
|
'error': str(e)
|
|
}
|
|
|
|
@classmethod
|
|
def rollback(cls, app_id: int, target_version: int = None) -> Dict:
|
|
"""
|
|
Rollback to a previous deployment version.
|
|
|
|
Args:
|
|
app_id: Application ID
|
|
target_version: Version to rollback to (default: previous version)
|
|
|
|
Returns:
|
|
Dict with rollback result
|
|
"""
|
|
from app import db
|
|
from app.models import Application, GitDeployment
|
|
from app.services.docker_service import DockerService
|
|
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return {'success': False, 'error': 'Application not found'}
|
|
|
|
# Get current deployment
|
|
current = GitDeployment.query.filter_by(app_id=app_id, status='success')\
|
|
.order_by(GitDeployment.version.desc()).first()
|
|
|
|
if not current:
|
|
return {'success': False, 'error': 'No successful deployment to rollback from'}
|
|
|
|
# Get target deployment
|
|
if target_version:
|
|
target = GitDeployment.query.filter_by(
|
|
app_id=app_id,
|
|
version=target_version,
|
|
status='success'
|
|
).first()
|
|
else:
|
|
# Get previous successful deployment
|
|
target = GitDeployment.query.filter_by(app_id=app_id, status='success')\
|
|
.filter(GitDeployment.version < current.version)\
|
|
.order_by(GitDeployment.version.desc()).first()
|
|
|
|
if not target:
|
|
return {'success': False, 'error': 'No previous version to rollback to'}
|
|
|
|
if not target.snapshot_data:
|
|
return {'success': False, 'error': 'Target deployment has no snapshot data'}
|
|
|
|
# Create rollback deployment
|
|
next_version = current.version + 1
|
|
deployment = GitDeployment(
|
|
app_id=app_id,
|
|
version=next_version,
|
|
commit_sha=target.commit_sha,
|
|
commit_message=f'Rollback to v{target.version}',
|
|
branch=target.branch,
|
|
triggered_by='rollback',
|
|
is_rollback=True,
|
|
rollback_from_version=current.version,
|
|
status='running',
|
|
started_at=datetime.utcnow()
|
|
)
|
|
|
|
db.session.add(deployment)
|
|
db.session.commit()
|
|
|
|
try:
|
|
# Restore snapshot
|
|
snapshot = json.loads(target.snapshot_data)
|
|
restore_result = cls._restore_snapshot(app, snapshot)
|
|
|
|
deployment.deploy_output = restore_result.get('output', '')
|
|
|
|
if not restore_result.get('success'):
|
|
raise Exception(f"Snapshot restore failed: {restore_result.get('error')}")
|
|
|
|
# Restart with restored config
|
|
restart_result = cls._standard_restart(app)
|
|
deployment.deploy_output += '\n' + restart_result.get('output', '')
|
|
|
|
if not restart_result.get('success'):
|
|
raise Exception(f"Restart failed: {restart_result.get('error')}")
|
|
|
|
# Success
|
|
deployment.status = 'success'
|
|
deployment.completed_at = datetime.utcnow()
|
|
deployment.duration_seconds = int(
|
|
(deployment.completed_at - deployment.started_at).total_seconds()
|
|
)
|
|
deployment.rolled_back_to_id = target.id
|
|
|
|
# Mark current as rolled back
|
|
current.rolled_back_at = datetime.utcnow()
|
|
|
|
# Update app
|
|
app.status = 'running'
|
|
app.last_deployed_at = datetime.utcnow()
|
|
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': True,
|
|
'deployment_id': deployment.id,
|
|
'version': deployment.version,
|
|
'rolled_back_from': current.version,
|
|
'rolled_back_to': target.version,
|
|
'message': f'Rolled back from v{current.version} to v{target.version}'
|
|
}
|
|
|
|
except Exception as e:
|
|
deployment.status = 'failed'
|
|
deployment.error_message = str(e)
|
|
deployment.completed_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
return {
|
|
'success': False,
|
|
'deployment_id': deployment.id,
|
|
'error': str(e)
|
|
}
|
|
|
|
@classmethod
|
|
def get_deployments(cls, app_id: int, limit: int = 20) -> Dict:
|
|
"""Get deployment history for an application."""
|
|
from app.models import GitDeployment
|
|
|
|
deployments = GitDeployment.query.filter_by(app_id=app_id)\
|
|
.order_by(GitDeployment.created_at.desc())\
|
|
.limit(limit).all()
|
|
|
|
return {
|
|
'success': True,
|
|
'deployments': [d.to_dict() for d in deployments],
|
|
'count': len(deployments)
|
|
}
|
|
|
|
@classmethod
|
|
def get_deployment(cls, deployment_id: int, include_logs: bool = False) -> Dict:
|
|
"""Get a specific deployment."""
|
|
from app.models import GitDeployment
|
|
|
|
deployment = GitDeployment.query.get(deployment_id)
|
|
if not deployment:
|
|
return {'success': False, 'error': 'Deployment not found'}
|
|
|
|
return {
|
|
'success': True,
|
|
'deployment': deployment.to_dict_full() if include_logs else deployment.to_dict()
|
|
}
|
|
|
|
@classmethod
|
|
def manual_deploy(cls, app_id: int, branch: str = None) -> Dict:
|
|
"""Trigger a manual deployment."""
|
|
from app.models import Application, GitWebhook
|
|
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return {'success': False, 'error': 'Application not found'}
|
|
|
|
# Find webhook for this app to get settings
|
|
webhook = GitWebhook.query.filter_by(app_id=app_id, is_active=True).first()
|
|
|
|
return cls.deploy(
|
|
app_id=app_id,
|
|
webhook_id=webhook.id if webhook else None,
|
|
branch=branch or (webhook.source_branch if webhook else 'main'),
|
|
triggered_by='manual'
|
|
)
|
|
|
|
# ==================== INTERNAL HELPERS ====================
|
|
|
|
@classmethod
|
|
def _git_pull(cls, path: str, branch: str = 'main') -> Dict:
|
|
"""Pull latest code from git."""
|
|
if not os.path.exists(path):
|
|
return {'success': False, 'error': f'Path does not exist: {path}'}
|
|
|
|
git_dir = os.path.join(path, '.git')
|
|
if not os.path.exists(git_dir):
|
|
return {'success': False, 'error': 'Not a git repository'}
|
|
|
|
# The branch flows in from API bodies and webhook configs; validate it
|
|
# and terminate options with '--' so it cannot be parsed as a git flag
|
|
# (GHSA-8vx6-432p-h62q).
|
|
ref_error = validate_ref_name(branch, 'branch')
|
|
if ref_error:
|
|
return {'success': False, 'error': ref_error}
|
|
|
|
try:
|
|
# Fetch and reset to remote branch
|
|
commands = [
|
|
git_argv('fetch', 'origin', '--', branch),
|
|
git_argv('reset', '--hard', f'origin/{branch}')
|
|
]
|
|
|
|
output = []
|
|
for cmd in commands:
|
|
result = run_checked(cmd, cwd=path, timeout=120, env=git_env())
|
|
output.append(f"$ {' '.join(cmd)}")
|
|
output.append(result['output'])
|
|
if result['stderr']:
|
|
output.append(result['stderr'])
|
|
|
|
if not result['success']:
|
|
return {
|
|
'success': False,
|
|
'error': result['error'],
|
|
'output': '\n'.join(output)
|
|
}
|
|
|
|
return {
|
|
'success': True,
|
|
'output': '\n'.join(output)
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Git pull timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def _standard_restart(cls, app, log_callback=None) -> Dict:
|
|
"""Standard restart - validate, build, then stop and start.
|
|
|
|
The default webhook path. It used to go straight to ``compose down``,
|
|
so a commit that broke the compose file or the build took the site down
|
|
and left it down (plan 72 B.1). The preflight now validates the compose
|
|
and builds the images to completion while the old containers are still
|
|
serving; only then is anything stopped.
|
|
"""
|
|
from app.services.docker_service import DockerService
|
|
from app.services import deploy_preflight_service as preflight
|
|
|
|
output = []
|
|
|
|
def _log(line: str) -> None:
|
|
output.append(line)
|
|
if log_callback:
|
|
try:
|
|
log_callback(line)
|
|
except Exception:
|
|
pass
|
|
|
|
checks = preflight.preflight_compose_project(app.root_path, log=_log)
|
|
if not checks.ok:
|
|
# Nothing was stopped — the previous release is still serving.
|
|
return {
|
|
'success': False,
|
|
'output': '\n'.join(output),
|
|
'error': checks.error,
|
|
'preflight': checks.to_dict(),
|
|
}
|
|
|
|
# ---- switchover: past this line the live stack is down -------------
|
|
stop_result = DockerService.compose_down(app.root_path)
|
|
output.append(f"Stop: {stop_result.get('message', stop_result.get('error', 'Unknown'))}")
|
|
|
|
# Build and start (the build is a cache hit — the preflight already
|
|
# built these images before anything was stopped)
|
|
up_result = DockerService.compose_up(app.root_path, detach=True, build=True)
|
|
output.append(f"Start: {up_result.get('message', up_result.get('error', 'Unknown'))}")
|
|
|
|
return {
|
|
'success': up_result.get('success', False),
|
|
'output': '\n'.join(output),
|
|
'error': up_result.get('error')
|
|
}
|
|
|
|
@classmethod
|
|
def _zero_downtime_restart(cls, app) -> Dict:
|
|
"""Zero-downtime restart using rolling update."""
|
|
from app.services.docker_service import DockerService
|
|
|
|
output = []
|
|
|
|
try:
|
|
# Build new images first
|
|
build_result = run_checked(['docker', 'compose', 'build'],
|
|
cwd=app.root_path, timeout=300)
|
|
output.append(f"Build: {build_result['output']}")
|
|
|
|
if not build_result['success']:
|
|
return {
|
|
'success': False,
|
|
'error': build_result['error'],
|
|
'output': '\n'.join(output)
|
|
}
|
|
|
|
# Rolling update - start new containers before stopping old
|
|
up_result = run_checked(
|
|
['docker', 'compose', 'up', '-d', '--no-deps', '--scale', 'web=2'],
|
|
cwd=app.root_path, timeout=120)
|
|
|
|
# Wait for the new container to be healthy. When a health-check path
|
|
# is declared, poll it instead of a blind fixed wait (plan 17 #4).
|
|
output.append(cls._wait_for_health(app))
|
|
|
|
# Scale back down
|
|
scale_result = run_checked(
|
|
['docker', 'compose', 'up', '-d', '--no-deps', '--scale', 'web=1'],
|
|
cwd=app.root_path, timeout=60)
|
|
|
|
output.append(f"Rolling update completed")
|
|
|
|
return {
|
|
'success': True,
|
|
'output': '\n'.join(output)
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Restart timed out', 'output': '\n'.join(output)}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e), 'output': '\n'.join(output)}
|
|
|
|
@classmethod
|
|
def _wait_for_health(cls, app, timeout: int = 30, fallback: int = 5) -> str:
|
|
"""Block until the app answers its health-check path, or time out.
|
|
|
|
Falls back to a fixed wait when no path/port is declared, preserving the
|
|
previous behavior. Best-effort — never raises.
|
|
"""
|
|
import time
|
|
|
|
path = getattr(app, 'healthcheck_path', None)
|
|
port = getattr(app, 'port', None)
|
|
if not path or not port:
|
|
time.sleep(fallback)
|
|
return f"Waited {fallback}s (no health check configured)"
|
|
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
url = f"http://127.0.0.1:{port}{path if path.startswith('/') else '/' + path}"
|
|
deadline = time.time() + timeout
|
|
last = None
|
|
while time.time() < deadline:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
|
if 200 <= resp.status < 400:
|
|
return f"Health check passed ({url} -> {resp.status})"
|
|
last = f"status {resp.status}"
|
|
except urllib.error.HTTPError as exc:
|
|
if 200 <= exc.code < 500:
|
|
# a 4xx still means the app is up and routing
|
|
return f"Health check reachable ({url} -> {exc.code})"
|
|
last = f"HTTP {exc.code}"
|
|
except Exception as exc: # not up yet
|
|
last = str(exc)
|
|
time.sleep(1)
|
|
return f"Health check did not pass within {timeout}s ({url}; last: {last})"
|
|
|
|
@classmethod
|
|
def _run_script(cls, script: str, cwd: str) -> Dict:
|
|
"""Run a deployment script."""
|
|
try:
|
|
result = run_checked(['bash', '-c', script], cwd=cwd, timeout=300,
|
|
env={**os.environ, 'DEPLOY_DIR': cwd})
|
|
|
|
return {
|
|
'success': result['success'],
|
|
'output': result['output'] + result['stderr'],
|
|
'error': result['error'],
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Script timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@classmethod
|
|
def _create_snapshot(cls, app) -> Dict:
|
|
"""Create a snapshot of current state for rollback."""
|
|
snapshot = {
|
|
'created_at': datetime.utcnow().isoformat(),
|
|
'app_id': app.id,
|
|
'files': {}
|
|
}
|
|
|
|
# Capture docker-compose.yml
|
|
compose_path = os.path.join(app.root_path, 'docker-compose.yml')
|
|
if os.path.exists(compose_path):
|
|
with open(compose_path, 'r') as f:
|
|
snapshot['files']['docker-compose.yml'] = f.read()
|
|
|
|
# Capture .env
|
|
env_path = os.path.join(app.root_path, '.env')
|
|
if os.path.exists(env_path):
|
|
with open(env_path, 'r') as f:
|
|
snapshot['files']['.env'] = f.read()
|
|
|
|
# Capture current git commit
|
|
try:
|
|
result = run_checked(['git', 'rev-parse', 'HEAD'],
|
|
cwd=app.root_path, timeout=10)
|
|
if result['success']:
|
|
snapshot['commit_sha'] = result['output'].strip()
|
|
except Exception:
|
|
pass
|
|
|
|
return snapshot
|
|
|
|
@classmethod
|
|
def _restore_snapshot(cls, app, snapshot: Dict) -> Dict:
|
|
"""Restore application state from snapshot."""
|
|
output = []
|
|
|
|
try:
|
|
# Restore files
|
|
for filename, content in snapshot.get('files', {}).items():
|
|
filepath = os.path.join(app.root_path, filename)
|
|
with open(filepath, 'w') as f:
|
|
f.write(content)
|
|
output.append(f"Restored {filename}")
|
|
|
|
# Checkout specific commit if available
|
|
if snapshot.get('commit_sha'):
|
|
result = run_checked(['git', 'checkout', snapshot['commit_sha']],
|
|
cwd=app.root_path, timeout=60)
|
|
if result['success']:
|
|
output.append(f"Checked out commit {snapshot['commit_sha'][:7]}")
|
|
else:
|
|
output.append(f"Warning: Could not checkout commit: {result['error']}")
|
|
|
|
return {
|
|
'success': True,
|
|
'output': '\n'.join(output)
|
|
}
|
|
|
|
except Exception as e:
|
|
return {
|
|
'success': False,
|
|
'error': str(e),
|
|
'output': '\n'.join(output)
|
|
}
|