Added workload id to portforward object Updated streamlit to show workload name
904 lines
36 KiB
Python
904 lines
36 KiB
Python
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")
|