A focused Docker Compose management web application.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: make robust aginst invalid compose files

Brooke 4f88d101 2bc41b10

+160 -114
+9
packages/node/src/api/project.rs
··· 1 1 //! Manages retrieving and updating project compose files. 2 2 3 + use eyre::eyre; 3 4 use salvo::Router; 4 5 use salvo::Writer; 5 6 use salvo::oapi::ToSchema; ··· 56 57 depot: &mut Depot, 57 58 ) -> LuminaryResponse<LuminaryProjectWithCompose> { 58 59 let engine = obtain!(depot, LuminaryEngine); 60 + 61 + if project.len() == 0 || payload.to.as_ref().is_some_and(|name| name.len() == 0) { 62 + Err(eyre!("Project name cannot be empty"))?; 63 + } 64 + 65 + if let Some(compose) = &payload.compose { 66 + engine.validate_compose(compose)?; 67 + } 59 68 60 69 engine.wait_until_idle(&project, None).await?; 61 70 engine.patch_project(&project, &payload.0).await?;
+7 -3
packages/node/src/core/model.rs
··· 21 21 pub struct LuminaryServiceList<String, LuminaryService>; 22 22 23 23 /// Represents a Luminary project, consisting of a docker compose project. 24 - /// 25 - /// This is derived entirely from that state of its services. 26 24 #[derive(Debug, Clone, PartialEq)] 27 25 pub struct LuminaryProject { 28 26 /// The name of this project 29 27 pub name: String, 30 28 /// A map of the services that make up this projects 31 29 pub services: LuminaryServiceList, 30 + 31 + /// [true] if the compose file is invalid or has no services. 32 + pub invalid: bool, 32 33 } 33 34 34 35 impl LuminaryProject { ··· 56 57 state.serialize_field("status", &self.status())?; 57 58 state.serialize_field("busy", &self.busy())?; 58 59 state.serialize_field("services", &self.services)?; 60 + state.serialize_field("invalid", &self.invalid)?; 59 61 state.end() 60 62 } 61 63 } ··· 73 75 .property("busy", Object::with_type(BasicType::Boolean)) 74 76 .required("busy") 75 77 .property("services", LuminaryServiceList::to_schema(components)) 76 - .required("services"), 78 + .required("services") 79 + .property("invalid", Object::with_type(BasicType::Boolean)) 80 + .required("invalid"), 77 81 )) 78 82 ); 79 83 }
+13
packages/node/src/core/project.rs
··· 1 1 use std::path::{Path, PathBuf}; 2 2 3 + use docker_compose_types::Compose; 3 4 use eyre::{Context, Ok, Result}; 4 5 use futures_util::StreamExt; 5 6 use luminary_macros::wrap_err; ··· 28 29 } 29 30 30 31 return Ok(read_to_string(path).await.wrap_err("Failed to read file")?); 32 + } 33 + 34 + /// Validates a compose file by attempting to parse it and performing basic checks on the structure. 35 + #[wrap_err("Invalid compose file")] 36 + pub fn validate_compose(&self, compose: &str) -> Result<()> { 37 + let compose = serde_saphyr::from_str::<Compose>(compose).wrap_err("Failed to parse compose file")?; 38 + 39 + if compose.services.is_empty() { 40 + eyre::bail!("Compose file must contain at least one service"); 41 + } 42 + 43 + return Ok(()); 31 44 } 32 45 33 46 /// Updates the given project by applying the provided patch
+56 -46
packages/node/src/core/state.rs
··· 105 105 } 106 106 } 107 107 108 - self.load_from_docker(&mut list).await?; 109 108 self.load_from_filesystem(&mut list).await?; 109 + self.load_from_docker(&mut list).await?; 110 110 111 111 list.0.retain(|_, project| { 112 112 project.services.0.retain(|_, service| !service.stale); 113 - return !project.services.0.is_empty(); 113 + return project.invalid || !project.services.0.is_empty(); 114 114 }); 115 115 116 116 self.broadcast(list.clone()).await; ··· 125 125 .wrap_err("Failed to list project directory contents")?; 126 126 127 127 while let Some(entry) = entries.next_entry().await? { 128 - if let Err(err) = self.load_project_dir(entry.path(), list).await { 129 - error!("{}", eyre_fmt!(err)); 128 + let mut path = entry.path(); 129 + 130 + if path.is_dir() 131 + && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()) 132 + { 133 + path.push(COMPOSE_FILENAME); 134 + if path.exists() { 135 + // Add project even if parsing the compose file fails 136 + let project = list 137 + .0 138 + .entry(project_name.clone()) 139 + .or_insert_with(|| LuminaryProject { 140 + name: project_name.clone(), 141 + services: LuminaryServiceList::new(), 142 + invalid: false, 143 + }); 144 + 145 + if let Err(err) = self.load_project_dir(project, path).await { 146 + warn!("{}", eyre_fmt!(err)); 147 + } 148 + 149 + project.invalid = project.services.0.is_empty(); 150 + } 130 151 } 131 152 } 132 153 ··· 134 155 } 135 156 136 157 /// Loads a single project from the filesystem if a compose file is found, merging it into the given state list. 137 - async fn load_project_dir(&self, mut path: PathBuf, list: &mut LuminaryProjectList) -> Result<()> { 138 - if path.is_dir() 139 - && let Some(project_name) = path.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()) 140 - { 141 - path.push(COMPOSE_FILENAME); 142 - if path.exists() { 143 - let file = File::open(path) 144 - .await 145 - .wrap_err("Failed to open compose file")? 146 - .into_std() 147 - .await; 148 - 149 - // Run this in a thread as it uses a blocking file reader instead of an async one 150 - let compose: Compose = tokio::task::spawn_blocking(move || { 151 - return serde_saphyr::from_reader(file); 152 - }) 153 - .await 154 - .wrap_err("Compose deserialization failed.")? 155 - .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project_name))?; 158 + async fn load_project_dir(&self, project: &mut LuminaryProject, path: PathBuf) -> Result<()> { 159 + let file = File::open(path) 160 + .await 161 + .wrap_err("Failed to open compose file")? 162 + .into_std() 163 + .await; 156 164 157 - let project = list 158 - .0 159 - .entry(project_name.clone()) 160 - .or_insert_with(|| LuminaryProject { 161 - name: project_name.clone(), 162 - services: LuminaryServiceList::new(), 163 - }); 165 + // Run this in a thread as it uses a blocking file reader instead of an async one 166 + let compose: Compose = tokio::task::spawn_blocking(move || { 167 + return serde_saphyr::from_reader(file); 168 + }) 169 + .await 170 + .wrap_err("Compose deserialization failed.")? 171 + .wrap_err_with(|| format!("Failed to deserialize compose file for {}", &project.name))?; 164 172 165 - for (service_name, _) in compose.services.0 { 166 - let existing = project.services.0.get(&service_name); 167 - project.services.0.insert( 168 - service_name.clone(), 169 - LuminaryService { 170 - stale: false, 171 - orphan: false, 172 - action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle), 173 - status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down), 174 - identifier: LuminaryIdentifier::new(project_name.clone(), service_name), 175 - }, 176 - ); 177 - } 178 - } 173 + for (service_name, _) in compose.services.0 { 174 + let existing = project.services.0.get(&service_name); 175 + project.services.0.insert( 176 + service_name.clone(), 177 + LuminaryService { 178 + stale: false, 179 + orphan: false, 180 + action: existing.map(|s| s.action).unwrap_or(LuminaryAction::Idle), 181 + status: existing.map(|s| s.status).unwrap_or(LuminaryStatus::Down), 182 + identifier: LuminaryIdentifier::new(project.name.clone(), service_name), 183 + }, 184 + ); 179 185 } 180 186 181 187 return Ok(()); ··· 218 224 .or_insert_with(|| LuminaryProject { 219 225 name: project_name.clone(), 220 226 services: LuminaryServiceList::new(), 227 + invalid: false, 221 228 }); 222 229 223 230 let existing = project.services.0.get(&service_name); 224 - let orphan = existing.as_ref().map(|e| e.stale || e.orphan).unwrap_or(false); 231 + let orphan = existing 232 + .as_ref() 233 + .map(|e| e.stale || e.orphan) 234 + .unwrap_or(project.invalid); 225 235 226 236 // If the service is down and the service is an orphan, remove it 227 237 if status == LuminaryStatus::Down && orphan { ··· 237 247 .map(|e| e.action) 238 248 .unwrap_or(LuminaryAction::Idle), 239 249 identifier: LuminaryIdentifier::new(project_name, service_name), 240 - stale: existing.is_none() && status != LuminaryStatus::Down, 250 + stale: false, 241 251 status, 242 252 orphan, 243 253 },
+7 -1
packages/panel/src/routes/(authenticated)/projects/+page.svelte
··· 73 73 {project.name} 74 74 </h2> 75 75 <div class="subtext"> 76 - {Object.keys(project.services).length} services {project.status} 76 + {#if project.invalid} 77 + <span style="color: var(--peach);">invalid compose</span> 78 + {/if} 79 + 80 + {#if Object.keys(project.services).length > 0} 81 + {Object.keys(project.services).length} services {project.status} 82 + {/if} 77 83 </div> 78 84 </a> 79 85 {/each}
+68 -64
packages/panel/src/routes/(authenticated)/projects/[project]/ProjectStatus.svelte
··· 18 18 </script> 19 19 20 20 <h2>Actions</h2> 21 - <div class="flexr gap-5 wrap"> 22 - <div> 23 - <PromiseButton 24 - style="outline" 25 - disabled={project.busy} 26 - loading={allAction === "starting"} 27 - onclick={() => 28 - api.client.POST("/api/project/{project}/start", { params: { path: { project: project.name } } })} 29 - > 30 - {#snippet children(loading)} 31 - <div class="flexr center gap-10"> 32 - {#if !loading}<Fa icon={faPlay} />{/if} 33 - Start All 34 - </div> 35 - {/snippet} 36 - </PromiseButton> 37 - </div> 38 - <div> 39 - <PromiseButton 40 - style="outline" 41 - disabled={project.busy} 42 - loading={allAction === "restarting"} 43 - onclick={() => 44 - api.client.POST("/api/project/{project}/restart", { params: { path: { project: project.name } } })} 45 - > 46 - {#snippet children(loading)} 47 - <div class="flexr center gap-10"> 48 - {#if !loading}<Fa icon={faArrowsRotate} />{/if} 49 - Restart All 50 - </div> 51 - {/snippet} 52 - </PromiseButton> 53 - </div> 54 - <div> 55 - <PromiseButton 56 - style="outline" 57 - disabled={project.busy} 58 - loading={allAction === "stopping"} 59 - onclick={() => 60 - api.client.POST("/api/project/{project}/stop", { params: { path: { project: project.name } } })} 61 - > 62 - {#snippet children(loading)} 63 - <div class="flexr center gap-10"> 64 - {#if !loading}<Fa icon={faStop} />{/if} 65 - Stop All 66 - </div> 67 - {/snippet} 68 - </PromiseButton> 69 - </div> 70 - <div> 71 - <PromiseButton 72 - style="outline" 73 - disabled={project.busy} 74 - onclick={() => 75 - api.client.POST("/api/project/{project}/recreate", { params: { path: { project: project.name } } })} 76 - > 77 - {#snippet children(loading)} 78 - <div class="flexr center gap-10"> 79 - {#if !loading}<Fa icon={faRocket} />{/if} 80 - Recreate All 81 - </div> 82 - {/snippet} 83 - </PromiseButton> 21 + {#if project.invalid} 22 + <div>You must fix the <a href="#compose">compose file</a> to trigger actions.</div> 23 + {:else} 24 + <div class="flexr gap-5 wrap"> 25 + <div> 26 + <PromiseButton 27 + style="outline" 28 + disabled={project.busy} 29 + loading={allAction === "starting"} 30 + onclick={() => 31 + api.client.POST("/api/project/{project}/start", { params: { path: { project: project.name } } })} 32 + > 33 + {#snippet children(loading)} 34 + <div class="flexr center gap-10"> 35 + {#if !loading}<Fa icon={faPlay} />{/if} 36 + Start All 37 + </div> 38 + {/snippet} 39 + </PromiseButton> 40 + </div> 41 + <div> 42 + <PromiseButton 43 + style="outline" 44 + disabled={project.busy} 45 + loading={allAction === "restarting"} 46 + onclick={() => 47 + api.client.POST("/api/project/{project}/restart", { params: { path: { project: project.name } } })} 48 + > 49 + {#snippet children(loading)} 50 + <div class="flexr center gap-10"> 51 + {#if !loading}<Fa icon={faArrowsRotate} />{/if} 52 + Restart All 53 + </div> 54 + {/snippet} 55 + </PromiseButton> 56 + </div> 57 + <div> 58 + <PromiseButton 59 + style="outline" 60 + disabled={project.busy} 61 + loading={allAction === "stopping"} 62 + onclick={() => 63 + api.client.POST("/api/project/{project}/stop", { params: { path: { project: project.name } } })} 64 + > 65 + {#snippet children(loading)} 66 + <div class="flexr center gap-10"> 67 + {#if !loading}<Fa icon={faStop} />{/if} 68 + Stop All 69 + </div> 70 + {/snippet} 71 + </PromiseButton> 72 + </div> 73 + <div> 74 + <PromiseButton 75 + style="outline" 76 + disabled={project.busy} 77 + onclick={() => 78 + api.client.POST("/api/project/{project}/recreate", { params: { path: { project: project.name } } })} 79 + > 80 + {#snippet children(loading)} 81 + <div class="flexr center gap-10"> 82 + {#if !loading}<Fa icon={faRocket} />{/if} 83 + Recreate All 84 + </div> 85 + {/snippet} 86 + </PromiseButton> 87 + </div> 84 88 </div> 85 - </div> 89 + {/if} 86 90 87 91 <h2>Services</h2> 88 92