create-ekka-desktop-app 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/vite.config.ts +34 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
//! Device Secret Management
|
|
2
|
+
//!
|
|
3
|
+
//! Generates and stores a device-bound secret for encrypting node-level data.
|
|
4
|
+
//! The device secret is stored as a 32-byte file with 0600 permissions.
|
|
5
|
+
//! This secret never leaves the device and is used to derive encryption keys.
|
|
6
|
+
|
|
7
|
+
use rand::RngCore;
|
|
8
|
+
use std::fs;
|
|
9
|
+
use std::io::{Read, Write};
|
|
10
|
+
use std::path::{Path, PathBuf};
|
|
11
|
+
|
|
12
|
+
#[cfg(unix)]
|
|
13
|
+
use std::os::unix::fs::PermissionsExt;
|
|
14
|
+
|
|
15
|
+
/// Device secret filename
|
|
16
|
+
const DEVICE_SECRET_FILENAME: &str = ".ekka-device-secret";
|
|
17
|
+
|
|
18
|
+
/// Device secret size in bytes (256 bits)
|
|
19
|
+
const DEVICE_SECRET_SIZE: usize = 32;
|
|
20
|
+
|
|
21
|
+
/// Get the path to the device secret file
|
|
22
|
+
pub fn device_secret_path(home: &Path) -> PathBuf {
|
|
23
|
+
home.join(DEVICE_SECRET_FILENAME)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Load or create the device secret
|
|
27
|
+
///
|
|
28
|
+
/// If the secret file exists, reads exactly 32 bytes.
|
|
29
|
+
/// If not, generates 32 random bytes and writes with 0600 permissions.
|
|
30
|
+
///
|
|
31
|
+
/// # Returns
|
|
32
|
+
/// * `Ok([u8; 32])` - The device secret bytes
|
|
33
|
+
/// * `Err` - If file operations fail
|
|
34
|
+
pub fn load_or_create_device_secret(home: &Path) -> anyhow::Result<[u8; 32]> {
|
|
35
|
+
let path = device_secret_path(home);
|
|
36
|
+
|
|
37
|
+
if path.exists() {
|
|
38
|
+
// Load existing secret
|
|
39
|
+
let mut file = fs::File::open(&path)?;
|
|
40
|
+
let mut secret = [0u8; DEVICE_SECRET_SIZE];
|
|
41
|
+
file.read_exact(&mut secret)?;
|
|
42
|
+
|
|
43
|
+
tracing::info!(
|
|
44
|
+
op = "device_secret.ready",
|
|
45
|
+
created = false,
|
|
46
|
+
"Device secret loaded"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
Ok(secret)
|
|
50
|
+
} else {
|
|
51
|
+
// Generate new secret
|
|
52
|
+
let mut secret = [0u8; DEVICE_SECRET_SIZE];
|
|
53
|
+
rand::thread_rng().fill_bytes(&mut secret);
|
|
54
|
+
|
|
55
|
+
// Write with secure permissions
|
|
56
|
+
let mut file = fs::File::create(&path)?;
|
|
57
|
+
file.write_all(&secret)?;
|
|
58
|
+
|
|
59
|
+
// Set 0600 permissions on Unix
|
|
60
|
+
#[cfg(unix)]
|
|
61
|
+
{
|
|
62
|
+
let perms = fs::Permissions::from_mode(0o600);
|
|
63
|
+
fs::set_permissions(&path, perms)?;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
tracing::info!(
|
|
67
|
+
op = "device_secret.ready",
|
|
68
|
+
created = true,
|
|
69
|
+
"Device secret created"
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
Ok(secret)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[cfg(test)]
|
|
77
|
+
mod tests {
|
|
78
|
+
use super::*;
|
|
79
|
+
use tempfile::TempDir;
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn test_load_or_create_device_secret_creates_new() {
|
|
83
|
+
let temp_dir = TempDir::new().unwrap();
|
|
84
|
+
let secret = load_or_create_device_secret(temp_dir.path()).unwrap();
|
|
85
|
+
|
|
86
|
+
// Should be 32 bytes
|
|
87
|
+
assert_eq!(secret.len(), 32);
|
|
88
|
+
|
|
89
|
+
// File should exist
|
|
90
|
+
assert!(device_secret_path(temp_dir.path()).exists());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#[test]
|
|
94
|
+
fn test_load_or_create_device_secret_loads_existing() {
|
|
95
|
+
let temp_dir = TempDir::new().unwrap();
|
|
96
|
+
|
|
97
|
+
// Create first
|
|
98
|
+
let secret1 = load_or_create_device_secret(temp_dir.path()).unwrap();
|
|
99
|
+
|
|
100
|
+
// Load again - should be same
|
|
101
|
+
let secret2 = load_or_create_device_secret(temp_dir.path()).unwrap();
|
|
102
|
+
|
|
103
|
+
assert_eq!(secret1, secret2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#[test]
|
|
107
|
+
fn test_device_secret_path() {
|
|
108
|
+
let path = device_secret_path(Path::new("/home/user/.ekka-desktop"));
|
|
109
|
+
assert_eq!(path, PathBuf::from("/home/user/.ekka-desktop/.ekka-device-secret"));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
//! Engine Process Management
|
|
2
|
+
//!
|
|
3
|
+
//! Handles spawning, readiness checking, and routing for the external ekka-engine binary.
|
|
4
|
+
//!
|
|
5
|
+
//! ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
//! DRIFT GUARD - ARCHITECTURE FREEZE
|
|
7
|
+
//! ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
8
|
+
//! DO NOT extend this without revisiting the Desktop–Engine architecture decision.
|
|
9
|
+
//!
|
|
10
|
+
//! This module is FROZEN as of Phase 3G. Responsibilities are:
|
|
11
|
+
//! - Spawn ekka-engine binary on startup
|
|
12
|
+
//! - Check readiness via health endpoint
|
|
13
|
+
//! - Route requests to engine (or fallback to stub)
|
|
14
|
+
//! - One-way disable on failure
|
|
15
|
+
//! - Clean shutdown on Desktop exit
|
|
16
|
+
//! - Read-only status visibility (installed, running, available, pid, version, build)
|
|
17
|
+
//! - Log streaming (stdout/stderr forwarding)
|
|
18
|
+
//!
|
|
19
|
+
//! Any changes require explicit architecture review.
|
|
20
|
+
//! ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
use crate::bootstrap::resolve_home_path;
|
|
23
|
+
use crate::node_credentials;
|
|
24
|
+
use crate::types::{EngineRequest, EngineResponse};
|
|
25
|
+
use serde::Serialize;
|
|
26
|
+
use std::fs;
|
|
27
|
+
use std::io::{BufRead, BufReader, Write};
|
|
28
|
+
use std::path::PathBuf;
|
|
29
|
+
use std::process::{Child, Command, Stdio};
|
|
30
|
+
use std::sync::Mutex;
|
|
31
|
+
use std::time::{Duration, Instant};
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// Engine Environment Builder
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
/// Build environment variables for engine process spawn.
|
|
38
|
+
/// Returns Err with missing key name if a required var is missing/invalid.
|
|
39
|
+
///
|
|
40
|
+
/// Order of precedence for node credentials:
|
|
41
|
+
/// 1. OS Keychain (stored via nodeCredentials.set)
|
|
42
|
+
/// 2. Environment variables (EKKA_NODE_ID, EKKA_NODE_SECRET)
|
|
43
|
+
fn build_engine_env() -> Result<Vec<(&'static str, String)>, &'static str> {
|
|
44
|
+
let mut env = Vec::new();
|
|
45
|
+
|
|
46
|
+
// EKKA_RUNNER_MODE=engine (always set)
|
|
47
|
+
env.push(("EKKA_RUNNER_MODE", "engine".to_string()));
|
|
48
|
+
|
|
49
|
+
// EKKA_ENGINE_URL - pass through from process env or use default
|
|
50
|
+
if let Ok(url) = std::env::var("EKKA_ENGINE_URL") {
|
|
51
|
+
env.push(("EKKA_ENGINE_URL", url));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// EKKA_INTERNAL_SERVICE_KEY - required for engine mode
|
|
55
|
+
let internal_key = std::env::var("EKKA_INTERNAL_SERVICE_KEY")
|
|
56
|
+
.or_else(|_| std::env::var("INTERNAL_SERVICE_KEY"))
|
|
57
|
+
.map_err(|_| "EKKA_INTERNAL_SERVICE_KEY")?;
|
|
58
|
+
env.push(("EKKA_INTERNAL_SERVICE_KEY", internal_key));
|
|
59
|
+
|
|
60
|
+
// EKKA_TENANT_ID - required, must be valid UUID
|
|
61
|
+
let tenant_id = std::env::var("EKKA_TENANT_ID")
|
|
62
|
+
.map_err(|_| "EKKA_TENANT_ID")?;
|
|
63
|
+
uuid::Uuid::parse_str(&tenant_id)
|
|
64
|
+
.map_err(|_| "EKKA_TENANT_ID")?;
|
|
65
|
+
env.push(("EKKA_TENANT_ID", tenant_id));
|
|
66
|
+
|
|
67
|
+
// EKKA_WORKSPACE_ID - required, must be valid UUID
|
|
68
|
+
let workspace_id = std::env::var("EKKA_WORKSPACE_ID")
|
|
69
|
+
.map_err(|_| "EKKA_WORKSPACE_ID")?;
|
|
70
|
+
uuid::Uuid::parse_str(&workspace_id)
|
|
71
|
+
.map_err(|_| "EKKA_WORKSPACE_ID")?;
|
|
72
|
+
env.push(("EKKA_WORKSPACE_ID", workspace_id));
|
|
73
|
+
|
|
74
|
+
// Node credentials: Try keychain first, fall back to env vars
|
|
75
|
+
// This enables headless engine startup without manual env exports
|
|
76
|
+
if let Ok((node_id, node_secret)) = node_credentials::load_credentials() {
|
|
77
|
+
env.push(("EKKA_NODE_ID", node_id.to_string()));
|
|
78
|
+
env.push(("EKKA_NODE_SECRET", node_secret));
|
|
79
|
+
|
|
80
|
+
tracing::info!(
|
|
81
|
+
op = "desktop.node.identity.loaded",
|
|
82
|
+
keys = ?["node_id", "node_secret"],
|
|
83
|
+
node_id = %node_id,
|
|
84
|
+
"Node credentials loaded from keychain for engine spawn"
|
|
85
|
+
);
|
|
86
|
+
} else {
|
|
87
|
+
// Fallback to environment variables
|
|
88
|
+
if let Ok(node_id) = std::env::var("EKKA_NODE_ID") {
|
|
89
|
+
env.push(("EKKA_NODE_ID", node_id));
|
|
90
|
+
}
|
|
91
|
+
if let Ok(node_secret) = std::env::var("EKKA_NODE_SECRET") {
|
|
92
|
+
env.push(("EKKA_NODE_SECRET", node_secret));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
Ok(env)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Engine status for diagnostics (read-only)
|
|
100
|
+
#[derive(Debug, Clone, Serialize)]
|
|
101
|
+
pub struct EngineStatus {
|
|
102
|
+
pub installed: bool,
|
|
103
|
+
pub running: bool,
|
|
104
|
+
pub available: bool,
|
|
105
|
+
pub pid: Option<u32>,
|
|
106
|
+
pub version: Option<String>,
|
|
107
|
+
pub build: Option<String>,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Engine process holder
|
|
111
|
+
pub struct EngineProcess {
|
|
112
|
+
child: Mutex<Option<Child>>,
|
|
113
|
+
available: Mutex<bool>,
|
|
114
|
+
installed: Mutex<bool>,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
impl EngineProcess {
|
|
118
|
+
pub fn new() -> Self {
|
|
119
|
+
Self {
|
|
120
|
+
child: Mutex::new(None),
|
|
121
|
+
available: Mutex::new(false),
|
|
122
|
+
installed: Mutex::new(false),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Check if engine is available
|
|
127
|
+
pub fn is_available(&self) -> bool {
|
|
128
|
+
self.available.lock().map(|g| *g).unwrap_or(false)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Check if engine binary is installed
|
|
132
|
+
pub fn is_installed(&self) -> bool {
|
|
133
|
+
self.installed.lock().map(|g| *g).unwrap_or(false)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Check if engine process is running (has child and not exited)
|
|
137
|
+
pub fn is_running(&self) -> bool {
|
|
138
|
+
if let Ok(mut guard) = self.child.lock() {
|
|
139
|
+
if let Some(ref mut child) = *guard {
|
|
140
|
+
// try_wait returns Ok(Some(_)) if exited, Ok(None) if still running
|
|
141
|
+
return child.try_wait().ok().flatten().is_none();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Get engine process ID (if running)
|
|
148
|
+
pub fn get_pid(&self) -> Option<u32> {
|
|
149
|
+
if let Ok(guard) = self.child.lock() {
|
|
150
|
+
if let Some(ref child) = *guard {
|
|
151
|
+
return Some(child.id());
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
None
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Get engine status (read-only diagnostics)
|
|
158
|
+
pub fn get_status(&self) -> EngineStatus {
|
|
159
|
+
EngineStatus {
|
|
160
|
+
installed: self.is_installed(),
|
|
161
|
+
running: self.is_running(),
|
|
162
|
+
available: self.is_available(),
|
|
163
|
+
pid: self.get_pid(),
|
|
164
|
+
// Version/build require engine info endpoint (not yet available)
|
|
165
|
+
version: None,
|
|
166
|
+
build: None,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Set availability
|
|
171
|
+
fn set_available(&self, available: bool) {
|
|
172
|
+
if let Ok(mut guard) = self.available.lock() {
|
|
173
|
+
*guard = available;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Set installed flag
|
|
178
|
+
fn set_installed(&self, installed: bool) {
|
|
179
|
+
if let Ok(mut guard) = self.installed.lock() {
|
|
180
|
+
*guard = installed;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// Permanently disable engine for this session (one-way switch)
|
|
185
|
+
pub fn disable(&self) {
|
|
186
|
+
self.set_available(false);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
impl Default for EngineProcess {
|
|
191
|
+
fn default() -> Self {
|
|
192
|
+
Self::new()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Compute engine binary path using ekka_home_folder
|
|
197
|
+
fn compute_engine_path() -> Result<PathBuf, String> {
|
|
198
|
+
let home = resolve_home_path()?;
|
|
199
|
+
Ok(home.join("engine").join("ekka-engine"))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Ensure bootstrap engine is installed from embedded resources
|
|
203
|
+
///
|
|
204
|
+
/// Extracts the bundled ekka-engine-bootstrap binary to ekka_home_folder/engine/ekka-engine
|
|
205
|
+
/// if it doesn't already exist.
|
|
206
|
+
///
|
|
207
|
+
/// Returns Ok(true) if installed, Ok(false) if already present.
|
|
208
|
+
pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>) -> Result<bool, String> {
|
|
209
|
+
let engine_path = compute_engine_path()?;
|
|
210
|
+
|
|
211
|
+
// Already installed - return silently
|
|
212
|
+
if engine_path.exists() {
|
|
213
|
+
return Ok(false);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Get resource bytes - try packaged path first, fall back to dev path
|
|
217
|
+
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
218
|
+
.join("resources")
|
|
219
|
+
.join("ekka-engine-bootstrap");
|
|
220
|
+
|
|
221
|
+
let effective_path = match &resource_path {
|
|
222
|
+
Some(path) if path.exists() => path.clone(),
|
|
223
|
+
_ => dev_path.clone(),
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if !effective_path.exists() {
|
|
227
|
+
tracing::warn!(
|
|
228
|
+
op = "engine.bootstrap.resource_missing",
|
|
229
|
+
resource = %effective_path.display(),
|
|
230
|
+
"Bootstrap resource not found"
|
|
231
|
+
);
|
|
232
|
+
return Err("Bootstrap resource not found".to_string());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let resource_bytes = fs::read(&effective_path).map_err(|e| {
|
|
236
|
+
tracing::warn!(
|
|
237
|
+
op = "engine.bootstrap.resource_missing",
|
|
238
|
+
resource = %effective_path.display(),
|
|
239
|
+
error = %e,
|
|
240
|
+
"Bootstrap resource not readable"
|
|
241
|
+
);
|
|
242
|
+
format!("Failed to read resource: {}", e)
|
|
243
|
+
})?;
|
|
244
|
+
|
|
245
|
+
// Create engine directory
|
|
246
|
+
let engine_dir = engine_path.parent().ok_or("Invalid engine path")?;
|
|
247
|
+
fs::create_dir_all(engine_dir)
|
|
248
|
+
.map_err(|e| format!("Failed to create engine directory: {}", e))?;
|
|
249
|
+
|
|
250
|
+
// Write atomically: tmp file -> chmod +x -> rename
|
|
251
|
+
let tmp_path = engine_path.with_extension("tmp");
|
|
252
|
+
|
|
253
|
+
let mut file = fs::File::create(&tmp_path)
|
|
254
|
+
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
|
255
|
+
file.write_all(&resource_bytes)
|
|
256
|
+
.map_err(|e| format!("Failed to write bootstrap binary: {}", e))?;
|
|
257
|
+
file.sync_all()
|
|
258
|
+
.map_err(|e| format!("Failed to sync file: {}", e))?;
|
|
259
|
+
drop(file);
|
|
260
|
+
|
|
261
|
+
// Set executable permission (Unix only)
|
|
262
|
+
#[cfg(unix)]
|
|
263
|
+
{
|
|
264
|
+
use std::os::unix::fs::PermissionsExt;
|
|
265
|
+
let mut perms = fs::metadata(&tmp_path)
|
|
266
|
+
.map_err(|e| format!("Failed to get permissions: {}", e))?
|
|
267
|
+
.permissions();
|
|
268
|
+
perms.set_mode(0o755);
|
|
269
|
+
fs::set_permissions(&tmp_path, perms)
|
|
270
|
+
.map_err(|e| format!("Failed to set executable: {}", e))?;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Atomic rename
|
|
274
|
+
fs::rename(&tmp_path, &engine_path)
|
|
275
|
+
.map_err(|e| format!("Failed to rename to final path: {}", e))?;
|
|
276
|
+
|
|
277
|
+
tracing::info!(
|
|
278
|
+
op = "engine.bootstrap.install",
|
|
279
|
+
path = %engine_path.display(),
|
|
280
|
+
"Bootstrap engine installed from resources"
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
Ok(true)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/// Spawn and wait for engine readiness
|
|
287
|
+
///
|
|
288
|
+
/// Returns true if engine is ready, false otherwise.
|
|
289
|
+
/// Stores result in EngineProcess.available.
|
|
290
|
+
pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
|
|
291
|
+
let engine_path = match compute_engine_path() {
|
|
292
|
+
Ok(p) => p,
|
|
293
|
+
Err(e) => {
|
|
294
|
+
tracing::warn!(
|
|
295
|
+
op = "engine.path.error",
|
|
296
|
+
error = %e,
|
|
297
|
+
"Failed to compute engine path"
|
|
298
|
+
);
|
|
299
|
+
engine.set_available(false);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Check if binary exists
|
|
305
|
+
let installed = engine_path.exists();
|
|
306
|
+
engine.set_installed(installed);
|
|
307
|
+
|
|
308
|
+
if !installed {
|
|
309
|
+
tracing::info!(
|
|
310
|
+
op = "engine.spawn.missing",
|
|
311
|
+
path = %engine_path.display(),
|
|
312
|
+
"Engine binary not found, using stub backend"
|
|
313
|
+
);
|
|
314
|
+
engine.set_available(false);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
tracing::info!(
|
|
319
|
+
op = "engine.spawn.start",
|
|
320
|
+
path = %engine_path.display(),
|
|
321
|
+
"Spawning engine process"
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Build engine environment from process env
|
|
325
|
+
let engine_env = match build_engine_env() {
|
|
326
|
+
Ok(env) => env,
|
|
327
|
+
Err(missing_key) => {
|
|
328
|
+
tracing::error!(
|
|
329
|
+
op = "engine.spawn.missing_required_env",
|
|
330
|
+
key = %missing_key,
|
|
331
|
+
"Required environment variable missing or invalid"
|
|
332
|
+
);
|
|
333
|
+
engine.set_available(false);
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
tracing::info!(
|
|
339
|
+
op = "engine.spawn.env",
|
|
340
|
+
keys = ?engine_env.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
|
|
341
|
+
"Setting engine environment"
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Spawn the engine process with piped stdout/stderr
|
|
345
|
+
let mut child = match Command::new(&engine_path)
|
|
346
|
+
.envs(engine_env)
|
|
347
|
+
.stdout(Stdio::piped())
|
|
348
|
+
.stderr(Stdio::piped())
|
|
349
|
+
.spawn()
|
|
350
|
+
{
|
|
351
|
+
Ok(c) => c,
|
|
352
|
+
Err(e) => {
|
|
353
|
+
tracing::warn!(
|
|
354
|
+
op = "engine.spawn.failed",
|
|
355
|
+
error = %e,
|
|
356
|
+
"Failed to spawn engine process"
|
|
357
|
+
);
|
|
358
|
+
engine.set_available(false);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// Spawn log reader threads (best-effort, ignore errors)
|
|
364
|
+
if let Some(stdout) = child.stdout.take() {
|
|
365
|
+
std::thread::spawn(move || {
|
|
366
|
+
let reader = BufReader::new(stdout);
|
|
367
|
+
for line in reader.lines() {
|
|
368
|
+
match line {
|
|
369
|
+
Ok(l) => tracing::info!(op = "engine.stdout", "{}", l),
|
|
370
|
+
Err(_) => break,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
tracing::debug!(op = "engine.stdout.closed", "Engine stdout stream closed");
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if let Some(stderr) = child.stderr.take() {
|
|
378
|
+
std::thread::spawn(move || {
|
|
379
|
+
let reader = BufReader::new(stderr);
|
|
380
|
+
for line in reader.lines() {
|
|
381
|
+
match line {
|
|
382
|
+
Ok(l) => tracing::info!(op = "engine.stderr", "{}", l),
|
|
383
|
+
Err(_) => break,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
tracing::debug!(op = "engine.stderr.closed", "Engine stderr stream closed");
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Get pid for logging
|
|
391
|
+
let pid = child.id();
|
|
392
|
+
|
|
393
|
+
// Store child process
|
|
394
|
+
if let Ok(mut guard) = engine.child.lock() {
|
|
395
|
+
*guard = Some(child);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
tracing::info!(op = "engine.spawn.success", pid = pid, "Engine process spawned");
|
|
399
|
+
|
|
400
|
+
// Allow bootstrap time to exec into real engine or fail
|
|
401
|
+
// Bootstrap either: execs real engine (same PID), or exits with error code
|
|
402
|
+
std::thread::sleep(Duration::from_secs(2));
|
|
403
|
+
|
|
404
|
+
// Check if bootstrap exited early (indicates failure)
|
|
405
|
+
let bootstrap_failed = if let Ok(mut guard) = engine.child.lock() {
|
|
406
|
+
if let Some(ref mut c) = *guard {
|
|
407
|
+
match c.try_wait() {
|
|
408
|
+
Ok(Some(status)) => {
|
|
409
|
+
let code = status.code();
|
|
410
|
+
tracing::warn!(
|
|
411
|
+
op = "engine.bootstrap.failed",
|
|
412
|
+
pid = pid,
|
|
413
|
+
exit_code = ?code,
|
|
414
|
+
"Bootstrap exited early - real engine not started"
|
|
415
|
+
);
|
|
416
|
+
true
|
|
417
|
+
}
|
|
418
|
+
Ok(None) => false, // Still running = bootstrap exec'd into real engine
|
|
419
|
+
Err(_) => true,
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
true
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
true
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if bootstrap_failed {
|
|
429
|
+
engine.set_available(false);
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
tracing::debug!(op = "engine.bootstrap.exec", pid = pid, "Bootstrap exec'd into real engine, starting readiness check");
|
|
434
|
+
|
|
435
|
+
// Wait for readiness (real engine should now be running)
|
|
436
|
+
let ready = wait_for_ready(15);
|
|
437
|
+
engine.set_available(ready);
|
|
438
|
+
|
|
439
|
+
if ready {
|
|
440
|
+
tracing::info!(op = "engine.ready", "Engine is ready");
|
|
441
|
+
} else {
|
|
442
|
+
tracing::warn!(op = "engine.ready.timeout", "Engine readiness timeout");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
ready
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/// Wait for engine to become ready by checking health endpoint
|
|
449
|
+
fn wait_for_ready(timeout_secs: u64) -> bool {
|
|
450
|
+
// Default port for engine health check
|
|
451
|
+
let port: u16 = std::env::var("EKKA_ENGINE_PORT")
|
|
452
|
+
.ok()
|
|
453
|
+
.and_then(|s| s.parse().ok())
|
|
454
|
+
.unwrap_or(9473);
|
|
455
|
+
|
|
456
|
+
let url = format!("http://127.0.0.1:{}/health", port);
|
|
457
|
+
|
|
458
|
+
let client = match reqwest::blocking::Client::builder()
|
|
459
|
+
.timeout(Duration::from_secs(2))
|
|
460
|
+
.build()
|
|
461
|
+
{
|
|
462
|
+
Ok(c) => c,
|
|
463
|
+
Err(_) => return false,
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
let start = Instant::now();
|
|
467
|
+
let timeout = Duration::from_secs(timeout_secs);
|
|
468
|
+
|
|
469
|
+
while start.elapsed() < timeout {
|
|
470
|
+
if let Ok(resp) = client.get(&url).send() {
|
|
471
|
+
if resp.status().is_success() {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
std::thread::sleep(Duration::from_millis(500));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
false
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Shutdown engine process (called on app exit)
|
|
482
|
+
pub fn shutdown(engine: &EngineProcess) {
|
|
483
|
+
if let Ok(mut guard) = engine.child.lock() {
|
|
484
|
+
if let Some(mut child) = guard.take() {
|
|
485
|
+
tracing::info!(op = "engine.shutdown", "Shutting down engine process");
|
|
486
|
+
let _ = child.kill();
|
|
487
|
+
let _ = child.wait();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
engine.set_available(false);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// Route a request to the real engine
|
|
494
|
+
///
|
|
495
|
+
/// Returns Some(response) if engine handled the request, None on failure.
|
|
496
|
+
/// On failure, caller should fall back to stub.
|
|
497
|
+
pub fn route_to_engine(req: &EngineRequest) -> Option<EngineResponse> {
|
|
498
|
+
let port: u16 = std::env::var("EKKA_ENGINE_PORT")
|
|
499
|
+
.ok()
|
|
500
|
+
.and_then(|s| s.parse().ok())
|
|
501
|
+
.unwrap_or(9473);
|
|
502
|
+
|
|
503
|
+
let url = format!("http://127.0.0.1:{}/request", port);
|
|
504
|
+
|
|
505
|
+
let client = match reqwest::blocking::Client::builder()
|
|
506
|
+
.timeout(Duration::from_secs(30))
|
|
507
|
+
.build()
|
|
508
|
+
{
|
|
509
|
+
Ok(c) => c,
|
|
510
|
+
Err(e) => {
|
|
511
|
+
tracing::warn!(op = "engine.route.client_error", error = %e, "Failed to create HTTP client");
|
|
512
|
+
return None;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
match client.post(&url).json(req).send() {
|
|
517
|
+
Ok(resp) if resp.status().is_success() => {
|
|
518
|
+
match resp.json::<EngineResponse>() {
|
|
519
|
+
Ok(engine_resp) => {
|
|
520
|
+
tracing::debug!(op = "engine.route.success", op_name = %req.op, "Routed to engine");
|
|
521
|
+
Some(engine_resp)
|
|
522
|
+
}
|
|
523
|
+
Err(e) => {
|
|
524
|
+
tracing::warn!(op = "engine.route.parse_error", error = %e, "Failed to parse engine response");
|
|
525
|
+
None
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
Ok(resp) => {
|
|
530
|
+
tracing::warn!(op = "engine.route.error", status = %resp.status(), "Engine returned error");
|
|
531
|
+
None
|
|
532
|
+
}
|
|
533
|
+
Err(e) => {
|
|
534
|
+
tracing::warn!(op = "engine.route.failed", error = %e, "Failed to route to engine");
|
|
535
|
+
None
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|