mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-03 02:26:43 +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>
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""Business logic for the Project / Environment hierarchy.
|
|
|
|
Hierarchy: Workspace -> Project -> Environment -> Applications.
|
|
|
|
The model is OPT-IN: applications carry nullable project_id/environment_id, so
|
|
existing apps remain unassigned and keep working. ProjectService centralizes the
|
|
CRUD plus the invariant that every project has at least one default environment.
|
|
"""
|
|
from datetime import datetime
|
|
|
|
from app import db
|
|
from app.models.project import Project
|
|
from app.models.environment import Environment
|
|
from app.models.application import Application
|
|
from app.utils.slug import unique_slug, slugify
|
|
|
|
|
|
# Canonical environment names a project starts with / can add. Free-form names
|
|
# are allowed too; these are just the well-known ones the UI nudges toward.
|
|
KNOWN_ENVIRONMENTS = ('production', 'staging', 'development')
|
|
DEFAULT_PROJECT_NAME = 'Default'
|
|
DEFAULT_ENVIRONMENT_NAME = 'production'
|
|
|
|
|
|
class ProjectService:
|
|
"""Static-method service for projects and their environments."""
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Projects
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@staticmethod
|
|
def list_projects(workspace_id):
|
|
"""All projects in a workspace, newest first."""
|
|
return (Project.query
|
|
.filter_by(workspace_id=workspace_id)
|
|
.order_by(Project.created_at.desc())
|
|
.all())
|
|
|
|
@staticmethod
|
|
def get_project(project_id):
|
|
return Project.query.get(project_id)
|
|
|
|
@staticmethod
|
|
def _unique_project_slug(workspace_id, name):
|
|
return unique_slug(
|
|
name,
|
|
lambda s: Project.query.filter_by(workspace_id=workspace_id, slug=s).first() is not None,
|
|
default='project',
|
|
)
|
|
|
|
@staticmethod
|
|
def create_project(workspace_id, name, description=None, metadata=None,
|
|
default_environment=DEFAULT_ENVIRONMENT_NAME):
|
|
"""Create a project and auto-create its default environment.
|
|
|
|
Returns the new Project. Raises ValueError on a blank name.
|
|
"""
|
|
name = (name or '').strip()
|
|
if not name:
|
|
raise ValueError('Project name is required')
|
|
|
|
slug = ProjectService._unique_project_slug(workspace_id, name)
|
|
project = Project(
|
|
workspace_id=workspace_id,
|
|
name=name,
|
|
slug=slug,
|
|
description=(description or None),
|
|
)
|
|
if metadata:
|
|
project.metadata_ = metadata
|
|
db.session.add(project)
|
|
db.session.flush() # assign project.id before creating the environment
|
|
|
|
ProjectService.create_environment(
|
|
project.id,
|
|
name=default_environment or DEFAULT_ENVIRONMENT_NAME,
|
|
is_default=True,
|
|
)
|
|
db.session.commit()
|
|
return project
|
|
|
|
@staticmethod
|
|
def update_project(project_id, name=None, description=None, metadata=None):
|
|
project = Project.query.get(project_id)
|
|
if not project:
|
|
return None
|
|
if name is not None and name.strip():
|
|
project.name = name.strip()
|
|
if description is not None:
|
|
project.description = description or None
|
|
if metadata is not None:
|
|
project.metadata_ = metadata
|
|
project.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
return project
|
|
|
|
@staticmethod
|
|
def delete_project(project_id):
|
|
"""Delete a project. Returns True on success, or the string 'has_apps' if
|
|
the project still has applications assigned (caller should 409)."""
|
|
project = Project.query.get(project_id)
|
|
if not project:
|
|
return None
|
|
# Deliberately counts tombstones too: a soft-deleted app still points at
|
|
# this project, and hard-deleting the project would leave it unrestorable.
|
|
if Application.query.filter_by(project_id=project_id).count() > 0:
|
|
return 'has_apps'
|
|
db.session.delete(project) # cascades to environments
|
|
db.session.commit()
|
|
return True
|
|
|
|
@staticmethod
|
|
def ensure_default(workspace_id):
|
|
"""Find-or-create the workspace's Default project (with a default
|
|
environment). Idempotent — safe to call repeatedly."""
|
|
existing = Project.query.filter_by(
|
|
workspace_id=workspace_id, slug=slugify(DEFAULT_PROJECT_NAME)
|
|
).first()
|
|
if existing:
|
|
# Guarantee it still has at least one environment (self-heal).
|
|
if existing.environments.count() == 0:
|
|
ProjectService.create_environment(
|
|
existing.id, name=DEFAULT_ENVIRONMENT_NAME, is_default=True)
|
|
db.session.commit()
|
|
return existing
|
|
return ProjectService.create_project(
|
|
workspace_id,
|
|
name=DEFAULT_PROJECT_NAME,
|
|
description='Default project (auto-created).',
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Environments
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@staticmethod
|
|
def list_environments(project_id):
|
|
return (Environment.query
|
|
.filter_by(project_id=project_id)
|
|
.order_by(Environment.order, Environment.id)
|
|
.all())
|
|
|
|
@staticmethod
|
|
def get_environment(environment_id):
|
|
return Environment.query.get(environment_id)
|
|
|
|
@staticmethod
|
|
def _unique_environment_slug(project_id, name):
|
|
return unique_slug(
|
|
name,
|
|
lambda s: Environment.query.filter_by(project_id=project_id, slug=s).first() is not None,
|
|
default='environment',
|
|
)
|
|
|
|
@staticmethod
|
|
def create_environment(project_id, name, is_default=False, order=None):
|
|
"""Create an environment under a project. Does NOT commit unless the
|
|
project was just created via create_project; callers that need a commit
|
|
(the API) should commit themselves — but create_environment flushes so the
|
|
id is available immediately."""
|
|
name = (name or '').strip()
|
|
if not name:
|
|
raise ValueError('Environment name is required')
|
|
|
|
slug = ProjectService._unique_environment_slug(project_id, name)
|
|
if order is None:
|
|
current_max = (db.session.query(db.func.max(Environment.order))
|
|
.filter_by(project_id=project_id).scalar())
|
|
order = (current_max + 1) if current_max is not None else 0
|
|
|
|
env = Environment(
|
|
project_id=project_id,
|
|
name=name,
|
|
slug=slug,
|
|
is_default=bool(is_default),
|
|
order=order,
|
|
)
|
|
db.session.add(env)
|
|
db.session.flush()
|
|
return env
|
|
|
|
@staticmethod
|
|
def update_environment(environment_id, name=None, is_default=None):
|
|
env = Environment.query.get(environment_id)
|
|
if not env:
|
|
return None
|
|
if name is not None and name.strip():
|
|
env.name = name.strip()
|
|
if is_default is not None:
|
|
env.is_default = bool(is_default)
|
|
db.session.commit()
|
|
return env
|
|
|
|
@staticmethod
|
|
def delete_environment(environment_id):
|
|
"""Delete an environment. Refuses to delete the last/only environment of a
|
|
project (returns 'last'), and detaches any apps assigned to it (sets their
|
|
environment_id to NULL) so apps are never orphaned. Returns True on
|
|
success, 'last' if it's the only one, None if not found."""
|
|
env = Environment.query.get(environment_id)
|
|
if not env:
|
|
return None
|
|
sibling_count = Environment.query.filter_by(project_id=env.project_id).count()
|
|
if sibling_count <= 1:
|
|
return 'last'
|
|
|
|
# Detach apps from the deleted environment (keep them in the project).
|
|
# Tombstones included on purpose — the Environment row is going away, so
|
|
# leaving a soft-deleted app pointing at it would dangle on restore.
|
|
Application.query.filter_by(environment_id=environment_id).update(
|
|
{'environment_id': None}, synchronize_session=False)
|
|
was_default = env.is_default
|
|
db.session.delete(env)
|
|
db.session.flush()
|
|
|
|
# If we removed the default env, promote the first remaining one.
|
|
if was_default:
|
|
replacement = (Environment.query
|
|
.filter_by(project_id=env.project_id)
|
|
.order_by(Environment.order, Environment.id)
|
|
.first())
|
|
if replacement:
|
|
replacement.is_default = True
|
|
db.session.commit()
|
|
return True
|
|
|
|
@staticmethod
|
|
def reorder_environments(project_id, ordered_ids):
|
|
"""Apply a new ordering to a project's environments. ordered_ids is the
|
|
list of environment ids in the desired order. Ids not belonging to the
|
|
project are ignored. Returns the reordered list."""
|
|
envs = {e.id: e for e in Environment.query.filter_by(project_id=project_id).all()}
|
|
position = 0
|
|
for eid in ordered_ids:
|
|
env = envs.get(eid)
|
|
if env is not None:
|
|
env.order = position
|
|
position += 1
|
|
# Any environments not named in ordered_ids keep a stable tail order.
|
|
for env in sorted(envs.values(), key=lambda e: (e.order, e.id)):
|
|
if env.id not in set(ordered_ids):
|
|
env.order = position
|
|
position += 1
|
|
db.session.commit()
|
|
return ProjectService.list_environments(project_id)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# App membership helpers
|
|
# ------------------------------------------------------------------ #
|
|
|
|
@staticmethod
|
|
def list_project_apps(project_id):
|
|
return Application.query_active().filter_by(project_id=project_id).all()
|
|
|
|
@staticmethod
|
|
def list_environment_apps(environment_id):
|
|
return Application.query_active().filter_by(environment_id=environment_id).all()
|
|
|
|
@staticmethod
|
|
def count_project_apps(project_id):
|
|
return Application.query_active().filter_by(project_id=project_id).count()
|