rustploy 0.1.37

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! `Command::ServiceUpdate`: grava um ServiceSpec novo (porta externa, TLS dos
//! domínios, firewall) sem redeployar.

use crate::features::services::ServicesContext;
use shared::{Response as RpResponse, ServiceSpec};
use tracing::warn;

pub async fn handle(state: ServicesContext, id: String, mut spec: ServiceSpec) -> RpResponse {
    if let Some(reason) = crate::platform::env_validation::validate(&spec.env_vars) {
        return RpResponse::err("InvalidInput", reason);
    }
    if let Err(resp) =
        crate::features::services::check_depends_on(&state.db, &spec, Some(&id)).await
    {
        return resp;
    }
    // Porta externa: sentinela 0 → aloca; manual duplicada → erro.
    if let Err(e) =
        crate::features::ingress::external_ports::resolve_host_port(&state.db, &mut spec, Some(&id))
            .await
    {
        return RpResponse::err("PortAllocationError", e);
    }
    let old_port = match crate::store::services::get(&state.db, &id).await {
        Ok(Some(s)) => s.spec.host_port,
        _ => None,
    };

    for route in spec.domain_routes().into_iter().filter(|r| r.tls) {
        let tls = state.tls.clone();
        let domain = route.domain.clone();
        tokio::spawn(async move {
            if let Err(e) = tls.ensure_cert(&domain).await {
                warn!(domain = %domain, error = %e, "TLS: falha ao provisionar certificado ao atualizar serviço");
            }
        });
    }

    match crate::store::services::update_spec(&state.db, &id, spec).await {
        Ok(Some(s)) => {
            sync_firewall(&state, &s.id, old_port, s.spec.host_port).await;
            RpResponse::Service(Box::new(s))
        }
        Ok(None) => RpResponse::err("NotFound", "service not found"),
        Err(e) => RpResponse::err("DatabaseError", e.to_string()),
    }
}

/// Porta mudou/removida → fecha a antiga (se mais ninguém a usa) e o listener
/// do ingress; porta nova/mantida → garante a liberação. Reusado por
/// `manifest_apply` (IaC import), que também atualiza specs fora deste handler.
pub(crate) async fn sync_firewall(
    state: &ServicesContext,
    id: &str,
    old: Option<u16>,
    new: Option<u16>,
) {
    if let Some(old_port) = old.filter(|o| Some(*o) != new) {
        state.ingress.remove_port_route(old_port);
        if !crate::features::ingress::external_ports::port_in_use_by_other(
            &state.db,
            old_port,
            Some(id),
        )
        .await
        {
            crate::features::ingress::firewall::ensure_denied_bg(old_port);
        }
    }
    if let Some(new_port) = new {
        crate::features::ingress::firewall::ensure_allowed_bg(new_port);
    }
}