Files
ServerKit/backend/app/api/git.py
T
Juan Denis d0b11714e7 Deploy compose apps through A/B slots
Plan 87 phase C. A compose app on slots runs each slot as its own
project, <name>-a / <name>-b, rendered from the merged compose config:
container_name stripped, only the web service published, on the
slot's loopback port, and every named volume pinned to the name it has
today. Without the pin compose prefixes volumes with the project name
and slot B would start on empty ones; the real-Docker leg checks this.

Stateful services (a database, cache or queue with a named volume)
cannot run twice. They move once, on an explicit previewed split, into
a shared <name>-data project that both slots reach by the same names.

The webhook, template-update and rollback paths deploy through the
engine instead of stopping the stack. The --scale web=2 rolling
restart and the Webhook.zero_downtime flag are deleted: it assumed a
service named web, ignored its own health result, and no UI set it.
Compose slot images get immutable per-deployment tags and are pruned
with the rest.
2026-09-28 22:26:44 -04:00

284 lines
9.7 KiB
Python

# Bucket: PER-APP (plan 29 #9, #11). The per-app deployment read gates on app
# visibility (require_app_member); webhook reads are scoped to the linked app's
# visibility (an unlinked webhook stays panel-wide viewer-visible). Webhook
# mutations and the Gitea host lifecycle stay admin-only.
"""Git webhook + deployment API — the deploy-pipeline half.
The Gitea self-host surface (lifecycle + repo browsing) moved to the
serverkit-git extension (plan 52 Phase 6), which mounts it back at this same
/api/v1/git prefix when installed."""
from flask import Blueprint, request, jsonify
from ..middleware.rbac import (
admin_required, viewer_required, require_app_member,
get_current_user, app_access_tier,
)
from ..models import Application, GitWebhook
from ..services.webhook_service import WebhookService
from ..services.git_deploy_service import GitDeployService
git_bp = Blueprint('git', __name__)
def _webhook_visible_to(user, webhook):
"""A webhook with no app linkage is a panel-wide viewer-visible resource; an
app-linked webhook is visible only to callers who can reach that app
(owner/admin/grant/workspace member — plan 29 #11)."""
if not getattr(webhook, 'app_id', None):
return True
# Deliberately unfiltered: this gate falls through to "panel-wide visible"
# when the app can't be resolved, so hiding tombstones here would WIDEN
# access — deleting an app would publish its webhook to every viewer.
app = Application.query.get(webhook.app_id)
if app is None:
return True
return app_access_tier(user, app) is not None
# ==================== WEBHOOK ENDPOINTS ====================
@git_bp.route('/webhooks', methods=['GET'])
@viewer_required
def list_webhooks():
"""List webhooks the caller can see (plan 29 #11): every unlinked webhook
plus any app-linked webhook whose app they can reach."""
user = get_current_user()
result = WebhookService.list_webhooks()
webhooks = result.get('webhooks', [])
visible = []
for w in webhooks:
wh = GitWebhook.query.get(w.get('id'))
if wh is None or _webhook_visible_to(user, wh):
visible.append(w)
result['webhooks'] = visible
if 'count' in result:
result['count'] = len(visible)
return jsonify(result), 200
@git_bp.route('/webhooks', methods=['POST'])
@admin_required
def create_webhook():
"""Create a new webhook."""
data = request.get_json() or {}
result = WebhookService.create_webhook(
name=data.get('name'),
source=data.get('source'),
source_repo_url=data.get('sourceRepoUrl'),
source_branch=data.get('sourceBranch', 'main'),
local_repo_name=data.get('localRepoName'),
sync_direction=data.get('syncDirection', 'pull'),
auto_sync=data.get('autoSync', True),
app_id=data.get('appId'),
deploy_on_push=data.get('deployOnPush', False),
pre_deploy_script=data.get('preDeployScript'),
post_deploy_script=data.get('postDeployScript'),
)
if result.get('success'):
return jsonify(result), 201
return jsonify(result), 400
@git_bp.route('/webhooks/<int:webhook_id>', methods=['GET'])
@viewer_required
def get_webhook(webhook_id):
"""Get a specific webhook (scoped to the linked app's visibility, #11)."""
user = get_current_user()
wh = GitWebhook.query.get(webhook_id)
if wh is None or not _webhook_visible_to(user, wh):
return jsonify({'error': 'Not found'}), 404
result = WebhookService.get_webhook(webhook_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@git_bp.route('/webhooks/<int:webhook_id>', methods=['PUT'])
@admin_required
def update_webhook(webhook_id):
"""Update a webhook."""
data = request.get_json() or {}
result = WebhookService.update_webhook(webhook_id, data)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@git_bp.route('/webhooks/<int:webhook_id>', methods=['DELETE'])
@admin_required
def delete_webhook(webhook_id):
"""Delete a webhook."""
result = WebhookService.delete_webhook(webhook_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@git_bp.route('/webhooks/<int:webhook_id>/toggle', methods=['POST'])
@admin_required
def toggle_webhook(webhook_id):
"""Toggle webhook active status."""
result = WebhookService.toggle_webhook(webhook_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@git_bp.route('/webhooks/<int:webhook_id>/logs', methods=['GET'])
@viewer_required
def get_webhook_logs(webhook_id):
"""Get logs for a specific webhook (scoped to the linked app's visibility)."""
user = get_current_user()
wh = GitWebhook.query.get(webhook_id)
if wh is None or not _webhook_visible_to(user, wh):
return jsonify({'error': 'Not found'}), 404
limit = request.args.get('limit', 50, type=int)
result = WebhookService.get_webhook_logs(webhook_id, limit=limit)
return jsonify(result), 200
@git_bp.route('/webhooks/<int:webhook_id>/test', methods=['POST'])
@admin_required
def test_webhook(webhook_id):
"""Test a webhook by triggering a manual sync."""
result = WebhookService.test_webhook(webhook_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
# Public endpoint - no auth required (verified by signature)
@git_bp.route('/webhooks/receive/<token>', methods=['POST'])
def receive_webhook(token):
"""Receive incoming webhook from GitHub/GitLab/Bitbucket."""
# Determine source from headers
if 'X-GitHub-Event' in request.headers:
source = 'github'
event_type = request.headers.get('X-GitHub-Event')
signature = request.headers.get('X-Hub-Signature-256')
delivery_id = request.headers.get('X-GitHub-Delivery')
elif 'X-Gitlab-Event' in request.headers:
source = 'gitlab'
event_type = request.headers.get('X-Gitlab-Event')
signature = request.headers.get('X-Gitlab-Token')
delivery_id = None
elif 'X-Event-Key' in request.headers:
source = 'bitbucket'
event_type = request.headers.get('X-Event-Key')
signature = request.headers.get('X-Hub-Signature')
delivery_id = request.headers.get('X-Request-Id')
else:
return jsonify({'error': 'Unknown webhook source'}), 400
result = WebhookService.handle_webhook(
token=token,
source=source,
event_type=event_type,
signature=signature,
delivery_id=delivery_id,
headers=dict(request.headers),
payload=request.get_data(),
payload_json=request.get_json(silent=True)
)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
# ==================== DEPLOYMENT ENDPOINTS ====================
@git_bp.route('/deployments/app/<int:app_id>', methods=['GET'])
@require_app_member()
def get_app_deployments(app_id):
"""Get deployment history for an application."""
limit = request.args.get('limit', 20, type=int)
result = GitDeployService.get_deployments(app_id, limit=limit)
return jsonify(result), 200
@git_bp.route('/deployments/<int:deployment_id>', methods=['GET'])
@viewer_required
def get_deployment(deployment_id):
"""Get a specific deployment with logs."""
from app.models import GitDeployment
deployment = GitDeployment.query.get(deployment_id)
if deployment is None:
return jsonify({'success': False, 'error': 'Deployment not found'}), 404
app = Application.query_active().filter_by(id=deployment.app_id).first()
if app is None or app_access_tier(get_current_user(), app) is None:
return jsonify({'success': False, 'error': 'Access denied'}), 403
include_logs = request.args.get('logs', 'false').lower() == 'true'
result = GitDeployService.get_deployment(deployment_id, include_logs=include_logs)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@git_bp.route('/deployments/app/<int:app_id>/deploy', methods=['POST'])
@admin_required
def manual_deploy(app_id):
"""Trigger a manual deployment for an application."""
data = request.get_json() or {}
branch = data.get('branch')
result = GitDeployService.manual_deploy(app_id, branch=branch)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@git_bp.route('/deployments/app/<int:app_id>/rollback', methods=['POST'])
@admin_required
def rollback_deployment(app_id):
"""Rollback to a previous deployment version."""
data = request.get_json() or {}
target_version = data.get('targetVersion')
result = GitDeployService.rollback(app_id, target_version=target_version)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 400
@git_bp.route('/deployments/webhook/<int:webhook_id>', methods=['GET'])
@viewer_required
def get_webhook_deployments(webhook_id):
"""Get deployments triggered by a specific webhook."""
from app.models import GitDeployment
webhook = GitWebhook.query.get(webhook_id)
if webhook is None:
return jsonify({'success': False, 'error': 'Webhook not found'}), 404
if not _webhook_visible_to(get_current_user(), webhook):
return jsonify({'success': False, 'error': 'Access denied'}), 403
limit = request.args.get('limit', 20, type=int)
deployments = GitDeployment.query.filter_by(webhook_id=webhook_id)\
.order_by(GitDeployment.created_at.desc())\
.limit(limit).all()
return jsonify({
'success': True,
'deployments': [d.to_dict() for d in deployments],
'count': len(deployments)
}), 200