mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-27 07:14:08 +09:00
fix(models): cascade ALL not-null children on application purge
Field follow-up to c1d4c0af: the live box then crashed on
deployments.app_id — dynamic relationships get the same FK null-out
on parent delete as the one-to-ones. Cascade every one-to-many from
Application whose child FK is NOT NULL (deployments, config
snapshots, image scans, SBOMs, image update checks, git deployments),
and pin the invariant with a mapper-inspection sweep so a future
NOT NULL child cannot reintroduce the purge crash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1d4c0af12
commit
1704d60b05
6 files changed
+49
-6
No files matched your search
@@ -59,7 +59,10 @@ class Deployment(JsonColumnMixin, db.Model):
|
||||
extra_data = db.Column(db.Text, default='{}')
|
||||
|
||||
# Relationships
|
||||
app = db.relationship('Application', backref=db.backref('deployments', lazy='dynamic'))
|
||||
# cascade: app_id is NOT NULL, so a real delete of the app (Recycle Bin
|
||||
# purge) must take the history with it — the default null-out IntegrityErrors.
|
||||
app = db.relationship('Application', backref=db.backref(
|
||||
'deployments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
deployer = db.relationship('User', backref=db.backref('user_deployments', lazy='dynamic'))
|
||||
|
||||
def get_metadata(self):
|
||||
|
||||
@@ -58,8 +58,13 @@ class DeploymentSnapshot(JsonColumnMixin, db.Model):
|
||||
# deployment.py (another agent owns those). The Application gains a
|
||||
# ``config_snapshots`` collection; the Deployment gains ``config_snapshots``
|
||||
# too via a distinct backref.
|
||||
# cascade: application_id is NOT NULL, so a real delete of the app (Recycle
|
||||
# Bin purge) must take the snapshots too — the default null-out IntegrityErrors.
|
||||
# (deployment_id below is nullable, so the Deployment side needs no cascade.)
|
||||
application = db.relationship(
|
||||
'Application', backref=db.backref('config_snapshots', lazy='dynamic')
|
||||
'Application',
|
||||
backref=db.backref('config_snapshots', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
)
|
||||
deployment = db.relationship(
|
||||
'Deployment', backref=db.backref('config_snapshots', lazy='dynamic')
|
||||
|
||||
@@ -20,7 +20,10 @@ class ImageVulnerabilityScan(JsonColumnMixin, db.Model):
|
||||
started_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
application = db.relationship('Application', backref=db.backref('image_scans', lazy='dynamic', order_by='ImageVulnerabilityScan.started_at.desc()'))
|
||||
# cascade: application_id is NOT NULL — purge must delete, not null out.
|
||||
application = db.relationship('Application', backref=db.backref(
|
||||
'image_scans', lazy='dynamic', cascade='all, delete-orphan',
|
||||
order_by='ImageVulnerabilityScan.started_at.desc()'))
|
||||
|
||||
def set_counts(self, counts):
|
||||
self.severity_counts = json.dumps(counts)
|
||||
@@ -72,7 +75,10 @@ class SbomArtifact(JsonColumnMixin, db.Model):
|
||||
sbom_json = db.Column(db.Text) # SPDX 2.3 JSON
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
application = db.relationship('Application', backref=db.backref('sboms', lazy='dynamic', order_by='SbomArtifact.created_at.desc()'))
|
||||
# cascade: application_id is NOT NULL — purge must delete, not null out.
|
||||
application = db.relationship('Application', backref=db.backref(
|
||||
'sboms', lazy='dynamic', cascade='all, delete-orphan',
|
||||
order_by='SbomArtifact.created_at.desc()'))
|
||||
|
||||
def to_dict(self, include_sbom=False):
|
||||
return {
|
||||
|
||||
@@ -18,7 +18,8 @@ class ImageUpdateCheck(SerializableMixin, db.Model):
|
||||
error_message = db.Column(db.Text)
|
||||
checked_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# cascade: application_id is NOT NULL — purge must delete, not null out.
|
||||
application = db.relationship('Application', backref=db.backref(
|
||||
'image_update_checks', lazy='dynamic',
|
||||
'image_update_checks', lazy='dynamic', cascade='all, delete-orphan',
|
||||
order_by='ImageUpdateCheck.checked_at.desc()'))
|
||||
|
||||
@@ -172,7 +172,10 @@ class GitDeployment(db.Model):
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
app = db.relationship('Application', backref=db.backref('git_deployments', lazy='dynamic'))
|
||||
# cascade: app_id is NOT NULL — purge must delete, not null out. (GitWebhook
|
||||
# above keeps a plain backref: its app_id is nullable.)
|
||||
app = db.relationship('Application', backref=db.backref(
|
||||
'git_deployments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
webhook = db.relationship('GitWebhook', backref=db.backref('deployments', lazy='dynamic'))
|
||||
|
||||
def to_dict(self):
|
||||
|
||||
@@ -171,10 +171,12 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
|
||||
|
||||
from app.models.container_scale_policy import ContainerScalePolicy
|
||||
from app.models.container_sleep_policy import ContainerSleepPolicy
|
||||
from app.models.deployment import Deployment
|
||||
|
||||
with app.app_context():
|
||||
db.session.add(ContainerScalePolicy(application_id=dead_app.id))
|
||||
db.session.add(ContainerSleepPolicy(application_id=dead_app.id))
|
||||
db.session.add(Deployment(app_id=dead_app.id, version=1))
|
||||
db.session.commit()
|
||||
|
||||
ok, warning = recycle_bin_service.purge('application', dead_app.id)
|
||||
@@ -185,6 +187,29 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
|
||||
application_id=dead_app.id).first() is None
|
||||
assert ContainerSleepPolicy.query.filter_by(
|
||||
application_id=dead_app.id).first() is None
|
||||
assert Deployment.query.filter_by(app_id=dead_app.id).first() is None
|
||||
|
||||
|
||||
def test_every_not_null_child_of_application_cascades_deletes(app):
|
||||
"""The invariant behind the purge crash, pinned for FUTURE models too: any
|
||||
one-to-many from Application whose child FK is NOT NULL must carry a delete
|
||||
cascade, because SQLAlchemy's default on parent delete is to NULL the child
|
||||
FK — which the NOT NULL constraint turns into an IntegrityError at purge."""
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
with app.app_context():
|
||||
offenders = []
|
||||
for rel in sa_inspect(Application).relationships:
|
||||
if rel.direction.name != 'ONETOMANY' or rel.viewonly:
|
||||
continue
|
||||
fk_cols = [c for c in rel.remote_side if c.foreign_keys]
|
||||
if not fk_cols or all(c.nullable for c in fk_cols):
|
||||
continue
|
||||
if 'delete' not in rel.cascade:
|
||||
offenders.append(str(rel))
|
||||
assert not offenders, (
|
||||
'NOT NULL children of Application without a delete cascade '
|
||||
f'(purge would IntegrityError): {offenders}')
|
||||
|
||||
|
||||
def test_purge_honours_an_explicit_remove_data_false(app, dead_app, monkeypatch):
|
||||
|
||||
Reference in new issue
Block a user