Skip to main content

systemprompt_cli/runner/
profile_routing.rs

1//! Profile bootstrap and cloud-routing policy for the CLI runner.
2//!
3//! Resolves the active profile, enforces whether a command may run locally or
4//! must route to a remote tenant, and initialises credentials, secrets, and
5//! paths accordingly. The single entry point is `bootstrap_profile`; its
6//! [`BootstrapOutcome`] tells the runner whether the command already ran on
7//! the remote tenant, should continue locally, or should reconnect against a
8//! cloud-issued database.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use anyhow::{Context, Result, bail};
14use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
15
16use super::{args, bootstrap};
17use crate::cli_settings::CliConfig;
18use crate::commands::{admin, infrastructure};
19use crate::descriptor::{CommandDescriptor, RoutingClass};
20use crate::env_overrides::EnvOverrides;
21use crate::interactive;
22use crate::shared::ProfileSource;
23
24/// What the runner does once the profile is bootstrapped.
25///
26/// `RemoteExecuted` means the command has already run on the remote tenant
27/// and its output has been streamed; the runner must not dispatch it again
28/// locally.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum BootstrapOutcome {
31    RemoteExecuted,
32    ContinueLocal,
33    ExternalDbUrl(String),
34}
35
36/// Where a command runs once the routing target is known.
37#[derive(Debug, PartialEq, Eq)]
38pub enum RoutingDecision {
39    ExecuteRemote {
40        hostname: String,
41        token: systemprompt_identifiers::SessionToken,
42        context: systemprompt_identifiers::ContextId,
43    },
44    ContinueLocal,
45}
46
47pub(super) async fn bootstrap_profile(
48    cli: &args::Cli,
49    desc: &CommandDescriptor,
50    cli_config: &CliConfig,
51    env: &EnvOverrides,
52) -> Result<BootstrapOutcome> {
53    let has_export = args::has_local_export_flag(cli.command.as_ref());
54    let ctx = bootstrap::resolve_and_display_profile(cli_config, env, has_export)?;
55
56    require_explicit_cloud_profile(ProfileBootstrap::get()?, ctx.source, desc)?;
57    if enforce_routing_policy(&ctx, cli, desc, cli_config).await?
58        == BootstrapOutcome::RemoteExecuted
59    {
60        return Ok(BootstrapOutcome::RemoteExecuted);
61    }
62
63    let needs_cloud = is_cloud_bypass_command(cli.command.as_ref());
64    initialize_post_routing(&ctx, desc, needs_cloud).await
65}
66
67async fn enforce_routing_policy(
68    ctx: &bootstrap::ProfileContext,
69    cli: &args::Cli,
70    desc: &CommandDescriptor,
71    cli_config: &CliConfig,
72) -> Result<BootstrapOutcome> {
73    let class = desc.routing_class();
74    if !ctx.env.is_deployment_host && class != RoutingClass::LocalOnly && !ctx.has_export {
75        let profile = ProfileBootstrap::get()?;
76        return try_remote_routing(cli, profile, cli_config, class).await;
77    }
78
79    if ctx.has_export && ctx.is_cloud && !ctx.external_db_access {
80        bail!(
81            "Export with cloud profile '{}' requires external database access.\nEnable \
82             external_db_access in the profile or use a local profile.",
83            ctx.profile_name
84        );
85    }
86
87    if ctx.is_cloud
88        && !ctx.env.is_deployment_host
89        && !ctx.external_db_access
90        && !is_cloud_bypass_command(cli.command.as_ref())
91    {
92        bail!(
93            "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
94             a local profile with --profile <name> or enable external database access.",
95            ctx.profile_name
96        );
97    }
98
99    Ok(BootstrapOutcome::ContinueLocal)
100}
101
102pub fn require_explicit_cloud_profile(
103    profile: &systemprompt_models::Profile,
104    source: ProfileSource,
105    desc: &CommandDescriptor,
106) -> Result<()> {
107    if !desc.requires_explicit_cloud_profile() || source.is_explicit() {
108        return Ok(());
109    }
110
111    let has_tenant = profile
112        .cloud
113        .as_ref()
114        .is_some_and(|cloud| cloud.tenant_id.is_some());
115    if !(has_tenant || profile.target.is_cloud()) {
116        return Ok(());
117    }
118
119    bail!(
120        "profile `{}` is a cloud profile selected implicitly (stored session or directory \
121         discovery); pass `--profile {}` or set SYSTEMPROMPT_PROFILE to target it",
122        profile.name,
123        profile.name
124    )
125}
126
127pub const fn is_cloud_bypass_command(command: Option<&args::Commands>) -> bool {
128    matches!(
129        command,
130        Some(args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_)))
131    )
132}
133
134async fn initialize_post_routing(
135    ctx: &bootstrap::ProfileContext,
136    desc: &CommandDescriptor,
137    needs_cloud: bool,
138) -> Result<BootstrapOutcome> {
139    if needs_cloud || (ctx.is_cloud && ctx.external_db_access) {
140        bootstrap::init_credentials_gracefully(needs_cloud).await?;
141    }
142
143    if desc.secrets() {
144        bootstrap::init_secrets().await?;
145    }
146
147    if ctx.is_cloud && ctx.external_db_access && desc.paths() && !ctx.env.is_deployment_host {
148        let secrets = SecretsBootstrap::get().context("Secrets required for external DB access")?;
149        let db_url = secrets.effective_database_url(true).to_owned();
150        return Ok(BootstrapOutcome::ExternalDbUrl(db_url));
151    }
152
153    if desc.paths() {
154        bootstrap::init_paths(desc.discovers_models()).await?;
155        if !desc.skip_validation() {
156            bootstrap::run_validation()?;
157        }
158    }
159
160    if !ctx.is_cloud {
161        bootstrap::validate_cloud_credentials(&ctx.env);
162    }
163
164    Ok(BootstrapOutcome::ContinueLocal)
165}
166
167async fn try_remote_routing(
168    cli: &args::Cli,
169    profile: &systemprompt_models::Profile,
170    cli_config: &CliConfig,
171    class: RoutingClass,
172) -> Result<BootstrapOutcome> {
173    use super::routing;
174
175    let decision = decide_routing(routing::determine_execution_target(), profile, class)?;
176    let RoutingDecision::ExecuteRemote {
177        hostname,
178        token,
179        context,
180    } = decision
181    else {
182        return Ok(BootstrapOutcome::ContinueLocal);
183    };
184
185    confirm_remote_job_run(cli, cli_config, &profile.name, &hostname)?;
186    let args = args::reconstruct_args(cli);
187    let exit_code = routing::execute_remote(&hostname, &token, &context, &args, 300).await?;
188    if exit_code != 0 {
189        bail!("Remote command exited with code {}", exit_code);
190    }
191    Ok(BootstrapOutcome::RemoteExecuted)
192}
193
194pub fn decide_routing(
195    target: Result<super::routing::ExecutionTarget>,
196    profile: &systemprompt_models::Profile,
197    class: RoutingClass,
198) -> Result<RoutingDecision> {
199    use super::routing::ExecutionTarget;
200
201    let is_cloud = profile.target.is_cloud();
202    match target {
203        Ok(ExecutionTarget::Remote {
204            hostname,
205            token,
206            context,
207        }) => Ok(RoutingDecision::ExecuteRemote {
208            hostname,
209            token,
210            context,
211        }),
212        Ok(ExecutionTarget::Local) if is_cloud => {
213            allow_local_execution(profile, class, "no tenant is configured")?;
214            Ok(RoutingDecision::ContinueLocal)
215        },
216        Err(e) if is_cloud => {
217            allow_local_execution(profile, class, &format!("routing failed: {}", e))?;
218            Ok(RoutingDecision::ContinueLocal)
219        },
220        Ok(ExecutionTarget::Local) => Ok(RoutingDecision::ContinueLocal),
221        Err(e) => {
222            tracing::debug!(error = %e, "Routing failed on a local profile; continuing locally");
223            Ok(RoutingDecision::ContinueLocal)
224        },
225    }
226}
227
228pub fn confirm_remote_job_run(
229    cli: &args::Cli,
230    cli_config: &CliConfig,
231    profile_name: &str,
232    hostname: &str,
233) -> Result<()> {
234    let Some(args::Commands::Infra(infrastructure::InfraCommands::Jobs(
235        infrastructure::jobs::JobsCommands::Run(run_args),
236    ))) = cli.command.as_ref()
237    else {
238        return Ok(());
239    };
240
241    let selection = if run_args.all {
242        "all jobs".to_owned()
243    } else if let Some(tag) = &run_args.tag {
244        format!("jobs tagged '{tag}'")
245    } else {
246        run_args.job_names.join(", ")
247    };
248
249    let message = format!(
250        "Run {selection} against REMOTE profile '{profile_name}' ({hostname})?\nPass --profile \
251         <local-profile> to target a local environment instead. Continue?"
252    );
253
254    interactive::require_confirmation(
255        &interactive::DialoguerPrompter,
256        &message,
257        run_args.yes,
258        cli_config,
259    )
260}
261
262pub fn allow_local_execution(
263    profile: &systemprompt_models::Profile,
264    class: RoutingClass,
265    reason: &str,
266) -> Result<()> {
267    if profile.database.external_db_access {
268        tracing::debug!(
269            profile_name = %profile.name,
270            reason = reason,
271            "Cloud profile allowing local execution via external_db_access"
272        );
273        return Ok(());
274    }
275
276    if class == RoutingClass::ReadOnly {
277        tracing::warn!(
278            profile_name = %profile.name,
279            reason = reason,
280            "Cloud profile could not route remotely; reading local data instead"
281        );
282        return Ok(());
283    }
284
285    bail!(
286        "Cloud profile '{}' requires remote execution but {}.\n{}",
287        profile.name,
288        reason,
289        remediation_for(reason)
290    )
291}
292
293pub fn remediation_for(reason: &str) -> &'static str {
294    if reason.contains("load tenants") || reason.contains("tenant") {
295        "Run 'systemprompt cloud tenant list' to sync the tenant store, and check you are in the \
296         project directory this profile belongs to."
297    } else {
298        "Run 'systemprompt admin session login' to authenticate."
299    }
300}