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:
2025-12-22 14:09:32 +01:00
commit adc02e176b
39 changed files with 2968 additions and 0 deletions

16
app/models/__init__.py Normal file
View 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
View 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
View 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
View 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

View 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})>"
)

View 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)