mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 16:47:01 +09:00
An audit found 102 FK columns with no index — every join, per-parent listing, and the delete cascades from _delete_cascade_policy scanned whole tables on them (deployments.app_id, audit_logs.user_id, dns_records.zone_id, webhook_logs.webhook_id, ...). All FK columns now declare index=True; migration 096 creates the same indexes on existing installs, deriving the list from the live schema at upgrade time (so it is idempotent and cannot drift from what an install actually has). Ratchet: test_every_fk_column_is_indexed pins the rule with no allowlist — a new FK column is born indexed or the build fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
17 KiB
Python
323 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.json_column_mixin import JsonColumnMixin
|
|
from app.models.mixins import SoftDeleteMixin, TimestampMixin
|
|
from app.utils.ingress import (
|
|
default_ingress_plane as _default_ingress_plane,
|
|
proxy_eligible as _proxy_eligible,
|
|
)
|
|
|
|
|
|
class Application(JsonColumnMixin, TimestampMixin, 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, index=True)
|
|
shared_config = db.Column(db.Text, nullable=True) # JSON string for shared resources
|
|
|
|
# Metadata
|
|
last_deployed_at = db.Column(db.DateTime, nullable=True)
|
|
|
|
# Foreign keys
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
|
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.
|
|
"""
|
|
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': self._json_read('buildpack_plan', None),
|
|
'buildpack_overrides': self._json_read('buildpack_overrides', 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': self._json_read('shared_config', 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
|