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};
15use systemprompt_identifiers::JobName;
16
17use super::routing_decision::{RoutingDecision, decide_routing};
18use super::{args, bootstrap};
19use crate::cli_settings::CliConfig;
20use crate::commands::{admin, infrastructure};
21use crate::descriptor::{CommandDescriptor, RoutingClass};
22use crate::env_overrides::EnvOverrides;
23use crate::interactive;
24use crate::shared::ProfileSource;
25
26/// What the runner does once the profile is bootstrapped.
27///
28/// `RemoteExecuted` means the command has already run on the remote tenant
29/// and its output has been streamed; the runner must not dispatch it again
30/// locally.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum BootstrapOutcome {
33    RemoteExecuted,
34    ContinueLocal,
35    ExternalDbUrl(String),
36}
37
38pub(super) async fn bootstrap_profile(
39    cli: &args::Cli,
40    desc: &CommandDescriptor,
41    cli_config: &CliConfig,
42    env: &EnvOverrides,
43) -> Result<BootstrapOutcome> {
44    let has_export = args::has_local_export_flag(cli.command.as_ref());
45    let ctx = bootstrap::resolve_and_display_profile(cli_config, env, has_export)?;
46
47    require_explicit_cloud_profile(ProfileBootstrap::get()?, ctx.source, desc)?;
48    if enforce_routing_policy(&ctx, cli, desc, cli_config).await?
49        == BootstrapOutcome::RemoteExecuted
50    {
51        return Ok(BootstrapOutcome::RemoteExecuted);
52    }
53
54    let needs_cloud = is_cloud_bypass_command(cli.command.as_ref());
55    initialize_post_routing(&ctx, desc, needs_cloud).await
56}
57
58async fn enforce_routing_policy(
59    ctx: &bootstrap::ProfileContext,
60    cli: &args::Cli,
61    desc: &CommandDescriptor,
62    cli_config: &CliConfig,
63) -> Result<BootstrapOutcome> {
64    let class = desc.routing_class();
65    if !ctx.env.is_deployment_host && class != RoutingClass::LocalOnly && !ctx.has_export {
66        let profile = ProfileBootstrap::get()?;
67        return try_remote_routing(cli, profile, cli_config, desc).await;
68    }
69
70    if ctx.has_export && ctx.is_cloud && !ctx.external_db_access {
71        bail!(
72            "Export with cloud profile '{}' requires external database access.\nEnable \
73             external_db_access in the profile or use a local profile.",
74            ctx.profile.name
75        );
76    }
77
78    if ctx.is_cloud
79        && !ctx.env.is_deployment_host
80        && !ctx.external_db_access
81        && !is_cloud_bypass_command(cli.command.as_ref())
82    {
83        bail!(
84            "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
85             a local profile with --profile <name> or enable external database access.",
86            ctx.profile.name
87        );
88    }
89
90    Ok(BootstrapOutcome::ContinueLocal)
91}
92
93pub fn require_explicit_cloud_profile(
94    profile: &systemprompt_manifest::Profile,
95    source: ProfileSource,
96    desc: &CommandDescriptor,
97) -> Result<()> {
98    if !desc.is_destructive() || source.is_explicit() {
99        return Ok(());
100    }
101
102    let has_tenant = profile
103        .cloud
104        .as_ref()
105        .is_some_and(|cloud| cloud.tenant_id.is_some());
106    if !(has_tenant || profile.target.is_cloud()) {
107        return Ok(());
108    }
109
110    bail!(
111        "profile `{}` is a cloud profile selected implicitly (stored session or directory \
112         discovery); pass `--profile {}` or set SYSTEMPROMPT_PROFILE to target it",
113        profile.name,
114        profile.name
115    )
116}
117
118pub const fn is_cloud_bypass_command(command: Option<&args::Commands>) -> bool {
119    matches!(
120        command,
121        Some(args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_)))
122    )
123}
124
125async fn initialize_post_routing(
126    ctx: &bootstrap::ProfileContext,
127    desc: &CommandDescriptor,
128    needs_cloud: bool,
129) -> Result<BootstrapOutcome> {
130    if needs_cloud || (ctx.is_cloud && ctx.external_db_access) {
131        bootstrap::init_credentials_gracefully(needs_cloud).await?;
132    }
133
134    if desc.secrets() {
135        bootstrap::init_secrets().await?;
136    }
137
138    if ctx.is_cloud && ctx.external_db_access && desc.paths() && !ctx.env.is_deployment_host {
139        let secrets = SecretsBootstrap::get().context("Secrets required for external DB access")?;
140        let db_url = secrets.effective_database_url(true).to_owned();
141        return Ok(BootstrapOutcome::ExternalDbUrl(db_url));
142    }
143
144    if desc.paths() {
145        bootstrap::init_paths(desc.discovers_models()).await?;
146        if !desc.skip_validation() {
147            bootstrap::run_validation()?;
148        }
149    }
150
151    if !ctx.is_cloud {
152        bootstrap::validate_cloud_credentials(&ctx.env);
153    }
154
155    Ok(BootstrapOutcome::ContinueLocal)
156}
157
158async fn try_remote_routing(
159    cli: &args::Cli,
160    profile: &systemprompt_manifest::Profile,
161    cli_config: &CliConfig,
162    desc: &CommandDescriptor,
163) -> Result<BootstrapOutcome> {
164    use super::routing;
165
166    let decision = decide_routing(
167        routing::determine_execution_target(),
168        profile,
169        desc.routing_class(),
170        desc.data_impact(),
171    )?;
172    let RoutingDecision::ExecuteRemote {
173        hostname,
174        token,
175        context,
176    } = decision
177    else {
178        return Ok(BootstrapOutcome::ContinueLocal);
179    };
180
181    confirm_remote_job_run(cli, cli_config, profile, &hostname)?;
182    let args = args::reconstruct_args();
183    let exit_code = routing::execute_remote(&hostname, &token, &context, &args, 300).await?;
184    if exit_code != 0 {
185        bail!("Remote command exited with code {}", exit_code);
186    }
187    Ok(BootstrapOutcome::RemoteExecuted)
188}
189
190pub fn confirm_remote_job_run(
191    cli: &args::Cli,
192    cli_config: &CliConfig,
193    profile: &systemprompt_manifest::Profile,
194    hostname: &str,
195) -> Result<()> {
196    let Some(args::Commands::Infra(infrastructure::InfraCommands::Jobs(
197        infrastructure::jobs::JobsCommands::Run(run_args),
198    ))) = cli.command.as_ref()
199    else {
200        return Ok(());
201    };
202
203    let selection = if run_args.all {
204        "all jobs".to_owned()
205    } else if let Some(tag) = &run_args.tag {
206        format!("jobs tagged '{tag}'")
207    } else {
208        run_args
209            .job_names
210            .iter()
211            .map(JobName::as_str)
212            .collect::<Vec<_>>()
213            .join(", ")
214    };
215
216    let message = format!(
217        "Run {selection} against REMOTE profile '{}' ({hostname})?\nPass --profile \
218         <local-profile> to target a local environment instead. Continue?",
219        profile.name
220    );
221
222    interactive::require_confirmation(
223        &interactive::DialoguerPrompter,
224        &message,
225        run_args.yes,
226        cli_config,
227    )
228}