freeleaps-service-hub/apps/devops/app/routes/deployment/service.py

264 lines
9.4 KiB
Python

import uuid
from collections import defaultdict
from datetime import datetime, timedelta
from typing import List
import httpx
import requests
from fastapi import HTTPException, Depends
from app.common.config.site_settings import site_settings
from app.common.models import Deployment, DevOpsReconcileRequest, DevOpsReconcileOperationType
from app.common.models.code_depot.code_depot import CodeDepotDoc, DepotStatus
from app.common.models.deployment.deployment import InitDeploymentRequest, CheckApplicationLogsRequest, \
CheckApplicationLogsResponse
from loguru import logger
class DeploymentService:
def __init__(self):
pass
async def init_deployment(
self,
request: InitDeploymentRequest,
) -> Deployment:
"""
"""
# TODO validate permission with user_id
# currently skip
code_depot = await self._get_code_depot_by_product_id(request.product_id)
git_url = await self._compose_git_url(code_depot.depot_name)
# retrieve project name
project_name = "TODO"
# retrieve product info, depot name should be the same as product name
product_id = request.product_id
product_name = code_depot.depot_name
deployment = Deployment.model_construct(
deployment_id = str(uuid.uuid4()),
deployment_stage = "init",
deployment_status = "started",
deployment_target_env = request.target_env,
deployment_ttl_hours = request.ttl_hours,
deployment_project_id = "project_id",
deployment_project_name = "project_name",
deployment_product_id = product_id,
deployment_product_name = product_name,
deployment_git_url = git_url,
deployment_git_sha256 = request.sha256,
deployment_reason = request.reason,
deployed_by = request.user_id,
created_at = datetime.now(),
updated_at = datetime.now(),
compute_unit = request.compute_unit,
)
await self._start_deployment(deployment)
res = await Deployment.insert(deployment)
return res
async def get_latest_deployment(
self,
product_id: str,
target_env: str,
) -> Deployment:
time_threshold = datetime.now() - timedelta(hours=168) # 7 days
deployment_records = await Deployment.find(
Deployment.deployment_product_id == product_id,
Deployment.deployment_target_env == target_env,
Deployment.updated_at >= time_threshold
).to_list()
if not deployment_records or len(deployment_records) == 0:
logger.warning(f"No deployment records found for product ID: {product_id} in the last 7 days")
return None
latest_deployment = max(deployment_records, key=lambda d: (d.updated_at, d.created_at))
return latest_deployment
async def check_deployment_status(
self,
product_id: str,
) -> List[Deployment]:
"""
Check the deployment status of the application, only check past 48 hours
"""
# TODO implement this function
time_threshold = datetime.now() - timedelta(hours=48)
deployment_records = await Deployment.find(
Deployment.deployment_product_id == product_id,
Deployment.created_at >= time_threshold
).to_list()
grouped = defaultdict(list)
for deployment in deployment_records:
grouped[deployment.deployment_id].append(deployment)
for deployment_list in grouped.values():
deployment_list.sort(key=lambda d: (d.created_at, d.updated_at))
latest_deployments = [deployments[-1] for deployments in grouped.values()]
return latest_deployments
async def update_deployment_status(
self,
deployment: Deployment
) -> Deployment:
latest_record = await Deployment.find_one(
Deployment.deployment_id == deployment.deployment_id,
sort=[("created_at", -1)]
)
if not latest_record:
raise HTTPException(status_code=404, detail="No record found, please initiate deployment first")
# TODO add more sanity check logic here
# if updating the same stage, just update the status and timestamp
# else, create a new record with the same deployment_id
res = None
if deployment.deployment_stage == latest_record.deployment_stage:
# update existing record
latest_record.deployment_status = deployment.deployment_status
latest_record.updated_at = deployment.updated_at or datetime.now()
res = await latest_record.save()
else:
# create new record
deployment.deployment_id = latest_record.deployment_id
deployment.created_at = datetime.now()
deployment.updated_at = datetime.now()
res = await deployment.insert()
return res
async def _get_code_depot_by_product_id(
self,
product_id: str,
) -> CodeDepotDoc:
"""
Retrieve code depot by product id
"""
code_depot = await CodeDepotDoc.find_one(CodeDepotDoc.product_id == product_id)
if not code_depot:
raise HTTPException(status_code=404,
detail="Code depot not found for the given product id, "
"please initialize the product first"
)
return code_depot
async def _compose_git_url(
self,
code_depot_name: str,
gitea_base_url: str = site_settings.BASE_GITEA_URL
) -> str:
"""
Retrieve git url by product id
"""
return f"{gitea_base_url}/prodcuts/{code_depot_name.lower()}.git"
async def _start_deployment(
self,
deployment: Deployment,
reconsile_base_url: str = site_settings.BASE_RECONCILE_URL,
) -> bool:
"""
Start the deployment
Return true atm, modify calling reconcile service later
"""
# construct request body
request = DevOpsReconcileRequest(
operation=DevOpsReconcileOperationType.START,
id=deployment.deployment_id,
devops_proj_id=deployment.deployment_product_id,
triggered_user_id=deployment.deployed_by,
causes=deployment.deployment_reason,
target_env=deployment.deployment_target_env,
ttl_control=deployment.deployment_ttl_hours > 0,
ttl=10800 if deployment.deployment_ttl_hours < 0 else deployment.deployment_ttl_hours * 60 * 60,
commit_sha256=deployment.deployment_git_sha256,
compute_unit=deployment.compute_unit
)
# send request to reoncile service
async with httpx.AsyncClient() as client:
response = await client.post(
f"{reconsile_base_url}/api/devops/reconcile",
json=request.model_dump()
)
if response.status_code != 200:
raise HTTPException(status_code=response.status_code, detail=response.text)
return True
async def check_application_logs(
self,
request: CheckApplicationLogsRequest,
loki_url: str = site_settings.BASE_LOKI_URL,
) -> CheckApplicationLogsResponse:
# Convert to nanoseconds since epoch
start_ns = int(request.start_time.timestamp() * 1e9)
end_ns = int(request.end_time.timestamp() * 1e9)
# TODO: convert product_id to application name if needed
base_query = f'{{application="{request.product_id}", environment="{request.target_env}"}}'
log_level = '|'.join(request.log_level) if request.log_level else ''
loki_query = f'{base_query} |~ "{log_level}"'
params = {
"query": loki_query,
"limit": request.limit,
"start": start_ns,
"end": end_ns,
}
url = f"{loki_url}/loki/api/v1/query_range"
response = requests.get(url, params=params)
if response.status_code != 200:
raise Exception(f"Query failed: {response.status_code} - {response.text}")
data = response.json()
streams = data.get("data", {}).get("result", [])
logs = []
for stream in streams:
for ts, log in stream.get("values", []):
timestamp = datetime.fromtimestamp(int(ts) / 1e9)
logs.append(f"[{timestamp}] {log.strip()}")
return CheckApplicationLogsResponse(
product_id=request.product_id,
target_env=request.target_env,
user_id=request.user_id,
log_level=request.log_level,
start_time=request.start_time,
end_time=request.end_time,
limit=request.limit,
logs=logs
)
# TODO: dummy test code, remove later
async def create_dummy_code_depot(
self,
) -> CodeDepotDoc:
"""
Create a dummy code depot for testing purposes.
"""
depot_name = f"dummy-depot-{uuid.uuid4()}"
code_depot = CodeDepotDoc(
depot_name=depot_name,
product_id="dummy-product-id",
depot_status=DepotStatus.CREATED
)
return await CodeDepotDoc.insert_one(code_depot)
deployment_service = DeploymentService()
def get_deployment_service() -> DeploymentService:
return deployment_service