Files
ServerKit/backend/app/models/app_volume.py
T
Juan DenisandClaude Opus 4.8 cbbd410bc8 feat(services): first-class managed volumes for apps
Give a service tracked, named persistent storage that survives redeploys,
replacing fragile relative bind mounts (./mysql-data:/var/lib/mysql).

- AppVolume model (namespaced serverkit-app-{id}-{slug}, unique per mount path)
  + migration 043.
- volume_service: create (docker volume + row, dup-path refusal), list_for_app
  (rows + live inspect), delete (row-only by default; wipe only when the app is
  stopped and no running container uses it), measure, convert_bind_mount,
  run_args, compose_fragment, host_paths_for_app.
- Wired into the single-container deploy (_deploy_docker passes -v specs to
  docker run) and new compose stacks (create_docker_app gains named_volumes ->
  top-level volumes: block).
- API under /api/v1/apps/<id>/volumes (list/attach/detach?wipe=/convert), ACL
  via ResourceGrantService.
- Frontend: Storage panel in Service detail Settings (attach/detach with an
  off-by-default "also delete data" checkbox, disabled while running).
- Tests: create + namespaced volume, dup-path refusal, wipe safety, single-
  container -v proving test, compose top-level named-volume generation, convert,
  API CRUD.

Scoping: volumes are backup-addressable (mountpoints exposed; protect via the
files target) rather than force-folded into the app's single-parent incremental
tar chain, which would break restore. Live compose files (esp. the WP multidev
stack) are not rewritten -- convert_bind_mount is the opt-in migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:57:07 -04:00

77 lines
3.2 KiB
Python

from datetime import datetime
from app import db
class AppVolume(db.Model):
"""A first-class, tracked persistent volume attached to an application.
Replaces ad-hoc **relative bind mounts** (``./mysql-data:/var/lib/mysql``)
with a **named Docker volume** that survives redeploys, is visible in the UI,
and is backup-addressable. v1 is the ``local`` driver only; ``driver`` is
modeled so networked drivers (NFS/CSI/cloud block) can grow later.
The real Docker volume is namespaced ``serverkit-app-{app_id}-{slug}`` so it
is unambiguously ServerKit-owned and never collides with a host admin's
volumes.
"""
__tablename__ = 'app_volumes'
id = db.Column(db.Integer, primary_key=True)
application_id = db.Column(
db.Integer, db.ForeignKey('applications.id', ondelete='CASCADE'),
nullable=False, index=True,
)
name = db.Column(db.String(120), nullable=False) # operator label, e.g. "uploads"
docker_volume_name = db.Column(db.String(200), nullable=False, unique=True)
mount_path = db.Column(db.String(500), nullable=False) # absolute path inside the container
driver = db.Column(db.String(40), nullable=False, default='local')
read_only = db.Column(db.Boolean, nullable=False, default=False)
size_bytes = db.Column(db.BigInteger, nullable=True) # denormalized last-measured size
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Deleting the app removes its volume rows (ORM cascade); the underlying
# Docker volumes are intentionally left on disk so an app delete never nukes
# data silently — prune them explicitly with a wipe.
application = db.relationship(
'Application',
backref=db.backref('volumes', cascade='all, delete-orphan'),
)
__table_args__ = (
db.UniqueConstraint('application_id', 'mount_path', name='uq_app_volume_mount'),
)
def mount_spec(self):
"""The ``docker run -v`` / compose short-syntax spec for this volume,
e.g. ``serverkit-app-5-uploads:/var/www/html/wp-content/uploads:ro``."""
spec = f'{self.docker_volume_name}:{self.mount_path}'
if self.read_only:
spec += ':ro'
return spec
def to_dict(self, live=None):
data = {
'id': self.id,
'application_id': self.application_id,
'name': self.name,
'docker_volume_name': self.docker_volume_name,
'mount_path': self.mount_path,
'driver': self.driver,
'read_only': self.read_only,
'size_bytes': self.size_bytes,
'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 live is not None:
data['present'] = live.get('present')
data['mountpoint'] = live.get('mountpoint')
if live.get('size_bytes') is not None:
data['size_bytes'] = live.get('size_bytes')
return data
def __repr__(self):
return f'<AppVolume {self.docker_volume_name} -> {self.mount_path}>'