use crate::api::AppState;
use crate::db;
use crate::docker::{self, DockerClient, networks};
use crate::event_bus::EventBus;
use crate::secrets::SecretsManager;
use anyhow::Result;
use chrono::Utc;
use shared::{Event, Job, JobRun};
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::OwnedSemaphorePermit;
use tracing::{error, info, warn};
use crate::deploy::queue::DeployQueue;
pub struct JobRunner {
pub db: Arc<db::Db>,
pub docker: Arc<DockerClient>,
pub bus: Arc<EventBus>,
pub secrets: Arc<SecretsManager>,
pub db_path: PathBuf,
pub registry_internal_token: Option<Arc<str>>,
}
pub async fn spawn(state: &AppState, job: Job) -> Result<JobRun> {
let run = db::job_run::create(&state.db, &job.id).await?;
let runner = Arc::new(JobRunner {
db: state.db.clone(),
docker: state.docker.clone(),
bus: state.bus.clone(),
secrets: state.secrets.clone(),
db_path: state.db_path.clone(),
registry_internal_token: state.registry_internal_token.clone(),
});
let run_id = run.id.clone();
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
if let Ok(mut map) = state.active_jobs.lock() {
map.insert(run_id.clone(), cancel_tx);
}
let active_jobs = state.active_jobs.clone();
runner.reschedule(&job).await;
let queue = state.deploy_queue.clone();
tokio::spawn(async move {
let vaga = {
let (r, j, id) = (runner.clone(), job.clone(), run_id.clone());
esperar_vaga(&queue, cancel_rx.clone(), async move {
r.linha(
&j.id,
&id,
"==> Aguardando a vez: há um deploy ou outro job em execução (ou a fila está pausada)",
)
.await;
})
.await
};
match vaga {
Some(vaga) => {
runner
.linha(&job.id, &run_id, "==> Sua vez: iniciando")
.await;
runner.run(&job, run_id.clone(), Some(cancel_rx)).await;
drop(vaga);
}
None => runner.cancelar_antes_de_comecar(&job, &run_id).await,
}
if let Ok(mut map) = active_jobs.lock() {
map.remove(&run_id);
}
});
Ok(run)
}
async fn esperar_vaga<F: Future<Output = ()>>(
queue: &DeployQueue,
mut cancel_rx: tokio::sync::watch::Receiver<bool>,
avisar_espera: F,
) -> Option<OwnedSemaphorePermit> {
if let Some(vaga) = queue.try_job_slot_now() {
return Some(vaga);
}
avisar_espera.await;
let cancelado = async {
if cancel_rx.wait_for(|c| *c).await.is_err() {
std::future::pending::<()>().await;
}
};
tokio::select! {
vaga = queue.acquire_job_slot() => Some(vaga),
_ = cancelado => None,
}
}
impl JobRunner {
pub async fn run(
&self,
job: &Job,
run_id: String,
cancel_rx: Option<tokio::sync::watch::Receiver<bool>>,
) {
info!(job_id = %job.id, run_id = %run_id, "job_runner: iniciando execução");
self.bus.publish(Event::JobRunStateChanged {
job_id: job.id.clone(),
job_run_id: run_id.clone(),
running: true,
success: None,
});
let result = self.run_inner_mirrored(job, &run_id, None, cancel_rx).await;
let success = match &result {
Ok(exit_code) => *exit_code == 0,
Err(e) => {
error!(job_id = %job.id, run_id = %run_id, error = %e, "job_runner: falha ao executar job");
let _ = db::job_run::finish(&self.db, &run_id, -1).await;
false
}
};
self.bus.publish(Event::JobRunStateChanged {
job_id: job.id.clone(),
job_run_id: run_id.clone(),
running: false,
success: Some(success),
});
self.reschedule(job).await;
}
pub(crate) async fn run_inner_mirrored(
&self,
job: &Job,
run_id: &str,
mirror_deployment: Option<(String, String)>,
cancel_rx: Option<tokio::sync::watch::Receiver<bool>>,
) -> Result<i32> {
let project_net = crate::db::projects::network_name(&self.db, &job.project_id).await?;
let network_name =
networks::ensure_project_network(&self.docker.inner, &project_net).await?;
let env_vars =
crate::deploy::env_resolve::resolve_job(&self.db, &self.secrets, job).await?;
let project_name = run_id.to_lowercase();
let build_dir = self.db_path.join("jobs").join(run_id);
let build_source = match &job.git_source {
Some(git) => {
let (token, username) = crate::deploy::git::resolve_clone_credentials(
&self.db,
&self.secrets,
git.provider_id.as_deref(),
git.credentials.as_deref(),
git.username.as_deref(),
&job.project_id,
)
.await;
let bus = self.bus.clone();
let db_handle = self.db.clone();
let jid = job.id.clone();
let rid = run_id.to_string();
crate::deploy::git::clone(
crate::deploy::git::CloneOptions {
url: &git.url,
branch: &git.branch,
token: token.as_deref(),
username: username.as_deref(),
dir: &build_dir,
},
move |p| {
let ts = Utc::now();
bus.publish(Event::JobLogLine {
job_run_id: rid.clone(),
job_id: jid.clone(),
line: p.description.clone(),
timestamp: ts,
stream: shared::protocol::LogStream::Stdout,
});
let db_handle = db_handle.clone();
let rid = rid.clone();
let line = p.description;
tokio::spawn(async move {
let _ = db::job_log::append(
&db_handle,
&rid,
&shared::protocol::LogStream::Stdout,
&line,
ts,
)
.await;
});
},
)
.await?;
docker::compose::JobBuildSource::Git {
compose_rel_path: &git.compose_path,
}
}
None => docker::compose::JobBuildSource::Compose(&job.compose),
};
let already_cancelled = cancel_rx.as_ref().map(|rx| *rx.borrow()).unwrap_or(false);
let exit_code = if already_cancelled {
docker::compose::CANCELLED_EXIT_CODE
} else {
docker::compose::run_once(
build_source,
&project_name,
&network_name,
&job.main_service,
&job.id,
run_id,
&self.bus,
&self.db,
&env_vars,
&build_dir,
self.registry_internal_token.clone(),
mirror_deployment,
cancel_rx,
)
.await?
};
db::job_run::finish(&self.db, run_id, exit_code).await?;
if let Err(e) = tokio::fs::remove_dir_all(&build_dir).await {
warn!(run_id = %run_id, error = %e, "job_runner: falha ao limpar build_dir (best-effort)");
}
Ok(exit_code)
}
async fn linha(&self, job_id: &str, run_id: &str, texto: &str) {
let ts = Utc::now();
self.bus.publish(Event::JobLogLine {
job_run_id: run_id.to_string(),
job_id: job_id.to_string(),
line: texto.to_string(),
timestamp: ts,
stream: shared::protocol::LogStream::Stdout,
});
let _ = db::job_log::append(
&self.db,
run_id,
&shared::protocol::LogStream::Stdout,
texto,
ts,
)
.await;
}
async fn cancelar_antes_de_comecar(&self, job: &Job, run_id: &str) {
self.linha(&job.id, run_id, "==> Cancelado antes de começar a executar")
.await;
let _ = db::job_run::finish(&self.db, run_id, docker::compose::CANCELLED_EXIT_CODE).await;
self.bus.publish(Event::JobRunStateChanged {
job_id: job.id.clone(),
job_run_id: run_id.to_string(),
running: false,
success: Some(false),
});
}
async fn reschedule(&self, job: &Job) {
let now = Utc::now();
let next_run_at = job.recurrence.map(|r| r.next_after(now));
if let Err(e) = db::job::mark_fired(&self.db, &job.id, now, next_run_at).await {
error!(job_id = %job.id, error = %e, "job_runner: falha ao reagendar");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::time::{sleep, timeout};
const ESPERA: Duration = Duration::from_millis(60);
const LIMITE: Duration = Duration::from_secs(2);
fn sem_cancelar() -> tokio::sync::watch::Receiver<bool> {
tokio::sync::watch::channel(false).1
}
#[tokio::test]
async fn vaga_livre_entra_na_hora_sem_avisar() {
let q = DeployQueue::new();
let avisou = Arc::new(AtomicBool::new(false));
let a = avisou.clone();
let vaga = esperar_vaga(&q, sem_cancelar(), async move {
a.store(true, Ordering::SeqCst)
})
.await;
assert!(vaga.is_some());
assert!(!avisou.load(Ordering::SeqCst), "não esperou, não avisa");
}
#[tokio::test]
async fn vaga_ocupada_avisa_uma_vez_e_entra_quando_liberar() {
let q = DeployQueue::new();
let ocupada = q.acquire_job_slot().await; let avisos = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (q2, a) = (q.clone(), avisos.clone());
let espera = tokio::spawn(async move {
esperar_vaga(&q2, sem_cancelar(), async move {
a.fetch_add(1, Ordering::SeqCst);
})
.await
});
sleep(ESPERA).await;
assert!(!espera.is_finished(), "espera enquanto a vaga está ocupada");
assert_eq!(avisos.load(Ordering::SeqCst), 1, "avisou uma vez");
drop(ocupada);
let vaga = timeout(LIMITE, espera).await.unwrap().unwrap();
assert!(vaga.is_some());
assert_eq!(avisos.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn cancelar_enquanto_espera_devolve_none_e_nao_prende_a_vaga() {
let q = DeployQueue::new();
let ocupada = q.acquire_job_slot().await;
let (tx, rx) = tokio::sync::watch::channel(false);
let q2 = q.clone();
let espera = tokio::spawn(async move { esperar_vaga(&q2, rx, async {}).await });
sleep(ESPERA).await;
tx.send(true).unwrap();
let vaga = timeout(LIMITE, espera).await.unwrap().unwrap();
assert!(vaga.is_none(), "cancelado antes de começar");
drop(ocupada);
assert!(
q.try_job_slot_now().is_some(),
"a espera cancelada não deixou a vaga presa"
);
}
#[tokio::test]
async fn fila_pausada_segura_o_job_ate_retomar() {
let q = DeployQueue::new();
q.set_paused(true);
let q2 = q.clone();
let espera = tokio::spawn(async move { esperar_vaga(&q2, sem_cancelar(), async {}).await });
sleep(ESPERA).await;
assert!(
!espera.is_finished(),
"pausada: o job espera, mesmo com a vaga livre"
);
q.set_paused(false);
assert!(timeout(LIMITE, espera).await.unwrap().unwrap().is_some());
}
}