feat: Initial Hub implementation
Complete LetsBe Hub service for license management and telemetry: - Client and Instance CRUD APIs - License key generation and validation (lb_inst_ format) - Hub API key generation (hk_ format) for telemetry auth - Instance activation endpoint - Telemetry collection with privacy-first redactor - Key rotation and suspend/reactivate functionality - Alembic migrations for PostgreSQL - Docker Compose deployment ready - Comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
16
app/models/__init__.py
Normal file
16
app/models/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Hub database models."""
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
from app.models.client import Client
|
||||
from app.models.instance import Instance
|
||||
from app.models.usage_sample import UsageSample
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"UUIDMixin",
|
||||
"TimestampMixin",
|
||||
"utc_now",
|
||||
"Client",
|
||||
"Instance",
|
||||
"UsageSample",
|
||||
]
|
||||
44
app/models/base.py
Normal file
44
app/models/base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Base model and mixins for SQLAlchemy ORM."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(AsyncAttrs, DeclarativeBase):
|
||||
"""Base class for all SQLAlchemy models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Mixin that adds a UUID primary key."""
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin that adds created_at and updated_at timestamps."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
onupdate=utc_now,
|
||||
nullable=False,
|
||||
)
|
||||
38
app/models/client.py
Normal file
38
app/models/client.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Client model - represents a company/organization using LetsBe."""
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.instance import Instance
|
||||
|
||||
|
||||
class Client(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
A client is a company or organization using LetsBe.
|
||||
|
||||
Clients can have multiple instances (orchestrator deployments).
|
||||
"""
|
||||
|
||||
__tablename__ = "clients"
|
||||
|
||||
# Client identification
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Billing/plan info (for future use)
|
||||
billing_plan: Mapped[str] = mapped_column(String(50), default="free")
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(50), default="active")
|
||||
# "active", "suspended", "archived"
|
||||
|
||||
# Relationships
|
||||
instances: Mapped[list["Instance"]] = relationship(
|
||||
back_populates="client",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
137
app/models/instance.py
Normal file
137
app/models/instance.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Instance model - represents a deployed orchestrator with licensing."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.client import Client
|
||||
|
||||
|
||||
class Instance(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
A deployed orchestrator instance with licensing.
|
||||
|
||||
Each instance is tied to a client and requires a valid license to operate.
|
||||
The Hub issues license keys and tracks activation status.
|
||||
"""
|
||||
|
||||
__tablename__ = "instances"
|
||||
|
||||
# Client relationship
|
||||
client_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("clients.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Instance identification
|
||||
instance_id: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# e.g., "acme-orchestrator"
|
||||
|
||||
# === LICENSING ===
|
||||
license_key_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
)
|
||||
# SHA-256 hash of the license key (lb_inst_...)
|
||||
|
||||
license_key_prefix: Mapped[str] = mapped_column(
|
||||
String(12),
|
||||
nullable=False,
|
||||
)
|
||||
# First 12 chars for display: "lb_inst_abc1"
|
||||
|
||||
license_status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="active",
|
||||
nullable=False,
|
||||
)
|
||||
# "active", "suspended", "expired", "revoked"
|
||||
|
||||
license_issued_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
license_expires_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# None = no expiry (perpetual)
|
||||
|
||||
# === ACTIVATION STATE ===
|
||||
activated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# Set when instance first calls /activate
|
||||
|
||||
last_activation_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# Updated on each activation call
|
||||
|
||||
activation_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# === TELEMETRY ===
|
||||
hub_api_key_hash: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
# Generated on activation, used for telemetry auth
|
||||
|
||||
# === METADATA ===
|
||||
region: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
# e.g., "eu-west-1"
|
||||
|
||||
version: Mapped[Optional[str]] = mapped_column(
|
||||
String(50),
|
||||
nullable=True,
|
||||
)
|
||||
# Last reported orchestrator version
|
||||
|
||||
last_seen_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# Last telemetry or heartbeat
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="pending",
|
||||
nullable=False,
|
||||
)
|
||||
# "pending" (created, not yet activated), "active", "inactive", "suspended"
|
||||
|
||||
# Relationships
|
||||
client: Mapped["Client"] = relationship(back_populates="instances")
|
||||
|
||||
def is_license_valid(self) -> bool:
|
||||
"""Check if the license is currently valid."""
|
||||
from app.models.base import utc_now
|
||||
|
||||
if self.license_status not in ("active",):
|
||||
return False
|
||||
|
||||
if self.license_expires_at and self.license_expires_at < utc_now():
|
||||
return False
|
||||
|
||||
return True
|
||||
93
app/models/telemetry_sample.py
Normal file
93
app/models/telemetry_sample.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Telemetry sample model - stores aggregated metrics from orchestrators.
|
||||
|
||||
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
|
||||
Only aggregated counts, tool names, durations, and status metrics.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, JSON, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
|
||||
class TelemetrySample(UUIDMixin, Base):
|
||||
"""
|
||||
Aggregated telemetry from an orchestrator instance.
|
||||
|
||||
PRIVACY: This model deliberately stores ONLY:
|
||||
- Instance reference
|
||||
- Time window boundaries
|
||||
- Uptime counter
|
||||
- Aggregated metrics (counts, durations, statuses)
|
||||
|
||||
It NEVER stores:
|
||||
- Task payloads or results
|
||||
- Environment variable values
|
||||
- File contents
|
||||
- Error messages or stack traces
|
||||
- Any PII
|
||||
|
||||
De-duplication: The unique constraint on (instance_id, window_start)
|
||||
prevents double-counting if the orchestrator retries submissions.
|
||||
"""
|
||||
|
||||
__tablename__ = "telemetry_samples"
|
||||
|
||||
# Instance reference (FK to instances.id, not instance_id string)
|
||||
instance_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Time window for this sample
|
||||
window_start: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
window_end: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Orchestrator uptime at time of submission
|
||||
uptime_seconds: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Aggregated metrics (stored as JSON for flexibility)
|
||||
# Uses generic JSON type for SQLite test compatibility
|
||||
# PostgreSQL will use native JSON support in production
|
||||
# Structure matches TelemetryMetrics schema:
|
||||
# {
|
||||
# "agents": {"online_count": 1, "offline_count": 0, "total_count": 1},
|
||||
# "tasks": {
|
||||
# "by_status": {"completed": 10, "failed": 1},
|
||||
# "by_type": {"SHELL": {"count": 5, "avg_duration_ms": 1200}}
|
||||
# },
|
||||
# "servers": {"total_count": 1}
|
||||
# }
|
||||
metrics: Mapped[dict] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Unique constraint for de-duplication
|
||||
# If orchestrator retries a failed submission, this prevents duplicates
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"instance_id",
|
||||
"window_start",
|
||||
name="uq_telemetry_instance_window",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<TelemetrySample(instance_id={self.instance_id}, "
|
||||
f"window_start={self.window_start})>"
|
||||
)
|
||||
72
app/models/usage_sample.py
Normal file
72
app/models/usage_sample.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Usage sample model - aggregated telemetry data.
|
||||
|
||||
PRIVACY GUARANTEE: This model contains NO sensitive data fields.
|
||||
Only tool names, durations, and counts are stored.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
|
||||
class UsageSample(UUIDMixin, Base):
|
||||
"""
|
||||
Aggregated usage statistics for an instance.
|
||||
|
||||
PRIVACY: This model deliberately has NO fields for:
|
||||
- Environment values
|
||||
- File contents
|
||||
- Request/response payloads
|
||||
- Screenshots
|
||||
- Credentials
|
||||
- Error messages or stack traces
|
||||
|
||||
Only metadata fields are allowed.
|
||||
"""
|
||||
|
||||
__tablename__ = "usage_samples"
|
||||
|
||||
# Instance reference
|
||||
instance_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("instances.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Time window
|
||||
window_start: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
window_end: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
window_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
)
|
||||
# "minute", "hour", "day"
|
||||
|
||||
# Tool (ONLY name, never payloads)
|
||||
tool_name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
# e.g., "sysadmin.env_update"
|
||||
|
||||
# Counts (aggregated)
|
||||
call_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
rate_limited_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Duration stats (milliseconds)
|
||||
total_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
min_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
max_duration_ms: Mapped[int] = mapped_column(Integer, default=0)
|
||||
Reference in New Issue
Block a user