chore: rename project Roundtable → Steward

Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".

Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.

Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
  Existing volumes need the role/db renamed inside Postgres, or override
  POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
  keep running under the old name; reinstall to switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:20:14 -04:00
parent e118543b2e
commit 88ab5b917e
148 changed files with 570 additions and 619 deletions
+20
View File
@@ -0,0 +1,20 @@
from .base import Base
from .users import User
from .hosts import Host, ProbeType
from .monitors import PingResult, DnsResult, PingStatus, DnsStatus
from .metrics import PluginMetric
from .alerts import AlertRule, AlertState, AlertEvent, AlertOperator, AlertStateEnum
from .ansible import AnsibleRun, AnsibleRunStatus
from .settings import AppSetting
from .dashboard import Dashboard, DashboardWidget, DashboardShareToken
__all__ = [
"Base", "User",
"Host", "ProbeType",
"PingResult", "DnsResult", "PingStatus", "DnsStatus",
"PluginMetric",
"AlertRule", "AlertState", "AlertEvent", "AlertOperator", "AlertStateEnum",
"AnsibleRun", "AnsibleRunStatus",
"AppSetting",
"Dashboard", "DashboardWidget", "DashboardShareToken",
]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class AlertOperator(str, enum.Enum):
gt = ">"
lt = "<"
gte = ">="
lte = "<="
eq = "=="
ne = "!="
class AlertStateEnum(str, enum.Enum):
inactive = "inactive"
pending = "pending"
firing = "firing"
acknowledged = "acknowledged"
resolved = "resolved"
class AlertRule(Base):
__tablename__ = "alert_rules"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
operator: Mapped[AlertOperator] = mapped_column(Enum(AlertOperator), nullable=False)
threshold: Mapped[float] = mapped_column(Float, nullable=False)
consecutive_failures_required: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
class AlertState(Base):
__tablename__ = "alert_states"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
rule_id: Mapped[str] = mapped_column(
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False, unique=True
)
state: Mapped[AlertStateEnum] = mapped_column(
Enum(AlertStateEnum), nullable=False, default=AlertStateEnum.inactive
)
consecutive_failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_transition_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
acknowledged_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class MaintenanceWindow(Base):
__tablename__ = "maintenance_windows"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
# Scope — null means "all". scope_resource requires scope_source to be set.
scope_source: Mapped[str | None] = mapped_column(String(64), nullable=True)
scope_resource: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_by: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
class AlertEvent(Base):
__tablename__ = "alert_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
rule_id: Mapped[str] = mapped_column(
String(36), ForeignKey("alert_rules.id", ondelete="CASCADE"), nullable=False
)
from_state: Mapped[str] = mapped_column(String(32), nullable=False)
to_state: Mapped[str] = mapped_column(String(32), nullable=False)
metric_value: Mapped[float] = mapped_column(Float, nullable=False)
transitioned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
email_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
email_error: Mapped[str | None] = mapped_column(Text, nullable=True)
webhook_sent: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
webhook_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class AnsibleRunStatus(str, enum.Enum):
running = "running"
success = "success"
failed = "failed"
interrupted = "interrupted"
class AnsibleRun(Base):
__tablename__ = "ansible_runs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
playbook_path: Mapped[str] = mapped_column(String(512), nullable=False)
inventory_path: Mapped[str] = mapped_column(String(512), nullable=False)
source_name: Mapped[str] = mapped_column(String(128), nullable=False)
triggered_by: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[AnsibleRunStatus] = mapped_column(
Enum(AnsibleRunStatus), nullable=False, default=AnsibleRunStatus.running
)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
output: Mapped[str | None] = mapped_column(Text, nullable=True)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class AuditEvent(Base):
__tablename__ = "audit_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Denormalized so display survives user deletion
username: Mapped[str] = mapped_column(String(64), nullable=False, default="system")
action: Mapped[str] = mapped_column(String(64), nullable=False)
entity_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
entity_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class Dashboard(Base):
__tablename__ = "dashboards"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, default="Default")
# NULL owner_id = system dashboard (visible to all, editable by admins)
owner_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, default=None,
)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# is_shared = True → visible to all logged-in users (read-only to non-owners)
is_shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class DashboardShareToken(Base):
__tablename__ = "dashboard_share_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dashboard_id: Mapped[int] = mapped_column(
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
)
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
label: Mapped[str] = mapped_column(String(128), nullable=False, default="")
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class DashboardWidget(Base):
__tablename__ = "dashboard_widgets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dashboard_id: Mapped[int] = mapped_column(
Integer, ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False,
)
widget_key: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
title: Mapped[str | None] = mapped_column(String(128), nullable=True)
config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ProbeType(str, enum.Enum):
tcp = "tcp"
icmp = "icmp"
class Host(Base):
__tablename__ = "hosts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name: Mapped[str] = mapped_column(String(128), nullable=False)
address: Mapped[str] = mapped_column(String(255), nullable=False)
probe_type: Mapped[ProbeType] = mapped_column(Enum(ProbeType), nullable=False, default=ProbeType.tcp)
probe_port: Mapped[int] = mapped_column(Integer, nullable=False, default=80)
ping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
dns_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
dns_expected_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)
poll_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PluginMetric(Base):
__tablename__ = "plugin_metrics"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
source_module: Mapped[str] = mapped_column(String(64), nullable=False)
resource_name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_name: Mapped[str] = mapped_column(String(128), nullable=False)
value: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Enum, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PingStatus(str, enum.Enum):
up = "up"
down = "down"
class DnsStatus(str, enum.Enum):
resolved = "resolved"
failed = "failed"
class PingResult(Base):
__tablename__ = "ping_results"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
)
probed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
status: Mapped[PingStatus] = mapped_column(Enum(PingStatus), nullable=False)
response_time_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
class DnsResult(Base):
__tablename__ = "dns_results"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
host_id: Mapped[str] = mapped_column(
String(36), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False
)
resolved_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
status: Mapped[DnsStatus] = mapped_column(Enum(DnsStatus), nullable=False)
resolved_ip: Mapped[str | None] = mapped_column(String(255), nullable=True)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from steward.models.base import Base
class AppSetting(Base):
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value_json: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class UserRole(str, enum.Enum):
admin = "admin"
operator = "operator"
viewer = "viewer"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.viewer)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)