feat(monitoring): add update-run log, fix scheduler skipping stale customer containers

The automatic scheduler only recreated customer containers when it had just
found a NEW image on Docker Hub. If an image was already pulled locally
(e.g. from an earlier manual pull, or auto-apply was off at the time) but
customer containers hadn't been recreated yet, the daily check reported
"already up to date" and silently never touched those customers again —
the recreate step needs to run independently of whether Hub had anything
new, based on each customer's actual container-vs-local-image state.

Also adds a persistent UpdateRunLog: every scheduled and manual pull/update
run now records per-image pull results and per-customer recreate results,
visible in Monitoring > Update Log, instead of only existing in container
logs.
This commit is contained in:
2026-08-21 22:40:12 +02:00
parent 276f4214a7
commit d4b6efeea1
10 changed files with 391 additions and 33 deletions
+51
View File
@@ -1,5 +1,6 @@
"""SQLAlchemy ORM models for NetBird MSP Appliance."""
import json
from datetime import datetime
from typing import Optional
@@ -321,6 +322,56 @@ class DeploymentLog(Base):
}
class UpdateRunLog(Base):
"""Audit trail for NetBird Docker image update runs (pull + container recreate).
Written for both scheduled (automatic) and manual (button-triggered)
runs, so the actual outcome — which image pulls failed, which customer
container recreates failed — is visible in the UI instead of only in
container logs.
"""
__tablename__ = "update_run_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
run_type: Mapped[str] = mapped_column(String(20), nullable=False) # "scheduled" | "manual"
trigger: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # username, or "scheduler"
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(
String(20), default="running"
) # running | no_update | success | partial | failed
any_update_available: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
pull_attempted: Mapped[bool] = mapped_column(Boolean, default=False)
pull_success: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
pull_details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON: {image: {success, error}}
apply_attempted: Mapped[bool] = mapped_column(Boolean, default=False)
customers_updated: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
customers_total: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
customer_details: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON list
error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
def to_dict(self) -> dict:
"""Serialize to dict, parsing the embedded JSON blobs."""
return {
"id": self.id,
"run_type": self.run_type,
"trigger": self.trigger,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"status": self.status,
"any_update_available": self.any_update_available,
"pull_attempted": self.pull_attempted,
"pull_success": self.pull_success,
"pull_details": json.loads(self.pull_details) if self.pull_details else None,
"apply_attempted": self.apply_attempted,
"customers_updated": self.customers_updated,
"customers_total": self.customers_total,
"customer_details": json.loads(self.customer_details) if self.customer_details else None,
"error": self.error,
}
class User(Base):
"""Admin user model."""