Files
ServerKit/backend/app/models/managed_database.py
T
Juan DenisandClaude Opus 4.8 58e80b2e18 feat(databases): track managed databases as first-class resources
Persist the databases ServerKit provisions (and let you adopt existing ones) so
backups, connection strings, and the UI have durable state -- beside, not
replacing, the live-introspection explorer. Not a DBaaS (no pooling/replicas).

- ManagedDatabase model (engine/name/host/port/origin/owner; admin secret
  Fernet-encrypted, unique per engine+host+name) + migration 044.
- managed_database_service: record_provisioned (additive on the create routes),
  adopt (idempotent), build_connection_uri (real URI, masked by default),
  sync_state (drift detection), delete (optional drop), protect.
- Backup FK fix (the headline): a managed-DB BackupPolicy uses
  target_id = managed_databases.id plus a `managed` marker, and _resolve_target
  resolves the descriptor (incl. decrypted secret) from the row. Legacy
  JSON-descriptor policies keep resolving unchanged (backward compatible).
- API under /api/v1/databases/managed (list/create/get+sync/delete?drop=/adopt/
  connection-uri/protect); the connection-uri reveal is admin-only and audited.
- Frontend: a "Managed" panel on the Database Explorer (connection-string
  reveal/copy, one-click Protect, Untrack/Drop).
- Tests: provisioning persists a row, adopt idempotent, connection URI per
  engine + masking, sync_state drift, protect resolves via the real FK, legacy
  policy still resolves, API masking/admin-gating/audited reveal.

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

80 lines
3.6 KiB
Python

from datetime import datetime
from app import db
# Engines whose lifecycle ServerKit actually drives (Mongo stays read-first).
VALID_DB_ENGINES = ('mysql', 'postgresql', 'mongodb')
class ManagedDatabase(db.Model):
"""A database ServerKit tracks as a first-class resource.
Adds durable state (backups, connection strings, ownership) **beside** the
live introspection in ``database_service`` — it does NOT replace that
discovery/browse surface. ``origin`` records whether ServerKit provisioned it
(``provisioned``) or adopted a live-discovered one (``adopted``).
The admin secret is Fernet-encrypted at rest (same helper as
``SourceConnection``); it is masked in API responses and revealed only through
an explicit, audited action.
"""
__tablename__ = 'managed_databases'
# Default listening ports per engine, used when a row doesn't pin one.
DEFAULT_PORTS = {'mysql': 3306, 'postgresql': 5432, 'mongodb': 27017}
# URI scheme per engine for build_connection_uri.
URI_SCHEMES = {'mysql': 'mysql', 'postgresql': 'postgresql', 'mongodb': 'mongodb'}
id = db.Column(db.Integer, primary_key=True)
engine = db.Column(db.String(20), nullable=False, index=True) # mysql|postgresql|mongodb
name = db.Column(db.String(200), nullable=False) # database name on the server
host_kind = db.Column(db.String(20), nullable=False, default='host') # host|docker
container_ref = db.Column(db.String(200), nullable=True) # container name/id when docker
host = db.Column(db.String(255), nullable=False, default='localhost')
port = db.Column(db.Integer, nullable=True)
# The app this DB backs, if any. SET NULL on app delete so the tracked DB
# survives its app being removed.
owner_application_id = db.Column(
db.Integer, db.ForeignKey('applications.id', ondelete='SET NULL'),
nullable=True, index=True,
)
origin = db.Column(db.String(20), nullable=False, default='provisioned') # provisioned|adopted
admin_username = db.Column(db.String(180), nullable=True)
admin_secret_encrypted = db.Column(db.Text, nullable=True)
workspace_id = db.Column(db.Integer, db.ForeignKey('workspaces.id'), nullable=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('engine', 'host', 'name', name='uq_managed_database'),
)
def effective_port(self):
return self.port or self.DEFAULT_PORTS.get(self.engine)
def to_dict(self, connection_uri=None):
data = {
'id': self.id,
'engine': self.engine,
'name': self.name,
'host_kind': self.host_kind,
'container_ref': self.container_ref,
'host': self.host,
'port': self.effective_port(),
'owner_application_id': self.owner_application_id,
'origin': self.origin,
'admin_username': self.admin_username,
# Never serialize the secret — expose only whether one is stored.
'has_secret': bool(self.admin_secret_encrypted),
'workspace_id': self.workspace_id,
'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 connection_uri is not None:
data['connection_uri'] = connection_uri
return data
def __repr__(self):
return f'<ManagedDatabase {self.engine}:{self.name}@{self.host}>'