systemprompt_cli/runner/
profile_routing.rs1use 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#[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}