use std::time::{Duration, Instant};
use serde_json::{Value, json};
use shared::Command;
use crate::api::agent::redigir::{redigir_resposta, restaurar_envs, sobrou_mascara};
use crate::api::dispatch::dispatch;
use crate::bootstrap::state::AppState;
const POLL_INTERVAL: Duration = Duration::from_secs(2);
const MAX_WAIT: Duration = Duration::from_secs(3600);
#[derive(Debug)]
pub struct ToolError {
pub code: &'static str,
pub message: String,
}
impl ToolError {
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new("bad_request", message)
}
}
pub struct ToolSpec {
pub name: &'static str,
pub description: &'static str,
pub input_schema: fn() -> Value,
}
fn no_arguments() -> Value {
json!({ "type": "object", "properties": {}, "additionalProperties": false })
}
pub const TOOLS: &[ToolSpec] = &[
ToolSpec {
name: "manual",
description: "O manual completo do agente rustploy (markdown). Leia primeiro se \
o seu cliente não mostrou as instruções do servidor.",
input_schema: no_arguments,
},
ToolSpec {
name: "status",
description: "Versão/uptime do daemon e o estado da fila global de deploys.",
input_schema: no_arguments,
},
ToolSpec {
name: "list_services",
description: "Índice achatado projeto→serviço: service_id, nome, projeto, status, \
origem da imagem e porta. Use para achar o service_id pelo nome.",
input_schema: no_arguments,
},
ToolSpec {
name: "list_deploys",
description: "Últimos deploys de todos os serviços com o desfecho resolvido: \
ok=true (Live), ok=false (Failed, com `error` = a causa), ok=null \
(em andamento, ou Stopped/Pruning, que não são falha).",
input_schema: || {
json!({
"type": "object",
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 200, "default": 20 }
}
})
},
},
ToolSpec {
name: "deploy",
description: "Dispara o deploy de um serviço e, com wait=true (padrão), só responde \
quando ele terminar — com ok, error e o fim do build log. Uma chamada \
responde 'funcionou?' e 'por quê não?'.",
input_schema: || {
json!({
"type": "object",
"properties": {
"service_id": { "type": "string", "description": "id do serviço (svc_…)" },
"service": { "type": "string", "description": "nome do serviço; recusado se ambíguo entre projetos" },
"wait": { "type": "boolean", "default": true },
"timeout_s": { "type": "integer", "default": 900, "maximum": 3600 },
"log_tail": { "type": "integer", "default": 40, "maximum": 500 }
}
})
},
},
ToolSpec {
name: "build_logs",
description: "Build log de um deployment paginado por cursor. Passe o next_after \
da resposta anterior para pegar só as linhas novas.",
input_schema: || {
json!({
"type": "object",
"properties": {
"deployment_id": { "type": "string" },
"after": { "type": "integer", "minimum": 0, "default": 0 },
"limit": { "type": "integer", "minimum": 1, "maximum": 5000, "default": 500 }
},
"required": ["deployment_id"]
})
},
},
ToolSpec {
name: "ingress_routes",
description: "Tabela de rotas VIVA do proxy reverso (domínios e portas → ip:porta). \
É o diagnóstico de um domínio respondendo 502.",
input_schema: no_arguments,
},
ToolSpec {
name: "ingress_reconcile",
description: "Recalcula as rotas do proxy a partir dos containers que existem de fato, \
sem redeploy, e devolve a tabela já corrigida.",
input_schema: || {
json!({
"type": "object",
"properties": {
"service_id": { "type": "string", "description": "omitido = todos os serviços" }
}
})
},
},
ToolSpec {
name: "rpc",
description: "Executa QUALQUER Command do protocolo do daemon e devolve a Response. \
Codificação serde externally-tagged: variante com campos é objeto de \
uma chave ({\"ServiceGet\":{\"id\":\"svc_…\"}}), sem campos é a string \
nua (\"ProjectList\"). Ver a seção de comandos do manual.",
input_schema: || {
json!({
"type": "object",
"properties": {
"command": { "description": "o Command em JSON, ex. \"ProjectList\" ou {\"DeployHistory\":{\"service_id\":\"svc_…\",\"limit\":3}}" }
},
"required": ["command"]
})
},
},
];
pub async fn call(state: &AppState, name: &str, args: &Value) -> Result<Value, ToolError> {
match name {
"manual" => Ok(json!({ "manual": super::MANUAL })),
"status" => status(state).await,
"list_services" => {
let lista = service_index(&snapshot(state).await?);
Ok(json!({ "count": lista.len(), "services": lista }))
}
"list_deploys" => list_deploys(state, args).await,
"deploy" => deploy(state, args).await,
"build_logs" => build_logs(state, args).await,
"ingress_routes" => rpc_expect(state, json!("IngressRoutes"), "IngressRoutes").await,
"ingress_reconcile" => {
let service_id = args.get("service_id").and_then(Value::as_str);
rpc_expect(
state,
json!({ "IngressReconcile": { "service_id": service_id } }),
"IngressRoutes",
)
.await
}
"rpc" => {
let command = args
.get("command")
.cloned()
.ok_or_else(|| ToolError::bad_request("informe \"command\""))?;
rpc(state, command).await
}
outro => Err(ToolError::new(
"no_such_tool",
format!("ferramenta {outro:?} não existe — veja tools/list"),
)),
}
}
fn proibido_ao_agente(cmd: &Command) -> Option<&'static str> {
match cmd {
Command::SecretReveal { .. } => {
Some("revelar segredos é ação de uma pessoa na GUI/webui, não do agente")
}
Command::SecretMove {
op: shared::SecretMoveOp::SecretToEnv,
..
} => Some(
"converter um secret em variável em claro expõe o valor: é ação de uma pessoa na GUI/webui",
),
Command::RegistryTokenCreate { .. } => Some(
"o token do registry é devolvido em texto plano: crie-o na GUI/webui e entregue-o direto ao cliente que vai usá-lo",
),
Command::ServiceExport {
include_values: true,
..
} => Some(
"exportar um serviço com valores leva as variáveis em claro: use `include_values: false` ou peça a uma pessoa na GUI/webui",
),
Command::StateGitResolve { .. } => Some(
"resolver um histórico divergido descarta um dos lados: é decisão de uma pessoa na GUI/webui",
),
_ => None,
}
}
pub async fn rpc(state: &AppState, command: Value) -> Result<Value, ToolError> {
let mut cmd: Command = serde_json::from_value(command)
.map_err(|e| ToolError::bad_request(format!("Command inválido: {e}")))?;
if matches!(cmd, Command::ApplyUpdate { .. } | Command::UpdateRollback)
&& !state.update_applier.allow_agent_apply()
{
return Err(ToolError::new(
"forbidden",
"atualizar o daemon não é permitido ao agente: ligue `allow_agent_apply = true` em [update] \
no config.toml, ou peça a uma pessoa que use Settings → Atualizações",
));
}
if let Some(motivo) = proibido_ao_agente(&cmd) {
return Err(ToolError::new("forbidden", motivo));
}
restaurar_mascaras(state, &mut cmd).await?;
let mut resposta = serde_json::to_value(
Box::pin(dispatch(state.clone(), cmd)).await,
)
.map_err(|e| ToolError::new("encode_failed", e.to_string()))?;
if let Some(err) = resposta.get("Err") {
let code = err.get("code").and_then(Value::as_str).unwrap_or("?");
let message = err.get("message").and_then(Value::as_str).unwrap_or("");
return Err(ToolError::new(
"daemon_command_error",
format!("{code}: {message}"),
));
}
redigir_resposta(&mut resposta);
Ok(resposta)
}
async fn restaurar_mascaras(state: &AppState, cmd: &mut Command) -> Result<(), ToolError> {
let recusar = |chave: &str| {
ToolError::bad_request(format!(
"a variável {chave} chegou com o valor mascarado (`***`) e não há valor anterior para \
restaurar. O agente não vê esse valor: envie o valor novo, ou use um secret"
))
};
match cmd {
Command::ServiceUpdate { id, spec } => {
if let Ok(Some(atual)) = crate::store::services::get(&state.db, id).await {
restaurar_envs(&mut spec.env_vars, &atual.spec.env_vars);
}
if let Some(chave) = sobrou_mascara(&spec.env_vars) {
return Err(recusar(chave));
}
}
Command::ProjectEnvSet {
project_id,
env_vars,
..
} => {
if let Ok(Some(atual)) = crate::store::projects::get(&state.db, project_id).await {
restaurar_envs(env_vars, &atual.env_vars);
}
if let Some(chave) = sobrou_mascara(env_vars) {
return Err(recusar(chave));
}
}
Command::ServiceCreate(spec) => {
if let Some(chave) = sobrou_mascara(&spec.env_vars) {
return Err(recusar(chave));
}
}
Command::JobCreate { env_vars, .. } | Command::JobUpdate { env_vars, .. } => {
if let Some(chave) = sobrou_mascara(env_vars) {
return Err(recusar(chave));
}
}
_ => {}
}
Ok(())
}
async fn rpc_expect(state: &AppState, command: Value, variante: &str) -> Result<Value, ToolError> {
let resposta = rpc(state, command).await?;
resposta.get(variante).cloned().ok_or_else(|| {
ToolError::new(
"unexpected_response",
format!("esperava Response::{variante}, veio {resposta}"),
)
})
}
async fn status(state: &AppState) -> Result<Value, ToolError> {
let daemon = rpc_expect(state, json!("DaemonStatus"), "DaemonStatus").await?;
let engine = rpc_expect(state, json!("DeployEngineStatus"), "DeployEngineStatus").await?;
Ok(json!({ "daemon": daemon, "deploy_engine": engine }))
}
async fn snapshot(state: &AppState) -> Result<Value, ToolError> {
let texto = Box::pin(crate::api::http::snapshot(state)).await;
let mut v: Value = serde_json::from_str(&texto).map_err(|e| {
ToolError::new(
"unexpected_response",
format!("snapshot não é JSON válido: {e}"),
)
})?;
redigir_resposta(&mut v);
Ok(v)
}
async fn list_deploys(state: &AppState, args: &Value) -> Result<Value, ToolError> {
let limit = args
.get("limit")
.and_then(Value::as_u64)
.unwrap_or(20)
.clamp(1, 200);
let sumarios = rpc_expect(
state,
json!({ "RecentDeployments": { "limit": limit } }),
"DeploymentSummaries",
)
.await?;
let lista: Vec<Value> = sumarios
.as_array()
.map(|a| a.iter().map(compact_summary).collect())
.unwrap_or_default();
Ok(json!({ "count": lista.len(), "deployments": lista }))
}
async fn deploy(state: &AppState, args: &Value) -> Result<Value, ToolError> {
let service_id = resolve_service(state, args).await?;
let esperar = args.get("wait").and_then(Value::as_bool).unwrap_or(true);
let log_tail = args
.get("log_tail")
.and_then(Value::as_u64)
.unwrap_or(40)
.min(500) as usize;
let limite = args
.get("timeout_s")
.and_then(Value::as_u64)
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(900))
.min(MAX_WAIT);
let dep = rpc_expect(
state,
json!({ "DeployStart": { "service_id": service_id } }),
"Deployment",
)
.await?;
let deployment_id = dep
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if !esperar {
return Ok(json!({
"deployment_id": deployment_id,
"service_id": service_id,
"state": dep.get("state").cloned().unwrap_or(Value::Null),
"waited": false,
"hint": "acompanhe com list_deploys ou build_logs(deployment_id, after)",
}));
}
let (final_dep, expirou) = wait_for_outcome(state, &service_id, &deployment_id, limite).await?;
let (log, total) = if log_tail > 0 {
let linhas = build_log_lines(state, &deployment_id)
.await
.unwrap_or_default();
let total = linhas.len();
let inicio = total.saturating_sub(log_tail);
(linhas[inicio..].to_vec(), total)
} else {
(Vec::new(), 0)
};
let mut out = compact_deployment(&final_dep);
if let Value::Object(m) = &mut out {
m.insert("waited".into(), json!(true));
m.insert("timed_out".into(), json!(expirou));
m.insert("log_tail".into(), json!(log));
m.insert("log_cursor".into(), json!(total));
}
Ok(out)
}
async fn resolve_service(state: &AppState, args: &Value) -> Result<String, ToolError> {
if let Some(id) = args
.get("service_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Ok(id.to_owned());
}
let nome = args
.get("service")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::bad_request("informe \"service_id\" ou \"service\" (nome)"))?;
let indice = service_index(&snapshot(state).await?);
let casos: Vec<&Value> = indice
.iter()
.filter(|s| {
s.get("name")
.and_then(Value::as_str)
.map(|n| n.eq_ignore_ascii_case(nome))
.unwrap_or(false)
})
.collect();
match casos.as_slice() {
[um] => Ok(um
.get("service_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned()),
[] => Err(ToolError::new(
"no_such_service",
format!("nenhum serviço chamado {nome:?} — veja list_services"),
)),
varios => Err(ToolError::bad_request(format!(
"{:?} existe em {} projetos — mande \"service_id\". Candidatos: {}",
nome,
varios.len(),
varios
.iter()
.filter_map(|s| {
Some(format!(
"{}={}",
s.get("project")?.as_str()?,
s.get("service_id")?.as_str()?
))
})
.collect::<Vec<_>>()
.join(", ")
))),
}
}
async fn wait_for_outcome(
state: &AppState,
service_id: &str,
deployment_id: &str,
limite: Duration,
) -> Result<(Value, bool), ToolError> {
let comeco = Instant::now();
let mut ultimo: Option<Value> = None;
loop {
let historico = rpc_expect(
state,
json!({ "DeployHistory": { "service_id": service_id, "limit": 10 } }),
"Deployments",
)
.await?;
let atual = historico.as_array().and_then(|a| {
a.iter()
.find(|d| d.get("id").and_then(Value::as_str) == Some(deployment_id))
.cloned()
});
if let Some(dep) = atual {
if is_terminal(&dep) {
return Ok((dep, false));
}
ultimo = Some(dep);
}
if comeco.elapsed() >= limite {
let dep = ultimo.unwrap_or_else(
|| json!({ "id": deployment_id, "service_id": service_id, "state": "Unknown" }),
);
return Ok((dep, true));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
async fn build_logs(state: &AppState, args: &Value) -> Result<Value, ToolError> {
let deployment_id = args
.get("deployment_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::bad_request("informe \"deployment_id\""))?;
let linhas = build_log_lines(state, deployment_id).await?;
let total = linhas.len();
let after = (args.get("after").and_then(Value::as_u64).unwrap_or(0) as usize).min(total);
let limit = args
.get("limit")
.and_then(Value::as_u64)
.unwrap_or(500)
.clamp(1, 5000) as usize;
let fim = (after + limit).min(total);
Ok(json!({
"deployment_id": deployment_id,
"total": total,
"after": after,
"next_after": fim,
"has_more": fim < total,
"lines": &linhas[after..fim],
}))
}
async fn build_log_lines(state: &AppState, deployment_id: &str) -> Result<Vec<String>, ToolError> {
let bruto = rpc_expect(
state,
json!({ "GetBuildLogs": { "deployment_id": deployment_id } }),
"BuildLogs",
)
.await?;
Ok(bruto
.as_array()
.map(|a| {
a.iter()
.filter_map(|l| l.get("line").and_then(Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default())
}
fn compact_summary(sum: &Value) -> Value {
let dep = sum.get("deployment").unwrap_or(&Value::Null);
let mut out = compact_deployment(dep);
if let Value::Object(m) = &mut out {
if let Some(n) = sum.get("service_name") {
m.insert("service".into(), n.clone());
}
if let Some(p) = sum.get("project_name") {
m.insert("project".into(), p.clone());
}
}
out
}
fn compact_deployment(dep: &Value) -> Value {
let estado = dep
.get("state")
.and_then(Value::as_str)
.unwrap_or("Unknown");
json!({
"deployment_id": dep.get("id").cloned().unwrap_or(Value::Null),
"service_id": dep.get("service_id").cloned().unwrap_or(Value::Null),
"image": dep.get("image").cloned().unwrap_or(Value::Null),
"state": estado,
"ok": outcome_ok(estado),
"error": failure_reason(dep),
"started_at": dep.get("started_at").cloned().unwrap_or(Value::Null),
"finished_at": dep.get("finished_at").cloned().unwrap_or(Value::Null),
})
}
fn outcome_ok(estado: &str) -> Value {
match estado {
"Live" => json!(true),
"Failed" => json!(false),
_ => Value::Null,
}
}
fn is_terminal(dep: &Value) -> bool {
matches!(
dep.get("state").and_then(Value::as_str),
Some("Live" | "Stopped" | "Failed" | "Pruning")
)
}
fn failure_reason(dep: &Value) -> Value {
let Some(log) = dep.get("states_log").and_then(Value::as_array) else {
return Value::Null;
};
log.iter()
.rev()
.find(|t| {
matches!(
t.get("to").and_then(Value::as_str),
Some("RollingBack" | "Failed")
) && t.get("message").map(|m| !m.is_null()).unwrap_or(false)
})
.and_then(|t| t.get("message").cloned())
.unwrap_or(Value::Null)
}
fn service_index(snapshot: &Value) -> Vec<Value> {
let Some(lista) = snapshot.get("services").and_then(Value::as_array) else {
return Vec::new();
};
lista
.iter()
.filter_map(|entrada| {
let svc = entrada.get("service")?;
let spec = svc.get("spec")?;
Some(json!({
"service_id": svc.get("id").cloned().unwrap_or(Value::Null),
"name": spec.get("name").cloned().unwrap_or(Value::Null),
"project": entrada.get("project_name").cloned().unwrap_or(Value::Null),
"project_id": spec.get("project_id").cloned().unwrap_or(Value::Null),
"status": status_label(svc.get("status")),
"source": source_label(spec.get("source")),
"port": spec.get("port").cloned().unwrap_or(Value::Null),
}))
})
.collect()
}
fn status_label(status: Option<&Value>) -> Value {
match status {
Some(Value::String(s)) => json!(s),
Some(Value::Object(m)) if m.len() == 1 => {
let (k, v) = m.iter().next().unwrap();
match v.as_str() {
Some(detalhe) if !detalhe.is_empty() => json!(format!("{k}: {detalhe}")),
_ => json!(k),
}
}
_ => Value::Null,
}
}
fn source_label(source: Option<&Value>) -> Value {
match source {
Some(Value::Object(m)) if m.len() == 1 => {
let (kind, v) = m.iter().next().unwrap();
match kind.as_str() {
"Registry" => json!({
"kind": "Registry",
"image": v.get("image").cloned().unwrap_or(Value::Null),
}),
"Git" => json!({
"kind": "Git",
"url": v.get("url").cloned().unwrap_or(Value::Null),
"branch": v.get("branch").cloned().unwrap_or(Value::Null),
"dockerfile_path": v.get("dockerfile_path").cloned().unwrap_or(Value::Null),
"build_context": v.get("build_context").cloned().unwrap_or(Value::Null),
}),
"Archive" => json!({
"kind": "Archive",
"dockerfile_path": v.get("dockerfile_path").cloned().unwrap_or(Value::Null),
"original_filename": v.get("original_filename").cloned().unwrap_or(Value::Null),
}),
outro => json!({ "kind": outro }),
}
}
Some(Value::String(s)) => json!({ "kind": s }),
_ => Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comandos_que_devolvem_valor_de_segredo_sao_proibidos_ao_agente() {
let proibidos = [
Command::SecretMove {
project_id: "p".into(),
service_id: None,
name: "K".into(),
op: shared::SecretMoveOp::SecretToEnv,
},
Command::RegistryTokenCreate {
name: "ci".into(),
scope: "push".into(),
},
Command::ServiceExport {
service_id: "s".into(),
include_values: true,
project_env_keys: vec![],
},
];
for c in &proibidos {
assert!(
proibido_ao_agente(c).is_some(),
"{c:?} deveria ser proibido"
);
}
let liberados = [
Command::SecretMove {
project_id: "p".into(),
service_id: None,
name: "K".into(),
op: shared::SecretMoveOp::EnvToSecret,
},
Command::ServiceExport {
service_id: "s".into(),
include_values: false,
project_env_keys: vec![],
},
];
for c in &liberados {
assert!(
proibido_ao_agente(c).is_none(),
"{c:?} deveria ser liberado"
);
}
}
#[test]
fn revelar_secret_e_proibido_ao_agente_e_o_resto_dos_secrets_nao() {
let reveal = Command::SecretReveal {
project_id: "p".into(),
service_id: None,
name: "K".into(),
};
assert!(proibido_ao_agente(&reveal).is_some());
for ok in [
Command::SecretList {
project_id: "p".into(),
service_id: None,
},
Command::SecretSet {
project_id: "p".into(),
service_id: None,
name: "K".into(),
value: "v".into(),
},
] {
assert!(proibido_ao_agente(&ok).is_none());
}
}
#[test]
fn desfecho_so_e_decidido_por_live_ou_failed() {
assert_eq!(outcome_ok("Live"), json!(true));
assert_eq!(outcome_ok("Failed"), json!(false));
assert_eq!(outcome_ok("Stopped"), Value::Null);
assert_eq!(outcome_ok("Pruning"), Value::Null);
assert_eq!(outcome_ok("BuildingImage"), Value::Null);
}
#[test]
fn causa_da_falha_vem_da_ultima_transicao_com_mensagem() {
let dep = json!({
"state": "Failed",
"states_log": [
{ "to": "BuildingImage", "message": null },
{ "to": "RollingBack", "message": "docker build error: sem Dockerfile" },
{ "to": "Failed", "message": null }
]
});
assert_eq!(
failure_reason(&dep),
json!("docker build error: sem Dockerfile")
);
}
#[test]
fn status_externally_tagged_vira_texto() {
assert_eq!(status_label(Some(&json!("Running"))), json!("Running"));
assert_eq!(
status_label(Some(&json!({ "Error": "porta ocupada" }))),
json!("Error: porta ocupada")
);
}
#[test]
fn todas_as_ferramentas_tem_schema_de_objeto() {
for tool in TOOLS {
assert_eq!(
(tool.input_schema)()["type"],
json!("object"),
"{}",
tool.name
);
}
}
}