diff --git a/src/api/asset.rs b/src/api/asset.rs index 1588a923..84d694fd 100644 --- a/src/api/asset.rs +++ b/src/api/asset.rs @@ -4,10 +4,6 @@ use crate::api::Api; use crate::commands; use crate::commands::CommandError; -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct AssetSignature { - pub(crate) signature: String, -} #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct AssetProcessingStatus { pub(crate) status: String, @@ -16,28 +12,15 @@ pub struct AssetProcessingStatus { } impl Api { - pub fn get_version_asset_signatures( - &self, - app_id: &str, - revision: u32, - ) -> Result, CommandError> { - Ok(serde_json::from_value(commands::get( - &self.authentication, - &format!( - "v4/assets?select=signature&app_id=eq.{app_id}&app_revision=eq.{revision}&type=eq.edge-app-file" - ), - )?)?) - } - pub fn get_processing_statuses( &self, - app_id: &str, - revision: u32, + asset_ids: &[String], ) -> Result, CommandError> { let response = commands::get( &self.authentication, &format!( - "v4/assets?select=status,processing_error,title&app_id=eq.{app_id}&app_revision=eq.{revision}&status=neq.finished" + "v4/assets?select=status,processing_error,title&id=in.({})&status=neq.finished", + asset_ids.join(",") ), )?; diff --git a/src/api/edge_app/app.rs b/src/api/edge_app/app.rs index e22f0b01..2cd1fce1 100644 --- a/src/api/edge_app/app.rs +++ b/src/api/edge_app/app.rs @@ -1,6 +1,5 @@ -use log::debug; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::json; use crate::api::Api; use crate::commands; @@ -81,12 +80,4 @@ impl Api { Ok(apps[0].clone()) } } - - pub fn copy_assets(&self, payload: Value) -> Result, CommandError> { - let response = commands::post(&self.authentication, "v4/edge-apps/copy-assets", &payload)?; - let copied_assets = serde_json::from_value::>(response)?; - - debug!("Copied assets: {copied_assets:?}"); - Ok(copied_assets) - } } diff --git a/src/api/edge_app/channel.rs b/src/api/edge_app/channel.rs deleted file mode 100644 index 601c224c..00000000 --- a/src/api/edge_app/channel.rs +++ /dev/null @@ -1,42 +0,0 @@ -use serde::Deserialize; -use serde_json::json; - -use crate::api::Api; -use crate::commands; -use crate::commands::CommandError; - -impl Api { - pub fn update_channel( - &self, - channel: &str, - app_id: &str, - revision: u32, - ) -> Result<(), CommandError> { - let response = commands::patch( - &self.authentication, - &format!( - "v4/edge-apps/channels?select=channel,app_revision&channel=eq.{channel}&app_id=eq.{app_id}" - ), - &json!( - { - "app_revision": revision, - }), - )?; - - #[derive(Clone, Debug, Default, PartialEq, Deserialize)] - struct Channel { - app_revision: u32, - channel: String, - } - - let channels = serde_json::from_value::>(response)?; - if channels.is_empty() { - return Err(CommandError::MissingField); - } - if channels[0].channel != channel || channels[0].app_revision != revision { - return Err(CommandError::MissingField); - } - - Ok(()) - } -} diff --git a/src/api/edge_app/deploy.rs b/src/api/edge_app/deploy.rs new file mode 100644 index 00000000..a1392fb0 --- /dev/null +++ b/src/api/edge_app/deploy.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; +use std::fmt; +use std::time::Duration; + +use log::debug; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::api::Api; +use crate::commands::CommandError; + +const DEPLOY_TIMEOUT_SECONDS: u64 = 60; + +#[derive(Debug, Serialize)] +pub struct DeployPayload { + pub manifest: Value, + pub file_tree: HashMap, + pub delete_missing_settings: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct FailedFile { + pub path: String, + pub error: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct OutstandingFiles { + #[serde(default)] + pub missing: Vec, + #[serde(default)] + pub pending: Vec, + #[serde(default)] + pub failed: Vec, +} + +pub fn describe_failed_files(files: &[FailedFile]) -> String { + files + .iter() + .map(|file| format!("{}: {}", file.path, file.error)) + .collect::>() + .join("; ") +} + +impl fmt::Display for OutstandingFiles { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut parts = Vec::new(); + if !self.missing.is_empty() { + parts.push(format!("not uploaded: {}", self.missing.join(", "))); + } + if !self.pending.is_empty() { + parts.push(format!("still processing: {}", self.pending.join(", "))); + } + if !self.failed.is_empty() { + parts.push(format!("failed: {}", describe_failed_files(&self.failed))); + } + write!(f, "{}", parts.join("; ")) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct SettingsDiff { + #[serde(default)] + pub create: Vec, + #[serde(default)] + pub update: Vec, + #[serde(default)] + pub delete: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct DeployDiff { + #[serde(default)] + pub settings: SettingsDiff, +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct DeployPreview { + pub deploy_needed: bool, + #[serde(default)] + pub outstanding: OutstandingFiles, + #[serde(default)] + pub diff: DeployDiff, +} + +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +pub struct DeployResult { + pub revision: u32, + pub created: bool, + #[serde(default)] + pub published: bool, + #[serde(default)] + pub channel: String, +} + +impl Api { + pub fn deploy_preview( + &self, + app_id: &str, + payload: &DeployPayload, + ) -> Result { + let (status, body) = self.post_deploy(app_id, "deploy/preview", payload)?; + if status != StatusCode::OK { + return Err(CommandError::WrongResponseStatus(status.as_u16())); + } + + Ok(serde_json::from_value(body)?) + } + + pub fn deploy( + &self, + app_id: &str, + payload: &DeployPayload, + ) -> Result { + #[derive(Deserialize)] + struct Conflict { + #[serde(default)] + outstanding: OutstandingFiles, + } + + let (status, body) = self.post_deploy(app_id, "deploy", payload)?; + match status { + StatusCode::OK => Ok(serde_json::from_value(body)?), + StatusCode::CONFLICT => { + let conflict: Conflict = serde_json::from_value(body)?; + Err(CommandError::DeployRejected( + conflict.outstanding.to_string(), + )) + } + _ => Err(CommandError::WrongResponseStatus(status.as_u16())), + } + } + + fn post_deploy( + &self, + app_id: &str, + endpoint: &str, + payload: &DeployPayload, + ) -> Result<(StatusCode, Value), CommandError> { + let url = format!( + "{}/v3/edge-apps/{app_id}/{endpoint}", + &self.authentication.config.url + ); + debug!("POST {url}"); + + let response = self + .authentication + .build_client()? + .post(&url) + .timeout(Duration::from_secs(DEPLOY_TIMEOUT_SECONDS)) + .json(payload) + .send()?; + + let status = response.status(); + debug!("POST {url} -> {status}"); + + match status { + StatusCode::OK | StatusCode::CONFLICT => { + Ok((status, serde_json::from_str(&response.text()?)?)) + } + StatusCode::NOT_FOUND => Err(CommandError::AppNotFound(format!( + "Edge App with ID '{app_id}' not found." + ))), + _ => { + debug!("Response: {:?}", &response.text()?); + Err(CommandError::WrongResponseStatus(status.as_u16())) + } + } + } +} diff --git a/src/api/edge_app/mod.rs b/src/api/edge_app/mod.rs index c8edaccf..8a263a1c 100644 --- a/src/api/edge_app/mod.rs +++ b/src/api/edge_app/mod.rs @@ -1,5 +1,4 @@ pub mod app; -pub mod channel; +pub mod deploy; pub mod installation; pub mod setting; -pub mod version; diff --git a/src/api/edge_app/setting.rs b/src/api/edge_app/setting.rs index a749fc59..4c68c65b 100644 --- a/src/api/edge_app/setting.rs +++ b/src/api/edge_app/setting.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::ops::Not; use std::str::FromStr; -use log::debug; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use strum::IntoEnumIterator; @@ -101,53 +100,6 @@ where Ok(settings) } -pub fn deserialize_settings_from_array<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let map: Vec> = serde::Deserialize::deserialize(deserializer)?; - let mut settings: Vec = map - .into_iter() - .map(|setting_data| { - let mut setting = Setting::default(); - for (key, value) in setting_data { - match key.as_str() { - "type" => { - setting.type_ = - deserialize_setting_type(value).expect("Failed to parse setting type."); - } - "default_value" => { - setting.default_value = value.as_str().map(|s| s.to_string()); - } - "title" => { - setting.title = value.as_str().map(|s| s.to_string()); - } - "optional" => { - setting.optional = value.as_bool().expect("Failed to parse optional.") - } - "help_text" => { - setting.help_text = value - .as_str() - .expect("Failed to parse help_text.") - .to_string(); - } - "is_global" => { - setting.is_global = value.as_bool().expect("Failed to parse is_global."); - } - "name" => { - setting.name = value.as_str().expect("Failed to parse name.").to_string(); - } - _ => {} - } - } - setting - }) - .collect(); - - settings.sort_by_key(|s| s.name.clone()); - Ok(settings) -} - fn serialize_setting_type(setting_type: &SettingType, serializer: S) -> Result where S: serde::Serializer, @@ -226,30 +178,7 @@ where } } -impl Setting { - pub fn new(type_: SettingType, title: &str, name: &str, help_text: &str, global: bool) -> Self { - Setting { - type_, - default_value: None, - title: Some(title.to_string()), - name: name.to_string(), - optional: false, - help_text: help_text.to_string(), - is_global: global, - } - } -} - impl Api { - pub fn get_settings(&self, app_id: &str) -> Result, CommandError> { - Ok(deserialize_settings_from_array(commands::get( - &self.authentication, - &format!( - "v4.1/edge-apps/settings?select=name,type,default_value,optional,title,help_text&app_id=eq.{app_id}&order=name.asc", - ), - )?)?) - } - pub fn is_setting_global(&self, app_id: &str, setting_key: &str) -> Result { let response = commands::get( &self.authentication, @@ -323,46 +252,6 @@ impl Api { Ok(Some(settings[0].clone())) } - pub fn create_setting(&self, app_id: &str, setting: &Setting) -> Result { - let value = serde_json::to_value(setting)?; - let mut payload = serde_json::from_value::>(value)?; - payload.insert("app_id".to_owned(), json!(app_id)); - payload.insert("name".to_owned(), json!(setting.name)); - - debug!("Creating setting: {:?}", &payload); - commands::post(&self.authentication, "v4.1/edge-apps/settings", &payload) - } - - pub fn update_setting(&self, app_id: &str, setting: &Setting) -> Result { - let value = serde_json::to_value(setting)?; - let mut payload = serde_json::from_value::>(value)?; - payload.insert("name".to_owned(), json!(setting.name)); - - debug!("Updating setting: {:?}", &payload); - - commands::patch( - &self.authentication, - &format!( - "v4.1/edge-apps/settings?app_id=eq.{id}&name=eq.{name}", - id = app_id, - name = setting.name - ), - &payload, - ) - } - - pub fn delete_setting(&self, app_id: &str, setting: &Setting) -> Result<(), CommandError> { - commands::delete( - &self.authentication, - &format!( - "v4.1/edge-apps/settings?app_id=eq.{id}&name=eq.{name}", - id = app_id, - name = setting.name - ), - )?; - Ok(()) - } - pub fn create_global_setting_value( &self, app_id: &str, diff --git a/src/api/edge_app/version.rs b/src/api/edge_app/version.rs deleted file mode 100644 index 74808fac..00000000 --- a/src/api/edge_app/version.rs +++ /dev/null @@ -1,79 +0,0 @@ -use std::collections::HashMap; - -use log::debug; -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::api::Api; -use crate::commands; -use crate::commands::CommandError; - -impl Api { - pub fn version_exists(&self, app_id: &str, revision: u32) -> Result { - let get_response = commands::get( - &self.authentication, - &format!( - "v4/edge-apps/versions?select=revision&app_id=eq.{app_id}&revision=eq.{revision}" - ), - )?; - let version = - serde_json::from_value::>>(get_response)?; - - if version.is_empty() { - return Ok(false); - } - - Ok(true) - } - - pub fn create_version(&self, json: HashMap<&str, Value>) -> Result { - let response = commands::post( - &self.authentication, - "v4/edge-apps/versions?select=revision", - &json, - )?; - if let Some(arr) = response.as_array() { - if let Some(obj) = arr.first() { - if let Some(revision) = obj["revision"].as_u64() { - debug!("New version revision: {revision}"); - return Ok(revision as u32); - } - } - } - - Err(CommandError::MissingField) - } - - pub fn get_file_tree( - &self, - app_id: &str, - revision: u32, - ) -> Result, CommandError> { - let response = commands::get( - &self.authentication, - &format!( - "v4/edge-apps/versions?select=file_tree&app_id=eq.{app_id}&revision=eq.{revision}" - ), - )?; - - #[derive(Clone, Debug, Default, PartialEq, Deserialize)] - struct FileTree { - file_tree: HashMap, - } - - let file_tree = serde_json::from_value::>(response)?; - if file_tree.is_empty() { - return Ok(HashMap::new()); - } - Ok(file_tree[0].file_tree.clone()) - } - - pub fn publish_version(&self, app_id: &str, revision: u32) -> Result<(), CommandError> { - commands::patch( - &self.authentication, - &format!("v4/edge-apps/versions?app_id=eq.{app_id}&revision=eq.{revision}"), - &json!({"published": true}), - )?; - Ok(()) - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 654b8b00..4d2cba65 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,7 +2,6 @@ use crate::authentication::Authentication; pub mod asset; pub mod edge_app; -pub mod version; pub struct Api { pub authentication: Authentication, diff --git a/src/api/version.rs b/src/api/version.rs deleted file mode 100644 index 9f9514be..00000000 --- a/src/api/version.rs +++ /dev/null @@ -1,47 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::api::Api; -use crate::commands; -use crate::commands::CommandError; - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct EdgeAppVersion { - #[serde(default)] - pub user_version: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub icon: Option, - #[serde(default)] - pub author: Option, - #[serde(default)] - pub homepage_url: Option, - #[serde(default)] - pub categories: Vec, - #[serde(default)] - pub ready_signal: bool, - #[serde(default)] - pub revision: u32, -} - -impl Api { - pub fn get_latest_revision( - &self, - app_id: &str, - ) -> Result, CommandError> { - let response = commands::get( - &self.authentication, - &format!( - "v4.1/edge-apps/versions?select=user_version,description,icon,author,homepage_url,categories,revision,ready_signal&app_id=eq.{app_id}&order=revision.desc&limit=1" - ), - )?; - - let versions: Vec = - serde_json::from_value::>(response)?; - - if versions.is_empty() { - return Ok(None); - } - Ok(versions.first().cloned()) - } -} diff --git a/src/cli.rs b/src/cli.rs index 1509aa2d..cb0761dd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -389,7 +389,7 @@ pub enum EdgeAppCommands { path: Option, /// Delete settings that exist on the server but not in the manifest. - #[arg(short, long)] + #[arg(short, long, num_args = 0..=1, default_missing_value = "true")] delete_missing_settings: Option, }, /// Deletes an Edge App. This cannot be undone. @@ -924,9 +924,15 @@ pub fn handle_cli_edge_app_command(command: &EdgeAppCommands, output: OutputForm path, delete_missing_settings, } => match edge_app_command.deploy(path.clone(), *delete_missing_settings) { - Ok(revision) => { - println!("Edge App successfully deployed. Revision: {revision}."); - } + Ok(outcome) => match outcome.revision { + Some(revision) if outcome.created => { + println!("Edge App successfully deployed. Revision: {revision}."); + } + Some(revision) => { + println!("Settings updated. No new revision needed. Revision: {revision}."); + } + None => println!("Edge App is already up to date."), + }, Err(e) => { eprintln!("Failed to upload Edge App: {e}."); std::process::exit(1); diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index d5ed98d4..70f6c6f9 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -1,36 +1,37 @@ -use std::collections::HashMap; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use std::{fs, io, str, thread}; +use std::{fs, thread}; use indicatif::ProgressBar; use log::debug; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use reqwest::header::HeaderMap; use reqwest::StatusCode; -use serde_json::json; use serde_yaml; +use crate::api::edge_app::deploy::{describe_failed_files, DeployPayload, FailedFile}; use crate::api::edge_app::setting::{Setting, SettingType}; -use crate::api::version::EdgeAppVersion; use crate::commands::edge_app::instance_manifest::InstanceManifest; use crate::commands::edge_app::manifest::{ EdgeAppManifest, Entrypoint, EntrypointType, MANIFEST_VERSION, }; use crate::commands::edge_app::utils::{ - collect_paths_for_upload, detect_changed_files, detect_changed_settings, - ensure_edge_app_has_all_necessary_files, generate_file_tree, - transform_edge_app_path_to_manifest, transform_instance_path_to_instance_manifest, FileChanges, - SettingChanges, + collect_paths_for_upload, generate_file_tree, transform_edge_app_path_to_manifest, + transform_instance_path_to_instance_manifest, }; use crate::commands::edge_app::EdgeAppCommand; use crate::commands::{CommandError, EdgeApps}; pub const INJECT_JS_FILE_NAME: &str = "screenly_inject.js"; +#[derive(Debug)] +pub struct DeployOutcome { + pub revision: Option, + pub created: bool, +} + // Edge apps commands impl EdgeAppCommand { pub fn create( @@ -135,39 +136,6 @@ impl EdgeAppCommand { } } -/// RAII guard that materializes an empty `index.html` in the edge app directory -/// for the duration of a deploy of a remote-entrypoint app. The backend rejects -/// publishing a version with no asset signatures, so we ship a placeholder. -struct VirtualIndexHtml { - path: PathBuf, - created: bool, -} - -impl VirtualIndexHtml { - /// Minimal HTML so the asset has non-zero size (the backend rejects empty - /// asset bodies with a check-constraint violation). The contents are never - /// rendered — the player loads the remote entrypoint URL instead. - const PLACEHOLDER: &'static str = - "\n"; - - fn ensure(parent_dir: &Path) -> Result { - let path = parent_dir.join("index.html"); - let created = !path.exists(); - if created { - fs::write(&path, Self::PLACEHOLDER)?; - } - Ok(Self { path, created }) - } -} - -impl Drop for VirtualIndexHtml { - fn drop(&mut self) { - if self.created { - let _ = fs::remove_file(&self.path); - } - } -} - impl EdgeAppCommand { pub fn create_in_place(&self, name: &str, path: &Path) -> Result<(), CommandError> { let parent_dir_path = path.parent().ok_or(CommandError::FileSystemError( @@ -206,118 +174,68 @@ impl EdgeAppCommand { self, path: Option, delete_missing_settings: Option, - ) -> Result { + ) -> Result { let manifest_path = transform_edge_app_path_to_manifest(&path)?; EdgeAppManifest::ensure_manifest_is_valid(&manifest_path)?; let manifest = EdgeAppManifest::new(&manifest_path)?; - let actual_app_id = match self.get_app_id(path.clone()) { - Ok(id) => id, - Err(_) => return Err(CommandError::MissingAppId), - }; - - let version_metadata_changed = - self.detect_version_metadata_changes(&actual_app_id, &manifest)?; + let actual_app_id = self + .get_app_id(path.clone()) + .map_err(|_| CommandError::MissingAppId)?; let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; - - // Remote-entrypoint apps don't need a real index.html, but the backend - // requires every published version to have at least one signed asset. - // Materialize an empty placeholder for the duration of this deploy. - let is_remote_entrypoint = matches!( - manifest.entrypoint, - Some(Entrypoint { - entrypoint_type: EntrypointType::RemoteGlobal, - .. - }) | Some(Entrypoint { - entrypoint_type: EntrypointType::RemoteLocal, - .. - }) - ); - let _virtual_index_html = if is_remote_entrypoint { - Some(VirtualIndexHtml::ensure(edge_app_dir)?) - } else { - None - }; - let local_files = collect_paths_for_upload(edge_app_dir)?; - ensure_edge_app_has_all_necessary_files(&local_files)?; - let prior_revision = self.api.get_latest_revision(&actual_app_id)?; - let is_first_deploy = prior_revision.is_none(); - let revision = prior_revision.map(|r| r.revision).unwrap_or(0); - - let remote_files = self - .api - .get_version_asset_signatures(&actual_app_id, revision)?; - let changed_files = detect_changed_files(&local_files, &remote_files)?; - debug!("Changed files: {:?}", &changed_files); - - let remote_settings = self.api.get_settings(&actual_app_id)?; - - let changed_settings = detect_changed_settings(&manifest, &remote_settings)?; - self.upload_changed_settings(actual_app_id.clone(), &changed_settings)?; - - self.maybe_delete_missing_settings( + let delete_missing_settings = delete_missing_settings == Some(true); + let payload = DeployPayload { + manifest: serde_json::to_value(&manifest)?, + file_tree: generate_file_tree(&local_files, edge_app_dir), delete_missing_settings, - actual_app_id.clone(), - changed_settings, - )?; - - self.update_entrypoint_value(path.clone())?; + }; - let file_tree = generate_file_tree(&local_files, edge_app_dir); + let preview = self.api.deploy_preview(&actual_app_id, &payload)?; + debug!("Deploy preview: {preview:?}"); - let old_file_tree = self.api.get_file_tree(&actual_app_id, revision); + let settings_to_delete = &preview.diff.settings.delete; + if !delete_missing_settings && !settings_to_delete.is_empty() { + println!( + "Settings not in manifest: {}. Re-run with --delete-missing-settings to remove.", + settings_to_delete.join(", ") + ); + } - let file_tree_changed = match old_file_tree { - Ok(tree) => file_tree != tree, - Err(_) => true, - }; + if !preview.deploy_needed { + debug!("Nothing to deploy."); + self.update_entrypoint_value(path)?; + return Ok(DeployOutcome { + revision: None, + created: false, + }); + } - debug!("File tree changed: {file_tree_changed}"); - let needs_new_version = is_first_deploy - || self.requires_upload(&changed_files) - || file_tree_changed - || version_metadata_changed; - - let final_revision = if needs_new_version { - let revision = - self.create_version(&manifest, generate_file_tree(&local_files, edge_app_dir))?; - - self.upload_changed_files(edge_app_dir, &actual_app_id, revision, &changed_files)?; - debug!("Files uploaded"); - - self.ensure_assets_processing_finished(&actual_app_id, revision)?; - // now we freeze it by publishing it - self.api.publish_version(&actual_app_id, revision)?; - debug!("Edge App published."); - - self.promote_version(&actual_app_id, revision, "stable")?; - revision - } else { - debug!("No version-creating changes; skipping version creation."); - revision - }; + let files_to_upload: Vec = preview + .outstanding + .missing + .iter() + .map(|file| edge_app_dir.join(file)) + .collect(); + let uploaded_asset_ids = self.upload_edge_app_assets(&actual_app_id, &files_to_upload)?; - Ok(final_revision) - } + self.wait_for_assets_processing(&uploaded_asset_ids)?; - fn promote_version( - &self, - app_id: &str, - revision: u32, - channel: &str, - ) -> Result<(), CommandError> { - let version_exists = self.api.version_exists(app_id, revision)?; - if !version_exists { - return Err(CommandError::RevisionNotFound(revision.to_string())); - } + let result = self.api.deploy(&actual_app_id, &payload)?; + debug!( + "Deployed revision {} (created: {}, published: {}, channel: {})", + result.revision, result.created, result.published, result.channel + ); - self.api.update_channel(channel, app_id, revision)?; + self.update_entrypoint_value(path)?; - Ok(()) + Ok(DeployOutcome { + revision: Some(result.revision), + created: result.created, + }) } pub fn delete_app(&self, app_id: &str) -> Result<(), CommandError> { @@ -332,37 +250,6 @@ impl EdgeAppCommand { Ok(()) } - fn maybe_delete_missing_settings( - &self, - delete_missing_settings: Option, - actual_app_id: String, - changed_settings: SettingChanges, - ) -> Result<(), CommandError> { - match delete_missing_settings { - Some(delete) => { - if delete { - self.delete_deleted_settings( - actual_app_id.clone(), - &changed_settings.deleted, - false, - )?; - } - } - None => { - if let Ok(_ci) = std::env::var("CI") { - return Ok(()); - } - self.delete_deleted_settings( - actual_app_id.clone(), - &changed_settings.deleted, - true, - )?; - } - } - - Ok(()) - } - pub fn update_entrypoint_value(&self, path: Option) -> Result<(), CommandError> { let manifest = EdgeAppManifest::new(&transform_edge_app_path_to_manifest(&path)?)?; let setting_key = "screenly_entrypoint"; @@ -393,67 +280,57 @@ impl EdgeAppCommand { Ok(()) } - fn ensure_assets_processing_finished( - &self, - app_id: &str, - revision: u32, - ) -> Result<(), CommandError> { - const SLEEP_TIME: u64 = 2; - const MAX_WAIT_TIME: u64 = 1000; // 1000 seconds - it could take a while for assets to process + fn wait_for_assets_processing(&self, asset_ids: &[String]) -> Result<(), CommandError> { + const POLL_INTERVAL_SECONDS: u64 = 2; + const MAX_WAIT_SECONDS: u64 = 1000; + + if asset_ids.is_empty() { + return Ok(()); + } - let mut pb: Option = None; - let mut assets_to_process = 0; + let mut progress_bar: Option = None; + let mut assets_to_process: u64 = 0; let start_time = Instant::now(); loop { - // TODO: we are not handling possible errors in asset processing here. - // Which are unlikely to happen, because we upload assets as they are, but still - if start_time.elapsed().as_secs() > MAX_WAIT_TIME { - return Err(CommandError::AssetProcessingTimeout); - } + let statuses = self.api.get_processing_statuses(asset_ids)?; + debug!("Assets still processing: {statuses:?}"); - let asset_processing_statuses = self.api.get_processing_statuses(app_id, revision)?; - if asset_processing_statuses.is_empty() { - if let Some(progress_bar) = pb.as_ref() { - progress_bar.finish_with_message("Assets processed"); - } - break; + let failed: Vec = statuses + .iter() + .filter(|status| status.status == "error") + .map(|status| FailedFile { + path: status.title.clone(), + error: status.processing_error.clone(), + }) + .collect(); + if !failed.is_empty() { + return Err(CommandError::AssetProcessingError(describe_failed_files( + &failed, + ))); } - debug!( - "ensure_assets_processing_finished: {:?}", - &asset_processing_statuses - ); - for asset_processing_status in &asset_processing_statuses { - if asset_processing_status.status == "error" { - return Err(CommandError::AssetProcessingError(format!( - "Asset {}. Error: {}", - asset_processing_status.title, asset_processing_status.processing_error - ))); - } + let pending_count = statuses.len() as u64; + if pending_count == 0 { + progress_bar + .as_ref() + .inspect(|bar| bar.finish_with_message("Assets processed")); + return Ok(()); } - let unprocessed_asset_count = asset_processing_statuses.len() as u64; + if start_time.elapsed().as_secs() > MAX_WAIT_SECONDS { + return Err(CommandError::AssetProcessingTimeout); + } - match &mut pb { - Some(ref mut progress_bar) => { - progress_bar.set_position(assets_to_process - unprocessed_asset_count); - progress_bar.set_message("Processing Items:"); - } - None => { - pb = Some(ProgressBar::new(unprocessed_asset_count)); - assets_to_process = unprocessed_asset_count; - } + if progress_bar.is_none() { + assets_to_process = pending_count; } + let bar = progress_bar.get_or_insert_with(|| ProgressBar::new(pending_count)); + bar.set_position(assets_to_process.saturating_sub(pending_count)); + bar.set_message("Processing Items:"); - thread::sleep(Duration::from_secs(SLEEP_TIME)); + thread::sleep(Duration::from_secs(POLL_INTERVAL_SECONDS)); } - Ok(()) - } - - // TODO: remove - fn requires_upload(&self, changed_files: &FileChanges) -> bool { - changed_files.has_changes() } } @@ -474,159 +351,33 @@ impl EdgeAppCommand { Ok(()) } - fn create_version( - &self, - manifest: &EdgeAppManifest, - file_tree: HashMap, - ) -> Result { - let mut json = EdgeAppManifest::prepare_payload(manifest); - json.insert("file_tree", json!(file_tree)); - - self.api.create_version(json) - } - - fn upload_changed_settings( - &self, - app_id: String, - changed_settings: &SettingChanges, - ) -> Result<(), CommandError> { - for setting in &changed_settings.creates { - self.create_setting(app_id.clone(), setting)?; - } - for setting in &changed_settings.updates { - self.update_setting(app_id.clone(), setting)?; - } - Ok(()) - } - - fn delete_deleted_settings( - &self, - app_id: String, - deleted: &Vec, - prompt_user: bool, - ) -> Result<(), CommandError> { - for setting in deleted { - self.try_delete_setting(app_id.clone(), setting, prompt_user)?; - } - Ok(()) - } - - fn upload_changed_files( + fn upload_edge_app_assets( &self, - edge_app_dir: &Path, app_id: &str, - revision: u32, - changed_files: &FileChanges, - ) -> Result<(), CommandError> { - debug!("Changed files: {changed_files:#?}"); - - let copied_signatures = self.copy_edge_app_assets( - app_id, - revision, - changed_files - .get_local_signatures() - .iter() - .cloned() - .collect(), - )?; - - debug!("Uploading Edge App assets"); - let files_to_upload = changed_files.get_files_to_upload(copied_signatures); - if files_to_upload.is_empty() { + paths: &[PathBuf], + ) -> Result, CommandError> { + if paths.is_empty() { debug!("No files to upload"); - return Ok(()); - } - - debug!("Uploading Edge App files: {files_to_upload:#?}"); - let file_paths: Vec = files_to_upload - .iter() - .map(|file| edge_app_dir.join(&file.path)) - .collect(); - - self.upload_edge_app_assets(app_id, revision, &file_paths)?; - - Ok(()) - } - - fn try_delete_setting( - &self, - app_id: String, - setting: &Setting, - prompt_user: bool, - ) -> Result<(), CommandError> { - debug!("Deleting setting: {:?}", &setting.name); - - let mut input_name = String::new(); - - if !prompt_user { - return self.delete_setting(app_id, setting); - } - - let prompt = format!("It seems like the setting \"{}\" is absent in the YAML file, but it exists on the server. If you wish to skip deletion, you can leave the input blank. Warning, deleting the setting will drop all the associated values. To proceed with deletion, please confirm the setting name by writing it down: ", setting.name); - println!("{prompt}"); - io::stdin() - .read_line(&mut input_name) - .expect("Failed to read input"); - - if input_name.trim() == "" { - return Ok(()); - } - - if input_name.trim() != setting.name { - // Should we ask for confirmation again if user input is wrong? - return Err(CommandError::WrongSettingName(setting.name.to_string())); + return Ok(Vec::new()); } - self.delete_setting(app_id, setting) - } - - fn copy_edge_app_assets( - &self, - app_id: &str, - revision: u32, - mut asset_signatures: Vec, - ) -> Result, CommandError> { - let mut headers = HeaderMap::new(); - headers.insert("Prefer", "return=representation".parse()?); - - asset_signatures.sort(); - let payload = json!({ - "app_id": app_id, - "revision": revision, - "signatures": asset_signatures, - }); - - let copied_assets = self.api.copy_assets(payload)?; - Ok(copied_assets) - } + debug!("Uploading Edge App files: {paths:#?}"); + let progress_bar = ProgressBar::new(paths.len() as u64); + progress_bar.set_message("Files uploaded:"); - fn upload_edge_app_assets( - &self, - app_id: &str, - revision: u32, - paths: &[PathBuf], - ) -> Result<(), CommandError> { - let pb = ProgressBar::new(paths.len() as u64); - pb.set_message("Files uploaded:"); - let shared_pb = Arc::new(Mutex::new(pb)); - - paths.par_iter().try_for_each(|path| { - let result = self.upload_single_asset(app_id, revision, path, &shared_pb); - if result.is_ok() { - let locked_pb = shared_pb.lock().unwrap(); - locked_pb.inc(1); - } - result - }) + paths + .par_iter() + .map(|path| { + let result = self.upload_single_asset(app_id, path); + if result.is_ok() { + progress_bar.inc(1); + } + result + }) + .collect() } - fn upload_single_asset( - &self, - app_id: &str, - revision: u32, - path: &Path, - _pb: &Arc>, - ) -> Result<(), CommandError> { + fn upload_single_asset(&self, app_id: &str, path: &Path) -> Result { let url = format!("{}/v4/assets", &self.api.authentication.config.url); let mut headers = HeaderMap::new(); @@ -644,7 +395,6 @@ impl EdgeAppCommand { .to_string(), ) .text("app_id", app_id.to_string()) - .text("app_revision", revision.to_string()) .file("file", path)?; let response = self @@ -663,30 +413,14 @@ impl EdgeAppCommand { return Err(CommandError::WrongResponseStatus(status.as_u16())); } - Ok(()) - } - - pub fn detect_version_metadata_changes( - &self, - app_id: &str, - manifest: &EdgeAppManifest, - ) -> Result { - let version = self.api.get_latest_revision(app_id)?; - // TODO: implement entrypoint changes on the backend - match version { - Some(_version) => Ok(_version - != EdgeAppVersion { - ready_signal: manifest.ready_signal.unwrap_or(false), - user_version: manifest.user_version.clone(), - description: manifest.description.clone(), - icon: manifest.icon.clone(), - author: manifest.author.clone(), - homepage_url: manifest.homepage_url.clone(), - categories: manifest.categories.clone(), - revision: _version.revision, - }), - None => Ok(false), - } + let created: serde_json::Value = serde_json::from_str(&response.text()?)?; + created + .get(0) + .unwrap_or(&created) + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_owned) + .ok_or(CommandError::MissingField) } pub fn get_installation_id(&self, path: Option) -> Result { @@ -709,9 +443,8 @@ impl EdgeAppCommand { #[cfg(test)] mod tests { - use std::env; - use httpmock::Method::{DELETE, GET, PATCH, POST}; + use serde_json::json; use tempfile::tempdir; use super::*; @@ -720,7 +453,81 @@ mod tests { create_edge_app_manifest_for_test, create_instance_manifest_for_test, prepare_edge_apps_test, }; - use crate::commands::edge_app::utils::EdgeAppFile; + + const APP_ID: &str = "01H2QZ6Z8WXWNDC0KQ198XCZEW"; + const ASSET_ID: &str = "01J0YQ9F0000000000000ASSET"; + const INDEX_HTML_SIGNATURE: &str = "0a209f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08122086cebd0c365d241e32d5b0972c07aae3a8d6499c2a9471aa85943a35577200021a180a14a94a8fe5ccb19ba61c4c0873d391e987982fbbd31000"; + + fn deploy_test_settings() -> Vec { + vec![ + Setting { + name: "asetting".to_string(), + type_: SettingType::String, + title: Some("atitle".to_string()), + optional: false, + default_value: Some("".to_string()), + is_global: false, + help_text: "help text".to_string(), + }, + Setting { + name: "nsetting".to_string(), + type_: SettingType::String, + title: Some("ntitle".to_string()), + optional: false, + default_value: Some("".to_string()), + is_global: false, + help_text: "help text".to_string(), + }, + ] + } + + fn write_deployable_edge_app(dir: &Path) -> EdgeAppManifest { + let mut manifest = create_edge_app_manifest_for_test(deploy_test_settings()); + manifest.user_version = None; + manifest.author = None; + manifest.entrypoint = None; + + let manifest_path = dir.join("screenly.yml"); + EdgeAppManifest::save_to_file(&manifest, manifest_path.as_path()).unwrap(); + let mut file = File::create(dir.join("index.html")).unwrap(); + write!(file, "test").unwrap(); + + EdgeAppManifest::new(manifest_path.as_path()).unwrap() + } + + fn missing_index_preview() -> serde_json::Value { + json!({ + "deploy_needed": true, + "outstanding": {"missing": ["index.html"], "pending": [], "failed": []}, + "diff": { + "settings": {"create": [], "update": [], "delete": []}, + "revision": {"update": []}, + "files": {"create": ["index.html"], "update": [], "delete": []} + } + }) + } + + fn upload_mock(mock_server: &httpmock::MockServer) -> httpmock::Mock<'_> { + mock_server.mock(|when, then| { + when.method(POST) + .path("/v4/assets") + .body_includes("name=\"app_id\"") + .body_includes(APP_ID); + then.status(201).json_body(json!([{"id": ASSET_ID}])); + }) + } + + fn no_outstanding_preview() -> serde_json::Value { + json!({ + "deploy_needed": true, + "outstanding": {"missing": [], "pending": [], "failed": []}, + "diff": { + "settings": {"create": [], "update": [], "delete": []}, + "revision": {"update": []}, + "files": {"create": [], "update": [], "delete": []} + } + }) + } #[test] fn test_edge_app_create_should_create_app_and_required_files() { @@ -1022,595 +829,185 @@ mod tests { let (temp_dir, command, mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); - let mut manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); - - manifest.user_version = None; - manifest.author = None; - manifest.entrypoint = None; + let manifest = write_deployable_edge_app(temp_dir.path()); + let expected_payload = json!({ + "manifest": serde_json::to_value(&manifest).unwrap(), + "file_tree": { "index.html": INDEX_HTML_SIGNATURE }, + "delete_missing_settings": true, + }); - // let get_entrypoint_mock = mock_server.mock(|when, then| { - // when.method(GET) - // .path("/v4.1/edge-apps/installations") - // .header("Authorization", "Token token") - // .header( - // "user-agent", - // format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - // ) - // .query_param("id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEB") - // .query_param("select", "entrypoint"); - // then.status(200).json_body(json!([{"entrypoint": null}])); - // }); - // "v4.1/edge-apps/versions?select=user_version,description,icon,author,entrypoint&app_id=eq.{}&order=revision.desc&limit=1", - let last_versions_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4.1/edge-apps/versions") + let preview_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")) .header("Authorization", "Token token") .header( "user-agent", format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), ) - .query_param( - "select", - "user_version,description,icon,author,homepage_url,categories,revision,ready_signal", - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("order", "revision.desc") - .query_param("limit", "1"); - then.status(200).json_body(json!([ - { - "user_version": "1", - "description": "desc", - "icon": "icon", - "author": "author", - "homepage_url": "homepage_url", - "categories": [], - "ready_signal": false, - "revision": 7, + .json_body(expected_payload.clone()); + then.status(200).json_body(json!({ + "deploy_needed": true, + "outstanding": {"missing": ["index.html"], "pending": [], "failed": []}, + "diff": { + "settings": {"create": ["asetting"], "update": ["nsetting"], "delete": ["isetting"]}, + "revision": {"update": []}, + "files": {"create": ["index.html"], "update": [], "delete": []} } - ])); + })); }); - // "v4/assets?select=signature&app_id=eq.{}&app_revision=eq.{}&type=eq.edge-app-file", - let assets_mock = mock_server.mock(|when, then| { - when.method(GET) + let upload_assets_mock = mock_server.mock(|when, then| { + when.method(POST) .path("/v4/assets") .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("select", "signature") - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("app_revision", "eq.7") - .query_param("type", "eq.edge-app-file"); - then.status(200).json_body(json!([{"signature": "sig"}])); + .body_includes("name=\"app_id\"") + .body_includes(APP_ID) + .body_includes("test"); + then.status(201).json_body(json!([{"id": ASSET_ID}])); }); - // v4/edge-apps/versions?select=file_tree&app_id=eq.{}&revision=eq.{} - let file_tree_from_version_mock = mock_server.mock(|when, then| { + let processing_status_mock = mock_server.mock(|when, then| { when.method(GET) - .path("/v4/edge-apps/versions") + .path("/v4/assets") + .query_param("id", format!("in.({ASSET_ID})")); + then.status(200).json_body(json!([])); + }); + + let deploy_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy")) .header("Authorization", "Token token") .header( "user-agent", format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("revision", "eq.7") - .query_param("select", "file_tree"); - then.status(200).json_body(json!([{"index.html": "sig"}])); + .json_body(expected_payload.clone()); + then.status(200).json_body(json!({ + "revision": 8, + "created": true, + "published": true, + "channel": "stable" + })); }); - // v4/edge-apps/settings?select=type,default_value,optional,title,help_text&app_id=eq.{}&order=title.asc - let settings_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4.1/edge-apps/settings") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("select", "name,type,default_value,optional,title,help_text") - .query_param("order", "name.asc"); - then.status(200).json_body(json!([{ - "name": "nsetting".to_string(), - "type": SettingType::String, - "default_value": "5".to_string(), - "title": "ntitle".to_string(), - "optional": true, - "help_text": "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - "is_global": false, - }, { - "name": "isetting".to_string(), - "type": SettingType::String, - "default_value": "5".to_string(), - "title": null, - "optional": true, - "help_text": "Some text".to_string(), - "is_global": false, - }])); - }); + let result = command.deploy( + Some(temp_dir.path().to_str().unwrap().to_string()), + Some(true), + ); - let create_version_mock = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .json_body(json!({ - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "description": "asdf", - "icon": "asdf", - "homepage_url": "asdfasdf", - "categories": ["Utilities", "Dashboards"], - "file_tree": { - "index.html": "0a209f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08122086cebd0c365d241e32d5b0972c07aae3a8d6499c2a9471aa85943a35577200021a180a14a94a8fe5ccb19ba61c4c0873d391e987982fbbd31000" - }, - "ready_signal": false, - })); - then.status(201).json_body(json!([{"revision": 8}])); - }); + preview_mock.assert(); + processing_status_mock.assert(); + upload_assets_mock.assert(); + deploy_mock.assert(); - // v4/edge-apps/settings?app_id=eq.{} - let settings_mock_create = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4.1/edge-apps/settings") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .json_body(json!({ - "name": "asetting", - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "type": "string", - "default_value": "", - "title": "atitle", - "optional": false, - "help_text": "help text", - })); - then.status(201).json_body(json!( - [{ - "name": "asetting", - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "type": "string", - "default_value": "", - "title": "atitle", - "optional": false, - "help_text": "help text", - }])); - }); - - let settings_mock_patch = mock_server.mock(|when, then| { - when.method(PATCH) - .path("/v4.1/edge-apps/settings") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("name", "eq.nsetting") - .json_body(json!({ - "name": "nsetting", - "type": "string", - "default_value": "", - "title": "ntitle", - "optional": false, - "help_text": "help text", - })); - then.status(200).json_body(json!( - [{ - "name": "nsetting", - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "type": "string", - "default_value": "", - "title": "ntitle", - "optional": false, - "help_text": "help text", - }])); - }); - - let settings_mock_delete = mock_server.mock(|when, then| { - when.method(DELETE) - .path("/v4.1/edge-apps/settings") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("name", "eq.isetting"); - then.status(204).json_body(json!({})); - }); + assert_eq!(result.unwrap().revision, Some(8)); + } - let copy_assets_mock = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/edge-apps/copy-assets") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ).json_body(json!({ - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "revision": 8, - "signatures": ["0a209f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08122086cebd0c365d241e32d5b0972c07aae3a8d6499c2a9471aa85943a35577200021a180a14a94a8fe5ccb19ba61c4c0873d391e987982fbbd31000"] - })); - then.status(201).json_body(json!([])); - }); + #[test] + fn test_deploy_when_server_reports_outstanding_files_should_return_error() { + let (temp_dir, command, mock_server, _manifest, _instance_manifest) = + prepare_edge_apps_test(false, false); - let upload_assets_mock = mock_server.mock(|when, then| { - when.method(POST).path("/v4/assets"); - then.status(201).body(""); - }); - // "v4/assets?select=status&app_id=eq.{}&app_revision=eq.{}&status=neq.finished&limit=1", - let finished_processing_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4/assets") - .query_param("select", "status,processing_error,title") - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("app_revision", "eq.8") - .query_param("status", "neq.finished"); - then.status(200).json_body(json!([])); - }); + write_deployable_edge_app(temp_dir.path()); - // "v4/edge-apps/versions?app_id=eq.{}&revision=eq.{}", - let publish_mock = mock_server.mock(|when, then| { - when.method(PATCH) - .path("/v4/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("revision", "eq.8") - .json_body(json!({"published": true })); - then.status(200); + let preview_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")) + .body_includes("\"delete_missing_settings\":false"); + then.status(200).json_body(no_outstanding_preview()); }); - let get_version_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("select", "revision") - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("revision", "eq.8"); - - then.status(200).json_body(json!([ - { - "revision": 8, - } - ])); + let deploy_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy")); + then.status(409).json_body(json!({ + "outstanding": {"missing": ["logo.png"], "pending": ["app.js"], "failed": []} + })); }); - let promote_mock = mock_server.mock(|when, then| { - when.method(PATCH) - .path("/v4/edge-apps/channels") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("channel", "eq.stable") - .query_param("select", "channel,app_revision") - .json_body(json!({ - "app_revision": 8, - })); - then.status(200).json_body(json!([ - { - "channel": "stable", - "app_revision": 8 - } - ])); - }); + let result = command.deploy(Some(temp_dir.path().to_str().unwrap().to_string()), None); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test").unwrap(); + preview_mock.assert(); + deploy_mock.assert(); - let result = command.deploy( - Some(temp_dir.path().to_str().unwrap().to_string()), - Some(true), + assert_eq!( + result.unwrap_err().to_string(), + "Deploy rejected: not uploaded: logo.png; still processing: app.js" ); - - // get_entrypoint_mock.assert(); - last_versions_mock.assert_calls(2); - assets_mock.assert(); - file_tree_from_version_mock.assert(); - settings_mock.assert(); - create_version_mock.assert(); - settings_mock_create.assert(); - settings_mock_patch.assert(); - settings_mock_delete.assert(); - upload_assets_mock.assert(); - finished_processing_mock.assert(); - publish_mock.assert(); - copy_assets_mock.assert(); - get_version_mock.assert(); - promote_mock.assert(); - - assert!(result.is_ok()); } #[test] - fn test_detect_version_metadata_changes_when_no_changes_should_return_false() { + fn test_deploy_when_asset_processing_failed_should_return_error() { let (temp_dir, command, mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); - let manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); + write_deployable_edge_app(temp_dir.path()); - let last_versions_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4.1/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param( - "select", - "user_version,description,icon,author,homepage_url,categories,revision,ready_signal", - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("order", "revision.desc") - .query_param("limit", "1"); - then.status(200).json_body(json!([ - { - "user_version": "1", - "description": "asdf", - "icon": "asdf", - "author": "asdf", - "homepage_url": "asdfasdf", - "categories": ["Utilities", "Dashboards"], - "ready_signal": false, - "revision": 1 - } - ])); + let preview_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")); + then.status(200).json_body(missing_index_preview()); }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); - - let manifest = - EdgeAppManifest::new(temp_dir.path().join("screenly.yml").as_path()).unwrap(); - let result = - command.detect_version_metadata_changes(&manifest.id.clone().unwrap(), &manifest); + let upload_assets_mock = upload_mock(&mock_server); - assert!(result.is_ok()); - assert!(!result.unwrap()); - last_versions_mock.assert(); - } - - #[test] - fn test_detect_version_metadata_changes_when_has_changes_should_return_true() { - let (temp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(false, false); - - let manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); - - let last_versions_mock = mock_server.mock(|when, then| { + let processing_status_mock = mock_server.mock(|when, then| { when.method(GET) - .path("/v4.1/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param( - "select", - "user_version,description,icon,author,homepage_url,categories,revision,ready_signal", - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("order", "revision.desc") - .query_param("limit", "1"); - then.status(200).json_body(json!([ - { - "user_version": "new_version", - "description": "description", - "icon": "another_icon", - "author": "asdf", - "homepage_url": "asdfasdf", - "categories": [], - "ready_signal": false, - "revision": 1, - } - ])); + .path("/v4/assets") + .query_param("id", format!("in.({ASSET_ID})")); + then.status(200).json_body(json!([{ + "status": "error", + "processing_error": "File type not supported.", + "title": "wrong_file.ext" + }])); }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); - - let manifest = - EdgeAppManifest::new(temp_dir.path().join("screenly.yml").as_path()).unwrap(); - let result = - command.detect_version_metadata_changes(&manifest.id.clone().unwrap(), &manifest); - - assert!(result.is_ok()); - assert!(result.unwrap()); - last_versions_mock.assert(); - } - - #[test] - fn test_detect_version_metadata_changes_when_no_version_exist_should_return_false() { - let (temp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(false, false); - - let manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); - - let last_versions_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4.1/edge-apps/versions") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .query_param( - "select", - "user_version,description,icon,author,homepage_url,categories,revision,ready_signal", - ) - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("order", "revision.desc") - .query_param("limit", "1"); - then.status(200).json_body(json!([])); + let deploy_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy")); + then.status(200).json_body(json!({"revision": 8})); }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); + let result = command.deploy( + Some(temp_dir.path().to_str().unwrap().to_string()), + Some(true), + ); - let manifest = - EdgeAppManifest::new(temp_dir.path().join("screenly.yml").as_path()).unwrap(); - let result = - command.detect_version_metadata_changes(&manifest.id.clone().unwrap(), &manifest); + preview_mock.assert(); + upload_assets_mock.assert(); + processing_status_mock.assert(); + deploy_mock.assert_calls(0); - assert!(result.is_ok()); - assert!(!result.unwrap()); - last_versions_mock.assert(); + assert_eq!( + result.unwrap_err().to_string(), + "Asset processing error: wrong_file.ext: File type not supported." + ); } #[test] - fn test_ensure_assets_processing_finished_when_processing_failed_should_return_error() { + fn test_deploy_when_app_does_not_exist_should_return_error() { let (temp_dir, command, mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); - let manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); + write_deployable_edge_app(temp_dir.path()); - // "v4/assets?select=status&app_id=eq.{}&app_revision=eq.{}&status=neq.finished&limit=1", - let finished_processing_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4/assets") - .query_param("select", "status,processing_error,title") - .query_param("app_id", "eq.01H2QZ6Z8WXWNDC0KQ198XCZEW") - .query_param("app_revision", "eq.8") - .query_param("status", "neq.finished"); - then.status(200).json_body(json!([ - { - "status": "error", - "title": "wrong_file.ext", - "processing_error": "File type not supported." - } - ])); + let preview_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")); + then.status(404) + .json_body(json!({"detail": "App not found"})); }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test").unwrap(); - - let result = command.ensure_assets_processing_finished("01H2QZ6Z8WXWNDC0KQ198XCZEW", 8); + let result = command.deploy( + Some(temp_dir.path().to_str().unwrap().to_string()), + Some(true), + ); - finished_processing_mock.assert(); + preview_mock.assert(); - assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), - "Asset processing error: Asset wrong_file.ext. Error: File type not supported." - .to_string() + format!("App not found: Edge App with ID '{APP_ID}' not found.") ); } @@ -1703,26 +1100,7 @@ mod tests { let (temp_dir, command, _mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); - let mut manifest = create_edge_app_manifest_for_test(vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }, - ]); + let mut manifest = create_edge_app_manifest_for_test(deploy_test_settings()); manifest.id = None; manifest.entrypoint = None; @@ -1745,256 +1123,92 @@ mod tests { } #[test] - fn test_changed_files_when_not_all_files_are_copied_should_upload_missed_ones() { + fn test_deploy_when_local_entrypoint_uri_set_and_no_new_revision_needed_should_update_setting() + { let (temp_dir, command, mock_server, _manifest, _instance_manifest) = prepare_edge_apps_test(false, false); - let manifest = EdgeAppManifest { - syntax: MANIFEST_VERSION.to_owned(), - ready_signal: None, - auth: None, - id: Some("01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string()), - user_version: Some("1".to_string()), - description: Some("asdf".to_string()), - icon: Some("asdf".to_string()), - author: Some("asdf".to_string()), - homepage_url: Some("asdfasdf".to_string()), - categories: vec![], - entrypoint: None, - settings: vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "asdf".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "asdf".to_string(), - }, - ], - }; - - let copy_assets_mock = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/edge-apps/copy-assets") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .json_body(json!({ - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "revision": 7, - "signatures": ["somesig", "somesig1", "somesig2"] - })); - then.status(201).json_body(json!(["somesig"])); - }); - - let upload_assets_mock = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/assets") - .body_includes("test222"); - then.status(201).body(""); - }); - let upload_assets_mock2 = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4/assets") - .body_includes("test333"); - then.status(201).body(""); + let mut manifest = create_edge_app_manifest_for_test(vec![]); + manifest.user_version = None; + manifest.author = None; + manifest.entrypoint = Some(Entrypoint { + entrypoint_type: EntrypointType::RemoteLocal, + uri: None, }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) .unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test").unwrap(); - - let screenly_path = temp_dir.path().join("screenly.yml"); - let path = screenly_path.as_path(); - let edge_app_dir = path.parent().ok_or(CommandError::MissingField).unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test111").unwrap(); - let mut file1 = File::create(temp_dir.path().join("index1.html")).unwrap(); - write!(file1, "test222").unwrap(); - let mut file2 = File::create(temp_dir.path().join("index2.html")).unwrap(); - write!(file2, "test333").unwrap(); - - let changed_files = FileChanges::new( - &[ - EdgeAppFile { - path: "index.html".to_owned(), - signature: "somesig".to_owned(), - }, - EdgeAppFile { - path: "index1.html".to_owned(), - signature: "somesig1".to_owned(), - }, - EdgeAppFile { - path: "index2.html".to_owned(), - signature: "somesig2".to_owned(), - }, - ], - true, - ); - let result = command.upload_changed_files( - edge_app_dir, - "01H2QZ6Z8WXWNDC0KQ198XCZEW", - 7, - &changed_files, - ); - - // Twice for somesig1 and somesig2 - upload_assets_mock.assert(); - upload_assets_mock2.assert(); - copy_assets_mock.assert(); + let mut instance_manifest = create_instance_manifest_for_test(); + instance_manifest.entrypoint_uri = Some("https://local-entrypoint.com".to_string()); + InstanceManifest::save_to_file( + &instance_manifest, + temp_dir.path().join("instance.yml").as_path(), + ) + .unwrap(); - assert!(result.is_ok()); - } + let preview_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")); + then.status(200).json_body(json!({ + "deploy_needed": false, + "outstanding": {"missing": [], "pending": [], "failed": []}, + "diff": { + "settings": {"create": [], "update": [], "delete": []}, + "revision": {"update": []}, + "files": {"create": [], "update": [], "delete": []} + } + })); + }); - #[test] - fn test_changed_files_when_all_files_are_copied_should_not_upload() { - let (temp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(false, false); + let setting_is_global_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/edge-apps/settings") + .query_param("select", "is_global") + .query_param("name", "eq.screenly_entrypoint"); + then.status(200).json_body(json!([{"is_global": false}])); + }); - let manifest = EdgeAppManifest { - syntax: MANIFEST_VERSION.to_owned(), - ready_signal: None, - auth: None, - id: Some("01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string()), - user_version: Some("1".to_string()), - description: Some("asdf".to_string()), - icon: Some("asdf".to_string()), - author: Some("asdf".to_string()), - homepage_url: Some("asdfasdf".to_string()), - categories: vec![], - entrypoint: None, - settings: vec![ - Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "sdfg".to_string(), - }, - Setting { - name: "nsetting".to_string(), - type_: SettingType::String, - title: Some("ntitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "asdf".to_string(), - }, - ], - }; + let setting_mock = mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/edge-apps/settings") + .query_param("select", "name,type,edge_app_setting_values(value)") + .query_param("name", "eq.screenly_entrypoint"); + then.status(200).json_body(json!([{ + "name": "screenly_entrypoint", + "type": "string", + "edge_app_setting_values": [], + }])); + }); - let copy_assets_mock = mock_server.mock(|when, then| { + let setting_value_mock = mock_server.mock(|when, then| { when.method(POST) - .path("/v4/edge-apps/copy-assets") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) + .path("/v4.1/edge-apps/settings/values") .json_body(json!({ - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "revision": 7, - "signatures": ["somesig", "somesig1", "somesig2"] + "value": "https://local-entrypoint.com", + "name": "screenly_entrypoint", + "installation_id": "01H2QZ6Z8WXWNDC0KQ198XCZEB", })); - then.status(201) - .json_body(json!(["somesig", "somesig1", "somesig2"])); + then.status(200).json_body(json!({})); }); - let upload_assets_mock = mock_server.mock(|when, then| { - when.method(POST).path("/v4/assets"); - then.status(201).body(""); + let deploy_mock = mock_server.mock(|when, then| { + when.method(POST) + .path(format!("/v3/edge-apps/{APP_ID}/deploy")); + then.status(200) + .json_body(json!({"revision": 8, "created": true})); }); - EdgeAppManifest::save_to_file(&manifest, temp_dir.path().join("screenly.yml").as_path()) - .unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test").unwrap(); + let result = command.deploy(Some(temp_dir.path().to_str().unwrap().to_string()), None); - let screenly_path = temp_dir.path().join("screenly.yml"); - let path = screenly_path.as_path(); - let edge_app_dir = path.parent().ok_or(CommandError::MissingField).unwrap(); - let mut file = File::create(temp_dir.path().join("index.html")).unwrap(); - write!(file, "test111").unwrap(); - let mut file1 = File::create(temp_dir.path().join("index1.html")).unwrap(); - write!(file1, "test222").unwrap(); - let mut file2 = File::create(temp_dir.path().join("index2.html")).unwrap(); - write!(file2, "test333").unwrap(); - - let changed_files = FileChanges::new( - &[ - EdgeAppFile { - path: "index.html".to_owned(), - signature: "somesig".to_owned(), - }, - EdgeAppFile { - path: "index1.html".to_owned(), - signature: "somesig1".to_owned(), - }, - EdgeAppFile { - path: "index2.html".to_owned(), - signature: "somesig2".to_owned(), - }, - ], - true, - ); - - let result = command.upload_changed_files( - edge_app_dir, - "01H2QZ6Z8WXWNDC0KQ198XCZEW", - 7, - &changed_files, - ); - - upload_assets_mock.assert_calls(0); - copy_assets_mock.assert(); - - assert!(result.is_ok()); - } - - #[test] - fn test_maybe_delete_missing_settings_when_ci_is_1_and_no_arg_provided_should_ignore_deleting_settings( - ) { - let (_temp_dir, command, _mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, false); + preview_mock.assert(); + setting_is_global_mock.assert(); + setting_mock.assert(); + setting_value_mock.assert(); + deploy_mock.assert_calls(0); - let changed_settings: SettingChanges = SettingChanges { - creates: vec![], - updates: vec![], - deleted: vec![Setting { - name: "asetting".to_string(), - type_: SettingType::String, - title: Some("atitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: false, - help_text: "help text".to_string(), - }], - }; - - temp_env::with_var("CI", Some("true"), || { - let result = command.maybe_delete_missing_settings( - None, - "01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string(), - changed_settings, - ); - assert!(result.is_ok()); - }); + let outcome = result.unwrap(); + assert_eq!(outcome.revision, None); + assert!(!outcome.created); } #[test] diff --git a/src/commands/edge_app/manifest.rs b/src/commands/edge_app/manifest.rs index 3fb61463..f509fc4a 100644 --- a/src/commands/edge_app/manifest.rs +++ b/src/commands/edge_app/manifest.rs @@ -1,11 +1,9 @@ -use std::collections::HashMap; use std::fs; use std::fs::File; use std::io::{ErrorKind, Write}; use std::path::Path; use serde::{Deserialize, Serialize}; -use serde_json::json; use super::manifest_auth::AuthType; use crate::api::edge_app::setting::{deserialize_settings, serialize_settings, Setting}; @@ -276,35 +274,6 @@ impl EdgeAppManifest { Ok(()) } - pub fn prepare_payload(manifest: &EdgeAppManifest) -> HashMap<&str, serde_json::Value> { - let entrypoint_uri = match &manifest.entrypoint { - Some(entrypoint) => entrypoint.uri.clone(), - None => None, - }; - - let mut payload: HashMap<&str, serde_json::Value> = [ - ("app_id", &manifest.id), - ("user_version", &manifest.user_version), - ("description", &manifest.description), - ("icon", &manifest.icon), - ("author", &manifest.author), - ("homepage_url", &manifest.homepage_url), - ("entrypoint", &entrypoint_uri), - ] - .iter() - .filter_map(|(key, value)| value.as_ref().map(|v| (*key, json!(v)))) - .collect(); - - payload.insert( - "ready_signal", - json!(manifest.ready_signal.unwrap_or(false)), - ); - - payload.insert("categories", json!(manifest.categories)); - - payload - } - pub fn ensure_manifest_is_valid(path: &Path) -> Result<(), CommandError> { match EdgeAppManifest::new(path) { Ok(_) => Ok(()), @@ -955,96 +924,4 @@ settings: assert_eq!(contents, expected_contents); } - - #[test] - fn test_prepare_manifest_payload_includes_some_fields() { - let manifest = EdgeAppManifest { - id: Some("test_app".to_string()), - ready_signal: Some(false), // Changed to false - auth: None, - syntax: MANIFEST_VERSION.to_owned(), - user_version: Some("test_version".to_string()), - description: Some("test_description".to_string()), - icon: Some("test_icon".to_string()), - author: Some("test_author".to_string()), - homepage_url: Some("test_url".to_string()), - categories: vec!["Utilities".to_string(), "Dashboards".to_string()], - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: Some("entrypoint.html".to_string()), - }), - settings: vec![Setting { - name: "username".to_string(), - title: Some("username title".to_string()), - type_: SettingType::String, - default_value: Some("stranger".to_string()), - optional: true, - is_global: false, - help_text: "An example of a setting that is used in index.html".to_string(), - }], - }; - let result = EdgeAppManifest::prepare_payload(&manifest); - assert_eq!(result["app_id"], json!("test_app")); - assert_eq!(result["user_version"], json!("test_version")); - assert_eq!(result["description"], json!("test_description")); - assert_eq!(result["icon"], json!("test_icon")); - assert_eq!(result["author"], json!("test_author")); - assert_eq!(result["homepage_url"], json!("test_url")); - assert_eq!(result["categories"], json!(["Utilities", "Dashboards"])); - assert_eq!(result["entrypoint"], json!("entrypoint.html")); - assert_eq!(result["ready_signal"], json!(false)); // Added assertion for ready_signal - } - - #[test] - fn test_prepare_manifest_payload_omits_none_fields() { - let manifest = EdgeAppManifest { - id: Some("test_app".to_string()), - user_version: None, - description: Some("test_description".to_string()), - icon: Some("test_icon".to_string()), - author: None, - homepage_url: Some("test_url".to_string()), - ready_signal: Some(false), // Added ready_signal - ..Default::default() - }; - let result = EdgeAppManifest::prepare_payload(&manifest); - assert_eq!(result["app_id"], json!("test_app")); - assert!(!result.contains_key("user_version")); - assert_eq!(result["description"], json!("test_description")); - assert_eq!(result["icon"], json!("test_icon")); - assert!(!result.contains_key("author")); - assert_eq!(result["homepage_url"], json!("test_url")); - assert_eq!(result["categories"], json!([])); - assert!(!result.contains_key("entrypoint")); - assert_eq!(result["ready_signal"], json!(false)); // Added assertion for ready_signal - } - - #[test] - fn test_prepare_manifest_payload_with_ready_signal_true() { - let manifest = EdgeAppManifest { - id: Some("test_app".to_string()), - ready_signal: Some(true), - user_version: Some("test_version".to_string()), - description: Some("test_description".to_string()), - icon: Some("test_icon".to_string()), - author: Some("test_author".to_string()), - homepage_url: Some("test_url".to_string()), - categories: vec!["Utilities".to_string(), "Dashboards".to_string()], - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: Some("entrypoint.html".to_string()), - }), - ..Default::default() - }; - let result = EdgeAppManifest::prepare_payload(&manifest); - assert_eq!(result["app_id"], json!("test_app")); - assert_eq!(result["user_version"], json!("test_version")); - assert_eq!(result["description"], json!("test_description")); - assert_eq!(result["icon"], json!("test_icon")); - assert_eq!(result["author"], json!("test_author")); - assert_eq!(result["homepage_url"], json!("test_url")); - assert_eq!(result["categories"], json!(["Utilities", "Dashboards"])); - assert_eq!(result["entrypoint"], json!("entrypoint.html")); - assert_eq!(result["ready_signal"], json!(true)); // Assert ready_signal is true - } } diff --git a/src/commands/edge_app/manifest_auth.rs b/src/commands/edge_app/manifest_auth.rs index c6204756..4d9d1eb5 100644 --- a/src/commands/edge_app/manifest_auth.rs +++ b/src/commands/edge_app/manifest_auth.rs @@ -1,88 +1,8 @@ use serde::{Deserialize, Serialize}; -use crate::api::edge_app::setting::{Setting, SettingType}; - #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuthType { Basic, Bearer, } - -impl AuthType { - pub fn generate_settings(&self, global: bool) -> Vec { - match self { - AuthType::Basic => vec![ - Setting::new( - SettingType::String, - "Username", - "screenly_http_basic_auth_username", - "The username for Basic Authentication.", - global, - ), - Setting::new( - SettingType::Secret, - "Password", - "screenly_http_basic_auth_password", - "The password for Basic Authentication.", - global, - ), - ], - AuthType::Bearer => vec![Setting::new( - SettingType::String, - "Token", - "screenly_http_bearer_token", - "The Bearer token for authentication.", - global, - )], - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_basic_auth_settings_when_generated_should_have_correct_properties() { - let auth_type = AuthType::Basic; - let settings = auth_type.generate_settings(false); - - assert_eq!(settings.len(), 2); - - let username_setting = &settings[0]; - assert_eq!(username_setting.type_, SettingType::String); - assert_eq!(username_setting.name, "screenly_http_basic_auth_username"); - assert_eq!(username_setting.title, Some("Username".to_string())); - assert!(!username_setting.optional); - assert!(username_setting - .help_text - .contains("username for Basic Authentication")); - - let password_setting = &settings[1]; - assert_eq!(password_setting.type_, SettingType::Secret); - assert_eq!(password_setting.name, "screenly_http_basic_auth_password"); - assert_eq!(password_setting.title, Some("Password".to_string())); - assert!(!password_setting.optional); - assert!(password_setting - .help_text - .contains("password for Basic Authentication")); - } - - #[test] - fn test_bearer_auth_settings_when_generated_should_have_correct_properties() { - let auth_type = AuthType::Bearer; - let settings = auth_type.generate_settings(false); - - assert_eq!(settings.len(), 1); - - let token_setting = &settings[0]; - assert_eq!(token_setting.type_, SettingType::String); - assert_eq!(token_setting.name, "screenly_http_bearer_token"); - assert_eq!(token_setting.title, Some("Token".to_string())); - assert!(!token_setting.optional); - assert!(token_setting - .help_text - .contains("Bearer token for authentication")); - } -} diff --git a/src/commands/edge_app/setting.rs b/src/commands/edge_app/setting.rs index 2dd48802..f91983dd 100644 --- a/src/commands/edge_app/setting.rs +++ b/src/commands/edge_app/setting.rs @@ -1,8 +1,5 @@ use std::str; -use log::debug; - -use crate::api::edge_app::setting::Setting; use crate::commands::edge_app::EdgeAppCommand; use crate::commands::{CommandError, EdgeAppSettings}; @@ -108,39 +105,6 @@ impl EdgeAppCommand { Ok(()) } - - pub fn create_setting(&self, app_id: String, setting: &Setting) -> Result<(), CommandError> { - let response = self.api.create_setting(&app_id, setting); - if response.is_err() { - let c = self.api.get_settings(&app_id)?; - debug!("Existing settings: {c:?}"); - return Err(CommandError::NoChangesToUpload("".to_owned())); - } - - Ok(()) - } - - pub fn update_setting(&self, app_id: String, setting: &Setting) -> Result<(), CommandError> { - let response = self.api.update_setting(&app_id, setting); - - if let Err(error) = response { - debug!("Failed to update setting: {}", setting.name); - return Err(error); - } - - Ok(()) - } - - pub fn delete_setting(&self, app_id: String, setting: &Setting) -> Result<(), CommandError> { - let response = self.api.delete_setting(&app_id, setting); - - if let Err(error) = response { - debug!("Failed to delete setting: {}", setting.name); - return Err(error); - } - - Ok(()) - } } #[cfg(test)] @@ -148,10 +112,9 @@ mod tests { use std::env; use httpmock::Method::{GET, PATCH, POST}; + use log::debug; use serde_json::{json, Value}; - use super::*; - use crate::api::edge_app::setting::SettingType; use crate::commands::edge_app::test_utils::tests::prepare_edge_apps_test; #[test] @@ -862,57 +825,4 @@ mod tests { setting_mock_get.assert(); assert!(result.is_ok()); } - - #[test] - fn test_create_is_global_setting_should_pass_is_global_property() { - let (_temp_dir, command, mock_server, _manifest, _instance_manifest) = - prepare_edge_apps_test(true, false); - - // v4/edge-apps/settings?app_id=eq.{} - let settings_mock_create = mock_server.mock(|when, then| { - when.method(POST) - .path("/v4.1/edge-apps/settings") - .header("Authorization", "Token token") - .header( - "user-agent", - format!("screenly-cli {}", env!("CARGO_PKG_VERSION")), - ) - .json_body(json!({ - "name": "ssetting", - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "type": "secret", - "default_value": "", - "title": "stitle", - "optional": false, - "help_text": "help text", - "is_global": true - })); - then.status(201).json_body(json!( - [{ - "name": "ssetting", - "app_id": "01H2QZ6Z8WXWNDC0KQ198XCZEW", - "type": "secret", - "default_value": "", - "title": "stitle", - "optional": false, - "help_text": "help text", - "is_global": true, - }])); - }); - - let setting = Setting { - name: "ssetting".to_string(), - type_: SettingType::Secret, - title: Some("stitle".to_string()), - optional: false, - default_value: Some("".to_string()), - is_global: true, - help_text: "help text".to_string(), - }; - command - .create_setting("01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string(), &setting) - .unwrap(); - - settings_mock_create.assert(); - } } diff --git a/src/commands/edge_app/utils.rs b/src/commands/edge_app/utils.rs index 5b78aa46..8b750c30 100644 --- a/src/commands/edge_app/utils.rs +++ b/src/commands/edge_app/utils.rs @@ -1,12 +1,10 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::env; use std::path::{Path, PathBuf}; use log::debug; use walkdir::{DirEntry, WalkDir}; -use crate::api::asset::AssetSignature; -use crate::api::edge_app::setting::{Setting, SettingType}; use crate::commands::edge_app::instance_manifest::InstanceManifest; use crate::commands::edge_app::manifest::EdgeAppManifest; use crate::commands::ignorer::Ignorer; @@ -22,47 +20,6 @@ pub struct EdgeAppFile { pub signature: String, } -#[derive(Debug)] -pub struct SettingChanges { - pub creates: Vec, - pub updates: Vec, - pub deleted: Vec, -} - -#[derive(Debug)] -pub struct FileChanges { - pub local_files: Vec, - changes_detected: bool, -} - -impl FileChanges { - pub fn new(local_files: &[EdgeAppFile], changes_detected: bool) -> Self { - Self { - local_files: local_files.to_vec(), - changes_detected, - } - } - - pub fn has_changes(&self) -> bool { - // not considering copies - copies are all assets from previous version anyhow - self.changes_detected - } - - pub fn get_local_signatures(&self) -> HashSet { - self.local_files - .iter() - .map(|f| f.signature.clone()) - .collect::>() - } - - pub fn get_files_to_upload(&self, exclude_signatures: Vec) -> Vec<&EdgeAppFile> { - self.local_files - .iter() - .filter(|f| !exclude_signatures.contains(&f.signature)) - .collect::>() - } -} - fn is_included(entry: &DirEntry, ignore: &Ignorer) -> bool { let exclusion_list = ["screenly.js", "screenly.yml", ".ignore", "instance.yml"]; if exclusion_list.contains(&entry.file_name().to_str().unwrap_or_default()) { @@ -157,114 +114,6 @@ pub fn collect_paths_for_upload(path: &Path) -> Result, Command Ok(files) } -pub fn ensure_edge_app_has_all_necessary_files(files: &[EdgeAppFile]) -> Result<(), CommandError> { - let required_files = vec!["index.html"]; - for file in required_files { - if !files.iter().any(|f| f.path == file) { - return Err(CommandError::MissingRequiredFile(file.to_owned())); - } - } - Ok(()) -} - -pub fn detect_changed_files( - local_files: &[EdgeAppFile], - remote_files: &[AssetSignature], -) -> Result { - let mut signatures: HashSet = HashSet::new(); - - // Store remote file signatures in the hashmap - for remote_file in remote_files { - signatures.insert(remote_file.signature.clone()); - } - - let mut file_changes = FileChanges::new(local_files, false); - - let local_signatures = file_changes.get_local_signatures(); - file_changes.changes_detected = local_signatures != signatures; - - Ok(file_changes) -} - -pub fn detect_changed_settings( - manifest: &EdgeAppManifest, - remote_settings: &[Setting], -) -> Result { - // Remote and local settings MUST be sorted. - // This function compares remote and local settings - // And returns if there are any new local settings missing from the remote - // And changed settings to update - - let mut new_settings = manifest.settings.clone(); - - if let Some(auth) = &manifest.auth { - let auth_settings = auth.auth_type.generate_settings(auth.global); - new_settings.extend(auth_settings); - } - - if let Some(_entrypoint) = &manifest.entrypoint { - match _entrypoint.entrypoint_type { - crate::commands::edge_app::manifest::EntrypointType::RemoteGlobal => { - new_settings.push(Setting::new( - SettingType::String, - "Entrypoint", - "screenly_entrypoint", - "The global entrypoint for the app.", - true, - )); - } - crate::commands::edge_app::manifest::EntrypointType::RemoteLocal => { - new_settings.push(Setting::new( - SettingType::String, - "Entrypoint", - "screenly_entrypoint", - "The entrypoint for the app.", - false, - )); - } - crate::commands::edge_app::manifest::EntrypointType::File => {} - } - } - - new_settings.sort_by_key(|s| s.name.clone()); - - let mut creates = Vec::new(); - let mut updates = Vec::new(); - let mut deleted: Vec = Vec::new(); - - let mut remote_iter = remote_settings.iter().peekable(); - let mut new_iter = new_settings.iter().peekable(); - - while let (Some(&remote_setting), Some(&new_setting)) = (remote_iter.peek(), new_iter.peek()) { - match remote_setting.name.cmp(&new_setting.name) { - std::cmp::Ordering::Equal => { - if remote_setting != new_setting { - updates.push(new_setting.clone()); - } - remote_iter.next(); - new_iter.next(); - } - std::cmp::Ordering::Less => { - deleted.push(remote_setting.clone()); - remote_iter.next(); - } - std::cmp::Ordering::Greater => { - creates.push(new_setting.clone()); - new_iter.next(); - } - } - } - - creates.extend(new_iter.cloned()); - deleted.extend(remote_iter.cloned()); - - Ok(SettingChanges { - creates, - updates, - deleted, - }) -} - pub fn generate_file_tree(files: &[EdgeAppFile], root_path: &Path) -> HashMap { let mut tree = HashMap::new(); let prefix = root_path.as_os_str().to_string_lossy().to_string(); @@ -317,417 +166,8 @@ mod tests { use tempfile::tempdir; use super::*; - use crate::api::edge_app::setting::{Setting, SettingType}; use crate::commands::edge_app::instance_manifest::INSTANCE_MANIFEST_VERSION; - use crate::commands::edge_app::manifest::{Auth, Entrypoint, EntrypointType, MANIFEST_VERSION}; - use crate::commands::edge_app::manifest_auth::AuthType; - - fn create_manifest() -> EdgeAppManifest { - EdgeAppManifest { - id: Some("01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string()), - auth: None, - syntax: MANIFEST_VERSION.to_owned(), - ready_signal: None, - user_version: Some("1".to_string()), - description: Some("asdf".to_string()), - icon: Some("asdf".to_string()), - author: Some("asdf".to_string()), - homepage_url: Some("asdfasdf".to_string()), - categories: vec!["Utilities".to_string(), "Dashboards".to_string()], - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: Some("entrypoint.html".to_string()), - }), - settings: vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - Setting { - name: "google_maps_api_key".to_string(), - type_: SettingType::String, - default_value: Some("6".to_string()), - title: Some("Google maps title".to_string()), - optional: true, - is_global: false, - help_text: "Specify a commercial Google Maps API key. Required due to the app's map feature.".to_string(), - }, - ], - } - } - - #[test] - fn test_detect_changed_settings_when_no_changes_should_detect_no_changes() { - // Arrange - let manifest = create_manifest(); - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - Setting { - name: "google_maps_api_key".to_string(), - type_: SettingType::String, - default_value: Some("6".to_string()), - title: Some("Google maps title".to_string()), - optional: true, - is_global: false, - help_text: "Specify a commercial Google Maps API key. Required due to the app's map feature.".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 0); - } - - #[test] - fn test_detect_changed_settings_when_title_is_null_remotely_should_detect_changes() { - let manifest = create_manifest(); - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: None, - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - Setting { - name: "google_maps_api_key".to_string(), - type_: SettingType::String, - default_value: Some("6".to_string()), - title: Some("Google maps title".to_string()), - optional: true, - is_global: false, - help_text: "Specify a commercial Google Maps API key. Required due to the app's map feature.".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.updates.len(), 1); - } - - #[test] - fn test_detect_changes_settings_when_setting_removed_should_detect_deleted_changes() { - // Arrange - let manifest = create_manifest(); - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - Setting { - name: "google_maps_api_key".to_string(), - type_: SettingType::String, - default_value: Some("6".to_string()), - title: Some("Google maps title".to_string()), - optional: true, - is_global: false, - help_text: "Specify a commercial Google Maps API key. Required due to the app's map feature.".to_string(), - }, - Setting { - name: "new_setting".to_string(), - type_: SettingType::String, - default_value: Some("10".to_string()), - title: Some("new setting title".to_string()), - optional: false, - is_global: false, - help_text: "New setting description".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.deleted.len(), 1); - assert_eq!(changes.deleted[0].name, "new_setting"); - } - - #[test] - fn test_detect_changes_settings_when_local_setting_added_should_detect_changes() { - // Arrange - let manifest = create_manifest(); - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 1); - assert_eq!(changes.creates[0].name, "google_maps_api_key"); - } - - // TODO: Update test, when patching is implemented - #[test] - fn test_detect_changed_settings_when_setting_are_modified_should_detect_changes() { - // Arrange - let manifest = create_manifest(); - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - Setting { - name: "google_maps_api_key".to_string(), - type_: SettingType::String, - default_value: Some("7".to_string()), // Modified default value - title: Some("Google maps title".to_string()), - optional: true, - is_global: false, - help_text: "Specify a commercial Google Maps API key. Required due to the app's map feature.".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 0); - assert_eq!(changes.updates.len(), 1); - assert_eq!(changes.updates[0].name, "google_maps_api_key"); - assert_eq!(changes.updates[0].default_value, Some("6".to_owned())); - } - - #[test] - fn test_detect_changed_settings_when_no_remote_settings_should_detect_changes() { - // Arrange - let manifest = create_manifest(); - - let remote_settings = Vec::new(); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 2); - } - - #[test] - fn test_detect_changed_settings_when_is_global_changed_on_setting_should_detect_changes() { - // Arrange - let manifest = EdgeAppManifest { - id: Some("01H2QZ6Z8WXWNDC0KQ198XCZEW".to_string()), - auth: None, - syntax: MANIFEST_VERSION.to_owned(), - ready_signal: None, - user_version: Some("1".to_string()), - description: Some("asdf".to_string()), - icon: Some("asdf".to_string()), - author: Some("asdf".to_string()), - homepage_url: Some("asdfasdf".to_string()), - categories: vec!["Utilities".to_string(), "Dashboards".to_string()], - entrypoint: Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: Some("entrypoint.html".to_string()), - }), - settings: vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: true, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - ], - }; - - let remote_settings = vec![ - Setting { - name: "display_time".to_string(), - type_: SettingType::String, - default_value: Some("5".to_string()), - title: Some("display time title".to_string()), - optional: true, - is_global: false, - help_text: "For how long to display the map overlay every time the rover has moved to a new position.".to_string(), - }, - ]; - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 0); - assert_eq!(changes.updates.len(), 1); - } - - #[test] - fn test_detect_changed_files_no_changes() { - // Arrange - let local_files = vec![ - EdgeAppFile { - path: "file1".to_string(), - signature: "signature1".to_string(), - }, - EdgeAppFile { - path: "file2".to_string(), - signature: "signature2".to_string(), - }, - ]; - - let remote_files = vec![ - AssetSignature { - signature: "signature1".to_string(), - }, - AssetSignature { - signature: "signature2".to_string(), - }, - ]; - - // Act - let result = detect_changed_files(&local_files, &remote_files); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.local_files.len(), 2); - assert!(!changes.changes_detected); - } - - #[test] - fn test_detect_changed_files_changes_detected() { - // Arrange - let local_files = vec![ - EdgeAppFile { - path: "file1".to_string(), - signature: "signature1".to_string(), - }, - EdgeAppFile { - path: "file2".to_string(), - signature: "signature2".to_string(), - }, - ]; - - let remote_files = vec![ - AssetSignature { - signature: "signature3".to_string(), - }, - AssetSignature { - signature: "signature2".to_string(), - }, - ]; - - // Act - let result = detect_changed_files(&local_files, &remote_files); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.local_files.len(), 2); - assert!(changes.changes_detected); - } - - #[test] - fn test_detect_changed_files_remote_files_empty() { - // Arrange - let local_files = vec![ - EdgeAppFile { - path: "file1".to_string(), - signature: "signature1".to_string(), - }, - EdgeAppFile { - path: "file2".to_string(), - signature: "signature2".to_string(), - }, - ]; - - let remote_files = Vec::new(); - - // Act - let result = detect_changed_files(&local_files, &remote_files); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.local_files.len(), 2); - assert!(changes.changes_detected); - } - - #[test] - fn test_detect_changed_when_files_local_deleted_should_detect_changes() { - // Arrange - let local_files = vec![EdgeAppFile { - path: "file1".to_string(), - signature: "signature1".to_string(), - }]; - - let remote_files = vec![ - AssetSignature { - signature: "signature1".to_string(), - }, - AssetSignature { - signature: "signature2".to_string(), - }, - ]; - - // Act - let result = detect_changed_files(&local_files, &remote_files); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.local_files.len(), 1); - assert!(changes.changes_detected); - } + use crate::commands::edge_app::manifest::{Entrypoint, EntrypointType, MANIFEST_VERSION}; #[test] fn test_ignore_functionality() { @@ -756,265 +196,6 @@ mod tests { assert_eq!(result[0].path, "file1.txt"); } - #[test] - fn test_detect_changed_settings_when_basic_auth_added_should_detect_changes() { - // Arrange - let mut manifest = create_manifest(); - manifest.auth = Some(Auth { - auth_type: AuthType::Basic, - global: false, - }); - - let remote_settings = manifest.settings.clone(); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 2); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_username" && !s.is_global)); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_password" && !s.is_global)); - } - - #[test] - fn test_detect_changed_settings_when_bearer_auth_added_should_detect_changes() { - // Arrange - let mut manifest = create_manifest(); - manifest.auth = Some(Auth { - auth_type: AuthType::Bearer, - global: false, - }); - - let remote_settings = manifest.settings.clone(); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 1); - assert_eq!(changes.creates[0].name, "screenly_http_bearer_token"); - assert!(!changes.creates[0].is_global); - } - - #[test] - fn test_detect_changed_settings_when_switching_from_basic_to_bearer_auth() { - // Arrange - let mut manifest = create_manifest(); - manifest.auth = Some(Auth { - auth_type: AuthType::Bearer, - global: false, - }); - - let mut remote_settings = manifest.settings.clone(); - remote_settings.extend(vec![ - Setting::new( - SettingType::String, - "Username", - "screenly_http_basic_auth_username", - "Basic auth username", - false, - ), - Setting::new( - SettingType::Secret, - "Password", - "screenly_http_basic_auth_password", - "Basic auth password", - false, - ), - ]); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 1); - assert_eq!(changes.creates[0].name, "screenly_http_bearer_token"); - assert!(!changes.creates[0].is_global); - assert_eq!(changes.deleted.len(), 2); - assert!(changes - .deleted - .iter() - .any(|s| s.name == "screenly_http_basic_auth_username")); - assert!(changes - .deleted - .iter() - .any(|s| s.name == "screenly_http_basic_auth_password")); - } - - #[test] - fn test_detect_changed_settings_when_switching_from_bearer_to_basic_auth() { - // Arrange - let mut manifest = create_manifest(); - manifest.auth = Some(Auth { - auth_type: AuthType::Basic, - global: false, - }); - - let mut remote_settings = manifest.settings.clone(); - remote_settings.push(Setting::new( - SettingType::String, - "Token", - "screenly_http_bearer_token", - "Bearer token", - false, - )); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 2); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_username" && !s.is_global)); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_password" && !s.is_global)); - assert_eq!(changes.deleted.len(), 1); - assert_eq!(changes.deleted[0].name, "screenly_http_bearer_token"); - } - - #[test] - fn test_detect_changed_settings_when_auth_is_global() { - // Arrange - let mut manifest = create_manifest(); - manifest.auth = Some(Auth { - auth_type: AuthType::Basic, - global: true, - }); - - let remote_settings = manifest.settings.clone(); - - // Act - let result = detect_changed_settings(&manifest, &remote_settings); - - // Assert - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 2); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_username" && s.is_global)); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_http_basic_auth_password" && s.is_global)); - } - - #[test] - fn test_detect_changed_settings_when_entrypoint_is_remote_should_create_global_setting() { - let mut manifest = create_manifest(); - manifest.entrypoint = Some(Entrypoint { - entrypoint_type: EntrypointType::RemoteGlobal, - uri: Some("https://global_entrypoint.html".to_string()), - }); - - let remote_settings = manifest.settings.clone(); - - let result = detect_changed_settings(&manifest, &remote_settings); - - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 1); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_entrypoint" - && s.is_global - && s.type_ == SettingType::String)); - } - - #[test] - fn test_detect_changed_settings_when_entrypoint_setting_exist_in_remote_should_not_create_global_setting( - ) { - let mut manifest = create_manifest(); - manifest.entrypoint = Some(Entrypoint { - entrypoint_type: EntrypointType::RemoteGlobal, - uri: Some("https://global_entrypoint.html".to_string()), - }); - - manifest.settings.push(Setting::new( - SettingType::String, - "SortedTest", - "t_sorted_after_entrypoint", - "Sorted after entrypoint setting.", - true, - )); - - let mut remote_settings = manifest.settings.clone(); - remote_settings.push(Setting::new( - SettingType::String, - "screenly_entrypoint", - "screenly_entrypoint", - "The global entrypoint for the app.", - true, - )); - remote_settings.sort_by_key(|s| s.name.clone()); - - let result = detect_changed_settings(&manifest, &remote_settings); - - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 0); - } - - #[test] - fn test_detect_changed_settings_when_entrypoint_is_local_should_create_local_setting() { - let mut manifest = create_manifest(); - manifest.entrypoint = Some(Entrypoint { - entrypoint_type: EntrypointType::RemoteLocal, - uri: Some("https://local_entrypoint.html".to_string()), - }); - - let remote_settings = manifest.settings.clone(); - - let result = detect_changed_settings(&manifest, &remote_settings); - - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 1); - assert!(changes - .creates - .iter() - .any(|s| s.name == "screenly_entrypoint" - && !s.is_global - && s.type_ == SettingType::String)); - } - - #[test] - fn test_detect_changed_settings_when_entrypoint_is_file_should_not_create_setting() { - let mut manifest = create_manifest(); - manifest.entrypoint = Some(Entrypoint { - entrypoint_type: EntrypointType::File, - uri: Some("entrypoint.html".to_string()), - }); - - let remote_settings = manifest.settings.clone(); - - let result = detect_changed_settings(&manifest, &remote_settings); - - assert!(result.is_ok()); - let changes = result.unwrap(); - assert_eq!(changes.creates.len(), 0); - } - #[test] #[cfg_attr(target_os = "macos", ignore)] fn test_transform_edge_app_instance_path_to_instance_manifest_should_return_current_dir_with_() diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8d9d0e39..82319144 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -122,14 +122,10 @@ pub enum CommandError { WrongResponseStatus(u16), #[error("Required field is missing in the response")] MissingField, - #[error("Required file is missing in the edge app directory: {0}")] - MissingRequiredFile(String), #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("Invalid header value: {0}")] InvalidHeaderValue(#[from] InvalidHeaderValue), - #[error("Cannot upload a new version: {0}")] - NoChangesToUpload(String), #[error("Strip prefix error: {0}")] StripPrefixError(#[from] std::path::StripPrefixError), #[error("Filesystem error: {0}")] @@ -142,18 +138,16 @@ pub enum CommandError { InitializationError(String), #[error("Asset processing error: {0}")] AssetProcessingError(String), + #[error("Deploy rejected: {0}")] + DeployRejected(String), #[error("App id is required in manifest.")] MissingAppId, - #[error("Edge App Revision {0} not found")] - RevisionNotFound(String), #[error("Manifest file validation failed with error: {0}")] InvalidManifest(String), #[error("Edge App Manifest (screenly.yml) doesn't exist under provided path: {0}. Enter a valid command line --path parameter or invoke command in a directory containing Edge App Manifest")] MisingManifest(String), #[error("Setting does not exist: {0}.")] SettingDoesNotExist(String), - #[error("Wrong setting name: {0}.")] - WrongSettingName(String), #[error("Failed to open browser")] OpenBrowserError(String), #[error("Instance already exists")] diff --git a/src/mcp/tools/edge_app.rs b/src/mcp/tools/edge_app.rs index 7858d329..20fbef2d 100644 --- a/src/mcp/tools/edge_app.rs +++ b/src/mcp/tools/edge_app.rs @@ -283,7 +283,8 @@ impl EdgeAppTools { let revision = command .deploy(Some(path), Some(false)) - .map_err(|e| format!("Failed to deploy Edge App: {}", e))?; + .map_err(|e| format!("Failed to deploy Edge App: {}", e))? + .revision; // Create/deploy already happened. Instance + local memory must not hide app_id. let mut warnings: Vec = Vec::new(); @@ -331,7 +332,7 @@ struct PublishFromHtmlResponse { instance_id: Option, instance_created: bool, name: String, - revision: u32, + revision: Option, created: bool, resolved_from_memory: bool, saved_to_memory: bool, @@ -924,7 +925,7 @@ mod registry_tests { instance_id: None, instance_created: false, name: "Lobby Board".to_string(), - revision: 3, + revision: Some(3), created: true, resolved_from_memory: false, saved_to_memory: false,