- 1264
Ok(spec) => sync_one(&spec, paths, runner), - 1265
Err(msg) => SyncOutcome { - 1266
name: (*name).to_string(), - 1267
action: SyncAction::Failed(msg), - 1268
}, - 1269
}) - 1270
.collect() - 1271
} - 1272
- 1273
/// [`status_specs`] over [`SERVICES`], rooted at the installed `bin_path`. - 1274
pub fn services_status( - 1275
bin_path: &Path, - 1276
names: &[&str], - 1277
paths: &Paths, - 1278
runner: &dyn CommandRunner, - 1279
) -> Vec<ServiceRow> { - 1280
let specs: Vec<ServiceSpec> = resolve_specs(bin_path, names) - 1281
.into_iter() - 1282
.flatten() - 1283
.collect(); - 1284
status_specs(&specs, paths, runner) - 1285
} - 1286
- 1287
#[cfg(test)] - 1288
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 1289
mod tests { - 1290
use super::*; - 1291
use std::sync::Mutex; - 1292
- 1293
struct Fake { - 1294
cmds: Mutex<Vec<(String, Vec<String>)>>, - 1295
pid_text: Option<String>, - 1296
fail_load: bool, - 1297
} - 1298
impl Fake { - 1299
fn new() -> Self { - 1300
Fake { - 1301
cmds: Mutex::new(Vec::new()), - 1302
pid_text: None, - 1303
fail_load: false, - 1304
} - 1305
} - 1306
fn with_pid(pid: u32) -> Self { - 1307
Fake { - 1308
pid_text: Some(format!("com.vak.x = {{\n\tpid = {pid}\n}}")), - 1309
..Self::new() - 1310
} - 1311
} - 1312
} - 1313
impl CommandRunner for Fake { - 1314
fn success(&self, program: &str, args: &[String]) -> bool { - 1315
self.cmds - 1316
.lock() - 1317
.unwrap() - 1318
.push((program.to_string(), args.to_vec())); - 1319
!(self.fail_load - 1320
&& program == "launchctl" - 1321
&& args.first().map(String::as_str) == Some("bootstrap")) - 1322
} - 1323
fn text(&self, program: &str, args: &[String]) -> Option<String> { - 1324
if program == "id" { - 1325
return Some("501".to_string()); - 1326
} - 1327
self.success(program, args); - 1328
self.pid_text.clone() - 1329
} - 1330
} - 1331
- 1332
fn tmp_paths(tag: &str) -> (tempfile::TempDir, Paths) { - 1333
let dir = tempfile::tempdir().unwrap(); - 1334
let paths = Paths { - 1335
launch_agents_dir: dir.path().join(format!("{tag}-agents")), - 1336
systemd_unit_dir: dir.path().join(format!("{tag}-units")), - 1337
}; - 1338
(dir, paths) - 1339
} - 1340
- 1341
fn spec_for(def: &ServiceDef, bin_dir: &Path, log_dir: &Path) -> ServiceSpec { - 1342
ServiceSpec { - 1343
name: def.name.to_string(), - 1344
bin_path: bin_dir.join(def.bin_file), - 1345
args: def.args.iter().map(|a| (*a).to_string()).collect(), - 1346
log_path: log_dir.join(def.log_file), - 1347
working_dir: PathBuf::from("/workspace"), - 1348
home_dir: PathBuf::from("/Users/x"), - 1349
path_env: "/usr/bin:/bin".into(), - 1350
keep_alive: def.keep_alive, - 1351
gui: def.gui, - 1352
run_at_load: true, - 1353
} - 1354
} - 1355
- 1356
fn def_named(name: &str) -> &'static ServiceDef { - 1357
SERVICES - 1358
.iter() - 1359
.find(|d| d.name == name) - 1360
.expect("service must exist") - 1361
} - 1362
- 1363
/// The desktop app is a GUI service, and the two ways it differs from - 1364
/// the headless ones are both load-bearing: - 1365
/// - 1366
/// * `RunAtLoad` is the whole point — nothing else brings the menu-bar - 1367
/// icon back after a logout or reboot. - 1368
/// * `KeepAlive` must be off. The tray's `Quit Vak` calls - 1369
/// `app.exit(0)`; with KeepAlive on, launchd relaunches a second - 1370
/// later and Quit visibly does not quit. - 1371
#[test] - 1372
fn desktop_unit_launches_at_login_hidden_and_is_not_kept_alive() { - 1373
let def = def_named("com.vak.desktop"); - 1374
let spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/l")); - 1375
- 1376
let plist = render_launchd_plist(&spec); - 1377
assert!(plist.contains("<string>/opt/vak/bin/vak-desktop</string>")); - 1378
assert!( - 1379
plist.contains("<key>RunAtLoad</key>\n\t<true/>"), - 1380
"the desktop service exists to come back at login: {plist}" - 1381
); - 1382
assert!( - 1383
plist.contains("<key>KeepAlive</key>\n\t<false/>"), - 1384
"KeepAlive would defeat the tray's Quit Vak: {plist}" - 1385
); - 1386
assert!( - 1387
plist.contains("<string>--tray</string>"), - 1388
"a login launch must not throw a window on screen: {plist}" - 1389
); - 1390
assert!(plist.contains("/l/desktop.log")); - 1391
// Without this key launchd runs the app in the background gui/<uid> - 1392
// domain: it starts, logs nothing, never checks in with - 1393
// LaunchServices, gets no WindowServer connection, and therefore - 1394
// draws no menu-bar icon — the exact failure this unit exists to - 1395
// prevent. - 1396
assert!( - 1397
plist.contains("<key>LimitLoadToSessionType</key>\n\t<string>Aqua</string>"), - 1398
"a GUI unit needs an Aqua session or it can draw no tray icon: {plist}" - 1399
); - 1400
- 1401
let unit = render_systemd_unit(&spec); - 1402
assert_eq!(def.systemd_unit(), "vak-desktop.service"); - 1403
assert!( - 1404
unit.contains("ExecStart=/opt/vak/bin/vak-desktop --tray"), - 1405
"{unit}" - 1406
); - 1407
assert!( - 1408
unit.contains("Restart=no"), - 1409
"the systemd analogue of KeepAlive=false: {unit}" - 1410
); - 1411
assert!(unit.contains("WantedBy=default.target"), "{unit}"); - 1412
} - 1413
- 1414
#[test] - 1415
fn desktop_unit_honors_disabled_autostart() { - 1416
let def = def_named("com.vak.desktop"); - 1417
let mut spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/l")); - 1418
spec.run_at_load = false; - 1419
- 1420
let plist = render_launchd_plist(&spec); - 1421
assert!( - 1422
plist.contains("<key>RunAtLoad</key>\n\t<false/>"), - 1423
"disabled autostart renders RunAtLoad false: {plist}" - 1424
); - 1425
- 1426
let systemd = render_systemd_unit(&spec); - 1427
assert!( - 1428
!systemd.contains("WantedBy=default.target"), - 1429
"disabled autostart omits WantedBy: {systemd}" - 1430
); - 1431
} - 1432
- 1433
/// Headless services keep the resurrecting behaviour they have always - 1434
/// had — the GUI exception must not leak into them. - 1435
#[test] - 1436
fn headless_services_are_still_kept_alive() { - 1437
// Per-bot bridge units are generated from bots.json rather than - 1438
// living in SERVICES, and are covered by the multi-bot tests below. - 1439
let name = "com.vak.gateway"; - 1440
let spec = spec_for(def_named(name), Path::new("/b"), Path::new("/l")); - 1441
assert!(render_launchd_plist(&spec).contains("<key>KeepAlive</key>\n\t<true/>")); - 1442
assert!(render_systemd_unit(&spec).contains("Restart=always")); - 1443
// Aqua-pinning a headless daemon would stop it loading in any - 1444
// session without a logged-in GUI user. - 1445
assert!( - 1446
!render_launchd_plist(&spec).contains("LimitLoadToSessionType"), - 1447
"{name} has no UI and must not be pinned to a GUI session" - 1448
); - 1449
} - 1450
- 1451
/// Headless services use the canonical default workspace regardless of - 1452
/// the caller's cwd. The desktop app picks its project in its own UI, so - 1453
/// its service starts from the account home instead. - 1454
#[test] - 1455
fn workspace_scoped_services_use_the_canonical_default() { - 1456
let home = Path::new("/Users/x"); - 1457
let default_workspace = Path::new("/Users/x/vak-home"); - 1458
for def in SERVICES { - 1459
let spec = def.spec(Path::new("/b"), home, default_workspace); - 1460
let expected = if def.workspace_scoped { - 1461
default_workspace - 1462
} else { - 1463
home - 1464
}; - 1465
assert_eq!(spec.working_dir, expected, "{}", def.name); - 1466
} - 1467
} - 1468
- 1469
#[test] - 1470
fn service_templates_follow_the_resolved_port() { - 1471
let gateway = def_named("com.vak.gateway").resolved_args(9123); - 1472
assert_eq!(gateway.last().map(String::as_str), Some("9123")); - 1473
assert_eq!( - 1474
gateway.get(gateway.len() - 2).map(String::as_str), - 1475
Some("--port") - 1476
); - 1477
} - 1478
- 1479
/// An optional component the build never produced must not get a unit - 1480
/// pointing at a path that does not exist. - 1481
#[test] - 1482
fn optional_services_are_skipped_when_their_binary_is_absent() { - 1483
let dir = tempfile::tempdir().unwrap(); - 1484
let cli = dir.path().join("vak"); - 1485
std::fs::write(&cli, b"cli").unwrap(); - 1486
- 1487
let without = default_service_names(&cli); - 1488
assert!(without.contains(&"com.vak.gateway")); - 1489
assert!( - 1490
!without.contains(&"com.vak.desktop"), - 1491
"no vak-desktop binary shipped, so no unit: {without:?}" - 1492
); - 1493
- 1494
std::fs::write(dir.path().join("vak-desktop"), b"gui").unwrap(); - 1495
assert!(default_service_names(&cli).contains(&"com.vak.desktop")); - 1496
} - 1497
- 1498
#[test] - 1499
fn launchd_plist_renders_paths_args_and_zero_secrets() { - 1500
let def = &SERVICES[0]; - 1501
let spec = spec_for( - 1502
def, - 1503
Path::new("/opt/vak/bin"), - 1504
Path::new("/Users/x/.vak/logs"), - 1505
); - 1506
let plist = render_launchd_plist(&spec); - 1507
assert!(plist.contains("/opt/vak/bin/vak")); - 1508
for a in def.args { - 1509
assert!(plist.contains(a), "missing arg {a}"); - 1510
} - 1511
assert!(plist.contains("/Users/x/.vak/logs/gateway.log")); - 1512
assert!(plist.contains("KeepAlive")); - 1513
assert!(plist.contains("RunAtLoad")); - 1514
assert!(plist.contains("<key>HOME</key>")); - 1515
assert!(plist.contains("<string>/Users/x</string>")); - 1516
assert!(plist.contains("<key>PATH</key>")); - 1517
assert!(plist.contains("/usr/bin:/bin")); - 1518
// Update safety (doc 32): units never embed credentials. - 1519
for secret in ["TOKEN", "SECRET", "BOT_TOKEN"] { - 1520
assert!(!plist.contains(secret), "unit must not contain {secret}"); - 1521
} - 1522
} - 1523
- 1524
/// Canonical-layout invariant (doc 32): specs derived from the real - 1525
/// resolver never point logs into the legacy dotdir nor binaries at - 1526
/// a build tree. This is the regression guard for the 0.7 incident - 1527
/// where services silently executed stale images from ad-hoc paths. - 1528
#[test] - 1529
fn resolved_specs_never_reference_legacy_dotdir_or_build_trees() { - 1530
let specs: Vec<ServiceSpec> = - 1531
resolve_specs(Path::new("/Applications/vak.app/Contents/MacOS/vak"), &[]) - 1532
.into_iter() - 1533
.flatten() - 1534
.collect(); - 1535
for spec in specs { - 1536
let log = spec.log_path.to_string_lossy(); - 1537
let bin = spec.bin_path.to_string_lossy(); - 1538
assert!( - 1539
!log.contains("/.vak/"), - 1540
"log path must use the canonical logs dir, got {log}" - 1541
); - 1542
assert!( - 1543
!bin.contains("/target/"), - 1544
"binaries must come from the managed install, got {bin}" - 1545
); - 1546
assert!( - 1547
bin.starts_with("/Applications/vak.app/"), - 1548
"binaries must live inside the installed bundle, got {bin}" - 1549
); - 1550
assert!( - 1551
log.contains("Library/Logs/vak"), - 1552
"logs must land in Library/Logs on macOS, got {log}" - 1553
); - 1554
} - 1555
} - 1556
- 1557
#[test] - 1558
fn systemd_unit_renders_restart_and_exec() { - 1559
let def = &SERVICES[0]; - 1560
let spec = spec_for(def, Path::new("/opt/vak/bin"), Path::new("/h/.vak")); - 1561
let unit = render_systemd_unit(&spec); - 1562
assert!( - 1563
unit.contains("ExecStart="), - 1564
"unit missing ExecStart: {unit}" - 1565
); - 1566
assert!(unit.contains("/opt/vak/bin/vak"), "{unit}"); - 1567
assert!(unit.contains("--gateway"), "{unit}"); - 1568
assert!(unit.contains("Restart=always")); - 1569
} - 1570
- 1571
#[test] - 1572
fn sync_is_created_then_unchanged_when_running() { - 1573
let (_d, paths) = tmp_paths("sync1"); - 1574
let fake = Fake::with_pid(4242); - 1575
let specs: Vec<ServiceSpec> = SERVICES - 1576
.iter() - 1577
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/tmp/logs"))) - 1578
.collect(); - 1579
- 1580
let first = sync_specs(&specs, &paths, &fake); - 1581
assert!( - 1582
matches!(first[0].action, SyncAction::Created), - 1583
"{:?}", - 1584
first[0] - 1585
); - 1586
let unit = std::fs::read_to_string(unit_file_path(SERVICES[0].name, &paths)).unwrap(); - 1587
assert!(unit.contains("/opt/vak/bin/vak")); - 1588
- 1589
let second = sync_specs(&specs, &paths, &fake); - 1590
assert!( - 1591
matches!(second[0].action, SyncAction::Unchanged), - 1592
"{:?}", - 1593
second[0] - 1594
); - 1595
} - 1596
- 1597
#[test] - 1598
fn sync_restarts_when_unit_current_but_process_down() { - 1599
let (_d, paths) = tmp_paths("sync2"); - 1600
let down = Fake::new(); // no pid text => not running - 1601
let up = Fake::with_pid(7); - 1602
let specs: Vec<ServiceSpec> = SERVICES - 1603
.iter() - 1604
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/tmp/logs"))) - 1605
.collect(); - 1606
let _ = sync_specs(&specs, &paths, &up); // create while "running" - 1607
let out = sync_specs(&specs, &paths, &down); - 1608
assert!( - 1609
matches!(out[0].action, SyncAction::Restarted), - 1610
"{:?}", - 1611
out[0] - 1612
); - 1613
} - 1614
- 1615
#[test] - 1616
fn sync_bounces_live_process_predating_installed_binary() { - 1617
let dir = tempfile::tempdir().unwrap(); - 1618
let bin = dir.path().join("vak"); - 1619
std::fs::write(&bin, b"binary").unwrap(); - 1620
let (_d, paths) = tmp_paths("sync3"); - 1621
let fake = Fake::with_pid(99); - 1622
let specs: Vec<ServiceSpec> = SERVICES - 1623
.iter() - 1624
.map(|d| spec_for(d, dir.path(), Path::new("/tmp/logs"))) - 1625
.collect(); - 1626
- 1627
assert!(matches!( - 1628
sync_specs(&specs, &paths, &fake)[0].action, - 1629
SyncAction::Created - 1630
)); - 1631
- 1632
// Simulate `self install` replacing the binary AFTER the unit was - 1633
// written: push the unit's mtime into the past relative to the - 1634
// binary so the running pid predates the current image. - 1635
let now = std::time::SystemTime::now(); - 1636
let unit_path = unit_file_path(SERVICES[0].name, &paths); - 1637
// Unit 10s in the past, binary at now → running pid predates image. - 1638
for (path, age) in [(&unit_path, 10u64), (&bin, 0u64)] { - 1639
let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); - 1640
f.set_modified(now - std::time::Duration::from_secs(age)) - 1641
.unwrap(); - 1642
drop(f); - 1643
} - 1644
- 1645
let second = sync_specs(&specs, &paths, &fake); - 1646
assert!( - 1647
matches!(second[0].action, SyncAction::Bounced), - 1648
"expected Bounced, got {:?}", - 1649
second[0] - 1650
); - 1651
{ - 1652
// Scope the guard: sync_specs locks the same log internally. - 1653
let bounced = fake.cmds.lock().unwrap(); - 1654
assert!( - 1655
bounced.iter().any(|(p, a)| p == "launchctl" - 1656
&& a.first().map(String::as_str) == Some("kickstart") - 1657
&& a.contains(&"-k".to_string())), - 1658
"expected kickstart -k among {:?}", - 1659
*bounced - 1660
); - 1661
} - 1662
- 1663
// The bounce re-stamps the unit, so later syncs are true no-ops. - 1664
let third = sync_specs(&specs, &paths, &fake); - 1665
assert!( - 1666
matches!(third[0].action, SyncAction::Unchanged), - 1667
"staleness must converge after a bounce, got {:?}", - 1668
third[0] - 1669
); - 1670
} - 1671
- 1672
#[test] - 1673
fn status_flags_stale_running_process() { - 1674
let dir = tempfile::tempdir().unwrap(); - 1675
let bin = dir.path().join("vak"); - 1676
std::fs::write(&bin, b"binary").unwrap(); - 1677
let (_d, paths) = tmp_paths("sync4"); - 1678
let fake = Fake::with_pid(11); - 1679
let specs: Vec<ServiceSpec> = SERVICES - 1680
.iter() - 1681
.map(|d| spec_for(d, dir.path(), Path::new("/tmp/logs"))) - 1682
.collect(); - 1683
let _ = sync_specs(&specs, &paths, &fake); - 1684
- 1685
let fresh = status_specs(&specs, &paths, &fake)[0].clone(); - 1686
assert!(!fresh.binary_stale); - 1687
- 1688
// Binary replaced after the unit landed → running pid is stale. - 1689
let now = std::time::SystemTime::now(); - 1690
let unit_path = unit_file_path(SERVICES[0].name, &paths); - 1691
let f = std::fs::OpenOptions::new() - 1692
.write(true) - 1693
.open(&unit_path) - 1694
.unwrap(); - 1695
f.set_modified(now - std::time::Duration::from_secs(10)) - 1696
.unwrap(); - 1697
drop(f); - 1698
let stale = status_specs(&specs, &paths, &fake)[0].clone(); - 1699
assert!(stale.binary_stale, "{stale:?}"); - 1700
assert!(stale.running_pid.is_some()); - 1701
} - 1702
- 1703
/// A service that keeps exiting with an error is reported as failing, - 1704
/// not as down: `launchctl print` names its last exit code. - 1705
#[cfg(target_os = "macos")] - 1706
#[test] - 1707
fn a_failing_service_reports_its_exit_status() { - 1708
let (_d, paths) = tmp_paths("failing"); - 1709
let dir = tempfile::tempdir().unwrap(); - 1710
let specs: Vec<ServiceSpec> = SERVICES - 1711
.iter() - 1712
.map(|d| spec_for(d, dir.path(), Path::new("/tmp/logs"))) - 1713
.collect(); - 1714
let _ = sync_specs(&specs, &paths, &Fake::with_pid(11)); - 1715
let failing = Fake { - 1716
pid_text: Some("com.vak.x = {\n\tlast exit code = 78: EX_CONFIG\n}".into()), - 1717
..Fake::new() - 1718
}; - 1719
let row = status_specs(&specs, &paths, &failing)[0].clone(); - 1720
assert_eq!(row.running_pid, None); - 1721
assert_eq!(row.failed_exit, Some(78)); - 1722
let stopped = Fake { - 1723
pid_text: Some("com.vak.x = {\n\tlast exit code = 0\n}".into()), - 1724
..Fake::new() - 1725
}; - 1726
assert_eq!(status_specs(&specs, &paths, &stopped)[0].failed_exit, None); - 1727
assert_eq!( - 1728
platform::parse_launchd_last_exit("last exit code = (never exited)"), - 1729
None - 1730
); - 1731
} - 1732
- 1733
#[test] - 1734
fn sync_updates_on_drift_and_restores_previous_unit_on_load_failure() { - 1735
let (_d, paths) = tmp_paths("sync3"); - 1736
let good = Fake::with_pid(9); - 1737
let specs: Vec<ServiceSpec> = SERVICES - 1738
.iter() - 1739
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/tmp/logs"))) - 1740
.collect(); - 1741
let _ = sync_specs(&specs, &paths, &good); - 1742
let unit_path = unit_file_path(SERVICES[0].name, &paths); - 1743
let original = std::fs::read_to_string(&unit_path).unwrap(); - 1744
- 1745
// Drifted content + a manager that refuses bootstrap. - 1746
let bad = Fake { - 1747
fail_load: true, - 1748
..Fake::with_pid(9) - 1749
}; - 1750
let drifted = spec_for( - 1751
&SERVICES[0], - 1752
Path::new("/elsewhere/bin"), - 1753
Path::new("/tmp/logs"), - 1754
); - 1755
let out = sync_one(&drifted, &paths, &bad); - 1756
assert!( - 1757
matches!(out.action, SyncAction::Failed(_)), - 1758
"{:?}", - 1759
out.action - 1760
); - 1761
// Rollback: the previous healthy unit is still on disk. - 1762
assert_eq!(std::fs::read_to_string(&unit_path).unwrap(), original); - 1763
- 1764
// A healthy runner sees content drift and reports Updated. - 1765
let moved = sync_one(&drifted, &paths, &good); - 1766
assert!( - 1767
matches!(moved.action, SyncAction::Updated), - 1768
"{:?}", - 1769
moved.action - 1770
); - 1771
} - 1772
- 1773
#[test] - 1774
fn status_flags_units_not_pointing_at_installed_binary() { - 1775
let (_d, paths) = tmp_paths("status"); - 1776
let fake = Fake::with_pid(11); - 1777
let installed: Vec<ServiceSpec> = SERVICES - 1778
.iter() - 1779
.map(|d| spec_for(d, Path::new("/opt/vak/bin"), Path::new("/l"))) - 1780
.collect(); - 1781
let _ = sync_specs(&installed, &paths, &fake); - 1782
- 1783
let rows = status_specs(&installed, &paths, &fake); - 1784
assert!(rows.iter().all(|r| r.unit_points_at_installed), "{rows:?}"); - 1785
- 1786
// Legacy layout: same name, binary inside a build tree ⇒ drift flag. - 1787
let legacy: Vec<ServiceSpec> = SERVICES - 1788
.iter() - 1789
.map(|d| spec_for(d, Path::new("/repo/target/release"), Path::new("/l"))) - 1790
.collect(); - 1791
let rows = status_specs(&legacy, &paths, &fake); - 1792
assert!(rows.iter().all(|r| !r.unit_points_at_installed), "{rows:?}"); - 1793
} - 1794
- 1795
#[test] - 1796
fn uninstall_removes_units_and_tolerates_missing() { - 1797
let (_d, paths) = tmp_paths("rm"); - 1798
let fake = Fake::new(); - 1799
let names: Vec<&str> = SERVICES.iter().map(|d| d.name).collect(); - 1800
services_uninstall(&names, &paths, &fake).unwrap(); // nothing to remove yet - 1801
let installed: Vec<ServiceSpec> = SERVICES - 1802
.iter() - 1803
.map(|d| spec_for(d, Path::new("/b"), Path::new("/l"))) - 1804
.collect(); - 1805
let _ = sync_specs(&installed, &paths, &fake); - 1806
services_uninstall(&names, &paths, &fake).unwrap(); - 1807
for def in SERVICES { - 1808
assert!(!unit_file_path(def.name, &paths).exists()); - 1809
} - 1810
} - 1811
- 1812
#[test] - 1813
fn resolve_specs_reports_unknown_names_without_touching_known_ones() { - 1814
let out = resolve_specs(Path::new("/b"), &["com.vak.gateway", "nope"]); - 1815
assert!(out[0].is_ok()); - 1816
assert!(out[1].as_ref().err().unwrap().contains("unknown service")); - 1817
- 1818
let synced = services_sync(Path::new("/b"), &["nope"], &Paths::default(), &Fake::new()); - 1819
assert!(matches!(synced[0].action, SyncAction::Failed(_))); - 1820
} - 1821
- 1822
fn write_bots_json(data_home: &Path, bots: &[(&str, &str)]) { - 1823
let dir = data_home.join("gateway"); - 1824
std::fs::create_dir_all(&dir).unwrap(); - 1825
let entries: Vec<String> = bots - 1826
.iter() - 1827
.map(|(id, surface)| format!(r#"{{"id":"{id}","surface":"{surface}"}}"#)) - 1828
.collect(); - 1829
std::fs::write( - 1830
dir.join("bots.json"), - 1831
format!(r#"{{"schema":1,"bots":[{}]}}"#, entries.join(",")), - 1832
) - 1833
.unwrap(); - 1834
} - 1835
- 1836
/// One unit per configured bot, named and argued so it resolves that - 1837
/// bot's own token — the fix for the multi-bot regression where a - 1838
/// single static `com.vak.telegram` unit with no `--bot-id` fell back - 1839
/// to the dead legacy `TELEGRAM_BOT_TOKEN` slot. - 1840
#[test] - 1841
fn bot_service_specs_one_per_configured_bot_with_bot_id_arg() { - 1842
let dir = tempfile::tempdir().unwrap(); - 1843
write_bots_json( - 1844
dir.path(), - 1845
&[("VakBot", "telegram"), ("VakyarthaBot", "telegram")], - 1846
); - 1847
let specs = bot_service_specs( - 1848
Path::new("/opt/vak/bin"), - 1849
Path::new("/Users/x"), - 1850
Path::new("/workspace"), - 1851
dir.path(), - 1852
"http://127.0.0.1:8901", - 1853
); - 1854
assert_eq!(specs.len(), 2); - 1855
let names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect(); - 1856
assert!(names.contains(&"com.vak.telegram-VakBot")); - 1857
assert!(names.contains(&"com.vak.telegram-VakyarthaBot")); - 1858
let vakbot = specs - 1859
.iter() - 1860
.find(|s| s.name == "com.vak.telegram-VakBot") - 1861
.unwrap(); - 1862
assert_eq!( - 1863
vakbot.args, - 1864
vec![ - 1865
"telegram", - 1866
"--server", - 1867
"http://127.0.0.1:8901", - 1868
"--bot-id", - 1869
"VakBot" - 1870
], - 1871
); - 1872
assert!(vakbot.keep_alive); - 1873
assert_eq!(vakbot.working_dir, Path::new("/workspace")); - 1874
// Every rendered unit must still carry zero secrets — the token - 1875
// lives only in the env var the bridge process reads for itself. - 1876
let plist = render_launchd_plist(vakbot); - 1877
for secret in ["TOKEN", "SECRET", "BOT_TOKEN"] { - 1878
assert!(!plist.contains(secret), "unit must not contain {secret}"); - 1879
} - 1880
} - 1881
- 1882
/// A surface the CLI has no bridge for (or a bots.json typo) must not - 1883
/// produce a unit that can never run. - 1884
#[test] - 1885
fn bot_service_specs_skips_unknown_surfaces_and_missing_file() { - 1886
let dir = tempfile::tempdir().unwrap(); - 1887
write_bots_json(dir.path(), &[("Weird", "carrier-pigeon")]); - 1888
let specs = bot_service_specs( - 1889
Path::new("/b"), - 1890
Path::new("/h"), - 1891
Path::new("/w"), - 1892
dir.path(), - 1893
"http://127.0.0.1:8901", - 1894
); - 1895
assert!(specs.is_empty()); - 1896
- 1897
let missing = tempfile::tempdir().unwrap(); - 1898
let specs = bot_service_specs( - 1899
Path::new("/b"), - 1900
Path::new("/h"), - 1901
Path::new("/w"), - 1902
missing.path(), - 1903
"http://127.0.0.1:8901", - 1904
); - 1905
assert!( - 1906
specs.is_empty(), - 1907
"no bots.json yet must mean no units, not an error" - 1908
); - 1909
} - 1910
- 1911
/// Deleting (or renaming) a bot must take its bridge process down too — - 1912
/// otherwise it keeps running forever against a token env var nothing - 1913
/// references any more. - 1914
#[test] - 1915
fn sync_bots_prunes_units_for_deleted_bots() { - 1916
let (_d, paths) = tmp_paths("bots-prune"); - 1917
let data = tempfile::tempdir().unwrap(); - 1918
let fake = Fake::with_pid(123); - 1919
- 1920
write_bots_json( - 1921
data.path(), - 1922
&[("VakBot", "telegram"), ("Second", "telegram")], - 1923
); - 1924
let first = sync_bots( - 1925
Path::new("/opt/vak/bin/vak"), - 1926
data.path(), - 1927
"http://127.0.0.1:8901", - 1928
&paths, - 1929
&fake, - 1930
); - 1931
assert_eq!(first.len(), 2); - 1932
assert!(unit_file_path("com.vak.telegram-VakBot", &paths).exists()); - 1933
assert!(unit_file_path("com.vak.telegram-Second", &paths).exists()); - 1934
- 1935
// User deletes "Second" from the admin console. - 1936
write_bots_json(data.path(), &[("VakBot", "telegram")]); - 1937
let second = sync_bots( - 1938
Path::new("/opt/vak/bin/vak"), - 1939
data.path(), - 1940
"http://127.0.0.1:8901", - 1941
&paths, - 1942
&fake, - 1943
); - 1944
assert_eq!( - 1945
second.len(), - 1946
1, - 1947
"only the surviving bot should be (re)synced" - 1948
); - 1949
assert!( - 1950
unit_file_path("com.vak.telegram-VakBot", &paths).exists(), - 1951
"surviving bot's unit must be untouched" - 1952
); - 1953
assert!( - 1954
!unit_file_path("com.vak.telegram-Second", &paths).exists(), - 1955
"deleted bot's unit must be removed, not left running forever" - 1956
); - 1957
} - 1958
- 1959
#[test] - 1960
fn bot_service_name_sanitizes_and_namespaces_by_surface() { - 1961
assert_eq!( - 1962
bot_service_name("telegram", "VakBot"), - 1963
"com.vak.telegram-VakBot" - 1964
); - 1965
assert_eq!( - 1966
bot_service_name("discord", "weird id!"), - 1967
"com.vak.discord-weird_id_" - 1968
); - 1969
} - 1970
- 1971
/// An uninstall must not reach into another install's units. - 1972
/// - 1973
/// The defect: `vak self uninstall --prefix <tmp>` tore down units by - 1974
/// global name against the real units directory, so running the test - 1975
/// suite on a machine with vak installed silently stopped its - 1976
/// services. Ownership is decided by what the unit actually execs. - 1977
#[test] - 1978
fn a_unit_belongs_only_to_the_prefix_it_execs_from() { - 1979
let units = tempfile::tempdir().unwrap(); - 1980
let paths = Paths { - 1981
launch_agents_dir: units.path().to_path_buf(), - 1982
systemd_unit_dir: units.path().to_path_buf(), - 1983
}; - 1984
let name = "com.vak.gateway"; - 1985
std::fs::write( - 1986
unit_file_path(name, &paths), - 1987
"ExecStart=/Applications/Vak.app/Contents/MacOS/vak serve\n", - 1988
) - 1989
.unwrap(); - 1990
- 1991
assert!( - 1992
unit_belongs_to_prefix(name, Path::new("/Applications/Vak.app"), &paths), - 1993
"the real install owns its own unit" - 1994
); - 1995
assert!( - 1996
!unit_belongs_to_prefix(name, Path::new("/tmp/throwaway-prefix"), &paths), - 1997
"a throwaway prefix must not claim a unit it does not exec" - 1998
); - 1999
assert!( - 2000
!unit_belongs_to_prefix("com.vak.absent", Path::new("/anything"), &paths), - 2001
"a unit that does not exist is owned by nobody" - 2002
); - 2003
} - 2004
- 2005
/// Configured and activated are different states, and the difference - 2006
/// has to be visible: a bot in `bots.json` with no unit file is one an - 2007
/// operator created but has not activated yet, not a fault. - 2008
#[test] - 2009
fn a_configured_bot_without_a_unit_reads_as_not_registered() { - 2010
let data = tempfile::tempdir().unwrap(); - 2011
let units = tempfile::tempdir().unwrap(); - 2012
let paths = Paths { - 2013
launch_agents_dir: units.path().to_path_buf(), - 2014
systemd_unit_dir: units.path().to_path_buf(), - 2015
}; - 2016
assert!(configured_bot_service_names_all(data.path()).is_empty()); - 2017
- 2018
write_bots_json(data.path(), &[("VakBot", "telegram")]); - 2019
let names = configured_bot_service_names_all(data.path()); - 2020
assert_eq!(names, vec!["com.vak.telegram-VakBot"]); - 2021
assert!( - 2022
!unit_is_registered(&names[0], &paths), - 2023
"configured is not activated" - 2024
); - 2025
- 2026
std::fs::write(unit_file_path(&names[0], &paths), b"unit").unwrap(); - 2027
assert!(unit_is_registered(&names[0], &paths)); - 2028
} - 2029
- 2030
#[test] - 2031
fn configured_bot_service_names_follow_the_live_bot_store() { - 2032
let data = tempfile::tempdir().unwrap(); - 2033
write_bots_json(data.path(), &[("VakBot", "telegram"), ("Other", "discord")]); - 2034
assert_eq!( - 2035
configured_bot_service_names(data.path(), "telegram"), - 2036
vec!["com.vak.telegram-VakBot"] - 2037
); - 2038
} - 2039
- 2040
#[test] - 2041
fn uninstall_bot_units_removes_every_bot_unit_regardless_of_bots_json() { - 2042
let (_d, paths) = tmp_paths("bots-uninstall"); - 2043
let data = tempfile::tempdir().unwrap(); - 2044
let fake = Fake::with_pid(123); - 2045
- 2046
write_bots_json(data.path(), &[("VakBot", "telegram"), ("Ops", "discord")]); - 2047
sync_bots( - 2048
Path::new("/opt/vak/bin/vak"), - 2049
data.path(), - 2050
"http://127.0.0.1:8901", - 2051
&paths, - 2052
&fake, - 2053
); - 2054
assert!(unit_file_path("com.vak.telegram-VakBot", &paths).exists()); - 2055
assert!(unit_file_path("com.vak.discord-Ops", &paths).exists()); - 2056
- 2057
// Uninstall must remove every bot unit even though bots.json still - 2058
// lists them — an uninstall wants "wanted = nothing", not a diff - 2059
// against the still-present config file. - 2060
uninstall_bot_units(&paths, &fake); - 2061
assert!(!unit_file_path("com.vak.telegram-VakBot", &paths).exists()); - 2062
assert!(!unit_file_path("com.vak.discord-Ops", &paths).exists()); - 2063
} - 2064
} - 2065
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.