Compare commits

..

No commits in common. "1e71c9be0978207670c14cee9a755add016135ff" and "1bbb94d5006cab39ac0eb0f05088763615373230" have entirely different histories.

23 changed files with 1303 additions and 1150 deletions

View file

@ -1,594 +0,0 @@
# BotServer Cinema Viewer Logging Plan
## 🎬 The Cinema Viewer Philosophy
Think of your logs as a movie with different viewing modes:
- **INFO** = **The Movie** - Watch the main story unfold (production-ready)
- **DEBUG** = **Director's Commentary** - Behind-the-scenes details (troubleshooting)
- **TRACE** = **Raw Footage** - Every single take and detail (deep debugging)
- **WARN** = **Plot Holes** - Things that shouldn't happen but are recoverable
- **ERROR** = **Scene Failures** - Critical issues that break the narrative
---
## 📊 Log Level Definitions
### ERROR - Critical Failures
**When to use:** System cannot proceed, requires immediate attention
```rust
// ✅ GOOD - Actionable, clear context
error!("Database connection failed - retrying in 5s: {}", e);
error!("Authentication failed for user {}: invalid credentials", user_id);
error!("Stage 2 BUILD failed: {}", e);
// ❌ BAD - Vague, no context
error!("Error!");
error!("Failed");
error!("Something went wrong: {}", e);
```
### WARN - Recoverable Issues
**When to use:** Unexpected state but system can continue
```rust
// ✅ GOOD - Explains the issue and impact
warn!("Failed to create data directory: {}. Using fallback.", e);
warn!("LLM server not ready - deferring embedding generation");
warn!("Rate limit approaching for API key {}", key_id);
// ❌ BAD - Not actionable
warn!("Warning");
warn!("Something happened");
```
### INFO - The Main Story
**When to use:** Key events, state changes, business milestones
```rust
// ✅ GOOD - Tells a story, shows progress
info!("Pipeline starting - task: {}, intent: {}", task_id, intent);
info!("Stage 1 PLAN complete - {} nodes planned", node_count);
info!("User {} logged in from {}", user_id, ip_address);
info!("Server started on port {}", port);
// ❌ BAD - Too verbose, implementation details
info!("Entering function process_request");
info!("Variable x = {}", x);
info!("Loop iteration {}", i);
```
### DEBUG - Behind the Scenes
**When to use:** Troubleshooting information, decision points, state inspections
```rust
// ✅ GOOD - Helps diagnose issues
debug!("Request payload: {:?}", payload);
debug!("Using cache key: {}", cache_key);
debug!("Retry attempt {} of {}", attempt, max_retries);
debug!("Selected LLM model: {} for task type: {}", model, task_type);
// ❌ BAD - Too trivial
debug!("Variable assigned");
debug!("Function called");
```
### TRACE - Raw Footage
**When to use:** Step-by-step execution, loop iterations, detailed flow
```rust
// ✅ GOOD - Detailed execution path
trace!("Starting monitoring loop");
trace!("Processing file: {:?}", path);
trace!("Checking bot directory: {}", dir);
trace!("WebSocket message received: {} bytes", len);
// ❌ BAD - Noise without value
trace!("Line 100");
trace!("Got here");
trace!("...");
```
---
## 🎭 Logging Patterns by Module Type
### 1. Orchestration & Pipeline Modules
**Examples:** `auto_task/orchestrator.rs`, `auto_task/agent_executor.rs`
**INFO Level:** Show the story arc
```rust
info!("Pipeline starting - task: {}, intent: {}", task_id, intent);
info!("Stage 1 PLAN starting - Agent #1 analyzing request");
info!("Stage 1 PLAN complete - {} nodes planned", node_count);
info!("Stage 2 BUILD complete - {} resources, url: {}", count, url);
info!("Pipeline complete - task: {}, nodes: {}, resources: {}", task_id, nodes, resources);
```
**DEBUG Level:** Show decision points
```rust
debug!("Classified intent as: {:?}", classification);
debug!("Selected app template: {}", template_name);
debug!("Skipping stage 3 - no resources to review");
```
**TRACE Level:** Show execution flow
```rust
trace!("Broadcasting thought to UI: {}", thought);
trace!("Updating agent {} status: {} -> {}", id, old, new);
trace!("Sub-task generated: {}", task_name);
```
### 2. File Monitoring & Compilation
**Examples:** `drive/local_file_monitor.rs`, `drive/drive_monitor/mod.rs`
**INFO Level:** Key file operations
```rust
info!("Local file monitor started - watching /opt/gbo/data/*.gbai");
info!("Compiling bot: {} ({} files)", bot_name, file_count);
info!("Bot {} compiled successfully - {} tools generated", bot_name, tool_count);
```
**DEBUG Level:** File processing details
```rust
debug!("Detected change in: {:?}", path);
debug!("Recompiling {} - modification detected", bot_name);
debug!("Skipping {} - no changes detected", bot_name);
```
**TRACE Level:** File system operations
```rust
trace!("Scanning directory: {:?}", dir);
trace!("File modified: {:?} at {:?}", path, time);
trace!("Watching directory: {:?}", path);
```
### 3. Security & Authentication
**Examples:** `security/jwt.rs`, `security/api_keys.rs`, `security/sql_guard.rs`
**INFO Level:** Security events (always log these!)
```rust
info!("User {} logged in from {}", user_id, ip);
info!("API key {} created for user {}", key_id, user_id);
info!("Failed login attempt for user {} from {}", username, ip);
info!("Rate limit exceeded for IP: {}", ip);
```
**DEBUG Level:** Security checks
```rust
debug!("Validating JWT for user {}", user_id);
debug!("Checking API key permissions: {:?}", permissions);
debug!("SQL query sanitized: {}", safe_query);
```
**TRACE Level:** Security internals
```rust
trace!("Token expiry check: {} seconds remaining", remaining);
trace!("Permission check: {} -> {}", resource, allowed);
trace!("Hashing password with cost factor: {}", cost);
```
### 4. API Handlers
**Examples:** HTTP endpoint handlers in `core/`, `drive/`, etc.
**INFO Level:** Request lifecycle
```rust
info!("Request started: {} {} from {}", method, path, ip);
info!("Request completed: {} {} -> {} ({}ms)", method, path, status, duration);
info!("User {} created resource: {}", user_id, resource_id);
```
**DEBUG Level:** Request details
```rust
debug!("Request headers: {:?}", headers);
debug!("Request body: {:?}", body);
debug!("Response payload: {} bytes", size);
```
**TRACE Level:** Request processing
```rust
trace!("Parsing JSON body");
trace!("Validating request parameters");
trace!("Serializing response");
```
### 5. Database Operations
**Examples:** `core/shared/models/`, Diesel queries
**INFO Level:** Database lifecycle
```rust
info!("Database connection pool initialized ({} connections)", pool_size);
info!("Migration completed - {} tables updated", count);
info!("Database backup created: {}", backup_path);
```
**DEBUG Level:** Query information
```rust
debug!("Executing query: {}", query);
debug!("Query returned {} rows in {}ms", count, duration);
debug!("Cache miss for key: {}", key);
```
**TRACE Level:** Query details
```rust
trace!("Preparing statement: {}", sql);
trace!("Binding parameter {}: {:?}", index, value);
trace!("Fetching next row");
```
### 6. LLM & AI Operations
**Examples:** `llm/`, `core/kb/`
**INFO Level:** LLM operations
```rust
info!("LLM request started - model: {}, tokens: {}", model, estimated_tokens);
info!("LLM response received - {} tokens, {}ms", tokens, duration);
info!("Embedding generated - {} dimensions", dimensions);
info!("Knowledge base indexed - {} documents", doc_count);
```
**DEBUG Level:** LLM details
```rust
debug!("LLM prompt: {}", prompt_preview);
debug!("Using temperature: {}, max_tokens: {}", temp, max);
debug!("Selected model variant: {}", variant);
```
**TRACE Level:** LLM internals
```rust
trace!("Sending request to LLM API: {}", url);
trace!("Streaming token: {}", token);
trace!("Parsing LLM response chunk");
```
### 7. Startup & Initialization
**Examples:** `main.rs`, `main_module/bootstrap.rs`
**INFO Level:** Startup milestones
```rust
info!("Server starting on port {}", port);
info!("Database initialized - PostgreSQL connected");
info!("Cache initialized - Valkey connected");
info!("Secrets loaded from Vault");
info!("BotServer ready - {} bots loaded", bot_count);
```
**DEBUG Level:** Configuration details
```rust
debug!("Using config: {:?}", config);
debug!("Environment: {}", env);
debug!("Feature flags: {:?}", features);
```
**TRACE Level:** Initialization steps
```rust
trace!("Loading .env file");
trace!("Setting up signal handlers");
trace!("Initializing thread registry");
```
---
## 🎯 The Cinema Viewer Experience
### Level 1: Watching the Movie (INFO)
```bash
RUST_LOG=botserver=info
```
**What you see:**
```
INFO botserver: Server starting on port 8080
INFO botserver: Database initialized - PostgreSQL connected
INFO botserver: User alice@example.com logged in from 192.168.1.100
INFO botserver::auto_task::orchestrator: Pipeline starting - task: abc123, intent: Create CRM
INFO botserver::auto_task::orchestrator: Stage 1 PLAN complete - 5 nodes planned
INFO botserver::auto_task::orchestrator: Stage 2 BUILD complete - 12 resources, url: /apps/crm
INFO botserver::auto_task::orchestrator: Pipeline complete - task: abc123, nodes: 5, resources: 12
INFO botserver: User alice@example.com logged out
```
**Perfect for:** Production monitoring, understanding system flow
### Level 2: Director's Commentary (DEBUG)
```bash
RUST_LOG=botserver=debug
```
**What you see:** Everything from INFO plus:
```
DEBUG botserver::auto_task::orchestrator: Classified intent as: AppGeneration
DEBUG botserver::auto_task::orchestrator: Selected app template: crm
DEBUG botserver::security::jwt: Validating JWT for user alice@example.com
DEBUG botserver::drive::local_file_monitor: Detected change in: /opt/gbo/data/crm.gbai
DEBUG botserver::llm: Using temperature: 0.7, max_tokens: 2000
```
**Perfect for:** Troubleshooting issues, understanding decisions
### Level 3: Raw Footage (TRACE)
```bash
RUST_LOG=botserver=trace
```
**What you see:** Everything from DEBUG plus:
```
TRACE botserver::drive::local_file_monitor: Scanning directory: /opt/gbo/data
TRACE botserver::auto_task::orchestrator: Broadcasting thought to UI: Analyzing...
TRACE botserver::llm: Streaming token: Create
TRACE botserver::llm: Streaming token: a
TRACE botserver::llm: Streaming token: CRM
TRACE botserver::core::db: Preparing statement: SELECT * FROM bots
```
**Perfect for:** Deep debugging, performance analysis, finding bugs
---
## ✨ Best Practices
### 1. Tell a Story
```rust
// ✅ GOOD - Shows the narrative
info!("Pipeline starting - task: {}", task_id);
info!("Stage 1 PLAN complete - {} nodes planned", nodes);
info!("Stage 2 BUILD complete - {} resources", resources);
info!("Pipeline complete - app deployed at {}", url);
// ❌ BAD - Just data points
info!("Task started");
info!("Nodes: {}", nodes);
info!("Resources: {}", resources);
info!("Done");
```
### 2. Use Structured Data
```rust
// ✅ GOOD - Easy to parse and filter
info!("User {} logged in from {}", user_id, ip);
info!("Request completed: {} {} -> {} ({}ms)", method, path, status, duration);
// ❌ BAD - Hard to parse
info!("User login happened");
info!("Request finished successfully");
```
### 3. Include Context
```rust
// ✅ GOOD - Provides context
error!("Database connection failed for bot {}: {}", bot_id, e);
warn!("Rate limit approaching for user {}: {}/{} requests", user_id, count, limit);
// ❌ BAD - No context
error!("Connection failed: {}", e);
warn!("Rate limit warning");
```
### 4. Use Appropriate Levels
```rust
// ✅ GOOD - Right level for right information
info!("Server started on port {}", port); // Key event
debug!("Using config: {:?}", config); // Troubleshooting
trace!("Listening on socket {:?}", socket); // Deep detail
// ❌ BAD - Wrong levels
trace!("Server started"); // Too important for trace
info!("Loop iteration {}", i); // Too verbose for info
error!("Variable is null"); // Not an error
```
### 5. Avoid Noise
```rust
// ✅ GOOD - Meaningful information
debug!("Retry attempt {} of {} for API call", attempt, max);
// ❌ BAD - Just noise
debug!("Entering function");
debug!("Exiting function");
debug!("Variable assigned");
```
### 6. Log State Changes
```rust
// ✅ GOOD - Shows what changed
info!("User {} role changed: {} -> {}", user_id, old_role, new_role);
info!("Bot {} status: {} -> {}", bot_id, old_status, new_status);
// ❌ BAD - No before/after
info!("User role updated");
info!("Bot status changed");
```
### 7. Include Timings for Operations
```rust
// ✅ GOOD - Performance visibility
info!("Database migration completed in {}ms", duration);
info!("LLM response received - {} tokens, {}ms", tokens, duration);
debug!("Query executed in {}ms", duration);
// ❌ BAD - No performance data
info!("Migration completed");
info!("LLM response received");
```
---
## 🔧 Implementation Guide
### Step 1: Audit Current Logging
```bash
# Find all logging statements
find botserver/src -name "*.rs" -exec grep -n "info!\|debug!\|trace!\|warn!\|error!" {} +
# Count by level
grep -r "info!" botserver/src | wc -l
grep -r "debug!" botserver/src | wc -l
grep -r "trace!" botserver/src | wc -l
```
### Step 2: Categorize by Module
Create a spreadsheet or document listing:
- Module name
- Current log levels used
- Purpose of the module
- What story should it tell
### Step 3: Refactor Module by Module
Start with critical path modules:
1. **auto_task/orchestrator.rs** - Already done! ✅
2. **drive/local_file_monitor.rs** - File operations
3. **security/jwt.rs** - Authentication events
4. **main.rs** - Startup sequence
5. **core/bot/** - Bot lifecycle
### Step 4: Test Different Verbosity Levels
```bash
# Test INFO level (production)
RUST_LOG=botserver=info cargo run
# Test DEBUG level (troubleshooting)
RUST_LOG=botserver=debug cargo run
# Test TRACE level (development)
RUST_LOG=botserver=trace cargo run
```
### Step 5: Document Module-Specific Patterns
For each module, document:
- What story does it tell at INFO level?
- What troubleshooting info at DEBUG level?
- What raw details at TRACE level?
---
## 📋 Quick Reference Card
### Log Level Decision Tree
```
Is this a failure that stops execution?
└─ YES → ERROR
└─ NO → Is this unexpected but recoverable?
└─ YES → WARN
└─ NO → Is this a key business event?
└─ YES → INFO
└─ NO → Is this useful for troubleshooting?
└─ YES → DEBUG
└─ NO → Is this step-by-step execution detail?
└─ YES → TRACE
└─ NO → Don't log it!
```
### Module-Specific Cheat Sheet
| Module Type | INFO | DEBUG | TRACE |
|-------------|------|-------|-------|
| **Orchestration** | Stage start/complete, pipeline milestones | Decision points, classifications | UI broadcasts, state changes |
| **File Monitoring** | Monitor start, bot compiled | Changes detected, recompiles | File scans, timestamps |
| **Security** | Logins, key events, failures | Validations, permission checks | Token details, hash operations |
| **API Handlers** | Request start/end, resource changes | Headers, payloads | JSON parsing, serialization |
| **Database** | Connections, migrations | Queries, row counts | Statement prep, row fetching |
| **LLM** | Requests, responses, indexing | Prompts, parameters | Token streaming, chunking |
| **Startup** | Service ready, milestones | Config, environment | Init steps, signal handlers |
---
## 🎬 Example: Complete Pipeline Logging
Here's how a complete auto-task pipeline looks at different levels:
### INFO Level (The Movie)
```
INFO Pipeline starting - task: task-123, intent: Create a CRM system
INFO Stage 1 PLAN starting - Agent #1 analyzing request
INFO Stage 1 PLAN complete - 5 nodes planned
INFO Stage 2 BUILD starting - Agent #2 generating code
INFO Stage 2 BUILD complete - 12 resources, url: /apps/crm-system
INFO Stage 3 REVIEW starting - Agent #3 checking code quality
INFO Stage 3 REVIEW complete - all checks passed
INFO Stage 4 DEPLOY starting - Agent #4 deploying to /apps/crm-system
INFO Stage 4 DEPLOY complete - app live at /apps/crm-system
INFO Stage 5 MONITOR starting - Agent #1 setting up monitoring
INFO Stage 5 MONITOR complete - monitoring active
INFO Pipeline complete - task: task-123, nodes: 5, resources: 12, url: /apps/crm-system
```
### DEBUG Level (Director's Commentary)
```
INFO Pipeline starting - task: task-123, intent: Create a CRM system
DEBUG Classified intent as: AppGeneration
DEBUG Selected app template: crm_standard
INFO Stage 1 PLAN starting - Agent #1 analyzing request
DEBUG Generated 5 sub-tasks from intent
INFO Stage 1 PLAN complete - 5 nodes planned
INFO Stage 2 BUILD starting - Agent #2 generating code
DEBUG Using database schema: contacts, deals, activities
DEBUG Generated 3 tables, 8 pages, 1 tool
INFO Stage 2 BUILD complete - 12 resources, url: /apps/crm-system
...
```
### TRACE Level (Raw Footage)
```
INFO Pipeline starting - task: task-123, intent: Create a CRM system
DEBUG Classified intent as: AppGeneration
TRACE Extracting entities from: "Create a CRM system"
TRACE Found entity: CRM
TRACE Found entity: system
DEBUG Selected app template: crm_standard
INFO Stage 1 PLAN starting - Agent #1 analyzing request
TRACE Broadcasting thought to UI: Analyzing request...
TRACE Deriving plan sub-tasks
TRACE Sub-task 1: Create database schema
TRACE Sub-task 2: Generate list page
TRACE Sub-task 3: Generate form pages
TRACE Sub-task 4: Create BASIC tools
TRACE Sub-task 5: Setup navigation
DEBUG Generated 5 sub-tasks from intent
...
```
---
## 🎯 Goals & Metrics
### Success Criteria
1. **INFO logs tell a complete story** - Can understand system flow without DEBUG/TRACE
2. **DEBUG logs enable troubleshooting** - Can diagnose issues with context
3. **TRACE logs show execution details** - Can see step-by-step for deep debugging
4. **No log spam** - Production logs are concise and meaningful
5. **Consistent patterns** - Similar modules log similarly
### Metrics to Track
- Lines of logs per request at INFO level: < 20
- Lines of logs per request at DEBUG level: < 100
- Lines of logs per request at TRACE level: unlimited
- Error logs include context: 100%
- WARN logs explain impact: 100%
---
## 🚀 Next Steps
1. **Audit** current logging in all 341 files
2. **Prioritize** modules by criticality
3. **Refactor** module by module following this plan
4. **Test** at each log level
5. **Document** module-specific patterns
6. **Train** team on logging standards
7. **Monitor** log volume and usefulness
8. **Iterate** based on feedback
---
## 📚 References
- [Rust log crate documentation](https://docs.rs/log/)
- [env_logger documentation](https://docs.rs/env_logger/)
- [Structured logging best practices](https://www.honeycomb.io/blog/structured-logging/)
- [The Log: What every software engineer should know](https://blog.codinghorror.com/the-log-everything-manifesto/)
---
**Remember:** Good logging is like good cinematography - it should be invisible when done right, but tell a compelling story when you pay attention to it. 🎬

View file

@ -229,13 +229,16 @@ impl Orchestrator {
) -> Result<OrchestrationResult, Box<dyn std::error::Error + Send + Sync>> {
let intent_preview = &classification.original_text
[..classification.original_text.len().min(80)];
info!("Pipeline starting - task: {}, intent: {}", self.task_id, intent_preview);
info!(
"Orchestrator: starting pipeline task={} intent={}",
self.task_id, intent_preview
);
self.broadcast_pipeline_start();
// ── Stage 1: PLAN ──────────────────────────────────────────────────
if let Err(e) = self.execute_plan_stage(classification).await {
error!("Stage 1 PLAN failed: {}", e);
error!("Plan stage failed: {e}");
return Ok(self.failure_result(0, &format!("Planning failed: {e}")));
}
@ -246,7 +249,7 @@ impl Orchestrator {
{
Ok(pair) => pair,
Err(e) => {
error!("Stage 2 BUILD failed: {}", e);
error!("Build stage failed: {e}");
return Ok(self.failure_result(1, &format!("Build failed: {e}")));
}
};
@ -272,10 +275,6 @@ impl Orchestrator {
.map(|r| format!("{} table created", r.name))
.collect();
// Log final summary
info!("Pipeline complete - task: {}, nodes: {}, resources: {}, url: {}",
self.task_id, node_count, resources.len(), app_url);
let message = format!(
"Got it. Here's the plan: I broke it down in **{node_count} nodes**.\n\n{}\n\nApp deployed at **{app_url}**",
if resource_summary.is_empty() {
@ -318,8 +317,6 @@ impl Orchestrator {
&mut self,
classification: &ClassifiedIntent,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Stage 1 PLAN starting - Agent #1 analyzing request");
self.update_stage(0, StageStatus::Running);
self.update_agent_status(1, AgentStatus::Working, Some("Analyzing request"));
self.broadcast_thought(
@ -356,7 +353,6 @@ impl Orchestrator {
self.update_stage(0, StageStatus::Completed);
self.update_agent_status(1, AgentStatus::Evolved, None);
info!("Stage 1 PLAN complete - {} nodes planned", node_count);
Ok(())
}
@ -369,8 +365,6 @@ impl Orchestrator {
classification: &ClassifiedIntent,
session: &UserSession,
) -> Result<(String, Vec<CreatedResource>), Box<dyn std::error::Error + Send + Sync>> {
info!("Stage 2 BUILD starting - Agent #2 generating code");
self.update_stage(1, StageStatus::Running);
self.update_agent_status(2, AgentStatus::Bred, Some("Preparing build"));
self.broadcast_thought(2, "Builder agent bred. Starting code generation...");
@ -443,12 +437,9 @@ impl Orchestrator {
self.update_stage(1, StageStatus::Completed);
self.update_agent_status(2, AgentStatus::Evolved, None);
info!("Stage 2 BUILD complete - {} resources, url: {}",
resources.len(), app_url);
Ok((app_url, resources))
}
Err(e) => {
error!("Stage 2 BUILD failed: {}", e);
self.update_stage(1, StageStatus::Failed);
self.update_agent_status(2, AgentStatus::Failed, Some("Build failed"));
Err(e)
@ -461,8 +452,6 @@ impl Orchestrator {
// =========================================================================
async fn execute_review_stage(&mut self, resources: &[CreatedResource]) {
info!("Stage 3 REVIEW starting - Agent #3 checking code quality");
self.update_stage(2, StageStatus::Running);
self.update_agent_status(3, AgentStatus::Bred, Some("Starting review"));
self.broadcast_thought(
@ -497,7 +486,6 @@ impl Orchestrator {
self.update_stage(2, StageStatus::Completed);
self.update_agent_status(3, AgentStatus::Evolved, None);
info!("Stage 3 REVIEW complete - all checks passed");
}
// =========================================================================
@ -505,8 +493,6 @@ impl Orchestrator {
// =========================================================================
async fn execute_deploy_stage(&mut self, app_url: &str) {
info!("Stage 4 DEPLOY starting - Agent #4 deploying to {}", app_url);
self.update_stage(3, StageStatus::Running);
self.update_agent_status(4, AgentStatus::Bred, Some("Preparing deploy"));
self.broadcast_thought(
@ -532,7 +518,6 @@ impl Orchestrator {
self.update_stage(3, StageStatus::Completed);
self.update_agent_status(4, AgentStatus::Evolved, None);
info!("Stage 4 DEPLOY complete - app live at {}", app_url);
}
// =========================================================================
@ -540,8 +525,6 @@ impl Orchestrator {
// =========================================================================
async fn execute_monitor_stage(&mut self, app_url: &str) {
info!("Stage 5 MONITOR starting - Agent #1 setting up monitoring");
self.update_stage(4, StageStatus::Running);
self.broadcast_thought(
1,
@ -561,7 +544,6 @@ impl Orchestrator {
self.broadcast_activity(1, "monitor_complete", &activity);
self.update_stage(4, StageStatus::Completed);
info!("Stage 5 MONITOR complete - monitoring active");
}
// =========================================================================

View file

@ -1,4 +1,4 @@
use log::trace;
use log::{info, trace};
pub fn convert_mail_line_with_substitution(line: &str) -> String {
let mut result = String::new();

View file

@ -4,7 +4,7 @@ pub mod talk;
pub use mail::convert_mail_block;
pub use talk::convert_talk_block;
use log::trace;
use log::{info, trace};
pub fn convert_begin_blocks(script: &str) -> String {
let mut result = String::new();

View file

@ -1,4 +1,4 @@
use log::trace;
use log::{info, trace};
pub fn convert_talk_line_with_substitution(line: &str) -> String {
let mut result = String::new();

View file

@ -6,7 +6,7 @@ use crate::basic::keywords::switch_case::switch_keyword;
use crate::core::shared::models::UserSession;
use crate::core::shared::state::AppState;
use diesel::prelude::*;
use log::trace;
use log::{info, trace};
use rhai::{Dynamic, Engine, EvalAltResult, Scope};
use std::collections::HashMap;
use std::sync::Arc;

View file

@ -146,9 +146,9 @@ impl BootstrapManager {
}
}
if pm.is_installed("drive") {
if pm.is_installed("minio") {
info!("Starting MinIO...");
match pm.start("drive") {
match pm.start("minio") {
Ok(_child) => {
info!("MinIO started");
}
@ -178,18 +178,13 @@ impl BootstrapManager {
info!("Zitadel/Directory service is already running");
// Create OAuth client if config doesn't exist (even when already running)
// Check both Vault and file system for existing config
let config_path = self.stack_dir("conf/system/directory_config.json");
let has_config = config_path.exists();
if !has_config {
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),
}
} else {
info!("Directory config already exists, skipping OAuth setup");
}
} else {
info!("Starting Zitadel/Directory service...");

View file

@ -208,7 +208,7 @@ pub enum BotExistsResult {
pub fn zitadel_health_check() -> bool {
// Check if Zitadel is responding on port 8300
if let Ok(output) = Command::new("curl")
.args(["-f", "-s", "--connect-timeout", "2", "http://localhost:8300/debug/healthz"])
.args(["-f", "-s", "--connect-timeout", "2", "http://localhost:8300/debug/ready"])
.output()
{
if output.status.success() {

View file

@ -273,11 +273,8 @@ impl BotOrchestrator {
}
info!(
"BotServer ready - {} bots loaded",
bots_mounted
);
log::debug!(
"Bot mounting details: {} created, {} already existed",
"Bot mounting complete: {} bots processed ({} created, {} already existed)",
bots_mounted,
bots_created,
bots_mounted - bots_created
);
@ -396,7 +393,7 @@ impl BotOrchestrator {
.execute(&mut conn)
.map_err(|e| format!("Failed to create bot: {e}"))?;
info!("User system created resource: bot {}", bot_id);
info!("Created bot '{}' with ID '{}'", bot_name, bot_id);
Ok(())
}

View file

@ -455,62 +455,16 @@ impl PackageManager {
pre_install_cmds_linux: vec![
"mkdir -p {{CONF_PATH}}/directory".to_string(),
"mkdir -p {{LOGS_PATH}}".to_string(),
// Create Zitadel steps YAML: configures a machine user (service account)
// with IAM_OWNER role and writes a PAT file for API bootstrap
concat!(
"cat > {{CONF_PATH}}/directory/zitadel-init-steps.yaml << 'STEPSEOF'\n",
"FirstInstance:\n",
" Org:\n",
" Machine:\n",
" Machine:\n",
" Username: gb-service-account\n",
" Name: General Bots Service Account\n",
" MachineKey:\n",
" Type: 1\n",
" Pat:\n",
" ExpirationDate: '2099-01-01T00:00:00Z'\n",
" PatPath: {{CONF_PATH}}/directory/admin-pat.txt\n",
" MachineKeyPath: {{CONF_PATH}}/directory/machine-key.json\n",
"STEPSEOF",
).to_string(),
],
post_install_cmds_linux: vec![
// Create zitadel DB user before start-from-init
"PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE ROLE zitadel WITH LOGIN PASSWORD 'zitadel'\" 2>&1 | grep -v 'already exists' || true".to_string(),
"PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"CREATE DATABASE zitadel WITH OWNER zitadel\" 2>&1 | grep -v 'already exists' || true".to_string(),
"PGPASSWORD='{{DB_PASSWORD}}' {{STACK_PATH}}/bin/tables/bin/psql -h localhost -p 5432 -U gbuser -d postgres -c \"GRANT ALL PRIVILEGES ON DATABASE zitadel TO zitadel\" 2>&1 || true".to_string(),
// Start Zitadel with --steps pointing to our init file (creates machine user + PAT)
concat!(
"ZITADEL_PORT=8300 ",
"ZITADEL_DATABASE_POSTGRES_HOST=localhost ",
"ZITADEL_DATABASE_POSTGRES_PORT=5432 ",
"ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=gbuser ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD={{DB_PASSWORD}} ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ",
"ZITADEL_EXTERNALSECURE=false ",
"ZITADEL_EXTERNALDOMAIN=localhost ",
"ZITADEL_EXTERNALPORT=8300 ",
"ZITADEL_TLS_ENABLED=false ",
"nohup {{BIN_PATH}}/zitadel start-from-init ",
"--masterkey MasterkeyNeedsToHave32Characters ",
"--tlsMode disabled ",
"--steps {{CONF_PATH}}/directory/zitadel-init-steps.yaml ",
"> {{LOGS_PATH}}/zitadel.log 2>&1 &",
).to_string(),
// Wait for Zitadel to be ready
"for i in $(seq 1 120); do curl -sf http://localhost:8300/debug/healthz && echo 'Zitadel is ready!' && break || sleep 2; done".to_string(),
// Wait for PAT token to be written to logs with retry loop
// Zitadel may take several seconds to write the PAT after health check passes
"echo 'Waiting for PAT token in logs...'; for i in $(seq 1 30); do sync; if grep -q -E '^[A-Za-z0-9_-]{40,}$' {{LOGS_PATH}}/zitadel.log 2>/dev/null; then echo \"PAT token found in logs after $((i*2)) seconds\"; break; fi; sleep 2; done".to_string(),
// Extract PAT token from logs if Zitadel printed it to stdout instead of file
// The PAT appears as a standalone line (alphanumeric with hyphens/underscores) after machine key JSON
"if [ ! -f '{{CONF_PATH}}/directory/admin-pat.txt' ]; then grep -E '^[A-Za-z0-9_-]{40,}$' {{LOGS_PATH}}/zitadel.log 2>/dev/null | head -1 > {{CONF_PATH}}/directory/admin-pat.txt && echo 'PAT extracted from logs' || echo 'Could not extract PAT from logs'; fi".to_string(),
// Verify PAT file was created and is not empty
"sync; sleep 1; if [ -f '{{CONF_PATH}}/directory/admin-pat.txt' ] && [ -s '{{CONF_PATH}}/directory/admin-pat.txt' ]; then echo 'PAT token created successfully'; cat {{CONF_PATH}}/directory/admin-pat.txt; else echo 'WARNING: PAT file not found or empty'; fi".to_string(),
"cat > {{CONF_PATH}}/directory/steps.yaml << 'EOF'\n---\nDatabase:\n postgres:\n Host: localhost\n Port: 5432\n Database: zitadel\n User:\n Username: zitadel\n Password: zitadel\n SSL:\n Mode: disable\n Admin:\n Username: gbuser\n Password: {{DB_PASSWORD}}\n SSL:\n Mode: disable\nEOF".to_string(),
"cat > {{CONF_PATH}}/directory/zitadel.yaml << 'EOF'\nLog:\n Level: info\n\nDatabase:\n postgres:\n Host: localhost\n Port: 5432\n Database: zitadel\n User:\n Username: zitadel\n Password: zitadel\n SSL:\n Mode: disable\n Admin:\n Username: gbuser\n Password: {{DB_PASSWORD}}\n SSL:\n Mode: disable\n\nMachine:\n Identification:\n Hostname: localhost\n WebhookAddress: http://localhost:8080\n\nPort: 8300\nExternalDomain: localhost\nExternalPort: 8300\nExternalSecure: false\n\nTLS:\n Enabled: false\nEOF".to_string(),
"ZITADEL_MASTERKEY=$(VAULT_ADDR=https://localhost:8200 VAULT_CACERT={{CONF_PATH}}/system/certificates/ca/ca.crt vault kv get -field=masterkey secret/gbo/directory 2>/dev/null || echo 'MasterkeyNeedsToHave32Characters') nohup {{BIN_PATH}}/zitadel start-from-init --config {{CONF_PATH}}/directory/zitadel.yaml --masterkeyFromEnv --tlsMode disabled --steps {{CONF_PATH}}/directory/steps.yaml > {{LOGS_PATH}}/zitadel.log 2>&1 &".to_string(),
"for i in $(seq 1 90); do curl -sf http://localhost:8300/debug/ready && break || sleep 1; done".to_string(),
],
pre_install_cmds_macos: vec![
"mkdir -p {{CONF_PATH}}/directory".to_string(),
@ -519,34 +473,14 @@ impl PackageManager {
pre_install_cmds_windows: vec![],
post_install_cmds_windows: vec![],
env_vars: HashMap::from([
("ZITADEL_PORT".to_string(), "8300".to_string()),
("ZITADEL_EXTERNALSECURE".to_string(), "false".to_string()),
("ZITADEL_EXTERNALDOMAIN".to_string(), "localhost".to_string()),
("ZITADEL_EXTERNALPORT".to_string(), "8300".to_string()),
("ZITADEL_TLS_ENABLED".to_string(), "false".to_string()),
]),
data_download_list: Vec::new(),
exec_cmd: concat!(
"ZITADEL_PORT=8300 ",
"ZITADEL_DATABASE_POSTGRES_HOST=localhost ",
"ZITADEL_DATABASE_POSTGRES_PORT=5432 ",
"ZITADEL_DATABASE_POSTGRES_DATABASE=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_USERNAME=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_PASSWORD=zitadel ",
"ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE=disable ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME=gbuser ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD={{DB_PASSWORD}} ",
"ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE=disable ",
"ZITADEL_EXTERNALSECURE=false ",
"ZITADEL_EXTERNALDOMAIN=localhost ",
"ZITADEL_EXTERNALPORT=8300 ",
"ZITADEL_TLS_ENABLED=false ",
"nohup {{BIN_PATH}}/zitadel start ",
"--masterkey MasterkeyNeedsToHave32Characters ",
"--tlsMode disabled ",
"> {{LOGS_PATH}}/zitadel.log 2>&1 &",
).to_string(),
check_cmd: "curl -f --connect-timeout 2 -m 5 http://localhost:8300/debug/healthz >/dev/null 2>&1".to_string(),
exec_cmd: "ZITADEL_MASTERKEY=$(VAULT_ADDR=https://localhost:8200 VAULT_CACERT={{CONF_PATH}}/system/certificates/ca/ca.crt vault kv get -field=masterkey secret/gbo/directory 2>/dev/null || echo 'MasterkeyNeedsToHave32Characters') nohup {{BIN_PATH}}/zitadel start --config {{CONF_PATH}}/directory/zitadel.yaml --masterkeyFromEnv --tlsMode disabled > {{LOGS_PATH}}/zitadel.log 2>&1 &".to_string(),
check_cmd: "curl -f --connect-timeout 2 -m 5 http://localhost:8300/healthz >/dev/null 2>&1".to_string(),
},
);
}

View file

@ -45,16 +45,108 @@ 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
}
/// Admin credentials structure
#[cfg(feature = "directory")]
struct AdminCredentials {
email: String,
password: String,
}
/// 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;
use std::collections::HashMap;
let stack_path = std::env::var("BOTSERVER_STACK_PATH")
.unwrap_or_else(|_| "./botserver-stack".to_string());
@ -62,51 +154,11 @@ pub async fn setup_directory() -> anyhow::Result<crate::core::package_manager::s
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 in Vault first
if let Ok(secrets_manager) = crate::core::secrets::SecretsManager::from_env() {
if secrets_manager.is_enabled() {
if let Ok(secrets) = secrets_manager.get_secret(crate::core::secrets::SecretPaths::DIRECTORY).await {
if let (Some(client_id), Some(client_secret)) = (secrets.get("client_id"), secrets.get("client_secret")) {
// Validate that credentials are real, not placeholders
let is_valid = !client_id.is_empty()
&& !client_secret.is_empty()
&& client_secret != "..."
&& client_id.contains('@') // OAuth client IDs contain @
&& client_secret.len() > 10; // Real secrets are longer than placeholders
if is_valid {
log::info!("Directory already configured with OAuth client in Vault");
// Reconstruct config from Vault
let config = crate::core::package_manager::setup::DirectoryConfig {
base_url: base_url.clone(),
issuer_url: secrets.get("issuer_url").cloned().unwrap_or_else(|| base_url.clone()),
issuer: secrets.get("issuer").cloned().unwrap_or_else(|| base_url.clone()),
client_id: client_id.clone(),
client_secret: client_secret.clone(),
redirect_uri: secrets.get("redirect_uri").cloned().unwrap_or_else(|| "http://localhost:3000/auth/callback".to_string()),
project_id: secrets.get("project_id").cloned().unwrap_or_default(),
api_url: secrets.get("api_url").cloned().unwrap_or_else(|| base_url.clone()),
service_account_key: secrets.get("service_account_key").cloned(),
};
return Ok(config);
}
}
}
}
}
// Check if config already exists with valid OAuth client in file
// Check if config already exists with valid OAuth client
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) {
// Validate that credentials are real, not placeholders
let is_valid = !config.client_id.is_empty()
&& !config.client_secret.is_empty()
&& config.client_secret != "..."
&& config.client_id.contains('@')
&& config.client_secret.len() > 10;
if is_valid {
if !config.client_id.is_empty() && !config.client_secret.is_empty() {
log::info!("Directory already configured with OAuth client");
return Ok(config);
}
@ -114,33 +166,96 @@ pub async fn setup_directory() -> anyhow::Result<crate::core::package_manager::s
}
}
// Initialize directory with default credentials
let mut directory_setup = crate::core::package_manager::setup::DirectorySetup::new(base_url.clone(), config_path.clone());
let config = directory_setup.initialize().await
.map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e))?;
// Try to get credentials from multiple sources
let credentials = get_admin_credentials(&stack_path).await?;
// Store credentials in Vault
if let Ok(secrets_manager) = crate::core::secrets::SecretsManager::from_env() {
if secrets_manager.is_enabled() {
let mut secrets = HashMap::new();
secrets.insert("url".to_string(), config.base_url.clone());
secrets.insert("issuer_url".to_string(), config.issuer_url.clone());
secrets.insert("issuer".to_string(), config.issuer.clone());
secrets.insert("client_id".to_string(), config.client_id.clone());
secrets.insert("client_secret".to_string(), config.client_secret.clone());
secrets.insert("redirect_uri".to_string(), config.redirect_uri.clone());
secrets.insert("project_id".to_string(), config.project_id.clone());
secrets.insert("api_url".to_string(), config.api_url.clone());
if let Some(key) = &config.service_account_key {
secrets.insert("service_account_key".to_string(), key.clone());
}
let mut directory_setup = crate::core::package_manager::setup::DirectorySetup::with_admin_credentials(
base_url,
config_path.clone(),
credentials.email,
credentials.password,
);
match secrets_manager.put_secret(crate::core::secrets::SecretPaths::DIRECTORY, secrets).await {
Ok(_) => log::info!("Directory credentials stored in Vault"),
Err(e) => log::warn!("Failed to store directory credentials in Vault: {}", e),
}
}
}
Ok(config)
directory_setup.initialize().await
.map_err(|e| anyhow::anyhow!("Failed to initialize directory: {}", e))
}
/// Get admin credentials from multiple sources
#[cfg(feature = "directory")]
async fn get_admin_credentials(stack_path: &str) -> anyhow::Result<AdminCredentials> {
// Approach 1: Read from ~/.gb-setup-credentials (most reliable - from first bootstrap)
if let Some(creds) = read_saved_credentials() {
log::info!("Using credentials from ~/.gb-setup-credentials");
return Ok(creds);
}
// Approach 2: Try to extract from Zitadel logs (fallback)
let log_path = std::path::PathBuf::from(stack_path).join("logs/directory/zitadel.log");
if let Some((email, password)) = extract_initial_admin_from_log(&log_path) {
log::info!("Using credentials extracted from Zitadel log");
return Ok(AdminCredentials { email, password });
}
// This should not be reached - initialize() will handle authentication errors
// If we get here, it means credentials were found but authentication failed
log::error!("═══════════════════════════════════════════════════════════════");
log::error!("❌ ZITADEL AUTHENTICATION FAILED");
log::error!("═══════════════════════════════════════════════════════════════");
log::error!("Credentials were found but authentication failed.");
log::error!("This usually means:");
log::error!(" • Credentials are from a previous Zitadel installation");
log::error!(" • User account is locked or disabled");
log::error!(" • Password has been changed");
log::error!("");
log::error!("SOLUTION: Reset and create fresh credentials:");
log::error!(" 1. Delete: rm ~/.gb-setup-credentials");
log::error!(" 2. Delete: rm .env");
log::error!(" 3. Delete: rm botserver-stack/conf/system/.bootstrap_completed");
log::error!(" 4. Run: ./reset.sh");
log::error!(" 5. New admin credentials will be displayed and saved");
log::error!("═══════════════════════════════════════════════════════════════");
anyhow::bail!("Authentication failed. Reset bootstrap to create fresh credentials.")
}
/// Read credentials from ~/.gb-setup-credentials file
#[cfg(feature = "directory")]
fn read_saved_credentials() -> Option<AdminCredentials> {
let home = std::env::var("HOME").ok()?;
let creds_path = std::path::PathBuf::from(&home).join(".gb-setup-credentials");
if !creds_path.exists() {
return None;
}
let content = std::fs::read_to_string(&creds_path).ok()?;
// Parse credentials from file
let mut username = None;
let mut password = None;
let mut email = None;
for line in content.lines() {
if line.contains("Username:") {
username = line.split("Username:")
.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 line.contains("Email:") {
email = line.split("Email:")
.nth(1)
.map(|s| s.trim().to_string());
}
}
if let (Some(_username), Some(password), Some(email)) = (username, password, email) {
Some(AdminCredentials { email, password })
} else {
None
}
}

View file

@ -1,338 +0,0 @@
use anyhow::Result;
use log::{info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use reqwest::Client;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectoryConfig {
pub base_url: String,
pub issuer_url: String,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub project_id: String,
pub api_url: String,
pub service_account_key: Option<String>,
}
pub struct DirectorySetup {
base_url: String,
config_path: PathBuf,
http_client: Client,
pat_token: Option<String>,
}
#[derive(Debug, Serialize)]
struct CreateProjectRequest {
name: String,
}
#[derive(Debug, Deserialize)]
struct ProjectResponse {
id: String,
}
impl DirectorySetup {
pub fn new(base_url: String, config_path: PathBuf) -> Self {
let http_client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|_| Client::new());
Self {
base_url,
config_path,
http_client,
pat_token: None,
}
}
pub async fn initialize(&mut self) -> Result<DirectoryConfig> {
info!("Initializing Directory (Zitadel) OAuth client...");
// Step 1: Wait for Zitadel to be ready
self.wait_for_zitadel().await?;
// Step 2: Load PAT token from file (created by start-from-init --steps)
info!("Loading PAT token from Zitadel init steps...");
self.load_pat_token()?;
// Step 3: Get or create project
let project_id = self.get_or_create_project().await?;
info!("Using project ID: {project_id}");
// Step 4: Create OAuth application
let (client_id, client_secret) = self.create_oauth_application(&project_id).await?;
info!("Created OAuth application with client_id: {client_id}");
// Step 5: Create config
let config = DirectoryConfig {
base_url: self.base_url.clone(),
issuer_url: self.base_url.clone(),
issuer: self.base_url.clone(),
client_id,
client_secret,
redirect_uri: "http://localhost:3000/auth/callback".to_string(),
project_id,
api_url: self.base_url.clone(),
service_account_key: None,
};
// Step 6: Save config
self.save_config(&config)?;
Ok(config)
}
async fn wait_for_zitadel(&self) -> Result<()> {
info!("Waiting for Zitadel to be ready...");
for i in 1..=120 {
match self.http_client
.get(format!("{}/debug/healthz", self.base_url))
.send()
.await
{
Ok(response) if response.status().is_success() => {
info!("Zitadel is ready (healthz OK)");
return Ok(());
}
_ => {
if i % 15 == 0 {
info!("Still waiting for Zitadel... (attempt {i}/120)");
}
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
}
Err(anyhow::anyhow!("Zitadel did not become ready within 240 seconds"))
}
/// Load the PAT token from the file generated by Zitadel's start-from-init --steps
/// The steps YAML configures FirstInstance.Org.PatPath which tells Zitadel to
/// create a machine user with IAM_OWNER role and write its PAT to disk
fn load_pat_token(&mut self) -> Result<()> {
let stack_path = std::env::var("BOTSERVER_STACK_PATH")
.unwrap_or_else(|_| "./botserver-stack".to_string());
let pat_path = PathBuf::from(&stack_path).join("conf/directory/admin-pat.txt");
if pat_path.exists() {
let pat_token = std::fs::read_to_string(&pat_path)
.map_err(|e| anyhow::anyhow!("Failed to read PAT file {}: {}", pat_path.display(), e))?
.trim()
.to_string();
if pat_token.is_empty() {
return Err(anyhow::anyhow!(
"PAT file exists at {} but is empty. Zitadel start-from-init may have failed.",
pat_path.display()
));
}
info!("Loaded PAT token from: {} (len={})", pat_path.display(), pat_token.len());
self.pat_token = Some(pat_token);
return Ok(());
}
// Also check the legacy location
let legacy_pat_path = std::path::Path::new("./botserver-stack/conf/directory/admin-pat.txt");
if legacy_pat_path.exists() {
let pat_token = std::fs::read_to_string(legacy_pat_path)
.map_err(|e| anyhow::anyhow!("Failed to read PAT file: {e}"))?
.trim()
.to_string();
if !pat_token.is_empty() {
info!("Loaded PAT token from legacy path");
self.pat_token = Some(pat_token);
return Ok(());
}
}
Err(anyhow::anyhow!(
"No PAT token file found at {}. \
Zitadel must be started with 'start-from-init --steps <steps.yaml>' \
where steps.yaml has FirstInstance.Org.PatPath configured.",
pat_path.display()
))
}
async fn get_or_create_project(&self) -> Result<String> {
info!("Getting or creating Zitadel project...");
let auth_header = self.get_auth_header()?;
// Try to list existing projects via management API v1
let list_response = self.http_client
.post(format!("{}/management/v1/projects/_search", self.base_url))
.header("Authorization", &auth_header)
.json(&serde_json::json!({}))
.send()
.await?;
if list_response.status().is_success() {
let projects: serde_json::Value = list_response.json().await?;
if let Some(result) = projects.get("result").and_then(|r| r.as_array()) {
for project in result {
if project.get("name")
.and_then(|n| n.as_str())
.map(|n| n == "General Bots")
.unwrap_or(false)
{
if let Some(id) = project.get("id").and_then(|i| i.as_str()) {
info!("Found existing 'General Bots' project: {id}");
return Ok(id.to_string());
}
}
}
}
}
// Create new project
info!("Creating new 'General Bots' project...");
let create_request = CreateProjectRequest {
name: "General Bots".to_string(),
};
let create_response = self.http_client
.post(format!("{}/management/v1/projects", self.base_url))
.header("Authorization", self.get_auth_header()?)
.json(&create_request)
.send()
.await?;
if !create_response.status().is_success() {
let error_text = create_response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Failed to create project: {error_text}"));
}
let project: ProjectResponse = create_response.json().await?;
info!("Created project with ID: {}", project.id);
Ok(project.id)
}
async fn create_oauth_application(&self, project_id: &str) -> Result<(String, String)> {
info!("Creating OAuth/OIDC application for BotServer...");
let auth_header = self.get_auth_header()?;
// Use the management v1 OIDC app creation endpoint which returns
// client_id and client_secret in the response directly
let app_body = serde_json::json!({
"name": "BotServer",
"redirectUris": [
"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"
],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_POST",
"postLogoutRedirectUris": ["http://localhost:3000"],
"devMode": true
});
let app_response = self.http_client
.post(format!("{}/management/v1/projects/{project_id}/apps/oidc", self.base_url))
.header("Authorization", &auth_header)
.json(&app_body)
.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 OIDC app: {error_text}"));
}
let app_data: serde_json::Value = app_response.json().await
.map_err(|e| anyhow::anyhow!("Failed to parse OIDC app response: {e}"))?;
info!("OIDC app creation response: {}", serde_json::to_string_pretty(&app_data).unwrap_or_default());
// The response contains clientId and clientSecret directly
let client_id = app_data
.get("clientId")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No clientId in OIDC app response: {app_data}"))?
.to_string();
let client_secret = app_data
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if client_secret.is_empty() {
warn!("No clientSecret returned — app may use PKCE only");
}
info!("Retrieved OAuth client credentials (client_id: {client_id})");
Ok((client_id, client_secret))
}
fn get_auth_header(&self) -> Result<String> {
match &self.pat_token {
Some(token) => Ok(format!("Bearer {token}")),
None => Err(anyhow::anyhow!(
"No PAT token available. Cannot authenticate with Zitadel API."
)),
}
}
fn save_config(&self, config: &DirectoryConfig) -> Result<()> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&self.config_path, content)?;
info!("Saved Directory config to: {}", self.config_path.display());
println!();
println!("╔════════════════════════════════════════════════════════════╗");
println!("║ ZITADEL OAUTH CLIENT CONFIGURED ║");
println!("╠════════════════════════════════════════════════════════════╣");
println!("║ Project ID: {:<43}", config.project_id);
println!("║ Client ID: {:<43}", config.client_id);
println!("║ Redirect URI: {:<43}", config.redirect_uri);
println!("║ Config saved: {:<43}", self.config_path.display().to_string().chars().take(43).collect::<String>());
println!("╚════════════════════════════════════════════════════════════╝");
println!();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_directory_config_serialization() {
let config = DirectoryConfig {
base_url: "http://localhost:8300".to_string(),
issuer_url: "http://localhost:8300".to_string(),
issuer: "http://localhost:8300".to_string(),
client_id: "test_client".to_string(),
client_secret: "test_secret".to_string(),
redirect_uri: "http://localhost:3000/callback".to_string(),
project_id: "12345".to_string(),
api_url: "http://localhost:8300".to_string(),
service_account_key: None,
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("test_client"));
assert!(json.contains("test_secret"));
}
}

View file

@ -0,0 +1,631 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tokio::time::sleep;
#[derive(Debug)]
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,
}
impl DirectorySetup {
pub fn set_admin_token(&mut self, token: String) {
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(&params)
.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 async fn ensure_admin_token(&mut self) -> Result<()> {
if self.admin_token.is_none() && self.admin_credentials.is_none() {
return Err(anyhow::anyhow!("Admin token or credentials must be configured"));
}
// If we have credentials but no token, authenticate and get the token
if self.admin_token.is_none() && self.admin_credentials.is_some() {
let token = self.get_admin_access_token().await?;
self.admin_token = Some(token);
log::info!("Obtained admin access token from credentials");
}
Ok(())
}
fn generate_secure_password() -> String {
use rand::distr::Alphanumeric;
use rand::Rng;
let mut rng = rand::rng();
(0..16)
.map(|_| {
let byte = rng.sample(Alphanumeric);
char::from(byte)
})
.collect()
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DefaultOrganization {
pub id: String,
pub name: String,
pub domain: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DefaultUser {
pub id: String,
pub username: String,
pub email: String,
pub password: String,
pub first_name: String,
pub last_name: String,
}
pub struct CreateUserParams<'a> {
pub org_id: &'a str,
pub username: &'a str,
pub email: &'a str,
pub password: &'a str,
pub first_name: &'a str,
pub last_name: &'a str,
pub is_admin: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DirectoryConfig {
pub base_url: String,
pub default_org: DefaultOrganization,
pub default_user: DefaultUser,
pub admin_token: String,
pub project_id: String,
pub client_id: String,
pub client_secret: String,
}
impl DirectorySetup {
pub fn new(base_url: String, config_path: PathBuf) -> 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: 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,
}
}
pub async fn wait_for_ready(&self, max_attempts: u32) -> Result<()> {
log::info!("Waiting for Directory service to be ready...");
for attempt in 1..=max_attempts {
match self
.client
.get(format!("{}/debug/ready", self.base_url))
.send()
.await
{
Ok(response) if response.status().is_success() => {
log::info!("Directory service is ready!");
return Ok(());
}
_ => {
log::debug!(
"Directory not ready yet (attempt {}/{})",
attempt,
max_attempts
);
sleep(Duration::from_secs(3)).await;
}
}
}
anyhow::bail!("Directory service did not become ready in time")
}
pub async fn initialize(&mut self) -> Result<DirectoryConfig> {
log::info!(" Initializing Directory (Zitadel) with defaults...");
if let Ok(existing_config) = self.load_existing_config().await {
log::info!("Directory already initialized, using existing config");
return Ok(existing_config);
}
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().await?;
let org = self.create_default_organization().await?;
log::info!(" Created default organization: {}", org.name);
let user = self.create_default_user(&org.id).await?;
log::info!(" Created default user: {}", user.username);
// 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?;
log::info!(" Granted admin permissions to default user");
let config = DirectoryConfig {
base_url: self.base_url.clone(),
default_org: org,
default_user: user,
admin_token: self.admin_token.clone().unwrap_or_default(),
project_id,
client_id,
client_secret,
};
self.save_config_internal(&config).await?;
log::info!(" Saved Directory configuration");
log::info!(" Directory initialization complete!");
log::info!("");
log::info!("╔══════════════════════════════════════════════════════════════╗");
log::info!("║ DEFAULT CREDENTIALS ║");
log::info!("╠══════════════════════════════════════════════════════════════╣");
log::info!("║ Email: {:<50}║", config.default_user.email);
log::info!("║ Password: {:<50}║", config.default_user.password);
log::info!("╠══════════════════════════════════════════════════════════════╣");
log::info!("║ Login at: {:<50}║", self.base_url);
log::info!("╚══════════════════════════════════════════════════════════════╝");
log::info!("");
log::info!(">>> COPY THESE CREDENTIALS NOW - Press ENTER to continue <<<");
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
Ok(config)
}
pub async fn create_organization(&mut self, name: &str, description: &str) -> Result<String> {
self.ensure_admin_token().await?;
let response = self
.client
.post(format!("{}/management/v1/orgs", self.base_url))
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
.json(&json!({
"name": name,
"description": description,
}))
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
anyhow::bail!("Failed to create organization: {}", error_text);
}
let result: serde_json::Value = response.json().await?;
Ok(result["id"].as_str().unwrap_or("").to_string())
}
async fn create_default_organization(&self) -> Result<DefaultOrganization> {
let org_name = "BotServer".to_string();
let response = self
.client
.post(format!("{}/management/v1/orgs", self.base_url))
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
.json(&json!({
"name": org_name,
}))
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
anyhow::bail!("Failed to create organization: {}", error_text);
}
let result: serde_json::Value = response.json().await?;
Ok(DefaultOrganization {
id: result["id"].as_str().unwrap_or("").to_string(),
name: org_name.clone(),
domain: format!("{}.localhost", org_name.to_lowercase()),
})
}
pub async fn create_user(
&mut self,
params: CreateUserParams<'_>,
) -> Result<DefaultUser> {
self.ensure_admin_token().await?;
let response = self
.client
.post(format!("{}/management/v1/users/human", self.base_url))
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
.json(&json!({
"userName": params.username,
"profile": {
"firstName": params.first_name,
"lastName": params.last_name,
"displayName": format!("{} {}", params.first_name, params.last_name)
},
"email": {
"email": params.email,
"isEmailVerified": true
},
"password": params.password,
"organisation": {
"orgId": params.org_id
}
}))
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
anyhow::bail!("Failed to create user: {}", error_text);
}
let result: serde_json::Value = response.json().await?;
let user = DefaultUser {
id: result["userId"].as_str().unwrap_or("").to_string(),
username: params.username.to_string(),
email: params.email.to_string(),
password: params.password.to_string(),
first_name: params.first_name.to_string(),
last_name: params.last_name.to_string(),
};
if params.is_admin {
self.grant_user_permissions(params.org_id, &user.id).await?;
}
Ok(user)
}
async fn create_default_user(&self, org_id: &str) -> Result<DefaultUser> {
let username = format!(
"admin_{}",
uuid::Uuid::new_v4()
.to_string()
.chars()
.take(8)
.collect::<String>()
);
let email = format!("{}@botserver.local", username);
let password = Self::generate_secure_password();
let response = self
.client
.post(format!("{}/management/v1/users/human", self.base_url))
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
.json(&json!({
"userName": username,
"profile": {
"firstName": "Admin",
"lastName": "User",
"displayName": "Administrator"
},
"email": {
"email": email,
"isEmailVerified": true
},
"password": password,
"organisation": {
"orgId": org_id
}
}))
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
anyhow::bail!("Failed to create user: {}", error_text);
}
let result: serde_json::Value = response.json().await?;
Ok(DefaultUser {
id: result["userId"].as_str().unwrap_or("").to_string(),
username: username.clone(),
email: email.clone(),
password: password.clone(),
first_name: "Admin".to_string(),
last_name: "User".to_string(),
})
}
pub async fn create_oauth_application(
&self,
_org_id: &str,
) -> Result<(String, String, String)> {
let app_name = "BotServer";
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(&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(&access_token)
.json(&json!({
"name": app_name,
"redirectUris": [redirect_uri, "http://localhost:3000/auth/callback", "http://localhost:8080/auth/callback", "http://localhost:9000/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:8080", "http://localhost:3000", "http://localhost:9000"],
"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"]
.as_str()
.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))
}
pub async fn grant_user_permissions(&self, org_id: &str, user_id: &str) -> Result<()> {
let _response = self
.client
.post(format!(
"{}/management/v1/orgs/{}/members",
self.base_url, org_id
))
.bearer_auth(self.admin_token.as_ref().unwrap_or(&String::new()))
.json(&json!({
"userId": user_id,
"roles": ["ORG_OWNER"]
}))
.send()
.await?;
Ok(())
}
pub async fn save_config(
&mut self,
org_id: String,
org_name: String,
admin_user: DefaultUser,
client_id: String,
client_secret: String,
) -> Result<DirectoryConfig> {
self.ensure_admin_token().await?;
let config = DirectoryConfig {
base_url: self.base_url.clone(),
default_org: DefaultOrganization {
id: org_id,
name: org_name.clone(),
domain: format!("{}.localhost", org_name.to_lowercase()),
},
default_user: admin_user,
admin_token: self.admin_token.clone().unwrap_or_default(),
project_id: String::new(),
client_id,
client_secret,
};
let json = serde_json::to_string_pretty(&config)?;
fs::write(&self.config_path, json).await?;
log::info!(
"Saved Directory configuration to {}",
self.config_path.display()
);
Ok(config)
}
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.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(())
}
async fn load_existing_config(&self) -> Result<DirectoryConfig> {
let content = fs::read_to_string(&self.config_path).await?;
let config: DirectoryConfig = serde_json::from_str(&content)?;
Ok(config)
}
pub async fn get_config(&self) -> Result<DirectoryConfig> {
self.load_existing_config().await
}
}
pub async fn generate_directory_config(config_path: PathBuf, _db_path: PathBuf) -> Result<()> {
let yaml_config = r"
Log:
Level: info
Database:
Postgres:
Host: localhost
Port: 5432
Database: zitadel
User: zitadel
Password: zitadel
SSL:
Mode: disable
Machine:
Identification:
Hostname: localhost
WebhookAddress: http://localhost:8080
Port: 9000
ExternalDomain: localhost
ExternalPort: 9000
ExternalSecure: false
TLS:
Enabled: false
"
.to_string();
fs::write(config_path, yaml_config).await?;
Ok(())
}

View file

@ -0,0 +1,342 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tokio::time::sleep;
#[derive(Debug)]
pub struct EmailSetup {
base_url: String,
admin_user: String,
admin_pass: String,
config_path: PathBuf,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailConfig {
pub base_url: String,
pub smtp_host: String,
pub smtp_port: u16,
pub imap_host: String,
pub imap_port: u16,
pub admin_user: String,
pub admin_pass: String,
pub directory_integration: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct EmailDomain {
pub domain: String,
pub enabled: bool,
}
impl EmailSetup {
pub fn new(base_url: String, config_path: PathBuf) -> Self {
let admin_user = format!(
"admin_{}@botserver.local",
uuid::Uuid::new_v4()
.to_string()
.chars()
.take(8)
.collect::<String>()
);
let admin_pass = Self::generate_secure_password();
Self {
base_url,
admin_user,
admin_pass,
config_path,
}
}
fn generate_secure_password() -> String {
use rand::distr::Alphanumeric;
use rand::Rng;
let mut rng = rand::rng();
(0..16)
.map(|_| {
let byte = rng.sample(Alphanumeric);
char::from(byte)
})
.collect()
}
pub async fn wait_for_ready(&self, max_attempts: u32) -> Result<()> {
log::info!("Waiting for Email service to be ready...");
for attempt in 1..=max_attempts {
if tokio::net::TcpStream::connect("127.0.0.1:25").await.is_ok() {
log::info!("Email service is ready!");
return Ok(());
}
log::debug!(
"Email service not ready yet (attempt {}/{})",
attempt,
max_attempts
);
sleep(Duration::from_secs(3)).await;
}
anyhow::bail!("Email service did not become ready in time")
}
pub async fn initialize(
&mut self,
directory_config_path: Option<PathBuf>,
) -> Result<EmailConfig> {
log::info!(" Initializing Email (Stalwart) server...");
if let Ok(existing_config) = self.load_existing_config().await {
log::info!("Email already initialized, using existing config");
return Ok(existing_config);
}
self.wait_for_ready(30).await?;
self.create_default_domain()?;
log::info!(" Created default email domain: localhost");
let directory_integration = if let Some(dir_config_path) = directory_config_path {
match self.setup_directory_integration(&dir_config_path) {
Ok(_) => {
log::info!(" Integrated with Directory for authentication");
true
}
Err(e) => {
log::warn!(" Directory integration failed: {}", e);
false
}
}
} else {
false
};
self.create_admin_account().await?;
log::info!(" Created admin email account: {}", self.admin_user);
let config = EmailConfig {
base_url: self.base_url.clone(),
smtp_host: "localhost".to_string(),
smtp_port: 25,
imap_host: "localhost".to_string(),
imap_port: 143,
admin_user: self.admin_user.clone(),
admin_pass: self.admin_pass.clone(),
directory_integration,
};
self.save_config(&config).await?;
log::info!(" Saved Email configuration");
log::info!(" Email initialization complete!");
log::info!("📧 SMTP: localhost:25 (587 for TLS)");
log::info!("📬 IMAP: localhost:143 (993 for TLS)");
log::info!("👤 Admin: {} / {}", config.admin_user, config.admin_pass);
Ok(config)
}
fn create_default_domain(&self) -> Result<()> {
let _ = self;
Ok(())
}
async fn create_admin_account(&self) -> Result<()> {
log::info!("Creating admin email account via Stalwart API...");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let api_url = format!("{}/api/account", self.base_url);
let account_data = serde_json::json!({
"name": self.admin_user,
"secret": self.admin_pass,
"description": "BotServer Admin Account",
"quota": 1_073_741_824,
"type": "individual",
"emails": [self.admin_user.clone()],
"memberOf": ["administrators"],
"enabled": true
});
let response = client
.post(&api_url)
.header("Content-Type", "application/json")
.json(&account_data)
.send()
.await;
// All branches return Ok(()) - just log appropriate messages
match response {
Ok(resp) => {
if resp.status().is_success() {
log::info!(
"Admin email account created successfully: {}",
self.admin_user
);
} else if resp.status().as_u16() == 409 {
log::info!("Admin email account already exists: {}", self.admin_user);
} else {
let status = resp.status();
log::warn!("Failed to create admin account via API (status {})", status);
}
}
Err(e) => {
log::warn!(
"Could not connect to Stalwart management API: {}. Account may need manual setup.",
e
);
}
}
Ok(())
}
fn setup_directory_integration(&self, directory_config_path: &PathBuf) -> Result<()> {
let _ = self;
let content = std::fs::read_to_string(directory_config_path)?;
let dir_config: serde_json::Value = serde_json::from_str(&content)?;
let issuer_url = dir_config["base_url"]
.as_str()
.unwrap_or("http://localhost:9000");
log::info!("Setting up OIDC authentication with Directory...");
log::info!("Issuer URL: {}", issuer_url);
Ok(())
}
async fn save_config(&self, config: &EmailConfig) -> Result<()> {
let json = serde_json::to_string_pretty(config)?;
fs::write(&self.config_path, json).await?;
Ok(())
}
async fn load_existing_config(&self) -> Result<EmailConfig> {
let content = fs::read_to_string(&self.config_path).await?;
let config: EmailConfig = serde_json::from_str(&content)?;
Ok(config)
}
pub async fn get_config(&self) -> Result<EmailConfig> {
self.load_existing_config().await
}
pub fn create_user_mailbox(&self, _username: &str, _password: &str, email: &str) -> Result<()> {
let _ = self;
log::info!("Creating mailbox for user: {}", email);
Ok(())
}
pub async fn sync_users_from_directory(&self, directory_config_path: &PathBuf) -> Result<()> {
log::info!("Syncing users from Directory to Email...");
let content = fs::read_to_string(directory_config_path).await?;
let dir_config: serde_json::Value = serde_json::from_str(&content)?;
if let Some(default_user) = dir_config.get("default_user") {
let email = default_user["email"].as_str().unwrap_or("");
let password = default_user["password"].as_str().unwrap_or("");
let username = default_user["username"].as_str().unwrap_or("");
if !email.is_empty() {
self.create_user_mailbox(username, password, email)?;
log::info!(" Created mailbox for: {}", email);
}
}
Ok(())
}
}
pub async fn generate_email_config(
config_path: PathBuf,
data_path: PathBuf,
directory_integration: bool,
) -> Result<()> {
let mut config = format!(
r#"
[server]
hostname = "localhost"
[server.listener."smtp"]
bind = ["0.0.0.0:25"]
protocol = "smtp"
[server.listener."smtp-submission"]
bind = ["0.0.0.0:587"]
protocol = "smtp"
tls.implicit = false
[server.listener."smtp-submissions"]
bind = ["0.0.0.0:465"]
protocol = "smtp"
tls.implicit = true
[server.listener."imap"]
bind = ["0.0.0.0:143"]
protocol = "imap"
[server.listener."imaps"]
bind = ["0.0.0.0:993"]
protocol = "imap"
tls.implicit = true
[server.listener."http"]
bind = ["0.0.0.0:9000"]
protocol = "http"
[storage]
data = "sqlite"
blob = "sqlite"
lookup = "sqlite"
fts = "sqlite"
[store."sqlite"]
type = "sqlite"
path = "{}/stalwart.db"
[directory."local"]
type = "internal"
store = "sqlite"
"#,
data_path.display()
);
if directory_integration {
config.push_str(
r#"
[directory."oidc"]
type = "oidc"
issuer = "http://localhost:9000"
client-id = "{{CLIENT_ID}}"
client-secret = "{{CLIENT_SECRET}}"
[authentication]
mechanisms = ["plain", "login"]
directory = "oidc"
fallback-directory = "local"
"#,
);
} else {
config.push_str(
r#"
[authentication]
mechanisms = ["plain", "login"]
directory = "local"
"#,
);
}
fs::write(config_path, config).await?;
Ok(())
}

View file

@ -0,0 +1,7 @@
pub mod directory_setup;
pub mod email_setup;
pub mod vector_db_setup;
pub use directory_setup::{DirectorySetup, DirectoryConfig, DefaultUser, CreateUserParams};
pub use email_setup::EmailSetup;
pub use vector_db_setup::VectorDbSetup;

View file

@ -0,0 +1,93 @@
use anyhow::Result;
use std::path::PathBuf;
use std::fs;
use tracing::info;
pub struct VectorDbSetup;
impl VectorDbSetup {
pub async fn setup(conf_path: PathBuf, data_path: PathBuf) -> Result<()> {
let config_dir = conf_path.join("vector_db");
fs::create_dir_all(&config_dir)?;
let data_dir = data_path.join("vector_db");
fs::create_dir_all(&data_dir)?;
let cert_dir = conf_path.join("system/certificates/vectordb");
// Convert to absolute paths for Qdrant config
let data_dir_abs = fs::canonicalize(&data_dir).unwrap_or(data_dir);
let cert_dir_abs = fs::canonicalize(&cert_dir).unwrap_or(cert_dir);
let config_content = generate_qdrant_config(&data_dir_abs, &cert_dir_abs);
let config_path = config_dir.join("config.yaml");
fs::write(&config_path, config_content)?;
info!("Qdrant vector_db configuration written to {:?}", config_path);
Ok(())
}
}
pub fn generate_qdrant_config(data_dir: &std::path::Path, cert_dir: &std::path::Path) -> String {
let data_path = data_dir.to_string_lossy();
let cert_path = cert_dir.join("server.crt").to_string_lossy().to_string();
let key_path = cert_dir.join("server.key").to_string_lossy().to_string();
let ca_path = cert_dir.join("ca.crt").to_string_lossy().to_string();
format!(
r#"# Qdrant configuration with TLS enabled
# Generated by BotServer bootstrap
log_level: INFO
storage:
storage_path: {data_path}
snapshots_path: {data_path}/snapshots
on_disk_payload: true
service:
host: 0.0.0.0
http_port: 6333
grpc_port: 6334
enable_tls: true
tls:
cert: {cert_path}
key: {key_path}
ca_cert: {ca_path}
verify_https_client_certificate: false
cluster:
enabled: false
telemetry_disabled: true
"#
)
}
pub async fn generate_vector_db_config(config_path: PathBuf, data_path: PathBuf) -> Result<()> {
VectorDbSetup::setup(config_path, data_path).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_generate_qdrant_config() {
let data_dir = PathBuf::from("/tmp/qdrant/data");
let cert_dir = PathBuf::from("/tmp/qdrant/certs");
let config = generate_qdrant_config(&data_dir, &cert_dir);
assert!(config.contains("enable_tls: true"));
assert!(config.contains("http_port: 6333"));
assert!(config.contains("grpc_port: 6334"));
assert!(config.contains("/tmp/qdrant/data"));
assert!(config.contains("/tmp/qdrant/certs/server.crt"));
assert!(config.contains("/tmp/qdrant/certs/server.key"));
}
}

View file

@ -188,9 +188,6 @@ impl SessionManager {
error!("Failed to create session in database: {}", e);
e
})?;
log::info!("User {} created resource: session {}", verified_uid, inserted.id);
Ok(inserted)
}

View file

@ -186,8 +186,6 @@ pub async fn write_file(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
log::info!("User system created resource: drive_file {}", key);
Ok(Json(serde_json::json!({"success": true})))
}
@ -209,10 +207,6 @@ pub async fn delete_file(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
// Technically a deletion, but we could log it as an audit change or leave it out
// The plan specifies resource creation: `info!("User {} created resource: {}", user_id, resource_id);`
log::info!("User system deleted resource: drive_file {}", file_id);
Ok(Json(serde_json::json!({"success": true})))
}
@ -245,8 +239,6 @@ pub async fn create_folder(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()}))))?;
log::info!("User system created resource: drive_folder {}", key);
Ok(Json(serde_json::json!({"success": true})))
}

View file

@ -50,7 +50,7 @@ impl LocalFileMonitor {
}
pub async fn start_monitoring(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
info!("Local file monitor started - watching /opt/gbo/data/*.gbai directories");
trace!("Starting local file monitor for /opt/gbo/data/*.gbai directories");
// Create data directory if it doesn't exist
if let Err(e) = tokio::fs::create_dir_all(&self.data_dir).await {
@ -68,7 +68,7 @@ impl LocalFileMonitor {
monitor.monitoring_loop().await;
});
debug!("Local file monitor successfully initialized");
trace!("Local file monitor started");
Ok(())
}
@ -116,7 +116,7 @@ impl LocalFileMonitor {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Any => {
for path in &event.paths {
if self.is_gbdialog_file(path) {
debug!("Detected change in: {:?}", path);
trace!("Detected change: {:?}", path);
if let Err(e) = self.compile_local_file(path).await {
error!("Failed to compile {:?}: {}", path, e);
}
@ -126,7 +126,7 @@ impl LocalFileMonitor {
EventKind::Remove(_) => {
for path in &event.paths {
if self.is_gbdialog_file(path) {
debug!("File removed: {:?}", path);
trace!("File removed: {:?}", path);
self.remove_file_state(path).await;
}
}
@ -167,7 +167,7 @@ impl LocalFileMonitor {
}
async fn scan_and_compile_all(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
trace!("Scanning directory: {:?}", self.data_dir);
debug!("[LOCAL_MONITOR] Scanning /opt/gbo/data for .gbai directories");
let entries = match tokio::fs::read_dir(&self.data_dir).await {
Ok(e) => e,
@ -203,7 +203,7 @@ impl LocalFileMonitor {
}
async fn compile_gbdialog(&self, bot_name: &str, gbdialog_path: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
info!("Compiling bot: {}", bot_name);
debug!("[LOCAL_MONITOR] Processing .gbdialog for bot: {}", bot_name);
let entries = tokio::fs::read_dir(gbdialog_path).await?;
let mut entries = entries;
@ -231,7 +231,7 @@ impl LocalFileMonitor {
};
if should_compile {
debug!("Recompiling {:?} - modification detected", path);
trace!("Compiling: {:?}", path);
if let Err(e) = self.compile_local_file(&path).await {
error!("Failed to compile {:?}: {}", path, e);
}

View file

@ -184,7 +184,7 @@ async fn main() -> std::io::Result<()> {
e
);
} else {
info!("Secrets loaded from Vault");
info!("SecretsManager initialized - fetching secrets from Vault");
}
} else {
trace!("Bootstrap not complete - skipping early SecretsManager init");
@ -195,7 +195,6 @@ async fn main() -> std::io::Result<()> {
aws_smithy_runtime=off,aws_smithy_runtime_api=off,aws_sdk_s3=off,aws_config=off,\
aws_credential_types=off,aws_http=off,aws_sig_auth=off,aws_types=off,\
mio=off,tokio=off,tokio_util=off,tower=off,tower_http=off,\
tokio_tungstenite=off,tungstenite=off,\
reqwest=off,hyper=off,hyper_util=off,h2=off,\
rustls=off,rustls_pemfile=off,tokio_rustls=off,\
tracing=off,tracing_core=off,tracing_subscriber=off,\
@ -291,7 +290,6 @@ async fn main() -> std::io::Result<()> {
dotenv().ok();
let pool = init_database(&progress_tx).await?;
info!("Database initialized - PostgreSQL connected");
let refreshed_cfg = load_config(&pool).await?;
let config = std::sync::Arc::new(refreshed_cfg.clone());
@ -381,7 +379,7 @@ async fn main() -> std::io::Result<()> {
trace!("Initial data setup task spawned");
trace!("All system threads started, starting HTTP server...");
info!("Server started on port {}", config.server.port);
info!("Starting HTTP server on port {}...", config.server.port);
if let Err(e) = run_axum_server(app_state, config.server.port, worker_count).await {
error!("Failed to start HTTP server: {}", e);
std::process::exit(1);
@ -394,3 +392,5 @@ async fn main() -> std::io::Result<()> {
Ok(())
}

View file

@ -302,7 +302,7 @@ pub async fn init_redis() -> Option<Arc<redis::Client>> {
// Test with PING
match redis::cmd("PING").query::<String>(&mut conn) {
Ok(response) if response == "PONG" => {
log::info!("Cache initialized - Valkey connected");
log::info!("Cache connection verified: PONG");
Ok(Some(Arc::new(client)))
}
Ok(response) => {

View file

@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::info;
use tracing::{debug, info};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -490,14 +490,14 @@ impl JwtManager {
claims.session_id,
)?;
info!("Tokens refreshed for user {}", user_id);
debug!("Refreshed tokens for user {user_id}");
Ok(new_pair)
}
pub async fn revoke_token(&self, jti: &str) -> Result<()> {
let mut blacklist = self.blacklist.write().await;
blacklist.insert(jti.to_string());
info!("Token revoked: {}", jti);
debug!("Revoked token {jti}");
Ok(())
}

View file

@ -189,11 +189,11 @@ error: Option<String>,
#[cfg(feature = "mail")]
#[derive(Debug, Deserialize)]
struct SmtpTestRequest {
host: String,
port: i32,
username: Option<String>,
password: Option<String>,
_use_tls: Option<bool>,
host: String,
port: i32,
username: Option<String>,
password: Option<String>,
use_tls: Option<bool>,
}
#[cfg(not(feature = "mail"))]