Initial commit: Egregore docs service
Rust microservice using rouille for documentation management. Provides HTTP API for reading and updating STATUS.md, HISTORY.md, RUNBOOK.md. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
2d62a179b9
3 changed files with 298 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Rust build artifacts
|
||||||
|
target/
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Secrets - BE PARANOID
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
credentials*
|
||||||
|
secrets*
|
||||||
10
Cargo.toml
Normal file
10
Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[package]
|
||||||
|
name = "docs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rouille = "3.6"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
264
src/main.rs
Normal file
264
src/main.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
//! Egregore Docs Service
|
||||||
|
//!
|
||||||
|
//! A lightweight Rust microservice for managing documentation files.
|
||||||
|
//! Provides HTTP API for reading and updating STATUS.md, HISTORY.md, etc.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rouille::{router, Request, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DOCS_DIR: &str = "/home/admin/docs";
|
||||||
|
const BIND_ADDR: &str = "127.0.0.1:8083";
|
||||||
|
|
||||||
|
/// List of allowed document names (without .md extension)
|
||||||
|
const ALLOWED_DOCS: &[&str] = &["STATUS", "HISTORY", "RUNBOOK"];
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DocInfo {
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
size: u64,
|
||||||
|
modified: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DocContent {
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
modified: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UpdateRequest {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiResponse<T> {
|
||||||
|
success: bool,
|
||||||
|
data: Option<T>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
service: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_doc_path(name: &str) -> Option<PathBuf> {
|
||||||
|
let upper_name = name.to_uppercase();
|
||||||
|
if ALLOWED_DOCS.contains(&upper_name.as_str()) {
|
||||||
|
Some(PathBuf::from(DOCS_DIR).join(format!("{}.md", upper_name)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_docs() -> Response {
|
||||||
|
let mut docs = Vec::new();
|
||||||
|
|
||||||
|
for name in ALLOWED_DOCS {
|
||||||
|
let path = PathBuf::from(DOCS_DIR).join(format!("{}.md", name));
|
||||||
|
if let Ok(metadata) = fs::metadata(&path) {
|
||||||
|
let modified = metadata
|
||||||
|
.modified()
|
||||||
|
.map(|t| {
|
||||||
|
let datetime: chrono::DateTime<Utc> = t.into();
|
||||||
|
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
|
||||||
|
docs.push(DocInfo {
|
||||||
|
name: name.to_string(),
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
size: metadata.len(),
|
||||||
|
modified,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::json(&ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: Some(docs),
|
||||||
|
error: None::<String>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_doc(name: &str) -> Response {
|
||||||
|
let Some(path) = get_doc_path(name) else {
|
||||||
|
return Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Document '{}' not allowed", name)),
|
||||||
|
})
|
||||||
|
.with_status_code(400);
|
||||||
|
};
|
||||||
|
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => {
|
||||||
|
let modified = fs::metadata(&path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.map(|t| {
|
||||||
|
let datetime: chrono::DateTime<Utc> = t.into();
|
||||||
|
datetime.format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
|
|
||||||
|
Response::json(&ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: Some(DocContent {
|
||||||
|
name: name.to_uppercase(),
|
||||||
|
content,
|
||||||
|
modified,
|
||||||
|
}),
|
||||||
|
error: None::<String>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Failed to read document: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(404),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_doc(name: &str, request: &Request) -> Response {
|
||||||
|
let Some(path) = get_doc_path(name) else {
|
||||||
|
return Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Document '{}' not allowed", name)),
|
||||||
|
})
|
||||||
|
.with_status_code(400);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse JSON body
|
||||||
|
let body: UpdateRequest = match rouille::input::json_input(request) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
return Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Invalid JSON: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
match fs::File::create(&path) {
|
||||||
|
Ok(mut file) => match file.write_all(body.content.as_bytes()) {
|
||||||
|
Ok(_) => {
|
||||||
|
let modified = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
Response::json(&ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: Some(DocContent {
|
||||||
|
name: name.to_uppercase(),
|
||||||
|
content: body.content,
|
||||||
|
modified,
|
||||||
|
}),
|
||||||
|
error: None::<String>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Failed to write: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(500),
|
||||||
|
},
|
||||||
|
Err(e) => Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Failed to create file: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_to_history(request: &Request) -> Response {
|
||||||
|
let path = PathBuf::from(DOCS_DIR).join("HISTORY.md");
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AppendRequest {
|
||||||
|
entry: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: AppendRequest = match rouille::input::json_input(request) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
return Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Invalid JSON: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read existing content
|
||||||
|
let mut content = fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
|
||||||
|
// Find the marker line and insert before it
|
||||||
|
let marker = "*Log significant changes with date, actions, and outcomes*";
|
||||||
|
if let Some(pos) = content.find(marker) {
|
||||||
|
let date = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
let new_entry = format!("\n## {} - {}\n\n---\n\n", date, body.entry);
|
||||||
|
content.insert_str(pos, &new_entry);
|
||||||
|
} else {
|
||||||
|
// Append at end if marker not found
|
||||||
|
content.push_str(&format!("\n## {}\n\n", body.entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::write(&path, &content) {
|
||||||
|
Ok(_) => Response::json(&ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: Some("Entry added to HISTORY.md"),
|
||||||
|
error: None::<String>,
|
||||||
|
}),
|
||||||
|
Err(e) => Response::json(&ApiResponse::<()> {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(format!("Failed to write: {}", e)),
|
||||||
|
})
|
||||||
|
.with_status_code(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn health() -> Response {
|
||||||
|
Response::json(&HealthResponse {
|
||||||
|
status: "ok".to_string(),
|
||||||
|
service: "docs".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_request(request: &Request) -> Response {
|
||||||
|
router!(request,
|
||||||
|
(GET) ["/health"] => { health() },
|
||||||
|
(GET) ["/docs"] => { list_docs() },
|
||||||
|
(GET) ["/docs/{name}", name: String] => { get_doc(&name) },
|
||||||
|
(PUT) ["/docs/{name}", name: String] => { update_doc(&name, request) },
|
||||||
|
(POST) ["/docs/history/append"] => { append_to_history(request) },
|
||||||
|
_ => Response::empty_404()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("Egregore Docs Service starting on {}", BIND_ADDR);
|
||||||
|
println!("Serving documents from: {}", DOCS_DIR);
|
||||||
|
|
||||||
|
rouille::start_server(BIND_ADDR, move |request| {
|
||||||
|
let response = handle_request(request);
|
||||||
|
|
||||||
|
// Add CORS headers for local development
|
||||||
|
response
|
||||||
|
.with_additional_header("Access-Control-Allow-Origin", "*")
|
||||||
|
.with_additional_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
|
||||||
|
.with_additional_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue