rustploy 0.1.0

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! `Command::ManifestApply`: reconcilia manifestos YAML de projeto (IaC) com o
//! banco, com prune e deploy opcionais.

use crate::api::AppState;
use shared::{
    ActionVerb, ApplyReport, ProjectManifest, ResourceAction, ResourceActionKind,
    Response as RpResponse, ServiceSource,
};
use std::collections::BTreeMap;
use tracing::{debug, info, warn};

/// Reconcilia uma lista de manifestos YAML de projeto contra o banco.
///
/// Casamento por **nome**. Por padrão é **aditivo** (cria/atualiza); com `prune`
/// também remove serviços ausentes do manifesto. Com `deploy`, dispara o deploy
/// dos serviços criados/alterados depois de sincronizar.
pub async fn handle(
    state: AppState,
    manifests: Vec<String>,
    prune: bool,
    deploy: bool,
) -> RpResponse {
    info!(
        count = manifests.len(),
        prune, deploy, "manifest_apply: reconciliando manifestos"
    );
    let mut report = ApplyReport::default();
    // (service_id, "projeto/serviço") dos serviços criados/alterados.
    let mut changed: Vec<(String, String)> = Vec::new();

    // Nome -> ID interno, para resolver `source.git.provider` de cada manifesto.
    let provider_ids: BTreeMap<String, String> =
        match crate::db::git_providers::list(&state.db).await {
            Ok(list) => list.into_iter().map(|p| (p.name, p.id)).collect(),
            Err(e) => {
                tracing::error!(error = %e, "manifest_apply: erro ao listar git providers");
                return RpResponse::err("DatabaseError", e.to_string());
            }
        };
    info!(
        ?provider_ids,
        "manifest_apply: git providers disponíveis pra resolução (nome -> id)"
    );

    for yaml in manifests {
        let manifest: ProjectManifest = match serde_yaml::from_str(&yaml) {
            Ok(m) => m,
            Err(e) => return RpResponse::err("InvalidManifest", e.to_string()),
        };
        if let Err(resp) = apply_one(
            &state,
            manifest,
            prune,
            &provider_ids,
            &mut report,
            &mut changed,
        )
        .await
        {
            return resp;
        }
    }

    // Deploy dos serviços alterados — feito por último, após todas as escritas.
    if deploy {
        for (service_id, label) in &changed {
            match super::deploy_start::handle(state.clone(), service_id.clone()).await {
                RpResponse::Err { code, message } => {
                    warn!(%label, %code, %message, "manifest_apply: deploy falhou ao iniciar");
                }
                _ => report.deployed.push(label.clone()),
            }
        }
    }

    info!(
        actions = report.actions.len(),
        deployed = report.deployed.len(),
        "manifest_apply: concluído"
    );
    RpResponse::ManifestReport(report)
}

