mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 08:36:55 +09:00
227 lines
10 KiB
Python
227 lines
10 KiB
Python
"""Recycle-bin hooks for Application: what a delete stops, and what a purge destroys.
|
|
|
|
The split this module exists to enforce:
|
|
|
|
SOFT DELETE stop serving. Containers down (volumes KEPT), vhost removed,
|
|
firewall ports closed, cron suspended. Every one of those is
|
|
reversible.
|
|
RESTORE re-publish. Vhost rewritten from the app's live domains,
|
|
firewall reopened, cron resumed. Containers are left STOPPED.
|
|
PURGE the irreversible half: data volumes removed, uploaded source
|
|
tree deleted, cron association dropped, row destroyed.
|
|
|
|
Why the split matters: `delete_app` used to run `compose_down(volumes=True)` and
|
|
`shutil.rmtree()` on the way out. Doing that AND keeping a tombstone would offer
|
|
a Restore button for a row whose data the delete had already destroyed — the one
|
|
promise the recycle bin cannot break. So the destructive half moved to purge and
|
|
the tombstone now means something.
|
|
|
|
Containers are deliberately NOT restarted on restore, for the reason
|
|
`domain_restore` does not re-issue certificates: "put it back" should not
|
|
silently start processes, open ports to the internet, or begin consuming CPU on
|
|
a machine whose owner has not looked at this app in three weeks. The row is
|
|
back, it is published, and Start is one click away.
|
|
"""
|
|
import logging
|
|
import os
|
|
import shutil
|
|
|
|
from app import db
|
|
from app.models import Application
|
|
from app.services.cron_service import CronService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def applications_owned_by(user_id):
|
|
"""Count of Application rows a user owns — live AND tombstoned.
|
|
|
|
Applications never ride a user delete: their user_id is NOT NULL, but the
|
|
rows own containers and volumes that must go through app delete + Recycle
|
|
Bin purge — never a silent cascade. Unfiltered on purpose: a tombstoned
|
|
app still holds the FK, so it blocks too.
|
|
"""
|
|
return Application.query.filter_by(user_id=user_id).count()
|
|
|
|
|
|
def _live_name_clash(app):
|
|
"""Another LIVE app already using this one's name, if any."""
|
|
return (Application.query_active()
|
|
.filter(Application.name == app.name, Application.id != app.id)
|
|
.first())
|
|
|
|
|
|
def pre_restore_application(app):
|
|
"""Refuse a restore that cannot work, with a sentence the user can act on.
|
|
|
|
Mirrors `pre_restore_domain`: a partial unique index makes "restorable" a
|
|
moving target, so the conflict has to be reported rather than raised as an
|
|
IntegrityError at commit.
|
|
"""
|
|
if app.private_slug:
|
|
clash = (Application.query_active()
|
|
.filter(Application.private_slug == app.private_slug,
|
|
Application.id != app.id)
|
|
.first())
|
|
if clash:
|
|
return (f'the private URL “/p/{app.private_slug}” has been taken by '
|
|
f'“{clash.name}” since this was deleted. Free it there first, '
|
|
f'or leave this in the bin.')
|
|
|
|
# `name` has no unique constraint, so this is NOT an integrity failure — but
|
|
# a second live app with the same name means one nginx vhost filename for
|
|
# two apps, and whichever writes last wins. Refusing is kinder than
|
|
# restoring something that will silently fight over its own config.
|
|
clash = _live_name_clash(app)
|
|
if clash:
|
|
return (f'“{app.name}” has been created again since this was deleted '
|
|
f'(app #{clash.id}). Rename one of them first, or leave this in '
|
|
f'the bin.')
|
|
|
|
if app.root_path and not os.path.exists(app.root_path):
|
|
return (f'its files are gone from {app.root_path}, so restoring the '
|
|
f'record would give you an app with nothing to serve')
|
|
|
|
return None
|
|
|
|
|
|
def on_restore_application(app):
|
|
"""Re-publish a restored app. Returns a notice string, or None.
|
|
|
|
Deliberately does NOT start containers — see the module docstring.
|
|
"""
|
|
notices = []
|
|
|
|
# Re-open any public ports the app declared. Best-effort: a firewall that
|
|
# refuses is worth telling the user about, not worth failing the restore.
|
|
try:
|
|
from app.services.app_port_service import AppPortService
|
|
declared = AppPortService.get_ports(app)
|
|
if declared:
|
|
AppPortService.open_firewall(declared)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Restore %s: firewall reopen failed: %s', app.name, exc)
|
|
notices.append('its firewall ports could not be reopened')
|
|
|
|
# Rewrite the vhost from the app's LIVE domains, through the same path the
|
|
# domain restore uses — write_app_vhost re-enables the site and re-attaches
|
|
# a covering wildcard cert, which a bare create_site would not.
|
|
try:
|
|
from app.services.site_domain_service import SiteDomainService
|
|
result = SiteDomainService.write_app_vhost(app) or {}
|
|
if result.get('warning'):
|
|
notices.append(result['warning'])
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Restore %s: vhost rewrite failed: %s', app.name, exc)
|
|
notices.append(f'its vhost could not be rewritten ({exc})')
|
|
|
|
# Re-enable exactly the cron jobs the delete suspended.
|
|
try:
|
|
resumed = CronService.resume_for_application(app.id)
|
|
if resumed:
|
|
notices.append(f'{resumed} cron job{"s" if resumed != 1 else ""} resumed')
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Restore %s: cron resume failed: %s', app.name, exc)
|
|
notices.append('its cron jobs could not be resumed')
|
|
|
|
notices.append('containers were left stopped — start it when you are ready')
|
|
return f'“{app.name}” is back: ' + '; '.join(notices) + '.'
|
|
|
|
|
|
def on_purge_application(app):
|
|
"""The irreversible half of deleting an app.
|
|
|
|
Runs immediately before the row is destroyed, from BOTH purge paths (an
|
|
explicit purge and the retention sweep). Anything here is unrecoverable by
|
|
definition, which is exactly why it is not on the delete path.
|
|
"""
|
|
from app import paths
|
|
from app.services.docker_service import DockerService
|
|
|
|
# A purge normally reclaims the volumes -- that is most of what it is for.
|
|
# `_purge_remove_data` is the one exception: DELETE ?purge=true skips the
|
|
# bin, and the caller may have said `?remove_data=false` (or be uninstalling
|
|
# a database ENGINE, which defaults to preserving its volumes because losing
|
|
# the data is the only irreversible part of that uninstall). The flag is
|
|
# stamped on the row by that route; the retention sweep sets nothing and so
|
|
# gets the default.
|
|
remove_data = getattr(app, '_purge_remove_data', True)
|
|
if app.app_type == 'docker' and app.root_path:
|
|
try:
|
|
DockerService.compose_down(app.root_path, volumes=remove_data,
|
|
remove_orphans=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Purge %s: compose down failed: %s', app.name, exc)
|
|
|
|
# Reclaim the app's directory whenever ServerKit created it — anything
|
|
# whose root_path lives under APPS_DIR: a git clone, an uploaded source
|
|
# tree, a template render. A purge that leaves the directory behind blocks
|
|
# the name forever ("App directory already exists" on the next create)
|
|
# while the Recycle Bin shows nothing to explain it. The one exception is
|
|
# a `manual` app: its root_path points at a directory the operator already
|
|
# had, and removing that would destroy data ServerKit never created — the
|
|
# same guard delete_app has always carried.
|
|
try:
|
|
base = os.path.abspath(paths.APPS_DIR)
|
|
root = os.path.abspath(app.root_path) if app.root_path else None
|
|
if (app.source != 'manual' and root
|
|
and root.startswith(base + os.sep)
|
|
and os.path.exists(root)):
|
|
shutil.rmtree(root)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Purge %s: root path removal failed: %s', app.name, exc)
|
|
|
|
# Now that the app is going for good, its cron jobs fall back to the System
|
|
# bucket rather than pointing at an id that no longer resolves.
|
|
try:
|
|
CronService.clear_application(app.id)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning('Purge %s: cron detach failed: %s', app.name, exc)
|
|
|
|
|
|
def suspend_application(app, user_id=None):
|
|
"""Tombstone an app and stop it serving. The reversible half of a delete.
|
|
|
|
Returns a dict of what happened, in the shape `delete_app` already reports.
|
|
"""
|
|
from app.services.docker_service import DockerService
|
|
from app.services.nginx_service import NginxService
|
|
|
|
results = {'docker': None, 'nginx': None, 'cron': None, 'firewall': None}
|
|
|
|
try:
|
|
from app.services.app_port_service import AppPortService
|
|
declared = AppPortService.get_ports(app)
|
|
if declared:
|
|
results['firewall'] = AppPortService.close_firewall(declared)
|
|
except Exception as exc: # noqa: BLE001
|
|
results['firewall'] = {'error': str(exc)}
|
|
|
|
if app.app_type == 'docker' and app.root_path:
|
|
try:
|
|
# volumes=False, ALWAYS. The volumes are what makes this undoable;
|
|
# they go at purge. This is the single most important line here.
|
|
results['docker'] = DockerService.compose_down(
|
|
app.root_path, volumes=False, remove_orphans=True)
|
|
except Exception as exc: # noqa: BLE001
|
|
results['docker'] = {'error': str(exc)}
|
|
|
|
try:
|
|
NginxService.disable_site(app.name)
|
|
NginxService.delete_site(app.name)
|
|
results['nginx'] = {'success': True}
|
|
except Exception as exc: # noqa: BLE001
|
|
results['nginx'] = {'error': str(exc)}
|
|
|
|
try:
|
|
results['cron'] = {'suspended': CronService.suspend_for_application(app.id)}
|
|
except Exception as exc: # noqa: BLE001
|
|
results['cron'] = {'error': str(exc)}
|
|
|
|
app.soft_delete(user_id)
|
|
app.status = 'stopped'
|
|
db.session.commit()
|
|
from app.services.previews.lifecycle import cleanup_application
|
|
results['previews'] = cleanup_application(app.id)
|
|
return results
|