1macro_rules! tool {
18 ($r:expr, $ctx:expr, $name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
19 let ctx = $ctx.clone();
20 let f = $f;
21 $r.register(
22 Tool::new($name, $desc, $schema, move |a, c| f(&ctx, a, c))
23 .title($title)
24 .annotations($ann.clone()),
25 )?;
26 }};
27}
28
29mod accounts;
30pub mod apps;
31pub mod audit;
32mod authorize;
33pub mod builds;
34pub mod data;
35mod default_org;
36mod dns;
37mod egress;
38mod kube;
39mod monitors;
40mod notify;
41mod orgs;
42pub mod policy;
43pub mod previews;
44pub mod secrets;
45mod servers;
46mod ssh;
47pub mod superadmin;
48pub mod templates;
49mod terminal;
50mod tools;
51pub mod volumes;
52pub mod workspaces;
53
54use std::collections::BTreeMap;
55use std::path::PathBuf;
56use std::sync::Arc;
57use std::time::{Duration, Instant};
58
59use serde::Deserialize;
60use serde::de::DeserializeOwned;
61use serde_json::{Value, json};
62
63use crate::auth::AuthConfig;
64use crate::auth::AuthStore;
65use crate::auth::http::{ApiConfig, AuthApi};
66use crate::client::Client;
67use crate::error::{Error, Result};
68use crate::exec::{ExecOptions, Stdin};
69use crate::sandbox::{EnsureOptions, Sandbox, SandboxInfo};
70use crate::server::{AccessValidator, Caller, Listener, Registry, Tool, ToolPolicy};
71use crate::spec::{ComposeFile, SandboxSpec};
72use crate::stack::{Controller, StackDef, Store, now_secs};
73use authorize::{CROSS_ORG_READS, PLATFORM_TOOLS, arg_org, authorize_class, tool_listed};
74use policy::RemotePolicy;
75use tools::Ann;
76
77pub const LABEL_OWNER: &str = "isb.owner";
79
80#[derive(Debug, Clone)]
82pub struct ServeConfig {
83 pub listen: Vec<String>,
87 pub socket: PathBuf,
88 pub access: Option<(String, String)>,
90 pub allow_unauthenticated: bool,
92 pub remote_tools: ToolPolicy,
94 pub policy: RemotePolicy,
95 pub state_dir: PathBuf,
96 pub interval: Duration,
97 pub keys: crate::secrets::KeySources,
99 pub secrets_config: PathBuf,
101 pub auth: AuthConfig,
103 pub public_url: Option<String>,
106 pub oauth: crate::auth::oauth::OAuthSettings,
108 pub open_signup: bool,
110 pub ingress: Option<crate::ingress::IngressConfig>,
112 pub workspace_mcp_port: u16,
115 pub workspace_pool: Option<String>,
118 pub workspace_home_root: Option<PathBuf>,
121 pub preview_domain: Option<workspaces::PreviewBase>,
123 pub audit_retention: Duration,
125 pub audit_all: bool,
127 pub history_retention: Duration,
129 pub history_max_rows: i64,
130 pub agent: Option<AgentConfig>,
133 pub superadmin_tailnet: Option<crate::server::tailnet::AllowList>,
136 pub superadmin_access: Option<superadmin::AccessAllowList>,
139 pub heartbeat: Option<crate::monitor::heartbeat::Heartbeat>,
141 pub egress_pins: Vec<String>,
144 pub egress_ca: Vec<PathBuf>,
146}
147
148#[derive(Debug, Clone)]
150pub struct AgentConfig {
151 pub listen: String,
153 pub tls_dir: PathBuf,
155}
156
157fn auth_routes(
161 cfg: &ServeConfig,
162 store: Arc<AuthStore>,
163 secrets: &Arc<crate::secrets::Secrets>,
164 log: &Arc<crate::audit::AuditLog>,
165 gate: Arc<superadmin::Gate>,
166) -> Result<crate::server::Routes> {
167 use crate::auth::oauth::SecretFn;
168 let default_org = crate::org::OrgId::default_org();
169 let lookup = |name: &str| -> Option<SecretFn> {
170 secrets.inspect(&default_org, name).ok()?;
171 let (s, org, name) = (secrets.clone(), default_org.clone(), name.to_string());
172 Some(Arc::new(move || {
173 let (v, _) = s.get(&org, &name).map_err(|e| e.to_string())?;
174 String::from_utf8(v)
175 .map(|v| v.trim().to_string())
176 .map_err(|_| "the secret is not UTF-8".to_string())
177 }))
178 };
179 let (providers, notes) = cfg.oauth.providers(&lookup);
180 for n in notes {
181 eprintln!("isb serve: {n}");
182 }
183 let path = crate::auth::db_path(&cfg.state_dir);
184 let agent_gate = gate.clone();
185 let agent_ways = gate.agent_ways().with_public_url(cfg.public_url.clone());
186 let api = AuthApi::new(
187 store.clone(),
188 ApiConfig {
189 agent: Some(Arc::new(move |r: &crate::server::http::Request| {
190 agent_gate.agent(r, None)
191 })),
192 agent_ways,
193 public_url: cfg.public_url.clone(),
194 notifier: None,
195 setup_token_file: Some(cfg.state_dir.join("setup-token")),
196 providers,
197 open_signup: cfg.open_signup,
198 audit: Some(log.clone()),
199 superadmin: Some(Arc::new(move |r: &crate::server::http::Request| match gate
200 .resolve(r, None)
201 {
202 superadmin::Resolved::Superadmin(s) => Some(s),
203 _ => None,
204 })),
205 },
206 )?;
207 eprintln!("isb serve: identity store {}", path.display());
208 if !crate::web::BUILT {
209 eprintln!(
210 "isb serve: this binary was built without the web UI (a placeholder page is served)"
211 );
212 }
213 let (auth, web) = (Arc::new(api).router(), crate::web::routes());
216 let tail = audit::stream_route(log.clone(), store);
217 Ok(Arc::new(move |r| {
218 auth(r).or_else(|| tail(r)).or_else(|| web(r))
219 }))
220}
221
222struct Daemon {
223 client: Client,
224 ctl: Controller,
225 policy: RemotePolicy,
226 state_dir: PathBuf,
227 secrets: Arc<crate::secrets::Secrets>,
228 apps: crate::app::Apps,
229 ingress: Option<Arc<crate::ingress::Manager>>,
230 users: Arc<AuthStore>,
232 notifier: crate::notify::Notifier,
233 monitors: crate::monitor::Monitors,
234 history: crate::metrics_history::History,
235 data: data::Ctx,
237 volumes: crate::volume_backup::VolumeBackups,
239 audit: Arc<crate::audit::AuditLog>,
240 servers: Option<Arc<crate::servers::Servers>>,
243 gate: Arc<superadmin::Gate>,
245 host: Value,
246 catalogs: Arc<crate::template::catalog::Catalogs>,
248 workspaces: Arc<workspaces::Workspaces>,
251 public_url: Option<String>,
253 egress: Arc<isb_egress::Manager>,
255}
256
257#[expect(
259 clippy::too_many_lines,
260 reason = "predates the lint ratchet; split it when next changed"
261)]
262pub fn serve(client: Client, cfg: ServeConfig) -> Result<()> {
263 client
264 .server_info()
265 .map_err(|e| Error::invalid(format!("isb serve needs incusd: {e}")))?;
266 default_org::warn_old_incus(&client);
267 let store = Store::open(&cfg.state_dir)?;
268 dns::open_dns_path(&cfg.state_dir);
269 if cfg.agent.is_none() {
272 default_org::ensure(&client, &store);
273 }
274 let secrets_config = crate::secrets::SecretsConfig::load(&cfg.secrets_config)?;
275 let opened = crate::secrets::Secrets::open(&cfg.state_dir, &cfg.keys, &secrets_config)?;
276 for n in &opened.notes {
277 eprintln!("isb serve: {n}");
278 }
279 let secrets = Arc::new(with_external_drivers(opened.secrets, &cfg.state_dir)?);
280 for r in crate::stack::migrate::run(&store, &secrets, Some(&client)) {
283 match r {
284 Ok(m) => eprintln!("isb serve: {m}"),
285 Err(e) => eprintln!("isb serve: WARNING: {e}"),
286 }
287 }
288 let db = crate::auth::db_path(&cfg.state_dir);
291 let users = Arc::new(
292 AuthStore::open_with(&db, cfg.auth.clone())
293 .map_err(|e| Error::invalid(format!("open {}: {e}", db.display())))?,
294 );
295 let audit_db = crate::audit::db_path(&cfg.state_dir);
296 let audit_log = Arc::new(
297 crate::audit::AuditLog::open(&audit_db, cfg.audit_retention)?
298 .with_history_limits(cfg.history_retention, cfg.history_max_rows),
299 );
300 let recorder = crate::history::Recorder::start(audit_log.clone());
303 let stop_history = Arc::new(std::sync::atomic::AtomicBool::new(false));
304 history_start(&audit_log, &recorder, &client, &stop_history);
305 eprintln!(
306 "isb serve: audit log {} (kept {} days{})",
307 audit_db.display(),
308 cfg.audit_retention.as_secs() / 86400,
309 if cfg.audit_all {
310 ", reads included"
311 } else {
312 ""
313 }
314 );
315 if cfg.agent.is_some() && !cfg.listen.is_empty() {
316 return Err(Error::invalid(
317 "--agent serves its control plane only: drop --listen (users reach the control plane)",
318 ));
319 }
320 let access = match &cfg.access {
321 Some((team, aud)) => Some(Arc::new(AccessValidator::new(team, aud)?)),
322 None => None,
323 };
324 let gate = Arc::new(superadmin::gate(&cfg, users.clone(), access.clone())?);
325 let auth = if cfg.listen.is_empty() {
326 None
327 } else {
328 Some(auth_routes(
329 &cfg,
330 users.clone(),
331 &secrets,
332 &audit_log,
333 gate.clone(),
334 )?)
335 };
336 let servers = match &cfg.agent {
337 None => Some(crate::servers::Servers::open(&cfg.state_dir)?),
338 Some(_) => None,
339 };
340 match crate::registry::Registry::open(&client, Some(&cfg.state_dir)) {
343 Ok(Some(r)) => {
344 eprintln!("isb serve: local registry {}", r.info().url());
345 crate::registry::install(Arc::new(r));
346 }
347 Ok(None) => {}
348 Err(e) => eprintln!("isb serve: WARNING: local registry: {e}"),
349 }
350 let ingress = match &cfg.ingress {
353 Some(ic) => Some(crate::ingress::Manager::new(
354 ic.clone(),
355 client.clone(),
356 secrets.clone(),
357 &cfg.state_dir,
358 )?),
359 None => None,
360 };
361 let observer = ingress
362 .clone()
363 .map(|m| m as Arc<dyn crate::stack::controller::Observer>);
364 let ctl = Controller::start_with(
365 client.clone(),
366 store,
367 cfg.interval,
368 secrets.clone(),
369 observer,
370 )?;
371 if let Some(m) = &ingress {
372 m.start(ctl.clone())?;
373 }
374 let apps = crate::app::Apps::new(&cfg.state_dir, client.clone(), ctl.clone(), secrets.clone());
375 let ra = apps.clone();
377 let resolve: crate::notify::Resolve = Arc::new(move |org, stack, service| {
378 let a = ra.get(org, service).ok()?;
379 let own = a.spec.stack().ok()? == stack;
381 let preview = stack.starts_with(&format!("{}-", a.spec.project))
382 && crate::app::preview::is_pr_suffix(stack);
383 (own || preview).then_some(a.spec.project)
384 });
385 let notifier = crate::notify::Notifier::new(&cfg.state_dir, secrets.clone(), resolve)?;
386 notifier.start(ctl.clone());
387 let monitors = monitors::start(&cfg, &apps, &secrets, ¬ifier);
388 ctl.set_event_sink(recorder.controller_sink());
390 let history = crate::metrics_history::History::new(&cfg.state_dir);
392 ctl.set_metrics_sink(history.start());
393 apps.start_preview_upkeep();
395 let jobs = crate::jobs::Jobs::new(&cfg.state_dir, apps.clone());
397 let backups = crate::backup::Backups::new(&cfg.state_dir, apps.clone());
398 let volumes =
399 crate::volume_backup::VolumeBackups::new(&cfg.state_dir, apps.clone(), backups.clone());
400 let scheduler = crate::jobs::Scheduler::start(vec![
401 Arc::new(jobs.clone()) as Arc<dyn crate::jobs::Scheduled>,
402 Arc::new(backups.clone()),
403 Arc::new(volumes.clone()),
404 ]);
405 jobs.set_scheduler(scheduler.clone());
406 backups.set_scheduler(scheduler.clone());
407 volumes.set_scheduler(scheduler.clone());
408 if let Some(ic) = &cfg.ingress {
409 if ic.tunnel_port == cfg.workspace_mcp_port {
410 return Err(Error::invalid(format!(
411 "--workspace-mcp-port {} is the ingress's tunnel port; pick another",
412 cfg.workspace_mcp_port
413 )));
414 }
415 }
416 let workspaces = workspaces::Workspaces::new(
417 &cfg.state_dir,
418 client.clone(),
419 secrets.clone(),
420 recorder.clone(),
421 cfg.workspace_mcp_port,
422 cfg.workspace_pool.clone(),
423 cfg.workspace_home_root.clone(),
424 );
425 workspaces.previews.set_base(cfg.preview_domain.clone());
426 let (egress, stop_egress) = egress::start(&cfg, &client, &secrets)?;
427 let d = Arc::new(Daemon {
428 client,
429 ctl: ctl.clone(),
430 policy: cfg.policy.clone(),
431 state_dir: cfg.state_dir.clone(),
432 secrets,
433 apps: apps.clone(),
434 ingress: ingress.clone(),
435 users: users.clone(),
436 notifier: notifier.clone(),
437 monitors: monitors.clone(),
438 history,
439 data: data::Ctx {
440 apps: apps.clone(),
441 jobs,
442 backups,
443 },
444 volumes,
445 audit: audit_log.clone(),
446 servers: servers.clone(),
447 gate: gate.clone(),
448 host: superadmin::host_summary(&cfg, &gate),
449 catalogs: Arc::new(crate::template::catalog::Catalogs::new(&cfg.state_dir)),
450 workspaces: workspaces.clone(),
451 public_url: cfg.public_url.clone(),
452 egress,
453 });
454 if let Some(s) = &servers {
455 s.start(ctl.clone());
456 }
457 let registry = registry(d.clone())?;
458 let mut hooks = hooks(d.clone(), users.clone(), cfg.allow_unauthenticated);
459 superadmin::announce(&cfg, &gate, &users);
460 hooks.audit = Some(audit::hook(audit_log.clone(), cfg.audit_all));
461 if servers.is_some() {
462 hooks.route = Some(servers::route(d.clone()));
463 }
464 let webhooks = {
467 let w = apps::webhook_routes(apps.clone());
468 let w = match &servers {
469 Some(s) => servers::forward_webhooks(w, s.clone()),
470 None => w,
471 };
472 audit::audited_webhooks(w, audit_log.clone())
473 };
474 let auth = auth.map(|a| -> crate::server::Routes {
476 let logo = templates::logo::route(
477 d.catalogs.clone(),
478 Arc::new(templates::logo::Logos::new(&cfg.state_dir)),
479 templates::logo::admit(
480 hooks.authn.clone().expect("the daemon authenticates"),
481 access.clone(),
482 cfg.allow_unauthenticated,
483 ),
484 );
485 Arc::new(move |r| logo(r).or_else(|| a(r)))
486 });
487 let mut listeners = vec![Listener::unix(&cfg.socket).hooks(hooks.clone())];
488 if let Some(ac) = &cfg.agent {
489 listeners.push(servers::agent_listener(
490 d.clone(),
491 &hooks,
492 ac,
493 webhooks.clone(),
494 )?);
495 }
496 for addr in &cfg.listen {
497 let tailnet = superadmin::is_tailnet_listen(addr);
498 let mut l = Listener::tcp(addr.clone())
499 .policy(cfg.remote_tools.clone())
500 .hooks(hooks.clone())
501 .public_routes(webhooks.clone())
502 .preview(workspaces::preview_route(d.clone()))
503 .tailnet(tailnet);
504 if let Some(r) = &auth {
505 l = l.routes(r.clone());
506 }
507 listeners.push(match &access {
508 Some(v) if !tailnet => l.access_shared(v.clone()),
510 _ => l.allow_unauthenticated(true),
514 });
515 }
516 let hd = d.clone();
517 let healthz: crate::server::Healthz = Arc::new(move || {
518 let stacks: Vec<Value> = hd
519 .ctl
520 .list()
521 .into_iter()
522 .map(|s| json!({"name": s.name, "converged": s.converged}))
523 .collect();
524 (
525 true,
526 json!({"ok": true, "isb": env!("CARGO_PKG_VERSION"), "stacks": stacks}),
527 )
528 });
529 let registry = Arc::new(registry);
533 workspaces.set_serving(
534 Listener::tcp("org-bridge")
535 .policy(cfg.remote_tools.clone())
536 .hooks(hooks.clone())
537 .allow_unauthenticated(true),
538 registry.clone(),
539 healthz.clone(),
540 );
541 let local: workspaces::LocalOrg = {
542 let d = d.clone();
543 Arc::new(move |o: &crate::org::OrgId| d.remote(o).is_none())
544 };
545 workspaces.start(ctl.clone(), local);
546 workspaces::start_ports(d.clone());
547 let r = crate::server::serve_shared(listeners, registry, healthz);
548 workspaces.shutdown();
549 stop_egress.store(true, std::sync::atomic::Ordering::Relaxed);
550 stop_history.store(true, std::sync::atomic::Ordering::Relaxed);
551 recorder.record(crate::history::marker(
552 "serve.stopped",
553 "isb serve stopped: incus events from now on are not observed".into(),
554 json!({"version": env!("CARGO_PKG_VERSION")}),
555 ));
556 recorder.shutdown();
557 if let Some(s) = &servers {
558 s.shutdown();
559 }
560 notifier.shutdown();
561 monitors.shutdown();
562 scheduler.shutdown();
563 ctl.shutdown();
564 if let Some(m) = &ingress {
565 m.shutdown();
566 }
567 r
568}
569
570fn history_start(
573 log: &Arc<crate::audit::AuditLog>,
574 rec: &Arc<crate::history::Recorder>,
575 client: &Client,
576 stop: &Arc<std::sync::atomic::AtomicBool>,
577) {
578 use crate::history::{HistoryQuery, marker};
579 let now = crate::audit::now_ms();
580 let last = log
581 .history_list(
582 &HistoryQuery::default(),
583 &crate::audit::Visibility::All,
584 None,
585 None,
586 1,
587 )
588 .ok()
589 .and_then(|v| v.into_iter().next());
590 if let Some(l) = last {
591 let clean = l.kind == "serve.stopped";
592 let reason = if clean {
593 "isb serve was not running"
594 } else {
595 "isb serve was not running (it did not stop cleanly)"
596 };
597 rec.record(marker(
598 "incus.gap",
599 format!(
600 "incus events between {} and {} were not observed: {reason}",
601 crate::history::fmt_ms(l.time),
602 crate::history::fmt_ms(now),
603 ),
604 json!({"from": l.time, "to": now, "reason": reason}),
605 ));
606 }
607 rec.record(marker(
608 "serve.started",
609 format!("isb serve {} started", env!("CARGO_PKG_VERSION")),
610 json!({"version": env!("CARGO_PKG_VERSION"), "pid": std::process::id()}),
611 ));
612 let (c, r, s) = (client.clone(), rec.clone(), stop.clone());
613 let _ = std::thread::Builder::new()
614 .name("isb-incus-events".into())
615 .spawn(move || crate::history::watch_incus(c, r, s));
616}
617
618fn with_external_drivers(
620 secrets: crate::secrets::Secrets,
621 state_dir: &std::path::Path,
622) -> Result<crate::secrets::Secrets> {
623 use crate::secrets::{Driver, local::LocalDriver, onepassword};
624 let local = Arc::new(LocalDriver::new(state_dir, secrets.keyring().clone()));
625 let token: onepassword::TokenSource =
626 Arc::new(move |org| match local.get(org, onepassword::TOKEN_SECRET) {
627 Ok((v, _)) => Ok(Some(
628 String::from_utf8(v)
629 .map_err(|_| Error::invalid("the 1Password token is not text"))?
630 .trim()
631 .to_string(),
632 )),
633 Err(e) if e.is_not_found() => Ok(None),
634 Err(e) => Err(e),
635 });
636 secrets.with_driver(Arc::new(onepassword::OnePasswordDriver::new(token)))
637}
638
639fn visible_orgs(c: &Caller) -> Option<Vec<crate::org::OrgId>> {
641 match c.principal() {
642 Some(p) if !p.platform_admin => Some(p.orgs.iter().map(|(o, _)| o.clone()).collect()),
643 _ => None,
644 }
645}
646
647fn hooks(d: Arc<Daemon>, users: Arc<AuthStore>, allow_anonymous: bool) -> crate::server::Hooks {
649 use crate::server::Authenticated;
650 let term = terminal::terminal(d.clone());
651 let ssh = ssh::ssh(d.clone(), users.clone());
652 let u = users.clone();
653 let gate = d.gate.clone();
654 let wsa = d.workspaces.clone();
655 let authn: crate::server::mcp::Authn = Arc::new(move |req, id| {
656 match gate.resolve(req, id) {
657 superadmin::Resolved::Superadmin(s) => return Authenticated::Superadmin(s),
658 superadmin::Resolved::Refused => return Authenticated::Refused,
659 superadmin::Resolved::None => {}
660 }
661 if let Some(t) = bearer(req) {
663 if t.starts_with(crate::auth::secret::TokenKind::Workspace.prefix()) {
664 return match wsa.authenticate(t) {
665 Some(p) => Authenticated::User(Arc::new(p)),
666 None => Authenticated::Refused,
667 };
668 }
669 }
670 if req.header("authorization").is_some()
671 || req
672 .header("cookie")
673 .is_some_and(|c| c.contains("isb_session="))
674 {
675 return match u.principal_from_request(req) {
676 Some(p) => Authenticated::User(Arc::new(p)),
677 None => Authenticated::Refused,
678 };
679 }
680 if let Some(email) = id.and_then(|i| i.email.as_deref()) {
682 if let Ok(Some(p)) = u.principal_for_email(email) {
683 return Authenticated::User(Arc::new(p));
684 }
685 }
686 if let Some(p) = gate.agent(req, id) {
688 return Authenticated::User(Arc::new(p));
689 }
690 Authenticated::None
691 });
692 let authorize: crate::server::mcp::Authorize = Arc::new(move |c, tool, args, scope| {
693 let args = crate::server::aliases::alias_args(tool, args);
695 authorize_class(
696 c,
697 &tool.name,
698 audit::class_for(tool, &args),
699 args,
700 scope,
701 allow_anonymous,
702 )
703 });
704 let events: crate::server::mcp::Events = Arc::new(move |c, since| {
705 if let (Caller::Unauthenticated { .. }, false) = (c, allow_anonymous) {
706 return Err(Error::Forbidden("sign in to follow events".into()));
707 }
708 if let Caller::Access(id) = c {
709 return Err(Error::Forbidden(format!(
710 "{} has no isb account",
711 id.name()
712 )));
713 }
714 let orgs = visible_orgs(c);
715 let ctl = d.ctl.clone();
716 Ok(Box::new(move |w: &mut dyn std::io::Write| {
717 let mut since = since;
718 loop {
719 let (seq, evs) = ctl.wait_events(since, 200, Duration::from_secs(15));
720 let mut wrote = false;
721 for e in evs {
722 if !event_visible(&orgs, &e.stack) {
723 continue;
724 }
725 let data = serde_json::to_string(&e).unwrap_or_default();
726 write!(w, "id: {}\nevent: {}\ndata: {data}\n\n", e.seq, e.level)?;
727 wrote = true;
728 }
729 if !wrote {
730 w.write_all(b": keepalive\n\n")?;
732 }
733 w.flush()?;
734 since = seq.max(since);
735 }
736 }))
737 });
738 crate::server::Hooks {
739 authn: Some(authn),
740 authorize: Some(authorize),
741 events: Some(events),
742 terminal: Some(term),
743 ssh: Some(ssh),
744 audit: None,
745 route: None,
746 listed: Some(Arc::new(tool_listed)),
747 refuse_anonymous: !allow_anonymous,
748 }
749}
750
751fn bearer(req: &crate::server::http::Request) -> Option<&str> {
753 let (scheme, token) = req.header("authorization")?.trim().split_once(' ')?;
754 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
755}
756
757fn event_visible(orgs: &Option<Vec<crate::org::OrgId>>, stack: &str) -> bool {
759 let Some(orgs) = orgs else { return true };
760 let org = stack
761 .split_once('/')
762 .map(|(o, _)| o)
763 .unwrap_or(crate::org::DEFAULT_ORG);
764 orgs.iter().any(|o| o.as_str() == org)
765}
766
767fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
768 serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
769}
770
771fn obj(mut props: Value, required: &[&str]) -> Value {
772 props["org"] =
774 json!({"type": "string", "description": "The org to act in (default: default)."});
775 json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
776}
777
778fn qname(org: &Option<String>, name: &str) -> Result<String> {
780 let org = match org {
781 Some(o) => crate::org::OrgId::new(o.clone())?,
782 None => crate::org::OrgId::default_org(),
783 };
784 Ok(crate::stack::qualified(&org, name))
785}
786
787fn caller_name(c: &Caller) -> String {
788 c.to_string()
789}
790
791fn registry(d: Arc<Daemon>) -> Result<Registry> {
793 let mut r = Registry::new().instructions(INSTRUCTIONS);
794 superadmin::register(&mut r, d.clone())?;
795 let ann = Ann {
796 ro: json!({"readOnlyHint": true, "openWorldHint": false}),
797 destructive: json!({"destructiveHint": true, "openWorldHint": false}),
798 write: json!({"destructiveHint": false, "openWorldHint": false}),
799 };
800
801 tools::stack_deploy_tool(&mut r, &d, &ann)?;
802 tools::overview_tool(&mut r, &d, &ann)?;
803 tools::events_tool(&mut r, &d, &ann)?;
804 tools::ingress_status_tool(&mut r, &d, &ann)?;
805 tools::stack_list_tool(&mut r, &d, &ann)?;
806 tools::stack_status_tool(&mut r, &d, &ann)?;
807 tools::stack_config_tool(&mut r, &d, &ann)?;
808 tools::stack_logs_tool(&mut r, &d, &ann)?;
809 tools::stack_scale_tool(&mut r, &d, &ann)?;
810 tools::stack_edit_tools(&mut r, &d, &ann)?;
811 tools::sandbox_create_tool(&mut r, &d, &ann)?;
812 let ctl = d.ctl.clone();
813 let bindings: secrets::Bindings = Arc::new(move |org: &crate::org::OrgId| {
814 let mut out = Vec::new();
815 for def in ctl.definitions().iter().filter(|def| def.org == *org) {
816 let used = crate::stack::secrets::used_keys(&def.file);
817 for (_, b) in def.secrets.iter().filter(|(k, _)| used.contains(*k)) {
818 out.push(secrets::Binding {
819 name: b.name.clone(),
820 driver: b.driver.clone(),
821 version: b.version,
822 stack: def.name.clone(),
823 });
824 }
825 }
826 out
827 });
828 let ctl = d.ctl.clone();
829 let in_use: secrets::InUse = Arc::new(move |org: &crate::org::OrgId, name: &str| {
830 ctl.definitions()
831 .iter()
832 .filter(|def| def.org == *org && def.store_secrets().contains(name))
833 .map(|def| def.name.clone())
834 .collect()
835 });
836 let ctl = d.ctl.clone();
837 let changed: secrets::Changed =
838 Arc::new(move |org: &crate::org::OrgId, name: &str| ctl.secret_changed(org, name));
839 let ctl = d.ctl.clone();
840 let refresh: secrets::Refresh =
841 Arc::new(move |org: &crate::org::OrgId, name: &str| ctl.refresh_secret(org, name));
842 secrets::register(
843 &mut r,
844 d.secrets.clone(),
845 secrets::Hooks {
846 in_use,
847 changed,
848 refresh,
849 bindings,
850 },
851 )?;
852 builds::register(
853 &mut r,
854 builds::Ctx {
855 client: d.client.clone(),
856 policy: d.policy.clone(),
857 ctl: d.ctl.clone(),
858 },
859 )?;
860 tools::sandbox_tools(&mut r, &d, &ann)?;
861 apps::register(&mut r, d.apps.clone(), d.ingress.is_some())?;
862 previews::register(&mut r, d.apps.clone())?;
863 let mut t = templates::Templates::new(
864 &d.state_dir,
865 d.apps.clone(),
866 d.secrets.clone(),
867 d.ingress.as_ref().and_then(|m| m.public_ip()),
868 );
869 t.catalogs = d.catalogs.clone();
870 templates::register(&mut r, t)?;
871 data::register(&mut r, d.data.clone())?;
872 volumes::register(&mut r, d.volumes.clone())?;
873 tools::server_status_tool(&mut r, &d, &ann)?;
874 orgs::register(&mut r, d.clone())?;
875 notify::register(
876 &mut r,
877 d.notifier.clone(),
878 d.history.clone(),
879 d.apps.clone(),
880 )?;
881 monitors::register(&mut r, d.monitors.clone())?;
882 audit::register(&mut r, d.audit.clone())?;
883 audit::register_history(&mut r, d.audit.clone())?;
884 servers::register(&mut r, d.clone())?;
885 ssh::register(&mut r, d.clone())?;
886 workspaces::register(&mut r, d.clone())?;
887 accounts::register(&mut r, d.clone())?;
888 Ok(r)
889}
890
891const INSTRUCTIONS: &str = "isb runs incus containers and VMs on this host. Two uses: \
892stacks (long-running services from a docker-compose-style file, with replicas, health checks, \
893rolling updates and a load balancer: stack_deploy, then stack_status) and sandboxes \
894(an isolated machine to run code in: sandbox_create, sandbox_exec, sandbox_remove). \
895Images: local incus aliases (dev-base), images:debian/12, OCI images (docker:nginx:1.27, ghcr:org/app:tag), \
896or the org's own builds in the local registry (registry:APP:TAG; build_run makes them, registry_list lists them). \
897Deploys return immediately; poll stack_status, or pass wait=true. \
898Each org also has a secret store (secret_create, secret_set, secret_list; values are base64). \
899Apps (Dokploy-style): project_create, then app_create (an image, or a repository with a builder), \
900app_env_set, app_deploy (or app_apply: a YAML definition that creates or updates, dry_run to diff first); each project environment runs as one stack <project>-<env>. \
901One-click apps: template_list, template_get, then template_deploy (dry_run first shows the plan). \
902Databases are apps too (database_create; connection details via database_get), backed up to S3-compatible \
903destinations on a cron schedule (backup_destination_create, backup_create, backup_run, backup_restore). \
904Scheduled jobs run commands against an app on a cron schedule (job_create, job_runs, job_run_log).";
905
906impl Daemon {
907 fn reachable(&self, c: &Caller, i: &SandboxInfo) -> bool {
910 c.is_trusted()
913 || c.principal().is_some()
914 || self.policy.any_instance
915 || i.config.contains_key("user.isb.stack")
916 || i.config.contains_key(&format!("user.{LABEL_OWNER}"))
917 }
918
919 fn oc(&self, org: &Option<String>) -> Result<Client> {
922 let org = crate::org::OrgId::new(org.as_deref().unwrap_or(crate::org::DEFAULT_ORG))?;
923 crate::org::check_exists(&self.client, &org)?;
924 Ok(crate::org::client(&self.client, &org))
925 }
926
927 fn reach(&self, c: &Caller, oc: &Client, name: &str) -> Result<SandboxInfo> {
928 let info = Sandbox::get(oc, name)?.info()?;
929 if !self.reachable(c, &info) {
930 return Err(Error::NotFound(format!("sandbox {name}")));
933 }
934 Ok(info)
935 }
936
937 fn workspaces_def(
940 &self,
941 org: &crate::org::OrgId,
942 name: &str,
943 ) -> Result<crate::workspace::Workspace> {
944 crate::workspace::Store::new(&self.state_dir)
945 .get(org, name)?
946 .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
947 }
948
949 fn files_dir(&self, stack: &str) -> Result<PathBuf> {
950 let p = self.state_dir.join("files").join(stack);
951 std::fs::create_dir_all(&p)?;
952 Ok(p)
953 }
954}
955
956#[derive(Deserialize)]
957#[serde(deny_unknown_fields)]
958struct DeployArgs {
959 name: String,
960 #[serde(default)]
961 org: Option<String>,
962 #[serde(default)]
964 compose: Option<String>,
965 #[serde(default)]
967 file: Option<ComposeFile>,
968 #[serde(default)]
969 vars: BTreeMap<String, String>,
970 #[serde(default)]
971 secrets: BTreeMap<String, String>,
972 #[serde(default)]
973 base_dir: Option<PathBuf>,
974 #[serde(default)]
975 wait: bool,
976 #[serde(default)]
977 dry_run: bool,
978 #[serde(default)]
979 timeout: Option<String>,
980}
981
982#[expect(
983 clippy::too_many_lines,
984 reason = "predates the lint ratchet; split it when next changed"
985)]
986fn stack_deploy(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
987 let a: DeployArgs = args(a)?;
988 crate::stack::validate_stack_name(&a.name)?;
989 if a.name == crate::ingress::cloudflare::TUNNEL_STACK {
990 return Err(Error::invalid(format!(
991 "stack name {} is isb's (an org's cloudflared)",
992 a.name
993 )));
994 }
995 let base = match &a.base_dir {
996 Some(b) => {
997 if !b.is_absolute() {
998 return Err(Error::invalid("base_dir must be absolute"));
999 }
1000 if !c.is_trusted() {
1001 d.policy.check_base_dir(b)?;
1002 }
1003 b.clone()
1004 }
1005 None => d.files_dir(&a.name)?,
1006 };
1007 let file = match (a.file, a.compose) {
1008 (Some(_), _) if !c.is_trusted() => {
1009 return Err(Error::invalid("remote callers send `compose` as YAML text"));
1010 }
1011 (Some(f), None) => f,
1012 (None, Some(text)) => {
1013 let vars = a.vars.clone();
1015 let lookup = move |k: &str| vars.get(k).cloned();
1016 crate::compose::load_docs(
1017 &[(PathBuf::from("compose.yaml"), text)],
1018 &base,
1019 Some(&a.name),
1020 &lookup,
1021 )?
1022 .file
1023 }
1024 _ => return Err(Error::invalid("pass exactly one of compose or file")),
1025 };
1026 if !c.is_trusted() {
1027 d.policy.check_file(&file, &base)?;
1028 }
1029 let org = match &a.org {
1030 Some(o) => crate::org::OrgId::new(o.clone())?,
1031 None => crate::org::OrgId::default_org(),
1032 };
1033 let mut given: BTreeMap<String, Vec<u8>> = BTreeMap::new();
1036 for key in crate::stack::secrets::used_keys(&file) {
1037 let Some(def) = file.secrets.get(&key) else {
1038 continue;
1039 };
1040 if !def.is_client_side() {
1041 continue;
1042 }
1043 let v = a.secrets.get(&key).cloned().or_else(|| {
1044 def.environment
1045 .as_ref()
1046 .and_then(|e| a.vars.get(e).cloned())
1047 });
1048 if let Some(v) = v {
1049 given.insert(key, v.into_bytes());
1050 }
1051 }
1052 let mut def = StackDef {
1053 name: a.name.clone(),
1054 org: org.clone(),
1055 file,
1056 base_dir: base,
1057 secrets: BTreeMap::new(),
1058 force: BTreeMap::new(),
1059 images: BTreeMap::new(),
1060 deployed_at: now_secs(),
1061 deployed_by: caller_name(c),
1062 previous: None,
1063 };
1064 d.ctl.validate(&def)?;
1066 if let Some(m) = &d.ingress {
1067 m.check(&def)?;
1068 }
1069 def.secrets =
1070 crate::stack::secrets::bind(&d.secrets, &org, &a.name, &def.file, &given, a.dry_run)?;
1071 if a.dry_run {
1072 return Ok(json!({"changes": d.ctl.plan(&def)?, "dry_run": true}));
1073 }
1074 let who = def.deployed_by.clone();
1075 let changes = d.ctl.deploy(def)?;
1076 let summary: Vec<String> = changes
1077 .iter()
1078 .filter(|c| c.change != "unchanged")
1079 .map(|c| format!("{} {}", c.service, c.change))
1080 .collect();
1081 d.ctl.note(
1082 "info",
1083 &crate::stack::qualified(&org, &a.name),
1084 format!(
1085 "deployed by {who}: {}",
1086 if summary.is_empty() {
1087 "no changes".to_string()
1088 } else {
1089 summary.join(", ")
1090 }
1091 ),
1092 );
1093 if !a.wait {
1094 return Ok(json!({"changes": changes}));
1095 }
1096 let timeout = match &a.timeout {
1097 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1098 None => Duration::from_secs(600),
1099 };
1100 let st = wait_settled(&d.ctl, &crate::stack::qualified(&org, &a.name), timeout)?;
1101 Ok(json!({"changes": changes, "status": st}))
1102}
1103
1104pub fn wait_settled(
1107 ctl: &Controller,
1108 name: &str,
1109 timeout: Duration,
1110) -> Result<crate::stack::controller::StackStatus> {
1111 let started = Instant::now();
1112 let def = ctl.definition(name)?;
1113 loop {
1114 let st = ctl.status(name)?;
1115 let settled = st.services.iter().all(|s| {
1118 let current = def.revision(&s.service).is_ok_and(|r| r == s.rev)
1119 && def
1120 .service(&s.service)
1121 .is_ok_and(|d| d.replicas() == s.replicas);
1122 current && matches!(s.state.as_str(), "converged" | "paused" | "failing")
1123 });
1124 if settled || started.elapsed() >= timeout {
1125 return Ok(st);
1126 }
1127 std::thread::sleep(Duration::from_secs(1));
1128 }
1129}
1130
1131#[derive(Deserialize)]
1132#[serde(untagged)]
1133enum SpecArg {
1134 Text(String),
1135 Object(Box<SandboxSpec>),
1136}
1137
1138#[expect(
1139 clippy::too_many_lines,
1140 reason = "predates the lint ratchet; split it when next changed"
1141)]
1142fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1143 #[derive(Deserialize)]
1144 struct A {
1145 spec: Value,
1146 #[serde(default)]
1147 wait_ready: Option<bool>,
1148 #[serde(default)]
1149 expires: Option<String>,
1150 #[serde(default)]
1151 idle_timeout: Option<String>,
1152 #[serde(default)]
1153 org: Option<String>,
1154 }
1155 let org = arg_org(&a)?;
1156 let a: A = args(a)?;
1157 let mut spec = match serde_json::from_value::<SpecArg>(a.spec)
1158 .map_err(|e| Error::invalid(format!("spec: {e}")))?
1159 {
1160 SpecArg::Text(t) => serde_yaml_ng::from_str::<SandboxSpec>(&t)
1161 .map_err(|e| Error::invalid(format!("spec: {e}")))?,
1162 SpecArg::Object(s) => *s,
1163 };
1164 let name = spec
1165 .name
1166 .clone()
1167 .ok_or_else(|| Error::invalid("spec needs container_name"))?;
1168 egress::check_secrets(&d.secrets, &org, &spec)?;
1169 let base = if c.is_local() {
1170 std::env::current_dir()?
1171 } else {
1172 d.files_dir("_sandboxes")?
1173 };
1174 if let Caller::Superadmin(s) = c {
1175 spec.labels.insert(LABEL_OWNER.into(), s.label());
1177 }
1178 if !c.is_trusted() {
1179 d.policy.check_spec(&spec, &base)?;
1180 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1181 if !d.reachable(c, &sb.info()?) {
1183 return Err(Error::AlreadyExists(name));
1184 }
1185 }
1186 spec.labels.insert(LABEL_OWNER.into(), owner_label(c));
1187 }
1188 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1189 let info = sb.info()?;
1190 if info.config.contains_key(crate::workspace::KEY_WORKSPACE) {
1192 return Err(Error::invalid(format!(
1193 "{name} is the org's workspace; pick another name"
1194 )));
1195 }
1196 }
1197 if !spec
1200 .raw_devices
1201 .get("root")
1202 .is_some_and(|r| r.contains_key("size"))
1203 && workspaces::project_has_disk_limit(&d.client, &org)
1204 {
1205 spec.raw_devices
1206 .entry("root".into())
1207 .or_default()
1208 .insert("size".into(), workspaces::SANDBOX_ROOT_SIZE.into());
1209 }
1210 let settings = d.workspaces.settings(&org)?;
1212 let (expires_at, idle) = crate::workspace::sandbox_deadlines(
1213 &settings,
1214 a.expires.as_deref(),
1215 a.idle_timeout.as_deref(),
1216 now_secs(),
1217 )?;
1218 spec.labels
1219 .insert("isb.expires_at".into(), expires_at.to_string());
1220 match idle {
1221 Some(s) => {
1222 spec.labels.insert("isb.idle_timeout".into(), s.to_string());
1223 }
1224 None => {
1225 spec.labels.insert("isb.idle_timeout".into(), "0".into());
1226 }
1227 }
1228 let opts = EnsureOptions {
1229 wait_ready: a.wait_ready.unwrap_or(true),
1230 ..Default::default()
1231 };
1232 let mut log: Vec<String> = Vec::new();
1233 let (sb, report) = Sandbox::connect_or_create_with_base(
1234 &d.oc(&a.org)?,
1235 &spec,
1236 &Default::default(),
1237 &base,
1238 opts,
1239 &mut |m| log.push(m.to_string()),
1240 )?;
1241 d.workspaces.mark_active(&org.incus_project(), &name);
1242 d.egress.kick();
1243 Ok(json!({
1244 "info": sb.info()?,
1245 "report": report,
1246 "log": log,
1247 "expires_at": expires_at,
1248 "idle_timeout": idle,
1249 "message": format!(
1250 "{name} expires {} from now{}; sandbox_extend pushes it out.",
1251 crate::workspace::human(expires_at.saturating_sub(now_secs())),
1252 match idle {
1253 Some(s) => format!(" and is deleted after {} idle", crate::workspace::human(s)),
1254 None => String::new(),
1255 }
1256 ),
1257 }))
1258}
1259
1260fn owner_label(c: &Caller) -> String {
1262 match c {
1263 Caller::Superadmin(s) => s.label(),
1264 Caller::User { principal } if principal.is_workspace() => {
1265 crate::auth::WORKSPACE_ACTOR.to_string()
1266 }
1267 _ => format!("mcp:{}", caller_name(c)),
1268 }
1269}
1270
1271fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1272 #[derive(Deserialize)]
1273 #[serde(deny_unknown_fields)]
1274 struct A {
1275 name: String,
1276 #[serde(default)]
1277 by: Option<String>,
1278 #[serde(default)]
1279 idle_timeout: Option<String>,
1280 #[serde(default)]
1281 org: Option<String>,
1282 }
1283 let org = arg_org(&a)?;
1284 let a: A = args(a)?;
1285 let oc = d.oc(&a.org)?;
1286 let info = d.reach(c, &oc, &a.name)?;
1287 let labels: BTreeMap<String, String> = info
1288 .config
1289 .iter()
1290 .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
1291 .collect();
1292 if crate::workspace::kind_of(&labels) != "sandbox" {
1293 return Err(Error::invalid(format!(
1294 "{} is a {}, not a sandbox: it does not expire",
1295 a.name,
1296 crate::workspace::kind_of(&labels)
1297 )));
1298 }
1299 let mine = labels
1301 .get("isb.owner")
1302 .is_some_and(|o| *o == owner_label(c));
1303 let admin = match c {
1304 Caller::Local { .. } | Caller::Superadmin(_) => true,
1305 Caller::User { principal } => {
1306 principal.platform_admin
1307 || principal
1308 .role_in(&org)
1309 .is_some_and(|r| r >= crate::auth::Role::Admin)
1310 }
1311 _ => false,
1312 };
1313 if !mine && !admin {
1314 return Err(Error::Forbidden(format!(
1315 "{} was created by {}; its creator or the org's admins extend it",
1316 a.name,
1317 labels
1318 .get("isb.owner")
1319 .map(String::as_str)
1320 .unwrap_or("someone else")
1321 )));
1322 }
1323 let now = now_secs();
1324 let mut patch = serde_json::Map::new();
1325 let current = labels
1326 .get("isb.expires_at")
1327 .and_then(|v| v.parse::<u64>().ok());
1328 let by = match &a.by {
1329 Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?,
1330 None if a.idle_timeout.is_some() => Duration::ZERO,
1331 None => Duration::from_secs(86400),
1332 };
1333 let mut expires_at = current;
1334 if !by.is_zero() {
1335 let e = crate::workspace::extended(current, by, now)?;
1336 patch.insert(
1337 crate::workspace::KEY_EXPIRES_AT.into(),
1338 json!(e.to_string()),
1339 );
1340 expires_at = Some(e);
1341 }
1342 let mut idle = labels
1343 .get("isb.idle_timeout")
1344 .and_then(|v| v.parse::<u64>().ok())
1345 .filter(|s| *s > 0);
1346 if let Some(t) = &a.idle_timeout {
1347 idle = crate::workspace::idle(t)?.map(|d| d.as_secs());
1348 patch.insert(
1349 crate::workspace::KEY_IDLE_TIMEOUT.into(),
1350 json!(idle.unwrap_or(0).to_string()),
1351 );
1352 }
1353 oc.mutate(
1354 "PATCH",
1355 &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)),
1356 Some(&json!({"config": patch})),
1357 &format!("extend sandbox {}", a.name),
1358 oc.timeouts.other,
1359 )?;
1360 d.workspaces.mark_active(&org.incus_project(), &a.name);
1361 Ok(json!({
1362 "name": a.name,
1363 "expires_at": expires_at,
1364 "idle_timeout": idle,
1365 "message": format!(
1366 "{} now expires {} from now.",
1367 a.name,
1368 crate::workspace::human(expires_at.unwrap_or(now).saturating_sub(now))
1369 ),
1370 }))
1371}
1372
1373const OUTPUT_CAP: usize = 256 * 1024;
1374
1375fn cap(b: &[u8]) -> (String, bool) {
1376 if b.len() <= OUTPUT_CAP {
1377 return (String::from_utf8_lossy(b).into_owned(), false);
1378 }
1379 (
1380 String::from_utf8_lossy(&b[b.len() - OUTPUT_CAP..]).into_owned(),
1381 true,
1382 )
1383}
1384
1385fn sandbox_exec(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1386 #[derive(Deserialize)]
1387 struct A {
1388 name: String,
1389 #[serde(default)]
1390 org: Option<String>,
1391 argv: Vec<String>,
1392 cwd: Option<String>,
1393 user: Option<String>,
1394 #[serde(default)]
1395 env: BTreeMap<String, String>,
1396 stdin: Option<String>,
1397 timeout: Option<String>,
1398 }
1399 let org = arg_org(&a)?;
1400 let a: A = args(a)?;
1401 let oc = d.oc(&a.org)?;
1402 d.reach(c, &oc, &a.name)?;
1403 d.workspaces.mark_active(&org.incus_project(), &a.name);
1404 let timeout = match &a.timeout {
1405 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1406 None => Duration::from_secs(600),
1407 };
1408 let mut opts = ExecOptions::default().timeout(timeout);
1409 opts.cwd = a.cwd;
1410 opts.user = a.user;
1411 opts.env = a.env;
1412 if let Some(s) = a.stdin {
1413 opts.stdin = Stdin::Bytes(s.into_bytes());
1414 }
1415 let sb = Sandbox::get(&oc, &a.name)?;
1416 let out = match sb.exec_with(a.argv, opts) {
1417 Err(Error::ExecTimeout { .. }) => {
1418 return Err(Error::invalid(format!(
1419 "timed out after {timeout:?} and was killed"
1420 )));
1421 }
1422 r => r?,
1423 };
1424 let (stdout, t1) = cap(&out.stdout);
1425 let (stderr, t2) = cap(&out.stderr);
1426 Ok(
1427 json!({"exit_code": out.exit_code, "stdout": stdout, "stderr": stderr, "truncated": t1 || t2}),
1428 )
1429}
1430
1431pub use crate::stack::local_deploy_args;
1432
1433pub fn default_state_dir() -> PathBuf {
1435 Store::default_dir()
1436}
1437
1438#[cfg(test)]
1439#[path = "agent_tests.rs"]
1440mod agent_tests;
1441
1442#[cfg(test)]
1443mod tests;
1444
1445#[cfg(test)]
1446mod downscope_tests;