Files
3cloud-backend/app/models/models.py
T
coryHawkvelt ff989be202 PortForwarding work
Added workload id to portforward object
Updated streamlit to show workload name
2025-07-25 15:38:52 +09:30

904 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import uuid
from datetime import datetime
from sqlalchemy import (
Column, String, Boolean, DateTime, ForeignKey, Integer, Float, Text, Enum, event, inspect
)
import sqlalchemy
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import relationship, declarative_base, Query
from app import db, logger
from sqlalchemy import exists, literal, and_
from sqlalchemy.orm import selectinload, with_loader_criteria
Base = declarative_base()
import json
from sqlalchemy.orm import aliased
# SoftDeleteMixin provides soft delete functionality to any model that inherits from it
class SoftDeleteMixin:
# Timestamp column to track when an object was deleted
deleted_at = Column(DateTime(timezone=True), nullable=True)
deleted = Column(Boolean, default=False, nullable=False)
# Method to mark an object as deleted with optional custom timestamp
def delete(self, deleted_at: datetime = None):
"""Soft delete the object by setting deleted_at timestamp"""
self.deleted_at = deleted_at or datetime.now()
self.deleted = True
# Method to restore a soft-deleted object
def restore(self):
"""Restore a soft-deleted object by clearing deleted_at"""
self.deleted_at = None
self.deleted = False
# Alias for delete
soft_delete = delete
# # Event listener that automatically filters out soft-deleted objects from queries
# @event.listens_for(Query, 'before_compile', retval=True)
# def before_compile(query):
# """
# Intercept all queries before compilation to filter out soft-deleted objects.
# Only applies to models inheriting from SoftDeleteMixin.
# Can be bypassed with include_deleted execution option.
# """
# # Check if query explicitly requested to include deleted items
# include_deleted = query._execution_options.get('include_deleted', False)
# if include_deleted:
# return query # Skip filtering if include_deleted is True
# # Examine all entities in the query
# for column in query.column_descriptions:
# entity = column['entity']
# if entity is None:
# continue
# # Check if the entity's model inherits from SoftDeleteMixin
# inspector = inspect(column['entity'])
# mapper = getattr(inspector, 'mapper', None)
# if mapper and issubclass(mapper.class_, SoftDeleteMixin):
# # Add filter to exclude soft-deleted objects
# query = query.enable_assertions(False).filter(
# entity.deleted == False, # Filter on the boolean deleted flag
# )
# return query
# # Event listener to catch accidental loading of deleted objects
# @event.listens_for(SoftDeleteMixin, 'load', propagate=True)
# def load(obj, context):
# """
# Safety check when objects are loaded to prevent accidental access to deleted objects.
# Raises TypeError if a deleted object is loaded without explicitly requesting it.
# """
# include_deleted = context.query._execution_options.get('include_deleted', False)
# if (obj.deleted or obj.deleted_at) and not include_deleted:
# raise TypeError(f'Deleted object {obj} was loaded, did you use joined eager loading?')
class BaseModel(SoftDeleteMixin,db.Model):
__abstract__ = True
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
visible = Column(Boolean, default=True, nullable=False)
name = Column(String(255), nullable=False)
description = Column(LONGTEXT, nullable=True)
status = Column(String(50), nullable=True)
created_by = Column(String(36), ForeignKey("users.id"), nullable=True)
# def __init__(self, *args, **kwargs):
# # Convert UUID strings to UUID objects for any UUID fields
# for key, value in kwargs.items():
# if hasattr(self.__table__.columns, key) and isinstance(getattr(self.__table__.columns, key).type, String):
# try:
# kwargs[key] = str(uuid.UUID(value)) if value else None
# except (ValueError, TypeError, AttributeError):
# pass
# super().__init__(*args, **kwargs)
@classmethod
def query_with_deleted(cls):
"""Query that includes deleted items"""
return db.session.query(cls).execution_options(include_deleted=True)
@classmethod
def query_only_deleted(cls):
"""Query that only returns deleted items"""
return db.session.query(cls).filter(cls.deleted == True)
def to_json(self):
result = {}
for column in self.__table__.columns:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
elif column.name.endswith('_id') and isinstance(value, str):
try:
result[column.name] = str(uuid.UUID(value))
except (ValueError, TypeError):
result[column.name] = value
else:
result[column.name] = value
return result
def getLabels(self):
"""Returns all labels associated with this object"""
return Label.query.filter_by(
target_object_id=self.id,
target_object_type=self.__tablename__
).all()
def addLabel(self, key, value, admin_only_view=False, admin_only_set=False):
"""Adds a new label to this object
Args:
key (str): The label key
value (str): The label value
admin_only_view (bool): Whether only admins can view this label
admin_only_set (bool): Whether only admins can set/modify this label
"""
label = Label(
target_object_type=self.__tablename__,
target_object_id=self.id,
label_key=key,
label_value=value,
admin_only_view=admin_only_view,
admin_only_set=admin_only_set,
name=f"Label {key}={value} for {self.__tablename__} {self.id}",
description=f"Automatically created label for {self.__tablename__} {self.id}"
)
db.session.add(label)
db.session.commit()
return label
class Universe(BaseModel):
__tablename__ = "universes"
class Project(BaseModel):
__tablename__ = "projects"
universe_id = Column(String(36), ForeignKey("universes.id"), nullable=False)
universe = relationship("Universe")
class Region(BaseModel):
__tablename__ = "regions"
private_region = Column(Boolean, default=False, nullable=False)
country = Column(String(255), nullable=False)
advertised_address = Column(String(255), nullable=True)
abbreviation = Column(String(16), nullable=False)
enrollment_key = Column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
ip_address_range_northsouth = Column(String(255), nullable=True)
ip_address_range_eastwest = Column(String(255), nullable=True)
class VirtualDataCenter(BaseModel):
__tablename__ = "vdcs"
region_id = Column(String(36), ForeignKey("regions.id"), nullable=False)
project_id = Column(String(36), ForeignKey("projects.id"), nullable=False)
region = relationship("Region")
project = relationship("Project")
class RegionAccess(db.Model):
__tablename__ = "region_access"
project_id = Column(String(36), ForeignKey("projects.id"), primary_key=True)
region_id = Column(String(36), ForeignKey("regions.id"), primary_key=True)
def to_json(self):
result = {}
for column in self.__table__.columns:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
elif column.name.endswith('_id') and isinstance(value, str):
try:
result[column.name] = str(uuid.UUID(value))
except (ValueError, TypeError):
result[column.name] = value
else:
result[column.name] = value
return result
class Label(BaseModel):
__tablename__ = "labels"
target_object_type = Column(String(255), nullable=False)
target_object_id = Column(String(36), nullable=False)
label_key = Column(String(255), nullable=False)
label_value = Column(String(255), nullable=True)
admin_only_view = Column(Boolean, default=False)
admin_only_set = Column(Boolean, default=False)
class WorkloadHost(BaseModel):
__tablename__ = "workload_hosts"
region_id = Column(String(36), ForeignKey("regions.id"), nullable=False)
secret_key = Column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
system_manufacturer = Column(String(255), nullable=True)
system_model = Column(String(255), nullable=True)
physical_identifier = Column(String(255), nullable=True)
dcim_identifier = Column(String(255), nullable=True)
installed_date = Column(DateTime, nullable=True)
installed_status = Column(String(50), nullable=True)
hostname = Column(String(255), nullable=True)
available_for_scheduling = Column(Boolean, default=True)
placement_priority = Column(Integer, default=100)
heartbeat_status = Column(String(50), nullable=True)
last_heartbeat_time = Column(DateTime, nullable=True)
ip_address_all = Column(LONGTEXT, nullable=True)
ip_address_eastwest = Column(String(25), nullable=True) #This IP is used for SDN capabilities in a region
ip_address_northsouth = Column(String(25), nullable=True) #This IP is used for port forwarding into a host for containers primarily
workloads = relationship(
"Workload",
back_populates="workload_host",
cascade="all, delete-orphan",
lazy="dynamic" # Allows querying like workload_host.workloads.filter(...)
)
fixed_resources = relationship(
"WorkloadHostFixedResource",
back_populates="workload_host",
cascade="all, delete-orphan"
)
pooled_resources = relationship(
"WorkloadHostPooledResource",
back_populates="workload_host",
cascade="all, delete-orphan"
)
ovs_bridges = relationship(
"WorkloadHostOVSBridge",
back_populates="workload_host",
cascade="all, delete-orphan"
)
# Private fields (convention with single underscore)
_status = db.Column("status", db.String(50), nullable=True, default="new")
# Property for status with controlled access
@property
def status(self):
"""Read-only access to status"""
return self._status
@status.setter
def status(self, value):
"""Block direct status assignment"""
raise AttributeError(
"Direct status assignment is not allowed. "
"Use set_status() method instead."
)
# Custom method for status changes
def set_status(self, new_status, changed_by=None, reason=None):
"""
Safe method to update status that triggers event handlers and validation
Args:
new_status (str): The new status value
changed_by (UUID): ID of user making the change
reason (str): Optional reason for status change
"""
if not hasattr(self, '_status'):
raise AttributeError("Status field not initialized")
old_status = self._status
# Validate status transition
# self._validate_status_change(old_status, new_status)
# Update the status
self._status = new_status
# # Trigger status change handlers
# self.handle_workload_status_change( old_status, new_status, changed_by)
def get_network_ports_not_on_host(self, network_id: str, exclude_host_id: str):
"""
Returns all NetworkPort records for the given network_id that:
- are attached to a workload
- are NOT attached to a workload on exclude_host_id
- are NOT attached to a workload on a deleted host
"""
WorkloadAlias = aliased(Workload)
WorkloadHostAlias = aliased(WorkloadHost)
from app.models.network import NetworkPort
return (
db.session.query(NetworkPort)
.join(WorkloadAlias, NetworkPort.workload_id == WorkloadAlias.id)
.filter(NetworkPort.network_id == network_id)
.filter(WorkloadAlias.workload_host_id != exclude_host_id)
.filter(WorkloadAlias.deleted == False)
.all()
)
def generate_sdn_payload(self, session):
"""
Generates the SDN update payload including peer ports and all local ports
for workloads (containers and virtual machines) running on this host.
Args:
session (Session): SQLAlchemy DB session.
new_workload (Workload, optional): A newly created workload to include in local ports.
Returns:
dict: SDN update payload
"""
sdn_payload = {
'peer-ports': [],
'local-ports': []
}
# Gather all workloads (excluding deleted) on this host + optional new one
workloads = list(self.workloads.filter_by(deleted=False).all())
logger.debug(f"Checking over {len(workloads)} workloads")
# --- Collect local ports ---
for workload in workloads:
ports = workload.network_ports
if not ports:
continue
sdn_payload['local-ports'].append({
"workload": {
"type": workload.workload_type,
"id": workload.id
},
"networks": [
{
"network_id": port.network_id,
"mac": port.mac_address,
"ip": f"{port.ip_address}/{port.subnet_mask}",
"vni": port.network.vni,
"port_name": port.name,
"ratelimit_out": 1000
} for port in ports
]
})
# --- Collect peer ports ---
# Initialize an empty set to track all network IDs in use
all_network_ids = set()
# Iterate over each workload assigned to this host
for workload in workloads:
# Iterate over each network port associated with the workload
for port in workload.network_ports:
# Add the network ID of this port to the set
all_network_ids.add(port.network_id)
for net_id in all_network_ids:
ports = self.get_network_ports_not_on_host(net_id, self.id)
for port in ports:
if not port.workload.deleted:
sdn_payload['peer-ports'].append({
"mac": port.mac_address,
"ip": port.ip_address,
"vni": port.network.vni,
"workload_host_ip": port.workload.workload_host.ip_address_eastwest
})
return sdn_payload
class WorkloadHostFixedResource(BaseModel):
__tablename__ = "workload_host_fixed_resources"
type = Column(String(50), nullable=False)
model = Column(String(255), nullable=True)
manufacturer = Column(String(255), nullable=True)
physical_address = Column(String(255), nullable=True)
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"), nullable=True)
workload_host = relationship(
"WorkloadHost",
back_populates="fixed_resources"
)
class WorkloadHostPooledResource(BaseModel):
__tablename__ = "workload_host_pooled_resources"
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"), nullable=False)
resource_type = Column(String(50), nullable=False)
total_quantity = Column(Float, default=0, nullable=False)
quantity_in_use = Column(Float, default=0, nullable=False)
quantity_available = Column(Float, default=0, nullable=False)
workload_host = relationship(
"WorkloadHost",
back_populates="pooled_resources"
)
class WorkloadHostOVSBridge(BaseModel):
__tablename__ = "workload_host_ovs_bridge"
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"), nullable=False)
ovs_bridge_name = Column(String(255), nullable=False)
workload_host = relationship(
"WorkloadHost",
back_populates="ovs_bridges"
)
class AuditEntry(db.Model):
__tablename__ = "audit_entry"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
object_id = Column(String(36), nullable=False)
object_type = Column(String(255), nullable=False)
audit_text = Column(LONGTEXT, nullable=False)
audit_entry_type = Column(String(50), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
def log_event(object, action, description, user_id=None,
additional_data=None, is_error=False):
"""
Logs an event to the audit trail
"""
# TODO - Expand on this debug line with more detail
# TODO - Bring the UserID in from the auth module, possibly using the g object?
logger.debug(f"Adding audit entry {object}")
entry = AuditEntry(
object_type=str(type(object)),
object_id=object.id,
audit_entry_type=action,
audit_text=f"{action} {description} {user_id}",
# user_id=user_id,
# additional_data=json.dumps(additional_data) if additional_data else None,
# is_error=is_error,
# timestamp=datetime.utcnow()
)
db.session.add(entry)
db.session.commit()
return entry
class Workload(BaseModel):
__tablename__ = "workloads"
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"), nullable=True)
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False)
workload_type = Column(String(50), nullable=False) # Typically Container, VM or NSController
# status = Column(String, nullable=True)
container_id = Column(String(255), nullable=True)
launch_params = Column(LONGTEXT, nullable=True)
time_to_provision = Column(Float, nullable=True)
# Private fields (convention with single underscore)
_status = db.Column("status", db.String(50), nullable=True, default="new")
# Relationship to VolumeWorkloadMapping
# Improved relationships
workload_host = relationship(
"WorkloadHost",
back_populates="workloads"
)
network_ports = relationship(
"NetworkPort",
back_populates="workload",
cascade="all, delete-orphan"
)
volume_mappings = relationship(
"VolumeWorkloadMapping",
back_populates="workload",
cascade="all, delete-orphan"
)
pod_mappings = relationship(
"ContainerPodContainer",
back_populates="container",
cascade="all, delete-orphan"
)
# Property for status with controlled access
@property
def status(self):
"""Read-only access to status"""
return self._status
@status.setter
def status(self, value):
"""Block direct status assignment"""
raise AttributeError(
"Direct status assignment is not allowed. "
"Use set_status() method instead."
)
# Custom method for status changes
def set_status(self, new_status, changed_by=None, reason=None):
"""
Safe method to update status that triggers event handlers and validation
Args:
new_status (str): The new status value
changed_by (UUID): ID of user making the change
reason (str): Optional reason for status change
"""
if not hasattr(self, '_status'):
raise AttributeError("Status field not initialized")
old_status = self._status
# Validate status transition
self._validate_status_change(old_status, new_status)
# Update the status
self._status = new_status
# # Trigger status change handlers
self.handle_workload_status_change( old_status, new_status, changed_by)
def _validate_status_change(self, old_status, new_status):
"""Internal validation for status changes"""
valid_transitions = {
None: ["pending", "pending-allocation", "error"], # Initial states
"pending": ["provisioning", "error"],
"provisioning": ["running", "error"],
"running": ["stopping", "error","pending-deleted","dead","stopped"],
"pending-allocation": ["running", "error","allocated","pending-deleted"],
"allocated":["pending-deleted","running","dead","stopped", "launch_failed"],
"pending-allocated": ["*"],
"dead": ["*"],
"new": ["*"],
"deleted": ["deleted"],
"pending-deleted": ["running","deleted","dead","stopped"], # A container can go from pending-deleted to running if there is a race between a deletion and a creation event
"stopped": ["deleted"]
}
if old_status not in valid_transitions:
raise ValueError(f"Invalid current status: {old_status}")
if new_status not in valid_transitions[old_status] and "*" not in valid_transitions[old_status] and new_status!=old_status:
raise ValueError(
f"Cannot transition from {old_status} to {new_status}. "
f"Valid transitions: {', '.join(valid_transitions[old_status])}"
)
def handle_workload_status_change(self, old_status, new_status, changed_by=None):
"""
Handles all workload status changes and triggers appropriate actions
"""
if old_status==new_status:
return
logger.debug(f"Handling status change for workload of type [{self.workload_type}] and ID [{self.id}] from status [{old_status}] to status [{new_status}]")
# Log the status change
AuditEntry.log_event(
object=self,
action="status_change",
description=f"Status changed from {old_status} to {new_status}",
user_id=changed_by,
additional_data={"old_status": old_status, "new_status": new_status}
)
# Handle specific status transitions
if new_status == "dead" and self.workload_type == "container":
# TODO - This should not trigger instantly, it should add an event to a queue and wait a pre-determined amount of time before attempting
# Normal container lifecycle will see a container go from running->dead->deleted when the container goes through a deletion
# EventHandlers.handle_failed_container(changed_by)
logger.error("Container failed and we arent doing anything about it!!")
elif new_status == "deleted":
self.soft_delete()
def handle_failed_container(self, changed_by=None):
"""
Special handler for failed containers
"""
try:
# TODO - What if this is a host failure? Do we just handle every container inviddually or should we aggregate
# the event into a host failure event and handle it more intelligently(Might be harder?)
# Attempt to rebuild in place
# container.rebuild_in_place()
logger.warning("Rebuilding container in place after failure")
# Log the rebuild attempt
AuditEntry.log_event(
object=self,
action="rebuild",
description="Container automatically rebuilt after failure",
user_id=changed_by
)
except Exception as e:
# Log the rebuild failure
AuditEntry.log_event(
object=self,
action="rebuild_failed",
description=f"Failed to automatically rebuild container: {str(e)}",
user_id=changed_by,
is_error=True
)
raise
class WorkloadHostPortMapping(db.Model):
__tablename__ = "workload_host_port_mappings"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"), nullable=False)
container_workload_id = Column(String(36), ForeignKey("workloads.id"), nullable=False)
internal_port = Column(Integer, nullable=False)
external_port = Column(Integer, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
workload_host = relationship("WorkloadHost", backref="port_mappings")
container_workload = relationship("Workload", backref="port_mappings")
def to_json(self):
return {
"id": str(uuid.UUID(self.id)) if self.id else None,
"workload_host_id": str(uuid.UUID(self.workload_host_id)) if self.workload_host_id else None,
"container_workload_id": str(uuid.UUID(self.container_workload_id)) if self.container_workload_id else None,
"internal_port": self.internal_port,
"external_port": self.external_port,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class ContainerPodContainer(db.Model):
__tablename__ = "container_pod_containers"
pod_id = Column(String(36), ForeignKey("container_pods.id"), primary_key=True)
container_workload_id = Column(String(36), ForeignKey("workloads.id"), primary_key=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships (optional backrefs if you want)
pod = relationship("ContainerPod", back_populates="container_mappings")
container = relationship("Workload", back_populates="pod_mappings")
class ContainerPod(BaseModel):
__tablename__ = "container_pods"
# ── columns ─────────────────────────────────────────────────────────
workload_host_id = Column(String(36), ForeignKey("workload_hosts.id"))
nscontroller_workload_id = Column(String(36), ForeignKey("workloads.id"))
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False)
# ── relationships (lightweight) ─────────────────────────────────────
workload_host = relationship("WorkloadHost", backref="container_pods")
nscontroller_workload = relationship("Workload", foreign_keys=[nscontroller_workload_id])
container_mappings = relationship(
"ContainerPodContainer",
back_populates="pod",
cascade="all, delete-orphan",
)
vdc = relationship("VirtualDataCenter")
# ── helpers ─────────────────────────────────────────────────────────
def active_containers(self, include_deleted: bool = False):
"""
Return container Workloads linked to this pod.
We query Workload directly to avoid SQLAlchemy autoloading
soft-deleted rows.
"""
container_ids = [m.container_workload_id for m in self.container_mappings]
if not container_ids:
return []
q = db.session.query(Workload).filter(Workload.id.in_(container_ids))
if not include_deleted:
q = q.filter(
and_(
Workload.deleted.is_(False),
Workload._status != "pending-deleted" # exclude soft-delete in-flight
)
)
containers = q.all()
logger.debug(
"Pod %s → %d containers (include_deleted=%s)",
self.id, len(containers), include_deleted,
)
return containers
@classmethod
def query_with_only_active_containers(cls, include_deleted_containers: bool = False):
"""
Build a list of pods + their containers with **pure Python loops**.
A pod is kept if it still has ≥1 active container, unless
`include_deleted_containers` is True.
"""
pods = db.session.query(cls).filter_by(deleted=False).all()
results = []
logger.debug(
"Scanning %d pods for active containers (include_deleted=%s)",
len(pods), include_deleted_containers,
)
for pod in pods:
containers = pod.active_containers(include_deleted_containers)
if containers or include_deleted_containers:
results.append({"pod": pod, "containers": containers})
logger.debug("Returning %d pods after filtering", len(results))
return results
class Image(BaseModel):
__tablename__ = "images"
location = Column(String(255), nullable=False)
location_type = Column(String(50), nullable=True)
size = Column(Float, nullable=False)
os_family = Column(String(50), nullable=True)
os_version = Column(String(50), nullable=True)
checksum = Column(String(255), nullable=True)
format = Column(String(50), nullable=True)
# Relationship to Volume
volumes = relationship("Volume", back_populates="image")
class Volume(BaseModel):
__tablename__ = "volumes"
path = Column(String(255), nullable=True)
type = Column(String(50), nullable=True)
source = Column(String(255), nullable=True)
size_gb = Column(Float, nullable=False)
boot = Column(Boolean, nullable=False, default=False)
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False)
# Optional foreign key to link a volume to an image
image_id = Column(String(36), ForeignKey("images.id"), nullable=True)
# Relationship to Image
image = relationship("Image", back_populates="volumes")
# Relationship to VolumeWorkloadMapping
workload_mappings = relationship("VolumeWorkloadMapping", back_populates="volume")
class VolumeWorkloadMapping(BaseModel):
__tablename__ = "volume_workload_mapping"
# Composite primary key (volume_id and workload_id)
volume_id = Column(String(36), ForeignKey("volumes.id"), primary_key=True)
workload_id = Column(String(36), ForeignKey("workloads.id"), primary_key=True)
# Relationships
volume = relationship("Volume", back_populates="workload_mappings")
workload = relationship("Workload", back_populates="volume_mappings")
class User(SoftDeleteMixin, db.Model):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
first_name = Column(String(255), nullable=False)
last_name = Column(String(255), nullable=False)
email = Column(String(255), unique=True, nullable=False)
oidc_id = Column(String(255), unique=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
visible = Column(Boolean, default=True, nullable=False)
description = Column(LONGTEXT, nullable=True)
status = Column(String(50), nullable=True)
def to_json(self):
result = {}
for column in self.__table__.columns:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
elif column.name.endswith('_id') and isinstance(value, str):
try:
result[column.name] = str(uuid.UUID(value))
except (ValueError, TypeError):
result[column.name] = value
else:
result[column.name] = value
return result
class CloudflareTunnel(BaseModel):
__tablename__ = "cloudflare_tunnels"
account_id = Column(String(255), nullable=False)
tunnel_id = Column(String(255), nullable=False, unique=True)
tunnel_secret = Column(String(255), nullable=False)
token = Column(String(255), nullable=False)
associated_hostname = Column(String(255), nullable=True)
notes = Column(LONGTEXT, nullable=True)
nscontroller_workload_id = Column(String(36), ForeignKey("workloads.id"))
# Relationship to DNS records is already defined via backref in CloudflareDNSRecord
# No need to repeat it here unless you want to customize it
def to_json(self):
data = super().to_json()
# Optionally hide the secret/token from normal to_json if you want
data.pop("tunnel_secret", None)
data.pop("token", None)
# Optionally include DNS records count
data["dns_records_count"] = len(self.dns_records) if hasattr(self, 'dns_records') else 0
return data
class CloudflareDNSRecord(BaseModel):
__tablename__ = "cloudflare_dns_records"
zone_id = Column(String(255), nullable=False)
dns_record_id = Column(String(255), nullable=False, unique=True)
hostname = Column(String(255), nullable=False)
record_type = Column(String(50), default="CNAME", nullable=False)
content = Column(String(255), nullable=False)
ttl = Column(Integer, default=120)
proxied = Column(Boolean, default=True)
notes = Column(LONGTEXT, nullable=True)
tunnel_id = Column(String(36), ForeignKey("cloudflare_tunnels.id"), nullable=True)
# Relationships
tunnel = relationship("CloudflareTunnel", backref="dns_records")
# Port forwarding relationship is established via backref from PortForwarding
def to_json(self):
result = super().to_json()
# Include tunnel information if available
if self.tunnel:
result["tunnel"] = {
"id": self.tunnel.id,
"name": self.tunnel.name,
"tunnel_id": self.tunnel.tunnel_id,
"associated_hostname": self.tunnel.associated_hostname
}
# Include port forwarding information if available
if self.port_forwardings:
result["port_forwardings"] = [{
"id": pf.id,
"internal_port": pf.internal_port,
"external_port": pf.external_port,
"protocol": pf.protocol,
"ip_address": pf.ip_address
} for pf in self.port_forwardings]
return result
class PortForwarding(BaseModel):
__tablename__ = "port_forwarding"
pod_id = Column(String(36), ForeignKey("container_pods.id"), nullable=False)
internal_port = Column(Integer, nullable=False)
external_port = Column(Integer, nullable=False)
protocol = Column(String(10), default="tcp", nullable=False)
ip_address = Column(String(25), nullable=False)
dns_record_id = Column(String(36), ForeignKey("cloudflare_dns_records.id"), nullable=True)
workload_id = Column(String(36), ForeignKey("workloads.id"), nullable=True)
pod = relationship("ContainerPod", backref="port_forwardings")
dns_record = relationship("CloudflareDNSRecord", backref="port_forwardings")
workload=relationship("Workload", backref="port_forwardings")
def to_json(self):
data = super().to_json()
if self.dns_record:
data["dns_record"] = {
"id": self.dns_record.id,
"hostname": self.dns_record.hostname,
"record_type": self.dns_record.record_type
}
return data
class WorkloadRequest(db.Model):
"""
Persisted intent for a new workload build.
Status lifecycle:
• pending-scheduling – request accepted, waiting on background job
• in-progress – Celery worker has started
• completed – workload built successfully
• failed – unrecoverable error during build
"""
__tablename__ = "workload_requests"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
vdc_id = Column(String(36), ForeignKey("vdcs.id"), nullable=False)
payload = db.Column(db.JSON, nullable=False)
status = db.Column(db.String(32), default="pending-scheduling")
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
workload_type = db.Column(db.String(32))
vdc = relationship("VirtualDataCenter")