Files
3cloud-backend/app/controller/api/workload_container_routes.py
T

797 lines
33 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.
#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
)