···11//! Manages retrieving and updating project compose files.
2233+use eyre::eyre;
34use salvo::Router;
45use salvo::Writer;
56use salvo::oapi::ToSchema;
···5657 depot: &mut Depot,
5758) -> LuminaryResponse<LuminaryProjectWithCompose> {
5859 let engine = obtain!(depot, LuminaryEngine);
6060+6161+ if project.len() == 0 || payload.to.as_ref().is_some_and(|name| name.len() == 0) {
6262+ Err(eyre!("Project name cannot be empty"))?;
6363+ }
6464+6565+ if let Some(compose) = &payload.compose {
6666+ engine.validate_compose(compose)?;
6767+ }
59686069 engine.wait_until_idle(&project, None).await?;
6170 engine.patch_project(&project, &payload.0).await?;
+7-3
packages/node/src/core/model.rs
···2121pub struct LuminaryServiceList<String, LuminaryService>;
22222323/// Represents a Luminary project, consisting of a docker compose project.
2424-///
2525-/// This is derived entirely from that state of its services.
2624#[derive(Debug, Clone, PartialEq)]
2725pub struct LuminaryProject {
2826 /// The name of this project
2927 pub name: String,
3028 /// A map of the services that make up this projects
3129 pub services: LuminaryServiceList,
3030+3131+ /// [true] if the compose file is invalid or has no services.
3232+ pub invalid: bool,
3233}
33343435impl LuminaryProject {
···5657 state.serialize_field("status", &self.status())?;
5758 state.serialize_field("busy", &self.busy())?;
5859 state.serialize_field("services", &self.services)?;
6060+ state.serialize_field("invalid", &self.invalid)?;
5961 state.end()
6062 }
6163}
···7375 .property("busy", Object::with_type(BasicType::Boolean))
7476 .required("busy")
7577 .property("services", LuminaryServiceList::to_schema(components))
7676- .required("services"),
7878+ .required("services")
7979+ .property("invalid", Object::with_type(BasicType::Boolean))
8080+ .required("invalid"),
7781 ))
7882 );
7983 }
+13
packages/node/src/core/project.rs
···11use std::path::{Path, PathBuf};
2233+use docker_compose_types::Compose;
34use eyre::{Context, Ok, Result};
45use futures_util::StreamExt;
56use luminary_macros::wrap_err;
···2829 }
29303031 return Ok(read_to_string(path).await.wrap_err("Failed to read file")?);
3232+ }
3333+3434+ /// Validates a compose file by attempting to parse it and performing basic checks on the structure.
3535+ #[wrap_err("Invalid compose file")]
3636+ pub fn validate_compose(&self, compose: &str) -> Result<()> {
3737+ let compose = serde_saphyr::from_str::<Compose>(compose).wrap_err("Failed to parse compose file")?;
3838+3939+ if compose.services.is_empty() {
4040+ eyre::bail!("Compose file must contain at least one service");
4141+ }
4242+4343+ return Ok(());
3144 }
32453346 /// Updates the given project by applying the provided patch
+56-46
packages/node/src/core/state.rs
···105105 }
106106 }
107107108108- self.load_from_docker(&mut list).await?;
109108 self.load_from_filesystem(&mut list).await?;
109109+ self.load_from_docker(&mut list).await?;
110110111111 list.0.retain(|_, project| {
112112 project.services.0.retain(|_, service| !service.stale);
113113- return !project.services.0.is_empty();
113113+ return project.invalid || !project.services.0.is_empty();
114114 });
115115116116 self.broadcast(list.clone()).await;
···125125 .wrap_err("Failed to list project directory contents")?;
126126127127 while let Some(entry) = entries.next_entry().await? {
128128- if let Err(err) = self.load_project_dir(entry.path(), list).await {
129129- error!("{}", eyre_fmt!(err));
128128+ let mut path = entry.path();
129129+130130+ if path.is_dir()
131131+ && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())
132132+ {
133133+ path.push(COMPOSE_FILENAME);
134134+ if path.exists() {
135135+ // Add project even if parsing the compose file fails
136136+ let project = list
137137+ .0
138138+ .entry(project_name.clone())
139139+ .or_insert_with(|| LuminaryProject {
140140+ name: project_name.clone(),
141141+ services: LuminaryServiceList::new(),
142142+ invalid: false,
143143+ });
144144+145145+ if let Err(err) = self.load_project_dir(project, path).await {
146146+ warn!("{}", eyre_fmt!(err));
147147+ }
148148+149149+ project.invalid = project.services.0.is_empty();
150150+ }
130151 }
131152 }
132153···134155 }
135156136157 /// Loads a single project from the filesystem if a compose file is found, merging it into the given state list.
137137- async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryProjectList) -> Result<()> {
138138- if path.is_dir()
139139- && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string())
140140- {
141141- path.push(COMPOSE_FILENAME);
142142- if path.exists() {
143143- let file = File::open(path)
144144- .await
145145- .wrap_err("Failed to open compose file")?
146146- .into_std()
147147- .await;
148148-149149- // Run this in a thread as it uses a blocking file reader instead of an async one
150150- let compose: Compose = tokio::task::spawn_blocking(move || {
151151- return serde_saphyr::from_reader(file);
152152- })
153153- .await
154154- .wrap_err("Compose deserialization failed.")?
155155- .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project_name))?;
158158+ async fn load_project_dir(&self, project: &mut LuminaryProject, path: PathBuf) -> Result<()> {
159159+ let file = File::open(path)
160160+ .await
161161+ .wrap_err("Failed to open compose file")?
162162+ .into_std()
163163+ .await;
156164157157- let project = list
158158- .0
159159- .entry(project_name.clone())
160160- .or_insert_with(|| LuminaryProject {
161161- name: project_name.clone(),
162162- services: LuminaryServiceList::new(),
163163- });
165165+ // Run this in a thread as it uses a blocking file reader instead of an async one
166166+ let compose: Compose = tokio::task::spawn_blocking(move || {
167167+ return serde_saphyr::from_reader(file);
168168+ })
169169+ .await
170170+ .wrap_err("Compose deserialization failed.")?
171171+ .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project.name))?;
164172165165- for (service_name, _) in compose.services.0 {
166166- let existing = project.services.0.get(&service_name);
167167- project.services.0.insert(
168168- service_name.clone(),
169169- LuminaryService {
170170- stale: false,
171171- orphan: false,
172172- action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle),
173173- status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down),
174174- identifier: LuminaryIdentifier::new(project_name.clone(), service_name),
175175- },
176176- );
177177- }
178178- }
173173+ for (service_name, _) in compose.services.0 {
174174+ let existing = project.services.0.get(&service_name);
175175+ project.services.0.insert(
176176+ service_name.clone(),
177177+ LuminaryService {
178178+ stale: false,
179179+ orphan: false,
180180+ action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle),
181181+ status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down),
182182+ identifier: LuminaryIdentifier::new(project.name.clone(), service_name),
183183+ },
184184+ );
179185 }
180186181187 return Ok(());
···218224 .or_insert_with(|| LuminaryProject {
219225 name: project_name.clone(),
220226 services: LuminaryServiceList::new(),
227227+ invalid: false,
221228 });
222229223230 let existing = project.services.0.get(&service_name);
224224- let orphan = existing.as_ref().map(|e| e.stale || e.orphan).unwrap_or(false);
231231+ let orphan = existing
232232+ .as_ref()
233233+ .map(|e| e.stale || e.orphan)
234234+ .unwrap_or(project.invalid);
225235226236 // If the service is down and the service is an orphan, remove it
227237 if status == LuminaryStatus::Down && orphan {
···237247 .map(|e| e.action)
238248 .unwrap_or(LuminaryAction::Idle),
239249 identifier: LuminaryIdentifier::new(project_name, service_name),
240240- stale: existing.is_none() && status != LuminaryStatus::Down,
250250+ stale: false,
241251 status,
242252 orphan,
243253 },