···1414 sse::{self, SseEvent},
1515};
16161717-use crate::{core::LuminaryEngine, eyre_fmt, obtain};
1717+use crate::{
1818+ core::{LuminaryEngine, ProjectLogChannelMessage},
1919+ eyre_fmt, obtain,
2020+};
18211922/// Subscribes to a stream of log messages for a given project, sent as Server-Sent Events.
2023#[endpoint(
···3033 let engine = obtain!(depot, LuminaryEngine);
3134 let project = project.into_inner();
32353333- let mut stream = engine.logs_subscribe(project).await;
3636+ let mut stream = engine.clone().logs_subscribe(project).await;
34373538 sse::stream(
3639 res,
3740 async_stream::stream! {
3838- while let Some(bytes) = stream.next().await {
3939- match create_event(&bytes).wrap_err("Failed to create SSE event from log bytes") {
4141+ while let Some(message) = stream.next().await {
4242+ match create_event(message).wrap_err("Failed to create SSE event from log bytes") {
4043 Err(err) => log::error!("{}", eyre_fmt!(err)),
4144 Ok(event) => yield Ok::<SseEvent, Infallible>(event),
4245 }
···4649}
47504851/// Creates a Server-Sent Event from a chunk of log bytes.
4949-fn create_event(bytes: &[u8]) -> Result<SseEvent, Infallible> {
5050- let encoded = STANDARD.encode(bytes);
5151- return Ok(SseEvent::default().text(encoded));
5252+fn create_event(message: ProjectLogChannelMessage) -> Result<SseEvent, Infallible> {
5353+ return match message {
5454+ ProjectLogChannelMessage::Close(uuid) => Ok(SseEvent::default().id("close").text(uuid)),
5555+ ProjectLogChannelMessage::Write(uuid, bytes) => {
5656+ let encoded = STANDARD.encode(bytes);
5757+ Ok(SseEvent::default().id(uuid).text(encoded))
5858+ }
5959+ };
5260}
+17-11
packages/node/src/core/action.rs
···99 #[wrap_err("Failed to set action for service")]
1010 pub(super) async fn set_action(
1111 &self,
1212- project: &str,
1313- service: Option<&str>,
1212+ project: &String,
1313+ service: Option<&String>,
1414 action: LuminaryAction,
1515 ) -> Result<()> {
1616 // Get list of targets to update
···5252 async fn run(
5353 &self,
5454 action: LuminaryAction,
5555- project: &str,
5656- service: Option<&str>,
5555+ project: &String,
5656+ service: Option<&String>,
5757 mut args: Vec<&str>,
5858 ) -> Result<()> {
5959 self.set_action(project, service, action).await?;
···6363 }
64646565 let mut stream = self.cli(&project, args)?;
6666- while let Some(_) = stream.next().await {}
6666+ let sender = self.create_log_sender(project.clone()).await;
6767+6868+ while let Some(bytes) = stream.next().await {
6969+ sender.write(bytes?).await;
7070+ }
7171+6772 self.set_action(project, service, LuminaryAction::Idle).await?;
7373+ sender.close().await;
6874 return Ok(());
6975 }
70767177 /// Restarts the given project and optionally, a specific service within that project.
7278 #[wrap_err("Failed to restart project/service")]
7373- pub async fn restart(&self, project: &str, service: Option<&str>) -> Result<()> {
7979+ pub async fn restart(&self, project: &String, service: Option<&String>) -> Result<()> {
7480 self.run(LuminaryAction::Restarting, project, service, vec!["restart"])
7581 .await?;
7682 Ok(())
···78847985 /// Starts the given project and optionally, a specific service within that project.
8086 #[wrap_err("Failed to start project/service")]
8181- pub async fn start(&self, project: &str, service: Option<&str>) -> Result<()> {
8787+ pub async fn start(&self, project: &String, service: Option<&String>) -> Result<()> {
8288 self.run(LuminaryAction::Starting, project, service, vec!["up", "-d"])
8389 .await?;
8490 Ok(())
···86928793 /// Stops the given project and optionally, a specific service within that project.
8894 #[wrap_err("Failed to stop project/service")]
8989- pub async fn stop(&self, project: &str, service: Option<&str>) -> Result<()> {
9595+ pub async fn stop(&self, project: &String, service: Option<&String>) -> Result<()> {
9096 self.run(
9197 LuminaryAction::Stopping,
9298 project,
···99105100106 /// Recreates the given project and optionally, a specific service within that project.
101107 #[wrap_err("Failed to recreate project/service")]
102102- pub async fn recreate(&self, project: &str, service: Option<&str>) -> Result<()> {
108108+ pub async fn recreate(&self, project: &String, service: Option<&String>) -> Result<()> {
103109 self.stop(project, service).await?;
104110 self.start(project, service).await?;
105111 Ok(())
···107113108114 /// Pulls the latest images for the given project and optionally, a specific service within that project.
109115 #[wrap_err("Failed to pull project/service images")]
110110- pub async fn pull(&self, project: &str, service: Option<&str>) -> Result<()> {
116116+ pub async fn pull(&self, project: &String, service: Option<&String>) -> Result<()> {
111117 self.run(
112118 LuminaryAction::Pulling,
113119 project,
···120126121127 /// Builds the images for the given project and optionally, a specific service within that project.
122128 #[wrap_err("Failed to build project/service images")]
123123- pub async fn build(&self, project: &str, service: Option<&str>) -> Result<()> {
129129+ pub async fn build(&self, project: &String, service: Option<&String>) -> Result<()> {
124130 self.run(
125131 LuminaryAction::Building,
126132 project,
+101-41
packages/node/src/core/logs.rs
···11-use std::sync::Arc;
22-31use crate::{
44- core::{LuminaryEngine, LuminaryStatus, ProjectLogChannel},
22+ core::{LuminaryEngine, LuminaryStatus, ProjectLogChannel, ProjectLogChannelMessage},
53 eyre_fmt,
64};
75use bytes::{Bytes, BytesMut};
86use eyre::Context;
97use futures_util::{StreamExt, stream::BoxStream};
108use log::{debug, error};
1111-use tokio::sync::{RwLock, broadcast};
99+use tokio::sync::{broadcast};
1010+use uuid::Uuid;
12111312const EMPTY_LOGS_MESSAGE: &[u8] = b"No logs to show. Waiting for project to start...\n\r";
1313+const LOG_WORKER_STREAM_UUID: Uuid = Uuid::from_u128(0x0);
14141515impl LuminaryEngine {
1616 /// Creates a stream of [Bytes] for clients to subscribe to.
1717- pub async fn logs_subscribe<'a>(&'_ self, project: String) -> BoxStream<'a, Bytes> {
1818- // Obtain entry for the project, creating a new one if neccessary
1919- let ProjectLogChannel { channel, buffer } = self
2020- .log_channels
2121- .lock()
2222- .await
2323- .entry(project.clone())
2424- .or_insert_with(|| self.spawn_log_worker(project.clone()))
2525- .clone();
1717+ pub async fn logs_subscribe<'a>(self, project: String) -> BoxStream<'a, ProjectLogChannelMessage> {
1818+ let ProjectLogChannel { channel, state } = self.get_log_channel(&project).await;
26192720 return async_stream::stream! {
2821 // Surround with a block to drop read guard after reading buffer
2929- {
2222+ {
2323+ // Spawn log worker if it hasn't been spawned already.
2424+ // Use temporary read guard as spawn_log_worker needs a write guard
2525+ if !state.read().await.contains_key(&LOG_WORKER_STREAM_UUID) {
2626+ self.spawn_log_worker(project.clone()).await;
2727+ }
2828+3029 // Send previous logs in buffer to bring client up to date
3131- let bytes = &buffer.read().await;
3232- if !bytes.is_empty() {
3333- yield <BytesMut as Clone>::clone(&bytes).freeze()
3434- }
3030+ for (uuid, buffer) in state.read().await.iter() {
3131+ if !buffer.is_empty() {
3232+ yield ProjectLogChannelMessage::Write(*uuid, <BytesMut as Clone>::clone(&buffer).freeze());
3333+ }
3434+ }
3535 }
36363737 let mut receiver = channel.subscribe();
···4848 .boxed();
4949 }
50505151+ /// Creates a new log sender for the given project. This will send a close message when dropped.
5252+ pub async fn create_log_sender(&self, project: String) -> ProjectLogChannelSender {
5353+ let channel = self.get_log_channel(&project).await;
5454+5555+ ProjectLogChannelSender {
5656+ uuid: Uuid::new_v4(),
5757+ engine: self.clone(),
5858+ project: project,
5959+ channel,
6060+ }
6161+ }
6262+6363+ /// Obtain the [ProjectLogChannel] for the given project, creating a new one if neccessary
6464+ async fn get_log_channel(&self, project: &String) -> ProjectLogChannel {
6565+ return self
6666+ .log_channels
6767+ .lock()
6868+ .await
6969+ .entry(project.clone())
7070+ .or_default()
7171+ .clone();
7272+ }
7373+5174 /// Spawns a background worker that listens for logs sends them to clients.
5252- fn spawn_log_worker(&self, project: String) -> ProjectLogChannel {
7575+ async fn spawn_log_worker(&self, project: String) {
5376 let this = self.clone();
7777+7878+ let channel = self.get_log_channel(&project).await;
7979+ channel.state.write().await.insert(LOG_WORKER_STREAM_UUID, BytesMut::new());
54805555- let entry = ProjectLogChannel {
5656- channel: broadcast::channel(64).0,
5757- buffer: Arc::new(RwLock::new(BytesMut::new())),
5858- };
8181+ let mut sender = this.create_log_sender(project.clone()).await;
8282+ sender.uuid = LOG_WORKER_STREAM_UUID; // Use reserved UUID for log worker stream
59836060- let ProjectLogChannel { channel, buffer } = entry.clone();
6184 tokio::spawn(async move {
8585+6286 loop {
6387 debug!("Starting logs stream for project '{}'...", project);
6488 // Spawn docker compose process, yielding logs as they are recieved
···7296 match result.wrap_err("Error streaming logs for project") {
7397 Err(err) => error!("{}", eyre_fmt!(err)),
7498 Ok(bytes) => {
7575- let bytes = normalise_line_endings(&bytes);
7676-7777- // Update buffer with logs and send to subscribers
7878- buffer.write().await.extend_from_slice(&bytes);
7979- if channel.send(bytes).is_err() {
8080- // There are no subscribers, so clean up and stop the worker
8181- debug!("Cleaning up logs stream for project '{}'...", project);
8282- this.log_channels.lock().await.remove(&project);
8383- return;
9999+ if sender.write(bytes).await {
100100+ sender.close().await;
101101+ break;
84102 }
85103 }
86104 }
···92110 debug!("Docker compose logs process exited, waiting for event to trigger retry...");
9311194112 // Send a message to clients if there are no logs to show
9595- {
9696- let mut buffer = buffer.write().await;
9797- if buffer.is_empty() {
9898- buffer.extend_from_slice(EMPTY_LOGS_MESSAGE);
9999- let _ = channel.send(Bytes::from(EMPTY_LOGS_MESSAGE));
100100- }
113113+ if sender.is_empty().await {
114114+ sender.write(Bytes::from(EMPTY_LOGS_MESSAGE)).await;
101115 }
102116103117 loop {
···114128 debug!("Received event indicating project is running, restarting logs stream...");
115129116130 // Clear buffer to avoid sending old logs
117117- buffer.write().await.clear();
131131+ sender.clear().await;
118132 }
119133 });
134134+ }
135135+}
120136121121- return entry;
137137+/// A wrapper representing a given project log stream, to be multiplexed and sent to clients.
138138+#[derive(Debug)]
139139+pub struct ProjectLogChannelSender {
140140+ channel: ProjectLogChannel,
141141+ engine: LuminaryEngine,
142142+ project: String,
143143+ uuid: Uuid,
144144+}
145145+146146+impl ProjectLogChannelSender {
147147+ //// Sends logs to clients and updates internal buffer. Returns true if there are no subscribers to receive the logs.
148148+ pub async fn write(&self, bytes: Bytes) -> bool {
149149+ let bytes = normalise_line_endings(&bytes);
150150+151151+ // Update buffer with new bytes
152152+ self.channel.state.write().await.entry(self.uuid).or_default().extend_from_slice(&bytes);
153153+154154+ // Send bytes to clients, and record if there are no subscribers
155155+ return self.channel.channel.send(ProjectLogChannelMessage::Write(self.uuid, bytes)).is_err();
156156+ }
157157+158158+ /// Clears the internal buffer for this log stream.
159159+ pub async fn clear(&self) {
160160+ self.channel.state.write().await.entry(self.uuid).or_default().clear();
161161+ }
162162+163163+ /// Checks if the internal buffer for this log stream is empty.
164164+ pub async fn is_empty(&self) -> bool {
165165+ return self.channel.state.read().await.get(&self.uuid).map(|buffer| buffer.is_empty()).unwrap_or(true);
166166+ }
167167+168168+ /// Closes this log sender, removing its buffer and notifying clients.
169169+ pub async fn close(&self) {
170170+ debug!("Closing log sender '{}' for project '{}'", &self.uuid, &self.project);
171171+ let mut streams = self.channel.state.write().await;
172172+ streams.remove(&self.uuid);
173173+174174+ if self.channel.channel.send(ProjectLogChannelMessage::Close(self.uuid)).is_err() {
175175+ if streams.is_empty() {
176176+ debug!("No more log streams for project '{}', removing log channel...", &self.project);
177177+ self.engine.log_channels.lock().await.remove(&self.project);
178178+ }
179179+ } else if streams.is_empty() {
180180+ error!("Log channel for '{}' has no streams but still has subscribers, report this!", &self.project);
181181+ }
122182 }
123183}
124184
+20-3
packages/node/src/core/model.rs
···11//! This module defines the core data models used within the Luminary application.
2233-use std::{fmt::Display, sync::Arc};
33+use std::{collections::HashMap, fmt::Display, sync::Arc};
4455use bytes::{Bytes, BytesMut};
66use luminary_macros::hashmap_schema;
77use salvo::oapi::{BasicType, Components, Object, RefOr, Schema, ToSchema};
88use serde::{Deserialize, Serialize, ser::SerializeStruct};
99use tokio::sync::{RwLock, broadcast};
1010+use uuid::Uuid;
10111112use crate::schema_ref_or;
1213···180181/// This is created lazily when a client subscribes to logs for a project.
181182#[derive(Debug, Clone)]
182183pub struct ProjectLogChannel {
183183- pub channel: broadcast::Sender<Bytes>,
184184+ pub channel: broadcast::Sender<ProjectLogChannelMessage>,
184185 // Using an Arc here to allow the worker to keep a reference to the log buffer
185185- pub buffer: Arc<RwLock<BytesMut>>,
186186+ pub state: Arc<RwLock<HashMap<Uuid, BytesMut>>>,
187187+}
188188+189189+impl Default for ProjectLogChannel {
190190+ fn default() -> Self {
191191+ Self {
192192+ state: Arc::new(RwLock::new(HashMap::new())),
193193+ channel: broadcast::channel(64).0,
194194+ }
195195+ }
196196+}
197197+198198+/// A message type for multiplexing log streams.
199199+#[derive(Debug, Clone)]
200200+pub enum ProjectLogChannelMessage {
201201+ Write(Uuid, Bytes),
202202+ Close(Uuid),
186203}
187204188205/// The configuration for updating a project. Allows for multiple updates at once.
+3-3
packages/node/src/core/project.rs
···33333434 /// Validates a compose file by attempting to parse it and performing basic checks on the structure.
3535 #[wrap_err("Invalid compose file")]
3636- fn validate_compose(&self, compose: &str) -> Result<()> {
3636+ fn validate_compose(&self, compose: &String) -> Result<()> {
3737 let compose = serde_saphyr::from_str::<Compose>(compose).wrap_err("Failed to parse compose file")?;
38383939 if compose.services.is_empty() {
···4444 }
45454646 #[wrap_err("Failed to delete project")]
4747- pub async fn delete_project(&self, project: &str) -> Result<()> {
4747+ pub async fn delete_project(&self, project: &String) -> Result<()> {
4848 let (project_path, _) = self.get_path(project);
49495050 self.stop(project, None).await?;
···6363 }
64646565 /// Updates the given project by applying the provided patch
6666- pub async fn patch_project(&self, project: &str, patch: &LuminaryProjectPatch) -> Result<()> {
6666+ pub async fn patch_project(&self, project: &String, patch: &LuminaryProjectPatch) -> Result<()> {
6767 // Validate request
6868 if project.len() == 0 || patch.to.as_ref().is_some_and(|name| name.len() == 0) {
6969 bail!("Project name cannot be empty");