1use 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum BootstrapOutcome {
31 RemoteExecuted,
32 ContinueLocal,
33 ExternalDbUrl(String),
34}
35
36#[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}