@@ -5,7 +5,10 @@ use std::time::Duration;
55use crate :: cli:: ai_client:: {
66 build_prompt, create_provider, ollama_complete_streaming, AiTask , PromptContext ,
77} ;
8- use crate :: cli:: config_parser:: { AiProviderType , DeployTarget , ServerConfig , StackerConfig } ;
8+ use crate :: cli:: config_parser:: {
9+ AiProviderType , CloudConfig , CloudOrchestrator , CloudProvider , DeployTarget , ServerConfig ,
10+ StackerConfig ,
11+ } ;
912use crate :: cli:: credentials:: CredentialsManager ;
1013use crate :: cli:: deployment_lock:: DeploymentLock ;
1114use crate :: cli:: error:: CliError ;
@@ -601,6 +604,97 @@ fn print_ai_deploy_help(project_dir: &Path, config_file: Option<&str>, err: &Cli
601604 }
602605}
603606
607+ /// Map a provider code string (as stored in CloudInfo.provider) to a `CloudProvider` enum.
608+ ///
609+ /// Accepts both short codes ("htz", "do", "aws", "lo", "vu") and full names
610+ /// ("hetzner", "digitalocean", "aws", "linode", "vultr").
611+ fn cloud_provider_from_code ( code : & str ) -> Option < CloudProvider > {
612+ match code. to_lowercase ( ) . as_str ( ) {
613+ "htz" | "hetzner" => Some ( CloudProvider :: Hetzner ) ,
614+ "do" | "digitalocean" => Some ( CloudProvider :: Digitalocean ) ,
615+ "aws" => Some ( CloudProvider :: Aws ) ,
616+ "lo" | "linode" => Some ( CloudProvider :: Linode ) ,
617+ "vu" | "vultr" => Some ( CloudProvider :: Vultr ) ,
618+ _ => None ,
619+ }
620+ }
621+
622+ /// Interactively prompt the user to select a saved cloud credential when
623+ /// no `deploy.cloud` section is present in stacker.yml.
624+ ///
625+ /// - Fetches the list of saved clouds from the Stacker server.
626+ /// - Presents an interactive `Select` menu with each cloud plus a
627+ /// "Connect a new cloud provider" option at the end.
628+ /// - Returns:
629+ /// - `Ok(Some(cloud_info))` when the user picks an existing credential.
630+ /// - `Ok(None)` when the user picks "Connect a new cloud provider".
631+ /// - `Err(...)` on I/O or network errors.
632+ fn prompt_select_cloud (
633+ access_token : & str ,
634+ ) -> Result < Option < stacker_client:: CloudInfo > , CliError > {
635+ let base_url = crate :: cli:: install_runner:: normalize_stacker_server_url (
636+ stacker_client:: DEFAULT_STACKER_URL ,
637+ ) ;
638+
639+ let rt = tokio:: runtime:: Builder :: new_current_thread ( )
640+ . enable_all ( )
641+ . build ( )
642+ . map_err ( |e| CliError :: ConfigValidation ( format ! ( "Failed to create async runtime: {}" , e) ) ) ?;
643+
644+ let clouds = rt. block_on ( async {
645+ let client = StackerClient :: new ( & base_url, access_token) ;
646+ client. list_clouds ( ) . await
647+ } ) ?;
648+
649+ const CONNECT_NEW : & str = "→ Connect a new cloud provider" ;
650+
651+ if clouds. is_empty ( ) {
652+ eprintln ! ( ) ;
653+ eprintln ! ( " No saved cloud credentials found." ) ;
654+ eprintln ! ( " To add cloud credentials, export your provider token and redeploy:" ) ;
655+ eprintln ! ( " HCLOUD_TOKEN=<token> stacker deploy --target cloud # Hetzner" ) ;
656+ eprintln ! ( " DO_API_TOKEN=<token> stacker deploy --target cloud # DigitalOcean" ) ;
657+ eprintln ! ( " AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret> stacker deploy --target cloud # AWS" ) ;
658+ eprintln ! ( ) ;
659+ return Err ( CliError :: CloudProviderMissing ) ;
660+ }
661+
662+ // Column widths for the interactive cloud selection menu.
663+ const CLOUD_ID_WIDTH : usize = 6 ;
664+ const CLOUD_NAME_WIDTH : usize = 24 ;
665+
666+ let mut items: Vec < String > = clouds
667+ . iter ( )
668+ . map ( |c| format ! ( "{:<width_id$} {:<width_name$} ({})" , c. id, c. name, c. provider,
669+ width_id = CLOUD_ID_WIDTH , width_name = CLOUD_NAME_WIDTH ) )
670+ . collect ( ) ;
671+ items. push ( CONNECT_NEW . to_string ( ) ) ;
672+
673+ eprintln ! ( ) ;
674+ eprintln ! ( " No cloud provider configured in stacker.yml." ) ;
675+ eprintln ! ( " Select a saved cloud credential to use for this deployment:" ) ;
676+ eprintln ! ( ) ;
677+
678+ let selection = dialoguer:: Select :: new ( )
679+ . with_prompt ( "Cloud credential" )
680+ . items ( & items)
681+ . default ( 0 )
682+ . interact ( )
683+ . map_err ( |e| CliError :: ConfigValidation ( format ! ( "Selection error: {}" , e) ) ) ?;
684+
685+ if selection == clouds. len ( ) {
686+ // User chose "Connect a new cloud provider"
687+ return Ok ( None ) ;
688+ }
689+
690+ Ok ( Some (
691+ clouds
692+ . into_iter ( )
693+ . nth ( selection)
694+ . expect ( "selection index should be within bounds of clouds vector" ) ,
695+ ) )
696+ }
697+
604698/// `stacker deploy [--target local|cloud|server] [--file stacker.yml] [--dry-run] [--force-rebuild]`
605699/// `stacker deploy --project=myapp --target cloud --key devops --server bastion`
606700///
@@ -741,7 +835,7 @@ pub fn run_deploy(
741835 None => project_dir. join ( DEFAULT_CONFIG_FILE ) ,
742836 } ;
743837
744- let config = StackerConfig :: from_file ( & config_path) ?;
838+ let mut config = StackerConfig :: from_file ( & config_path) ?;
745839 ensure_env_file_if_needed ( & config, project_dir) ?;
746840
747841 // 2. Resolve deploy target (flag > config)
@@ -830,11 +924,82 @@ pub fn run_deploy(
830924 }
831925 }
832926
833- // 3. Cloud/server prerequisites
834- if deploy_target == DeployTarget :: Cloud {
835- // Verify login
927+ // 3. Cloud/server prerequisites — verify login and keep credentials for later use.
928+ let cloud_creds = if deploy_target == DeployTarget :: Cloud {
836929 let cred_manager = CredentialsManager :: with_default_store ( ) ;
837- cred_manager. require_valid_token ( "cloud deploy" ) ?;
930+ Some ( cred_manager. require_valid_token ( "cloud deploy" ) ?)
931+ } else {
932+ None
933+ } ;
934+
935+ // 3b. If cloud target but no cloud section in stacker.yml, prompt to select a saved credential.
936+ if deploy_target == DeployTarget :: Cloud && config. deploy . cloud . is_none ( ) {
937+ let access_token = & cloud_creds
938+ . as_ref ( )
939+ . expect ( "cloud_creds should be set when deploy_target is Cloud (verified in step 3)" )
940+ . access_token ;
941+
942+ match prompt_select_cloud ( access_token) ? {
943+ Some ( cloud_info) => {
944+ // Map the provider code to a CloudProvider enum value.
945+ let provider = cloud_provider_from_code ( & cloud_info. provider )
946+ . ok_or_else ( || CliError :: ConfigValidation ( format ! (
947+ "Unrecognised cloud provider '{}' for credential '{}'. \
948+ Supported providers: hetzner (htz), digitalocean (do), aws, linode (lo), vultr (vu).",
949+ cloud_info. provider, cloud_info. name
950+ ) ) ) ?;
951+
952+ eprintln ! (
953+ " Selected cloud credential: {} (id={}, provider={})" ,
954+ cloud_info. name, cloud_info. id, cloud_info. provider
955+ ) ;
956+
957+ // Apply the selected cloud to the in-memory config.
958+ config. deploy . target = DeployTarget :: Cloud ;
959+ config. deploy . cloud = Some ( CloudConfig {
960+ provider,
961+ orchestrator : CloudOrchestrator :: Remote ,
962+ region : None ,
963+ size : None ,
964+ install_image : None ,
965+ remote_payload_file : None ,
966+ ssh_key : None ,
967+ key : Some ( cloud_info. name . clone ( ) ) ,
968+ server : None ,
969+ } ) ;
970+
971+ // Persist the selection to stacker.yml so subsequent deploys
972+ // do not prompt again.
973+ if config_path. exists ( ) {
974+ let yaml = serde_yaml:: to_string ( & config) . map_err ( |e| {
975+ CliError :: ConfigValidation ( format ! (
976+ "Failed to serialize updated config: {}" ,
977+ e
978+ ) )
979+ } ) ?;
980+ std:: fs:: write ( & config_path, yaml) ?;
981+ eprintln ! (
982+ " ✓ Updated {} with deploy.cloud.key={}" ,
983+ config_path. display( ) ,
984+ cloud_info. name
985+ ) ;
986+ }
987+ }
988+ None => {
989+ // User chose "Connect a new cloud provider"
990+ eprintln ! ( ) ;
991+ eprintln ! ( " To connect a new cloud provider, export your API token and redeploy:" ) ;
992+ eprintln ! ( " Hetzner: HCLOUD_TOKEN=<token> stacker deploy --target cloud" ) ;
993+ eprintln ! ( " DigitalOcean: DO_API_TOKEN=<token> stacker deploy --target cloud" ) ;
994+ eprintln ! ( " Linode: LINODE_TOKEN=<token> stacker deploy --target cloud" ) ;
995+ eprintln ! ( " Vultr: VULTR_API_KEY=<key> stacker deploy --target cloud" ) ;
996+ eprintln ! ( " AWS: AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret> stacker deploy --target cloud" ) ;
997+ eprintln ! ( ) ;
998+ eprintln ! ( " Or configure manually with: stacker config setup cloud" ) ;
999+ eprintln ! ( ) ;
1000+ return Err ( CliError :: CloudProviderMissing ) ;
1001+ }
1002+ }
8381003 }
8391004
8401005 // 4. Validate via strategy
@@ -1969,6 +2134,27 @@ services:
19692134 assert_eq ! ( result. project_id, Some ( 7 ) ) ;
19702135 }
19712136
2137+ #[ test]
2138+ fn test_cloud_provider_from_code ( ) {
2139+ // Short codes
2140+ assert_eq ! ( cloud_provider_from_code( "htz" ) , Some ( CloudProvider :: Hetzner ) ) ;
2141+ assert_eq ! ( cloud_provider_from_code( "do" ) , Some ( CloudProvider :: Digitalocean ) ) ;
2142+ assert_eq ! ( cloud_provider_from_code( "aws" ) , Some ( CloudProvider :: Aws ) ) ;
2143+ assert_eq ! ( cloud_provider_from_code( "lo" ) , Some ( CloudProvider :: Linode ) ) ;
2144+ assert_eq ! ( cloud_provider_from_code( "vu" ) , Some ( CloudProvider :: Vultr ) ) ;
2145+ // Full names
2146+ assert_eq ! ( cloud_provider_from_code( "hetzner" ) , Some ( CloudProvider :: Hetzner ) ) ;
2147+ assert_eq ! ( cloud_provider_from_code( "digitalocean" ) , Some ( CloudProvider :: Digitalocean ) ) ;
2148+ assert_eq ! ( cloud_provider_from_code( "linode" ) , Some ( CloudProvider :: Linode ) ) ;
2149+ assert_eq ! ( cloud_provider_from_code( "vultr" ) , Some ( CloudProvider :: Vultr ) ) ;
2150+ // Case insensitive
2151+ assert_eq ! ( cloud_provider_from_code( "HTZ" ) , Some ( CloudProvider :: Hetzner ) ) ;
2152+ assert_eq ! ( cloud_provider_from_code( "AWS" ) , Some ( CloudProvider :: Aws ) ) ;
2153+ // Unknown
2154+ assert_eq ! ( cloud_provider_from_code( "unknown" ) , None ) ;
2155+ assert_eq ! ( cloud_provider_from_code( "" ) , None ) ;
2156+ }
2157+
19722158 #[ test]
19732159 fn test_with_watch_flags ( ) {
19742160 let cmd = DeployCommand :: new ( None , None , false , false )
0 commit comments