Skip to content

Commit 41c2b27

Browse files
authored
Merge pull request #129 from trydirect/copilot/add-cloud-provider-selection
Add interactive cloud credential selection on `stacker deploy --target cloud`
2 parents a7cb0eb + ff9e555 commit 41c2b27

File tree

1 file changed

+192
-6
lines changed

1 file changed

+192
-6
lines changed

src/console/commands/cli/deploy.rs

Lines changed: 192 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use std::time::Duration;
55
use 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+
};
912
use crate::cli::credentials::CredentialsManager;
1013
use crate::cli::deployment_lock::DeploymentLock;
1114
use 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

Comments
 (0)