From 0e06d59c25e4794cfa2aa70a1d9e4a1dd0028d94 Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Wed, 26 Aug 2026 13:27:41 +0400 Subject: [PATCH 1/2] Move Edge App deploy orchestration to the server The CLI drove deploys by hand: reading the manifest, diffing and writing settings one at a time, creating a version row, uploading files against that revision, then publishing it. A failure part-way through left an orphan revision behind, and the ordering meant every file had to be uploaded before the client could tell whether a deploy was needed at all. Deploys now go through two endpoints: POST /v3/edge-apps/{id}/deploy/preview what would change, and which files the platform is still missing POST /v3/edge-apps/{id}/deploy settings, version, files and publish in one transaction Files upload as staged assets through the existing /v4/assets route, carrying an app_id but no app_revision, and the deploy that follows claims them. Nothing is created until the content is already there, so a failed deploy leaves no version behind. The preview also lets the CLI stop early: an app that is already up to date never posts a deploy at all. Drops the client-side manifest reconciliation, version create/publish calls, revision-scoped asset queries and the per-file upload bookkeeping that the server now owns. Requires the server endpoints from Screenly/Screenly#2558. --- src/api/asset.rs | 24 +- src/api/edge_app/app.rs | 11 +- src/api/edge_app/channel.rs | 42 - src/api/edge_app/deploy.rs | 173 +++ src/api/edge_app/mod.rs | 3 +- src/api/edge_app/setting.rs | 111 -- src/api/edge_app/version.rs | 79 -- src/api/mod.rs | 1 - src/api/version.rs | 47 - src/cli.rs | 12 +- src/commands/edge_app/app.rs | 1445 +++++------------------- src/commands/edge_app/manifest.rs | 123 -- src/commands/edge_app/manifest_auth.rs | 80 -- src/commands/edge_app/setting.rs | 92 +- src/commands/edge_app/utils.rs | 823 +------------- src/commands/mod.rs | 10 +- src/mcp/tools/edge_app.rs | 7 +- 17 files changed, 463 insertions(+), 2620 deletions(-) delete mode 100644 src/api/edge_app/channel.rs create mode 100644 src/api/edge_app/deploy.rs delete mode 100644 src/api/edge_app/version.rs delete mode 100644 src/api/version.rs diff --git a/src/api/asset.rs b/src/api/asset.rs index 1588a923..05470db7 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,16 @@ 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( + /// Staged assets carry no revision until a deploy claims them, and no signature until the + /// processor has run. + pub fn get_staged_processing_statuses( &self, app_id: &str, - revision: u32, ) -> 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&app_id=eq.{app_id}&app_revision=is.null&status=neq.finished" ), )?; 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..8d14a486 --- /dev/null +++ b/src/api/edge_app/deploy.rs @@ -0,0 +1,173 @@ +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 { + #[serde(default)] + 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, + #[serde(default)] + 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..08b77e3e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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..b964133f 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,65 @@ 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) + .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 old_file_tree = self.api.get_file_tree(&actual_app_id, revision); - - let file_tree_changed = match old_file_tree { - Ok(tree) => file_tree != tree, - Err(_) => true, }; - 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 preview = self.api.deploy_preview(&actual_app_id, &payload)?; + debug!("Deploy preview: {preview:?}"); - Ok(final_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(", ") + ); + } - 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())); + if !preview.deploy_needed { + debug!("Nothing to deploy."); + return Ok(DeployOutcome { + revision: None, + created: false, + }); } - self.api.update_channel(channel, app_id, revision)?; + let files_to_upload: Vec = preview + .outstanding + .missing + .iter() + .map(|file| edge_app_dir.join(file)) + .collect(); + self.upload_edge_app_assets(&actual_app_id, &files_to_upload)?; - Ok(()) + self.wait_for_assets_processing(&actual_app_id)?; + + let result = self.api.deploy(&actual_app_id, &payload)?; + debug!( + "Deployed revision {} (created: {}, published: {}, channel: {})", + result.revision, result.created, result.published, result.channel + ); + + Ok(DeployOutcome { + revision: Some(result.revision), + created: result.created, + }) } pub fn delete_app(&self, app_id: &str) -> Result<(), CommandError> { @@ -332,37 +247,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 +277,53 @@ 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, app_id: &str) -> Result<(), CommandError> { + const POLL_INTERVAL_SECONDS: u64 = 2; + const MAX_WAIT_SECONDS: u64 = 1000; - 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_staged_processing_statuses(app_id)?; + debug!("Staged 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 +344,26 @@ 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( - &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() { + fn upload_edge_app_assets(&self, app_id: &str, 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())); - } - - 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) - } - - fn upload_edge_app_assets( - &self, - app_id: &str, - revision: u32, - paths: &[PathBuf], - ) -> Result<(), CommandError> { + debug!("Uploading Edge App files: {paths:#?}"); 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); + let result = self.upload_single_asset(app_id, path); if result.is_ok() { - let locked_pb = shared_pb.lock().unwrap(); - locked_pb.inc(1); + pb.inc(1); } result }) } - 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<(), CommandError> { let url = format!("{}/v4/assets", &self.api.authentication.config.url); let mut headers = HeaderMap::new(); @@ -644,7 +381,6 @@ impl EdgeAppCommand { .to_string(), ) .text("app_id", app_id.to_string()) - .text("app_revision", revision.to_string()) .file("file", path)?; let response = self @@ -666,29 +402,6 @@ impl EdgeAppCommand { 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), - } - } - pub fn get_installation_id(&self, path: Option) -> Result { let instance_manifest = InstanceManifest::new(&transform_instance_path_to_instance_manifest(&path)?)?; @@ -709,9 +422,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 +432,58 @@ 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 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 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 +785,191 @@ 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).body(""); }); - // v4/edge-apps/versions?select=file_tree&app_id=eq.{}&revision=eq.{} - let file_tree_from_version_mock = mock_server.mock(|when, then| { + let staged_status_mock = mock_server.mock(|when, then| { when.method(GET) - .path("/v4/edge-apps/versions") + .path("/v4/assets") + .query_param("app_id", format!("eq.{APP_ID}")) + .query_param("app_revision", "is.null"); + 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(); + staged_status_mock.assert(); + upload_assets_mock.assert(); + deploy_mock.assert(); + + assert_eq!(result.unwrap().revision, Some(8)); + } + + #[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); - // v4/edge-apps/settings?app_id=eq.{} - let settings_mock_create = mock_server.mock(|when, then| { + write_deployable_edge_app(temp_dir.path()); + + let preview_mock = 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", - }])); + .path(format!("/v3/edge-apps/{APP_ID}/deploy/preview")) + .body_includes("\"delete_missing_settings\":false"); + then.status(200).json_body(no_outstanding_preview()); }); - 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!({})); - }); - - 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!([])); - }); - - 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| { + let staged_status_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"); + .query_param("app_revision", "is.null"); then.status(200).json_body(json!([])); }); - // "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 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(); + staged_status_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(no_outstanding_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); - - 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 staged_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("app_revision", "is.null"); + 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(); + staged_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 +1062,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; @@ -1744,259 +1084,6 @@ mod tests { ); } - #[test] - fn test_changed_files_when_not_all_files_are_copied_should_upload_missed_ones() { - 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(""); - }); - - 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(); - - assert!(result.is_ok()); - } - - #[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 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 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", "somesig1", "somesig2"])); - }); - - let upload_assets_mock = mock_server.mock(|when, then| { - when.method(POST).path("/v4/assets"); - then.status(201).body(""); - }); - - 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, - ); - - 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); - - 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()); - }); - } - #[test] fn test_get_installation_id_when_manifest_has_id_should_return_id() { let (temp_dir, command, _mock_server, _manifest, _instance_manifest) = 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, From cd9c0487415b4262dddfe7848b3159906d11de20 Mon Sep 17 00:00:00 2001 From: Rusko124 Date: Wed, 26 Aug 2026 14:15:33 +0400 Subject: [PATCH 2/2] Address review: scope asset polling, restore entrypoint propagation Staged assets keep app_revision null until a deploy claims them, and nothing removes them when processing fails. Polling by app id therefore let one bad file block every later deploy of that app until the platform swept it a week later, made concurrent deploys observe each other, and let a foreign stuck asset burn the full wait. Poll by the asset ids this run uploaded instead. upload_single_asset already asked for the created row via Prefer: return=representation and discarded it; it now returns the id. Also from review: - deploy stopped calling update_entrypoint_value, so a remote-local entrypoint URI never reached the setting: it lives in instance.yml and the server cannot derive it from the manifest. Restored on both the deploy and the early-return path, since a changed URI alone leaves deploy_needed false. - --delete-missing-settings demanded a value. Accept it bare while keeping the existing value form working. - deploy_needed and created no longer default, so a response that omits them fails instead of reading as "nothing to do" or inverting the message. --- src/api/asset.rs | 9 +- src/api/edge_app/deploy.rs | 2 - src/cli.rs | 2 +- src/commands/edge_app/app.rs | 201 ++++++++++++++++++++++++++++------- 4 files changed, 169 insertions(+), 45 deletions(-) diff --git a/src/api/asset.rs b/src/api/asset.rs index 05470db7..84d694fd 100644 --- a/src/api/asset.rs +++ b/src/api/asset.rs @@ -12,16 +12,15 @@ pub struct AssetProcessingStatus { } impl Api { - /// Staged assets carry no revision until a deploy claims them, and no signature until the - /// processor has run. - pub fn get_staged_processing_statuses( + pub fn get_processing_statuses( &self, - app_id: &str, + 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=is.null&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/deploy.rs b/src/api/edge_app/deploy.rs index 8d14a486..a1392fb0 100644 --- a/src/api/edge_app/deploy.rs +++ b/src/api/edge_app/deploy.rs @@ -77,7 +77,6 @@ pub struct DeployDiff { #[derive(Clone, Debug, Default, PartialEq, Deserialize)] pub struct DeployPreview { - #[serde(default)] pub deploy_needed: bool, #[serde(default)] pub outstanding: OutstandingFiles, @@ -88,7 +87,6 @@ pub struct DeployPreview { #[derive(Clone, Debug, Default, PartialEq, Deserialize)] pub struct DeployResult { pub revision: u32, - #[serde(default)] pub created: bool, #[serde(default)] pub published: bool, diff --git a/src/cli.rs b/src/cli.rs index 08b77e3e..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. diff --git a/src/commands/edge_app/app.rs b/src/commands/edge_app/app.rs index b964133f..70f6c6f9 100644 --- a/src/commands/edge_app/app.rs +++ b/src/commands/edge_app/app.rs @@ -181,7 +181,7 @@ impl EdgeAppCommand { let manifest = EdgeAppManifest::new(&manifest_path)?; let actual_app_id = self - .get_app_id(path) + .get_app_id(path.clone()) .map_err(|_| CommandError::MissingAppId)?; let edge_app_dir = manifest_path.parent().ok_or(CommandError::MissingField)?; @@ -207,6 +207,7 @@ impl EdgeAppCommand { if !preview.deploy_needed { debug!("Nothing to deploy."); + self.update_entrypoint_value(path)?; return Ok(DeployOutcome { revision: None, created: false, @@ -219,9 +220,9 @@ impl EdgeAppCommand { .iter() .map(|file| edge_app_dir.join(file)) .collect(); - self.upload_edge_app_assets(&actual_app_id, &files_to_upload)?; + let uploaded_asset_ids = self.upload_edge_app_assets(&actual_app_id, &files_to_upload)?; - self.wait_for_assets_processing(&actual_app_id)?; + self.wait_for_assets_processing(&uploaded_asset_ids)?; let result = self.api.deploy(&actual_app_id, &payload)?; debug!( @@ -229,6 +230,8 @@ impl EdgeAppCommand { result.revision, result.created, result.published, result.channel ); + self.update_entrypoint_value(path)?; + Ok(DeployOutcome { revision: Some(result.revision), created: result.created, @@ -277,17 +280,21 @@ impl EdgeAppCommand { Ok(()) } - fn wait_for_assets_processing(&self, app_id: &str) -> Result<(), CommandError> { + 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 progress_bar: Option = None; let mut assets_to_process: u64 = 0; let start_time = Instant::now(); loop { - let statuses = self.api.get_staged_processing_statuses(app_id)?; - debug!("Staged assets still processing: {statuses:?}"); + let statuses = self.api.get_processing_statuses(asset_ids)?; + debug!("Assets still processing: {statuses:?}"); let failed: Vec = statuses .iter() @@ -344,26 +351,33 @@ impl EdgeAppCommand { Ok(()) } - fn upload_edge_app_assets(&self, app_id: &str, paths: &[PathBuf]) -> Result<(), CommandError> { + fn upload_edge_app_assets( + &self, + app_id: &str, + paths: &[PathBuf], + ) -> Result, CommandError> { if paths.is_empty() { debug!("No files to upload"); - return Ok(()); + return Ok(Vec::new()); } debug!("Uploading Edge App files: {paths:#?}"); - let pb = ProgressBar::new(paths.len() as u64); - pb.set_message("Files uploaded:"); - - paths.par_iter().try_for_each(|path| { - let result = self.upload_single_asset(app_id, path); - if result.is_ok() { - pb.inc(1); - } - result - }) + let progress_bar = ProgressBar::new(paths.len() as u64); + progress_bar.set_message("Files uploaded:"); + + 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, path: &Path) -> 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(); @@ -399,7 +413,14 @@ impl EdgeAppCommand { return Err(CommandError::WrongResponseStatus(status.as_u16())); } - Ok(()) + 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 { @@ -434,6 +455,7 @@ mod tests { }; const APP_ID: &str = "01H2QZ6Z8WXWNDC0KQ198XCZEW"; + const ASSET_ID: &str = "01J0YQ9F0000000000000ASSET"; const INDEX_HTML_SIGNATURE: &str = "0a209f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08122086cebd0c365d241e32d5b0972c07aae3a8d6499c2a9471aa85943a35577200021a180a14a94a8fe5ccb19ba61c4c0873d391e987982fbbd31000"; fn deploy_test_settings() -> Vec { @@ -473,6 +495,28 @@ mod tests { 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, @@ -819,14 +863,13 @@ mod tests { .body_includes("name=\"app_id\"") .body_includes(APP_ID) .body_includes("test"); - then.status(201).body(""); + then.status(201).json_body(json!([{"id": ASSET_ID}])); }); - let staged_status_mock = mock_server.mock(|when, then| { + let processing_status_mock = mock_server.mock(|when, then| { when.method(GET) .path("/v4/assets") - .query_param("app_id", format!("eq.{APP_ID}")) - .query_param("app_revision", "is.null"); + .query_param("id", format!("in.({ASSET_ID})")); then.status(200).json_body(json!([])); }); @@ -853,7 +896,7 @@ mod tests { ); preview_mock.assert(); - staged_status_mock.assert(); + processing_status_mock.assert(); upload_assets_mock.assert(); deploy_mock.assert(); @@ -874,13 +917,6 @@ mod tests { then.status(200).json_body(no_outstanding_preview()); }); - let staged_status_mock = mock_server.mock(|when, then| { - when.method(GET) - .path("/v4/assets") - .query_param("app_revision", "is.null"); - 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")); @@ -892,7 +928,6 @@ mod tests { let result = command.deploy(Some(temp_dir.path().to_str().unwrap().to_string()), None); preview_mock.assert(); - staged_status_mock.assert(); deploy_mock.assert(); assert_eq!( @@ -911,13 +946,15 @@ mod tests { 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(no_outstanding_preview()); + then.status(200).json_body(missing_index_preview()); }); - let staged_status_mock = mock_server.mock(|when, then| { + let upload_assets_mock = upload_mock(&mock_server); + + let processing_status_mock = mock_server.mock(|when, then| { when.method(GET) .path("/v4/assets") - .query_param("app_revision", "is.null"); + .query_param("id", format!("in.({ASSET_ID})")); then.status(200).json_body(json!([{ "status": "error", "processing_error": "File type not supported.", @@ -937,7 +974,8 @@ mod tests { ); preview_mock.assert(); - staged_status_mock.assert(); + upload_assets_mock.assert(); + processing_status_mock.assert(); deploy_mock.assert_calls(0); assert_eq!( @@ -1084,6 +1122,95 @@ mod tests { ); } + #[test] + 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 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 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(); + + 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": []} + } + })); + }); + + 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 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 setting_value_mock = mock_server.mock(|when, then| { + when.method(POST) + .path("/v4.1/edge-apps/settings/values") + .json_body(json!({ + "value": "https://local-entrypoint.com", + "name": "screenly_entrypoint", + "installation_id": "01H2QZ6Z8WXWNDC0KQ198XCZEB", + })); + 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, "created": true})); + }); + + let result = command.deploy(Some(temp_dir.path().to_str().unwrap().to_string()), None); + + preview_mock.assert(); + setting_is_global_mock.assert(); + setting_mock.assert(); + setting_value_mock.assert(); + deploy_mock.assert_calls(0); + + let outcome = result.unwrap(); + assert_eq!(outcome.revision, None); + assert!(!outcome.created); + } + #[test] fn test_get_installation_id_when_manifest_has_id_should_return_id() { let (temp_dir, command, _mock_server, _manifest, _instance_manifest) =