mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 13:35:12 +09:00
- Project / Environment hierarchy (C2): Workspace -> Project -> Environment org model. New Project/Environment models + CRUD APIs, nullable applications.project_id/environment_id (opt-in, no destructive backfill), ProjectService.ensure_default, a Projects list + ProjectDetail (env tabs), a project/environment selector in the create wizard, and sidebar nav. - Polymorphic shared resources facade (C9): ResourceTag + SharedVariableGroup /SharedVariable/attachment models keyed by (resource_type, resource_id), reusing the env-var Fernet encryption; SharedResourceService resolves merged variables (most-recent attachment wins) with secrets masked. /api/v1/shared endpoints + reusable TagsPanel/EnvironmentVariablesPanel + a Shared Variables management page. Existing per-resource tables untouched (facade first). - PR preview environments (C3): ApplicationPreview + settings models, a PreviewService with pure domain-templating + reconcile (open PRs vs active previews) cores and best-effort provisioning (incl. guarded WordPress file+DB clone), a pull_request webhook endpoint, preview.* jobs, and a Previews tab on the app detail page. - Per-server managed proxy stack (C6): opt-in Dockerized Traefik/Caddy as a Compose stack (host Nginx stays default and superior for PHP/WordPress). ProxyStack model + ProxyStackService with pure compose/config generators and versioned config backups, /api/v1/servers/<id>/proxy endpoints, and a Proxy tab on the server detail page. Migrations 031-034 add the new tables/columns; the full chain applies cleanly from an empty DB. Full backend suite: 871 passed, 14 skipped; frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
3.8 KiB
Python
111 lines
3.8 KiB
Python
"""PR Preview Environments API.
|
|
|
|
Mounted by the host at ``/api/v1/apps`` so every route here is nested under an
|
|
application id, e.g. ``GET /api/v1/apps/<app_id>/previews``.
|
|
"""
|
|
from flask import Blueprint, request, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from app.middleware.rbac import developer_required
|
|
from app.models import Application
|
|
from app.models.application_preview import ApplicationPreview
|
|
from app.services.preview_service import PreviewService
|
|
|
|
previews_bp = Blueprint('previews', __name__)
|
|
|
|
|
|
def _get_app_or_404(app_id):
|
|
app = Application.query.get(app_id)
|
|
if not app:
|
|
return None, (jsonify({'error': 'Application not found'}), 404)
|
|
return app, None
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews', methods=['GET'])
|
|
@jwt_required()
|
|
def list_previews(app_id):
|
|
"""List the non-destroyed previews for an application (newest first)."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
previews = ApplicationPreview.query.filter(
|
|
ApplicationPreview.application_id == app_id,
|
|
ApplicationPreview.status != ApplicationPreview.STATUS_DESTROYED,
|
|
).order_by(ApplicationPreview.pr_number.desc()).all()
|
|
return jsonify({'previews': [p.to_dict() for p in previews]}), 200
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews/settings', methods=['GET'])
|
|
@jwt_required()
|
|
def get_preview_settings(app_id):
|
|
"""Return the per-app preview settings (defaults when never configured)."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
settings = PreviewService.get_settings(app_id)
|
|
return jsonify(settings.to_dict()), 200
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews/settings', methods=['PUT'])
|
|
@developer_required
|
|
def update_preview_settings(app_id):
|
|
"""Enable/disable previews and set the domain template + TTL for an app."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
data = request.get_json(silent=True) or {}
|
|
try:
|
|
result = PreviewService.enable_previews(app_id, data)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
return jsonify({'error': str(exc)}), 400
|
|
return jsonify(result), 200
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews/sync', methods=['POST'])
|
|
@developer_required
|
|
def sync_previews(app_id):
|
|
"""Reconcile this app's previews against its open PRs (best-effort)."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
result = PreviewService.sync_previews(app_id)
|
|
status = 400 if result.get('error') else 200
|
|
return jsonify(result), status
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>/redeploy', methods=['POST'])
|
|
@developer_required
|
|
def redeploy_preview(app_id, preview_id):
|
|
"""Re-provision a preview for the same PR (idempotent update-in-place)."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
preview = ApplicationPreview.query.filter_by(
|
|
id=preview_id, application_id=app_id).first()
|
|
if not preview:
|
|
return jsonify({'error': 'Preview not found'}), 404
|
|
result = PreviewService.create_preview(app, {
|
|
'pr_number': preview.pr_number,
|
|
'branch': preview.branch,
|
|
'pr_title': preview.pr_title,
|
|
'commit_sha': preview.commit_sha,
|
|
})
|
|
status = 400 if result.get('error') else 200
|
|
return jsonify(result), status
|
|
|
|
|
|
@previews_bp.route('/<int:app_id>/previews/<int:preview_id>', methods=['DELETE'])
|
|
@developer_required
|
|
def destroy_preview(app_id, preview_id):
|
|
"""Tear down and mark a preview destroyed."""
|
|
app, err = _get_app_or_404(app_id)
|
|
if err:
|
|
return err
|
|
preview = ApplicationPreview.query.filter_by(
|
|
id=preview_id, application_id=app_id).first()
|
|
if not preview:
|
|
return jsonify({'error': 'Preview not found'}), 404
|
|
result = PreviewService.destroy_preview(preview_id)
|
|
status = 400 if result.get('error') else 200
|
|
return jsonify(result), status
|