Add workspace debug endpoints for template development

Add three new API endpoints to help debug init script issues:
- GET /api/workspaces/:id/debug - Container state info (dirs, sizes, etc.)
- GET /api/workspaces/:id/init-log - Read init script log from container
- POST /api/workspaces/:id/rerun-init - Re-run init script without rebuild

These enable faster iteration when developing workspace templates by
allowing developers to inspect container state and re-run init scripts
without waiting for full container rebuilds.
This commit is contained in:
Thomas Marchand
2026-01-15 10:32:25 +00:00
parent 7e74e77a66
commit 0353c8eeea
2 changed files with 485 additions and 0 deletions

View File

@@ -197,6 +197,140 @@ Opens an interactive PTY shell session via WebSocket.
---
## Debug Endpoints (Template Development)
These endpoints help debug init script issues when developing workspace templates.
### Get Debug Info
```
GET /api/workspaces/:id/debug
```
Returns detailed information about the container state, useful for understanding why an init script might be failing.
**Response**:
```json
{
"id": "uuid",
"name": "minecraft",
"status": "error",
"path": "/root/.openagent/containers/minecraft",
"path_exists": true,
"size_bytes": 1234567890,
"directories": [
{"path": "bin", "exists": true, "file_count": 156},
{"path": "usr", "exists": true, "file_count": 12},
{"path": "etc", "exists": true, "file_count": 45},
{"path": "var", "exists": true, "file_count": 8},
{"path": "var/log", "exists": true, "file_count": 3},
{"path": "root", "exists": true, "file_count": 2},
{"path": "tmp", "exists": true, "file_count": 0}
],
"has_bash": true,
"init_script_exists": false,
"init_script_modified": null,
"distro": "ubuntu-noble",
"last_error": "Init script failed: E: Unable to correct problems..."
}
```
| Field | Type | Description |
|-------|------|-------------|
| `path_exists` | boolean | Whether the container directory exists |
| `size_bytes` | number | Total size of container in bytes |
| `directories` | array | Key directories and their file counts |
| `has_bash` | boolean | Whether `/bin/bash` is available |
| `init_script_exists` | boolean | Whether the init script file exists |
| `init_script_modified` | string | Last modification time of init script |
| `last_error` | string | Error message from last build attempt |
### Get Init Script Log
```
GET /api/workspaces/:id/init-log
```
Reads the init script log from `/var/log/openagent-init.log` inside the container.
**Response**:
```json
{
"exists": true,
"content": "Starting init script\nRunning apt-get update...\n...",
"total_lines": 1234,
"log_path": "/var/log/openagent-init.log"
}
```
**Note**: Returns the last 500 lines if the log is larger.
### Re-run Init Script
```
POST /api/workspaces/:id/rerun-init
```
Re-runs the init script without rebuilding the container from scratch. This is much faster for iterating on init script development.
**Requirements**:
- Workspace must be `chroot` type
- Container must already exist (debootstrap completed)
- Workspace must have an init script configured
**Response**:
```json
{
"success": false,
"exit_code": 1,
"stdout": "Starting init script\nRunning apt-get update...\n...",
"stderr": "",
"duration_secs": 45.3
}
```
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Whether the script completed successfully |
| `exit_code` | number | Exit code from the script |
| `stdout` | string | Standard output from the script |
| `stderr` | string | Standard error from the script |
| `duration_secs` | number | How long the script took to run |
### Debug Workflow Example
```bash
# 1. Check current container state
curl "http://localhost:3000/api/workspaces/{id}/debug" \
-H "Authorization: Bearer <token>"
# 2. View the init script log
curl "http://localhost:3000/api/workspaces/{id}/init-log" \
-H "Authorization: Bearer <token>"
# 3. Update the template with a fix
curl -X PUT "http://localhost:3000/api/library/workspace-template/my-template" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"init_script": "#!/bin/bash\n# fixed script..."}'
# 4. Update workspace to use new init script
curl -X PUT "http://localhost:3000/api/workspaces/{id}" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"init_script": "#!/bin/bash\n# fixed script..."}'
# 5. Re-run the init script (fast iteration)
curl -X POST "http://localhost:3000/api/workspaces/{id}/rerun-init" \
-H "Authorization: Bearer <token>"
# 6. If it works, do a full rebuild to verify
curl -X POST "http://localhost:3000/api/workspaces/{id}/build?rebuild=true" \
-H "Authorization: Bearer <token>"
```
---
## Workspace Templates
Templates are stored in the library and define reusable workspace configurations.

