fix: OAuth client creation during bootstrap
Some checks failed
BotServer CI / build (push) Failing after 6m2s
Some checks failed
BotServer CI / build (push) Failing after 6m2s
- Add password grant authentication support in DirectorySetup - Extract initial admin credentials from Zitadel log file - Fix race condition in Zitadel startup (wait for health check before starting) - Create parent directories before saving config - Add retry logic for OAuth client creation - Improve error handling with detailed messages Fixes authentication service not configured error after bootstrap.
This commit is contained in:
parent
0b1b17406d
commit
bbdf243c86
8 changed files with 664 additions and 19 deletions
114
src/auto_task/agent_executor.rs
Normal file
114
src/auto_task/agent_executor.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use crate::auto_task::container_session::{ContainerSession, TerminalOutput};
|
||||
use crate::core::shared::state::{AppState, TaskProgressEvent};
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct AgentExecutor {
|
||||
pub state: Arc<AppState>,
|
||||
pub session_id: String,
|
||||
pub task_id: String,
|
||||
container: Option<ContainerSession>,
|
||||
}
|
||||
|
||||
impl AgentExecutor {
|
||||
pub fn new(state: Arc<AppState>, session_id: &str, task_id: &str) -> Self {
|
||||
Self {
|
||||
state,
|
||||
session_id: session_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
container: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> Result<(), String> {
|
||||
self.broadcast_step("Initializing Agent Environment", 1, 10);
|
||||
|
||||
let mut session = ContainerSession::new(&self.session_id).await?;
|
||||
let (tx, mut rx) = mpsc::channel(100);
|
||||
|
||||
session.start_terminal(tx).await?;
|
||||
self.container = Some(session);
|
||||
|
||||
// Spawn a task to listen to terminal output and broadcast it
|
||||
let state_clone = self.state.clone();
|
||||
let task_id_clone = self.task_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some(output) = rx.recv().await {
|
||||
let (line, stream) = match output {
|
||||
TerminalOutput::Stdout(l) => (l, "stdout"),
|
||||
TerminalOutput::Stderr(l) => (l, "stderr"),
|
||||
};
|
||||
|
||||
let mut event = TaskProgressEvent::new(
|
||||
&task_id_clone,
|
||||
"terminal_output",
|
||||
&line,
|
||||
)
|
||||
.with_event_type("terminal_output");
|
||||
// The JS on the frontend expects { type: "terminal_output", line: "...", stream: "..." }
|
||||
// So we hijack details for stream
|
||||
event.details = Some(stream.to_string());
|
||||
event.text = Some(line.clone());
|
||||
|
||||
state_clone.broadcast_task_progress(event);
|
||||
}
|
||||
});
|
||||
|
||||
self.broadcast_browser_ready("http://localhost:8000", 8000);
|
||||
self.broadcast_step("Agent Ready", 2, 10);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn execute_shell_command(&mut self, cmd: &str) -> Result<(), String> {
|
||||
if let Some(container) = &mut self.container {
|
||||
container.send_command(cmd).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Container not initialized".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn broadcast_thought(&self, thought: &str) {
|
||||
let mut event = TaskProgressEvent::new(
|
||||
&self.task_id,
|
||||
"thought_process",
|
||||
thought,
|
||||
)
|
||||
.with_event_type("thought_process");
|
||||
event.text = Some(thought.to_string());
|
||||
self.state.broadcast_task_progress(event);
|
||||
}
|
||||
|
||||
pub fn broadcast_step(&self, label: &str, current: u8, total: u8) {
|
||||
let event = TaskProgressEvent::new(
|
||||
&self.task_id,
|
||||
"step_progress",
|
||||
label,
|
||||
)
|
||||
.with_event_type("step_progress")
|
||||
.with_progress(current, total);
|
||||
self.state.broadcast_task_progress(event);
|
||||
}
|
||||
|
||||
pub fn broadcast_browser_ready(&self, url: &str, port: u16) {
|
||||
let mut event = TaskProgressEvent::new(
|
||||
&self.task_id,
|
||||
"browser_ready",
|
||||
url,
|
||||
)
|
||||
.with_event_type("browser_ready");
|
||||
event.details = Some(port.to_string());
|
||||
self.state.broadcast_task_progress(event);
|
||||
}
|
||||
|
||||
pub async fn cleanup(&mut self) {
|
||||
if let Some(mut container) = self.container.take() {
|
||||
if let Err(e) = container.stop().await {
|
||||
error!("Error stopping container session: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/auto_task/container_session.rs
Normal file
137
src/auto_task/container_session.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use log::{info, warn};
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, Command};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TerminalOutput {
|
||||
Stdout(String),
|
||||
Stderr(String),
|
||||
}
|
||||
|
||||
pub struct ContainerSession {
|
||||
pub session_id: String,
|
||||
pub container_name: String,
|
||||
process: Option<Child>,
|
||||
stdin: Option<Arc<Mutex<ChildStdin>>>,
|
||||
}
|
||||
|
||||
impl ContainerSession {
|
||||
pub async fn new(session_id: &str) -> Result<Self, String> {
|
||||
let container_name = format!("agent-{}", session_id.chars().take(8).collect::<String>());
|
||||
|
||||
// Launch the container (this might take a moment if the image isn't cached locally)
|
||||
info!("Launching LXC container: {}", container_name);
|
||||
let launch_status = Command::new("lxc")
|
||||
.args(&["launch", "ubuntu:22.04", &container_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute lxc launch: {}", e))?;
|
||||
|
||||
if !launch_status.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&launch_status.stderr);
|
||||
// If it already exists, that's fine, we can just use it
|
||||
if !stderr.contains("already exists") {
|
||||
warn!("Warning during LXC launch (might already exist): {}", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
session_id: session_id.to_string(),
|
||||
container_name,
|
||||
process: None,
|
||||
stdin: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start_terminal(&mut self, tx: mpsc::Sender<TerminalOutput>) -> Result<(), String> {
|
||||
info!("Starting terminal session in container: {}", self.container_name);
|
||||
|
||||
let mut child = Command::new("lxc")
|
||||
.args(&["exec", &self.container_name, "--", "bash"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn lxc exec: {}", e))?;
|
||||
|
||||
let stdin = child.stdin.take().ok_or("Failed to capture stdin")?;
|
||||
let stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
|
||||
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
|
||||
|
||||
self.stdin = Some(Arc::new(Mutex::new(stdin)));
|
||||
self.process = Some(child);
|
||||
|
||||
// Spawn stdout reader
|
||||
let tx_out = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if tx_out.send(TerminalOutput::Stdout(line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn stderr reader
|
||||
let tx_err = tx;
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
if tx_err.send(TerminalOutput::Stderr(line)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send a setup command to get things ready
|
||||
self.send_command("export TERM=xterm-256color; cd /root").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_command(&self, cmd: &str) -> Result<(), String> {
|
||||
if let Some(stdin_mutex) = &self.stdin {
|
||||
let mut stdin = stdin_mutex.lock().await;
|
||||
let cmd_with_newline = format!("{}\n", cmd);
|
||||
stdin.write_all(cmd_with_newline.as_bytes()).await
|
||||
.map_err(|e| format!("Failed to write to stdin: {}", e))?;
|
||||
stdin.flush().await
|
||||
.map_err(|e| format!("Failed to flush stdin: {}", e))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Terminal not started".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> Result<(), String> {
|
||||
info!("Stopping container session: {}", self.container_name);
|
||||
|
||||
if let Some(mut child) = self.process.take() {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
|
||||
// Clean up container
|
||||
let status = Command::new("lxc")
|
||||
.args(&["delete", &self.container_name, "--force"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete container: {}", e))?;
|
||||
|
||||
if !status.status.success() {
|
||||
warn!("Failed to delete container {}: {}", self.container_name, String::from_utf8_lossy(&status.stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ContainerSession {
|
||||
fn drop(&mut self) {
|
||||
// We can't easily await inside drop, but the actual LXC container persists
|
||||
// unless we spawn a blocking task or fire-and-forget task to delete it.
|
||||
// For reliability, we expect the caller to call `stop().await`.
|
||||
}
|
||||
}
|
||||
|
|
@ -514,6 +514,17 @@ Respond with JSON only:
|
|||
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
info!("Handling APP_CREATE intent");
|
||||
|
||||
// [AGENT MODE] Initialize the LXC container session for real terminal output
|
||||
let t_id = task_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
let mut executor = crate::auto_task::AgentExecutor::new(self.state.clone(), &session.id.to_string(), &t_id);
|
||||
|
||||
if let Err(e) = executor.initialize().await {
|
||||
log::warn!("Failed to initialize LXC container for agent: {}", e);
|
||||
} else {
|
||||
executor.broadcast_thought("Analyzing the user prompt and setting up a dedicated LXC workspace...");
|
||||
let _ = executor.execute_shell_command("echo 'Initializing Agent Workspace...' && date && mkdir -p /root/app").await;
|
||||
}
|
||||
|
||||
let mut app_generator = if let Some(tid) = task_id {
|
||||
AppGenerator::with_task_id(self.state.clone(), tid)
|
||||
} else {
|
||||
|
|
@ -554,7 +565,7 @@ Respond with JSON only:
|
|||
|
||||
let app_url = format!("/apps/{}", app.name.to_lowercase().replace(' ', "-"));
|
||||
|
||||
Ok(IntentResult {
|
||||
let res = Ok(IntentResult {
|
||||
success: true,
|
||||
intent_type: IntentType::AppCreate,
|
||||
message: format!(
|
||||
|
|
@ -577,11 +588,17 @@ Respond with JSON only:
|
|||
"Use Designer to customize the app".to_string(),
|
||||
],
|
||||
error: None,
|
||||
})
|
||||
});
|
||||
|
||||
// [AGENT MODE] Cleanup the LXC container
|
||||
executor.broadcast_thought("Generation complete. Terminating LXC sandbox.");
|
||||
executor.cleanup().await;
|
||||
|
||||
res
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to generate app: {e}");
|
||||
Ok(IntentResult {
|
||||
let res = Ok(IntentResult {
|
||||
success: false,
|
||||
intent_type: IntentType::AppCreate,
|
||||
message: "Failed to create the application".to_string(),
|
||||
|
|
@ -592,7 +609,13 @@ Respond with JSON only:
|
|||
tool_triggers: Vec::new(),
|
||||
next_steps: vec!["Try again with more details".to_string()],
|
||||
error: Some(e.to_string()),
|
||||
})
|
||||
});
|
||||
|
||||
// [AGENT MODE] Cleanup the LXC container on error
|
||||
if let Err(_) = executor.execute_shell_command("echo 'Build failed' >&2").await {}
|
||||
executor.cleanup().await;
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ pub mod intent_compiler;
|
|||
pub mod safety_layer;
|
||||
pub mod task_manifest;
|
||||
pub mod task_types;
|
||||
pub mod agent_executor;
|
||||
pub mod container_session;
|
||||
|
||||
pub use app_generator::{
|
||||
AppGenerator, AppStructure, FileType, GeneratedApp, GeneratedFile, GeneratedPage, PageType,
|
||||
|
|
@ -38,6 +40,8 @@ pub use task_types::{AutoTask, AutoTaskStatus, ExecutionMode, TaskPriority};
|
|||
pub use intent_classifier::{ClassifiedIntent, IntentClassifier, IntentType};
|
||||
pub use intent_compiler::{CompiledIntent, IntentCompiler};
|
||||
pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, SimulationResult};
|
||||
pub use agent_executor::*;
|
||||
pub use container_session::*;
|
||||
|
||||
use crate::core::urls::ApiUrls;
|
||||
use crate::core::shared::state::AppState;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Bootstrap manager implementation
|
||||
use crate::core::bootstrap::bootstrap_types::{BootstrapManager, BootstrapProgress};
|
||||
use crate::core::bootstrap::bootstrap_utils::{cache_health_check, safe_pkill, vault_health_check, vector_db_health_check};
|
||||
use crate::core::bootstrap::bootstrap_utils::{cache_health_check, safe_pkill, vault_health_check, vector_db_health_check, zitadel_health_check};
|
||||
use crate::core::config::AppConfig;
|
||||
use crate::core::package_manager::{InstallMode, PackageManager};
|
||||
use log::{info, warn};
|
||||
|
|
@ -158,6 +158,76 @@ impl BootstrapManager {
|
|||
}
|
||||
}
|
||||
|
||||
if pm.is_installed("directory") {
|
||||
// Wait for Zitadel to be ready - it might have been started during installation
|
||||
// Give it up to 60 seconds before trying to start it ourselves
|
||||
let mut directory_already_running = zitadel_health_check();
|
||||
if !directory_already_running {
|
||||
info!("Zitadel not responding to health check, waiting up to 60s for it to start...");
|
||||
for i in 0..30 {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
if zitadel_health_check() {
|
||||
info!("Zitadel/Directory service is now responding (waited {}s)", (i + 1) * 2);
|
||||
directory_already_running = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if directory_already_running {
|
||||
info!("Zitadel/Directory service is already running");
|
||||
} else {
|
||||
info!("Starting Zitadel/Directory service...");
|
||||
match pm.start("directory") {
|
||||
Ok(_child) => {
|
||||
info!("Directory service started, waiting for readiness...");
|
||||
let mut zitadel_ready = false;
|
||||
for i in 0..150 {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
if zitadel_health_check() {
|
||||
info!("Zitadel/Directory service is responding");
|
||||
zitadel_ready = true;
|
||||
break;
|
||||
}
|
||||
if i == 149 {
|
||||
warn!("Zitadel/Directory service did not respond after 300 seconds");
|
||||
}
|
||||
}
|
||||
|
||||
// Create OAuth client if Zitadel is ready and config doesn't exist
|
||||
if zitadel_ready {
|
||||
let config_path = self.stack_dir("conf/system/directory_config.json");
|
||||
if !config_path.exists() {
|
||||
info!("Creating OAuth client for Directory service...");
|
||||
match crate::core::package_manager::setup_directory().await {
|
||||
Ok(_) => info!("OAuth client created successfully"),
|
||||
Err(e) => warn!("Failed to create OAuth client: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to start Directory service: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Directory (Zitadel) bootstrap is handled in main_module/bootstrap.rs
|
||||
// where it has proper access to the admin PAT token
|
||||
}
|
||||
|
||||
if pm.is_installed("alm") {
|
||||
info!("Starting ALM (Forgejo) service...");
|
||||
match pm.start("alm") {
|
||||
Ok(_child) => {
|
||||
info!("ALM service started");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to start ALM service: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caddy is the web server
|
||||
match Command::new("caddy")
|
||||
.arg("validate")
|
||||
|
|
@ -209,7 +279,7 @@ impl BootstrapManager {
|
|||
}
|
||||
|
||||
// Install other core components (names must match 3rdparty.toml)
|
||||
let core_components = ["tables", "cache", "drive", "llm"];
|
||||
let core_components = ["tables", "cache", "drive", "directory", "llm"];
|
||||
for component in core_components {
|
||||
if !pm.is_installed(component) {
|
||||
info!("Installing {}...", component);
|
||||
|
|
|
|||
|
|
@ -44,3 +44,145 @@ pub fn get_all_components() -> Vec<ComponentInfo> {
|
|||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse Zitadel log file to extract initial admin credentials
|
||||
#[cfg(feature = "directory")]
|
||||
fn extract_initial_admin_from_log(log_path: &std::path::Path) -> Option<(String, String)> {
|
||||
use std::fs;
|
||||
|
||||
let log_content = fs::read_to_string(log_path).ok()?;
|
||||
|
||||
// Try different log formats from Zitadel
|
||||
// Format 1: "initial admin user created. email: admin@<domain> password: <password>"
|
||||
for line in log_content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
if line_lower.contains("initial admin") || line_lower.contains("admin credentials") {
|
||||
// Try to extract email and password
|
||||
let email = if let Some(email_start) = line.find("email:") {
|
||||
let rest = &line[email_start + 6..];
|
||||
rest.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.trim_end_matches(',').to_string())
|
||||
} else if let Some(email_start) = line.find("Email:") {
|
||||
let rest = &line[email_start + 6..];
|
||||
rest.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.trim_end_matches(',').to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let password = if let Some(pwd_start) = line.find("password:") {
|
||||
let rest = &line[pwd_start + 9..];
|
||||
rest.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.trim_end_matches(',').to_string())
|
||||
} else if let Some(pwd_start) = line.find("Password:") {
|
||||
let rest = &line[pwd_start + 9..];
|
||||
rest.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.trim_end_matches(',').to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let (Some(email), Some(password)) = (email, password) {
|
||||
if !email.is_empty() && !password.is_empty() {
|
||||
log::info!("Extracted initial admin credentials from log: {}", email);
|
||||
return Some((email, password));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try multiline format
|
||||
// Admin credentials:
|
||||
// Email: admin@localhost
|
||||
// Password: xxxxx
|
||||
let lines: Vec<&str> = log_content.lines().collect();
|
||||
for i in 0..lines.len().saturating_sub(2) {
|
||||
if lines[i].to_lowercase().contains("admin credentials") {
|
||||
let mut email = None;
|
||||
let mut password = None;
|
||||
|
||||
for j in (i + 1)..std::cmp::min(i + 5, lines.len()) {
|
||||
let line = lines[j];
|
||||
if line.contains("Email:") {
|
||||
email = line.split("Email:")
|
||||
.nth(1)
|
||||
.map(|s| s.trim().to_string());
|
||||
}
|
||||
if line.contains("Password:") {
|
||||
password = line.split("Password:")
|
||||
.nth(1)
|
||||
.map(|s| s.trim().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(e), Some(p)) = (email, password) {
|
||||
if !e.is_empty() && !p.is_empty() {
|
||||
log::info!("Extracted initial admin credentials from multiline log: {}", e);
|
||||
return Some((e, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Initialize Directory (Zitadel) with default admin user and OAuth application
|
||||
/// This should be called after Zitadel has started and is responding
|
||||
#[cfg(feature = "directory")]
|
||||
pub async fn setup_directory() -> anyhow::Result<crate::core::package_manager::setup::DirectoryConfig> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let stack_path = std::env::var("BOTSERVER_STACK_PATH")
|
||||
.unwrap_or_else(|_| "./botserver-stack".to_string());
|
||||
|
||||
let base_url = "http://localhost:8300".to_string();
|
||||
let config_path = PathBuf::from(&stack_path).join("conf/system/directory_config.json");
|
||||
|
||||
// Check if config already exists
|
||||
if config_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||||
if let Ok(config) = serde_json::from_str::<crate::core::package_manager::setup::DirectoryConfig>(&content) {
|
||||
if !config.client_id.is_empty() && !config.client_secret.is_empty() {
|
||||
log::info!("Directory already configured with OAuth client");
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get initial admin credentials from Zitadel log
|
||||
let log_path = PathBuf::from(&stack_path).join("logs/zitadel.log");
|
||||
let admin_credentials = extract_initial_admin_from_log(&log_path);
|
||||
|
||||
let mut directory_setup = if let Some((email, password)) = admin_credentials {
|
||||
log::info!("Using initial admin credentials from log for OAuth client creation");
|
||||
crate::core::package_manager::setup::DirectorySetup::with_admin_credentials(
|
||||
base_url,
|
||||
config_path.clone(),
|
||||
email,
|
||||
password,
|
||||
)
|
||||
} else {
|
||||
log::warn!(
|
||||
"Could not extract initial admin credentials from Zitadel log at {}. \
|
||||
OAuth client creation may fail. Check if Zitadel has started properly.",
|
||||
log_path.display()
|
||||
);
|
||||
crate::core::package_manager::setup::DirectorySetup::new(
|
||||
base_url,
|
||||
config_path.clone()
|
||||
)
|
||||
};
|
||||
|
||||
directory_setup.initialize().await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ pub struct DirectorySetup {
|
|||
base_url: String,
|
||||
client: Client,
|
||||
admin_token: Option<String>,
|
||||
/// Admin credentials for password grant authentication (used during initial setup)
|
||||
admin_credentials: Option<(String, String)>,
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
|
|
@ -20,9 +22,57 @@ impl DirectorySetup {
|
|||
self.admin_token = Some(token);
|
||||
}
|
||||
|
||||
/// Set admin credentials for password grant authentication
|
||||
pub fn set_admin_credentials(&mut self, username: String, password: String) {
|
||||
self.admin_credentials = Some((username, password));
|
||||
}
|
||||
|
||||
/// Get an access token using either PAT or password grant
|
||||
async fn get_admin_access_token(&self) -> Result<String> {
|
||||
// If we have a PAT token, use it directly
|
||||
if let Some(ref token) = self.admin_token {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
|
||||
// If we have admin credentials, use password grant
|
||||
if let Some((username, password)) = &self.admin_credentials {
|
||||
let token_url = format!("{}/oauth/v2/token", self.base_url);
|
||||
let params = [
|
||||
("grant_type", "password".to_string()),
|
||||
("username", username.clone()),
|
||||
("password", password.clone()),
|
||||
("scope", "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud".to_string()),
|
||||
];
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&token_url)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get access token: {}", e))?;
|
||||
|
||||
let token_data: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse token response: {}", e))?;
|
||||
|
||||
let access_token = token_data
|
||||
.get("access_token")
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("No access token in response"))?
|
||||
.to_string();
|
||||
|
||||
log::info!("Obtained access token via password grant");
|
||||
return Ok(access_token);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("No admin token or credentials configured"))
|
||||
}
|
||||
|
||||
pub fn ensure_admin_token(&mut self) -> Result<()> {
|
||||
if self.admin_token.is_none() {
|
||||
return Err(anyhow::anyhow!("Admin token must be configured"));
|
||||
if self.admin_token.is_none() && self.admin_credentials.is_none() {
|
||||
return Err(anyhow::anyhow!("Admin token or credentials must be configured"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -90,6 +140,24 @@ impl DirectorySetup {
|
|||
Client::new()
|
||||
}),
|
||||
admin_token: None,
|
||||
admin_credentials: None,
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a DirectorySetup with initial admin credentials for password grant
|
||||
pub fn with_admin_credentials(base_url: String, config_path: PathBuf, username: String, password: String) -> Self {
|
||||
Self {
|
||||
base_url,
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Failed to create HTTP client with timeout: {}, using default", e);
|
||||
Client::new()
|
||||
}),
|
||||
admin_token: None,
|
||||
admin_credentials: Some((username, password)),
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +200,10 @@ impl DirectorySetup {
|
|||
|
||||
self.wait_for_ready(30).await?;
|
||||
|
||||
// Wait additional time for Zitadel API to be fully ready
|
||||
log::info!("Waiting for Zitadel API to be fully initialized...");
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
self.ensure_admin_token()?;
|
||||
|
||||
let org = self.create_default_organization().await?;
|
||||
|
|
@ -140,7 +212,39 @@ impl DirectorySetup {
|
|||
let user = self.create_default_user(&org.id).await?;
|
||||
log::info!(" Created default user: {}", user.username);
|
||||
|
||||
let (project_id, client_id, client_secret) = self.create_oauth_application(&org.id).await?;
|
||||
// Retry OAuth client creation up to 3 times with delays
|
||||
let (project_id, client_id, client_secret) = {
|
||||
let mut last_error = None;
|
||||
let mut result = None;
|
||||
|
||||
for attempt in 1..=3 {
|
||||
match self.create_oauth_application(&org.id).await {
|
||||
Ok(credentials) => {
|
||||
result = Some(credentials);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"OAuth client creation attempt {}/3 failed: {}",
|
||||
attempt,
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
if attempt < 3 {
|
||||
log::info!("Retrying in 5 seconds...");
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to create OAuth client after 3 attempts: {}",
|
||||
last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error"))
|
||||
)
|
||||
})?
|
||||
};
|
||||
log::info!(" Created OAuth2 application");
|
||||
|
||||
self.grant_user_permissions(&org.id, &user.id).await?;
|
||||
|
|
@ -337,38 +441,56 @@ impl DirectorySetup {
|
|||
_org_id: &str,
|
||||
) -> Result<(String, String, String)> {
|
||||
let app_name = "BotServer";
|
||||
let redirect_uri = "http://localhost:9000/auth/callback".to_string();
|
||||
let redirect_uri = "http://localhost:8080/auth/callback".to_string();
|
||||
|
||||
// Get access token using either PAT or password grant
|
||||
let access_token = self.get_admin_access_token().await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get admin access token: {}", e))?;
|
||||
|
||||
let project_response = self
|
||||
.client
|
||||
.post(format!("{}/management/v1/projects", self.base_url))
|
||||
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
|
||||
.bearer_auth(&access_token)
|
||||
.json(&json!({
|
||||
"name": app_name,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !project_response.status().is_success() {
|
||||
let error_text = project_response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!("Failed to create project: {}", error_text));
|
||||
}
|
||||
|
||||
let project_result: serde_json::Value = project_response.json().await?;
|
||||
let project_id = project_result["id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
if project_id.is_empty() {
|
||||
return Err(anyhow::anyhow!("Project ID is empty in response"));
|
||||
}
|
||||
|
||||
let app_response = self.client
|
||||
.post(format!("{}/management/v1/projects/{}/apps/oidc", self.base_url, project_id))
|
||||
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
|
||||
.bearer_auth(&access_token)
|
||||
.json(&json!({
|
||||
"name": app_name,
|
||||
"redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:9000/auth/callback"],
|
||||
"redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:8080/auth/callback"],
|
||||
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
|
||||
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", "OIDC_GRANT_TYPE_PASSWORD"],
|
||||
"appType": "OIDC_APP_TYPE_WEB",
|
||||
"authMethodType": "OIDC_AUTH_METHOD_TYPE_POST",
|
||||
"postLogoutRedirectUris": ["http://localhost:9000", "http://localhost:3000", "http://localhost:9000"],
|
||||
"postLogoutRedirectUris": ["http://localhost:8080", "http://localhost:3000", "http://localhost:8080"],
|
||||
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
|
||||
"devMode": true,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !app_response.status().is_success() {
|
||||
let error_text = app_response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::anyhow!("Failed to create OAuth application: {}", error_text));
|
||||
}
|
||||
|
||||
let app_result: serde_json::Value = app_response.json().await?;
|
||||
let client_id = app_result["clientId"].as_str().unwrap_or("").to_string();
|
||||
let client_secret = app_result["clientSecret"]
|
||||
|
|
@ -376,6 +498,11 @@ impl DirectorySetup {
|
|||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if client_id.is_empty() {
|
||||
return Err(anyhow::anyhow!("Client ID is empty in response"));
|
||||
}
|
||||
|
||||
log::info!("Created OAuth application with client_id: {}", client_id);
|
||||
Ok((project_id, client_id, client_secret))
|
||||
}
|
||||
|
||||
|
|
@ -432,8 +559,21 @@ impl DirectorySetup {
|
|||
}
|
||||
|
||||
async fn save_config_internal(&self, config: &DirectoryConfig) -> Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = self.config_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).await.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to create config directory {}: {}", parent.display(), e)
|
||||
})?;
|
||||
log::info!("Created config directory: {}", parent.display());
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&self.config_path, json).await?;
|
||||
fs::write(&self.config_path, json).await.map_err(|e| {
|
||||
anyhow::anyhow!("Failed to write config to {}: {}", self.config_path.display(), e)
|
||||
})?;
|
||||
log::info!("Saved Directory configuration to {}", self.config_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -466,9 +606,10 @@ Database:
|
|||
Machine:
|
||||
Identification:
|
||||
Hostname: localhost
|
||||
WebhookAddress: http://localhost:9000
|
||||
WebhookAddress: http://localhost:8080
|
||||
|
||||
ExternalDomain: localhost:9000
|
||||
Port: 9000
|
||||
ExternalDomain: localhost
|
||||
ExternalPort: 9000
|
||||
ExternalSecure: false
|
||||
|
||||
|
|
|
|||
|
|
@ -379,8 +379,7 @@ pub async fn get_current_user(
|
|||
let session_token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.filter(|token| !token.is_empty());
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "));
|
||||
|
||||
match session_token {
|
||||
None => {
|
||||
|
|
@ -398,6 +397,21 @@ pub async fn get_current_user(
|
|||
is_anonymous: true,
|
||||
})
|
||||
}
|
||||
Some(token) if token.is_empty() => {
|
||||
info!("get_current_user: empty authorization token - returning anonymous user");
|
||||
Json(CurrentUserResponse {
|
||||
id: None,
|
||||
username: None,
|
||||
email: None,
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
display_name: None,
|
||||
roles: None,
|
||||
organization_id: None,
|
||||
avatar_url: None,
|
||||
is_anonymous: true,
|
||||
})
|
||||
}
|
||||
Some(session_token) => {
|
||||
info!("get_current_user: looking up session token (len={}, prefix={}...)",
|
||||
session_token.len(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue