rustploy 0.1.6

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! Parar um serviço (`Command::ServiceStop`): para um serviço (todas as réplicas live, ou a stack
//! Compose) e marca o deployment como Stopped.

use crate::features::deploy::DeployContext;
use crate::{engine, engine::containers};
use chrono::Utc;
use shared::{
    DeployState, EnvVarValue, Event, Response as RpResponse, ServiceSource, ServiceStatus,
};

pub async fn handle(state: DeployContext, service_id: String) -> RpResponse {
    let previous = match crate::store::services::get(&state.db, &service_id).await {
        Ok(Some(s)) => Some((s.status.clone(), s.live_container_id.clone())),
        _ => None,
    };
    let response = stop(&state, &service_id).await;
    // Falha no meio do stop não pode deixar o serviço preso em `Stopping`:
    // devolve o status anterior (a GUI volta a mostrar os botões, dá para repetir).
    if let (RpResponse::Err { .. }, Some((status, container_id))) = (&response, previous)
        && status != ServiceStatus::Stopping
        && crate::store::services::update_status(
            &state.db,
            &service_id,
            &status,
            container_id.as_deref(),
        )
        .await
        .is_ok()
    {
        state
            .bus
            .publish(Event::ServiceStatusChanged { service_id, status });
    }
    response
}

async fn stop(state: &DeployContext, service_id: &str) -> RpResponse {
    let state = state.clone();
    let service_id = service_id.to_string();
    let svc = match crate::store::services::get(&state.db, &service_id).await {
        Ok(Some(s)) => s,
        Ok(None) => return RpResponse::err("NotFound", "serviço não encontrado"),
        Err(e) => return RpResponse::err("DatabaseError", e.to_string()),
    };

    // Marca o serviço como Stopping antes de qualquer operação bloqueante.
    if let Err(e) = crate::store::services::update_status(
        &state.db,
        &service_id,
        &ServiceStatus::Stopping,
        svc.live_container_id.as_deref(),
    )
    .await
    {
        return RpResponse::err("DatabaseError", e.to_string());
    }
    state.bus.publish(Event::ServiceStatusChanged {
        service_id: service_id.clone(),
        status: ServiceStatus::Stopping,
    });

    // Compose services are stopped via compose_down.
    if let ServiceSource::Compose(compose) = &svc.spec.source {
        let pid = &svc.spec.project_id;
        let network_name = match crate::store::projects::network_name(&state.db, pid).await {
            Ok(n) => n,
            Err(e) => return RpResponse::err("DatabaseError", e.to_string()),
        };

        // Build env map: project vars as base, service vars override (mirrors resolve_env in executor.rs).
        let mut env_map: std::collections::HashMap<String, String> =
            std::collections::HashMap::new();

        if let Ok(Some(project)) = crate::store::projects::get(&state.db, pid).await {
            for ev in &project.env_vars {
                let value = match &ev.value {
                    EnvVarValue::Plain(v) => v.clone(),
                    EnvVarValue::Secret(name) => {
                        state.secrets.get_raw(pid, name).await.unwrap_or_default()
                    }
                };
                env_map.insert(ev.key.clone(), value);
            }
        }

        for ev in &svc.spec.env_vars {
            let value = match &ev.value {
                EnvVarValue::Plain(v) => v.clone(),
                EnvVarValue::Secret(name) => {
                    state.secrets.get_raw(pid, name).await.unwrap_or_default()
                }
            };
            env_map.insert(ev.key.clone(), value);
        }

        let env_vars: Vec<(String, String)> = env_map.into_iter().collect();
        return stop_compose(
            &state,
            &service_id,
            &svc.compose_project_name(),
            &compose.content,
            &network_name,
            &env_vars,
        )
        .await;
    }

    // Para todas as instâncias do serviço (suporte a replicas).
    let all_ids = match containers::find_all_by_service_id(&state.docker.inner, &service_id).await {
        Ok(ids) => ids,
        Err(e) => return RpResponse::err("RuntimeError", e.to_string()),
    };

    // Fallback: containers existentes antes da migração de prefixos de ID têm
    // labels antigas (sem prefixo svc_) e não são encontrados via
    // find_all_by_service_id.
    let ids_to_stop: Vec<String> = if !all_ids.is_empty() {
        all_ids
    } else if let Some(cid) = &svc.live_container_id {
        vec![cid.clone()]
    } else {
        // Último recurso: réplicas live pelo label em qualquer formato
        // (`find_live_replicas` aceita o ID sem `svc_`) — nunca pelo nome, que
        // pode ser de um serviço homônimo de outro projeto.
        containers::find_live_replicas(&state.docker.inner, &service_id)
            .await
            .map(|v| v.into_iter().map(|r| r.id).collect())
            .unwrap_or_default()
    };

    if ids_to_stop.is_empty() {
        // Nada rodando: o estado desejado (parado) já está satisfeito. Trata como
        // sucesso idempotente — marca Stopped e retorna Ok — em vez de erro, para
        // que "Parar" e "Parar e remover" funcionem num serviço sem containers
        // ativos (senão o `stop_and_delete_service` do client aborta o delete).
        return finish_stop(&state, &service_id, svc.live_container_id.as_deref()).await;
    }

    for cid in &ids_to_stop {
        if let Err(e) = containers::stop_graceful(&state.docker.inner, cid, 10).await {
            return RpResponse::err("RuntimeError", e.to_string());
        }
    }

    let primary_id = ids_to_stop
        .first()
        .map(|s| s.as_str())
        .or(svc.live_container_id.as_deref());
    finish_stop(&state, &service_id, primary_id).await
}

async fn stop_compose(
    state: &DeployContext,
    service_id: &str,
    project_name: &str,
    content: &str,
    network_name: &str,
    env_vars: &[(String, String)],
) -> RpResponse {
    if let Err(e) = engine::compose::down(content, project_name, network_name, env_vars).await {
        return RpResponse::err("RuntimeError", e.to_string());
    }
    finish_stop(state, service_id, None).await
}

async fn finish_stop(
    state: &DeployContext,
    service_id: &str,
    container_id: Option<&str>,
) -> RpResponse {
    if let Err(e) = crate::store::services::update_status(
        &state.db,
        service_id,
        &ServiceStatus::Stopped,
        container_id,
    )
    .await
    {
        return RpResponse::err("DatabaseError", e.to_string());
    }

    if let Ok(history) = crate::store::deployments::list_for_service(&state.db, service_id, 1).await
        && let Some(dep) = history.into_iter().find(|d| d.state == DeployState::Live)
    {
        let _ = crate::store::deployments::transition(
            &state.db,
            &dep.id,
            &DeployState::Live,
            DeployState::Stopped,
            None,
        )
        .await;
        state.bus.publish(Event::DeployStateChanged {
            deployment_id: dep.id,
            service_id: service_id.to_string(),
            state: DeployState::Stopped,
            timestamp: Utc::now(),
            message: None,
        });
    }

    state.bus.publish(Event::ServiceStatusChanged {
        service_id: service_id.to_string(),
        status: ServiceStatus::Stopped,
    });

    RpResponse::Ok
}