View File

@@ -33,6 +33,10 @@ pub fn routes() -> Router<Arc<super::routes::AppState>> {
.route("/:id/build", post(build_workspace))
.route("/:id/sync", post(sync_workspace))
.route("/:id/exec", post(exec_workspace_command))
// Debug endpoints for template development
.route("/:id/debug", get(get_workspace_debug))
.route("/:id/rerun-init", post(rerun_init_script))
.route("/:id/init-log", get(get_init_log))
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -771,6 +775,74 @@ pub struct ExecCommandResponse {
pub timed_out: bool,
}
// ─────────────────────────────────────────────────────────────────────────────
// Debug Types (for template development)
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct WorkspaceDebugInfo {
/// Workspace ID
pub id: Uuid,
/// Workspace name
pub name: String,
/// Current status
pub status: WorkspaceStatus,
/// Container/workspace path
pub path: String,
/// Whether the container directory exists
pub path_exists: bool,
/// Size of the container in bytes (if applicable)
pub size_bytes: Option<u64>,
/// Key directories that exist in the container
pub directories: Vec<DirectoryInfo>,
/// Whether bash is available
pub has_bash: bool,
/// Whether the init script file exists in the container
pub init_script_exists: bool,
/// Last modification time of init script
pub init_script_modified: Option<String>,
/// Distro information
pub distro: Option<String>,
/// Any error message from last build
pub last_error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DirectoryInfo {
/// Directory path
pub path: String,
/// Whether it exists
pub exists: bool,
/// Approximate file count (if exists)
pub file_count: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct InitLogResponse {
/// Whether the log file exists
pub exists: bool,
/// Log content (last N lines)
pub content: String,
/// Total lines in log
pub total_lines: Option<u32>,
/// Log file path inside container
pub log_path: String,
}
#[derive(Debug, Serialize)]
pub struct RerunInitResponse {
/// Whether the rerun was successful
pub success: bool,
/// Exit code from the script
pub exit_code: i32,
/// Standard output from the script
pub stdout: String,
/// Standard error from the script
pub stderr: String,
/// Execution time in seconds
pub duration_secs: f64,
}
/// POST /api/workspaces/:id/exec - Execute a command in a workspace.
async fn exec_workspace_command(
State(state): State<Arc<super::routes::AppState>>,
@@ -953,6 +1025,285 @@ async fn exec_workspace_command(
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Debug Endpoints (for template development)
// ─────────────────────────────────────────────────────────────────────────────
/// GET /api/workspaces/:id/debug - Get debug information about a workspace container.
///
/// Returns detailed information about the container state useful for debugging
/// init script issues: directory structure, file existence, sizes, etc.
async fn get_workspace_debug(
State(state): State<Arc<super::routes::AppState>>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<WorkspaceDebugInfo>, (StatusCode, String)> {
let workspace = state
.workspaces
.get(id)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?;
let path = &workspace.path;
let path_exists = path.exists();
// Calculate container size (only for chroot workspaces)
let size_bytes = if workspace.workspace_type == WorkspaceType::Chroot && path_exists {
// Use du command for quick size calculation
let output = tokio::process::Command::new("du")
.args(["-sb", &path.to_string_lossy()])
.output()
.await
.ok();
output.and_then(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.split_whitespace().next()?.parse::<u64>().ok()
})
} else {
None
};
// Check key directories
let key_dirs = ["bin", "usr", "etc", "var", "var/log", "root", "tmp"];
let directories: Vec<DirectoryInfo> = key_dirs
.iter()
.map(|dir| {
let full_path = path.join(dir);
let exists = full_path.exists() && full_path.is_dir();
let file_count = if exists {
std::fs::read_dir(&full_path)
.map(|entries| entries.count() as u32)
.ok()
} else {
None
};
DirectoryInfo {
path: dir.to_string(),
exists,
file_count,
}
})
.collect();
// Check for bash
let has_bash = path.join("bin/bash").exists() || path.join("usr/bin/bash").exists();
// Check for init script
let init_script_path = path.join("openagent-init.sh");
let init_script_exists = init_script_path.exists();
let init_script_modified = if init_script_exists {
std::fs::metadata(&init_script_path)
.ok()
.and_then(|m| m.modified().ok())
.map(|t| {
chrono::DateTime::<chrono::Utc>::from(t)
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string()
})
} else {
None
};
Ok(Json(WorkspaceDebugInfo {
id: workspace.id,
name: workspace.name.clone(),
status: workspace.status.clone(),
path: path.to_string_lossy().to_string(),
path_exists,
size_bytes,
directories,
has_bash,
init_script_exists,
init_script_modified,
distro: workspace.distro.clone(),
last_error: workspace.error_message.clone(),
}))
}
/// GET /api/workspaces/:id/init-log - Get the init script log from inside the container.
///
/// Reads /var/log/openagent-init.log from inside the container to show what
/// the init script has logged. Useful for debugging template issues.
async fn get_init_log(
State(state): State<Arc<super::routes::AppState>>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<InitLogResponse>, (StatusCode, String)> {
let workspace = state
.workspaces
.get(id)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?;
let log_path = "/var/log/openagent-init.log";
let host_log_path = workspace.path.join("var/log/openagent-init.log");
if !host_log_path.exists() {
return Ok(Json(InitLogResponse {
exists: false,
content: String::new(),
total_lines: None,
log_path: log_path.to_string(),
}));
}
// Read the log file
let content = tokio::fs::read_to_string(&host_log_path)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to read log file: {}", e),
)
})?;
let lines: Vec<&str> = content.lines().collect();
let total_lines = lines.len() as u32;
// Return last 500 lines max
let start = if lines.len() > 500 { lines.len() - 500 } else { 0 };
let truncated_content = lines[start..].join("\n");
Ok(Json(InitLogResponse {
exists: true,
content: truncated_content,
total_lines: Some(total_lines),
log_path: log_path.to_string(),
}))
}
/// POST /api/workspaces/:id/rerun-init - Re-run the init script without rebuilding the container.
///
/// This allows template developers to iterate on their init script without
/// waiting for a full container rebuild. The container must already exist.
async fn rerun_init_script(
State(state): State<Arc<super::routes::AppState>>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<RerunInitResponse>, (StatusCode, String)> {
let mut workspace = state
.workspaces
.get(id)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Workspace {} not found", id)))?;
// Only works for container workspaces
if workspace.workspace_type != WorkspaceType::Chroot {
return Err((
StatusCode::BAD_REQUEST,
"Rerun init only works for container workspaces".to_string(),
));
}
// Container must exist (at least have basic structure)
if !workspace.path.join("bin").exists() {
return Err((
StatusCode::BAD_REQUEST,
"Container doesn't exist yet. Build it first.".to_string(),
));
}
// Must have an init script configured
let init_script = workspace.init_script.clone().unwrap_or_default();
if init_script.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"No init script configured for this workspace".to_string(),
));
}
// Write the init script to the container
let script_path = workspace.path.join("openagent-init.sh");
tokio::fs::write(&script_path, &init_script)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to write init script: {}", e),
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
tokio::fs::set_permissions(&script_path, perms)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set script permissions: {}", e),
)
})?;
}
// Update status to building
workspace.status = WorkspaceStatus::Building;
workspace.error_message = None;
state.workspaces.update(workspace.clone()).await;
// Run the init script
let start_time = std::time::Instant::now();
let shell = if workspace.path.join("bin/bash").exists() {
"/bin/bash"
} else {
"/bin/sh"
};
let mut config = crate::nspawn::NspawnConfig::default();
config.env = workspace.env_vars.clone();
let command = vec![shell.to_string(), "/openagent-init.sh".to_string()];
let output = crate::nspawn::execute_in_container(&workspace.path, &command, &config)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to execute init script: {}", e),
)
})?;
let duration_secs = start_time.elapsed().as_secs_f64();
let exit_code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let success = output.status.success();
// Update workspace status
if success {
workspace.status = WorkspaceStatus::Ready;
workspace.error_message = None;
} else {
workspace.status = WorkspaceStatus::Error;
let mut error_msg = String::new();
if !stderr.trim().is_empty() {
error_msg.push_str(stderr.trim());
}
if !stdout.trim().is_empty() {
if !error_msg.is_empty() {
error_msg.push_str(" | ");
}
error_msg.push_str(stdout.trim());
}
if error_msg.is_empty() {
error_msg = format!("Init script failed with exit code {}", exit_code);
}
workspace.error_message = Some(format!("Init script failed: {}", error_msg));
}
// Clean up the script file
let _ = tokio::fs::remove_file(&script_path).await;
state.workspaces.update(workspace).await;
Ok(Json(RerunInitResponse {
success,
exit_code,
stdout,
stderr,
duration_secs,
}))
}
#[cfg(test)]
mod tests {
use super::*;