use std::{io::Write, thread::sleep, time::Duration};
use anyhow::{Context, Result, bail};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::json;
use termcolor::{ColorChoice, StandardStream, WriteColor};
use crate::{
CliCommand,
core::{command::command, hmac::AuthMode},
managed::{
client::{
Missing, extract_list, get_value_if_supported, post_json, print_dryrun,
require_managed_mode, resolve_managed_auth,
},
types::ManagedInstance,
},
};
const POLL_INTERVAL_SECS: u64 = 4;
#[derive(Debug)]
pub(super) struct CreateCommand;
impl CreateCommand {
pub(super) fn new() -> Self {
Self
}
}
impl CliCommand for CreateCommand {
fn command(&self) -> Command {
command(
"create",
"Launch a managed instance of a published template",
)
.long_about(
"Launch a managed instance of a published template.\n\n\
This provisions a deployment for one end customer. The template must already\n\
have a published version — launching from a draft or unbuilt template fails.\n\n\
Provisioning is asynchronous: the instance comes back in a provisioning state\n\
and becomes claimable once it is up. Once it reaches awaiting_claim, reveal\n\
its one-time claim link with `forklaunch managed instance claim-link`.",
)
.arg(
Arg::new("template")
.long("template")
.required(true)
.help("Slug of the published template to launch"),
)
.arg(
Arg::new("region")
.long("region")
.required(true)
.help("Region to provision the instance in (for example: us-west-2)"),
)
.arg(
Arg::new("instance-size")
.long("instance-size")
.value_parser([
"pico", "nano", "micro", "small", "medium", "large", "xlarge", "2xlarge",
])
.help(
"Compute tier for the instance's services. Omitted => the managed default (pico, ~0.1 vCPU / 256 MB). Use a larger tier to give the instance more compute.",
),
)
.arg(
Arg::new("no-wait")
.long("no-wait")
.help(
"Return as soon as the instance is launched instead of waiting for provisioning to finish",
)
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dryrun")
.long("dryrun")
.help("Print the request that would be sent without sending it")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("json")
.long("json")
.help("Output raw JSON instead of formatted terminal output")
.action(ArgAction::SetTrue),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let template = matches
.get_one::<String>("template")
.context("--template is required")?;
let region = matches
.get_one::<String>("region")
.context("--region is required")?;
let instance_size = matches.get_one::<String>("instance-size");
let mut body = json!({ "templateSlug": template, "region": region });
if let Some(size) = instance_size {
body["instanceSize"] = json!(size);
}
if matches.get_flag("dryrun") {
return print_dryrun("POST", "/instances", Some(&body));
}
let auth_mode = resolve_managed_auth()?;
require_managed_mode(&auth_mode)?;
let instance: ManagedInstance = post_json(
&auth_mode,
"/instances",
body,
Missing::Resource(format!("published template '{}'", template)),
)?;
if matches.get_flag("json") {
println!("{}", serde_json::to_string_pretty(&instance)?);
return Ok(());
}
let wait = !matches.get_flag("no-wait");
log_ok!(
stdout,
"Launched instance of '{}' in {}",
instance
.template_slug
.as_deref()
.unwrap_or(template.as_str()),
instance.region.as_deref().unwrap_or(region.as_str())
);
if let Some(id) = instance.id.as_deref() {
log_info!(stdout, "Instance id: {}", id);
}
if let Some(host) = instance.host.as_deref() {
log_info!(stdout, "Host: {}", host);
}
let id = instance.id.as_deref();
if !wait || id.is_none() {
if let Some(state) = instance.state.as_deref() {
log_info!(stdout, "State: {}", state);
}
writeln!(stdout)?;
log_info!(
stdout,
"Provisioning runs in the background. Watch it with `forklaunch managed instance list`."
);
if let Some(id) = id {
log_info!(
stdout,
"Once it reaches awaiting_claim, hand the customer their claim link: forklaunch managed instance claim-link --id {}",
id
);
}
return Ok(());
}
writeln!(stdout)?;
log_info!(stdout, "Waiting for provisioning to finish...");
wait_for_provisioning(
&auth_mode,
id.expect("id presence checked above"),
instance.state.as_deref(),
&mut stdout,
)
}
}
enum Outcome {
AwaitingClaim,
Failed,
}
fn terminal_outcome(state: &str) -> Option<Outcome> {
match state {
"awaiting_claim" => Some(Outcome::AwaitingClaim),
"provisioning_failed" => Some(Outcome::Failed),
_ => None,
}
}
fn fetch_instances(auth_mode: &AuthMode) -> Result<Vec<ManagedInstance>> {
match get_value_if_supported(auth_mode, "/instances")? {
Some(value) => extract_list::<ManagedInstance>(value, &["instances"]),
None => Ok(require_managed_mode(auth_mode)?.instances),
}
}
fn wait_for_provisioning(
auth_mode: &AuthMode,
id: &str,
initial_state: Option<&str>,
stdout: &mut StandardStream,
) -> Result<()> {
let mut last_state: Option<String> = None;
if let Some(state) = initial_state {
log_info!(stdout, "State: {}", state);
last_state = Some(state.to_string());
if let Some(outcome) = terminal_outcome(state) {
return finish(outcome, id, None, stdout);
}
}
loop {
sleep(Duration::from_secs(POLL_INTERVAL_SECS));
let instances = fetch_instances(auth_mode)?;
let instance = instances
.into_iter()
.find(|instance| instance.id.as_deref() == Some(id));
let Some(instance) = instance else {
continue;
};
let Some(state) = instance.state.clone() else {
continue;
};
if last_state.as_deref() != Some(state.as_str()) {
log_info!(stdout, "State: {}", state);
last_state = Some(state.clone());
}
if let Some(outcome) = terminal_outcome(&state) {
return finish(outcome, id, instance.last_error.as_deref(), stdout);
}
}
}
fn finish(
outcome: Outcome,
id: &str,
last_error: Option<&str>,
stdout: &mut StandardStream,
) -> Result<()> {
match outcome {
Outcome::AwaitingClaim => {
writeln!(stdout)?;
log_ok!(stdout, "Instance provisioned and awaiting claim.");
log_info!(
stdout,
"Hand the customer their one-time claim link: forklaunch managed instance claim-link --id {}",
id
);
Ok(())
}
Outcome::Failed => {
writeln!(stdout)?;
log_error!(stdout, "Provisioning failed.");
if let Some(error) = last_error {
log_error!(stdout, "Error: {}", error);
} else {
log_info!(
stdout,
"See `forklaunch managed instance list` for details."
);
}
bail!("instance provisioning failed");
}
}
}