Skip to content

Commit bd4fecb

Browse files
authored
Merge pull request #144 from TryCli-Studio/copilot/sub-pr-141-another-one
Fix legacy container restoration after dependency upgrade
2 parents 889718d + b74d40e commit bd4fecb

2 files changed

Lines changed: 203 additions & 64 deletions

File tree

server/src/handlers/project.rs

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use axum::{
77
Json, Router,
88
};
99
use bollard::exec::{CreateExecOptions, StartExecResults};
10-
use bollard::image::{CreateImageOptions, RemoveImageOptions};
10+
use bollard::image::{CreateImageOptions, RemoveImageOptions};
11+
use bollard::container::ListContainersOptions;
1112
use tower_sessions::Session;
1213
use uuid::Uuid;
1314
use serde::Deserialize;
@@ -335,6 +336,53 @@ pub async fn publish_handler(
335336
Ok(Json("Published!".to_string()))
336337
}
337338

339+
/// Helper function to find an existing viewer container for a project
340+
/// This is used to reuse containers for old projects that don't have session_id labels
341+
async fn find_existing_viewer_container(state: &AppState, project_slug: &str, owner_id: i64) -> Option<String> {
342+
// First, check in-memory sessions for a match
343+
{
344+
let sessions = state.lock_sessions();
345+
for (session_id, ctx) in sessions.iter() {
346+
if ctx.project_owner_id == Some(owner_id)
347+
&& ctx.project_slug.as_deref() == Some(project_slug)
348+
&& !ctx.container_name.is_empty()
349+
&& ctx.container_name != "INITIALIZING" {
350+
return Some(session_id.clone());
351+
}
352+
}
353+
}
354+
355+
// If not in memory, check Docker containers
356+
// First try with project_slug label (new containers)
357+
let filters_with_slug = HashMap::from([
358+
("label".to_string(), vec![
359+
"managed_by=TryCli Studio".to_string(),
360+
"container_type=viewer".to_string(),
361+
format!("project_owner_id={}", owner_id),
362+
format!("project_slug={}", project_slug),
363+
])
364+
]);
365+
366+
let opts = ListContainersOptions {
367+
all: false,
368+
filters: filters_with_slug,
369+
..Default::default()
370+
};
371+
372+
if let Ok(containers) = state.docker.list_containers(Some(opts)).await {
373+
if let Some(container) = containers.first() {
374+
// Extract session_id from labels
375+
if let Some(labels) = &container.labels {
376+
if let Some(session_id) = labels.get("session_id") {
377+
return Some(session_id.clone());
378+
}
379+
}
380+
}
381+
}
382+
383+
None
384+
}
385+
338386
pub async fn get_project(
339387
Path((username, slug)): Path<(String, String)>,
340388
State(state): State<AppState>,
@@ -477,23 +525,32 @@ pub async fn get_project(
477525
}
478526

479527
// 7. Construct JSON response
480-
// Generate the session ID here, but DO NOT spawn Docker yet.
481-
let session_id = Uuid::new_v4().to_string();
482-
483-
{
484-
let mut map = state.lock_sessions();
485-
map.insert(session_id.clone(), SessionContext {
486-
container_name: String::new(), // Empty indicates "Not Started"
487-
pending_image_tag: Some(image_tag), // Store tag for WS handler
488-
shell,
489-
owner_id: None,
490-
project_owner_id: Some(owner_id),
491-
is_publishing: false,
492-
project_slug: Some(slug),
493-
created_at: std::time::Instant::now(),
494-
is_ws_connected: false,
495-
});
496-
}
528+
// Check if there's an existing viewer container for this project
529+
let session_id = match find_existing_viewer_container(&state, &slug, owner_id).await {
530+
Some(existing_session_id) => {
531+
tracing::info!("Reusing existing session {} for project {}/{}", existing_session_id, username, slug);
532+
existing_session_id
533+
}
534+
None => {
535+
// Generate a new session ID and prepare for lazy container spawn
536+
let new_session_id = Uuid::new_v4().to_string();
537+
538+
let mut map = state.lock_sessions();
539+
map.insert(new_session_id.clone(), SessionContext {
540+
container_name: String::new(), // Empty indicates "Not Started"
541+
pending_image_tag: Some(image_tag.clone()), // Store tag for WS handler
542+
shell: shell.clone(),
543+
owner_id: None,
544+
project_owner_id: Some(owner_id),
545+
is_publishing: false,
546+
project_slug: Some(slug.clone()),
547+
created_at: std::time::Instant::now(),
548+
is_ws_connected: false,
549+
});
550+
551+
new_session_id
552+
}
553+
};
497554

498555
let mut response_json = serde_json::json!({
499556
"markdown": markdown,

server/src/services/websocket.rs

Lines changed: 128 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ fn create_container_labels(
4747
labels
4848
}
4949

50+
/// Calculate an approximate creation time based on container's created timestamp
51+
/// This converts a Unix timestamp to an Instant for session tracking
52+
fn calculate_session_created_at(created_ts: Option<i64>) -> std::time::Instant {
53+
if let Some(created_ts) = created_ts {
54+
let now = std::time::SystemTime::now()
55+
.duration_since(std::time::UNIX_EPOCH)
56+
.unwrap()
57+
.as_secs() as i64;
58+
let age_secs = now - created_ts;
59+
60+
std::time::Instant::now()
61+
.checked_sub(std::time::Duration::from_secs(age_secs.max(0) as u64))
62+
.unwrap_or_else(|| std::time::Instant::now())
63+
} else {
64+
std::time::Instant::now()
65+
}
66+
}
67+
5068
/// Restore sessions from existing Docker containers on server startup
5169
/// This allows pre-existing containers to be reconnected after server restart
5270
pub async fn restore_sessions_from_containers(state: &AppState) {
@@ -72,47 +90,70 @@ pub async fn restore_sessions_from_containers(state: &AppState) {
7290
let project_owner_id = labels.get("project_owner_id").and_then(|s| s.parse::<i64>().ok());
7391
let project_slug = labels.get("project_slug").map(|s| s.clone());
7492

75-
if let (Some(session_id), Some(names)) = (session_id, container.names) {
76-
let container_name = names.first()
77-
.map(|n| n.trim_start_matches('/').to_string())
78-
.unwrap_or_default();
93+
let container_name = container.names
94+
.as_ref()
95+
.and_then(|names| names.first())
96+
.map(|n| n.trim_start_matches('/').to_string())
97+
.unwrap_or_default();
98+
99+
if container_name.is_empty() {
100+
continue;
101+
}
102+
103+
// Handle new containers (with session_id label)
104+
if let Some(session_id) = session_id {
105+
let mut map = state.lock_sessions();
79106

80-
if !container_name.is_empty() {
81-
// Restore session to in-memory map
107+
if !map.contains_key(&session_id) {
108+
let created_at = calculate_session_created_at(container.created);
109+
110+
map.insert(session_id.clone(), SessionContext {
111+
container_name: container_name.clone(),
112+
shell,
113+
pending_image_tag: None,
114+
owner_id,
115+
project_owner_id,
116+
is_publishing: false,
117+
project_slug,
118+
created_at,
119+
is_ws_connected: false,
120+
});
121+
restored += 1;
122+
tracing::info!("Restored session {} with container {}", session_id, container_name);
123+
}
124+
} else {
125+
// Handle legacy containers (without session_id label)
126+
// Extract UUID from container name for use as session_id
127+
let legacy_session_id = if container_name.starts_with("trycli-studio-viewer-") {
128+
container_name.strip_prefix("trycli-studio-viewer-").map(|s| s.to_string())
129+
} else if container_name.starts_with("trycli-studio-session-") {
130+
container_name.strip_prefix("trycli-studio-session-").map(|s| s.to_string())
131+
} else {
132+
None
133+
};
134+
135+
if let Some(legacy_session_id) = legacy_session_id {
82136
let mut map = state.lock_sessions();
83137

84-
// Only restore if not already present (shouldn't happen, but be defensive)
85-
if !map.contains_key(&session_id) {
86-
// Calculate elapsed time from container creation
87-
let created_at = if let Some(created_ts) = container.created {
88-
// Container age in seconds
89-
let now = std::time::SystemTime::now()
90-
.duration_since(std::time::UNIX_EPOCH)
91-
.unwrap()
92-
.as_secs() as i64;
93-
let age_secs = now - created_ts;
94-
95-
// Set created_at to approximate original time
96-
std::time::Instant::now()
97-
.checked_sub(std::time::Duration::from_secs(age_secs.max(0) as u64))
98-
.unwrap_or_else(|| std::time::Instant::now())
99-
} else {
100-
std::time::Instant::now()
101-
};
138+
if !map.contains_key(&legacy_session_id) {
139+
let created_at = calculate_session_created_at(container.created);
140+
141+
// For legacy containers, we don't know the exact metadata
142+
// Set reasonable defaults based on container type
102143

103-
map.insert(session_id.clone(), SessionContext {
144+
map.insert(legacy_session_id.clone(), SessionContext {
104145
container_name: container_name.clone(),
105146
shell,
106147
pending_image_tag: None,
107-
owner_id,
108-
project_owner_id,
148+
owner_id: None, // Unknown for legacy containers
149+
project_owner_id: None, // Unknown for legacy containers
109150
is_publishing: false,
110-
project_slug,
151+
project_slug: None, // Unknown for legacy containers
111152
created_at,
112-
is_ws_connected: false, // Will be set to true on reconnection
153+
is_ws_connected: false,
113154
});
114155
restored += 1;
115-
tracing::info!("Restored session {} with container {}", session_id, container_name);
156+
tracing::info!("Restored legacy session {} from container {}", legacy_session_id, container_name);
116157
}
117158
}
118159
}
@@ -171,7 +212,8 @@ pub async fn ws_handler(
171212
/// Attempt to restore a specific session from Docker containers
172213
/// This is called when a client tries to connect to a session that isn't in memory
173214
async fn restore_specific_session(state: &AppState, session_id: &str) {
174-
let filters = HashMap::from([
215+
// First, try to find a container with the session_id label (new containers)
216+
let filters_with_label = HashMap::from([
175217
("label".to_string(), vec![
176218
"managed_by=TryCli Studio".to_string(),
177219
format!("session_id={}", session_id)
@@ -180,7 +222,7 @@ async fn restore_specific_session(state: &AppState, session_id: &str) {
180222

181223
let opts = ListContainersOptions {
182224
all: false, // Only running containers
183-
filters,
225+
filters: filters_with_label,
184226
..Default::default()
185227
};
186228

@@ -198,20 +240,7 @@ async fn restore_specific_session(state: &AppState, session_id: &str) {
198240
.unwrap_or_default();
199241

200242
if !container_name.is_empty() {
201-
// Calculate elapsed time from container creation
202-
let created_at = if let Some(created_ts) = container.created {
203-
let now = std::time::SystemTime::now()
204-
.duration_since(std::time::UNIX_EPOCH)
205-
.unwrap()
206-
.as_secs() as i64;
207-
let age_secs = now - created_ts;
208-
209-
std::time::Instant::now()
210-
.checked_sub(std::time::Duration::from_secs(age_secs.max(0) as u64))
211-
.unwrap_or_else(|| std::time::Instant::now())
212-
} else {
213-
std::time::Instant::now()
214-
};
243+
let created_at = calculate_session_created_at(container.created);
215244

216245
let mut map = state.lock_sessions();
217246
map.insert(session_id.to_string(), SessionContext {
@@ -232,6 +261,59 @@ async fn restore_specific_session(state: &AppState, session_id: &str) {
232261
}
233262
}
234263
}
264+
265+
// If not found, try to find a legacy container by matching container name
266+
// Legacy containers have UUID in name: trycli-studio-viewer-{uuid} or trycli-studio-session-{uuid}
267+
let legacy_container_names = vec![
268+
format!("trycli-studio-viewer-{}", session_id),
269+
format!("trycli-studio-session-{}", session_id),
270+
];
271+
272+
for legacy_name in legacy_container_names {
273+
// Try to find container by exact name match
274+
let filters_by_name = HashMap::from([
275+
("label".to_string(), vec!["managed_by=TryCli Studio".to_string()]),
276+
("name".to_string(), vec![legacy_name.clone()])
277+
]);
278+
279+
let opts = ListContainersOptions {
280+
all: false,
281+
filters: filters_by_name,
282+
..Default::default()
283+
};
284+
285+
if let Ok(containers) = state.docker.list_containers(Some(opts)).await {
286+
if let Some(container) = containers.first() {
287+
let labels = container.labels.as_ref();
288+
let shell = labels
289+
.and_then(|l| l.get("shell"))
290+
.map(|s| s.clone())
291+
.unwrap_or_else(|| "/bin/bash".to_string());
292+
293+
let container_name = container.names.as_ref()
294+
.and_then(|names| names.first())
295+
.map(|n| n.trim_start_matches('/').to_string())
296+
.unwrap_or(legacy_name.clone());
297+
298+
let created_at = calculate_session_created_at(container.created);
299+
300+
let mut map = state.lock_sessions();
301+
map.insert(session_id.to_string(), SessionContext {
302+
container_name: container_name.clone(),
303+
shell,
304+
pending_image_tag: None,
305+
owner_id: None, // Unknown for legacy containers
306+
project_owner_id: None,
307+
is_publishing: false,
308+
project_slug: None,
309+
created_at,
310+
is_ws_connected: false,
311+
});
312+
tracing::info!("Restored legacy session {} from container {}", session_id, container_name);
313+
return;
314+
}
315+
}
316+
}
235317
}
236318

237319
async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: String, user_id: Option<i64>) {

0 commit comments

Comments
 (0)