mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 05:24:49 +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>
68 lines
2.5 KiB
Python
68 lines
2.5 KiB
Python
from datetime import datetime
|
|
from app import db
|
|
import json
|
|
|
|
|
|
class Project(db.Model):
|
|
"""A Project groups applications under a Workspace.
|
|
|
|
Hierarchy: Workspace -> Project -> Environment -> Applications.
|
|
|
|
OPT-IN and backward compatible: applications carry a nullable project_id, so
|
|
existing apps remain "unassigned" until a user organizes them into projects.
|
|
"""
|
|
__tablename__ = 'projects'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=False, index=True)
|
|
name = db.Column(db.String(128), nullable=False)
|
|
slug = db.Column(db.String(128), nullable=False)
|
|
description = db.Column(db.Text, nullable=True)
|
|
metadata_json = db.Column(db.Text, nullable=True)
|
|
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
# Environments belonging to this project. Deleting a project cascades to its
|
|
# environments (apps are detached/blocked separately by the service layer).
|
|
environments = db.relationship(
|
|
'Environment',
|
|
backref='project',
|
|
lazy='dynamic',
|
|
cascade='all, delete-orphan',
|
|
order_by='Environment.order',
|
|
)
|
|
|
|
__table_args__ = (
|
|
db.UniqueConstraint('workspace_id', 'slug', name='uq_project_workspace_slug'),
|
|
)
|
|
|
|
@property
|
|
def metadata_(self):
|
|
return json.loads(self.metadata_json) if self.metadata_json else {}
|
|
|
|
@metadata_.setter
|
|
def metadata_(self, v):
|
|
self.metadata_json = json.dumps(v) if v else None
|
|
|
|
def to_dict(self, include_counts=False):
|
|
result = {
|
|
'id': self.id,
|
|
'workspace_id': self.workspace_id,
|
|
'name': self.name,
|
|
'slug': self.slug,
|
|
'description': self.description,
|
|
'metadata': self.metadata_,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
}
|
|
if include_counts:
|
|
from app.models.application import Application
|
|
result['environment_count'] = self.environments.count()
|
|
# Live apps only — a project claiming 5 while showing 3 is just wrong.
|
|
result['app_count'] = Application.query_active().filter_by(project_id=self.id).count()
|
|
return result
|
|
|
|
def __repr__(self):
|
|
return f'<Project {self.name}>'
|