diff --git a/mantle/Cargo.lock b/mantle/Cargo.lock index 07df629..c3216cf 100644 --- a/mantle/Cargo.lock +++ b/mantle/Cargo.lock @@ -41,6 +41,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "anyhow" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" + [[package]] name = "approx" version = "0.5.1" @@ -2143,6 +2149,7 @@ dependencies = [ name = "rbx_mantle" version = "0.11.14" dependencies = [ + "anyhow", "async-trait", "chrono", "clap", @@ -2156,8 +2163,10 @@ dependencies = [ "rusoto_s3", "schemars", "serde", + "serde_json", "serde_yaml", "sha2", + "tinytemplate", "tokio", "url", "yansi", @@ -3111,6 +3120,16 @@ dependencies = [ "syn 1.0.107", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/mantle/mantle/src/commands/deploy.rs b/mantle/mantle/src/commands/deploy.rs index f4480c8..069456e 100644 --- a/mantle/mantle/src/commands/deploy.rs +++ b/mantle/mantle/src/commands/deploy.rs @@ -122,7 +122,7 @@ fn log_target_results( pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchases: bool) -> i32 { logger::start_action("Loading project:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -137,7 +137,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchas payment_source, state_config, owner_config, - } = match load_project(project_path.clone(), config, environment).await { + } = match load_project(&config_file, environment).await { Ok(Some(v)) => v, Ok(None) => { logger::end_action("No deployment necessary"); @@ -149,7 +149,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchas } }; let mut next_graph = - match get_desired_graph(project_path.as_path(), &target_config, &owner_config) { + match get_desired_graph(&config_file.project_path, &target_config, &owner_config) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -159,14 +159,14 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchas logger::end_action("Succeeded"); logger::start_action("Deploying resources:"); - let mut resource_manager = match RobloxResourceManager::new(&project_path, payment_source).await - { - Ok(v) => v, - Err(e) => { - logger::end_action(Paint::red(e)); - return 1; - } - }; + let mut resource_manager = + match RobloxResourceManager::new(&config_file.project_path, payment_source).await { + Ok(v) => v, + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; let results = next_graph .evaluate(¤t_graph, &mut resource_manager, allow_purchases) @@ -201,7 +201,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchas if environment_config.tag_commit && results.is_ok() { logger::start_action("Tagging commit:"); match tag_commit( - project_path.clone(), + config_file.project_path.clone(), &target_config, &next_graph, ¤t_graph, @@ -219,7 +219,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, allow_purchas environment_config.label.clone(), next_graph.get_resource_list(), ); - match save_state(&project_path, &state_config, &state).await { + match save_state(&config_file.project_path, &state_config, &state).await { Ok(_) => {} Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/mantle/src/commands/destroy.rs b/mantle/mantle/src/commands/destroy.rs index b81b8f5..3e2392a 100644 --- a/mantle/mantle/src/commands/destroy.rs +++ b/mantle/mantle/src/commands/destroy.rs @@ -12,7 +12,7 @@ use rbx_mantle::{ pub async fn run(project: Option<&str>, environment: Option<&str>) -> i32 { logger::start_action("Loading project:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -26,7 +26,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>) -> i32 { payment_source, state_config, .. - } = match load_project(project_path.clone(), config, environment).await { + } = match load_project(&config_file, environment).await { Ok(Some(v)) => v, Ok(None) => { logger::end_action("No deployment necessary"); @@ -40,14 +40,14 @@ pub async fn run(project: Option<&str>, environment: Option<&str>) -> i32 { logger::end_action("Succeeded"); logger::start_action("Destroying resources:"); - let mut resource_manager = match RobloxResourceManager::new(&project_path, payment_source).await - { - Ok(v) => v, - Err(e) => { - logger::end_action(Paint::red(e)); - return 1; - } - }; + let mut resource_manager = + match RobloxResourceManager::new(&config_file.project_path, payment_source).await { + Ok(v) => v, + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; let mut next_graph = ResourceGraph::new(&Vec::new()); let results = next_graph @@ -79,7 +79,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>) -> i32 { next_graph.get_resource_list(), ); } - match save_state(&project_path, &state_config, &state).await { + match save_state(&config_file.project_path, &state_config, &state).await { Ok(_) => {} Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/mantle/src/commands/diff.rs b/mantle/mantle/src/commands/diff.rs index 53648a4..bdc64c3 100644 --- a/mantle/mantle/src/commands/diff.rs +++ b/mantle/mantle/src/commands/diff.rs @@ -64,7 +64,7 @@ pub async fn run( format: Option<&str>, ) -> i32 { logger::start_action("Loading project:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -76,7 +76,7 @@ pub async fn run( target_config, owner_config, .. - } = match load_project(project_path.clone(), config, environment).await { + } = match load_project(&config_file, environment).await { Ok(Some(v)) => v, Ok(None) => { logger::end_action("No diff available"); @@ -87,14 +87,17 @@ pub async fn run( return 1; } }; - let mut next_graph = - match get_desired_graph(project_path.as_path(), &target_config, &owner_config) { - Ok(v) => v, - Err(e) => { - logger::end_action(Paint::red(e)); - return 1; - } - }; + let mut next_graph = match get_desired_graph( + config_file.project_path.as_path(), + &target_config, + &owner_config, + ) { + Ok(v) => v, + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; logger::end_action("Succeeded"); logger::start_action("Diffing resource graphs:"); diff --git a/mantle/mantle/src/commands/download.rs b/mantle/mantle/src/commands/download.rs index dee756c..9f41c72 100644 --- a/mantle/mantle/src/commands/download.rs +++ b/mantle/mantle/src/commands/download.rs @@ -7,7 +7,7 @@ use rbx_mantle::{ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { logger::start_action("Download state file:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -15,12 +15,14 @@ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { } }; - if !matches!(config.state, StateConfig::Remote(_)) { + // config_file.header. + + if !matches!(config_file.header.state, StateConfig::Remote(_)) { logger::end_action(Paint::red("Project is not configured with remote state")); return 1; } - let state = match get_state(&project_path, &config).await { + let state = match get_state(&config_file.project_path, &config_file.header).await { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -32,7 +34,7 @@ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { Some(key) => StateConfig::LocalKey(key.to_owned()), None => StateConfig::Local, }; - match save_state(&project_path, &state_config, &state).await { + match save_state(&config_file.project_path, &state_config, &state).await { Ok(_) => {} Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/mantle/src/commands/import.rs b/mantle/mantle/src/commands/import.rs index 676df14..49b6b2d 100644 --- a/mantle/mantle/src/commands/import.rs +++ b/mantle/mantle/src/commands/import.rs @@ -10,7 +10,7 @@ use rbx_mantle::{ pub async fn run(project: Option<&str>, environment: Option<&str>, target_id: &str) -> i32 { logger::start_action("Loading project:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -23,7 +23,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, target_id: &s environment_config, state_config, .. - } = match load_project(project_path.clone(), config, environment).await { + } = match load_project(&config_file, environment).await { Ok(Some(v)) => v, Ok(None) => { logger::end_action("No import necessary"); @@ -90,7 +90,7 @@ pub async fn run(project: Option<&str>, environment: Option<&str>, target_id: &s environment_config.label.clone(), imported_graph.get_resource_list(), ); - match save_state(&project_path, &state_config, &state).await { + match save_state(&config_file.project_path, &state_config, &state).await { Ok(_) => {} Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/mantle/src/commands/outputs.rs b/mantle/mantle/src/commands/outputs.rs index 2b4c077..90e936c 100644 --- a/mantle/mantle/src/commands/outputs.rs +++ b/mantle/mantle/src/commands/outputs.rs @@ -15,25 +15,24 @@ pub async fn run( format: &str, ) -> i32 { logger::start_action("Load outputs:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); return 1; } }; - let Project { current_graph, .. } = - match load_project(project_path.clone(), config, environment).await { - Ok(Some(v)) => v, - Ok(None) => { - logger::end_action("No outputs available"); - return 0; - } - Err(e) => { - logger::end_action(Paint::red(e)); - return 1; - } - }; + let Project { current_graph, .. } = match load_project(&config_file, environment).await { + Ok(Some(v)) => v, + Ok(None) => { + logger::end_action("No outputs available"); + return 0; + } + Err(e) => { + logger::end_action(Paint::red(e)); + return 1; + } + }; let resources = current_graph.get_resource_list(); let outputs_map = resources diff --git a/mantle/mantle/src/commands/upload.rs b/mantle/mantle/src/commands/upload.rs index 4cde6fb..d74a34a 100644 --- a/mantle/mantle/src/commands/upload.rs +++ b/mantle/mantle/src/commands/upload.rs @@ -7,7 +7,7 @@ use rbx_mantle::{ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { logger::start_action("Upload state file:"); - let (project_path, config) = match load_project_config(project) { + let config_file = match load_project_config(project) { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -15,7 +15,7 @@ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { } }; - if !matches!(config.state, StateConfig::Remote(_)) { + if !matches!(config_file.header.state, StateConfig::Remote(_)) { logger::end_action(Paint::red("Project is not configured with remote state")); return 1; } @@ -24,7 +24,7 @@ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { Some(key) => StateConfig::LocalKey(key.to_owned()), None => StateConfig::Local, }; - let state = match get_state_from_source(&project_path, state_config).await { + let state = match get_state_from_source(&config_file.project_path, state_config).await { Ok(v) => v, Err(e) => { logger::end_action(Paint::red(e)); @@ -32,7 +32,7 @@ pub async fn run(project: Option<&str>, key: Option<&str>) -> i32 { } }; - match save_state(&project_path, &config.state, &state).await { + match save_state(&config_file.project_path, &config_file.header.state, &state).await { Ok(_) => {} Err(e) => { logger::end_action(Paint::red(e)); diff --git a/mantle/project-fixtures/light/.mantle-state.yml b/mantle/project-fixtures/light/.mantle-state.yml new file mode 100644 index 0000000..2ba6550 --- /dev/null +++ b/mantle/project-fixtures/light/.mantle-state.yml @@ -0,0 +1,383 @@ +# +# WARNING - Generated file. Do not modify directly unless you know what you are doing! +# This file was generated by Mantle v0.11.12 on 2023-12-16T17:04:44Z +# + +--- +version: "5" +environments: + prod: + - id: experience_singleton + inputs: + experience: + groupId: ~ + outputs: + experience: + assetId: 4103268770 + startPlaceId: 11558644509 + dependencies: [] + - id: product_1376936007 + inputs: + product: + name: "zzz_DEPRECATED(2023-02-13 ################" + description: "" + price: 0 + outputs: + product: + assetId: 1376936007 + productId: 20420004 + dependencies: + - experience_singleton + - id: product_1352273139 + inputs: + product: + name: "zzz_DEPRECATED(2022-12-30 ################" + description: "" + price: 0 + outputs: + product: + assetId: 1352273139 + productId: 19058042 + dependencies: + - experience_singleton + - id: productIcon_1352273139 + inputs: + productIcon: + filePath: fake-path + fileHash: fake-hash + outputs: + productIcon: + assetId: 11969797609 + dependencies: + - product_1352273139 + - id: product_1352254835 + inputs: + product: + name: game-icon.png + description: asdf + price: 0 + outputs: + product: + assetId: 1352254835 + productId: 19056823 + dependencies: + - experience_singleton + - id: productIcon_1352254835 + inputs: + productIcon: + filePath: fake-path + fileHash: fake-hash + outputs: + productIcon: + assetId: 11969378680 + dependencies: + - product_1352254835 + - id: product_1352251011 + inputs: + product: + name: Developer Product 1 + description: "" + price: 1 + outputs: + product: + assetId: 1352251011 + productId: 19056642 + dependencies: + - experience_singleton + - id: place_start + inputs: + place: + isStart: true + outputs: + place: + assetId: 11558644509 + dependencies: + - experience_singleton + - id: placeFile_start + inputs: + placeFile: + filePath: fake-path + fileHash: fake-hash + outputs: + placeFile: + version: 4 + dependencies: + - place_start + - id: placeConfiguration_start + inputs: + placeConfiguration: + name: "[STAGING] Lightweight Mantle Tests" + description: Created with Mantle + maxPlayerCount: 50 + allowCopying: false + socialSlotType: Custom + customSocialSlotsCount: 4 + outputs: placeConfiguration + dependencies: + - place_start + - id: pass_116228587 + inputs: + pass: + name: "zzz_DEPRECATED(2022-12-30 ################" + description: "" + price: ~ + iconFilePath: fake-path + iconFileHash: fake-hash + outputs: + pass: + assetId: 116228587 + iconAssetId: 11969910924 + dependencies: + - experience_singleton + - id: pass_116224897 + inputs: + pass: + name: Testasd + description: pass + price: ~ + iconFilePath: fake-path + iconFileHash: fake-hash + outputs: + pass: + assetId: 116224897 + iconAssetId: 11969869134 + dependencies: + - experience_singleton + - id: experienceThumbnailOrder_singleton + inputs: experienceThumbnailOrder + outputs: experienceThumbnailOrder + dependencies: + - experience_singleton + - id: experienceConfiguration_singleton + inputs: + experienceConfiguration: + genre: All + playableDevices: + - Computer + - Phone + - Tablet + isFriendsOnly: false + allowPrivateServers: false + privateServerPrice: ~ + isForSale: false + price: 0 + studioAccessToApisAllowed: false + permissions: + IsThirdPartyPurchaseAllowed: false + IsThirdPartyTeleportAllowed: false + universeAvatarType: MorphToR15 + universeAnimationType: PlayerChoice + universeCollisionType: OuterBox + universeAvatarMinScales: + height: "0.9" + width: "0.7" + head: "0.95" + bodyType: "0" + proportion: "0" + universeAvatarMaxScales: + height: "1.05" + width: "1" + head: "1" + bodyType: "1" + proportion: "1" + universeAvatarAssetOverrides: + - assetTypeID: 18 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 17 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 27 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 29 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 28 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 30 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 31 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 2 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 11 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 12 + isPlayerChoice: true + assetID: ~ + isArchived: false + outputs: experienceConfiguration + dependencies: + - experience_singleton + - id: experienceActivation_singleton + inputs: + experienceActivation: + isActive: false + outputs: experienceActivation + dependencies: + - experience_singleton + - id: badge_2130183201 + inputs: + badge: + name: game-icon.png + description: "" + enabled: true + iconFilePath: fake-path + outputs: + badge: + assetId: 2130183201 + initialIconAssetId: 11969931552 + dependencies: + - experience_singleton + - id: badgeIcon_2130183201 + inputs: + badgeIcon: + filePath: fake-path + fileHash: fake-hash + outputs: + badgeIcon: + assetId: 11969931552 + dependencies: + - badge_2130183201 + - id: asset_Images/badge-2 (2) + inputs: + imageAsset: + filePath: fake-path + fileHash: fake-hash + groupId: ~ + outputs: + imageAsset: + assetId: 13637430429 + decalAssetId: ~ + dependencies: [] + - id: assetAlias_Images/badge-2 (2) + inputs: + assetAlias: + name: Images/badge-2 (2) + outputs: + assetAlias: + name: Images/badge-2 (2) + dependencies: + - experience_singleton + - asset_Images/badge-2 (2) + staging: + - id: experience_singleton + inputs: + experience: + groupId: ~ + outputs: + experience: + assetId: 4103268770 + startPlaceId: 11558644509 + dependencies: [] + - id: place_start + inputs: + place: + isStart: true + outputs: + place: + assetId: 11558644509 + dependencies: + - experience_singleton + - id: placeFile_start + inputs: + placeFile: + filePath: place.rbxl + fileHash: 742b1049250df8374029e8dfae92ad87d221448994ccad60e94810c3305577c2 + outputs: + placeFile: + version: 4 + dependencies: + - place_start + - id: placeConfiguration_start + inputs: + placeConfiguration: + name: "[STAGING] Lightweight Mantle Tests" + description: Created with Mantle + maxPlayerCount: 50 + allowCopying: false + socialSlotType: Custom + customSocialSlotsCount: 4 + outputs: placeConfiguration + dependencies: + - place_start + - id: experienceConfiguration_singleton + inputs: + experienceConfiguration: + genre: All + playableDevices: + - Computer + - Phone + - Tablet + isFriendsOnly: ~ + allowPrivateServers: false + privateServerPrice: ~ + isForSale: false + price: ~ + studioAccessToApisAllowed: false + permissions: + IsThirdPartyPurchaseAllowed: false + IsThirdPartyTeleportAllowed: false + universeAvatarType: MorphToR15 + universeAnimationType: PlayerChoice + universeCollisionType: OuterBox + universeAvatarMinScales: + height: "0.9" + width: "0.7" + head: "0.95" + bodyType: "0" + proportion: "0" + universeAvatarMaxScales: + height: "1.05" + width: "1" + head: "1" + bodyType: "1" + proportion: "1" + universeAvatarAssetOverrides: + - assetTypeID: 18 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 17 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 27 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 29 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 28 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 30 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 31 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 2 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 11 + isPlayerChoice: true + assetID: ~ + - assetTypeID: 12 + isPlayerChoice: true + assetID: ~ + isArchived: false + outputs: experienceConfiguration + dependencies: + - experience_singleton + - id: experienceActivation_singleton + inputs: + experienceActivation: + isActive: false + outputs: experienceActivation + dependencies: + - experience_singleton diff --git a/mantle/project-fixtures/light/mantle.yml b/mantle/project-fixtures/light/mantle.yml index 86d5cf9..8788632 100644 --- a/mantle/project-fixtures/light/mantle.yml +++ b/mantle/project-fixtures/light/mantle.yml @@ -1,21 +1,32 @@ environments: - label: staging branches: [dev, dev/*] - targetNamePrefix: environmentLabel - targetAccess: private + variables: + - name: playability + value: private + - label: production + branches: [main] + variables: + - name: playability + value: public + - name: owner + value: + group: 123456 +state: + remote: + region: us-west-2 + bucket: mantle-states + key: project-fixtures/light +--- target: experience: + configuration: + playability: "{vars.playability}" places: start: file: place.rbxl configuration: - name: Lightweight Mantle Test + name: "[{env.label}] Lightweight Mantle Test" serverFill: reservedSlots: 4 - -state: - remote: - region: us-west-2 - bucket: mantle-states - key: project-fixtures/light diff --git a/mantle/rbx_mantle/Cargo.toml b/mantle/rbx_mantle/Cargo.toml index 03d791e..bc90be3 100644 --- a/mantle/rbx_mantle/Cargo.toml +++ b/mantle/rbx_mantle/Cargo.toml @@ -15,6 +15,7 @@ rbx_api = { path = "../rbx_api" } logger = { path = "../logger" } serde_yaml = { version = "0.8" } +serde_json = { version = "1.0.59" } serde = { version = "1.0", features = ["derive"] } clap = "2.33.0" glob = "0.3.0" @@ -33,3 +34,5 @@ schemars = { version = "=0.8.8-blake.2", git = "https://github.com/blake-mealey/ "url", "preserve_order", ] } +anyhow = "1.0.82" +tinytemplate = "1.2.1" diff --git a/mantle/rbx_mantle/src/config.rs b/mantle/rbx_mantle/src/config.rs index e07aadf..1074186 100644 --- a/mantle/rbx_mantle/src/config.rs +++ b/mantle/rbx_mantle/src/config.rs @@ -5,6 +5,7 @@ use std::{ str, }; +use anyhow::bail; use rbx_api::{ experiences::models::{ ExperienceAnimationType, ExperienceAvatarType, ExperienceCollisionType, @@ -16,9 +17,60 @@ use rbx_api::{ use rusoto_core::Region; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tinytemplate::TinyTemplate; use url::Url; use yansi::Paint; +use crate::repo::{get_current_branch, match_branch}; + +#[derive(JsonSchema, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConfigHeader { + /// The list of environments which Mantle can deploy to. + /// + /// ```yml title="Example" + /// environments: + /// - label: staging + /// branches: [dev, dev/*] + /// targetOverrides: + /// configuration: + /// icon: marketing/beta-game-icon.png + /// - label: production + /// branches: [main] + /// targetAccess: public + /// ``` + pub environments: Vec, + + /// default('local') + /// + /// Defines how Mantle should manage state files (locally or remotely). + /// + /// | Value | Description | + /// |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + /// | `'local'` | Mantle will save and load its state to and from a local `.mantle-state.yml` file. | + /// | `localKey: ` | Mantle will save and load its state to and from a local file using the provided key with the format `.mantle-state.yml`. | + /// | `remote: ` | Mantle will save and load its state to and from a remote file stored in a cloud provider. Currently the only supported provider is Amazon S3. For more information, see the [Remote State](/docs/remote-state) guide. | + /// + /// ```yml title="Local State Example (Default)" + /// state: local + /// ``` + /// + /// ```yml title="Custom Local State Example" + /// state: + /// localKey: pirate-wars + /// ``` + /// + /// ```yml title="Remote State Example" + /// state: + /// remote: + /// region: us-west-1 + /// bucket: my-mantle-states + /// key: pirate-wars + /// ``` + #[serde(default)] + pub state: StateConfig, +} + #[derive(JsonSchema, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Config { @@ -57,21 +109,6 @@ pub struct Config { #[serde(default)] pub payments: PaymentsConfig, - /// The list of environments which Mantle can deploy to. - /// - /// ```yml title="Example" - /// environments: - /// - label: staging - /// branches: [dev, dev/*] - /// targetOverrides: - /// configuration: - /// icon: marketing/beta-game-icon.png - /// - label: production - /// branches: [main] - /// targetAccess: public - /// ``` - pub environments: Vec, - /// Defines the target resource which Mantle will deploy to. Currently /// Mantle only supports targeting Experiences, but in the future it will /// support other types like Plugins and Models. @@ -81,35 +118,6 @@ pub struct Config { /// experience: {} /// ``` pub target: TargetConfig, - - /// default('local') - /// - /// Defines how Mantle should manage state files (locally or remotely). - /// - /// | Value | Description | - /// |--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| - /// | `'local'` | Mantle will save and load its state to and from a local `.mantle-state.yml` file. | - /// | `localKey: ` | Mantle will save and load its state to and from a local file using the provided key with the format `.mantle-state.yml`. | - /// | `remote: ` | Mantle will save and load its state to and from a remote file stored in a cloud provider. Currently the only supported provider is Amazon S3. For more information, see the [Remote State](/docs/remote-state) guide. | - /// - /// ```yml title="Local State Example (Default)" - /// state: local - /// ``` - /// - /// ```yml title="Custom Local State Example" - /// state: - /// localKey: pirate-wars - /// ``` - /// - /// ```yml title="Remote State Example" - /// state: - /// remote: - /// region: us-west-1 - /// bucket: my-mantle-states - /// key: pirate-wars - /// ``` - #[serde(default)] - pub state: StateConfig, } #[derive(JsonSchema, Deserialize, Clone)] @@ -238,6 +246,13 @@ impl fmt::Display for RemoteStateConfig { } } +#[derive(JsonSchema, Deserialize, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnvironmentVariableConfig { + pub name: String, + pub value: serde_json::Value, +} + #[derive(JsonSchema, Deserialize, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct EnvironmentConfig { @@ -263,55 +278,10 @@ pub struct EnvironmentConfig { #[serde(default)] pub tag_commit: bool, - /// skip_properties() - /// - /// Adds a prefix to the target's name configuration. The implementation is dependent on the - /// target's type. For Experience targets, all place names will be updated with the prefix. - /// - /// | Value | Description | - /// |----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| - /// | `'environmentLabel'` | The target name prefix will use the format `[] ` where `` is the value of the environment's [`label`](#environments--label) property in all caps. For example, if the environment's label was `'dev'` and the target's name was "Made with Mantle", the resulting target name will be "[DEV] Made with Mantle". | - /// | `custom: ` | The target name prefix will be the supplied value. | - /// - /// ```yml title="Environment Label Example" - /// environments: - /// - label: dev - /// targetNamePrefix: environmentLabel - /// - label: prod - /// ``` - /// - /// ```yml title="Custom Example" - /// environments: - /// - label: dev - /// targetNamePrefix: - /// custom: 'Prefix: ' - /// - label: prod - /// ``` - pub target_name_prefix: Option, - - /// Overrides the target's access. The implementation is dependent on the - /// target's type. For Experience targets, the - /// [`playability`](#target-experience-configuration-playability) property - /// will be overridden. - /// - /// | Value | Description | - /// |-------------|-------------------------------------------------------------------------------------------| - /// | `'public'` | The target will be accessible to all Roblox users. | - /// | `'private'` | The target will only be accessible to the authorized user. | - /// | `'friends'` | The target will only be accessible to the authorized user and that user's Roblox friends. | - pub target_access: Option, - - // TODO: This could break future target types. It is implemented this way in order to support schemars - /// skip_properties() - /// - /// Environment-specific overrides for the target resource definition. This - /// will override all configuration, including changes made by the - /// [`targetNamePrefix`](#environments--targetnameprefix) and - /// [`targetAccess`](#environments--targetaccess) properties. - /// - /// Override the target configuration. Should match the type of the target - /// configuration. - pub target_overrides: Option, + /// Variables to set in the environment. These can be accessed in the config body with + /// ${{ env. }} + #[serde(default)] + pub variables: Vec, } #[derive(JsonSchema, Deserialize, Clone)] @@ -1203,32 +1173,128 @@ fn parse_project_path(project: Option<&str>) -> Result<(PathBuf, PathBuf), Strin Err(format!("Config file {} not found", config_file.display())) } -fn load_config_file(config_file: &Path) -> Result { - let data = fs::read_to_string(config_file).map_err(|e| { +#[derive(Serialize)] +struct EnvConfigContext { + label: String, +} + +#[derive(Serialize)] +struct ConfigContexts { + env: EnvConfigContext, + vars: HashMap, +} + +pub struct ConfigFile { + pub config_path: PathBuf, + pub project_path: PathBuf, + pub header: ConfigHeader, + pub config_data: String, +} + +impl ConfigFile { + pub fn environment_config( + &self, + environment_label: Option<&str>, + ) -> anyhow::Result> { + match environment_label { + Some(label) => { + if let Some(result) = self.header.environments.iter().find(|d| d.label == label) { + logger::log(format!( + "Selected provided environment configuration {}", + Paint::cyan(label) + )); + Ok(Some(result)) + } else { + bail!("No environment configuration found with name {}", label) + } + } + None => { + let current_branch = get_current_branch(&self.project_path)?; + if let Some(result) = self + .header + .environments + .iter() + .find(|environment| match_branch(¤t_branch, &environment.branches)) + { + logger::log(format!( + "Selected environment configuration {} because the current branch {} matched one of [{}]", + Paint::cyan(result.label.clone()), + Paint::cyan(current_branch), + result.branches.iter().map(|b|Paint::cyan(b).to_string()).collect::>().join(", ") + )); + Ok(Some(result)) + } else { + // TODO: should this be an error? + logger::log(format!( + "No environment configuration found for the current branch {}", + Paint::cyan(current_branch) + )); + return Ok(None); + } + } + } + } + + pub fn config(&self, environment: &EnvironmentConfig) -> anyhow::Result { + let mut tt = TinyTemplate::new(); + tt.add_template("config", &self.config_data)?; + + let contexts = ConfigContexts { + env: EnvConfigContext { + label: environment.label.clone(), + }, + vars: environment + .variables + .iter() + .map(|x| (x.name.clone(), x.value.clone())) + .collect(), + }; + + let rendered = tt.render("config", &contexts)?; + + serde_yaml::from_str::(&rendered).map_err(|e| e.into()) + } +} + +pub fn load_project_config(project: Option<&str>) -> Result { + let (project_path, config_path) = parse_project_path(project)?; + + let mut data: &str = &fs::read_to_string(&config_path).map_err(|e| { format!( "Unable to read config file: {}\n\t{}", - config_file.display(), + config_path.display(), e ) })?; - serde_yaml::from_str::(&data).map_err(|e| { - format!( - "Unable to parse config file {}\n\t{}", - config_file.display(), - e - ) - }) -} + // TODO: improve document parsing + if data.starts_with("---") { + data = &data[3..]; + } -pub fn load_project_config(project: Option<&str>) -> Result<(PathBuf, Config), String> { - let (project_path, config_path) = parse_project_path(project)?; - let config = load_config_file(&config_path)?; + let header_end = data.find("\n---"); + if header_end.is_none() { + return Result::Err("Config file is missing a document separator".to_string()); + } + + let header = + serde_yaml::from_str::(&data[0..header_end.unwrap()]).map_err(|e| { + format!( + "Unable to parse config file {}\n\t{}", + config_path.display(), + e + ) + })?; logger::log(format!( "Loaded config file {}", - Paint::cyan(config_path.display()) + Paint::cyan(&config_path.display()) )); - Ok((project_path, config)) + Ok(ConfigFile { + config_path, + project_path, + header, + config_data: data[header_end.unwrap() + 4..].to_string(), + }) } diff --git a/mantle/rbx_mantle/src/lib.rs b/mantle/rbx_mantle/src/lib.rs index e095d6e..765b13b 100644 --- a/mantle/rbx_mantle/src/lib.rs +++ b/mantle/rbx_mantle/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod project; +pub mod repo; pub mod resource_graph; pub mod roblox_resource_manager; pub mod state; diff --git a/mantle/rbx_mantle/src/project.rs b/mantle/rbx_mantle/src/project.rs index e51ac86..3f8811c 100644 --- a/mantle/rbx_mantle/src/project.rs +++ b/mantle/rbx_mantle/src/project.rs @@ -1,152 +1,14 @@ -use std::{path::PathBuf, process::Command, str}; +use rbx_api::models::CreatorType; -use rbx_api::{models::CreatorType, places::models::DEFAULT_PLACE_NAME}; -use yansi::Paint; +use crate::config::ConfigFile; use super::{ - config::{ - Config, EnvironmentConfig, ExperienceTargetConfig, ExperienceTargetConfigurationConfig, - OwnerConfig, PaymentsConfig, PlaceTargetConfigurationConfig, PlayabilityTargetConfig, - StateConfig, TargetAccessConfig, TargetConfig, TargetNamePrefixConfig, - }, + config::{EnvironmentConfig, OwnerConfig, PaymentsConfig, StateConfig, TargetConfig}, resource_graph::ResourceGraph, roblox_resource_manager::{RobloxInputs, RobloxOutputs, RobloxResource}, state::{get_previous_state, ResourceStateVLatest}, }; -fn run_command(dir: PathBuf, command: &str) -> std::io::Result { - if cfg!(target_os = "windows") { - return Command::new("cmd") - .current_dir(dir) - .arg("/C") - .arg(command) - .output(); - } else { - return Command::new("sh") - .current_dir(dir) - .arg("-c") - .arg(command) - .output(); - } -} - -fn get_current_branch(project_path: PathBuf) -> Result { - let output = run_command(project_path, "git symbolic-ref --short HEAD"); - let result = match output { - Ok(v) => v, - Err(e) => { - return Err(format!( - "Unable to determine git branch. Are you in a git repository?\n\t{}", - e - )) - } - }; - - if !result.status.success() { - return Err("Unable to determine git branch. Are you in a git repository?".to_string()); - } - - let current_branch = str::from_utf8(&result.stdout).unwrap().trim(); - if current_branch.is_empty() { - return Err("Unable to determine git branch. Are you in a git repository?".to_string()); - } - - Ok(current_branch.to_owned()) -} - -fn match_branch(branch: &str, patterns: &[String]) -> bool { - for pattern in patterns { - let glob_pattern = glob::Pattern::new(pattern); - if glob_pattern.is_ok() && glob_pattern.unwrap().matches(branch) { - return true; - } - } - false -} - -fn override_yaml(a: &mut serde_yaml::Value, b: serde_yaml::Value) { - match (a, b) { - (a @ &mut serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(b)) => { - let a = a.as_mapping_mut().unwrap(); - for (k, v) in b { - if !v.is_null() { - if a.contains_key(&k) { - override_yaml(&mut a[&k], v); - } else { - a.insert(k.to_owned(), v.to_owned()); - } - } - } - } - (a, b) => *a = b, - } -} - -fn get_target_config( - environment: EnvironmentConfig, - target: TargetConfig, -) -> Result { - let target = match target { - TargetConfig::Experience(mut experience) => { - // Apply the name prefix to all places in the experience - if let Some(target_name_prefix) = environment.target_name_prefix { - let name_prefix = match target_name_prefix { - TargetNamePrefixConfig::Custom(prefix) => prefix, - TargetNamePrefixConfig::EnvironmentLabel => { - format!("[{}] ", environment.label.to_uppercase()) - } - }; - if let Some(places) = &mut experience.places { - for (_, place) in places.iter_mut() { - if let Some(config) = &mut place.configuration { - let name = match config.name.clone() { - Some(name) => name, - None => DEFAULT_PLACE_NAME.to_owned(), - }; - config.name = Some(format!("{}{}", name_prefix, name)); - } else { - place.configuration = Some(PlaceTargetConfigurationConfig { - name: Some(format!("{}{}", name_prefix, DEFAULT_PLACE_NAME)), - ..Default::default() - }) - } - } - } - } - - // Apply the access to all places in the experience - if let Some(target_access) = environment.target_access { - let playability = Some(match target_access { - TargetAccessConfig::Public => PlayabilityTargetConfig::Public, - TargetAccessConfig::Private => PlayabilityTargetConfig::Private, - TargetAccessConfig::Friends => PlayabilityTargetConfig::Friends, - }); - if let Some(config) = &mut experience.configuration { - config.playability = playability - } else { - experience.configuration = Some(ExperienceTargetConfigurationConfig { - playability, - ..Default::default() - }); - } - } - - // Apply overrides last (they are the final trump) - if let Some(overrides) = environment.target_overrides { - let overrides = serde_yaml::to_value(overrides).unwrap(); - let mut as_value = serde_yaml::to_value(experience) - .map_err(|e| format!("Failed to serialize target: {}", e))?; - override_yaml(&mut as_value, overrides); - experience = serde_yaml::from_value::(as_value) - .map_err(|e| format!("Failed to deserialize target: {}", e))?; - }; - - TargetConfig::Experience(experience) - } - }; - Ok(target) -} - pub struct Project { pub current_graph: ResourceGraph, pub state: ResourceStateVLatest, @@ -158,81 +20,56 @@ pub struct Project { } pub async fn load_project( - project_path: PathBuf, - config: Config, + config_file: &ConfigFile, environment: Option<&str>, ) -> Result, String> { - let environment_config = match environment { - Some(label) => { - if let Some(result) = config.environments.iter().find(|d| d.label == label) { - logger::log(format!( - "Selected provided environment configuration {}", - Paint::cyan(label) - )); - result - } else { - return Err(format!( - "No environment configuration found with name {}", - label - )); - } - } - None => { - let current_branch = get_current_branch(project_path.clone())?; - if let Some(result) = config - .environments - .iter() - .find(|environment| match_branch(¤t_branch, &environment.branches)) - { - logger::log(format!( - "Selected environment configuration {} because the current branch {} matched one of [{}]", - Paint::cyan(result.label.clone()), - Paint::cyan(current_branch), - result.branches.iter().map(|b|Paint::cyan(b).to_string()).collect::>().join(", ") - )); - result - } else { - logger::log(format!( - "No environment configuration found for the current branch {}", - Paint::cyan(current_branch) - )); - return Ok(None); - } - } - }; - - let target_config = get_target_config(environment_config.clone(), config.target.clone())?; - - let payment_source = match config.payments { - PaymentsConfig::Owner => match config.owner { - OwnerConfig::Personal => CreatorType::User, - OwnerConfig::Group(_) => CreatorType::Group, - }, - PaymentsConfig::Personal => CreatorType::User, - PaymentsConfig::Group => match config.owner { - OwnerConfig::Personal => { - return Err( - "Cannot specify `payments: group` when owner is not a group.".to_owned(), - ) - } - OwnerConfig::Group(_) => CreatorType::Group, - }, - }; - - // Get previous state - let state = get_previous_state(project_path.as_path(), &config, environment_config).await?; - - // Get our resource graphs - let previous_graph = - ResourceGraph::new(state.environments.get(&environment_config.label).unwrap()); - - Ok(Some(Project { - current_graph: previous_graph, - state, - environment_config: environment_config.clone(), - target_config, - payment_source, - state_config: config.state.clone(), - owner_config: config.owner, - })) + let environment_config = config_file + .environment_config(environment) + .map_err(|e| e.to_string())?; + + if let Some(environment_config) = environment_config { + let config = config_file + .config(environment_config) + .map_err(|e| e.to_string())?; + + let payment_source = match config.payments { + PaymentsConfig::Owner => match config.owner { + OwnerConfig::Personal => CreatorType::User, + OwnerConfig::Group(_) => CreatorType::Group, + }, + PaymentsConfig::Personal => CreatorType::User, + PaymentsConfig::Group => match config.owner { + OwnerConfig::Personal => { + return Err( + "Cannot specify `payments: group` when owner is not a group.".to_owned(), + ) + } + OwnerConfig::Group(_) => CreatorType::Group, + }, + }; + + // Get previous state + let state = get_previous_state( + config_file.project_path.as_path(), + &config_file.header, + environment_config, + ) + .await?; + + // Get our resource graphs + let previous_graph = + ResourceGraph::new(state.environments.get(&environment_config.label).unwrap()); + + Ok(Some(Project { + current_graph: previous_graph, + state, + environment_config: environment_config.clone(), + target_config: config.target, + payment_source, + state_config: config_file.header.state.clone(), + owner_config: config.owner, + })) + } else { + Ok(None) + } } diff --git a/mantle/rbx_mantle/src/repo.rs b/mantle/rbx_mantle/src/repo.rs new file mode 100644 index 0000000..10807c8 --- /dev/null +++ b/mantle/rbx_mantle/src/repo.rs @@ -0,0 +1,53 @@ +use std::{path::Path, process::Command, str}; + +use anyhow::bail; + +fn run_command(dir: &Path, command: &str) -> std::io::Result { + if cfg!(target_os = "windows") { + return Command::new("cmd") + .current_dir(dir) + .arg("/C") + .arg(command) + .output(); + } else { + return Command::new("sh") + .current_dir(dir) + .arg("-c") + .arg(command) + .output(); + } +} + +pub fn get_current_branch(project_path: &Path) -> anyhow::Result { + let output = run_command(project_path, "git symbolic-ref --short HEAD"); + let result = match output { + Ok(v) => v, + Err(e) => { + bail!( + "Unable to determine git branch. Are you in a git repository?\n\t{}", + e + ) + } + }; + + if !result.status.success() { + bail!("Unable to determine git branch. Are you in a git repository?"); + } + + let current_branch = str::from_utf8(&result.stdout).unwrap().trim(); + if current_branch.is_empty() { + bail!("Unable to determine git branch. Are you in a git repository?"); + } + + Ok(current_branch.to_owned()) +} + +pub fn match_branch(branch: &str, patterns: &[String]) -> bool { + for pattern in patterns { + let glob_pattern = glob::Pattern::new(pattern); + if glob_pattern.is_ok() && glob_pattern.unwrap().matches(branch) { + return true; + } + } + false +} diff --git a/mantle/rbx_mantle/src/state/mod.rs b/mantle/rbx_mantle/src/state/mod.rs index e3f813a..7d7867c 100644 --- a/mantle/rbx_mantle/src/state/mod.rs +++ b/mantle/rbx_mantle/src/state/mod.rs @@ -28,9 +28,11 @@ use sha2::{Digest, Sha256}; use tokio::io::AsyncReadExt; use yansi::Paint; +use crate::config::ConfigHeader; + use super::{ config::{ - AssetTargetConfig, Config, EnvironmentConfig, ExperienceTargetConfig, OwnerConfig, + AssetTargetConfig, EnvironmentConfig, ExperienceTargetConfig, OwnerConfig, PlayabilityTargetConfig, RemoteStateConfig, StateConfig, TargetConfig, }, resource_graph::ResourceGraph, @@ -198,17 +200,17 @@ pub async fn get_state_from_source( pub async fn get_state( project_path: &Path, - config: &Config, + config_header: &ConfigHeader, ) -> Result { - get_state_from_source(project_path, config.state.clone()).await + get_state_from_source(project_path, config_header.state.clone()).await } pub async fn get_previous_state( project_path: &Path, - config: &Config, + config_header: &ConfigHeader, environment_config: &EnvironmentConfig, ) -> Result { - let mut state = get_state(project_path, config).await?; + let mut state = get_state(project_path, config_header).await?; if state.environments.get(&environment_config.label).is_none() { logger::log(format!(