44//! Auto-bootstrap helpers for sandbox creation.
55//!
66//! When `sandbox create` cannot reach a gateway, these helpers determine whether
7- //! to offer gateway bootstrap, prompt the user for confirmation, and execute the
8- //! local or remote bootstrap flow .
7+ //! to attempt gateway bootstrap and execute the local or remote bootstrap flow.
8+ //! Bootstrap proceeds automatically unless the user opts out with `--no-bootstrap` .
99
10- use crate :: tls:: TlsOptions ;
11- use dialoguer:: Confirm ;
10+ use std:: time:: Duration ;
11+
12+ use crate :: tls:: { TlsOptions , grpc_client} ;
1213use miette:: Result ;
1314use owo_colors:: OwoColorize ;
14- use std:: io:: IsTerminal ;
1515
1616use crate :: run:: { deploy_gateway_with_panel, print_deploy_summary} ;
1717
@@ -95,46 +95,55 @@ fn is_connectivity_error(error: &miette::Report) -> bool {
9595 connectivity_patterns. iter ( ) . any ( |p| lower. contains ( p) )
9696}
9797
98- /// Prompt the user to confirm gateway bootstrap.
98+ /// Decide whether gateway bootstrap should proceed .
9999///
100- /// When `override_value` is `Some(true)` or `Some(false)`, the decision is
101- /// made immediately (from `--bootstrap` / `--no-bootstrap`). Otherwise,
102- /// prompts interactively when stdin is a terminal, or returns an error in
103- /// non-interactive mode.
100+ /// When `override_value` is `Some(false)` (from `--no-bootstrap`), returns
101+ /// `false` to skip bootstrap. Otherwise returns `true` — a gateway is created
102+ /// automatically without prompting the user.
104103pub fn confirm_bootstrap ( override_value : Option < bool > ) -> Result < bool > {
105- // Explicit flag takes precedence over interactive detection.
106- if let Some ( value) = override_value {
107- return Ok ( value) ;
108- }
109-
110- if !std:: io:: stdin ( ) . is_terminal ( ) {
111- return Err ( miette:: miette!(
112- "Gateway not reachable and bootstrap requires confirmation from an interactive terminal.\n \
113- Pass --bootstrap to auto-confirm, or run 'openshell gateway start' first."
114- ) ) ;
104+ if let Some ( false ) = override_value {
105+ return Ok ( false ) ;
115106 }
107+ Ok ( true )
108+ }
116109
117- let confirmed = Confirm :: new ( )
118- . with_prompt ( format ! (
119- "{} No gateway available to launch sandbox in. Create one now?" ,
120- "!" . yellow( )
121- ) )
122- . default ( true )
123- . interact ( )
124- . map_err ( |e| miette:: miette!( "failed to read confirmation: {e}" ) ) ?;
125-
126- Ok ( confirmed)
110+ /// Resolve the gateway name for bootstrap.
111+ ///
112+ /// Respects `$OPENSHELL_GATEWAY` if set, otherwise falls back to the default.
113+ fn resolve_bootstrap_name ( ) -> String {
114+ std:: env:: var ( "OPENSHELL_GATEWAY" )
115+ . ok ( )
116+ . filter ( |v| !v. trim ( ) . is_empty ( ) )
117+ . unwrap_or_else ( || DEFAULT_GATEWAY_NAME . to_string ( ) )
127118}
128119
129120/// Bootstrap a local gateway and return refreshed TLS options that pick up the
130- /// newly-written mTLS certificates.
121+ /// newly-written mTLS certificates, along with the gateway name used .
131122pub async fn run_bootstrap (
132123 remote : Option < & str > ,
133124 ssh_key : Option < & str > ,
134- ) -> Result < ( TlsOptions , String ) > {
125+ ) -> Result < ( TlsOptions , String , String ) > {
126+ let gateway_name = resolve_bootstrap_name ( ) ;
135127 let location = if remote. is_some ( ) { "remote" } else { "local" } ;
136128
137- let mut options = navigator_bootstrap:: DeployOptions :: new ( DEFAULT_GATEWAY_NAME ) ;
129+ eprintln ! ( ) ;
130+ eprintln ! (
131+ "{} No gateway found — starting one automatically." ,
132+ "ℹ" . cyan( ) . bold( )
133+ ) ;
134+ eprintln ! ( ) ;
135+ eprintln ! ( " The Gateway provides a secure control plane for OpenShell. It streamlines" ) ;
136+ eprintln ! ( " access for humans and agents alike — handles sandbox orchestration, and" ) ;
137+ eprintln ! ( " enables secure, concurrent agent workflows." ) ;
138+ eprintln ! ( ) ;
139+ eprintln ! (
140+ " Manage it later with: {} or {}" ,
141+ "openshell gateway status" . bold( ) ,
142+ "openshell gateway stop" . bold( ) ,
143+ ) ;
144+ eprintln ! ( ) ;
145+
146+ let mut options = navigator_bootstrap:: DeployOptions :: new ( & gateway_name) ;
138147 if let Some ( dest) = remote {
139148 let mut remote_opts = navigator_bootstrap:: RemoteOptions :: new ( dest) ;
140149 if let Some ( key) = ssh_key {
@@ -151,23 +160,59 @@ pub async fn run_bootstrap(
151160 options = options. with_registry_token ( token) ;
152161 }
153162
154- let handle = deploy_gateway_with_panel ( options, DEFAULT_GATEWAY_NAME , location) . await ?;
163+ let handle = deploy_gateway_with_panel ( options, & gateway_name , location) . await ?;
155164 let server = handle. gateway_endpoint ( ) . to_string ( ) ;
156165
157- print_deploy_summary ( DEFAULT_GATEWAY_NAME , & handle) ;
166+ print_deploy_summary ( & gateway_name , & handle) ;
158167
159168 // Auto-activate the bootstrapped gateway.
160- if let Err ( err) = navigator_bootstrap:: save_active_gateway ( DEFAULT_GATEWAY_NAME ) {
169+ if let Err ( err) = navigator_bootstrap:: save_active_gateway ( & gateway_name ) {
161170 tracing:: debug!( "failed to set active gateway after bootstrap: {err}" ) ;
162171 }
163172
164173 // Build fresh TLS options that resolve the newly-written mTLS certs from
165174 // the default XDG path for this gateway, using the gateway name directly.
166175 let tls = TlsOptions :: default ( )
167- . with_gateway_name ( DEFAULT_GATEWAY_NAME )
176+ . with_gateway_name ( & gateway_name )
168177 . with_default_paths ( & server) ;
169178
170- Ok ( ( tls, server) )
179+ // Wait for the gateway gRPC endpoint to accept connections before
180+ // handing back to the caller. The Docker health check may pass before
181+ // the gRPC listener is fully ready, so retry with backoff.
182+ wait_for_grpc_ready ( & server, & tls) . await ?;
183+
184+ Ok ( ( tls, server, gateway_name) )
185+ }
186+
187+ /// Retry connecting to the gateway gRPC endpoint until it succeeds or a
188+ /// timeout is reached. Uses exponential backoff starting at 500 ms, doubling
189+ /// up to 4 s, with a total deadline of 30 s.
190+ async fn wait_for_grpc_ready ( server : & str , tls : & TlsOptions ) -> Result < ( ) > {
191+ const MAX_WAIT : Duration = Duration :: from_secs ( 30 ) ;
192+ const INITIAL_BACKOFF : Duration = Duration :: from_millis ( 500 ) ;
193+
194+ let start = std:: time:: Instant :: now ( ) ;
195+ let mut backoff = INITIAL_BACKOFF ;
196+ let mut last_err = None ;
197+
198+ while start. elapsed ( ) < MAX_WAIT {
199+ match grpc_client ( server, tls) . await {
200+ Ok ( _client) => return Ok ( ( ) ) ,
201+ Err ( err) => {
202+ tracing:: debug!(
203+ elapsed = ?start. elapsed( ) ,
204+ "gateway not yet accepting connections: {err:#}"
205+ ) ;
206+ last_err = Some ( err) ;
207+ }
208+ }
209+ tokio:: time:: sleep ( backoff) . await ;
210+ backoff = ( backoff * 2 ) . min ( Duration :: from_secs ( 4 ) ) ;
211+ }
212+
213+ Err ( last_err
214+ . unwrap_or_else ( || miette:: miette!( "timed out waiting for gateway" ) )
215+ . wrap_err ( "gateway deployed but not accepting connections after 30 s" ) )
171216}
172217
173218#[ cfg( test) ]
0 commit comments