1mod args;
2pub mod builder;
3pub mod config;
4mod init;
5mod provisioner_server;
6mod util;
7
8use std::collections::{BTreeMap, HashMap};
9use std::ffi::OsString;
10use std::fs;
11use std::io::{Read, Write};
12use std::net::{Ipv4Addr, SocketAddr};
13use std::path::{Path, PathBuf};
14use std::process::Stdio;
15use std::sync::Arc;
16
17use anyhow::{bail, Context, Result};
18use args::DeploymentTrackingArgs;
19use chrono::Utc;
20use clap::{parser::ValueSource, CommandFactory, FromArgMatches};
21use crossterm::style::Stylize;
22use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
23use futures::{SinkExt, StreamExt};
24use git2::Repository;
25use globset::{Glob, GlobSetBuilder};
26use ignore::overrides::OverrideBuilder;
27use ignore::WalkBuilder;
28use indicatif::ProgressBar;
29use indoc::formatdoc;
30use reqwest::header::HeaderMap;
31use shuttle_api_client::ShuttleApiClient;
32use shuttle_builder::render_rust_dockerfile;
33use shuttle_common::{
34 constants::{
35 headers::X_CARGO_SHUTTLE_VERSION, other_env_api_url, EXAMPLES_REPO, SHUTTLE_API_URL,
36 SHUTTLE_CONSOLE_URL, TEMPLATES_SCHEMA_VERSION,
37 },
38 models::{
39 auth::{KeyMessage, TokenMessage},
40 deployment::{
41 BuildArgs as CommonBuildArgs, BuildMeta, DeploymentRequest,
42 DeploymentRequestBuildArchive, DeploymentRequestImage, DeploymentResponse,
43 DeploymentState, Environment, GIT_STRINGS_MAX_LENGTH,
44 },
45 error::ApiError,
46 log::LogItem,
47 project::ProjectUpdateRequest,
48 resource::ResourceType,
49 },
50 tables::{deployments_table, get_certificates_table, get_projects_table, get_resource_tables},
51};
52use shuttle_ifc::parse_infra_from_code;
53use strum::{EnumMessage, VariantArray};
54use tokio::io::{AsyncBufReadExt, BufReader};
55use tokio::time::{sleep, Duration};
56use tokio_tungstenite::tungstenite::Message;
57use tracing::{debug, error, info, trace, warn};
58use tracing_subscriber::{fmt, prelude::*, registry, EnvFilter};
59use util::cargo_green_eprintln;
60use zip::write::FileOptions;
61
62use crate::args::{
63 BuildArgs, CertificateCommand, ConfirmationArgs, DeployArgs, DeploymentCommand,
64 GenerateCommand, InitArgs, LoginArgs, LogoutArgs, LogsArgs, McpCommand, OutputMode,
65 ProjectCommand, ProjectUpdateCommand, ResourceCommand, SecretsArgs, TableArgs,
66 TemplateLocation,
67};
68pub use crate::args::{BuildArgsShared, Command, ProjectArgs, RunArgs, ShuttleArgs};
69use crate::builder::{
70 cargo_build, find_first_shuttle_package, gather_rust_build_args, BuiltService,
71};
72use crate::config::RequestContext;
73use crate::provisioner_server::{ProvApiState, ProvisionerServer};
74use crate::util::{
75 bacon, cargo_metadata, check_and_warn_runtime_version, generate_completions, generate_manpage,
76 get_templates_schema, is_dirty, open_gh_issue, read_ws_until_text, update_cargo_shuttle,
77};
78
79const VERSION: &str = env!("CARGO_PKG_VERSION");
80
81pub fn parse_args() -> (ShuttleArgs, bool) {
83 let matches = ShuttleArgs::command().get_matches();
84 let mut args =
85 ShuttleArgs::from_arg_matches(&matches).expect("args to already be parsed successfully");
86 let provided_path_to_init = matches
87 .subcommand_matches("init")
88 .is_some_and(|init_matches| {
89 init_matches.value_source("path") == Some(ValueSource::CommandLine)
90 });
91
92 if args
94 .api_env
95 .as_ref()
96 .is_some_and(|e| e == "prod" || e == "production")
97 {
98 args.api_env = None;
99 }
100
101 (args, provided_path_to_init)
102}
103
104pub fn setup_tracing(debug: bool) {
105 registry()
106 .with(fmt::layer().with_writer(std::io::stderr))
107 .with(
108 EnvFilter::try_from_default_env().unwrap_or_else(|_| {
110 if debug {
111 EnvFilter::new("info,cargo_shuttle=trace,shuttle=trace")
112 } else {
113 EnvFilter::default()
114 }
115 }),
116 )
117 .init();
118}
119
120#[derive(PartialEq)]
121pub enum Binary {
122 CargoShuttle,
123 Shuttle,
124}
125
126impl Binary {
127 pub fn name(&self) -> String {
128 match self {
129 Self::CargoShuttle => "cargo-shuttle".to_owned(),
130 Self::Shuttle => "shuttle".to_owned(),
131 }
132 }
133}
134
135pub struct Shuttle {
136 ctx: RequestContext,
137 client: Option<ShuttleApiClient>,
138 output_mode: OutputMode,
139 bin: Binary,
141}
142
143impl Shuttle {
144 pub fn new(bin: Binary, env_override: Option<String>) -> Result<Self> {
145 let ctx = RequestContext::load_global(env_override.inspect(|e| {
146 eprintln!(
147 "{}",
148 format!("INFO: Using non-default global config file: {e}").yellow(),
149 )
150 }))?;
151 Ok(Self {
152 ctx,
153 client: None,
154 output_mode: OutputMode::Normal,
155 bin,
156 })
157 }
158
159 pub async fn run(mut self, args: ShuttleArgs, provided_path_to_init: bool) -> Result<()> {
160 self.output_mode = args.output_mode;
161
162 if matches!(
164 args.cmd,
165 Command::Init(..)
166 | Command::Deploy(..)
167 | Command::Logs { .. }
168 | Command::Account
169 | Command::Login(..)
170 | Command::Logout(..)
171 | Command::Deployment(..)
172 | Command::Resource(..)
173 | Command::Certificate(..)
174 | Command::Project(..)
175 ) {
176 let api_url = args
177 .api_url
178 .or_else(|| args.api_env.as_ref().map(|env| other_env_api_url(env)))
180 .map(|u| if args.admin { format!("{u}/admin") } else { u });
182 if let Some(ref url) = api_url {
183 if url != SHUTTLE_API_URL {
184 eprintln!(
185 "{}",
186 format!("INFO: Targeting non-default API: {url}").yellow(),
187 );
188 }
189 if url.ends_with('/') {
190 eprintln!("WARNING: API URL is probably incorrect. Ends with '/': {url}");
191 }
192 }
193 self.ctx.set_api_url(api_url);
194
195 let client = ShuttleApiClient::new(
196 self.ctx.api_url(),
197 self.ctx.api_key().ok(),
198 Some(
199 HeaderMap::try_from(&HashMap::from([(
200 X_CARGO_SHUTTLE_VERSION.clone(),
201 crate::VERSION.to_owned(),
202 )]))
203 .unwrap(),
204 ),
205 None,
206 );
207 self.client = Some(client);
208 }
209
210 if matches!(
212 args.cmd,
213 Command::Deploy(..)
214 | Command::Deployment(..)
215 | Command::Resource(..)
216 | Command::Certificate(..)
217 | Command::Project(
218 ProjectCommand::Update(..)
221 | ProjectCommand::Status
222 | ProjectCommand::Delete { .. }
223 | ProjectCommand::Link
224 )
225 | Command::Logs { .. }
226 ) {
227 self.load_project_id(
229 &args.project_args,
230 matches!(args.cmd, Command::Project(ProjectCommand::Link)),
231 matches!(args.cmd, Command::Deploy(..)),
233 )
234 .await?;
235 }
236
237 match args.cmd {
238 Command::Init(init_args) => {
239 self.init(
240 init_args,
241 args.project_args,
242 provided_path_to_init,
243 args.offline,
244 )
245 .await
246 }
247 Command::Generate(cmd) => match cmd {
248 GenerateCommand::Manpage => generate_manpage(),
249 GenerateCommand::Shell { shell, output_file } => {
250 generate_completions(self.bin, shell, output_file)
251 }
252 },
253 Command::Account => self.account().await,
254 Command::Login(login_args) => self.login(login_args, args.offline, true).await,
255 Command::Logout(logout_args) => self.logout(logout_args).await,
256 Command::Feedback => open_gh_issue(),
257 Command::Run(run_args) => {
258 self.ctx.load_local_config(&args.project_args)?;
259 self.local_run(run_args, args.debug).await
260 }
261 Command::Build(build_args) => {
262 self.ctx.load_local_config(&args.project_args)?;
263 self.build(&build_args).await
264 }
265 Command::Deploy(deploy_args) => self.deploy(deploy_args).await,
266 Command::Logs(logs_args) => self.logs(logs_args).await,
267 Command::Deployment(cmd) => match cmd {
268 DeploymentCommand::List { page, limit, table } => {
269 self.deployments_list(page, limit, table).await
270 }
271 DeploymentCommand::Status { deployment_id } => {
272 self.deployment_get(deployment_id).await
273 }
274 DeploymentCommand::Redeploy {
275 deployment_id,
276 tracking_args,
277 } => self.deployment_redeploy(deployment_id, tracking_args).await,
278 DeploymentCommand::Stop { tracking_args } => {
279 self.deployment_stop(tracking_args).await
280 }
281 },
282 Command::Resource(cmd) => match cmd {
283 ResourceCommand::List {
284 table,
285 show_secrets,
286 } => self.resources_list(table, show_secrets).await,
287 ResourceCommand::Delete {
288 resource_type,
289 confirmation: ConfirmationArgs { yes },
290 } => self.resource_delete(&resource_type, yes).await,
291 ResourceCommand::Dump { resource_type } => self.resource_dump(&resource_type).await,
292 },
293 Command::Certificate(cmd) => match cmd {
294 CertificateCommand::Add { domain } => self.add_certificate(domain).await,
295 CertificateCommand::List { table } => self.list_certificates(table).await,
296 CertificateCommand::Delete {
297 domain,
298 confirmation: ConfirmationArgs { yes },
299 } => self.delete_certificate(domain, yes).await,
300 },
301 Command::Project(cmd) => match cmd {
302 ProjectCommand::Create => self.project_create(args.project_args.name).await,
303 ProjectCommand::Update(cmd) => match cmd {
304 ProjectUpdateCommand::Name { new_name } => self.project_rename(new_name).await,
305 },
306 ProjectCommand::Status => self.project_status().await,
307 ProjectCommand::List { table, .. } => self.projects_list(table).await,
308 ProjectCommand::Delete(ConfirmationArgs { yes }) => self.project_delete(yes).await,
309 ProjectCommand::Link => Ok(()), },
311 Command::Upgrade { preview } => update_cargo_shuttle(preview).await,
312 Command::Mcp(cmd) => match cmd {
313 McpCommand::Start => shuttle_mcp::run_mcp_server().await,
314 },
315 }
316 }
317
318 async fn init(
323 &mut self,
324 args: InitArgs,
325 mut project_args: ProjectArgs,
326 provided_path_to_init: bool,
327 offline: bool,
328 ) -> Result<()> {
329 let git_template = args.git_template()?;
331 let no_git = args.no_git;
332
333 let needs_name = project_args.name.is_none();
334 let needs_template = git_template.is_none();
335 let needs_path = !provided_path_to_init;
336 let needs_login = self.ctx.api_key().is_err() && args.login_args.api_key.is_none();
337 let should_link = project_args.id.is_some();
338 let interactive = needs_name || needs_template || needs_path || needs_login;
339
340 let theme = ColorfulTheme::default();
341
342 if needs_login {
344 eprintln!("First, let's log in to your Shuttle account.");
345 self.login(args.login_args.clone(), offline, false).await?;
346 eprintln!();
347 } else if args.login_args.api_key.is_some() {
348 self.login(args.login_args.clone(), offline, false).await?;
349 }
350
351 let mut prev_name: Option<String> = None;
353 loop {
354 let name: String = if let Some(name) = project_args.name.clone() {
356 name
357 } else {
358 Input::with_theme(&theme)
360 .with_prompt("Project name")
361 .interact()?
362 };
363 let force_name = args.force_name
364 || (needs_name && prev_name.as_ref().is_some_and(|prev| prev == &name));
365 if force_name {
366 project_args.name = Some(name);
367 break;
368 }
369 if self
371 .check_project_name(&mut project_args, name.clone())
372 .await
373 {
374 break;
376 } else if needs_name {
377 eprintln!(r#"Type the same name again to use "{}" anyways."#, name);
379 prev_name = Some(name);
380 } else {
381 bail!(
383 "Invalid or unavailable project name. Use `--force-name` to use this project name anyways."
384 );
385 }
386 }
387 if needs_name {
388 eprintln!();
389 }
390
391 let path = if needs_path {
393 let path = args
394 .path
395 .join(project_args.name.as_ref().expect("name should be set"));
396
397 loop {
398 eprintln!("Where should we create this project?");
399
400 let directory_str: String = Input::with_theme(&theme)
401 .with_prompt("Directory")
402 .default(format!("{}", path.display()))
403 .interact()?;
404 eprintln!();
405
406 let path = args::create_and_parse_path(OsString::from(directory_str))?;
407
408 if fs::read_dir(&path)
409 .expect("init dir to exist and list entries")
410 .count()
411 > 0
412 && !Confirm::with_theme(&theme)
413 .with_prompt("Target directory is not empty. Are you sure?")
414 .default(true)
415 .interact()?
416 {
417 eprintln!();
418 continue;
419 }
420
421 break path;
422 }
423 } else {
424 args.path.clone()
425 };
426
427 let template = match git_template {
429 Some(git_template) => git_template,
430 None => {
431 let schema = if offline {
434 None
435 } else {
436 get_templates_schema()
437 .await
438 .map_err(|e| {
439 error!(err = %e, "Failed to get templates");
440 eprintln!(
441 "{}",
442 "Failed to look up template list. Falling back to internal list."
443 .yellow()
444 )
445 })
446 .ok()
447 .and_then(|s| {
448 if s.version == TEMPLATES_SCHEMA_VERSION {
449 return Some(s);
450 }
451 eprintln!(
452 "{}",
453 "Template list with incompatible version found. Consider upgrading Shuttle CLI. Falling back to internal list."
454 .yellow()
455 );
456
457 None
458 })
459 };
460 if let Some(schema) = schema {
461 eprintln!("What type of project template would you like to start from?");
462 let i = Select::with_theme(&theme)
463 .items(&[
464 "A Hello World app in a supported framework",
465 "Browse our full library of templates", ])
467 .clear(false)
468 .default(0)
469 .interact()?;
470 eprintln!();
471 if i == 0 {
472 let mut starters = schema.starters.into_values().collect::<Vec<_>>();
474 starters.sort_by_key(|t| {
475 if t.title.starts_with("No") {
477 "zzz".to_owned()
478 } else {
479 t.title.clone()
480 }
481 });
482 let starter_strings = starters
483 .iter()
484 .map(|t| {
485 format!("{} - {}", t.title.clone().bold(), t.description.clone())
486 })
487 .collect::<Vec<_>>();
488 let index = Select::with_theme(&theme)
489 .with_prompt("Select template")
490 .items(&starter_strings)
491 .default(0)
492 .interact()?;
493 eprintln!();
494 let path = starters[index]
495 .path
496 .clone()
497 .expect("starter to have a path");
498
499 TemplateLocation {
500 auto_path: EXAMPLES_REPO.into(),
501 subfolder: Some(path),
502 }
503 } else {
504 let mut templates = schema.templates.into_values().collect::<Vec<_>>();
506 templates.sort_by_key(|t| t.title.clone());
507 let template_strings = templates
508 .iter()
509 .map(|t| {
510 format!(
511 "{} - {}{}",
512 t.title.clone().bold(),
513 t.description.clone(),
514 t.tags
515 .first()
516 .map(|tag| format!(" ({tag})").dim().to_string())
517 .unwrap_or_default(),
518 )
519 })
520 .collect::<Vec<_>>();
521 let index = Select::with_theme(&theme)
522 .with_prompt("Select template")
523 .items(&template_strings)
524 .default(0)
525 .interact()?;
526 eprintln!();
527 let path = templates[index]
528 .path
529 .clone()
530 .expect("template to have a path");
531
532 TemplateLocation {
533 auto_path: EXAMPLES_REPO.into(),
534 subfolder: Some(path),
535 }
536 }
537 } else {
538 eprintln!("Shuttle works with many frameworks. Which one do you want to use?");
539 let frameworks = args::InitTemplateArg::VARIANTS;
540 let framework_strings = frameworks
541 .iter()
542 .map(|t| {
543 t.get_documentation()
544 .expect("all template variants to have docs")
545 })
546 .collect::<Vec<_>>();
547 let index = Select::with_theme(&theme)
548 .items(&framework_strings)
549 .default(0)
550 .interact()?;
551 eprintln!();
552 frameworks[index].template()
553 }
554 }
555 };
556
557 crate::init::generate_project(
559 path.clone(),
560 project_args
561 .name
562 .as_ref()
563 .expect("to have a project name provided"),
564 &template,
565 no_git,
566 )?;
567 eprintln!();
568
569 let should_create_project = if should_link {
571 false
573 } else if !interactive {
574 args.create_project
576 } else if args.create_project {
577 true
579 } else {
580 let name = project_args
582 .name
583 .as_ref()
584 .expect("to have a project name provided");
585
586 let should_create = Confirm::with_theme(&theme)
587 .with_prompt(format!(
588 r#"Create a project on Shuttle with the name "{name}"?"#
589 ))
590 .default(true)
591 .interact()?;
592 eprintln!();
593 should_create
594 };
595
596 if should_link || should_create_project {
597 project_args.working_directory.clone_from(&path);
600
601 self.load_project_id(&project_args, true, true).await?;
602 }
603
604 if std::env::current_dir().is_ok_and(|d| d != path) {
605 eprintln!("You can `cd` to the directory, then:");
606 }
607 eprintln!("Run `shuttle deploy` to deploy it to Shuttle.");
608
609 Ok(())
610 }
611
612 async fn check_project_name(&self, project_args: &mut ProjectArgs, name: String) -> bool {
614 let client = self.client.as_ref().unwrap();
615 match client
616 .check_project_name(&name)
617 .await
618 .map(|r| r.into_inner())
619 {
620 Ok(true) => {
621 project_args.name = Some(name);
622
623 true
624 }
625 Ok(false) => {
626 panic!("Unexpected API response");
628 }
629 Err(e) => {
630 if let Ok(api_error) = e.downcast::<ApiError>() {
632 if api_error.message().contains("Invalid project name") {
634 eprintln!("{}", api_error.message().yellow());
635 eprintln!("{}", "Try a different name.".yellow());
636 return false;
637 }
638 }
639 project_args.name = Some(name);
642 eprintln!(
643 "{}",
644 "Failed to check if project name is available.".yellow()
645 );
646
647 true
648 }
649 }
650 }
651
652 pub async fn load_project_id(
655 &mut self,
656 project_args: &ProjectArgs,
657 do_linking: bool,
658 create_missing_project: bool,
659 ) -> Result<()> {
660 trace!("project arguments: {project_args:?}");
661
662 self.ctx.load_local_config(project_args)?;
663 self.ctx.load_local_internal_config(project_args)?;
665
666 if project_args.id.is_none() {
669 if let Some(name) = project_args.name.as_ref() {
670 let client = self.client.as_ref().unwrap();
671 trace!(%name, "looking up project id from project name");
672 if let Some(proj) = client
673 .get_projects_list()
674 .await?
675 .into_inner()
676 .projects
677 .into_iter()
678 .find(|p| p.name == *name)
679 {
680 trace!("found project by name");
681 self.ctx.set_project_id(proj.id);
682 } else {
683 trace!("did not find project by name");
684 if create_missing_project {
685 trace!("creating project since it was not found");
686 let proj = client.create_project(name).await?.into_inner();
688 eprintln!("Created project '{}' with id {}", proj.name, proj.id);
689 self.ctx.set_project_id(proj.id);
690 } else if do_linking {
691 self.project_link_interactive().await?;
692 return Ok(());
693 } else {
694 bail!(
695 "Project with name '{}' not found in your project list. \
696 Use 'shuttle project link' to create it or link an existing project.",
697 name
698 );
699 }
700 }
701 }
702 }
703
704 match (self.ctx.project_id_found(), do_linking) {
705 (true, true) => {
706 let arg_given = project_args.id.is_some() || project_args.name.is_some();
707 if arg_given {
708 eprintln!("Linking to project {}", self.ctx.project_id());
710 self.ctx.save_local_internal()?;
711 } else {
712 self.project_link_interactive().await?;
714 }
715 }
716 (true, false) => (),
718 (false, _) => {
720 trace!("no project id found");
721 self.project_link_interactive().await?;
722 }
723 }
724
725 Ok(())
726 }
727
728 async fn project_link_interactive(&mut self) -> Result<()> {
729 let client = self.client.as_ref().unwrap();
730 let projs = client.get_projects_list().await?.into_inner().projects;
731
732 let theme = ColorfulTheme::default();
733
734 let selected_project = if projs.is_empty() {
735 eprintln!("Create a new project to link to this directory:");
736
737 None
738 } else {
739 eprintln!("Which project do you want to link this directory to?");
740
741 let mut items = projs
742 .iter()
743 .map(|p| {
744 if let Some(team_id) = p.team_id.as_ref() {
745 format!("Team {}: {}", team_id, p.name)
746 } else {
747 p.name.clone()
748 }
749 })
750 .collect::<Vec<_>>();
751 items.extend_from_slice(&["[CREATE NEW]".to_string()]);
752 let index = Select::with_theme(&theme)
753 .items(&items)
754 .default(0)
755 .interact()?;
756
757 if index == projs.len() {
758 None
760 } else {
761 Some(projs[index].clone())
762 }
763 };
764
765 let proj = match selected_project {
766 Some(proj) => proj,
767 None => {
768 let name: String = Input::with_theme(&theme)
769 .with_prompt("Project name")
770 .interact()?;
771
772 let proj = client.create_project(&name).await?.into_inner();
774 eprintln!("Created project '{}' with id {}", proj.name, proj.id);
775
776 proj
777 }
778 };
779
780 eprintln!("Linking to project '{}' with id {}", proj.name, proj.id);
781 self.ctx.set_project_id(proj.id);
782 self.ctx.save_local_internal()?;
783
784 Ok(())
785 }
786
787 async fn account(&self) -> Result<()> {
788 let client = self.client.as_ref().unwrap();
789 let r = client.get_current_user().await?;
790 match self.output_mode {
791 OutputMode::Normal => {
792 print!("{}", r.into_inner().to_string_colored());
793 }
794 OutputMode::Json => {
795 println!("{}", r.raw_json);
796 }
797 }
798
799 Ok(())
800 }
801
802 async fn login(&mut self, login_args: LoginArgs, offline: bool, login_cmd: bool) -> Result<()> {
804 let api_key = match login_args.api_key {
805 Some(api_key) => api_key,
806 None => {
807 if login_args.prompt {
808 Password::with_theme(&ColorfulTheme::default())
809 .with_prompt("API key")
810 .validate_with(|input: &String| {
811 if input.is_empty() {
812 return Err("Empty API key was provided");
813 }
814 Ok(())
815 })
816 .interact()?
817 } else {
818 self.device_auth(login_args.console_url).await?
820 }
821 }
822 };
823
824 self.ctx.set_api_key(api_key.clone())?;
825
826 if let Some(client) = self.client.as_mut() {
827 client.api_key = Some(api_key);
828
829 if offline {
830 eprintln!("INFO: Skipping API key verification");
831 } else {
832 let (user, raw_json) = client
833 .get_current_user()
834 .await
835 .context("failed to check API key validity")?
836 .into_parts();
837 if login_cmd {
838 match self.output_mode {
839 OutputMode::Normal => {
840 println!("Logged in as {}", user.id.bold());
841 }
842 OutputMode::Json => {
843 println!("{}", raw_json);
844 }
845 }
846 } else {
847 eprintln!("Logged in as {}", user.id.bold());
848 }
849 }
850 }
851
852 Ok(())
853 }
854
855 async fn device_auth(&self, console_url: Option<String>) -> Result<String> {
856 let client = self.client.as_ref().unwrap();
857
858 if let Some(u) = console_url.as_ref() {
860 if u.ends_with('/') {
861 eprintln!("WARNING: Console URL is probably incorrect. Ends with '/': {u}");
862 }
863 }
864
865 let (mut tx, mut rx) = client.get_device_auth_ws().await?.split();
866
867 let pinger = tokio::spawn(async move {
869 loop {
870 if let Err(e) = tx.send(Message::Ping(Default::default())).await {
871 error!(error = %e, "Error when pinging websocket");
872 break;
873 };
874 sleep(Duration::from_secs(20)).await;
875 }
876 });
877
878 let token_message = read_ws_until_text(&mut rx).await?;
879 let Some(token_message) = token_message else {
880 bail!("Did not receive device auth token over websocket");
881 };
882 let token_message = serde_json::from_str::<TokenMessage>(&token_message)?;
883 let token = token_message.token;
884
885 let url = token_message.url.unwrap_or_else(|| {
887 format!(
888 "{}/device-auth?token={}",
889 console_url.as_deref().unwrap_or(SHUTTLE_CONSOLE_URL),
890 token
891 )
892 });
893 let _ = webbrowser::open(&url);
894 eprintln!("Complete login in Shuttle Console to authenticate the Shuttle CLI.");
895 eprintln!("If your browser did not automatically open, go to {url}");
896 eprintln!();
897 eprintln!("{}", format!("Token: {token}").bold());
898 eprintln!();
899
900 let key = read_ws_until_text(&mut rx).await?;
901 let Some(key) = key else {
902 bail!("Failed to receive API key over websocket");
903 };
904 let key = serde_json::from_str::<KeyMessage>(&key)?.api_key;
905
906 pinger.abort();
907
908 Ok(key)
909 }
910
911 async fn logout(&mut self, logout_args: LogoutArgs) -> Result<()> {
912 if logout_args.reset_api_key {
913 let client = self.client.as_ref().unwrap();
914 client.reset_api_key().await.context("Resetting API key")?;
915 eprintln!("Successfully reset the API key.");
916 }
917 self.ctx.clear_api_key()?;
918 eprintln!("Successfully logged out.");
919 eprintln!(" -> Use `shuttle login` to log in again.");
920
921 Ok(())
922 }
923
924 async fn deployment_stop(&self, tracking_args: DeploymentTrackingArgs) -> Result<()> {
925 let client = self.client.as_ref().unwrap();
926 let pid = self.ctx.project_id();
927 let res = client.stop_service(pid).await?.into_inner();
928 println!("{res}");
929
930 if tracking_args.no_follow {
931 return Ok(());
932 }
933
934 wait_with_spinner(2000, |_, pb| async move {
935 let (deployment, raw_json) = client.get_current_deployment(pid).await?.into_parts();
936
937 let get_cleanup = |d: Option<DeploymentResponse>| {
938 move || {
939 if let Some(d) = d {
940 match self.output_mode {
941 OutputMode::Normal => {
942 eprintln!("{}", d.to_string_colored());
943 }
944 OutputMode::Json => {
945 }
947 }
948 }
949 }
950 };
951 let Some(deployment) = deployment else {
952 return Ok(Some(get_cleanup(None)));
953 };
954
955 let state = deployment.state.clone();
956 match self.output_mode {
957 OutputMode::Normal => {
958 pb.set_message(deployment.to_string_summary_colored());
959 }
960 OutputMode::Json => {
961 println!("{}", raw_json);
962 }
963 }
964 let cleanup = get_cleanup(Some(deployment));
965 match state {
966 DeploymentState::Pending
967 | DeploymentState::Stopping
968 | DeploymentState::InProgress
969 | DeploymentState::Running => Ok(None),
970 DeploymentState::Building | DeploymentState::Failed
972 | DeploymentState::Stopped
973 | DeploymentState::Unknown(_) => Ok(Some(cleanup)),
974 }
975 })
976 .await?;
977
978 Ok(())
979 }
980
981 async fn logs(&self, args: LogsArgs) -> Result<()> {
982 if args.follow {
983 eprintln!("Streamed logs are not yet supported on the shuttle.dev platform.");
984 return Ok(());
985 }
986 if args.tail.is_some() | args.head.is_some() {
987 eprintln!("Fetching log ranges are not yet supported on the shuttle.dev platform.");
988 return Ok(());
989 }
990 let client = self.client.as_ref().unwrap();
991 let pid = self.ctx.project_id();
992 let r = if args.all_deployments {
993 client.get_project_logs(pid).await?
994 } else {
995 let id = if args.latest {
996 let deployments = client
998 .get_deployments(pid, 1, 1)
999 .await?
1000 .into_inner()
1001 .deployments;
1002 let Some(most_recent) = deployments.into_iter().next() else {
1003 println!("No deployments found");
1004 return Ok(());
1005 };
1006 eprintln!("Getting logs from: {}", most_recent.id);
1007 most_recent.id
1008 } else if let Some(id) = args.deployment_id {
1009 id
1010 } else {
1011 let Some(current) = client.get_current_deployment(pid).await?.into_inner() else {
1012 println!("No deployments found");
1013 return Ok(());
1014 };
1015 eprintln!("Getting logs from: {}", current.id);
1016 current.id
1017 };
1018 client.get_deployment_logs(pid, &id).await?
1019 };
1020 match self.output_mode {
1021 OutputMode::Normal => {
1022 let logs = r.into_inner().logs;
1023 for log in logs {
1024 if args.raw {
1025 println!("{}", log.line);
1026 } else {
1027 println!("{log}");
1028 }
1029 }
1030 }
1031 OutputMode::Json => {
1032 println!("{}", r.raw_json);
1033 }
1034 }
1035
1036 Ok(())
1037 }
1038
1039 async fn deployments_list(&self, page: u32, limit: u32, table_args: TableArgs) -> Result<()> {
1040 if limit == 0 {
1041 warn!("Limit is set to 0, no deployments will be listed.");
1042 return Ok(());
1043 }
1044 let client = self.client.as_ref().unwrap();
1045 let pid = self.ctx.project_id();
1046
1047 let limit = limit + 1;
1049 let (deployments, raw_json) = client
1050 .get_deployments(pid, page as i32, limit as i32)
1051 .await?
1052 .into_parts();
1053 let mut deployments = deployments.deployments;
1054 let page_hint = if deployments.len() == limit as usize {
1055 deployments.pop();
1057 true
1058 } else {
1059 false
1060 };
1061 match self.output_mode {
1062 OutputMode::Normal => {
1063 let table = deployments_table(&deployments, table_args.raw);
1064 println!("{}", format!("Deployments in project '{}'", pid).bold());
1065 println!("{table}");
1066 if page_hint {
1067 println!("View the next page using `--page {}`", page + 1);
1068 }
1069 }
1070 OutputMode::Json => {
1071 println!("{}", raw_json);
1072 }
1073 }
1074
1075 Ok(())
1076 }
1077
1078 async fn deployment_get(&self, deployment_id: Option<String>) -> Result<()> {
1079 let client = self.client.as_ref().unwrap();
1080 let pid = self.ctx.project_id();
1081
1082 let deployment = match deployment_id {
1083 Some(id) => {
1084 let r = client.get_deployment(pid, &id).await?;
1085 if self.output_mode == OutputMode::Json {
1086 println!("{}", r.raw_json);
1087 return Ok(());
1088 }
1089 r.into_inner()
1090 }
1091 None => {
1092 let r = client.get_current_deployment(pid).await?;
1093 if self.output_mode == OutputMode::Json {
1094 println!("{}", r.raw_json);
1095 return Ok(());
1096 }
1097
1098 let Some(d) = r.into_inner() else {
1099 println!("No deployment found");
1100 return Ok(());
1101 };
1102 d
1103 }
1104 };
1105
1106 println!("{}", deployment.to_string_colored());
1107
1108 Ok(())
1109 }
1110
1111 async fn deployment_redeploy(
1112 &self,
1113 deployment_id: Option<String>,
1114 tracking_args: DeploymentTrackingArgs,
1115 ) -> Result<()> {
1116 let client = self.client.as_ref().unwrap();
1117
1118 let pid = self.ctx.project_id();
1119 let deployment_id = match deployment_id {
1120 Some(id) => id,
1121 None => {
1122 let d = client.get_current_deployment(pid).await?.into_inner();
1123 let Some(d) = d else {
1124 println!("No deployment found");
1125 return Ok(());
1126 };
1127 d.id
1128 }
1129 };
1130 let (deployment, raw_json) = client.redeploy(pid, &deployment_id).await?.into_parts();
1131
1132 if tracking_args.no_follow {
1133 match self.output_mode {
1134 OutputMode::Normal => {
1135 println!("{}", deployment.to_string_colored());
1136 }
1137 OutputMode::Json => {
1138 println!("{}", raw_json);
1139 }
1140 }
1141 return Ok(());
1142 }
1143
1144 self.track_deployment_status_and_print_logs_on_fail(pid, &deployment.id, tracking_args.raw)
1145 .await
1146 }
1147
1148 async fn resources_list(&self, table_args: TableArgs, show_secrets: bool) -> Result<()> {
1149 let client = self.client.as_ref().unwrap();
1150 let pid = self.ctx.project_id();
1151 let r = client.get_service_resources(pid).await?;
1152
1153 match self.output_mode {
1154 OutputMode::Normal => {
1155 let table = get_resource_tables(
1156 r.into_inner().resources.as_slice(),
1157 pid,
1158 table_args.raw,
1159 show_secrets,
1160 );
1161 println!("{table}");
1162 }
1163 OutputMode::Json => {
1164 println!("{}", r.raw_json);
1165 }
1166 }
1167
1168 Ok(())
1169 }
1170
1171 async fn resource_delete(&self, resource_type: &ResourceType, no_confirm: bool) -> Result<()> {
1172 let client = self.client.as_ref().unwrap();
1173
1174 if !no_confirm {
1175 eprintln!(
1176 "{}",
1177 formatdoc!(
1178 "
1179 WARNING:
1180 Are you sure you want to delete this project's {}?
1181 This action is permanent.",
1182 resource_type
1183 )
1184 .bold()
1185 .red()
1186 );
1187 if !Confirm::with_theme(&ColorfulTheme::default())
1188 .with_prompt("Are you sure?")
1189 .default(false)
1190 .interact()
1191 .unwrap()
1192 {
1193 return Ok(());
1194 }
1195 }
1196
1197 let msg = client
1198 .delete_service_resource(self.ctx.project_id(), resource_type)
1199 .await?
1200 .into_inner();
1201 println!("{msg}");
1202
1203 eprintln!(
1204 "{}",
1205 formatdoc! {"
1206 Note:
1207 Remember to remove the resource annotation from your #[shuttle_runtime::main] function.
1208 Otherwise, it will be provisioned again during the next deployment."
1209 }
1210 .yellow(),
1211 );
1212
1213 Ok(())
1214 }
1215
1216 async fn resource_dump(&self, resource_type: &ResourceType) -> Result<()> {
1217 let client = self.client.as_ref().unwrap();
1218
1219 let bytes = client
1220 .dump_service_resource(self.ctx.project_id(), resource_type)
1221 .await?;
1222 std::io::stdout()
1223 .write_all(&bytes)
1224 .context("writing output to stdout")?;
1225
1226 Ok(())
1227 }
1228
1229 async fn list_certificates(&self, table_args: TableArgs) -> Result<()> {
1230 let client = self.client.as_ref().unwrap();
1231 let r = client.list_certificates(self.ctx.project_id()).await?;
1232
1233 match self.output_mode {
1234 OutputMode::Normal => {
1235 let table =
1236 get_certificates_table(r.into_inner().certificates.as_ref(), table_args.raw);
1237 println!("{table}");
1238 }
1239 OutputMode::Json => {
1240 println!("{}", r.raw_json);
1241 }
1242 }
1243
1244 Ok(())
1245 }
1246 async fn add_certificate(&self, domain: String) -> Result<()> {
1247 let client = self.client.as_ref().unwrap();
1248 let r = client
1249 .add_certificate(self.ctx.project_id(), domain.clone())
1250 .await?;
1251
1252 match self.output_mode {
1253 OutputMode::Normal => {
1254 println!("Added certificate for {}", r.into_inner().subject);
1255 }
1256 OutputMode::Json => {
1257 println!("{}", r.raw_json);
1258 }
1259 }
1260
1261 Ok(())
1262 }
1263 async fn delete_certificate(&self, domain: String, no_confirm: bool) -> Result<()> {
1264 let client = self.client.as_ref().unwrap();
1265
1266 if !no_confirm {
1267 eprintln!(
1268 "{}",
1269 formatdoc!(
1270 "
1271 WARNING:
1272 Delete the certificate for {}?",
1273 domain
1274 )
1275 .bold()
1276 .red()
1277 );
1278 if !Confirm::with_theme(&ColorfulTheme::default())
1279 .with_prompt("Are you sure?")
1280 .default(false)
1281 .interact()
1282 .unwrap()
1283 {
1284 return Ok(());
1285 }
1286 }
1287
1288 let msg = client
1289 .delete_certificate(self.ctx.project_id(), domain.clone())
1290 .await?
1291 .into_inner();
1292 println!("{msg}");
1293
1294 Ok(())
1295 }
1296
1297 fn get_secrets(
1298 args: &SecretsArgs,
1299 workspace_root: &Path,
1300 dev: bool,
1301 ) -> Result<Option<HashMap<String, String>>> {
1302 let files: &[PathBuf] = if dev {
1304 &[
1305 workspace_root.join("Secrets.dev.toml"),
1306 workspace_root.join("Secrets.toml"),
1307 ]
1308 } else {
1309 &[workspace_root.join("Secrets.toml")]
1310 };
1311 let secrets_file = args.secrets.as_ref().or_else(|| {
1312 files
1313 .iter()
1314 .find(|&secrets_file| secrets_file.exists() && secrets_file.is_file())
1315 });
1316
1317 let Some(secrets_file) = secrets_file else {
1318 trace!("No secrets file was found");
1319 return Ok(None);
1320 };
1321
1322 trace!("Loading secrets from {}", secrets_file.display());
1323 let Ok(secrets_str) = fs::read_to_string(secrets_file) else {
1324 tracing::warn!("Failed to read secrets file, no secrets were loaded");
1325 return Ok(None);
1326 };
1327
1328 let secrets = toml::from_str::<HashMap<String, String>>(&secrets_str)
1329 .context("parsing secrets file")?;
1330 trace!(keys = ?secrets.keys(), "Loaded secrets");
1331
1332 Ok(Some(secrets))
1333 }
1334
1335 async fn build(&self, build_args: &BuildArgs) -> Result<()> {
1336 eprintln!("WARN: The build command is EXPERIMENTAL. Please submit feedback on GitHub or Discord if you encounter issues.");
1337 if let Some(path) = build_args.output_archive.as_ref() {
1338 let archive = self.make_archive()?;
1339 eprintln!("Writing archive to {}", path.display());
1340 fs::write(path, archive).context("writing archive")?;
1341 Ok(())
1342 } else if build_args.inner.docker {
1343 self.local_docker_build(&build_args.inner).await
1344 } else {
1345 self.local_build(&build_args.inner).await.map(|_| ())
1346 }
1347 }
1348
1349 async fn local_build(&self, build_args: &BuildArgsShared) -> Result<BuiltService> {
1350 let project_directory = self.ctx.project_directory();
1351
1352 cargo_green_eprintln("Building", project_directory.display());
1353
1354 let quiet = false;
1356 cargo_build(project_directory.to_owned(), build_args.release, quiet).await
1357 }
1358
1359 fn find_available_port(run_args: &mut RunArgs) {
1360 let original_port = run_args.port;
1361 for port in (run_args.port..=u16::MAX).step_by(10) {
1362 if !portpicker::is_free_tcp(port) {
1363 continue;
1364 }
1365 run_args.port = port;
1366 break;
1367 }
1368
1369 if run_args.port != original_port {
1370 eprintln!(
1371 "Port {} is already in use. Using port {}.",
1372 original_port, run_args.port,
1373 )
1374 };
1375 }
1376
1377 async fn local_run(&self, mut run_args: RunArgs, debug: bool) -> Result<()> {
1378 let project_name = self.ctx.project_name().to_owned();
1379 let project_directory = self.ctx.project_directory();
1380
1381 trace!("starting a local run with args: {run_args:?}");
1382
1383 if run_args.build_args.bacon {
1385 cargo_green_eprintln(
1386 "Starting",
1387 format!("{} in watch mode using bacon", project_name),
1388 );
1389 eprintln!();
1390 return bacon::run_bacon(project_directory).await;
1391 }
1392
1393 if run_args.build_args.docker {
1394 eprintln!("WARN: Local run with --docker is EXPERIMENTAL. Please submit feedback on GitHub or Discord if you encounter issues.");
1395 }
1396
1397 let secrets = Shuttle::get_secrets(&run_args.secret_args, project_directory, true)?
1398 .unwrap_or_default();
1399 Shuttle::find_available_port(&mut run_args);
1400
1401 let s_re = if !run_args.build_args.docker {
1402 let service = self.local_build(&run_args.build_args).await?;
1403 trace!(path = ?service.executable_path, "runtime executable");
1404 if let Some(warning) = check_and_warn_runtime_version(&service.executable_path).await? {
1405 eprint!("{}", warning);
1406 }
1407 let runtime_executable = service.executable_path.clone();
1408
1409 Some((service, runtime_executable))
1410 } else {
1411 self.local_docker_build(&run_args.build_args).await?;
1412 None
1413 };
1414
1415 let api_port = portpicker::pick_unused_port()
1416 .expect("failed to find available port for local provisioner server");
1417 let api_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), api_port);
1418 let healthz_port = portpicker::pick_unused_port()
1419 .expect("failed to find available port for runtime health check");
1420 let ip = if run_args.external {
1421 Ipv4Addr::UNSPECIFIED
1422 } else {
1423 Ipv4Addr::LOCALHOST
1424 };
1425
1426 let state = Arc::new(ProvApiState {
1427 project_name: project_name.clone(),
1428 secrets,
1429 });
1430 tokio::spawn(async move { ProvisionerServer::run(state, &api_addr).await });
1431
1432 let mut envs = vec![
1433 ("SHUTTLE_BETA", "true".to_owned()),
1434 ("SHUTTLE_PROJECT_ID", "proj_LOCAL".to_owned()),
1435 ("SHUTTLE_PROJECT_NAME", project_name.clone()),
1436 ("SHUTTLE_ENV", Environment::Local.to_string()),
1437 ("SHUTTLE_RUNTIME_IP", ip.to_string()),
1438 ("SHUTTLE_RUNTIME_PORT", run_args.port.to_string()),
1439 ("SHUTTLE_HEALTHZ_PORT", healthz_port.to_string()),
1440 ("SHUTTLE_API", format!("http://127.0.0.1:{}", api_port)),
1441 ];
1442 if debug && std::env::var("RUST_LOG").is_err() {
1444 envs.push(("RUST_LOG", "info,shuttle=trace,reqwest=debug".to_owned()));
1445 } else if let Ok(v) = std::env::var("RUST_LOG") {
1446 envs.push(("RUST_LOG", v));
1448 }
1449
1450 let name = format!("shuttle-run-{project_name}");
1451 let mut child = if run_args.build_args.docker {
1452 let image = format!("shuttle-build-{project_name}");
1453 eprintln!();
1454 cargo_green_eprintln(
1455 "Starting",
1456 format!("{} on http://{}:{}", image, ip, run_args.port),
1457 );
1458 eprintln!();
1459 info!(image, "Spawning 'docker run' process");
1460 let mut docker = tokio::process::Command::new("docker");
1461 docker
1462 .arg("run")
1463 .arg("--rm")
1466 .arg("--network")
1467 .arg("host")
1468 .arg("--name")
1469 .arg(&name);
1470 for (k, v) in envs {
1471 docker.arg("--env").arg(format!("{k}={v}"));
1472 }
1473
1474 docker
1475 .arg(&image)
1476 .stdout(std::process::Stdio::piped())
1477 .stderr(std::process::Stdio::piped())
1478 .kill_on_drop(true)
1479 .spawn()
1480 .context("spawning 'docker run' process")?
1481 } else {
1482 let (service, runtime_executable) = s_re.context("developer skill issue")?;
1483 eprintln!();
1484 cargo_green_eprintln(
1485 "Starting",
1486 format!("{} on http://{}:{}", service.target_name, ip, run_args.port),
1487 );
1488 eprintln!();
1489 info!(
1490 path = %runtime_executable.display(),
1491 "Spawning runtime process",
1492 );
1493 tokio::process::Command::new(
1494 dunce::canonicalize(runtime_executable)
1495 .context("canonicalize path of executable")?,
1496 )
1497 .current_dir(&service.workspace_path)
1498 .envs(envs)
1499 .stdout(std::process::Stdio::piped())
1500 .stderr(std::process::Stdio::piped())
1501 .kill_on_drop(true)
1502 .spawn()
1503 .context("spawning runtime process")?
1504 };
1505
1506 let raw = run_args.raw;
1508 let mut stdout_reader = BufReader::new(
1509 child
1510 .stdout
1511 .take()
1512 .context("child process did not have a handle to stdout")?,
1513 )
1514 .lines();
1515 tokio::spawn(async move {
1516 while let Some(line) = stdout_reader.next_line().await.unwrap() {
1517 if raw {
1518 println!("{}", line);
1519 } else {
1520 let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1521 println!("{log_item}");
1522 }
1523 }
1524 });
1525 let mut stderr_reader = BufReader::new(
1526 child
1527 .stderr
1528 .take()
1529 .context("child process did not have a handle to stderr")?,
1530 )
1531 .lines();
1532 tokio::spawn(async move {
1533 while let Some(line) = stderr_reader.next_line().await.unwrap() {
1534 if raw {
1535 println!("{}", line);
1536 } else {
1537 let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1538 println!("{log_item}");
1539 }
1540 }
1541 });
1542
1543 tokio::spawn(async move {
1545 loop {
1546 tokio::time::sleep(tokio::time::Duration::from_millis(5000)).await;
1548
1549 tracing::trace!("Health check against runtime");
1550 if let Err(e) = reqwest::get(format!("http://127.0.0.1:{}/", healthz_port)).await {
1551 tracing::trace!("Health check against runtime failed: {e}");
1552 }
1553 }
1554 });
1555
1556 #[cfg(target_family = "unix")]
1557 let exit_result = {
1558 let mut sigterm_notif =
1559 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1560 .expect("Can not get the SIGTERM signal receptor");
1561 let mut sigint_notif =
1562 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
1563 .expect("Can not get the SIGINT signal receptor");
1564 tokio::select! {
1565 exit_result = child.wait() => {
1566 Some(exit_result)
1567 }
1568 _ = sigterm_notif.recv() => {
1569 eprintln!("Received SIGTERM.");
1570 None
1571 },
1572 _ = sigint_notif.recv() => {
1573 eprintln!("Received SIGINT.");
1574 None
1575 }
1576 }
1577 };
1578 #[cfg(target_family = "windows")]
1579 let exit_result = {
1580 let mut ctrl_break_notif = tokio::signal::windows::ctrl_break()
1581 .expect("Can not get the CtrlBreak signal receptor");
1582 let mut ctrl_c_notif =
1583 tokio::signal::windows::ctrl_c().expect("Can not get the CtrlC signal receptor");
1584 let mut ctrl_close_notif = tokio::signal::windows::ctrl_close()
1585 .expect("Can not get the CtrlClose signal receptor");
1586 let mut ctrl_logoff_notif = tokio::signal::windows::ctrl_logoff()
1587 .expect("Can not get the CtrlLogoff signal receptor");
1588 let mut ctrl_shutdown_notif = tokio::signal::windows::ctrl_shutdown()
1589 .expect("Can not get the CtrlShutdown signal receptor");
1590 tokio::select! {
1591 exit_result = child.wait() => {
1592 Some(exit_result)
1593 }
1594 _ = ctrl_break_notif.recv() => {
1595 eprintln!("Received ctrl-break.");
1596 None
1597 },
1598 _ = ctrl_c_notif.recv() => {
1599 eprintln!("Received ctrl-c.");
1600 None
1601 },
1602 _ = ctrl_close_notif.recv() => {
1603 eprintln!("Received ctrl-close.");
1604 None
1605 },
1606 _ = ctrl_logoff_notif.recv() => {
1607 eprintln!("Received ctrl-logoff.");
1608 None
1609 },
1610 _ = ctrl_shutdown_notif.recv() => {
1611 eprintln!("Received ctrl-shutdown.");
1612 None
1613 }
1614 }
1615 };
1616 match exit_result {
1617 Some(Ok(exit_status)) => {
1618 bail!(
1619 "Runtime process exited with code {}",
1620 exit_status.code().unwrap_or_default()
1621 );
1622 }
1623 Some(Err(e)) => {
1624 bail!("Failed to wait for runtime process to exit: {e}");
1625 }
1626 None => {
1627 eprintln!("Stopping runtime.");
1628 child.kill().await?;
1629 if run_args.build_args.docker {
1630 let status = tokio::process::Command::new("docker")
1631 .arg("stop")
1632 .arg(name)
1633 .kill_on_drop(true)
1634 .stdout(Stdio::null())
1635 .spawn()
1636 .context("spawning 'docker stop'")?
1637 .wait()
1638 .await
1639 .context("waiting for 'docker stop'")?;
1640
1641 if !status.success() {
1642 eprintln!("WARN: 'docker stop' failed");
1643 }
1644 }
1645 }
1646 }
1647
1648 Ok(())
1649 }
1650
1651 async fn local_docker_build(&self, build_args: &BuildArgsShared) -> Result<()> {
1652 let project_name = self.ctx.project_name().to_owned();
1653 let project_directory = self.ctx.project_directory();
1654
1655 let metadata = cargo_metadata(project_directory)?;
1656 let rust_build_args = gather_rust_build_args(&metadata)?;
1657
1658 cargo_green_eprintln("Building", format!("{} with docker", project_name));
1659
1660 let tempdir = tempfile::Builder::new()
1661 .prefix("shuttle-build-")
1662 .tempdir()?
1663 .keep();
1664 tracing::debug!("Building in {}", tempdir.display());
1665
1666 let build_files = self.gather_build_files()?;
1667 if build_files.is_empty() {
1668 error!("No files included in build. Aborting...");
1669 bail!("No files included in build");
1670 }
1671
1672 tracing::debug!("Creating prebuild script file");
1674 fs::write(tempdir.join("shuttle_prebuild.sh"), "")?;
1675 for (path, name) in build_files {
1676 let dest = tempdir.join(&name);
1677 tracing::debug!("Copying {} to tempdir", name.display());
1678 fs::create_dir_all(dest.parent().expect("destination to not be the root"))?;
1679 fs::copy(path, dest)?;
1680 }
1681 tracing::debug!("Removing any .dockerignore file");
1682 let _ = fs::remove_file(tempdir.join(".dockerignore"));
1684
1685 let dockerfile = tempdir.join("__shuttle.Dockerfile");
1687 tracing::debug!("Writing dockerfile to {}", dockerfile.display());
1688 fs::write(&dockerfile, render_rust_dockerfile(&rust_build_args))?;
1689
1690 let mut docker_cmd = tokio::process::Command::new("docker");
1691 docker_cmd
1692 .arg("buildx")
1693 .arg("build")
1694 .arg("--file")
1695 .arg(dockerfile)
1696 .arg("--tag")
1697 .arg(format!("shuttle-build-{project_name}"));
1698 if let Some(ref tag) = build_args.tag {
1699 docker_cmd.arg("--tag").arg(tag);
1700 }
1701
1702 let docker = docker_cmd.arg(tempdir).kill_on_drop(true).spawn();
1703
1704 let result = docker
1705 .context("spawning docker build command")?
1706 .wait()
1707 .await
1708 .context("waiting for docker build to exit")?;
1709 if !result.success() {
1710 bail!("Docker build error");
1711 }
1712
1713 cargo_green_eprintln("Finished", "building with docker");
1714
1715 Ok(())
1716 }
1717
1718 async fn deploy(&mut self, args: DeployArgs) -> Result<()> {
1719 let client = self.client.as_ref().unwrap();
1720 let project_directory = self.ctx.project_directory();
1721
1722 let secrets = Shuttle::get_secrets(&args.secret_args, project_directory, false)?;
1723
1724 if let Some(image) = args.image {
1726 let pid = self.ctx.project_id();
1727 let deployment_req_image = DeploymentRequestImage { image, secrets };
1728
1729 let (deployment, raw_json) = client
1730 .deploy(pid, DeploymentRequest::Image(deployment_req_image))
1731 .await?
1732 .into_parts();
1733
1734 if args.tracking_args.no_follow {
1735 match self.output_mode {
1736 OutputMode::Normal => {
1737 println!("{}", deployment.to_string_colored());
1738 }
1739 OutputMode::Json => {
1740 println!("{}", raw_json);
1741 }
1742 }
1743 return Ok(());
1744 }
1745
1746 return self
1747 .track_deployment_status_and_print_logs_on_fail(
1748 pid,
1749 &deployment.id,
1750 args.tracking_args.raw,
1751 )
1752 .await;
1753 }
1754
1755 let mut deployment_req = DeploymentRequestBuildArchive {
1757 secrets,
1758 ..Default::default()
1759 };
1760 let mut build_meta = BuildMeta::default();
1761
1762 let metadata = cargo_metadata(project_directory)?;
1763
1764 let rust_build_args = gather_rust_build_args(&metadata)?;
1765 deployment_req.build_args = Some(CommonBuildArgs::Rust(rust_build_args));
1766
1767 let (_, target, _) = find_first_shuttle_package(&metadata)?;
1768 deployment_req.infra = parse_infra_from_code(
1769 &fs::read_to_string(target.src_path.as_path())
1770 .context("reading target file when extracting infra annotations")?,
1771 )
1772 .context("parsing infra annotations")?;
1773
1774 if let Ok(repo) = Repository::discover(project_directory) {
1775 let repo_path = repo
1776 .workdir()
1777 .context("getting working directory of repository")?;
1778 let repo_path = dunce::canonicalize(repo_path)?;
1779 trace!(?repo_path, "found git repository");
1780
1781 let dirty = is_dirty(&repo);
1782 build_meta.git_dirty = Some(dirty.is_err());
1783
1784 let check_dirty = self.ctx.deny_dirty().is_some_and(|d| d);
1785 if check_dirty && !args.allow_dirty && dirty.is_err() {
1786 bail!(dirty.unwrap_err());
1787 }
1788
1789 if let Ok(head) = repo.head() {
1790 build_meta.git_branch = head
1793 .shorthand()
1794 .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1795 if let Ok(commit) = head.peel_to_commit() {
1796 build_meta.git_commit_id = Some(commit.id().to_string());
1797 build_meta.git_commit_msg = commit
1799 .summary()
1800 .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1801 }
1802 }
1803 }
1804
1805 cargo_green_eprintln("Packing", "build files");
1806 let archive = self.make_archive()?;
1807
1808 if let Some(path) = args.output_archive {
1809 eprintln!("Writing archive to {}", path.display());
1810 fs::write(path, archive).context("writing archive")?;
1811
1812 return Ok(());
1813 }
1814
1815 let pid = self.ctx.project_id();
1818
1819 cargo_green_eprintln("Uploading", "build archive");
1820 let arch = client.upload_archive(pid, archive).await?.into_inner();
1821 deployment_req.archive_version_id = arch.archive_version_id;
1822 deployment_req.build_meta = Some(build_meta);
1823
1824 cargo_green_eprintln("Creating", "deployment");
1825 let (deployment, raw_json) = client
1826 .deploy(
1827 pid,
1828 DeploymentRequest::BuildArchive(Box::new(deployment_req)),
1829 )
1830 .await?
1831 .into_parts();
1832
1833 if args.tracking_args.no_follow {
1834 match self.output_mode {
1835 OutputMode::Normal => {
1836 println!("{}", deployment.to_string_colored());
1837 }
1838 OutputMode::Json => {
1839 println!("{}", raw_json);
1840 }
1841 }
1842 return Ok(());
1843 }
1844
1845 self.track_deployment_status_and_print_logs_on_fail(
1846 pid,
1847 &deployment.id,
1848 args.tracking_args.raw,
1849 )
1850 .await
1851 }
1852
1853 async fn track_deployment_status(&self, pid: &str, id: &str) -> Result<bool> {
1855 let client = self.client.as_ref().unwrap();
1856 let failed = wait_with_spinner(2000, |_, pb| async move {
1857 let (deployment, raw_json) = client.get_deployment(pid, id).await?.into_parts();
1858
1859 let state = deployment.state.clone();
1860 match self.output_mode {
1861 OutputMode::Normal => {
1862 pb.set_message(deployment.to_string_summary_colored());
1863 }
1864 OutputMode::Json => {
1865 println!("{}", raw_json);
1866 }
1867 }
1868 let failed = state == DeploymentState::Failed;
1869 let cleanup = move || {
1870 match self.output_mode {
1871 OutputMode::Normal => {
1872 eprintln!("{}", deployment.to_string_colored());
1873 }
1874 OutputMode::Json => {
1875 }
1877 }
1878 failed
1879 };
1880 match state {
1881 DeploymentState::Pending
1883 | DeploymentState::Building
1884 | DeploymentState::InProgress => Ok(None),
1885 DeploymentState::Running
1887 | DeploymentState::Stopped
1888 | DeploymentState::Stopping
1889 | DeploymentState::Unknown(_)
1890 | DeploymentState::Failed => Ok(Some(cleanup)),
1891 }
1892 })
1893 .await?;
1894
1895 Ok(failed)
1896 }
1897
1898 async fn track_deployment_status_and_print_logs_on_fail(
1899 &self,
1900 proj_id: &str,
1901 depl_id: &str,
1902 raw: bool,
1903 ) -> Result<()> {
1904 let client = self.client.as_ref().unwrap();
1905 let failed = self.track_deployment_status(proj_id, depl_id).await?;
1906 if failed {
1907 let r = client.get_deployment_logs(proj_id, depl_id).await?;
1908 match self.output_mode {
1909 OutputMode::Normal => {
1910 let logs = r.into_inner().logs;
1911 for log in logs {
1912 if raw {
1913 println!("{}", log.line);
1914 } else {
1915 println!("{log}");
1916 }
1917 }
1918 }
1919 OutputMode::Json => {
1920 println!("{}", r.raw_json);
1921 }
1922 }
1923 bail!("Deployment failed");
1924 }
1925
1926 Ok(())
1927 }
1928
1929 async fn project_create(&self, name: Option<String>) -> Result<()> {
1930 let Some(ref name) = name else {
1931 bail!("Provide a project name with '--name <name>'");
1932 };
1933
1934 let client = self.client.as_ref().unwrap();
1935 let r = client.create_project(name).await?;
1936
1937 match self.output_mode {
1938 OutputMode::Normal => {
1939 let project = r.into_inner();
1940 println!("Created project '{}' with id {}", project.name, project.id);
1941 }
1942 OutputMode::Json => {
1943 println!("{}", r.raw_json);
1944 }
1945 }
1946
1947 Ok(())
1948 }
1949
1950 async fn project_rename(&self, name: String) -> Result<()> {
1951 let client = self.client.as_ref().unwrap();
1952
1953 let r = client
1954 .update_project(
1955 self.ctx.project_id(),
1956 ProjectUpdateRequest {
1957 name: Some(name),
1958 ..Default::default()
1959 },
1960 )
1961 .await?;
1962
1963 match self.output_mode {
1964 OutputMode::Normal => {
1965 let project = r.into_inner();
1966 println!("Renamed project {} to '{}'", project.id, project.name);
1967 }
1968 OutputMode::Json => {
1969 println!("{}", r.raw_json);
1970 }
1971 }
1972
1973 Ok(())
1974 }
1975
1976 async fn projects_list(&self, table_args: TableArgs) -> Result<()> {
1977 let client = self.client.as_ref().unwrap();
1978 let r = client.get_projects_list().await?;
1979
1980 match self.output_mode {
1981 OutputMode::Normal => {
1982 let all_projects = r.into_inner().projects;
1983 let mut all_projects_map = BTreeMap::new();
1985 for proj in all_projects {
1986 all_projects_map
1987 .entry(proj.team_id.clone())
1988 .or_insert_with(Vec::new)
1989 .push(proj);
1990 }
1991 for (team_id, projects) in all_projects_map {
1992 println!(
1993 "{}",
1994 if let Some(team_id) = team_id {
1995 format!("Team {} projects", team_id)
1996 } else {
1997 "Personal Projects".to_owned()
1998 }
1999 .bold()
2000 );
2001 println!("{}\n", get_projects_table(&projects, table_args.raw));
2002 }
2003 }
2004 OutputMode::Json => {
2005 println!("{}", r.raw_json);
2006 }
2007 }
2008
2009 Ok(())
2010 }
2011
2012 async fn project_status(&self) -> Result<()> {
2013 let client = self.client.as_ref().unwrap();
2014 let r = client.get_project(self.ctx.project_id()).await?;
2015
2016 match self.output_mode {
2017 OutputMode::Normal => {
2018 print!("{}", r.into_inner().to_string_colored());
2019 }
2020 OutputMode::Json => {
2021 println!("{}", r.raw_json);
2022 }
2023 }
2024
2025 Ok(())
2026 }
2027
2028 async fn project_delete(&self, no_confirm: bool) -> Result<()> {
2029 let client = self.client.as_ref().unwrap();
2030 let pid = self.ctx.project_id();
2031
2032 if !no_confirm {
2033 let proj = client.get_project(pid).await?.into_inner();
2035 eprintln!(
2036 "{}",
2037 formatdoc!(
2038 r#"
2039 WARNING:
2040 Are you sure you want to delete '{}' ({})?
2041 This will...
2042 - Shut down your service
2043 - Delete any databases and secrets in this project
2044 - Delete any custom domains linked to this project
2045 This action is permanent."#,
2046 proj.name,
2047 pid,
2048 )
2049 .bold()
2050 .red()
2051 );
2052 if !Confirm::with_theme(&ColorfulTheme::default())
2053 .with_prompt("Are you sure?")
2054 .default(false)
2055 .interact()
2056 .unwrap()
2057 {
2058 return Ok(());
2059 }
2060 }
2061
2062 let res = client.delete_project(pid).await?.into_inner();
2063
2064 println!("{res}");
2065
2066 Ok(())
2067 }
2068
2069 fn gather_build_files(&self) -> Result<BTreeMap<PathBuf, PathBuf>> {
2071 let include_patterns = self.ctx.include();
2072 let project_directory = self.ctx.project_directory();
2073
2074 let mut entries = Vec::new();
2079
2080 let ignore_overrides = OverrideBuilder::new(project_directory)
2082 .add("!.git/")
2083 .context("adding override `!.git/`")?
2084 .add("!target/")
2085 .context("adding override `!target/`")?
2086 .build()
2087 .context("building archive override rules")?;
2088 for r in WalkBuilder::new(project_directory)
2089 .hidden(false)
2090 .overrides(ignore_overrides)
2091 .build()
2092 {
2093 entries.push(r.context("list dir entry")?.into_path())
2094 }
2095
2096 let mut globs = GlobSetBuilder::new();
2098 if let Some(rules) = include_patterns {
2099 for r in rules {
2100 globs.add(Glob::new(r.as_str()).context(format!("parsing glob pattern {:?}", r))?);
2101 }
2102 }
2103
2104 let globs = globs.build().context("glob glob")?;
2106 for entry in walkdir::WalkDir::new(project_directory) {
2107 let path = entry.context("list dir")?.into_path();
2108 if globs.is_match(
2109 path.strip_prefix(project_directory)
2110 .context("strip prefix of path")?,
2111 ) {
2112 entries.push(path);
2113 }
2114 }
2115
2116 let mut archive_files = BTreeMap::new();
2117 for path in entries {
2118 if path.is_dir() {
2120 trace!("Skipping {:?}: is a directory", path);
2121 continue;
2122 }
2123 if path.is_symlink() {
2125 trace!("Skipping {:?}: is a symlink", path);
2126 continue;
2127 }
2128
2129 let name = path
2131 .strip_prefix(project_directory)
2132 .context("strip prefix of path")?
2133 .to_owned();
2134
2135 archive_files.insert(path, name);
2136 }
2137
2138 Ok(archive_files)
2139 }
2140
2141 fn make_archive(&self) -> Result<Vec<u8>> {
2142 let archive_files = self.gather_build_files()?;
2143 if archive_files.is_empty() {
2144 bail!("No files included in build");
2145 }
2146
2147 let bytes = {
2148 debug!("making zip archive");
2149 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
2150 for (path, name) in archive_files {
2151 debug!("Packing {path:?}");
2152
2153 let name = name.to_str().expect("valid filename").replace('\\', "/");
2155 zip.start_file(name, FileOptions::<()>::default())?;
2156
2157 let mut b = Vec::new();
2158 fs::File::open(path)?.read_to_end(&mut b)?;
2159 zip.write_all(&b)?;
2160 }
2161 let r = zip.finish().context("finish encoding zip archive")?;
2162
2163 r.into_inner()
2164 };
2165 debug!("Archive size: {} bytes", bytes.len());
2166
2167 Ok(bytes)
2168 }
2169}
2170
2171async fn wait_with_spinner<Fut, C, O>(
2177 millis: u64,
2178 f: impl Fn(usize, ProgressBar) -> Fut,
2179) -> Result<O, anyhow::Error>
2180where
2181 Fut: std::future::Future<Output = Result<Option<C>>>,
2182 C: FnOnce() -> O,
2183{
2184 let progress_bar = create_spinner();
2185 let mut count = 0usize;
2186 let cleanup = loop {
2187 if let Some(cleanup) = f(count, progress_bar.clone()).await? {
2188 break cleanup;
2189 }
2190 count += 1;
2191 sleep(Duration::from_millis(millis)).await;
2192 };
2193 progress_bar.finish_and_clear();
2194
2195 Ok(cleanup())
2196}
2197
2198fn create_spinner() -> ProgressBar {
2199 let pb = indicatif::ProgressBar::new_spinner();
2200 pb.enable_steady_tick(std::time::Duration::from_millis(250));
2201 pb.set_style(
2202 indicatif::ProgressStyle::with_template("{spinner:.orange} {msg}")
2203 .unwrap()
2204 .tick_strings(&[
2205 "( ● )",
2206 "( ● )",
2207 "( ● )",
2208 "( ● )",
2209 "( ●)",
2210 "( ● )",
2211 "( ● )",
2212 "( ● )",
2213 "( ● )",
2214 "(● )",
2215 "(●●●●●●)",
2216 ]),
2217 );
2218
2219 pb
2220}
2221
2222#[cfg(test)]
2223mod tests {
2224 use zip::ZipArchive;
2225
2226 use crate::args::ProjectArgs;
2227 use crate::Shuttle;
2228 use std::fs;
2229 use std::io::Cursor;
2230 use std::path::PathBuf;
2231
2232 pub fn path_from_workspace_root(path: &str) -> PathBuf {
2233 let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
2234 .join("..")
2235 .join(path);
2236
2237 dunce::canonicalize(path).unwrap()
2238 }
2239
2240 async fn get_archive_entries(project_args: ProjectArgs) -> Vec<String> {
2241 let mut shuttle = Shuttle::new(crate::Binary::Shuttle, None).unwrap();
2242 shuttle
2243 .load_project_id(&project_args, false, false)
2244 .await
2245 .unwrap();
2246
2247 let archive = shuttle.make_archive().unwrap();
2248
2249 let mut zip = ZipArchive::new(Cursor::new(archive)).unwrap();
2250 (0..zip.len())
2251 .map(|i| zip.by_index(i).unwrap().name().to_owned())
2252 .collect()
2253 }
2254
2255 #[tokio::test]
2256 async fn make_archive_respect_rules() {
2257 let working_directory = fs::canonicalize(path_from_workspace_root(
2258 "cargo-shuttle/tests/resources/archiving",
2259 ))
2260 .unwrap();
2261
2262 fs::write(working_directory.join("Secrets.toml"), "KEY = 'value'").unwrap();
2263 fs::write(working_directory.join("Secrets.dev.toml"), "KEY = 'dev'").unwrap();
2264 fs::write(working_directory.join("asset2"), "").unwrap();
2265 fs::write(working_directory.join("asset4"), "").unwrap();
2266 fs::create_dir_all(working_directory.join("dist")).unwrap();
2267 fs::write(working_directory.join("dist").join("dist1"), "").unwrap();
2268
2269 fs::create_dir_all(working_directory.join("target")).unwrap();
2270 fs::write(working_directory.join("target").join("binary"), b"12345").unwrap();
2271
2272 let project_args = ProjectArgs {
2273 working_directory: working_directory.clone(),
2274 name: None,
2275 id: Some("proj_archiving-test".to_owned()),
2276 };
2277 let mut entries = get_archive_entries(project_args.clone()).await;
2278 entries.sort();
2279
2280 let expected = vec![
2281 ".gitignore",
2282 ".ignore",
2283 "Cargo.toml",
2284 "Secrets.toml.example",
2285 "Shuttle.toml",
2286 "asset1", "asset2", "asset4", "asset5", "dist/dist1", "nested/static/nested1", "src/main.rs",
2295 ];
2296 assert_eq!(entries, expected);
2297 }
2298
2299 #[tokio::test]
2300 async fn finds_workspace_root() {
2301 let project_args = ProjectArgs {
2302 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
2303 name: None,
2304 id: None,
2305 };
2306
2307 assert_eq!(
2308 project_args.workspace_path().unwrap(),
2309 path_from_workspace_root("examples/axum/hello-world")
2310 );
2311 }
2312}