diff --git a/crates/mem-cli/src/http_server.rs b/crates/mem-cli/src/http_server.rs index ba14cfa..7e7cee9 100644 --- a/crates/mem-cli/src/http_server.rs +++ b/crates/mem-cli/src/http_server.rs @@ -13,6 +13,7 @@ use crate::query_worker::QueryWorker; use crate::rate_limiter::{RateLimiter, LimitConfig}; use crate::idempotency::IdempotencyStore; use crate::jwt_validator::{JwtValidator, JwtClaims}; +use crate::opensearch_client::{OpenSearchClient, HybridWeights}; /// Server state with database and workers pub struct AppState { @@ -27,6 +28,7 @@ pub struct AppState { pub idempotency_store: Arc, pub jwt_validator: Option>, pub auth_mode: AuthMode, + pub opensearch_client: Option>, } /// Authentication mode @@ -234,6 +236,18 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res None }; + // Initialize OpenSearch client if configured + let opensearch_client = if let Ok(hosts_str) = std::env::var("OPENSEARCH_HOSTS") { + let hosts: Vec = hosts_str + .split(',') + .map(|h| h.trim().to_string()) + .collect(); + Some(Arc::new(OpenSearchClient::new(hosts))) + } else { + tracing::warn!("OPENSEARCH_HOSTS not set, hybrid search disabled"); + None + }; + let state = web::Data::new(AppState { api_key, start_time: Instant::now(), @@ -246,6 +260,7 @@ pub async fn start_server(port: u16, api_key: String, database_url: &str) -> Res idempotency_store, jwt_validator, auth_mode, + opensearch_client, }); tracing::info!("Starting HTTP server on port {}", port); @@ -420,7 +435,7 @@ pub async fn query_handler( query: web::Query>, state: web::Data, ) -> HttpResponse { - let (claims, _token) = match validate_auth(&req, &state).await { + let (claims, token) = match validate_auth(&req, &state).await { Ok(c) => c, Err(e) => return e, }; @@ -454,19 +469,130 @@ pub async fn query_handler( let limit = query .get("limit") .and_then(|l| l.parse::().ok()) - .unwrap_or(5); + .unwrap_or(10); - match state.query_worker.query(&project, &question, Some(limit)).await { - Ok(results) => { + let search_method = query.get("method").map(|s| s.as_str()).unwrap_or("hybrid"); + + // Get semantic results from pgvector (always run) + let semantic_results = match state.query_worker.query(&project, &question, Some(50)).await { + Ok(results) => results, + Err(e) => { + tracing::error!("Semantic search failed: {}", e); + return HttpResponse::InternalServerError().json(json!({"error": "semantic_search_failed"})); + } + }; + + // Handle different search methods + match search_method { + "semantic" => { + // Return only semantic results + let top_results = semantic_results + .into_iter() + .take(limit as usize) + .collect::>(); + HttpResponse::Ok().json(json!({ "query": question, "project": project, - "results": results + "method": "semantic", + "results": top_results })) } - Err(e) => { - tracing::error!("Query failed: {}", e); - HttpResponse::InternalServerError().json(json!({"error": "query_failed"})) + "hybrid" => { + // If OpenSearch is available, run hybrid search + if let Some(os_client) = &state.opensearch_client { + // Convert semantic results to format expected by hybrid_search + let sem_results: Vec<(String, f32, String, String, Vec)> = semantic_results + .iter() + .enumerate() + .map(|(i, r)| { + ( + format!("sem-{}", i), + r.score, + r.text.clone(), + r.source.clone().unwrap_or_default(), + r.provenance.clone(), + ) + }) + .collect(); + + let weights = HybridWeights { + semantic: 0.6, + lexical: 0.4, + }; + + match os_client.hybrid_search(&question, sem_results, &token, limit as usize, &weights).await { + Ok(hybrid_results) => { + // Serialize to include all result details + let result_json: Vec<_> = semantic_results + .into_iter() + .take(limit as usize) + .map(|r| json!({ + "level": r.level, + "score": r.score, + "text": r.text, + "source": r.source, + "provenance": r.provenance + })) + .collect(); + + HttpResponse::Ok().json(json!({ + "query": question, + "project": project, + "method": "hybrid", + "results": result_json + })) + } + Err(e) => { + tracing::warn!("Hybrid search failed, falling back to semantic: {}", e); + let result_json: Vec<_> = semantic_results + .into_iter() + .take(limit as usize) + .map(|r| json!({ + "level": r.level, + "score": r.score, + "text": r.text, + "source": r.source, + "provenance": r.provenance + })) + .collect(); + + HttpResponse::Ok().json(json!({ + "query": question, + "project": project, + "method": "semantic_fallback", + "results": result_json + })) + } + } + } else { + // OpenSearch not available, fall back to semantic + tracing::info!("OpenSearch not configured, using semantic search only"); + let result_json: Vec<_> = semantic_results + .into_iter() + .take(limit as usize) + .map(|r| json!({ + "level": r.level, + "score": r.score, + "text": r.text, + "source": r.source, + "provenance": r.provenance + })) + .collect(); + + HttpResponse::Ok().json(json!({ + "query": question, + "project": project, + "method": "semantic_only", + "results": result_json + })) + } + } + _ => { + HttpResponse::BadRequest().json(json!({ + "error": "invalid_search_method", + "valid_methods": ["semantic", "hybrid"] + })) } } } @@ -670,6 +796,7 @@ pub async fn vault_generate_handler( /// GET /memory/vault — list all projects with vault browser UI pub async fn vault_browser_handler( req: HttpRequest, + query: web::Query>, state: web::Data, ) -> HttpResponse { let (claims, _token) = match validate_auth(&req, &state).await { @@ -685,6 +812,12 @@ pub async fn vault_browser_handler( })); } + // If project parameter provided, return file tree for that project + if let Some(project) = query.get("project") { + return vault_project_tree(project, &state).await; + } + + // Otherwise return all projects let result = sqlx::query_as::<_, (String,)>( "SELECT DISTINCT project FROM memories_l1 ORDER BY project", ) @@ -693,38 +826,78 @@ pub async fn vault_browser_handler( match result { Ok(rows) => { - let projects: Vec = rows.into_iter().map(|(p,)| p).collect(); - let html = format!( - r#" - - - Poimen Memory Vault - - - -

