Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
letsbe-orchestrator/app/models/__init__.py
Normal file
21
letsbe-orchestrator/app/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""SQLAlchemy models for the Orchestrator."""
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.server import Server
|
||||
from app.models.task import Task, TaskStatus
|
||||
from app.models.agent import Agent, AgentStatus
|
||||
from app.models.event import Event
|
||||
from app.models.registration_token import RegistrationToken
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Tenant",
|
||||
"Server",
|
||||
"Task",
|
||||
"TaskStatus",
|
||||
"Agent",
|
||||
"AgentStatus",
|
||||
"Event",
|
||||
"RegistrationToken",
|
||||
]
|
||||
92
letsbe-orchestrator/app/models/agent.py
Normal file
92
letsbe-orchestrator/app/models/agent.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Agent model for SysAdmin automation workers."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AgentStatus(str, Enum):
|
||||
"""Agent status values."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
INVALID = "invalid" # Agent with NULL tenant_id, must re-register
|
||||
|
||||
|
||||
class Agent(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Agent model representing a SysAdmin automation worker.
|
||||
|
||||
Agents register with the orchestrator and receive tasks to execute.
|
||||
"""
|
||||
|
||||
__tablename__ = "agents"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default=AgentStatus.OFFLINE.value,
|
||||
index=True,
|
||||
)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
# Legacy field - kept for backward compatibility during migration
|
||||
# Will be removed after all agents migrate to new auth scheme
|
||||
token: Mapped[str] = mapped_column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default="",
|
||||
)
|
||||
# New secure credential storage - SHA-256 hash of agent secret
|
||||
secret_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
default="",
|
||||
comment="SHA-256 hash of the agent secret",
|
||||
)
|
||||
# Reference to the registration token used to create this agent
|
||||
registration_token_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("registration_tokens.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant | None"] = relationship(
|
||||
back_populates="agents",
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
back_populates="agent",
|
||||
lazy="selectin",
|
||||
)
|
||||
registration_token: Mapped["RegistrationToken | None"] = relationship()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Agent(id={self.id}, name={self.name}, status={self.status})>"
|
||||
44
letsbe-orchestrator/app/models/base.py
Normal file
44
letsbe-orchestrator/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,
|
||||
)
|
||||
72
letsbe-orchestrator/app/models/event.py
Normal file
72
letsbe-orchestrator/app/models/event.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Event model for audit logging."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.task import Task
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC datetime."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Event(UUIDMixin, Base):
|
||||
"""
|
||||
Event model for audit logging and activity tracking.
|
||||
|
||||
Events are immutable records of system activity.
|
||||
Only has created_at (no updated_at since events are immutable).
|
||||
"""
|
||||
|
||||
__tablename__ = "events"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utc_now,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="events",
|
||||
)
|
||||
task: Mapped["Task | None"] = relationship(
|
||||
back_populates="events",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Event(id={self.id}, type={self.event_type})>"
|
||||
101
letsbe-orchestrator/app/models/registration_token.py
Normal file
101
letsbe-orchestrator/app/models/registration_token.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Registration token model for secure agent registration."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, 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.tenant import Tenant
|
||||
|
||||
|
||||
class RegistrationToken(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Registration token for secure agent registration.
|
||||
|
||||
Tokens are pre-provisioned by admins and map to specific tenants.
|
||||
Agents use these tokens during initial registration to:
|
||||
1. Authenticate the registration request
|
||||
2. Associate themselves with the correct tenant
|
||||
|
||||
Tokens can be:
|
||||
- Single-use (max_uses=1, default)
|
||||
- Limited-use (max_uses > 1)
|
||||
- Unlimited (max_uses=0)
|
||||
- Time-limited (expires_at set)
|
||||
- Manually revoked (revoked=True)
|
||||
"""
|
||||
|
||||
__tablename__ = "registration_tokens"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="SHA-256 hash of the registration token",
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Human-readable description for the token",
|
||||
)
|
||||
max_uses: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
comment="Maximum number of uses (0 = unlimited)",
|
||||
)
|
||||
use_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
comment="Current number of times this token has been used",
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="Optional expiration timestamp",
|
||||
)
|
||||
revoked: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="Whether this token has been manually revoked",
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="Identifier of who created this token (for audit)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="registration_tokens",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RegistrationToken(id={self.id}, tenant_id={self.tenant_id}, uses={self.use_count}/{self.max_uses})>"
|
||||
|
||||
def is_valid(self, now: datetime | None = None) -> bool:
|
||||
"""Check if the token can still be used for registration."""
|
||||
from app.models.base import utc_now
|
||||
|
||||
if now is None:
|
||||
now = utc_now()
|
||||
|
||||
if self.revoked:
|
||||
return False
|
||||
if self.expires_at is not None and self.expires_at < now:
|
||||
return False
|
||||
if self.max_uses > 0 and self.use_count >= self.max_uses:
|
||||
return False
|
||||
return True
|
||||
59
letsbe-orchestrator/app/models/server.py
Normal file
59
letsbe-orchestrator/app/models/server.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Server model for provisioned infrastructure."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class ServerStatus(str, Enum):
|
||||
"""Server provisioning status."""
|
||||
|
||||
PROVISIONING = "provisioning"
|
||||
READY = "ready"
|
||||
ERROR = "error"
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class Server(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Server model representing a provisioned VM or container.
|
||||
|
||||
Tracks provisioning state and network configuration.
|
||||
"""
|
||||
|
||||
__tablename__ = "servers"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
hostname: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
nullable=False,
|
||||
)
|
||||
ip_address: Mapped[str | None] = mapped_column(
|
||||
String(45), # Supports IPv6
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=ServerStatus.PROVISIONING.value,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="servers",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Server(id={self.id}, hostname={self.hostname}, status={self.status})>"
|
||||
85
letsbe-orchestrator/app/models/task.py
Normal file
85
letsbe-orchestrator/app/models/task.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Task model for orchestration jobs."""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import ForeignKey, JSON, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.agent import Agent
|
||||
from app.models.event import Event
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Task execution status."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class Task(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Task model representing an orchestration job.
|
||||
|
||||
Tasks are assigned to agents and track execution state.
|
||||
Payload and result use JSONB for flexible, queryable storage.
|
||||
"""
|
||||
|
||||
__tablename__ = "tasks"
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
agent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("agents.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSONType,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=TaskStatus.PENDING.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
result: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONType,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
tenant: Mapped["Tenant"] = relationship(
|
||||
back_populates="tasks",
|
||||
)
|
||||
agent: Mapped["Agent | None"] = relationship(
|
||||
back_populates="tasks",
|
||||
)
|
||||
events: Mapped[list["Event"]] = relationship(
|
||||
back_populates="task",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task(id={self.id}, type={self.type}, status={self.status})>"
|
||||
67
letsbe-orchestrator/app/models/tenant.py
Normal file
67
letsbe-orchestrator/app/models/tenant.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tenant model for multi-tenancy support."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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.agent import Agent
|
||||
from app.models.event import Event
|
||||
from app.models.registration_token import RegistrationToken
|
||||
from app.models.server import Server
|
||||
from app.models.task import Task
|
||||
|
||||
|
||||
class Tenant(UUIDMixin, TimestampMixin, Base):
|
||||
"""
|
||||
Tenant model representing a customer organization.
|
||||
|
||||
Each tenant has isolated servers, tasks, agents, and events.
|
||||
"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
domain: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=True,
|
||||
)
|
||||
dashboard_token_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), # SHA-256 hex = 64 characters
|
||||
nullable=True,
|
||||
comment="SHA-256 hash of dashboard authentication token",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
servers: Mapped[list["Server"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
tasks: Mapped[list["Task"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
agents: Mapped[list["Agent"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
events: Mapped[list["Event"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
registration_tokens: Mapped[list["RegistrationToken"]] = relationship(
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
Reference in New Issue
Block a user