Files
ServerKit/backend/app/models/application_manifest.py
Juan DenisandClaude Fable 5 f34969eaac fix(models): close all 26 dangling-on-delete FKs + orphan sweep migration
Every NOT NULL FK from the KNOWN_DANGLING_ON_DELETE audit now declares
its parent-side relationship, so the delete-cascade policy door covers
it: AI conversations/pending actions, API keys, dashboards, event
subscriptions, login links, sent invitations, resource grants (users);
env vars + history, previews + settings, WAF policy (applications);
surveys, doctor results, proxy stack, tunnels (servers); manifests
(projects); DDNS hosts (zones); exposed services (tunnels); drills
(backup policies); rollouts (agent versions); site plugin installs
(WordPress sites); queue messages (groups). The ratchet set is now
EMPTY and stays that way.

One new deliberate exception: Workspace.projects — projects carry
environments and app groupings, so delete_workspace now refuses (409)
while any remain instead of silently destroying them (previously it
crashed with IntegrityError).

Migration 095 sweeps rows already orphaned on existing installs by
pre-fix hard-deletes — including an application's leftover env-var
secrets, which a reused SQLite row id could have attached to a future
app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:25:29 -04:00

81 lines
3.2 KiB
Python

"""Persisted declarative manifest for a project.
One row per project (the manifest maps to a Project). Import stores the raw
text + normalized JSON + hash + source so nothing detected is discarded and
later pushes can re-read it. History lives in the existing DeploymentSnapshot
mechanism, not a second table.
"""
import json
from datetime import datetime
from app import db
from app.models.mixins import TimestampMixin
from app.models.json_column_mixin import JsonColumnMixin
# status values
STATUS_PENDING = 'pending' # a changed manifest awaiting an explicit apply
STATUS_APPLIED = 'applied' # live state matches the declared spec
STATUS_DRIFTED = 'drifted' # live state diverged from the manifest
STATUS_ERROR = 'error' # last apply / parse failed
class ApplicationManifest(TimestampMixin, JsonColumnMixin, db.Model):
__tablename__ = 'application_manifests'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
# Parent-side backref so the delete-cascade policy covers the NOT NULL FK.
project = db.relationship('Project',
backref=db.backref('manifests', lazy='dynamic'))
raw_text = db.Column(db.Text, nullable=True) # the manifest file as committed
normalized_json = db.Column(db.Text, nullable=True) # ManifestSpecService.normalize() output
manifest_hash = db.Column(db.String(64), nullable=True, index=True)
# provenance
source_repo = db.Column(db.String(500), nullable=True)
source_ref = db.Column(db.String(200), nullable=True)
source_commit = db.Column(db.String(64), nullable=True)
source_path = db.Column(db.String(255), nullable=True, default='serverkit.yaml')
status = db.Column(db.String(20), nullable=False, default=STATUS_PENDING, index=True)
last_error = db.Column(db.Text, nullable=True)
applied_at = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.UniqueConstraint('project_id', name='uq_application_manifest_project'),
)
def get_normalized(self):
return self._json_read('normalized_json', None)
def set_normalized(self, value):
self.normalized_json = json.dumps(value) if value is not None else None
def to_dict(self, include_raw=False):
data = {
'id': self.id,
'project_id': self.project_id,
'manifest_hash': self.manifest_hash,
'status': self.status,
'last_error': self.last_error,
'source': {
'repo': self.source_repo,
'ref': self.source_ref,
'commit': self.source_commit,
'path': self.source_path,
},
'normalized': self.get_normalized(),
'applied_at': self.applied_at.isoformat() if self.applied_at else None,
'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_raw:
data['raw_text'] = self.raw_text
return data
def __repr__(self):
return f'<ApplicationManifest project={self.project_id} status={self.status}>'