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