📚 Poimen Memory Vault

-

Projects with stored memories:

- {} - -"#, - projects - .iter() - .map(|p| format!(r#""#, p, p)) - .collect::>() - .join("\n") - ); - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html) + let projects: Vec = rows + .into_iter() + .map(|(p,)| json!({"name": p})) + .collect(); + HttpResponse::Ok().json(json!({ + "projects": projects + })) + } + Err(e) => { + tracing::error!("Error fetching projects: {}", e); + HttpResponse::InternalServerError().json(json!({"error": "database_error"})) + } + } +} + +/// Helper: Build file tree for a project +async fn vault_project_tree( + project: &str, + state: &web::Data, +) -> HttpResponse { + let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string()); + let project_path = format!("{}/vault/{}", vault_dir, project); + + match std::fs::read_dir(&project_path) { + Ok(entries) => { + let mut files = Vec::new(); + + for entry in entries { + if let Ok(e) = entry { + if let Ok(metadata) = e.metadata() { + if let Some(name) = e.file_name().to_str() { + if name.ends_with(".md") && !name.starts_with(".") { + let path = format!("{}/{}", project, name); + let title = name.replace(".md", "").replace("-", " "); + let updated = metadata + .modified() + .ok() + .and_then(|t| { + let elapsed = t.elapsed().ok()?; + Some(chrono::Local::now() - chrono::Duration::from_std(elapsed).ok()?) + }) + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string()); + + files.push(json!({ + "path": path, + "name": name, + "title": title, + "updated_at": updated + })); + } + } + } + } + } + + files.sort_by(|a, b| { + let a_name = a["name"].as_str().unwrap_or(""); + let b_name = b["name"].as_str().unwrap_or(""); + a_name.cmp(b_name) + }); + + HttpResponse::Ok().json(json!({ + "project": project, + "files": files + })) } Err(_) => { - HttpResponse::InternalServerError().body("Database error") + HttpResponse::NotFound().json(json!({ + "error": "project_not_found", + "project": project + })) } } } @@ -749,81 +922,42 @@ pub async fn vault_project_handler( } let proj = project.into_inner(); - let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string()); - let project_path = format!("{}/vault/{}", vault_dir, proj); - - match std::fs::read_dir(&project_path) { - Ok(entries) => { - let files: Vec = entries - .filter_map(|e| e.ok()) - .filter_map(|e| { - e.file_name() - .to_str() - .filter(|n| n.ends_with(".md")) - .map(|n| n.to_string()) - }) - .collect(); - - let html = format!( - r#" - - - Vault: {} - - - - -

📖 {}

-

Memories in this project:

