Files
ServerKit/backend/app/models/application.py
T
Juan DenisandClaude Opus 5 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
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>
2026-08-12 20:26:04 -04:00

325 lines
17 KiB
Python

from datetime import datetime
from app import db
# Application carries FKs to projects.id / environments.id (opt-in Project /
# Environment hierarchy). Import those modules here so their tables are always
# registered on db.Model's metadata whenever Application is loaded, keeping the
# FK targets resolvable regardless of import order.
from app.models import project as _project # noqa: F401
from app.models import environment as _environment # noqa: F401
from app.models.mixins import SoftDeleteMixin
from app.utils.ingress import (
default_ingress_plane as _default_ingress_plane,
proxy_eligible as _proxy_eligible,
)
class Application(SoftDeleteMixin, db.Model):
"""A managed app.
SOFT DELETED (plan 70). `Application.query` therefore returns TOMBSTONES —
read `query_active()` anywhere you mean "an app that still exists". That
distinction is not cosmetic here: this model drives nginx vhosts, container
lifecycles, DNS, backups and outbound registry calls, and the equivalent
oversight on `Domain` produced a WordPress search-replace to a dead host and
real ACME orders.
Two rules specific to this model:
* **Sibling tables are the sharp edge.** `ContainerSleepPolicy`,
`BackupPolicy`, `DeploymentJob` and `WafPolicy` are enumerated on a
SCHEDULE and resolve the app afterwards, so they never appear in a search
for `Application.query`. Each of those sweeps joins on liveness; see
`deleted_app_ids()`.
* **A soft delete keeps the files.** Stopping containers and unpublishing
the vhost is reversible, so it happens on delete; removing data volumes
and the uploaded source is not, so it waits for purge. See
`application_restore.on_purge_application`.
"""
__tablename__ = 'applications'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
app_type = db.Column(db.String(50), nullable=False) # 'php', 'wordpress', 'flask', 'django', 'docker', 'static'
status = db.Column(db.String(20), default='stopped') # 'running', 'stopped', 'error', 'deploying'
# Configuration
php_version = db.Column(db.String(10), nullable=True) # '8.0', '8.1', '8.2', '8.3'
python_version = db.Column(db.String(10), nullable=True) # '3.9', '3.10', '3.11', '3.12'
port = db.Column(db.Integer, nullable=True)
root_path = db.Column(db.String(500), nullable=True)
# HTTP path used for health checks and the zero-downtime restart gate.
# Populated from a manifest at import, editable in Settings. NULL => no gate
# (the restart falls back to a fixed wait).
healthcheck_path = db.Column(db.String(255), nullable=True)
# Docker specific
docker_image = db.Column(db.String(200), nullable=True)
container_id = db.Column(db.String(100), nullable=True)
# Optional private-registry binding. Set => authenticate (docker login) with
# the stored credentials before pulling docker_image. NULL => anonymous pull
# (today's behavior). See app/services/container_registry_service.py.
registry_id = db.Column(db.Integer, db.ForeignKey('container_registries.id'), nullable=True, index=True)
# Build packs (zero-Dockerfile deploys). When the build method routes through
# the build-pack layer, the detected plan and any user overrides are persisted
# here so the generated Dockerfile is reproducible and the UI can show it.
# Per-app resource limits (task #23). Docker-enforced caps emitted into the
# generated compose service block (`cpus` / `mem_limit`). NULL = unlimited.
cpu_limit = db.Column(db.String(16), nullable=True) # CPU cores, e.g. '1.5'
memory_limit = db.Column(db.String(16), nullable=True) # e.g. '512m', '2g'
buildpack_type = db.Column(db.String(20), nullable=True) # 'nixpacks' | 'static' | 'dockerfile-present' | 'unknown'
buildpack_plan = db.Column(db.Text, nullable=True) # JSON: the detected build plan
buildpack_overrides = db.Column(db.Text, nullable=True) # JSON: user overrides applied to the plan
# Source / lifecycle: github (repo clone), template (built-in template),
# manual (local path already on server), upload (zip upload managed by ServerKit)
source = db.Column(db.String(20), default='github', nullable=False)
# Manual / local service configuration
compose_file = db.Column(db.String(200), nullable=True)
systemd_unit = db.Column(db.String(100), nullable=True)
managed_by = db.Column(db.String(20), nullable=True) # 'docker_compose', 'systemd'
# Ingress plane: which reverse proxy is expected to serve this app —
# 'nginx' (host Nginx, the default) or 'proxy_stack' (Dockerized
# Traefik/Caddy). NULL is treated as the default. See app/utils/ingress.py.
ingress_plane = db.Column(db.String(20), nullable=True)
# Appliance tier (plan 35): typed port declarations (JSON list of
# {container, host, protocol, ...}) and a one-shot bootstrap-completed flag.
ports = db.Column(db.Text, nullable=True)
bootstrap_done = db.Column(db.Boolean, nullable=False, server_default='0', default=False)
# Upload versioning
version = db.Column(db.Integer, default=0, nullable=False)
upload_path = db.Column(db.String(500), nullable=True)
# Private URL feature
# NOT `unique=True`: the uniqueness is a PARTIAL index predicated on
# `deleted_at IS NULL` (migration 084), so a tombstone releases its slug and
# deleting an app does not burn `/p/<slug>` forever. Same shape as
# Domain.name. A column-level unique here would re-impose the burn on any
# database built from the models.
private_slug = db.Column(db.String(50), nullable=True, index=True)
private_url_enabled = db.Column(db.Boolean, default=False)
# Opt-in nginx micro-cache (task #21): short-TTL page cache emitted into
# the site's vhost, with bypasses for auth/admin/cart traffic. NULL/False
# = off (today's behavior).
micro_cache_enabled = db.Column(db.Boolean, default=False, nullable=True)
# Environment linking
environment_type = db.Column(db.String(20), default='standalone') # 'production', 'development', 'staging', 'standalone'
linked_app_id = db.Column(db.Integer, db.ForeignKey('applications.id'), nullable=True)
shared_config = db.Column(db.Text, nullable=True) # JSON string for shared resources
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_deployed_at = db.Column(db.DateTime, nullable=True)
# Foreign keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
server_id = db.Column(db.String(36), db.ForeignKey('servers.id'), nullable=True, index=True)
# Workspace scoping (#33). Nullable: existing rows are backfilled to a default
# workspace by migration 015; new rows are stamped on create.
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=True, index=True)
# Project / Environment hierarchy (opt-in). Nullable: existing apps stay
# "unassigned" and keep working; stamped on create when provided.
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
environment_id = db.Column(db.Integer, db.ForeignKey('environments.id'), nullable=True, index=True)
# Relationships
# Use 'subquery' to eagerly load domains in a single query, avoiding N+1
# NOTE: this collection includes SOFT-DELETED domains. It is deliberately
# NOT filtered with a primaryjoin: combined with delete-orphan, a tombstoned
# child would fall out of the collection and SQLAlchemy would HARD-delete it
# on the next flush — soft delete would destroy the very row the recycle bin
# exists to keep. Read `live_domains` anywhere you mean "domains this app
# actually serves".
domains = db.relationship('Domain', backref='application', lazy='subquery', cascade='all, delete-orphan')
linked_app = db.relationship('Application', remote_side=[id], backref='linked_from', foreign_keys=[linked_app_id])
server = db.relationship('Server', backref=db.backref('applications', lazy='dynamic'))
# Lightweight, read-only relationships to resolve the project/environment
# names in to_dict() (the FK columns above are the source of truth). No
# backref/cascade — these only exist so an app row can show where it lives.
project = db.relationship('Project', foreign_keys=[project_id], viewonly=True)
environment = db.relationship('Environment', foreign_keys=[environment_id], viewonly=True)
@classmethod
def deleted_ids(cls):
"""Ids of every tombstoned app, as a set.
For the sweeps that DO NOT start from Application — `ContainerSleepPolicy`,
`BackupPolicy`, `DeploymentJob`, `WafPolicy` — which enumerate their own
table on a schedule and resolve `application_id` afterwards. Those never
appear in a search for `Application.query`, which is exactly how they
would keep acting on a deleted app: waking its containers, running its
backups, executing its queued deploys.
A set, not a join: each of those sweeps is already written as "select my
rows, then loop", and a set membership test drops into that loop without
restructuring the query. The table is small enough that the cost is
nothing next to the docker/restic work the loop is about to do.
"""
return {row.id for row in cls.query_deleted().with_entities(cls.id).all()}
@property
def live_domains(self):
"""The domains this app actually serves — tombstones excluded.
`self.domains` still holds soft-deleted rows (see the relationship note
above), so anything that answers "what is published / what should nginx
or DNS or a manifest see" must read this instead.
"""
return [d for d in self.domains if getattr(d, 'deleted_at', None) is None]
# Fields to_dict() can emit that are NOT mapped columns. Each one costs a
# query (or a collection walk) to produce, so `$select` has to know they
# exist in order to let a caller decline them. Kept next to to_dict so the
# two cannot drift.
DERIVED_FIELDS = (
'project_name', 'environment_name', 'server_name', 'domains',
'image_scan', 'image_update', 'sleep', 'linked_app', 'has_linked_app',
'ingress_plane', 'ingress_proxy_eligible',
)
def to_dict(self, include_linked=False, fields=None):
"""Serialize the app.
`fields` (a set, normally from ``$select``) is not only a payload
narrowing — it decides which DERIVED fields get computed, and those are
where the cost is. ``image_scan`` and ``image_update`` read
``lazy='dynamic'`` relationships, so they issue a query EACH, per row,
and no amount of eager loading can hoist them; ``sleep``, ``server``,
``project`` and ``environment`` are lazy scalars. Unnarrowed, drawing a
100-app list costs roughly 300 extra queries. A caller that asks for
``$select=id,name`` now costs none of them.
`fields=None` keeps the full legacy payload, so every existing caller
is unaffected.
"""
import json
want = fields.__contains__ if fields is not None else (lambda _key: True)
result = {
'id': self.id,
'name': self.name,
'app_type': self.app_type,
'status': self.status,
'php_version': self.php_version,
'python_version': self.python_version,
'port': self.port,
'healthcheck_path': self.healthcheck_path,
'root_path': self.root_path,
'docker_image': self.docker_image,
'container_id': self.container_id,
'registry_id': self.registry_id,
'cpu_limit': self.cpu_limit,
'memory_limit': self.memory_limit,
'buildpack_type': self.buildpack_type,
'buildpack_plan': json.loads(self.buildpack_plan) if self.buildpack_plan else None,
'buildpack_overrides': json.loads(self.buildpack_overrides) if self.buildpack_overrides else None,
'source': self.source,
'compose_file': self.compose_file,
'systemd_unit': self.systemd_unit,
'managed_by': self.managed_by,
'version': self.version,
'upload_path': self.upload_path,
'private_slug': self.private_slug,
'private_url_enabled': self.private_url_enabled,
'micro_cache_enabled': bool(self.micro_cache_enabled),
'environment_type': self.environment_type,
'linked_app_id': self.linked_app_id,
'shared_config': json.loads(self.shared_config) if self.shared_config else None,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat(),
'last_deployed_at': self.last_deployed_at.isoformat() if self.last_deployed_at else None,
'user_id': self.user_id,
'server_id': self.server_id,
'workspace_id': self.workspace_id,
'project_id': self.project_id,
'environment_id': self.environment_id,
}
# Narrow the plain columns. These are already loaded on the instance, so
# dropping them saves payload, not queries — the queries are below.
if fields is not None:
result = {key: value for key, value in result.items() if key in fields}
# Pure derivations off already-loaded columns — cheap, but still elided
# when unasked so `$select` means one thing everywhere.
if want('ingress_plane'):
result['ingress_plane'] = self.ingress_plane or _default_ingress_plane(self.app_type, self.managed_by)
if want('ingress_proxy_eligible'):
result['ingress_proxy_eligible'] = _proxy_eligible(self.app_type, self.managed_by)
if want('has_linked_app'):
result['has_linked_app'] = self.linked_app_id is not None
# Derived display names for the project/environment/server this app
# lives in (null when unassigned). Each is a lazy relationship load.
if want('project_name'):
result['project_name'] = self.project.name if self.project else None
if want('environment_name'):
result['environment_name'] = self.environment.name if self.environment else None
if want('server_name'):
result['server_name'] = self.server.name if self.server else 'Local server'
if want('domains'):
result['domains'] = [d.to_dict() for d in self.live_domains]
# Lightweight image-scan badge (latest scan only).
if want('image_scan'):
latest_scan = self.image_scans.first()
result['image_scan'] = {
'status': latest_scan.status,
'highest_severity': latest_scan.highest_severity,
'severity_counts': latest_scan.get_counts(),
'scanned_at': latest_scan.completed_at.isoformat() if latest_scan.completed_at else None,
} if latest_scan else None
# Lightweight image-update badge (latest digest check only).
if want('image_update'):
latest_update = self.image_update_checks.first()
result['image_update'] = {
'status': latest_update.status,
'update_available': latest_update.update_available,
'checked_at': latest_update.checked_at.isoformat() if latest_update.checked_at else None,
} if latest_update else None
# Lightweight auto-sleep badge.
if want('sleep'):
sleep_policy = self.sleep_policy
result['sleep'] = {
'enabled': sleep_policy.enabled,
'asleep': sleep_policy.asleep,
'idle_timeout_minutes': sleep_policy.idle_timeout_minutes,
} if sleep_policy else None
if include_linked and want('linked_app') and self.linked_app:
result['linked_app'] = {
'id': self.linked_app.id,
'name': self.linked_app.name,
'environment_type': self.linked_app.environment_type,
'status': self.linked_app.status
}
return result
def __repr__(self):
return f'<Application {self.name}>'
@db.event.listens_for(Application, 'before_delete')
def _clear_cron_association(mapper, connection, target):
"""Cron jobs are never silently deleted with an app — they fall back to the
System bucket. Clearing the association here (one place) covers every delete
path (apps/docker/python/git/workflow), since cron metadata lives in a JSON
store outside the DB and can't ride a FK cascade. Best-effort: a failure here
must never block deleting the app."""
try:
from app.services.cron_service import CronService
CronService.clear_application(target.id)
except Exception: # noqa: BLE001 - cleanup must not block app deletion
pass