use std::fs;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use clap::Args;
use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
use ed25519_dalek::SigningKey;
use pkcs8::LineEnding;
use rand::rngs::OsRng;
use rand::RngCore;
use rusqlite::Connection;
#[derive(Debug, Args)]
pub struct InitArgs {
#[arg(long, default_value = "hierarchical")]
pub preset: String,
#[arg(long)]
pub root: PathBuf,
#[arg(long)]
pub projects: Option<String>,
#[arg(long, default_value = "127.0.0.1:19090")]
pub bind: String,
#[arg(long)]
pub non_interactive: bool,
#[arg(long)]
pub force: bool,
}
pub fn run(args: InitArgs) -> Result<()> {
let bearer_marker = args.root.join("config/admin.bearer.txt");
if bearer_marker.exists() && !args.force {
return Err(anyhow!(
"init déjà effectuée (admin.bearer.txt existe dans {}) ; \
passer --force pour ré-initialiser",
args.root.display()
));
}
create_layout(&args.root)?;
if args.force {
for secret_file in [
"config/jwt.private.pem",
"config/jwt.public.pem",
"config/admin.bearer.txt",
] {
let p = args.root.join(secret_file);
if p.exists() {
fs::remove_file(&p)
.with_context(|| format!("suppression (--force) de {}", p.display()))?;
}
}
}
generate_jwt_keys(&args.root)?;
let bearer = generate_admin_bearer(&args.root)?;
materialize_preset(&args.root, &args.preset, args.projects.as_deref())?;
write_or_merge_server_toml(&args.root, &args.bind)?;
init_sqlite_dbs(&args.root)?;
if !args.non_interactive {
println!(
"\nBearer admin (sauvegardé dans {}, affiché UNE SEULE FOIS) :\n {}",
bearer_marker.display(),
bearer
);
}
Ok(())
}
fn create_layout(root: &Path) -> Result<()> {
for sub in ["md", "db", "config"] {
let p = root.join(sub);
fs::create_dir_all(&p)
.with_context(|| format!("création du répertoire {}", p.display()))?;
fs::set_permissions(&p, fs::Permissions::from_mode(0o750))
.with_context(|| format!("chmod 0750 sur {}", p.display()))?;
}
Ok(())
}
fn generate_jwt_keys(root: &Path) -> Result<()> {
let mut csprng = OsRng;
let signing = SigningKey::generate(&mut csprng);
let verifying = signing.verifying_key();
let priv_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.context("encodage de la clé privée JWT en PKCS8 PEM")?;
let priv_path = root.join("config/jwt.private.pem");
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&priv_path)
.with_context(|| {
format!(
"ouverture en création exclusive de {} (déjà initialisé ?)",
priv_path.display()
)
})?
.write_all(priv_pem.as_bytes())
.with_context(|| format!("écriture de {}", priv_path.display()))?;
let pub_pem = verifying
.to_public_key_pem(LineEnding::LF)
.context("encodage de la clé publique JWT en SPKI PEM")?;
let pub_path = root.join("config/jwt.public.pem");
fs::write(&pub_path, pub_pem.as_bytes())
.with_context(|| format!("écriture de {}", pub_path.display()))?;
fs::set_permissions(&pub_path, fs::Permissions::from_mode(0o644))
.with_context(|| format!("chmod 0644 sur {}", pub_path.display()))?;
Ok(())
}
fn generate_admin_bearer(root: &Path) -> Result<String> {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
let bearer = hex::encode(bytes);
let path = root.join("config/admin.bearer.txt");
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.with_context(|| {
format!(
"ouverture en création exclusive de {} (déjà initialisé ?)",
path.display()
)
})?
.write_all(bearer.as_bytes())
.with_context(|| format!("écriture de {}", path.display()))?;
Ok(bearer)
}
const PRESET_HIERARCHICAL: &str = include_str!("../../../examples/presets/hierarchical.toml");
const PRESET_FLAT: &str = include_str!("../../../examples/presets/flat.toml");
fn resolve_preset(preset: &str) -> Result<String> {
if preset.contains('/') {
fs::read_to_string(preset)
.with_context(|| format!("lecture du preset depuis le fichier '{preset}'"))
} else {
match preset {
"hierarchical" => Ok(PRESET_HIERARCHICAL.to_owned()),
"flat" => Ok(PRESET_FLAT.to_owned()),
other => Err(anyhow!(
"preset inconnu : '{other}'. \
Presets embarqués disponibles : hierarchical, flat. \
Pour un preset custom, passer un chemin contenant '/' \
(ex. --preset /etc/gradatum/mon-preset.toml)"
)),
}
}
}
pub fn materialize_preset(root: &Path, preset: &str, projects: Option<&str>) -> Result<()> {
let template = resolve_preset(preset)?;
let projects_list = projects.unwrap_or("main");
let materialized = template
.replace("${PROJECTS}", projects_list)
.replace("${AGENT}", "*")
.replace("${THEME}", "*");
let bearer_toml = root.join("config/bearer.toml");
if bearer_toml.exists() {
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let backup = bearer_toml.with_extension(format!("toml.bak.{ts}"));
fs::copy(&bearer_toml, &backup)
.with_context(|| format!("backup {} → {}", bearer_toml.display(), backup.display()))?;
tracing::info!(backup = %backup.display(), "bearer.toml sauvegardé avant écrasement");
}
fs::write(&bearer_toml, materialized.as_bytes())
.with_context(|| format!("écriture de {}", bearer_toml.display()))?;
fs::set_permissions(&bearer_toml, fs::Permissions::from_mode(0o640))
.with_context(|| format!("chmod 0640 sur {}", bearer_toml.display()))?;
Ok(())
}
pub fn generate_server_toml_template(root: &Path, bind: &str) -> String {
let root_str = root.display();
format!(
r#"# Généré par `gradatum-admin init` — modifier avec précaution.
[server]
bind = "{bind}"
metrics_bind = "127.0.0.1:19091"
[storage]
root = "{root_str}"
vault_index_path = "{root_str}/db/index.sqlite"
[auth]
jwt_public_key_path = "{root_str}/config/jwt.public.pem"
jwt_private_key_path = "{root_str}/config/jwt.private.pem"
jwt_ttl_human_secs = 3600
jwt_ttl_service_secs = 86400
revocation_store = "sqlite"
revocation_db_path = "{root_str}/db/revocation.sqlite"
api_keys_db_path = "{root_str}/db/api_keys.sqlite"
[acl]
preset_path = "{root_str}/config/bearer.toml"
[log]
format = "json"
[embed]
# Embedder HTTP.
# Active la génération d'embeddings async via gradatum-worker → POST endpoint HTTP.
# Sans cette section, le worker démarre embedder=None et skip silencieusement les jobs embed_note.
# Default values — override in server.toml for your deployment.
enabled = true
endpoint = "http://localhost:8436/v1/embeddings"
model = "bge-m3-Q8_0"
dim = 1024
timeout_ms = 5000
"#
)
}
const KEY_MIGRATIONS: &[(&str, &str)] = &[
("storage.db_path", "storage.vault_index_path"),
];
fn write_or_merge_server_toml(root: &Path, bind: &str) -> Result<()> {
let p = root.join("config/server.toml");
let new_content = generate_server_toml_template(root, bind);
let final_content = if p.exists() {
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
let backup = p.with_extension(format!("toml.bak.{ts}"));
fs::copy(&p, &backup)
.with_context(|| format!("backup {} → {}", p.display(), backup.display()))?;
tracing::info!(backup = %backup.display(), "server.toml sauvegardé avant re-init");
let existing =
fs::read_to_string(&p).with_context(|| format!("lecture de {}", p.display()))?;
merge_user_config(&existing, &new_content)?
} else {
new_content
};
fs::write(&p, final_content.as_bytes())
.with_context(|| format!("écriture de {}", p.display()))?;
fs::set_permissions(&p, fs::Permissions::from_mode(0o640))
.with_context(|| format!("chmod 0640 sur {}", p.display()))?;
Ok(())
}
pub fn merge_user_config(existing: &str, new_template: &str) -> Result<String> {
use toml_edit::DocumentMut;
let existing_doc: DocumentMut = existing
.parse()
.context("parse server.toml existant (backup)")?;
let mut result: DocumentMut = new_template
.parse()
.context("parse nouveau template server.toml")?;
let mut backup_migrated = existing_doc.clone();
for (old_path, new_path) in KEY_MIGRATIONS {
if let Some(old_item) = lookup_item(backup_migrated.as_table(), old_path) {
let val = old_item.clone();
set_item_or_insert(backup_migrated.as_table_mut(), new_path, val);
remove_path(backup_migrated.as_table_mut(), old_path);
tracing::info!(
old = %old_path,
new = %new_path,
"merge server.toml — rename migration appliquée pré-walk"
);
}
}
let mut preserved = 0usize;
let mut new_keys = 0usize;
let mut user_added = 0usize;
walk_and_merge(
result.as_table_mut(),
backup_migrated.as_table(),
"",
&mut preserved,
&mut new_keys,
&mut user_added,
);
tracing::info!(
preserved,
new_keys,
user_added,
"merge server.toml — valeurs préservées + nouvelles clés avec défauts + extensions user"
);
Ok(result.to_string())
}
fn walk_and_merge(
target: &mut toml_edit::Table,
source: &toml_edit::Table,
path_prefix: &str,
preserved: &mut usize,
new_keys: &mut usize,
user_added: &mut usize,
) {
let source_keys: Vec<String> = source.iter().map(|(k, _)| k.to_string()).collect();
for key in &source_keys {
let full_path = if path_prefix.is_empty() {
key.clone()
} else {
format!("{path_prefix}.{key}")
};
let source_item = match source.get(key.as_str()) {
Some(it) => it.clone(),
None => continue,
};
match target.get_mut(key.as_str()) {
Some(target_item)
if matches!(target_item, toml_edit::Item::Table(_))
&& matches!(source_item, toml_edit::Item::Table(_)) =>
{
if let (toml_edit::Item::Table(t_target), toml_edit::Item::Table(t_source)) =
(target_item, &source_item)
{
walk_and_merge(
t_target, t_source, &full_path, preserved, new_keys, user_added,
);
}
}
Some(target_item) => {
*target_item = source_item;
*preserved += 1;
}
None => {
target.insert(key.as_str(), source_item);
*user_added += 1;
tracing::info!(path = %full_path, "merge: section/key préservée (user extension)");
}
}
}
for (key, _) in target.iter() {
if !source.contains_key(key) {
*new_keys += 1;
}
}
}
fn lookup_item<'a>(table: &'a toml_edit::Table, path: &str) -> Option<&'a toml_edit::Item> {
let mut parts = path.splitn(2, '.');
let head = parts.next()?;
let rest = parts.next();
match (table.get(head), rest) {
(Some(item), None) => Some(item),
(Some(toml_edit::Item::Table(sub)), Some(tail)) => lookup_item(sub, tail),
_ => None,
}
}
fn set_item_or_insert(table: &mut toml_edit::Table, path: &str, value: toml_edit::Item) {
let mut parts = path.splitn(2, '.');
let head = match parts.next() {
Some(h) => h,
None => return,
};
let tail = parts.next();
match tail {
None => {
table.insert(head, value);
}
Some(key) => {
if table.get(head).is_none() {
table.insert(head, toml_edit::Item::Table(toml_edit::Table::new()));
}
if let Some(toml_edit::Item::Table(sub)) = table.get_mut(head) {
sub.insert(key, value);
}
}
}
}
fn remove_path(table: &mut toml_edit::Table, path: &str) {
let mut parts = path.splitn(2, '.');
let head = match parts.next() {
Some(h) => h,
None => return,
};
let tail = parts.next();
match tail {
None => {
table.remove(head);
}
Some(key) => {
if let Some(toml_edit::Item::Table(sub)) = table.get_mut(head) {
sub.remove(key);
}
}
}
}
#[allow(dead_code)]
pub(crate) fn validate_server_toml(content: &str) -> Result<()> {
content
.parse::<toml_edit::DocumentMut>()
.map(|_| ())
.map_err(|e| anyhow::anyhow!("server.toml invalide : {e}"))
}
fn init_sqlite_dbs(root: &Path) -> Result<()> {
let queue_path = root.join("db/queue.sqlite");
let conn = Connection::open(&queue_path)
.with_context(|| format!("ouverture de {}", queue_path.display()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
status TEXT NOT NULL,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status_lease ON jobs(status, lease_until);",
)
.with_context(|| format!("initialisation de {}", queue_path.display()))?;
let revoc_path = root.join("db/revocation.sqlite");
let conn = Connection::open(&revoc_path)
.with_context(|| format!("ouverture de {}", revoc_path.display()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS revoked (
jti TEXT PRIMARY KEY,
exp INTEGER NOT NULL,
revoked_at INTEGER NOT NULL
);",
)
.with_context(|| format!("initialisation de {}", revoc_path.display()))?;
let api_keys_path = root.join("db/api_keys.sqlite");
let conn = Connection::open(&api_keys_path)
.with_context(|| format!("ouverture de {}", api_keys_path.display()))?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
prefix TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL,
owner TEXT NOT NULL,
scopes_json TEXT NOT NULL,
tenant_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER,
description TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(prefix);",
)
.with_context(|| format!("initialisation de {}", api_keys_path.display()))?;
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM api_keys", [], |r| r.get(0))
.unwrap_or(0);
if count > 0 {
tracing::warn!(
rows = count,
"api_keys table existe avec {} rows — re-init non-destructive",
count
);
}
Ok(())
}