feat: socle BDD (tâche 1.9 Phase 1-2) + moteur APT (tâche 2 SJ-0→3) + WIP capabilities/auth/Rust
Checkpoint multi-chantiers (arbre vert : tsc 0 erreur, 70 tests, build OK). - tâche 1.9 Phase 1 : schéma socle (machine_state/events/reports/raw_artifacts/ hardware/metrics + colonnes étendues) + wiring refresh/execute. Migration 0002. - tâche 1.9 Phase 2 : machine_credentials + machine_host_keys (non destructif, dual-read + backfill). Migration 0003. Fix séquence journal de migration. - tâche 2 : SJ-0 (types étendus rétro-compatibles, réducteur Docker, resolveTemplate), SJ-1 (update-analyze enrichi), SJ-2 (apply + diff dpkg + timeout inactivité SSH), SJ-3 (reboot vérifié boot_id). - WIP parallèle inclus : /api/capabilities, auth/apiTokens/apiClients, system metrics, scaffold app_rust, ajustements frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HttpUrl {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub base_path: String,
|
||||
}
|
||||
|
||||
impl HttpUrl {
|
||||
pub fn parse(server_url: &str) -> Result<Self, String> {
|
||||
let without_scheme = server_url
|
||||
.strip_prefix("http://")
|
||||
.ok_or_else(|| "ce premier client supporte seulement http://".to_string())?;
|
||||
|
||||
let (host_port, base_path) = match without_scheme.split_once('/') {
|
||||
Some((host_port, path)) => (host_port, format!("/{path}")),
|
||||
None => (without_scheme, String::new()),
|
||||
};
|
||||
|
||||
let (host, port) = match host_port.rsplit_once(':') {
|
||||
Some((host, port)) => {
|
||||
let parsed_port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "port serveur invalide".to_string())?;
|
||||
(host.to_string(), parsed_port)
|
||||
}
|
||||
None => (host_port.to_string(), 80),
|
||||
};
|
||||
|
||||
if host.is_empty() {
|
||||
return Err("hôte serveur manquant".to_string());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
base_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self, endpoint: &str) -> String {
|
||||
let base = self.base_path.trim_end_matches('/');
|
||||
let endpoint = endpoint.trim_start_matches('/');
|
||||
if base.is_empty() {
|
||||
format!("/{endpoint}")
|
||||
} else {
|
||||
format!("{base}/{endpoint}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
server: HttpUrl,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(server_url: &str, token: Option<String>) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
server: HttpUrl::parse(server_url)?,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_capabilities(&self) -> Result<String, String> {
|
||||
self.get("/api/capabilities")
|
||||
}
|
||||
|
||||
pub fn get_system_status(&self) -> Result<String, String> {
|
||||
self.get("/api/system/status")
|
||||
}
|
||||
|
||||
pub fn get_system_metrics(&self) -> Result<String, String> {
|
||||
self.get("/api/system/metrics")
|
||||
}
|
||||
|
||||
pub fn get_machines(&self) -> Result<String, String> {
|
||||
self.get("/api/machines")
|
||||
}
|
||||
|
||||
fn get(&self, endpoint: &str) -> Result<String, String> {
|
||||
let path = self.server.path(endpoint);
|
||||
let mut request = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {}\r\nUser-Agent: system-update-gnome/0.1\r\nAccept: application/json\r\nConnection: close\r\n",
|
||||
self.server.host
|
||||
);
|
||||
|
||||
if let Some(token) = &self.token {
|
||||
request.push_str(&format!("Authorization: Bearer {token}\r\n"));
|
||||
}
|
||||
request.push_str("\r\n");
|
||||
|
||||
let mut stream = TcpStream::connect((&*self.server.host, self.server.port))
|
||||
.map_err(|err| format!("connexion serveur échouée: {err}"))?;
|
||||
stream
|
||||
.write_all(request.as_bytes())
|
||||
.map_err(|err| format!("envoi requête échoué: {err}"))?;
|
||||
|
||||
let mut response = String::new();
|
||||
stream
|
||||
.read_to_string(&mut response)
|
||||
.map_err(|err| format!("lecture réponse échouée: {err}"))?;
|
||||
|
||||
split_http_response(&response)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_http_response(response: &str) -> Result<String, String> {
|
||||
let (headers, body) = response
|
||||
.split_once("\r\n\r\n")
|
||||
.ok_or_else(|| "réponse HTTP invalide".to_string())?;
|
||||
let status_line = headers.lines().next().unwrap_or_default();
|
||||
if !status_line.contains(" 200 ") {
|
||||
return Err(format!("réponse serveur inattendue: {status_line}"));
|
||||
}
|
||||
Ok(body.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_default_http_port() {
|
||||
let url = HttpUrl::parse("http://localhost").expect("url");
|
||||
assert_eq!(url.host, "localhost");
|
||||
assert_eq!(url.port, 80);
|
||||
assert_eq!(url.path("/api/capabilities"), "/api/capabilities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_http_port_and_base_path() {
|
||||
let url = HttpUrl::parse("http://10.0.0.80:8787/system-update").expect("url");
|
||||
assert_eq!(url.host, "10.0.0.80");
|
||||
assert_eq!(url.port, 8787);
|
||||
assert_eq!(
|
||||
url.path("/api/capabilities"),
|
||||
"/system-update/api/capabilities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_https_until_tls_client_is_added() {
|
||||
assert!(HttpUrl::parse("https://10.0.0.80:8787").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_success_body() {
|
||||
let body = split_http_response(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}",
|
||||
)
|
||||
.expect("body");
|
||||
assert_eq!(body, "{\"ok\":true}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user