mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-08 21:14:47 +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>
325 lines
17 KiB
Python
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
|