diff --git a/.github/workflows/ci-cd-web-desktop.yml b/.github/workflows/ci-cd-web-desktop.yml index 655049a..da59c39 100644 --- a/.github/workflows/ci-cd-web-desktop.yml +++ b/.github/workflows/ci-cd-web-desktop.yml @@ -206,6 +206,34 @@ jobs: echo "Forcing service restart..." docker service update --force sistema_convex_backend || true + - name: Smoke test — register + heartbeat + run: | + set -e + # Load MACHINE_PROVISIONING_SECRET from production .env on the host + if [ -f /srv/apps/sistema/.env ]; then + set -o allexport + . /srv/apps/sistema/.env + set +o allexport + fi + if [ -z "${MACHINE_PROVISIONING_SECRET:-}" ]; then + echo "MACHINE_PROVISIONING_SECRET ausente — pulando smoke test"; exit 0 + fi + HOSTNAME_TEST="ci-smoke-$(date +%s)" + BODY='{"provisioningSecret":"'"$MACHINE_PROVISIONING_SECRET"'","tenantId":"tenant-atlas","hostname":"'"$HOSTNAME_TEST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventory":{"cpu":"i7","ramGb":16}},"registeredBy":"ci-smoke"}' + HTTP=$(curl -sS -o resp.json -w "%{http_code}" -H 'Content-Type: application/json' -d "$BODY" https://tickets.esdrasrenan.com.br/api/machines/register || true) + echo "Register HTTP=$HTTP" + if [ "$HTTP" != "201" ]; then + echo "Register failed:"; tail -c 600 resp.json || true; exit 1; fi + TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' ) + if [ -z "$TOKEN" ]; then echo "Missing token in register response"; exit 1; fi + HB=$(curl -sS -o /dev/null -w "%{http_code}" -H 'Content-Type: application/json' -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":5,"memFreePct":70}}' https://tickets.esdrasrenan.com.br/api/machines/heartbeat || true) + echo "Heartbeat HTTP=$HB" + if [ "$HB" != "200" ]; then echo "Heartbeat failed"; exit 1; fi + + - name: Cleanup old build workdirs (keep last 3) + run: | + ls -1dt $HOME/apps/sistema.build.* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true + - name: Restart web service with new code run: | docker service update --force sistema_web || true @@ -261,26 +289,24 @@ jobs: env: CONVEX_SELF_HOSTED_URL: ${{ secrets.CONVEX_SELF_HOSTED_URL }} CONVEX_SELF_HOSTED_ADMIN_KEY: ${{ secrets.CONVEX_SELF_HOSTED_ADMIN_KEY }} + MACHINE_PROVISIONING_SECRET: ${{ secrets.MACHINE_PROVISIONING_SECRET }} + MACHINE_TOKEN_TTL_MS: ${{ secrets.MACHINE_TOKEN_TTL_MS }} + FLEET_SYNC_SECRET: ${{ secrets.FLEET_SYNC_SECRET }} run: | set -e - # Load production values from /srv (do not copy .env to workspace) - if [ -f /srv/apps/sistema/.env ]; then - set -o allexport - . /srv/apps/sistema/.env - set +o allexport - fi docker run --rm -i \ -v "$EFFECTIVE_APP_DIR":/app \ -w /app \ -e CONVEX_SELF_HOSTED_URL \ -e CONVEX_SELF_HOSTED_ADMIN_KEY \ - -e MACHINE_PROVISIONING_SECRET="${MACHINE_PROVISIONING_SECRET:-}" \ - -e MACHINE_TOKEN_TTL_MS="${MACHINE_TOKEN_TTL_MS:-}" \ - -e FLEET_SYNC_SECRET="${FLEET_SYNC_SECRET:-}" \ + -e MACHINE_PROVISIONING_SECRET \ + -e MACHINE_TOKEN_TTL_MS \ + -e FLEET_SYNC_SECRET \ node:20-bullseye bash -lc "set -euo pipefail; unset CONVEX_DEPLOYMENT; corepack enable; corepack prepare pnpm@9 --activate; pnpm install --frozen-lockfile --prod=false; \ - if [ -n \"\${MACHINE_PROVISIONING_SECRET:-}\" ]; then pnpm exec convex env set MACHINE_PROVISIONING_SECRET \"\${MACHINE_PROVISIONING_SECRET}\" -y; fi; \ - if [ -n \"\${MACHINE_TOKEN_TTL_MS:-}\" ]; then pnpm exec convex env set MACHINE_TOKEN_TTL_MS \"\${MACHINE_TOKEN_TTL_MS}\" -y; fi; \ - if [ -n \"\${FLEET_SYNC_SECRET:-}\" ]; then pnpm exec convex env set FLEET_SYNC_SECRET \"\${FLEET_SYNC_SECRET}\" -y; fi;" + if [ -n \"$MACHINE_PROVISIONING_SECRET\" ]; then pnpm exec convex env set MACHINE_PROVISIONING_SECRET \"$MACHINE_PROVISIONING_SECRET\" -y; fi; \ + if [ -n \"$MACHINE_TOKEN_TTL_MS\" ]; then pnpm exec convex env set MACHINE_TOKEN_TTL_MS \"$MACHINE_TOKEN_TTL_MS\" -y; fi; \ + if [ -n \"$FLEET_SYNC_SECRET\" ]; then pnpm exec convex env set FLEET_SYNC_SECRET \"$FLEET_SYNC_SECRET\" -y; fi; \ + pnpm exec convex env list" - name: Ensure .env is not present for Convex deploy run: | diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 7b5a8ac..2cfd3bf 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -61,6 +61,7 @@ name = "appsdesktop" version = "0.1.0" dependencies = [ "chrono", + "get_if_addrs", "hostname", "once_cell", "parking_lot", @@ -372,6 +373,12 @@ dependencies = [ "serde", ] +[[package]] +name = "c_linked_list" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4964518bd3b4a8190e832886cdc0da9794f12e8e6c1613a9e90ff331c4c8724b" + [[package]] name = "cairo-rs" version = "0.18.5" @@ -801,7 +808,7 @@ dependencies = [ "dlopen2_derive", "libc", "once_cell", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1139,6 +1146,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "gcc" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2" + [[package]] name = "gdk" version = "0.18.2" @@ -1248,6 +1261,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "get_if_addrs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abddb55a898d32925f3148bd281174a68eeb68bbfd9a5938a57b18f506ee4ef7" +dependencies = [ + "c_linked_list", + "get_if_addrs-sys", + "libc", + "winapi 0.2.8", +] + +[[package]] +name = "get_if_addrs-sys" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d04f9fb746cf36b191c00f3ede8bde9c8e64f9f4b05ae2694a9ccf5e3f5ab48" +dependencies = [ + "gcc", + "libc", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1321,7 +1356,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -1975,7 +2010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" dependencies = [ "cfg-if", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -2182,7 +2217,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" dependencies = [ - "winapi", + "winapi 0.3.9", ] [[package]] @@ -4542,7 +4577,7 @@ checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ "memoffset", "tempfile", - "winapi", + "winapi 0.3.9", ] [[package]] @@ -4927,6 +4962,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + [[package]] name = "winapi" version = "0.3.9" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 7ef2c39..c78a41d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,7 +23,8 @@ tauri-plugin-opener = "2" tauri-plugin-store = "2.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system"] } +sysinfo = { version = "0.31", default-features = false, features = ["multithread", "network", "system", "disk"] } +get_if_addrs = "0.5" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } once_cell = "1.19" diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index 816de05..abfe45b 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -5,6 +5,7 @@ use chrono::{DateTime, Utc}; use once_cell::sync::Lazy; use parking_lot::Mutex; use serde::Serialize; +use serde_json::json; use sysinfo::{Networks, System}; use tauri::async_runtime::{self, JoinHandle}; use tokio::sync::Notify; @@ -100,6 +101,181 @@ fn collect_mac_addresses() -> Vec { macs } +#[cfg(target_os = "linux")] +fn collect_serials_platform() -> Vec { + let mut out = Vec::new(); + for path in [ + "/sys/class/dmi/id/product_uuid", + "/sys/class/dmi/id/product_serial", + "/sys/class/dmi/id/board_serial", + "/etc/machine-id", + ] { + if let Ok(raw) = std::fs::read_to_string(path) { + let s = raw.trim().to_string(); + if !s.is_empty() && !out.contains(&s) { + out.push(s); + } + } + } + out +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn collect_serials_platform() -> Vec { + // Fase 1: sem coleta nativa; será implementada via WMI/ioreg na fase 2. + Vec::new() +} + +fn collect_serials() -> Vec { + collect_serials_platform() +} + +fn collect_network_addrs() -> Vec { + let mut entries = Vec::new(); + if let Ok(ifaces) = get_if_addrs::get_if_addrs() { + for iface in ifaces { + let name = iface.name; + let mac = iface.mac.map(|m| m.to_string()); + let addr = iface.ip(); + let ip = addr.to_string(); + entries.push(json!({ + "name": name, + "mac": mac, + "ip": ip, + })); + } + } + entries +} + +fn collect_disks(system: &System) -> Vec { + let mut out = Vec::new(); + for d in system.disks() { + let name = d.name().to_string_lossy().to_string(); + let mount = d.mount_point().to_string_lossy().to_string(); + let fs = String::from_utf8_lossy(d.file_system()).to_string(); + let total = d.total_space(); + let avail = d.available_space(); + out.push(json!({ + "name": name, + "mountPoint": mount, + "fs": fs, + "totalBytes": total, + "availableBytes": avail, + })); + } + out +} + +fn build_inventory_metadata(system: &System) -> serde_json::Value { + let cpu_brand = system + .cpus() + .first() + .map(|cpu| cpu.brand().to_string()) + .unwrap_or_default(); + let mem_total_bytes = system.total_memory().saturating_mul(1024); + let network = collect_network_addrs(); + let disks = collect_disks(system); + let mut inventory = json!({ + "cpu": { "brand": cpu_brand }, + "memory": { "totalBytes": mem_total_bytes }, + "network": network, + "disks": disks, + }); + + #[cfg(target_os = "linux")] + { + // Softwares instalados (dpkg ou rpm) + let software = collect_software_linux(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("software".into(), software); + } + + // Serviços ativos (systemd) + let services = collect_services_linux(); + if let Some(obj) = inventory.as_object_mut() { + obj.insert("services".into(), services); + } + } + + json!({ "inventory": inventory }) +} + +#[cfg(target_os = "linux")] +fn collect_software_linux() -> serde_json::Value { + use std::process::Command; + // Tenta dpkg-query primeiro + let dpkg = Command::new("sh") + .arg("-lc") + .arg("dpkg-query -W -f='${binary:Package}\t${Version}\n' 2>/dev/null || true") + .output(); + if let Ok(out) = dpkg { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + let mut parts = line.split('\t'); + let name = parts.next().unwrap_or("").trim(); + let version = parts.next().unwrap_or("").trim(); + if !name.is_empty() { + items.push(json!({"name": name, "version": version, "source": "dpkg"})); + } + } + return json!(items); + } + } + + // Fallback rpm + let rpm = std::process::Command::new("sh") + .arg("-lc") + .arg("rpm -qa --qf '%{NAME}\t%{VERSION}-%{RELEASE}\n' 2>/dev/null || true") + .output(); + if let Ok(out) = rpm { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + let mut parts = line.split('\t'); + let name = parts.next().unwrap_or("").trim(); + let version = parts.next().unwrap_or("").trim(); + if !name.is_empty() { + items.push(json!({"name": name, "version": version, "source": "rpm"})); + } + } + return json!(items); + } + } + json!([]) +} + +#[cfg(target_os = "linux")] +fn collect_services_linux() -> serde_json::Value { + use std::process::Command; + let out = Command::new("sh") + .arg("-lc") + .arg("systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null || true") + .output(); + if let Ok(out) = out { + if out.status.success() { + let s = String::from_utf8_lossy(&out.stdout); + let mut items = Vec::new(); + for line in s.lines() { + // Typical format: UNIT LOAD ACTIVE SUB DESCRIPTION + // We take UNIT and ACTIVE + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.is_empty() { continue; } + let unit = cols.get(0).unwrap_or(&""); + let active = cols.get(2).copied().unwrap_or(""); + if !unit.is_empty() { + items.push(json!({"name": unit, "status": active})); + } + } + return json!(items); + } + } + json!([]) +} + fn collect_system() -> System { let mut system = System::new_all(); system.refresh_all(); @@ -154,7 +330,7 @@ pub fn collect_profile() -> Result { let architecture = std::env::consts::ARCH.to_string(); let mac_addresses = collect_mac_addresses(); - let serials: Vec = Vec::new(); + let serials: Vec = collect_serials(); if mac_addresses.is_empty() && serials.is_empty() { return Err(AgentError::MissingIdentifiers); @@ -216,7 +392,7 @@ async fn post_heartbeat(base_url: &str, token: &str, status: Option) -> hostname: Some(hostname), os: Some(os), metrics: Some(metrics), - metadata: None, + metadata: Some(build_inventory_metadata(&system)), }; let url = format!("{}/api/machines/heartbeat", base_url); diff --git a/docs/OPERACAO-PRODUCAO.md b/docs/OPERACAO-PRODUCAO.md index 24e117a..e8d24b3 100644 --- a/docs/OPERACAO-PRODUCAO.md +++ b/docs/OPERACAO-PRODUCAO.md @@ -165,6 +165,41 @@ docker run --rm -it \ Observação - Sempre que alterar código em `convex/`, repita o comando acima para publicar as mudanças. +### Variáveis do Convex (importante) +As functions do Convex leem variáveis via `convex env`, não do `.env` do container. +No CI, defina os seguintes Secrets (Repo → Settings → Secrets and variables → Actions): + +- `CONVEX_SELF_HOSTED_URL` — ex.: `https://convex.esdrasrenan.com.br` +- `CONVEX_SELF_HOSTED_ADMIN_KEY` — gerada por `./generate_admin_key.sh` +- `MACHINE_PROVISIONING_SECRET` — hex forte +- (opcional) `MACHINE_TOKEN_TTL_MS` — ex.: `2592000000` +- (opcional) `FLEET_SYNC_SECRET` + +O job `convex_deploy` sempre roda `convex env set` com os Secrets acima antes do `convex deploy`. +Se preferir setar manualmente: + +- `MACHINE_PROVISIONING_SECRET` — obrigatório para `/api/machines/register` +- (opcional) `MACHINE_TOKEN_TTL_MS`, `FLEET_SYNC_SECRET` + +CLI manual (exemplo): +``` +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL=https://convex.esdrasrenan.com.br \ + -e CONVEX_SELF_HOSTED_ADMIN_KEY='COLE_A_CHAVE' \ + node:20-bullseye bash -lc "set -euo pipefail; corepack enable && corepack prepare pnpm@9 --activate && pnpm i --frozen-lockfile --prod=false; \ + unset CONVEX_DEPLOYMENT; \ + pnpm exec convex env set MACHINE_PROVISIONING_SECRET 'seu-hex' -y; \ + pnpm exec convex env list" +``` + +### Smoke test pós‑deploy (CI) +O pipeline executa um teste rápido após o deploy do Web: +- Registra uma máquina fake usando `MACHINE_PROVISIONING_SECRET` do `/srv/apps/sistema/.env` +- Espera `HTTP 201` e extrai `machineToken` +- Envia `heartbeat` e espera `HTTP 200` +- Se falhar, o job é marcado como erro (evita regressões silenciosas) + ## Seeds - Dados de demonstração Convex: acesse uma vez `https://tickets.esdrasrenan.com.br/dev/seed`. - Usuários (Better Auth): diff --git a/docs/convex-self-hosted-env.md b/docs/convex-self-hosted-env.md new file mode 100644 index 0000000..753b307 --- /dev/null +++ b/docs/convex-self-hosted-env.md @@ -0,0 +1,55 @@ +Convex Self‑Hosted — Configurar env e testar provisionamento + +Pré‑requisitos +- Rodar na VPS com Docker. +- Projeto em `/srv/apps/sistema`. +- Admin Key do Convex (já obtida): + `convex-self-hosted|011c148069bd37e4a3f1c10b41b19459427a20e6d7ba81f53b659861f7658cd4985c8936e9` + +1) Exportar variáveis da sessão (URL + Admin Key) +export CONVEX_SELF_HOSTED_URL="https://convex.esdrasrenan.com.br" +export CONVEX_SELF_HOSTED_ADMIN_KEY='convex-self-hosted|011c148069bd37e4a3f1c10b41b19459427a20e6d7ba81f53b659861f7658cd4985c8936e9' + +2) Definir MACHINE_PROVISIONING_SECRET no Convex (obrigatório) +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + node:20-bullseye bash -lc "set -euo pipefail; \ + corepack enable && corepack prepare pnpm@9 --activate && pnpm i --frozen-lockfile --prod=false; \ + unset CONVEX_DEPLOYMENT; \ + pnpm exec convex env set MACHINE_PROVISIONING_SECRET '71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6' -y; \ + pnpm exec convex env list" + +3) (Opcional) Definir MACHINE_TOKEN_TTL_MS (padrão 30 dias) +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + node:20-bullseye bash -lc "set -euo pipefail; \ + corepack enable && corepack prepare pnpm@9 --activate && pnpm i --frozen-lockfile --prod=false; \ + unset CONVEX_DEPLOYMENT; \ + pnpm exec convex env set MACHINE_TOKEN_TTL_MS '2592000000' -y; \ + pnpm exec convex env list" + +4) (Opcional) Definir FLEET_SYNC_SECRET +docker run --rm -it \ + -v /srv/apps/sistema:/app -w /app \ + -e CONVEX_SELF_HOSTED_URL -e CONVEX_SELF_HOSTED_ADMIN_KEY \ + node:20-bullseye bash -lc "set -euo pipefail; \ + corepack enable && corepack prepare pnpm@9 --activate && pnpm i --frozen-lockfile --prod=false; \ + unset CONVEX_DEPLOYMENT; \ + pnpm exec convex env set FLEET_SYNC_SECRET '' -y; \ + pnpm exec convex env list" + +5) Testar registro (gera machineToken) — substitua o hostname se quiser +HOST="vm-teste-$(date +%s)"; \ +curl -sS -o resp.json -w "%{http_code}\n" -X POST 'https://tickets.esdrasrenan.com.br/api/machines/register' \ + -H 'Content-Type: application/json' \ + -d '{"provisioningSecret":"71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6","tenantId":"tenant-atlas","hostname":"'"$HOST"'","os":{"name":"Linux","version":"6.1.0","architecture":"x86_64"},"macAddresses":["AA:BB:CC:DD:EE:FF"],"serialNumbers":[],"metadata":{"inventario":{"cpu":"i7","ramGb":16}},"registeredBy":"manual-test"}'; \ +echo; tail -c 400 resp.json || true + +6) (Opcional) Enviar heartbeat com o token retornado +TOKEN=$(node -e 'try{const j=require("fs").readFileSync("resp.json","utf8");process.stdout.write(JSON.parse(j).machineToken||"");}catch(e){process.stdout.write("")}' ); \ +[ -n "$TOKEN" ] && curl -sS -o /dev/null -w "%{http_code}\n" -X POST 'https://tickets.esdrasrenan.com.br/api/machines/heartbeat' \ + -H 'Content-Type: application/json' \ + -d '{"machineToken":"'"$TOKEN"'","status":"online","metrics":{"cpuPct":12,"memFreePct":61}}' + diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 620045c..e5ab691 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -184,10 +184,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) { {machines.length === 0 ? ( ) : ( -
- - - +
+
+ + Hostname Status Último heartbeat @@ -209,8 +209,10 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{machine.hostname}

{machine.authEmail ?? "—"}

- - + +
+ +