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