Skip to content

Commit

Permalink
feat(cargo-shuttle): beta project linking (#1885)
Browse files Browse the repository at this point in the history
* feat: project create model

* feat: link project id if none is found locally

* feat: use project id in requests

* new beta create project

* clippy

* feat: write gitignore file after saving internal local config

* chore: type

* feat: allow translating name->id with --name

* fixes, add project link command

* name_or_id

* clippy
  • Loading branch information
jonaro00 authored Sep 30, 2024
1 parent b9e94c7 commit f3e1a19
Show file tree
Hide file tree
Showing 9 changed files with 340 additions and 145 deletions.
11 changes: 8 additions & 3 deletions api-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use shuttle_common::log::{LogsRange, LogsResponseBeta};
use shuttle_common::models::deployment::{
DeploymentListResponseBeta, DeploymentRequest, DeploymentRequestBeta, UploadArchiveResponseBeta,
};
use shuttle_common::models::project::ProjectListResponseBeta;
use shuttle_common::models::project::{ProjectCreateRequestBeta, ProjectListResponseBeta};
use shuttle_common::models::{deployment, project, service, team, user};
use shuttle_common::resource::{
ProvisionResourceRequestBeta, ResourceListResponseBeta, ResourceResponseBeta,
Expand Down Expand Up @@ -265,8 +265,13 @@ impl ShuttleApiClient {
.context("failed to make create project request")
}
pub async fn create_project_beta(&self, name: &str) -> Result<project::ProjectResponseBeta> {
self.post_json(format!("/projects/{name}"), None::<()>)
.await
self.post_json(
"/projects",
Some(ProjectCreateRequestBeta {
name: name.to_string(),
}),
)
.await
}

pub async fn clean_project(&self, project: &str) -> Result<String> {
Expand Down
23 changes: 13 additions & 10 deletions cargo-shuttle/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ pub struct ShuttleArgs {
pub project_args: ProjectArgs,
/// Run this command against the API at the supplied URL
/// (allows targeting a custom deployed instance for this command only, mainly for development)
#[arg(long, env = "SHUTTLE_API")]
#[arg(global = true, long, env = "SHUTTLE_API")]
pub api_url: Option<String>,
/// Disable network requests that are not strictly necessary. Limits some features.
#[arg(long, env = "SHUTTLE_OFFLINE")]
#[arg(global = true, long, env = "SHUTTLE_OFFLINE")]
pub offline: bool,
/// Turn on tracing output for Shuttle libraries. (WARNING: can print sensitive data)
#[arg(long, env = "SHUTTLE_DEBUG")]
#[arg(global = true, long, env = "SHUTTLE_DEBUG")]
pub debug: bool,
/// Target Shuttle's development environment
#[arg(long, env = "SHUTTLE_BETA", hide = true)]
#[arg(global = true, long, env = "SHUTTLE_BETA", hide = true)]
pub beta: bool,

#[command(subcommand)]
Expand All @@ -52,9 +52,10 @@ pub struct ProjectArgs {
/// Specify the working directory
#[arg(global = true, long, visible_alias = "wd", default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_path))]
pub working_directory: PathBuf,
/// Specify the name of the project (overrides crate name)
#[arg(global = true, long)]
pub name: Option<String>,
/// Specify the name or id of the project (overrides crate name)
#[arg(global = true, long = "name", visible_alias = "id")]
// in alpha mode, this is always a name
pub name_or_id: Option<String>,
}

impl ProjectArgs {
Expand Down Expand Up @@ -269,6 +270,8 @@ pub enum ProjectCommand {
/// Delete a project and all linked data
#[command(visible_alias = "rm")]
Delete(ConfirmationArgs),
/// Link this workspace to a Shuttle project
Link,
}

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -597,7 +600,7 @@ mod tests {
fn workspace_path() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
name_or_id: None,
};

assert_eq!(
Expand All @@ -610,7 +613,7 @@ mod tests {
fn project_name() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
name_or_id: None,
};

assert_eq!(
Expand All @@ -625,7 +628,7 @@ mod tests {
working_directory: path_from_workspace_root(
"examples/rocket/workspace/hello-world/src",
),
name: None,
name_or_id: None,
};

assert_eq!(
Expand Down
127 changes: 120 additions & 7 deletions cargo-shuttle/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use shuttle_common::{constants::API_URL_DEFAULT, ApiKey};
use tracing::trace;

use crate::args::ProjectArgs;
use crate::init::create_or_update_ignore_file;

/// Helper trait for dispatching fs ops for different config files
pub trait ConfigManager: Sized {
Expand Down Expand Up @@ -144,11 +145,22 @@ impl GlobalConfig {
}
}

/// Project-local config for things like customizing project name
/// Shuttle.toml schema (User-facing project-local config)
#[derive(Deserialize, Serialize, Default)]
pub struct ProjectConfig {
// unused on new platform
pub name: Option<String>,
pub assets: Option<Vec<String>>,
// unused in cargo-shuttle, used in new platform builder.
// is used here to validate the type if used.
pub build_assets: Option<Vec<String>>,
}

/// .shuttle/config.toml schema (internal project-local config)
#[derive(Deserialize, Serialize, Default)]
pub struct InternalProjectConfig {
// should be in internal local config
pub id: Option<String>,
}

/// A handler for configuration files. The type parameter `M` is the [`ConfigManager`] which handles
Expand Down Expand Up @@ -233,6 +245,7 @@ where
pub struct RequestContext {
global: Config<GlobalConfigManager, GlobalConfig>,
project: Option<Config<LocalConfigManager, ProjectConfig>>,
project_internal: Option<Config<LocalConfigManager, InternalProjectConfig>>,
api_url: Option<String>,
}

Expand All @@ -249,10 +262,82 @@ impl RequestContext {
Ok(Self {
global,
project: None,
project_internal: None,
api_url: None,
})
}

pub fn load_local_internal(&mut self, project_args: &ProjectArgs) -> Result<()> {
let workspace_path = project_args
.workspace_path()
.unwrap_or(project_args.working_directory.clone());

trace!(
"looking for .shuttle/config.toml in {}",
workspace_path.display()
);
let local_manager =
LocalConfigManager::new(workspace_path, ".shuttle/config.toml".to_string());
let mut project_internal = Config::new(local_manager);
if !project_internal.exists() {
trace!("no local .shuttle/config.toml found");
project_internal.replace(InternalProjectConfig::default());
} else {
trace!("found a local .shuttle/config.toml");
project_internal.open()?;
}

let config = project_internal.as_mut().unwrap();

// Project id is preferred in this order:
// 1. Name given on command line
// 2. Name from .shuttle/config.toml file
match (&project_args.name_or_id, &config.id) {
// Command-line name parameter trumps everything
(Some(id_from_args), _) => {
trace!("using command-line project id");
config.id = Some(id_from_args.clone());
}
// If key exists in config then keep it as it is
(None, Some(_)) => {
trace!("using .shuttle/config.toml project id");
}
(None, None) => {
trace!("no project id in args or config found");
}
};

self.project_internal = Some(project_internal);

Ok(())
}

pub fn set_project_id(&mut self, id: String) {
*self.project_internal.as_mut().unwrap().as_mut().unwrap() =
InternalProjectConfig { id: Some(id) };
}

pub fn save_local_internal(&mut self) -> Result<()> {
// if self.project_internal.is_some() {
self.project_internal.as_ref().unwrap().save()?;
// }

// write updated gitignore file to root of workspace
// TODO: assumes git is used
create_or_update_ignore_file(
&self
.project
.as_ref()
.unwrap()
.manager
.working_directory
.join(".gitignore"),
)
.context("Failed to create .gitignore file")?;

Ok(())
}

/// Load the project configuration at the given `working_directory`
///
/// Ensures that if `--name` is not specified on the command-line, and either the project
Expand All @@ -277,7 +362,6 @@ impl RequestContext {
trace!("looking for Shuttle.toml in {}", workspace_path.display());
let local_manager = LocalConfigManager::new(workspace_path, "Shuttle.toml".to_string());
let mut project = Config::new(local_manager);

if !project.exists() {
trace!("no local Shuttle.toml found");
project.replace(ProjectConfig::default());
Expand All @@ -292,8 +376,8 @@ impl RequestContext {
// 1. Name given on command line
// 2. Name from Shuttle.toml file
// 3. Name from Cargo.toml package if it's a crate
// 3. Name from the workspace directory if it's a workspace
match (&project_args.name, &config.name) {
// 4. Name from the workspace directory if it's a workspace
match (&project_args.name_or_id, &config.name) {
// Command-line name parameter trumps everything
(Some(name_from_args), _) => {
trace!("using command-line project name");
Expand All @@ -309,6 +393,7 @@ impl RequestContext {
config.name = Some(project_args.project_name()?);
}
};

Ok(project)
}

Expand Down Expand Up @@ -373,6 +458,7 @@ impl RequestContext {
self.global.as_mut().unwrap().clear_api_key();
self.global.save()
}

/// Get the current project name.
///
/// # Panics
Expand Down Expand Up @@ -400,6 +486,33 @@ impl RequestContext {
.assets
.as_ref()
}

/// Check if the current project id has been loaded.
pub fn project_id_found(&self) -> bool {
self.project_internal
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.id
.is_some()
}

/// Get the current project id.
///
/// # Panics
/// Panics if the internal project configuration has not been loaded.
pub fn project_id(&self) -> &str {
self.project_internal
.as_ref()
.unwrap()
.as_ref()
.unwrap()
.id
.as_ref()
.unwrap()
.as_str()
}
}

#[cfg(test)]
Expand All @@ -424,7 +537,7 @@ mod tests {
fn get_local_config_finds_name_in_shuttle_toml() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/"),
name: None,
name_or_id: None,
};

let local_config = RequestContext::get_local_config(&project_args).unwrap();
Expand All @@ -436,7 +549,7 @@ mod tests {
fn get_local_config_finds_name_from_workspace_dir() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/rocket/workspace/hello-world/"),
name: None,
name_or_id: None,
};

let local_config = RequestContext::get_local_config(&project_args).unwrap();
Expand All @@ -448,7 +561,7 @@ mod tests {
fn setting_name_overrides_name_in_config() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/"),
name: Some("my-fancy-project-name".to_owned()),
name_or_id: Some("my-fancy-project-name".to_owned()),
};

let local_config = RequestContext::get_local_config(&project_args).unwrap();
Expand Down
13 changes: 6 additions & 7 deletions cargo-shuttle/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub fn generate_project(
// if the crate name was not updated, set it in Shuttle.toml instead
edit_shuttle_toml(&path, (!crate_name_set).then_some(name))
.context("Failed to edit Shuttle.toml")?;
create_ignore_file(&path, if no_git { ".ignore" } else { ".gitignore" })
create_or_update_ignore_file(&path.join(if no_git { ".ignore" } else { ".gitignore" }))
.context("Failed to create .gitignore file")?;

copy_dirs(&path, &dest, GitDir::Ignore)
Expand Down Expand Up @@ -273,18 +273,17 @@ fn edit_shuttle_toml(path: &Path, set_name: Option<&str>) -> Result<()> {
Ok(())
}

/// Adds any missing recommended ignore rules to .gitignore or .ignore depending on if git is used.
fn create_ignore_file(path: &Path, name: &str) -> Result<()> {
let path = path.join(name);
let mut contents = std::fs::read_to_string(&path).unwrap_or_default();
/// Adds any missing recommended ignore rules to an ignore file (usually .gitignore)
pub fn create_or_update_ignore_file(path: &Path) -> Result<()> {
let mut contents = std::fs::read_to_string(path).unwrap_or_default();

for rule in ["/target", ".shuttle-storage", "Secrets*.toml"] {
for rule in ["/target", ".shuttle*", "Secrets*.toml"] {
if !contents.lines().any(|l| l == rule) {
writeln!(&mut contents, "{rule}")?;
}
}

std::fs::write(&path, contents)?;
std::fs::write(path, contents)?;

Ok(())
}
Expand Down
Loading

0 comments on commit f3e1a19

Please sign in to comment.