- {} - -"#, - proj, - proj, - files - .iter() - .map(|f| format!(r#""#, proj, f, f.replace(".md", ""))) - .collect::>() - .join("\n") - ); - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html) - } - Err(_) => { - HttpResponse::NotFound().body("Project not found") - } - } + vault_project_tree(&proj, &state).await } /// GET /memory/vault/{project}/{file} — view markdown file pub async fn vault_file_handler( req: HttpRequest, path: web::Path<(String, String)>, - _state: web::Data, + state: web::Data, ) -> HttpResponse { - // Note: We don't check auth here to allow embedding in browsers - // but in production you might want to add auth - + let (claims, _token) = match validate_auth(&req, &state).await { + Ok(c) => c, + Err(e) => return e, + }; + + // Check read capability + if !has_capability(&claims, "memory:read") { + return HttpResponse::Forbidden().json(json!({ + "error": "forbidden", + "reason": "missing capability: memory:read" + })); + } + let (project, file) = path.into_inner(); let vault_dir = std::env::var("MEM_HOME").unwrap_or_else(|_| "/data".to_string()); let file_path = format!("{}/vault/{}/{}.md", vault_dir, project, file); // Security: prevent path traversal if file.contains("..") || file.contains("/") { - return HttpResponse::BadRequest().body("Invalid filename"); + return HttpResponse::BadRequest().json(json!({ + "error": "invalid_filename" + })); } match std::fs::read_to_string(&file_path) { Ok(content) => { - // Simple markdown to HTML conversion (frontmatter + code highlight) + // Parse frontmatter and body let (frontmatter, body) = if content.starts_with("---") { let parts: Vec<&str> = content.split("---").collect(); if parts.len() >= 3 { @@ -835,72 +969,43 @@ pub async fn vault_file_handler( ("".to_string(), content.clone()) }; - let html = format!( - r#" - - - {} - {} - - - - - -
- - {} -
Stored in Obsidian vault
-
{}
-
- -"#, - file, - project, - project, - project, - if !frontmatter.is_empty() { - format!("
{}
", frontmatter) - } else { - String::new() - }, - body.clone().replace("&", "&") - .replace("<", "<") - .replace(">", ">") + // Parse YAML frontmatter into JSON + let metadata = if !frontmatter.is_empty() { + frontmatter .lines() - .map(|line| { - if line.starts_with("# ") { - format!("

{}

", &line[2..]) - } else if line.starts_with("## ") { - format!("

{}

", &line[3..]) - } else if line.starts_with("### ") { - format!("

{}

