mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 13:35:12 +09:00
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.
**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:
DELETE containers down (VOLUMES KEPT), vhost removed, firewall closed,
cron suspended, row tombstoned. All reversible.
RESTORE vhost rewritten, firewall reopened, cron resumed. Containers left
STOPPED -- "put it back" must not silently start processes or open
ports, the same reasoning that stops domain restore re-issuing certs.
PURGE volumes removed, uploaded source deleted, cron detached.
register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.
A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.
**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.
**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().
Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.
Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.
Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
122 lines
4.6 KiB
Python
122 lines
4.6 KiB
Python
# Bucket: PER-APP (plan 29 #9). Reads require app access (can_access_app); the
|
|
# enable/sync/redeploy/destroy mutations require write access (can_edit_app),
|
|
# replacing the old panel-wide @developer_required.
|
|
"""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, get_jwt_identity
|
|
|
|
from app.models import Application, User
|
|
from app.models.application_preview import ApplicationPreview
|
|
from app.services.preview_service import PreviewService
|
|
from app.services.resource_grant_service import ResourceGrantService
|
|
|
|
previews_bp = Blueprint('previews', __name__)
|
|
|
|
|
|
def _get_app_or_404(app_id, write=False):
|
|
"""Resolve the app and enforce per-app access (plan 29 #9). ``write`` requires
|
|
can_edit_app (owner/admin/editor); otherwise can_access_app (adds workspace
|
|
members). A caller without the needed tier gets a sealed 404 (no leak)."""
|
|
app = Application.query_active().filter_by(id=app_id).first()
|
|
if not app:
|
|
return None, (jsonify({'error': 'Application not found'}), 404)
|
|
user = User.query.get(get_jwt_identity())
|
|
ok = ResourceGrantService.can_edit_app(user, app) if write \
|
|
else ResourceGrantService.can_access_app(user, app)
|
|
if not ok:
|
|
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'])
|
|
@jwt_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, write=True)
|
|
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'])
|
|
@jwt_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, write=True)
|
|
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'])
|
|
@jwt_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, write=True)
|
|
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'])
|
|
@jwt_required()
|
|
def destroy_preview(app_id, preview_id):
|
|
"""Tear down and mark a preview destroyed."""
|
|
app, err = _get_app_or_404(app_id, write=True)
|
|
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
|