A focused Docker Compose management web application.
0
fork

Configure Feed

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

fix: fobid concurrent project patches

Brooke 2bc41b10 f1c27c9e

+116 -69
+7 -30
packages/node/src/api/project.rs
··· 8 8 Depot, 9 9 oapi::{endpoint, extract::PathParam}, 10 10 }; 11 - use serde::Deserialize; 12 11 use serde::Serialize; 13 12 14 13 use crate::core::LuminaryProject; 14 + use crate::core::LuminaryProjectPatch; 15 15 use crate::{api::response::LuminaryResponse, core::LuminaryEngine, obtain}; 16 16 17 17 /// Returns the router for compose related endpoints. 18 18 pub fn router() -> Router { 19 - return Router::new().get(get_project_endpoint).patch(put_compose); 19 + return Router::new().get(get_project_endpoint).patch(patch_compose); 20 20 } 21 21 22 22 /// Retrieves the compose file for a given project. ··· 50 50 51 51 /// Perform a configurable change to a project. 52 52 #[endpoint] 53 - pub async fn put_compose( 53 + pub async fn patch_compose( 54 54 project: PathParam<String>, 55 - payload: JsonBody<LuminaryProjectPatch>, 55 + mut payload: JsonBody<LuminaryProjectPatch>, 56 56 depot: &mut Depot, 57 57 ) -> LuminaryResponse<LuminaryProjectWithCompose> { 58 58 let engine = obtain!(depot, LuminaryEngine); 59 - let mut changed = false; 60 59 61 - if let Some(compose) = &payload.compose { 62 - engine.put_compose(&project, &compose).await?; 63 - changed = true; 64 - } 65 - 66 - if let Some(from) = &payload.from { 67 - engine.rename_project(from, &project).await?; 68 - changed = true; 69 - } 70 - 71 - if changed { 72 - engine.refresh().await?; 73 - } 74 - 75 - return get_project(engine, &project).await; 76 - } 77 - 78 - /// The payload for updating a project. Allows for multiple updates at once. 79 - #[derive(Debug, Clone, Deserialize, ToSchema)] 80 - struct LuminaryProjectPatch { 81 - /// If [Some], the new compose file for this project. If [None], the compose file will not be updated. 82 - compose: Option<String>, 83 - 84 - /// If [Some], renames the project with the given name. If [None], no rename will take place. 85 - from: Option<String>, 60 + engine.wait_until_idle(&project, None).await?; 61 + engine.patch_project(&project, &payload.0).await?; 62 + return get_project(engine, &payload.to.take().unwrap_or(project.0)).await; 86 63 }
+6 -1
packages/node/src/core/action.rs
··· 7 7 impl LuminaryEngine { 8 8 /// Updates the currently processing action for the given project and optionally, a specific service within that project. 9 9 #[wrap_err("Failed to set action for service")] 10 - async fn set_action(&self, project: &str, service: Option<&str>, action: LuminaryAction) -> Result<()> { 10 + pub(super) async fn set_action( 11 + &self, 12 + project: &str, 13 + service: Option<&str>, 14 + action: LuminaryAction, 15 + ) -> Result<()> { 11 16 // Get list of targets to update 12 17 let mut project_list = self.list.write().await; 13 18 let targets = match project_list.0.get_mut(project) {
+1 -1
packages/node/src/core/logs.rs
··· 102 102 103 103 loop { 104 104 match this 105 - .wait_until(&project, None, LuminaryStatus::Running) 105 + .wait_until_status(&project, None, LuminaryStatus::Running) 106 106 .await 107 107 .wrap_err("Error while waiting for project to restart") 108 108 {
+12 -1
packages/node/src/core/model.rs
··· 5 5 use bytes::{Bytes, BytesMut}; 6 6 use luminary_macros::hashmap_schema; 7 7 use salvo::oapi::{BasicType, Components, Object, RefOr, Schema, ToSchema}; 8 - use serde::{Serialize, ser::SerializeStruct}; 8 + use serde::{Deserialize, Serialize, ser::SerializeStruct}; 9 9 use tokio::sync::{RwLock, broadcast}; 10 10 11 11 use crate::schema_ref_or; ··· 169 169 Starting, 170 170 Pulling, 171 171 Building, 172 + Patching, 172 173 } 173 174 174 175 /// Stores the log channel and buffer for a project. ··· 179 180 // Using an Arc here to allow the worker to keep a reference to the log buffer 180 181 pub buffer: Arc<RwLock<BytesMut>>, 181 182 } 183 + 184 + /// The configuration for updating a project. Allows for multiple updates at once. 185 + #[derive(Debug, Clone, Deserialize, ToSchema)] 186 + pub struct LuminaryProjectPatch { 187 + /// If [Some], the new compose file for this project. If [None], the compose file will not be updated. 188 + pub compose: Option<String>, 189 + 190 + /// If [Some], renames the current to the given name. If [None], no rename will take place. 191 + pub to: Option<String>, 192 + }
+36 -12
packages/node/src/core/project.rs
··· 1 - use std::path::{self, Path, PathBuf}; 1 + use std::path::{Path, PathBuf}; 2 2 3 3 use eyre::{Context, Ok, Result}; 4 4 use futures_util::StreamExt; 5 5 use luminary_macros::wrap_err; 6 6 use tokio::fs::{self, read_to_string}; 7 7 8 - use crate::core::{COMPOSE_FILENAME, LuminaryEngine, LuminaryStatus}; 8 + use crate::core::{COMPOSE_FILENAME, LuminaryAction, LuminaryEngine, LuminaryProjectPatch, LuminaryStatus}; 9 9 10 10 impl LuminaryEngine { 11 11 /// Retrieves the paths for the given project directory and its compose file. ··· 30 30 return Ok(read_to_string(path).await.wrap_err("Failed to read file")?); 31 31 } 32 32 33 + /// Updates the given project by applying the provided patch 34 + pub async fn patch_project(&self, project: &str, patch: &LuminaryProjectPatch) -> Result<()> { 35 + self.set_action(project, None, LuminaryAction::Patching).await?; 36 + 37 + let (project_path, compose_path) = self.get_path(project); 38 + let mut changed = false; 39 + 40 + if let Some(compose) = &patch.compose { 41 + self.put_compose(&project_path, &compose_path, &compose).await?; 42 + changed = true; 43 + } 44 + 45 + if let Some(to) = &patch.to { 46 + self.rename_project(&project, &project_path, to).await?; 47 + changed = true; 48 + } 49 + 50 + self.set_action(project, None, LuminaryAction::Idle).await?; 51 + 52 + if changed { 53 + self.refresh().await?; 54 + } 55 + 56 + return Ok(()); 57 + } 58 + 33 59 /// Updates the docker compose file for a given project. 34 60 /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function. 35 61 #[wrap_err("Failed to update compose file")] 36 - pub async fn put_compose(&self, project: &str, compose: &str) -> Result<()> { 37 - let (project_path, compose_path) = self.get_path(project); 38 - 62 + async fn put_compose(&self, project_path: &PathBuf, compose_path: &PathBuf, compose: &str) -> Result<()> { 39 63 // Create project directory if it doesn't exist 40 64 fs::create_dir_all(&project_path) 41 65 .await ··· 52 76 /// Renames the project dirctory for a given project. Recreating the project if it was previously running. 53 77 /// WARNING: Does not automatically call `refresh`, make sure to do this after calling this function. 54 78 #[wrap_err("Failed to rename project")] 55 - pub async fn rename_project(&self, from: &str, to: &str) -> Result<()> { 56 - let src_path = path::absolute(self.get_path(from).0).wrap_err("Failed to get absolute path")?; 57 - let to_path = src_path.parent().unwrap().join(to); 79 + async fn rename_project(&self, from: &str, from_path: &PathBuf, to: &str) -> Result<()> { 80 + let to_path = from_path.parent().unwrap().join(to); 58 81 59 - if !src_path.exists() { 82 + if !from_path.exists() { 60 83 eyre::bail!("Project '{}' does not exist", from); 61 84 } 62 85 ··· 74 97 .any(|(_, s)| s.status != LuminaryStatus::Down); 75 98 76 99 if recreate { 77 - self.stop(&from, None).await?; 100 + // Use manual command as action is currently "patching" 101 + let mut stream = self.cli(&from, vec!["down", "--remove-orphans"])?; 102 + while let Some(_) = stream.next().await {} 78 103 }; 79 104 80 - fs::rename(&src_path, &to_path) 105 + fs::rename(&from_path, &to_path) 81 106 .await 82 107 .wrap_err("Failed to rename project directory")?; 83 108 84 109 if recreate { 85 - // Use manual command as the project wont be in the program state at this point 86 110 let mut stream = self.cli(&to, vec!["up", "-d"])?; 87 111 while let Some(_) = stream.next().await {} 88 112 }
+29 -2
packages/node/src/core/state.rs
··· 10 10 secret::ContainerSummaryStateEnum, 11 11 }; 12 12 use docker_compose_types::Compose; 13 - use eyre::{ContextCompat, Result, WrapErr}; 13 + use eyre::{ContextCompat, Result, WrapErr, bail}; 14 14 use futures_util::{StreamExt, stream::BoxStream}; 15 15 use log::{debug, error, warn}; 16 16 use luminary_macros::wrap_err; ··· 279 279 } 280 280 281 281 /// Waits until a given project or service reaches the desired status by listening to Docker events. 282 - pub(super) async fn wait_until( 282 + pub(super) async fn wait_until_status( 283 283 &self, 284 284 project: &String, 285 285 service: Option<&String>, ··· 311 311 312 312 warn!("Docker event stream ended, restarting..."); 313 313 tokio::time::sleep(std::time::Duration::from_secs(1)).await; 314 + } 315 + } 316 + 317 + /// Waits until a given project or service is no longer processing any actions. 318 + pub async fn wait_until_idle(&self, project: &String, service: Option<&String>) -> Result<()> { 319 + let mut stream = self.state_subscribe().await; 320 + 321 + loop { 322 + if let Some(list) = stream.next().await { 323 + if let Some(busy) = list.0.get(project).and_then(|p| { 324 + if service.is_none() { 325 + return Some(p.busy()); 326 + } else { 327 + return p 328 + .services 329 + .0 330 + .get(service?) 331 + .map(|s| s.action != LuminaryAction::Idle); 332 + } 333 + }) { 334 + if !busy { 335 + return Ok(()); 336 + } 337 + } else { 338 + bail!("Project or service does not exist"); 339 + } 340 + } 314 341 } 315 342 } 316 343 }
+16 -14
packages/panel/src/routes/(authenticated)/projects/[project]/+page.svelte
··· 17 17 let { data } = $props(); 18 18 19 19 // svelte-ignore state_referenced_locally 20 - let copied = $state({ 21 - name: project.name, 22 - compose: data.compose, 20 + let copy = $state({ 21 + compose: data.compose ?? "", 22 + name: project?.name ?? "", 23 23 }); 24 24 25 25 // Watch for changes to set unsaved state ··· 27 27 $effect(() => { 28 28 if (!project) return; 29 29 30 - unsaved = copied.name !== project.name || copied.compose !== data.compose; 30 + unsaved = copy.name !== project.name || copy.compose !== data.compose; 31 31 }); 32 32 33 33 function revert() { 34 - copied.compose = data.compose; 35 - copied.name = project.name; 34 + copy.compose = data.compose ?? ""; 35 + copy.name = project.name; 36 36 } 37 37 38 38 async function save() { 39 - const rename = copied.name !== project.name; 39 + const rename = copy.name !== project.name; 40 40 41 - await api.client.PATCH(`/api/project/{project}`, { 42 - params: { path: { project: rename ? copied.name : project.name } }, 41 + const response = await api.client.PATCH(`/api/project/{project}`, { 42 + params: { path: { project: project.name } }, 43 43 body: { 44 - compose: copied.compose === data.compose ? null : copied.compose, 45 - from: rename ? project.name : null, 44 + compose: copy.compose === data.compose ? null : copy.compose, 45 + to: rename ? copy.name : null, 46 46 }, 47 47 }); 48 48 49 + api.putProject(response.data!); 50 + 49 51 if (rename) { 50 - await goto(`/projects/${copied.name}${location.hash}`); 52 + await goto(`/projects/${copy.name}${location.hash}`); 51 53 return; 52 54 } 53 55 54 56 unsaved = false; 55 - data.compose = copied.compose; 57 + data.compose = copy.compose; 56 58 } 57 59 58 60 onMount(() => { ··· 85 87 </div> 86 88 </h1> 87 89 88 - <EditTabs bind:data={copied} tabs={[{ label: "status", icon: faCircleInfo, content: status }]} /> 90 + <EditTabs bind:data={copy} tabs={[{ label: "status", icon: faCircleInfo, content: status }]} /> 89 91 </div> 90 92 91 93 {#snippet status()}
+9 -8
packages/panel/src/routes/(authenticated)/projects/[project]/+page.ts
··· 1 1 import type { components } from "$lib/api/openapi"; 2 2 import type { LuminaryProject } from "$lib/api"; 3 3 import type { PageLoad } from "./$types"; 4 - import { error } from "@sveltejs/kit"; 5 4 import { api } from "$lib"; 6 5 7 6 type LuminaryProjectWithCompose = components["schemas"]["luminary_node.api.project.LuminaryProjectWithCompose"]; ··· 11 10 .GET("/api/project/{project}", { 12 11 params: { path: { project: params.project } }, 13 12 }) 14 - .catch(() => error(404, "Project not found")); 13 + .catch(() => undefined); 15 14 16 - const data = response.data! as LuminaryProject & Partial<LuminaryProjectWithCompose>; 17 - const compose = data.compose!; 18 - delete data.compose; 15 + if (response !== undefined) { 16 + const data = response.data! as LuminaryProject & Partial<LuminaryProjectWithCompose>; 17 + const compose = data.compose!; 18 + delete data.compose; 19 19 20 - // Update global project list immediately in case realtime patches haven't arrived yet 21 - api.putProject(data); 20 + // Update global project list immediately in case realtime patches haven't arrived yet 21 + api.putProject(data); 22 22 23 - return { compose }; 23 + return { compose }; 24 + } 24 25 };