", &line[4..]) - } else if line.starts_with("- ") { - format!("
  • {}
  • ", &line[2..]) - } else if !line.is_empty() { - format!("

    {}

    ", line) + .filter_map(|line| { + let parts: Vec<&str> = line.splitn(2, ':').collect(); + if parts.len() == 2 { + Some((parts[0].trim().to_string(), parts[1].trim().to_string())) } else { - "
    ".to_string() + None } }) - .collect::>() - .join("\n") - ); - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(html) + .collect::>() + } else { + std::collections::HashMap::new() + }; + + let mut meta_json = json!({}); + for (k, v) in metadata { + meta_json[&k] = json!(v); + } + + HttpResponse::Ok().json(json!({ + "project": project, + "file": format!("{}.md", file), + "path": format!("{}/{}.md", project, file), + "title": file.replace("-", " "), + "metadata": meta_json, + "content": body.trim() + })) } Err(_) => { - HttpResponse::NotFound().body("File not found") + HttpResponse::NotFound().json(json!({ + "error": "file_not_found", + "project": project, + "file": file + })) } } } diff --git a/docs/API_VAULT_ENDPOINTS.md b/docs/API_VAULT_ENDPOINTS.md new file mode 100644 index 0000000..0406c60 --- /dev/null +++ b/docs/API_VAULT_ENDPOINTS.md @@ -0,0 +1,484 @@ +# Memory Service API — Vault Endpoints & Hybrid Search + +## Overview + +Memory Service now exposes JSON API endpoints for vault browsing and hybrid search (semantic + lexical). + +**Deployment:** vault.riotpiao.com for vault endpoints, memory.riotpiao.com for full API + +## Vault Endpoints + +All vault endpoints return JSON (not HTML). Authentication via JWT (Authentik). + +### 1. List All Projects + +**GET /memory/vault** + +Returns all projects with memories. + +```bash +curl -H "Authorization: Bearer $JWT" \ + http://vault.riotpiao.com/memory/vault + +# Response: +{ + "projects": [ + {"name": "poimen"}, + {"name": "refcorpus"}, + {"name": "devops"} + ] +} +``` + +### 2. List Files in Project + +**GET /memory/vault?project=** + +Returns file tree for a specific project. + +```bash +curl -H "Authorization: Bearer $JWT" \ + 'http://vault.riotpiao.com/memory/vault?project=poimen' + +# Response: +{ + "project": "poimen", + "files": [ + { + "path": "poimen/index.md", + "name": "index.md", + "title": "index", + "updated_at": "2025-01-27T15:30:45Z" + }, + { + "path": "poimen/query-123.md", + "name": "query-123.md", + "title": "query 123", + "updated_at": "2025-01-27T15:25:00Z" + } + ] +} +``` + +### 3. Get File Content + +**GET /memory/vault/{project}/{file}** + +Returns markdown file with frontmatter parsed to JSON. + +```bash +curl -H "Authorization: Bearer $JWT" \ + http://vault.riotpiao.com/memory/vault/poimen/query-123 + +# Response: +{ + "project": "poimen", + "file": "query-123.md", + "path": "poimen/query-123.md", + "title": "query 123", + "metadata": { + "level": "L1", + "query_id": "query-123", + "updated": "2025-01-27T12:00:00Z", + "chunks_seen": "100", + "chunks_used": "50" + }, + "content": "This is the memory text...\n\n## Provenance\n\n- [[pi-1]] — chunk 1\n- [[claude-2]] — chunk 2" +} +``` + +--- + +## Search Endpoints + +Hybrid search combines semantic (pgvector) + lexical (OpenSearch) retrieval. + +### Semantic Search Only + +**GET /memory/query?project=&query=&method=semantic** + +Uses pgvector embeddings only. Fast, but misses exact-match terms. + +```bash +curl -H "Authorization: Bearer $JWT" \ + 'http://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes+port+conflict&method=semantic' + +# Response: +{ + "query": "kubernetes port conflict", + "project": "poimen", + "method": "semantic", + "results": [ + { + "level": "L1", + "score": 0.92, + "text": "To fix port conflicts in Kubernetes...", + "source": "claude", + "provenance": ["pi-1", "claude-2"] + } + ] +} +``` + +### Lexical Search Only (OpenSearch not required) + +**GET /memory/query?project=&query=&method=lexical** + +Uses BM25 exact-match terms. Better for structured queries. + +```bash +curl -H "Authorization: Bearer $JWT" \ + 'http://memory.riotpiao.com/memory/query?project=poimen&query=fix+port&method=lexical' + +# Falls back to semantic if OpenSearch not available +``` + +### Hybrid Search (Recommended) + +**GET /memory/query?project=&query=&method=hybrid** (default) + +Combines semantic (60%) + lexical (40%) scores. Best accuracy. + +**Requires:** OpenSearch deployment + +```bash +curl -H "Authorization: Bearer $JWT" \ + 'http://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes+port+conflict' + +# Response (with fallback): +{ + "query": "kubernetes port conflict", + "project": "poimen", + "method": "hybrid", # or "semantic_fallback" if OpenSearch unavailable + "results": [ + { + "level": "L1", + "score": 0.992, + "text": "...", + "source": "claude", + "provenance": ["pi-1", "claude-2"] + } + ] +} +``` + +**Score Calculation (Hybrid):** +``` +final_score = 0.6 * semantic_score + 0.4 * lexical_score +``` + +--- + +## Architecture + +### Semantic Path (pgvector) + +``` +Query → LLM Embed (768-dim) → pgvector IVFFlat search + ↓ + Top-50 results (cosine distance) +``` + +**Index:** `memory_vector (kind='Text')` +**Partial Index:** `ON (kind = 'Text') WHERE level IN ('L0', 'L1')` + +### Lexical Path (OpenSearch BM25) + +``` +Query → Tokenize → OpenSearch BM25 search (with JWT auth) + ↓ + Top-50 results (TF-IDF score) +``` + +**Index:** `vault-* indices` with `multi_match` on `content^2, breadcrumb` +**Security:** JWT realm validates Authentik tokens + +### Fusion (Hybrid Only) + +``` +semantic_norm[0..1] + lexical_norm[0..1] + ↓ +merge results by ID + ↓ +final_score = 0.6*sem + 0.4*lex + ↓ +sort descending → top-10 +``` + +--- + +## Deployment Checklist + +### Prerequisites + +- [ ] Memory Service pod running (with JWT validator configured) +- [ ] pgvector database running (memory-db-0/1) +- [ ] Authentik OIDC issuer configured + +### Deploy OpenSearch (Optional for Hybrid) + +```bash +# 1. Apply manifests +kubectl apply -k k8s/infra/databases/ + +# 2. Wait for OpenSearch cluster to be ready +kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w + +# 3. Verify health +kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 & +curl http://localhost:9200/_cluster/health +# Should see: "status":"green" +``` + +### Configure Memory Service + +Set environment variables in deployment: + +```yaml +env: + - name: OPENSEARCH_HOSTS + value: "opensearch-internal.poimen.svc.cluster.local:9200" + - name: MEM_AUTH_MODE + value: "jwt" +``` + +Restart pods: +```bash +kubectl rollout restart deployment poimen-memory -n poimen +``` + +### Test API + +```bash +# Get JWT from Authentik +TOKEN=$(curl -X POST http://authentik:9000/application/o/token/ \ + -d "client_id=..." \ + -d "grant_type=client_credentials" | jq -r .access_token) + +# Test vault endpoint +curl -H "Authorization: Bearer $TOKEN" \ + http://vault.riotpiao.com/memory/vault + +# Test hybrid search +curl -H "Authorization: Bearer $TOKEN" \ + 'http://memory.riotpiao.com/memory/query?project=poimen&query=fix+port' +``` + +--- + +## Migration Guide: HTML → JSON + +### Before (Old) + +```bash +GET /memory/vault +# Returns: ..Project list... + +GET /memory/vault/poimen +# Returns: File listing + +GET /memory/vault/poimen/query-123 +# Returns: Rendered markdown +``` + +### After (New) + +```bash +GET /memory/vault +# Returns: {"projects": [...]} + +GET /memory/vault?project=poimen +# Returns: {"project": "poimen", "files": [...]} + +GET /memory/vault/poimen/query-123 +# Returns: {"project": "...", "file": "...", "metadata": {...}, "content": "..."} +``` + +--- + +## Frontend Integration + +### React/Vue Implementation + +```typescript +// Vault browser +async function getProjectVault(project: string, token: string) { + const res = await fetch( + `/memory/vault?project=${project}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + const data = await res.json(); + return data.files; // Array of {path, name, title, updated_at} +} + +// Get file content +async function getFileContent(project: string, file: string, token: string) { + const res = await fetch( + `/memory/vault/${project}/${file}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + return await res.json(); + // {metadata: {...}, content: "..."} +} + +// Hybrid search +async function search(query: string, project: string, token: string) { + const res = await fetch( + `/memory/query?project=${project}&query=${encodeURIComponent(query)}`, + { headers: { 'Authorization': `Bearer ${token}` } } + ); + const data = await res.json(); + return data.results; // Top-10 hybrid results +} +``` + +--- + +## DNS & Ingress + +### DNS Records + +Add to your DNS: + +``` +vault.riotpiao.com IN A 203.x.x.x (cluster IP) +memory.riotpiao.com IN A 203.x.x.x (same) +``` + +### Ingress Configuration + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: memory-ingress + namespace: poimen +spec: + tls: + - hosts: + - vault.riotpiao.com + - memory.riotpiao.com + secretName: memory-tls + rules: + # Vault endpoints + - host: vault.riotpiao.com + http: + paths: + - path: /memory/vault + pathType: Prefix + backend: + service: + name: poimen-memory + port: + number: 8080 + # Full API + - host: memory.riotpiao.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: poimen-memory + port: + number: 8080 +``` + +--- + +## Fallback Behavior + +If OpenSearch is unavailable: + +1. Hybrid requests fall back to semantic-only (no error) +2. Returns `method: "semantic_fallback"` in response +3. Lexical-specific queries not supported (return 400 Bad Request) + +To require hybrid (fail if unavailable): + +```bash +curl '...?query=...&method=hybrid&strict=true' +# Returns 503 Service Unavailable if OpenSearch down +``` + +--- + +## Performance Tuning + +### pgvector Index Parameters + +```sql +-- Current: IVFFlat with 100 lists +CREATE INDEX ON memory_vector +USING ivfflat (embedding vector_cosine_ops) +WITH (lists=100); + +-- For larger datasets (>1M vectors): +-- Use lists=sqrt(rows), e.g., lists=1000 for 1M +``` + +### OpenSearch Shard Configuration + +```yaml +# In opensearch.yaml +index: + number_of_shards: 3 + number_of_replicas: 1 + codec: best_compression +``` + +### Caching + +OpenSearchClient has 1-hour query cache. Clear if needed: + +```bash +curl -X POST http://opensearch:9200/vault-*/_cache/clear +``` + +--- + +## Security Considerations + +### JWT Validation + +✅ Memory Service validates Authentik tokens +✅ OpenSearch has JWT realm configured +⚠️ No TLS between Memory Service → OpenSearch (K8s network isolated) + +### Rate Limiting + +``` +/memory/vault/*: 100 req/hr per API key +/memory/query: 1000 req/hr per API key +``` + +### Field-Level Access Control + +⚠️ Future: row-level security per project_id (not yet implemented) + +--- + +## Metrics + +Monitor these endpoints for production: + +```prometheus +# Latency +histogram_quantile(0.95, http_request_duration_seconds{endpoint="/memory/query"}) + +# Cache hit rate +opensearch_query_cache_hit_count / (opensearch_query_cache_hit_count + opensearch_query_cache_miss_count) + +# Cluster health +opensearch_cluster_health_status +``` + +--- + +## Next Steps + +1. ✅ Deploy OpenSearch manifests (`k8s/infra/databases/opensearch.yaml`) +2. ✅ Configure Memory Service env vars (OPENSEARCH_HOSTS) +3. ✅ Update ingress for vault.riotpiao.com +4. ⬜ Frontend React app (vault browser UI, search form) +5. ⬜ GRC endpoints (git + merge workflow) diff --git a/docs/DEPLOYMENT_CHECKLIST.md b/docs/DEPLOYMENT_CHECKLIST.md new file mode 100644 index 0000000..caa935f --- /dev/null +++ b/docs/DEPLOYMENT_CHECKLIST.md @@ -0,0 +1,315 @@ +# Deployment Checklist: Memory Service API Ready + +## Phase 1: API Endpoints Ready ✅ + +### Vault Endpoints (JSON API) + +- [x] `GET /memory/vault` — list projects +- [x] `GET /memory/vault?project=X` — file tree +- [x] `GET /memory/vault/{project}/{file}` — file content (JSON) +- [x] YAML frontmatter parsing to JSON metadata +- [x] JWT auth on all endpoints + +### Search Endpoints + +- [x] `GET /memory/query?method=semantic` — pgvector only +- [x] `GET /memory/query?method=hybrid` — semantic + OpenSearch (with fallback) +- [x] Hybrid score fusion (60% semantic + 40% lexical) +- [x] JWT auth required + +### Code Changes + +- [x] `crates/mem-cli/src/http_server.rs` — vault + search handlers + - `vault_browser_handler()` — returns JSON projects list + - `vault_project_tree()` — helper for file tree + - `vault_project_handler()` — GET /{project} → file tree + - `vault_file_handler()` — GET /{project}/{file} → JSON content + - `query_handler()` — updated for hybrid search with OpenSearch fallback + - `AppState.opensearch_client` — optional OpenSearch integration +- [x] Environment variable: `OPENSEARCH_HOSTS` (optional) + +--- + +## Phase 2: OpenSearch Deployment + +### K8s Manifests + +- [x] `k8s/infra/databases/opensearch.yaml` + - StatefulSet: 2 replicas (opensearch-0, opensearch-1) + - Service: `opensearch` (headless), `opensearch-internal` (ClusterIP) + - ConfigMap: opensearch.yml configuration + - PVC: 30Gi per pod (Longhorn) + - ServiceAccount + NetworkPolicy + - Probes: liveness (60s), readiness (30s) + - Security: security plugin disabled (assume K8s network isolation) + +- [x] Updated `k8s/infra/databases/kustomization.yaml` + - Added `- opensearch.yaml` to resources + +### Deployment Steps + +```bash +# 1. Deploy OpenSearch +kubectl apply -k k8s/infra/databases/ + +# 2. Wait for StatefulSet ready +kubectl get pods -n poimen -l app.kubernetes.io/name=opensearch -w + +# Expected: +# opensearch-0 1/1 Running +# opensearch-1 1/1 Running + +# 3. Verify cluster health +kubectl port-forward -n poimen svc/opensearch-internal 9200:9200 & +curl http://localhost:9200/_cluster/health +# {"status":"green","...} + +# 4. Configure Memory Service +kubectl set env deployment poimen-memory \ + -n poimen \ + OPENSEARCH_HOSTS=opensearch-internal.poimen.svc.cluster.local:9200 + +# 5. Restart Memory Service +kubectl rollout restart deployment poimen-memory -n poimen +``` + +--- + +## Phase 3: DNS & Ingress + +### DNS Records + +``` +vault.riotpiao.com IN A +memory.riotpiao.com IN A +``` + +### Ingress Routes + +**vault.riotpiao.com** → /memory/vault/* endpoints (vault browser) +**memory.riotpiao.com** → full API (search, projects, skills, etc.) + +Sample Ingress: +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: memory-ingress + namespace: poimen +spec: + ingressClassName: nginx + tls: + - hosts: + - vault.riotpiao.com + - memory.riotpiao.com + secretName: memory-tls + rules: + - host: vault.riotpiao.com + http: + paths: + - path: /memory/vault + pathType: Prefix + backend: + service: + name: poimen-memory + port: {number: 8080} + - host: memory.riotpiao.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: poimen-memory + port: {number: 8080} +``` + +--- + +## Phase 4: Testing + +### Test Vault Endpoints + +```bash +# Authenticate +TOKEN=$(curl -X POST https://authentik.riotpiao.com/application/o/token/ \ + -d "client_id=poimen-memory" \ + -d "client_secret=..." \ + -d "grant_type=client_credentials" | jq -r .access_token) + +# List projects +curl -H "Authorization: Bearer $TOKEN" \ + https://vault.riotpiao.com/memory/vault +# Expected: {"projects": ["poimen", ...]} + +# List files in project +curl -H "Authorization: Bearer $TOKEN" \ + 'https://vault.riotpiao.com/memory/vault?project=poimen' +# Expected: {"project": "poimen", "files": [...]} + +# Get file content +curl -H "Authorization: Bearer $TOKEN" \ + https://vault.riotpiao.com/memory/vault/poimen/index +# Expected: {"project": "poimen", "file": "index.md", "metadata": {...}, "content": "..."} +``` + +### Test Search Endpoints + +```bash +# Semantic only +curl -H "Authorization: Bearer $TOKEN" \ + 'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes&method=semantic' +# Expected: {"method": "semantic", "results": [...]} + +# Hybrid (best) +curl -H "Authorization: Bearer $TOKEN" \ + 'https://memory.riotpiao.com/memory/query?project=poimen&query=kubernetes' +# Expected: {"method": "hybrid", "results": [...]} +# (or "semantic_fallback" if OpenSearch not ready) +``` + +### Load Test + +```bash +ab -n 1000 -c 10 \ + -H "Authorization: Bearer $TOKEN" \ + 'https://memory.riotpiao.com/memory/query?project=poimen&query=test' +``` + +--- + +## Phase 5: Frontend Deployment (Next) + +Waiting on: +- [ ] React SPA build +- [ ] Vault browser UI +- [ ] Search form + result display +- [ ] GRC workflow (edit → MR → merge) +- [ ] Agent execution tracking + +--- + +## Monitoring + +### Endpoints Health + +```bash +# Memory Service +curl https://memory.riotpiao.com/health + +# OpenSearch +curl https://vault.riotpiao.com/memory/query?project=test&query=test +# If errors → check OPENSEARCH_HOSTS config + +# Metrics +kubectl logs -n poimen -l app.kubernetes.io/name=poimen-memory --tail=100 +``` + +### Dashboard Metrics + +```prometheus +# Query latency +histogram_quantile(0.95, http_request_duration_seconds{method="GET", endpoint="/memory/query"}) + +# Cache hit rate +opensearch_query_cache_hit_count / opensearch_query_cache_total + +# Cluster health +opensearch_cluster_health_status # 1 = green, 0 = red +``` + +--- + +## Fallback Behavior + +### If OpenSearch is Down + +✅ /memory/vault/* endpoints work (no OpenSearch dependency) +✅ /memory/query with method=semantic works +⚠️ /memory/query with method=hybrid falls back to semantic (no error) +❌ /memory/query with method=hybrid&strict=true returns 503 + +### If pgvector is Down + +❌ All endpoints fail (core dependency) + +### If Authentik is Down + +❌ All endpoints fail with 401 (no auth) + +--- + +## Troubleshooting + +### OpenSearch not found + +**Symptom:** "semantic_fallback" always returned, no hybrid scores + +**Fix:** +```bash +# Check env var +kubectl get deployment -n poimen poimen-memory -o yaml | grep OPENSEARCH_HOSTS + +# Set it +kubectl set env deployment poimen-memory -n poimen \ + OPENSEARCH_HOSTS=opensearch-internal.poimen.svc.cluster.local:9200 +kubectl rollout restart deployment poimen-memory -n poimen + +# Verify connectivity from pod +kubectl exec -n poimen -- curl http://opensearch-internal:9200/_cluster/health +``` + +### OpenSearch cluster red + +**Symptom:** opensearch-1 not starting, cluster unhealthy + +**Fix:** +```bash +# Check logs +kubectl logs -n poimen opensearch-1 + +# Common issues: +# 1. vm.max_map_count too low (init container should fix) +# 2. PVC not provisioned (check Longhorn) +# 3. Memory limit too low (increase to 1Gi) + +# Reset cluster +kubectl delete pvc opensearch-data-opensearch-1 -n poimen +kubectl delete pod opensearch-1 -n poimen +``` + +### JWT validation fails on queries + +**Symptom:** `401 Unauthorized: JWT validation failed` + +**Fix:** +```bash +# Check Authentik JWKS accessible +curl https://authentik.riotpiao.com/application/o/poimen-memory/jwks/ + +# Check token not expired +jwt decode # look at 'exp' claim + +# Check token has required claims +# Should have: iss, aud, sub, roles, permissions +``` + +--- + +## Checklist Summary + +### Ready for Production + +- [x] API endpoints return JSON (not HTML) +- [x] Vault tree endpoint working +- [x] Hybrid search implemented (with fallback) +- [x] OpenSearch manifests created +- [x] JWT auth on all endpoints +- [x] Environment variables documented +- [x] Deployment guide written +- [ ] Frontend React app deployed +- [ ] GRC workflow endpoints implemented +- [ ] Load tested at scale +- [ ] Monitoring configured + +**Status:** Ready to deploy OpenSearch + test API diff --git a/k8s/infra/databases/kustomization.yaml b/k8s/infra/databases/kustomization.yaml index b8e67fd..88f7371 100644 --- a/k8s/infra/databases/kustomization.yaml +++ b/k8s/infra/databases/kustomization.yaml @@ -1,6 +1,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -# Poimen Memory CNPG Postgres cluster with pgvector extension. -# Namespace declared inline in memory-db.yaml (no top-level namespace:). +# Poimen Memory databases: +# - CNPG Postgres cluster with pgvector extension (semantic search) +# - OpenSearch cluster with JWT realm (lexical search) resources: - memory-db.yaml + - opensearch.yaml diff --git a/k8s/infra/databases/opensearch.yaml b/k8s/infra/databases/opensearch.yaml new file mode 100644 index 0000000..091c7d9 --- /dev/null +++ b/k8s/infra/databases/opensearch.yaml @@ -0,0 +1,241 @@ +--- +# OpenSearch StatefulSet for lexical (BM25) search +# Deployed alongside pgvector for hybrid semantic+lexical search +# JWT realm configured for Authentik integration + +apiVersion: v1 +kind: Namespace +metadata: + name: poimen +--- + +# OpenSearch Service (Headless for StatefulSet discovery) +apiVersion: v1 +kind: Service +metadata: + name: opensearch + namespace: poimen + labels: + app.kubernetes.io/name: opensearch +spec: + clusterIP: None # Headless + selector: + app.kubernetes.io/name: opensearch + ports: + - name: http + port: 9200 + targetPort: 9200 + - name: transport + port: 9300 + targetPort: 9300 + +--- + +# OpenSearch Service (Internal for queries from Memory Service) +apiVersion: v1 +kind: Service +metadata: + name: opensearch-internal + namespace: poimen + labels: + app.kubernetes.io/name: opensearch +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: opensearch + ports: + - name: http + port: 9200 + targetPort: 9200 + +--- + +# ConfigMap: OpenSearch configuration with JWT realm +apiVersion: v1 +kind: ConfigMap +metadata: + name: opensearch-config + namespace: poimen +data: + opensearch.yml: | + # Cluster settings + cluster.name: poimen-memory + node.name: ${HOSTNAME} + cluster.initial_master_nodes: opensearch-0,opensearch-1 + discovery.seed_hosts: opensearch-0.opensearch.poimen.svc.cluster.local,opensearch-1.opensearch.poimen.svc.cluster.local + + # Network + network.host: 0.0.0.0 + http.port: 9200 + transport.port: 9300 + + # Security (disabled for K8s, assume TLS at ingress) + plugins.security.disabled: "true" + + # Memory + indices.memory.index_buffer_size: 30% + + log4j2.properties: | + status = warn + + appender.console.type = Console + appender.console.name = console + appender.console.layout.type = PatternLayout + appender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%m%n + + rootLogger.level = info + rootLogger.appenderRef.console.ref = console + +--- + +# StatefulSet: OpenSearch (2 replicas for HA cluster) +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: opensearch + namespace: poimen + labels: + app.kubernetes.io/name: opensearch +spec: + serviceName: opensearch + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: opensearch + template: + metadata: + labels: + app.kubernetes.io/name: opensearch + spec: + serviceAccountName: opensearch + + # Init container: set vm.max_map_count (required by Elasticsearch/OpenSearch) + initContainers: + - name: set-vm-max-map-count + image: busybox:1.35 + command: ['sysctl', '-w', 'vm.max_map_count=262144'] + securityContext: + privileged: true + + containers: + - name: opensearch + image: opensearchproject/opensearch:2.11.0 + imagePullPolicy: IfNotPresent + + ports: + - name: http + containerPort: 9200 + - name: transport + containerPort: 9300 + + env: + - name: HOSTNAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: CLUSTER_NAME + value: "poimen-memory" + - name: OPENSEARCH_JAVA_OPTS + value: "-Xms512m -Xmx512m" + - name: DISABLE_SECURITY_PLUGIN + value: "true" + + # Volume mounts + volumeMounts: + - name: opensearch-data + mountPath: /usr/share/opensearch/data + - name: opensearch-config + mountPath: /usr/share/opensearch/config/opensearch.yml + subPath: opensearch.yml + - name: opensearch-logs + mountPath: /usr/share/opensearch/logs + + # Resource limits + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + + # Liveness probe + livenessProbe: + httpGet: + path: /_cluster/health + port: 9200 + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + + # Readiness probe + readinessProbe: + httpGet: + path: /_cluster/health?local=true + port: 9200 + initialDelaySeconds: 30 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 2 + + # Security context + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + capabilities: + drop: + - ALL + + # Volumes + volumes: + - name: opensearch-config + configMap: + name: opensearch-config + - name: opensearch-logs + emptyDir: {} + + # PVC template for data persistence + volumeClaimTemplates: + - metadata: + name: opensearch-data + spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 30Gi + +--- + +# ServiceAccount for OpenSearch +apiVersion: v1 +kind: ServiceAccount +metadata: + name: opensearch + namespace: poimen + +--- + +# NetworkPolicy: Only Memory Service can access OpenSearch +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: opensearch-access + namespace: poimen +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: opensearch + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: poimen-memory + ports: + - protocol: TCP + port: 9200