797 lines
33 KiB
Python
797 lines
33 KiB
Python
#app/controller/api/workload_container_routes.py
|
||
import uuid
|
||
from flask import json, request
|
||
from app import app, db, logger
|
||
from app.models.models import Workload, WorkloadHostPortMapping, VirtualDataCenter, ContainerPod, CloudflareDNSRecord, CloudflareTunnel, WorkloadRequest
|
||
from datetime import datetime
|
||
from app.controller import api_bp
|
||
from sqlalchemy import or_
|
||
from app.utils.standard_responses import api_response
|
||
from app.utils.container_deleted import check_deleted_container
|
||
from app.models.models import AuditEntry
|
||
from app.controller.api.workload_container_pods_routes import send_pod_deletion_update
|
||
from app.utils.auth_utils import get_request_user_id
|
||
from app.utils.create_workload_container import persist_pod_and_containers
|
||
|
||
def validate_payload(payload):
|
||
"""
|
||
Validate the input payload for container deployment.
|
||
Exactly one of 'virtual_data_center' or 'pod' must be provided.
|
||
|
||
Args:
|
||
payload (dict): The input JSON object to validate.
|
||
|
||
Returns:
|
||
dict: A sanitized version of the input payload containing only the expected parameters.
|
||
|
||
Raises:
|
||
ValueError: If the payload fails any validation rule.
|
||
"""
|
||
import uuid
|
||
from app.models.models import Volume, VirtualDataCenter
|
||
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("Input must be a valid JSON object (dict).")
|
||
|
||
sanitized_payload = {}
|
||
|
||
# Enforce exactly one of 'virtual_data_center' or 'pod'
|
||
has_vdc = 'virtual_data_center' in payload
|
||
has_pod = 'pod' in payload
|
||
|
||
if not (has_vdc or has_pod):
|
||
raise ValueError("Either 'virtual_data_center' or 'pod' must be provided.")
|
||
if has_vdc and has_pod:
|
||
raise ValueError("Only one of 'virtual_data_center' or 'pod' can be provided — not both.")
|
||
|
||
# Store VDC ID for volume validation
|
||
vdc_id = None
|
||
|
||
if has_vdc:
|
||
try:
|
||
vdc_id = payload['virtual_data_center']
|
||
uuid.UUID(vdc_id, version=4)
|
||
sanitized_payload['virtual_data_center'] = vdc_id
|
||
|
||
# Verify VDC exists
|
||
vdc = VirtualDataCenter.query.filter_by(id=vdc_id, deleted=False).first()
|
||
if not vdc:
|
||
raise ValueError(f"Virtual Data Center with ID {vdc_id} not found or deleted")
|
||
|
||
except ValueError as e:
|
||
if "not found" in str(e):
|
||
raise e
|
||
raise ValueError("'virtual_data_center' must be a valid UUID.")
|
||
|
||
if has_pod:
|
||
try:
|
||
pod_id = payload['pod']
|
||
uuid.UUID(pod_id, version=4)
|
||
sanitized_payload['pod'] = pod_id
|
||
|
||
# Get VDC ID from pod for volume validation
|
||
from app.models.models import ContainerPod
|
||
pod = ContainerPod.query.filter_by(id=pod_id, deleted=False).first()
|
||
if not pod:
|
||
raise ValueError(f"Pod with ID {pod_id} not found or deleted")
|
||
vdc_id = pod.vdc_id
|
||
|
||
except ValueError as e:
|
||
if "not found" in str(e):
|
||
raise e
|
||
raise ValueError(f"'pod' must be a valid UUID. got {type(payload['pod'])} {payload['pod']}")
|
||
|
||
# Validate container list
|
||
if 'containers' not in payload:
|
||
raise ValueError("'containers' key is missing.")
|
||
if not isinstance(payload['containers'], list) or len(payload['containers']) == 0:
|
||
raise ValueError("'containers' must be a list with at least one container.")
|
||
|
||
sanitized_payload['containers'] = []
|
||
|
||
for container in payload['containers']:
|
||
if not isinstance(container, dict):
|
||
raise ValueError("Each container must be a dictionary.")
|
||
|
||
if 'docker_image' not in container:
|
||
raise ValueError("Container is missing 'docker_image'.")
|
||
if 'container_name' not in container:
|
||
raise ValueError("Container is missing 'container_name'.")
|
||
|
||
sanitized_container = {
|
||
'docker_image': container['docker_image'].strip(),
|
||
'container_name': container['container_name']
|
||
}
|
||
|
||
if 'cpu_shares' in container:
|
||
if not isinstance(container['cpu_shares'], (int, float)) or container['cpu_shares'] <= 0:
|
||
raise ValueError("'cpu_shares' must be a positive number.")
|
||
sanitized_container['cpu_shares'] = container['cpu_shares']
|
||
else:
|
||
#If user does not specify a cpu_shares we set a default of 1
|
||
sanitized_container['cpu_shares']=1
|
||
|
||
if 'mem_limit' in container:
|
||
if not isinstance(container['mem_limit'], (int, float)) or container['mem_limit'] <= 0:
|
||
raise ValueError("'mem_limit' must be a positive number.")
|
||
sanitized_container['mem_limit'] = container['mem_limit']
|
||
else:
|
||
#If user does not specify a mem_limit we set a default of 256MB
|
||
sanitized_container['mem_limit']=128
|
||
|
||
if 'ports' in container:
|
||
if not isinstance(container['ports'], list):
|
||
raise ValueError("'ports' must be a list.")
|
||
sanitized_ports = []
|
||
for port_mapping in container['ports']:
|
||
if not isinstance(port_mapping, dict):
|
||
raise ValueError("Each port mapping must be a dictionary with 'internal' and 'external' keys.")
|
||
if 'internal' not in port_mapping or 'external' not in port_mapping:
|
||
raise ValueError("Each port mapping must have 'internal' and 'external' keys.")
|
||
if not isinstance(port_mapping['internal'], int) or not isinstance(port_mapping['external'], int):
|
||
raise ValueError("'internal' and 'external' ports must be integers.")
|
||
|
||
port_data = {
|
||
'internal': port_mapping['internal'],
|
||
'external': port_mapping['external']
|
||
}
|
||
if 'use_dns' in port_mapping:
|
||
port_data['use_dns'] = bool(port_mapping['use_dns'])
|
||
|
||
sanitized_ports.append(port_data)
|
||
|
||
if sanitized_ports:
|
||
sanitized_container['ports'] = sanitized_ports
|
||
|
||
# Validate storage volumes
|
||
if 'storage' in container:
|
||
if not isinstance(container['storage'], list):
|
||
raise ValueError("'storage' must be a list of volume mappings.")
|
||
|
||
sanitized_storage = []
|
||
for volume_mapping in container['storage']:
|
||
if not isinstance(volume_mapping, dict):
|
||
raise ValueError("Each volume mapping must be a dictionary.")
|
||
|
||
if 'volume_id' not in volume_mapping:
|
||
raise ValueError("Volume mapping is missing 'volume_id'.")
|
||
|
||
if 'mount_point' not in volume_mapping:
|
||
raise ValueError("Volume mapping is missing 'mount_point'.")
|
||
|
||
# Validate volume_id is a valid UUID
|
||
try:
|
||
volume_id = volume_mapping['volume_id']
|
||
uuid.UUID(volume_id, version=4)
|
||
except ValueError:
|
||
raise ValueError(f"'volume_id' must be a valid UUID: {volume_id}")
|
||
|
||
# Validate mount_point is a valid path
|
||
mount_point = volume_mapping['mount_point']
|
||
if not isinstance(mount_point, str) or not mount_point.startswith('/'):
|
||
raise ValueError(f"'mount_point' must be a valid absolute path: {mount_point}")
|
||
|
||
# Check if volume exists and belongs to the correct VDC
|
||
if vdc_id:
|
||
volume = Volume.query.filter_by(id=volume_id, deleted=False).first()
|
||
if not volume:
|
||
raise ValueError(f"Volume with ID {volume_id} not found or deleted")
|
||
|
||
if volume.vdc_id != vdc_id:
|
||
raise ValueError(f"Volume with ID {volume_id} does not belong to the specified VDC")
|
||
|
||
sanitized_volume = {
|
||
'volume_id': volume_id,
|
||
'mount_point': mount_point
|
||
}
|
||
|
||
# Optional read-only flag
|
||
if 'read_only' in volume_mapping:
|
||
sanitized_volume['read_only'] = bool(volume_mapping['read_only'])
|
||
|
||
sanitized_storage.append(sanitized_volume)
|
||
|
||
if sanitized_storage:
|
||
sanitized_container['storage'] = sanitized_storage
|
||
|
||
if 'networks' in container:
|
||
if isinstance(container['networks'], str):
|
||
if container['networks'].strip():
|
||
sanitized_container['networks'] = container['networks'].strip()
|
||
elif isinstance(container['networks'], list):
|
||
sanitized_networks = []
|
||
for net in container['networks']:
|
||
if not isinstance(net, str):
|
||
raise ValueError("Each network name must be a string.")
|
||
if net.strip():
|
||
sanitized_networks.append(net.strip())
|
||
if sanitized_networks:
|
||
sanitized_container['networks'] = sanitized_networks
|
||
elif container['networks'] is None:
|
||
pass
|
||
else:
|
||
raise ValueError("'networks' must be a string or a list of strings.")
|
||
|
||
if 'env' in container:
|
||
if isinstance(container['env'], dict):
|
||
sanitized_env = []
|
||
for key, value in container['env'].items():
|
||
if not isinstance(key, str) or not isinstance(value, str):
|
||
raise ValueError("Environment variable keys and values must be strings.")
|
||
sanitized_env.append(f"{key}={value}")
|
||
sanitized_container['env'] = sanitized_env
|
||
elif isinstance(container['env'], list):
|
||
sanitized_env = []
|
||
for entry in container['env']:
|
||
if not isinstance(entry, str) or '=' not in entry:
|
||
raise ValueError("Each environment variable in the list must be a string in 'KEY=VALUE' format.")
|
||
sanitized_env.append(entry)
|
||
sanitized_container['env'] = sanitized_env
|
||
else:
|
||
raise ValueError("'env' must be a dictionary or a list of 'KEY=VALUE' strings.")
|
||
|
||
# Validate command parameter
|
||
if 'command' in container:
|
||
if not isinstance(container['command'], str):
|
||
raise ValueError("'command' must be a string.")
|
||
sanitized_container['command'] = container['command']
|
||
|
||
# Validate injected_files parameter
|
||
if 'injected_files' in container:
|
||
if not isinstance(container['injected_files'], list):
|
||
raise ValueError("'injected_files' must be a list.")
|
||
sanitized_injected_files = []
|
||
for injected_file in container['injected_files']:
|
||
if not isinstance(injected_file, dict):
|
||
raise ValueError("Each injected file must be a dictionary.")
|
||
if 'filename' not in injected_file:
|
||
raise ValueError("Injected file is missing 'filename'.")
|
||
if 'content' not in injected_file:
|
||
raise ValueError("Injected file is missing 'content'.")
|
||
if 'permissions' not in injected_file:
|
||
raise ValueError("Injected file is missing 'permissions'.")
|
||
|
||
filename = injected_file['filename']
|
||
content = injected_file['content']
|
||
permissions = injected_file['permissions']
|
||
|
||
if not isinstance(filename, str) or not filename.startswith('/'):
|
||
raise ValueError(f"'filename' must be a valid absolute path: {filename}")
|
||
|
||
# Sanitize filename to prevent path traversal attacks
|
||
if '..' in filename:
|
||
raise ValueError(f"'filename' must not contain '..' for security: {filename}")
|
||
if '\0' in filename:
|
||
raise ValueError(f"'filename' must not contain null bytes: {filename}")
|
||
# Normalize path and ensure it's still absolute
|
||
import os.path
|
||
normalized = os.path.normpath(filename)
|
||
if not normalized.startswith('/'):
|
||
raise ValueError(f"'filename' must resolve to an absolute path: {filename}")
|
||
if normalized != filename:
|
||
raise ValueError(f"'filename' must be a normalized path: {filename}")
|
||
if not isinstance(content, str):
|
||
raise ValueError("'content' must be a base64-encoded string.")
|
||
# Validate base64 content length (reasonable limits: 1 byte to 10MB when decoded)
|
||
if len(content) < 4: # Minimum base64 length for 1 byte
|
||
raise ValueError("'content' must be at least 4 characters (minimum 1 byte when decoded).")
|
||
if len(content) > 13653334: # Maximum base64 length for ~10MB
|
||
raise ValueError("'content' exceeds maximum allowed length (10MB when decoded).")
|
||
# Validate that content is valid base64
|
||
try:
|
||
import base64
|
||
base64.b64decode(content, validate=True)
|
||
except Exception:
|
||
raise ValueError("'content' must be valid base64-encoded data.")
|
||
if not isinstance(permissions, str) or not permissions.isdigit() or len(permissions) != 4:
|
||
raise ValueError(f"'permissions' must be a 4-digit octal string (e.g., '0644'): {permissions}")
|
||
|
||
sanitized_injected_files.append({
|
||
'filename': filename,
|
||
'content': content,
|
||
'permissions': permissions
|
||
})
|
||
|
||
if sanitized_injected_files:
|
||
logger.debug(f"Added injected_files to container {container['container_name']}: {sanitized_injected_files}")
|
||
sanitized_container['injected_files'] = sanitized_injected_files
|
||
else:
|
||
logger.debug(f"No injected_files for container {container['container_name']}")
|
||
|
||
sanitized_payload['containers'].append(sanitized_container)
|
||
|
||
return sanitized_payload
|
||
|
||
@api_bp.route("/workloads/containers", methods=["POST"])
|
||
def request_container_workload():
|
||
"""
|
||
DB-first route: persist Pod + Containers immediately, then queue allocation.
|
||
Returns request_id, pod_id, and container_ids so the UI can report status instantly.
|
||
"""
|
||
logger.debug(f"Before validation {request.get_json(force=True)}")
|
||
try:
|
||
validated_data = validate_payload(request.get_json(force=True))
|
||
except ValueError as exc:
|
||
logger.warning("Validation error: %s", exc)
|
||
return api_response(success=False, message=str(exc), status=400)
|
||
logger.debug(f"After valdiation{validated_data}")
|
||
# Resolve Virtual Data Center ID from either pod or direct input
|
||
if 'pod' in validated_data:
|
||
container_pod = ContainerPod.query.filter_by(id=validated_data['pod']).first()
|
||
if not container_pod:
|
||
logger.warning(f"Referenced pod ID not found: {validated_data['pod']}")
|
||
return api_response(
|
||
success=False,
|
||
message="Container Pod not found",
|
||
status=404,
|
||
error_type="NOT_FOUND"
|
||
)
|
||
vdc_id = container_pod.vdc_id
|
||
else:
|
||
vdc_id = validated_data['virtual_data_center']
|
||
|
||
request_vdc = VirtualDataCenter.query.filter_by(id=vdc_id).first()
|
||
if request_vdc is None:
|
||
logger.warning(f"Requested a Virtual Data Center that does not exist: {vdc_id}")
|
||
return api_response(
|
||
success=False,
|
||
message="Virtual Data Center not found",
|
||
status=404,
|
||
error_type="NOT_FOUND"
|
||
)
|
||
|
||
# 1) Persist Pod + Containers immediately (no host assigned)
|
||
try:
|
||
logger.debug(f"Writing {validated_data}")
|
||
persist_result = persist_pod_and_containers(validated_data)
|
||
pod_id = persist_result["pod_id"]
|
||
container_ids = persist_result["container_ids"]
|
||
# Ensure the WorkloadRequest payload always contains the pod_id for allocation
|
||
if "pod" not in validated_data:
|
||
validated_data["pod"] = pod_id
|
||
except Exception as exc:
|
||
logger.error("Persist phase failed: %s", exc, exc_info=True)
|
||
return api_response(
|
||
success=False,
|
||
message=f"Failed to persist workloads: {exc}",
|
||
status=500,
|
||
error_type="PERSIST_FAILED"
|
||
)
|
||
|
||
# 2) Create WorkloadRequest linking to the pod
|
||
req = WorkloadRequest(
|
||
vdc_id=vdc_id,
|
||
payload=validated_data,
|
||
status="pending-scheduling",
|
||
workload_type="container"
|
||
)
|
||
db.session.add(req)
|
||
db.session.commit()
|
||
|
||
# 3) Queue allocation/dispatch in Celery
|
||
logger.info("Created workload request %s – enqueuing allocation task", req.id)
|
||
from app.tasks.process_workload_request import process_workload
|
||
process_workload.delay(str(req.id))
|
||
|
||
# Audit
|
||
try:
|
||
user_id = get_request_user_id(request)
|
||
container_count = len(validated_data.get("containers", []))
|
||
scope = f"pod={validated_data.get('pod')}"
|
||
AuditEntry.log_event(
|
||
object=req,
|
||
action="container_workload_requested",
|
||
description=f"{scope} containers={container_count}",
|
||
user_id=user_id
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Audit logging failed for container workload request %s: %s", req.id, exc)
|
||
|
||
return api_response(
|
||
data={
|
||
"request_id": str(req.id),
|
||
"pod_id": pod_id,
|
||
"container_ids": container_ids
|
||
},
|
||
message="Workload persisted and queued for allocation",
|
||
status=202,
|
||
)
|
||
|
||
@api_bp.route('/workloads/containers/status_update/<system_container_id>', methods=['PUT'])
|
||
def update_container_workload(system_container_id):
|
||
data = request.json
|
||
logger.debug(data)
|
||
# Validate new_status
|
||
valid_statuses = ["running", "deleted", "stopped", "dead", "launch_failed", "pulling"]
|
||
new_status = data.get("new_status")
|
||
if new_status not in valid_statuses:
|
||
error_message = f"Invalid status {new_status}. Must be one of: {', '.join(valid_statuses)}"
|
||
logger.error(error_message)
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=400,
|
||
error_type="INVALID_STATUS",
|
||
error_details={"valid_statuses": valid_statuses, "provided_status": new_status}
|
||
)
|
||
|
||
# Validate worker_id as UUIDs
|
||
try:
|
||
worker_id = data.get("worker_id")
|
||
uuid.UUID(str(worker_id))
|
||
except (ValueError, TypeError) as e:
|
||
error_message = "Invalid UUID format for worker_id."
|
||
logger.error(f"{error_message} Error: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=400,
|
||
error_type="INVALID_UUID",
|
||
error_details={"field": "worker_id"}
|
||
)
|
||
|
||
# Validate system_container_id as UUID
|
||
try:
|
||
uuid.UUID(str(system_container_id))
|
||
except (ValueError, TypeError) as e:
|
||
error_message = "Invalid UUID format for system_container_id."
|
||
logger.error(f"{error_message} Error: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=400,
|
||
error_type="INVALID_UUID",
|
||
error_details={"field": "system_container_id"}
|
||
)
|
||
|
||
# Validate timestamp as ISO format
|
||
try:
|
||
datetime.fromisoformat(data.get("timestamp"))
|
||
except (ValueError, TypeError) as e:
|
||
error_message = "Invalid ISO timestamp format."
|
||
logger.error(f"{error_message} Error: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=400,
|
||
error_type="INVALID_TIMESTAMP",
|
||
error_details={"provided_timestamp": data.get("timestamp")}
|
||
)
|
||
|
||
# Fetch the container from the database
|
||
try:
|
||
container: Workload = Workload.query.filter(
|
||
Workload.id == system_container_id,
|
||
or_(Workload.workload_type == "Container", Workload.workload_type == "NSController"),
|
||
# Commenting out the deleted filter because when we need handle containers being
|
||
# rebuilt we must accept that deleted containers can become not deleted again
|
||
# Workload.deleted == False
|
||
).first_or_404()
|
||
|
||
except Exception as e:
|
||
error_message = f"Failed to fetch container with ID {system_container_id}."
|
||
logger.error(f"{error_message} Error: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=404,
|
||
error_type="CONTAINER_NOT_FOUND",
|
||
error_details={"container_id": system_container_id}
|
||
)
|
||
|
||
# Check if the container is actually on the reported worker
|
||
if container.workload_host_id != worker_id:
|
||
error_message = f"Workload host in DB for container ID {system_container_id} is {container.workload_host_id}, but was reported from {worker_id}. Ignoring."
|
||
logger.error(error_message)
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=400,
|
||
error_type="WORKER_MISMATCH",
|
||
error_details={
|
||
"container_id": system_container_id,
|
||
"expected_worker": container.workload_host_id,
|
||
"reported_worker": worker_id
|
||
}
|
||
)
|
||
|
||
# Update the status of the container
|
||
try:
|
||
new_status = data.get('new_status')
|
||
logger.info(f"Updating Container ID: {system_container_id} to new status {new_status}")
|
||
|
||
# Handle pull progress data if status is "pulling"
|
||
if new_status == "pulling" and "pull_progress" in data:
|
||
pull_progress = data["pull_progress"]
|
||
logger.debug(f"Updating pull progress for container {system_container_id}: {pull_progress}")
|
||
|
||
# Set pull start time if not already set
|
||
if 'pull_start_time' not in data["pull_progress"] or data["pull_progress"]['pull_start_time'] is None:
|
||
data["pull_progress"]['pull_start_time'] = datetime.utcnow()
|
||
|
||
# Update pull source if provided
|
||
if "status" in pull_progress:
|
||
if pull_progress["status"] == "completed":
|
||
data["pull_progress"]['pull_source'] = pull_progress.get("source", "remote")
|
||
data["pull_progress"]['pull_end_time'] = datetime.utcnow()
|
||
if data["pull_progress"]['pull_start_time']:
|
||
duration = (data["pull_progress"]['pull_end_time'] - data["pull_progress"]['pull_start_time']).total_seconds()
|
||
data["pull_progress"]['pull_duration_seconds'] = duration
|
||
elif pull_progress["status"] == "failed":
|
||
data["pull_progress"]['pull_status'] = "failed"
|
||
data["pull_progress"]['pull_end_time'] = datetime.utcnow()
|
||
if data["pull_progress"]['pull_start_time']:
|
||
duration = (data["pull_progress"]['pull_end_time'] - data["pull_progress"]['pull_start_time']).total_seconds()
|
||
data["pull_progress"]['pull_duration_seconds'] = duration
|
||
# Update pull progress fields
|
||
container.extended_status=json.dumps(data["pull_progress"])
|
||
|
||
container.set_status(new_status)
|
||
if new_status == "deleted":
|
||
container.soft_delete()
|
||
# If container status is "deleted", check if all containers in the same pod are deleted
|
||
check_deleted_container(container)
|
||
else:
|
||
container.restore()
|
||
|
||
db.session.commit()
|
||
# Audit (route-level context)
|
||
try:
|
||
user_id = get_request_user_id(request)
|
||
AuditEntry.log_event(
|
||
object=container,
|
||
action="container_status_updated",
|
||
description=f"new_status={new_status} worker_id={worker_id} ts={data.get('timestamp')}",
|
||
user_id=user_id
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Audit logging failed for container status update %s: %s", system_container_id, exc)
|
||
|
||
return api_response(
|
||
success=True,
|
||
message="Status updated successfully.",
|
||
data={
|
||
"container_id": system_container_id,
|
||
"new_status": new_status,
|
||
"timestamp": data.get("timestamp")
|
||
}
|
||
)
|
||
except Exception as e:
|
||
error_message = f"Failed to update status for Container ID: {system_container_id}."
|
||
logger.error(f"{error_message} Error: {str(e)}")
|
||
db.session.rollback()
|
||
return api_response(
|
||
success=False,
|
||
message=error_message,
|
||
status=500,
|
||
error_type="DATABASE_ERROR",
|
||
error_details={"error": str(e)}
|
||
)
|
||
|
||
@api_bp.route('/workloads/containers/<workload_id>', methods=['GET'])
|
||
def get_container_workload(workload_id):
|
||
try:
|
||
# Convert workload_id to UUID and ensure it's valid
|
||
uuid.UUID(str(workload_id))
|
||
except ValueError:
|
||
# Return 404 if it's not a valid UUID
|
||
return api_response(
|
||
success=False,
|
||
message="Invalid workload ID format",
|
||
status=404,
|
||
error_type="INVALID_UUID",
|
||
error_details={"workload_id": workload_id}
|
||
)
|
||
|
||
try:
|
||
# Query the workload
|
||
workload = Workload.query.filter(
|
||
Workload.id == workload_id,
|
||
or_(Workload.workload_type == "Container", Workload.workload_type == "NSController"),
|
||
Workload.deleted == False
|
||
).first_or_404()
|
||
|
||
logger.info(f"{workload.workload_type} {workload.deleted}")
|
||
|
||
# Return the JSON representation of the workload
|
||
return api_response(
|
||
success=True,
|
||
data=workload.to_json(),
|
||
message="Container details retrieved successfully"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error retrieving container {workload_id}: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message="Container not found",
|
||
status=404,
|
||
error_type="CONTAINER_NOT_FOUND",
|
||
error_details={"workload_id": workload_id}
|
||
)
|
||
|
||
@api_bp.route('/workloads/containers', methods=['GET'])
|
||
def get_container_workloads():
|
||
"""
|
||
Retrieve a list of container workloads.
|
||
|
||
By default, only non-deleted containers are returned. To include deleted containers,
|
||
pass the query parameter 'include_deleted=true'.
|
||
|
||
Query Parameters:
|
||
include_deleted (str): Set to 'true' to include deleted containers in the response.
|
||
Defaults to 'false'.
|
||
|
||
Returns:
|
||
JSON response with a list of container workload objects.
|
||
"""
|
||
try:
|
||
from sqlalchemy import and_
|
||
filters = [or_(Workload.workload_type == "Container", Workload.workload_type == "NSController")]
|
||
include_deleted = request.args.get('include_deleted', 'false').lower() == 'true'
|
||
if not include_deleted:
|
||
filters.append(Workload.deleted == False)
|
||
workloads = Workload.query.filter(and_(*filters)).all()
|
||
|
||
return api_response(
|
||
success=True,
|
||
data=[workload.to_json() for workload in workloads],
|
||
message="Container workloads retrieved successfully"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error retrieving container workloads: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message="Failed to retrieve container workloads",
|
||
status=500,
|
||
error_type="DATABASE_ERROR",
|
||
error_details={"error": str(e)}
|
||
)
|
||
|
||
@api_bp.route('/workloads/containers/<workload_id>', methods=['DELETE'])
|
||
def delete_container_workload(workload_id):
|
||
"""
|
||
Mark a single container and its associated resources as pending-deleted immediately,
|
||
then queue a Celery task for asynchronous deletion processing.
|
||
"""
|
||
try:
|
||
workload_uuid = workload_id
|
||
except ValueError:
|
||
return api_response(
|
||
success=False,
|
||
message="Invalid workload ID format",
|
||
status=404,
|
||
error_type="INVALID_UUID",
|
||
error_details={"workload_id": workload_id}
|
||
)
|
||
|
||
try:
|
||
_container = Workload.query.filter(
|
||
Workload.id == workload_uuid,
|
||
or_(Workload.workload_type == "Container", Workload.workload_type == "NSController"),
|
||
Workload.deleted == False
|
||
).first_or_404()
|
||
|
||
pod = ContainerPod.query.filter_by(id=_container.pod_id).first()
|
||
if not pod:
|
||
logger.error(f"Container {_container.id} is not part of a pod.")
|
||
return api_response(
|
||
success=False,
|
||
message="Container is not part of a pod",
|
||
status=400,
|
||
error_type="CONTAINER_NOT_IN_POD",
|
||
error_details={"container_id": str(_container.id)}
|
||
)
|
||
|
||
# Mark container as pending-deleted immediately
|
||
_container.set_status("pending-deleted")
|
||
db.session.add(_container)
|
||
|
||
# Mark associated port forwardings as pending-deleted
|
||
from app.models.models import PortForwarding
|
||
port_forwardings = PortForwarding.query.filter_by(workload_id=_container.id, deleted=False).all()
|
||
for pf in port_forwardings:
|
||
pf.status = "pending-deleted"
|
||
pf.soft_delete()
|
||
db.session.add(pf)
|
||
|
||
# Mark associated volume mappings as pending-deleted
|
||
from app.models.models import VolumeWorkloadMapping
|
||
volume_mappings = VolumeWorkloadMapping.query.filter_by(workload_id=_container.id).all()
|
||
for vm in volume_mappings:
|
||
vm.status = "pending-deleted"
|
||
vm.soft_delete()
|
||
db.session.add(vm)
|
||
|
||
# Mark associated WorkloadResourceUsage as pending-deleted
|
||
from app.models.models import WorkloadResourceUsage
|
||
resource_usages = WorkloadResourceUsage.query.filter_by(workload_id=_container.id).all()
|
||
for ru in resource_usages:
|
||
ru.status = "pending-deleted"
|
||
ru.soft_delete()
|
||
db.session.add(ru)
|
||
|
||
db.session.commit()
|
||
|
||
# Queue Celery task for asynchronous deletion processing
|
||
from app.tasks.container_lifecycle import process_container_deletion
|
||
process_container_deletion.delay(str(_container.id))
|
||
|
||
logger.info(f"Marked container {_container.id} and associated resources as pending-deleted")
|
||
# Audit
|
||
try:
|
||
user_id = get_request_user_id(request)
|
||
AuditEntry.log_event(
|
||
object=_container,
|
||
action="container_delete_requested",
|
||
description="Marked container and associated resources as pending-deleted, queued for processing",
|
||
user_id=user_id
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Audit logging failed for container delete request %s: %s", workload_id, exc)
|
||
return api_response(
|
||
success=True,
|
||
message="Container marked for deletion and queued for processing",
|
||
data={"container_id": str(_container.id)}
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Error deleting container {workload_id}: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message="Container not found or error during deletion",
|
||
status=404,
|
||
error_type="CONTAINER_DELETE_ERROR",
|
||
error_details={"error": str(e)}
|
||
)
|
||
|
||
@api_bp.route('/workloads/containers/<workload_id>/lifecycle/<action>', methods=['POST'])
|
||
def container_lifecycle_action(workload_id, action):
|
||
"""
|
||
Perform lifecycle operations (start, stop, restart) on a container.
|
||
|
||
This is a non-blocking API that queues a Celery task.
|
||
"""
|
||
valid_actions = ["start", "stop", "restart"]
|
||
if action not in valid_actions:
|
||
return api_response(
|
||
success=False,
|
||
message=f"Invalid action. Must be one of: {', '.join(valid_actions)}",
|
||
status=400
|
||
)
|
||
|
||
try:
|
||
# Validate container exists and is of the right type
|
||
container = Workload.query.filter(
|
||
Workload.id == workload_id,
|
||
Workload.workload_type.in_(["Container", "NSController"]),
|
||
Workload.deleted == False
|
||
).first_or_404()
|
||
|
||
# Queue the Celery task
|
||
from app.tasks.container_lifecycle import container_lifecycle
|
||
container_lifecycle.delay(str(container.id), action)
|
||
|
||
# Audit
|
||
try:
|
||
user_id = get_request_user_id(request)
|
||
AuditEntry.log_event(
|
||
object=container,
|
||
action=f"container_{action}_requested",
|
||
description=f"Lifecycle '{action}' requested",
|
||
user_id=user_id
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Audit logging failed for container lifecycle %s on %s: %s", action, workload_id, exc)
|
||
return api_response(
|
||
data={"container_id": str(container.id)},
|
||
message=f"Container {action} operation queued",
|
||
status=202
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error queuing container {action} operation: {str(e)}")
|
||
return api_response(
|
||
success=False,
|
||
message=f"Failed to queue container {action} operation",
|
||
status=500
|
||
)
|