/// Aplica um manifesto de projeto: resolve o projeto pelo nome, atualiza env
/// vars só se o manifesto declara alguma, e reconcilia os serviços por nome
/// (com prune e deploy opcionais).
async fn apply_one(
    state: &AppState,
    manifest: ProjectManifest,
    prune: bool,
    provider_ids: &BTreeMap<String, String>,
    report: &mut ApplyReport,
    changed: &mut Vec<(String, String)>,
) -> Result<(), RpResponse> {
    let (name, description, env_vars) = manifest.project_fields();
    if name.trim().is_empty() {
        return Err(RpResponse::err(
            "InvalidManifest",
            "projeto sem nome no manifesto",
        ));
    }

    // 1. Resolver projeto por nome.
    let existing = crate::db::projects::list(&state.db)
        .await
        .map_err(db_err)?
        .into_iter()
        .find(|p| p.name == name);

    let (project_id, project_action) = match existing {
        Some(p) if p.description == description => {
            // Só actualiza env vars se o manifesto declara pelo menos uma.
            // Lista vazia significa "manifesto sem env vars" (ex: export sem secrets),
            // não "apagar tudo" — preservar as existentes nesse caso.
            if !env_vars.is_empty() && p.env_vars != env_vars {
                crate::db::projects::update(&state.db, &p.id, name.clone(), description)
                    .await
                    .map_err(db_err)?;
                crate::db::projects::update_env_vars(&state.db, &p.id, env_vars, Vec::new())
                    .await
                    .map_err(db_err)?;
                (p.id, ActionVerb::Updated)
            } else {
                (p.id, ActionVerb::Unchanged)
            }
        }
        Some(p) => {
            crate::db::projects::update(&state.db, &p.id, name.clone(), description)
                .await
                .map_err(db_err)?;
            if !env_vars.is_empty() {
                crate::db::projects::update_env_vars(&state.db, &p.id, env_vars, Vec::new())
                    .await
                    .map_err(db_err)?;
            }
            (p.id, ActionVerb::Updated)
        }
        None => {
            let created = crate::db::projects::create(&state.db, name.clone(), description)
                .await
                .map_err(db_err)?;
            if !env_vars.is_empty() {
                crate::db::projects::update_env_vars(&state.db, &created.id, env_vars, Vec::new())
                    .await
                    .map_err(db_err)?;
            }
            (created.id, ActionVerb::Created)
        }
    };

    report.actions.push(ResourceAction {
        kind: ResourceActionKind::Project,
        name: name.clone(),
        action: project_action,
    });

    // 2. Reconciliar serviços por nome dentro do projeto.
    let existing_services = crate::db::services::list(&state.db, &project_id)
        .await
        .map_err(db_err)?;

    let mut specs = manifest.service_specs(&project_id, provider_ids);
    // Sentinela `host_port: 0` no manifesto: reusa a porta já alocada do
    // serviço homônimo (estabilidade entre applies) ou aloca uma nova — nunca
    // deixa o 0 cru chegar ao DB/executor.
    for spec in &mut specs {
        if spec.host_port != Some(crate::ports::AUTO_PORT) {
            continue;
        }
        let existing_port = existing_services
            .iter()
            .find(|s| s.spec.name == spec.name)
            .and_then(|s| s.spec.host_port)
            .filter(|p| *p != crate::ports::AUTO_PORT);
        match existing_port {
            Some(p) => spec.host_port = Some(p),
            None => {
                crate::ports::resolve_host_port(&state.db, spec, None)
                    .await
                    .map_err(|e| RpResponse::err("PortAllocationError", e))?;
                if let Some(p) = spec.host_port {
                    crate::firewall::ensure_allowed_bg(p);
                }
            }
        }
    }
    for spec in &specs {
        let svc_name = spec.name.clone();
        let label = format!("{name}/{svc_name}");
        if let ServiceSource::Git(g) = &spec.source {
            info!(
                service = %label,
                provider_id = ?g.provider_id,
                url = %g.url,
                "manifest_apply: fonte git resolvida a partir do manifesto"
            );
        }
        let existing_match = existing_services.iter().find(|s| s.spec.name == svc_name);
        if let Some(s) = existing_match {
            if let (ServiceSource::Git(old_g), ServiceSource::Git(new_g)) =
                (&s.spec.source, &spec.source)
            {
                if old_g.provider_id != new_g.provider_id {
                    warn!(
                        service = %label,
                        old_provider_id = ?old_g.provider_id,
                        new_provider_id = ?new_g.provider_id,
                        "manifest_apply: provider_id do serviço git VAI MUDAR nesta atualização"
                    );
                }
            }
        }
        let (action, changed_id) = match existing_match {
            Some(s) if &s.spec == spec => {
                debug!(service = %label, "manifest_apply: spec idêntico ao existente, Unchanged");
                (ActionVerb::Unchanged, None)
            }
            Some(s) => {
                // Se o manifesto não declara env vars, preservar as existentes para não
                // apagar vars que foram definidas manualmente após o export.
                let mut effective_spec = spec.clone();
                if effective_spec.env_vars.is_empty() && !s.spec.env_vars.is_empty() {
                    effective_spec.env_vars = s.spec.env_vars.clone();
                }
                crate::db::services::update_spec(&state.db, &s.id, effective_spec.clone())
                    .await
                    .map_err(db_err)?;
                super::service_update::sync_firewall(
                    state,
                    &s.id,
                    s.spec.host_port,
                    effective_spec.host_port,
                )
                .await;
                debug!(service = %label, service_id = %s.id, "manifest_apply: serviço atualizado (Updated)");
                (ActionVerb::Updated, Some(s.id.clone()))
            }
            None => {
                let created = crate::db::services::create(&state.db, spec.clone())
                    .await
                    .map_err(db_err)?;
                debug!(service = %label, service_id = %created.id, "manifest_apply: serviço criado (Created)");
                (ActionVerb::Created, Some(created.id))
            }
        };
        if let Some(id) = changed_id {
            changed.push((id, label.clone()));
        }
        report.actions.push(ResourceAction {
            kind: ResourceActionKind::Service,
            name: label,
            action,
        });
    }

    // 3. Prune: remover serviços do projeto que não constam no manifesto.
    if prune {
        let wanted: std::collections::HashSet<&str> =
            specs.iter().map(|s| s.name.as_str()).collect();
        for s in &existing_services {
            if wanted.contains(s.spec.name.as_str()) {
                continue;
            }
            let label = format!("{name}/{}", s.spec.name);
            // Reusa a mesma semântica de remoção da TUI (remove rota + DB).
            match super::service_delete::handle(state.clone(), s.id.clone()).await {
                RpResponse::Err { code, message } => {
                    warn!(%label, %code, %message, "manifest_apply: prune falhou");
                }
                _ => {
                    info!(%label, "manifest_apply: serviço removido (prune)");
                    report.actions.push(ResourceAction {
                        kind: ResourceActionKind::Service,
                        name: label,
                        action: ActionVerb::Deleted,
                    });
                }
            }
        }
    }

    Ok(())
}

fn db_err(e: anyhow::Error) -> RpResponse {
    tracing::error!(error = %e, "manifest_apply: erro de banco");
    RpResponse::err("DatabaseError", e.to_string())
}