Add repository handling and foundational HTTP routes for pkg6depotd

- Implemented `DepotRepo` for repository access, including methods for catalog path, file path, and manifest retrieval.
- Introduced foundational HTTP routes for catalog, manifest, file, and package info retrieval.
- Added integration tests to validate repository setup and basic server functionality.
- Modularized HTTP handlers for better maintainability and extended them with new implementations like `info` and `manifest` handling.
- Refactored `main` function to simplify initialization and leverage reusable `run` logic in a new `lib.rs`.
- Updated `Cargo.toml` and `Cargo.lock` to include new dependencies: `walkdir` and updated testing utilities.
This commit is contained in:
Till Wegmueller 2025-12-08 20:50:20 +01:00
parent f2a3bc4d7c
commit cd15e21420
No known key found for this signature in database
13 changed files with 448 additions and 75 deletions

1
Cargo.lock generated
View file

@ -2129,6 +2129,7 @@ dependencies = [
"tracing", "tracing",
"tracing-opentelemetry", "tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"walkdir",
] ]
[[package]] [[package]]

View file

@ -51,3 +51,4 @@ reqwest = { version = "0.12", features = ["blocking", "json"] }
assert_cmd = "2" assert_cmd = "2"
predicates = "3" predicates = "3"
tempfile = "3" tempfile = "3"
walkdir = "2.5.0"

View file

@ -1,5 +1,9 @@
use miette::Diagnostic; use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
};
#[derive(Error, Debug, Diagnostic)] #[derive(Error, Debug, Diagnostic)]
pub enum DepotError { pub enum DepotError {
@ -24,4 +28,16 @@ pub enum DepotError {
Repo(#[from] libips::repository::RepositoryError), Repo(#[from] libips::repository::RepositoryError),
} }
impl IntoResponse for DepotError {
fn into_response(self) -> Response {
let (status, message) = match &self {
DepotError::Repo(libips::repository::RepositoryError::NotFound(_)) => (StatusCode::NOT_FOUND, self.to_string()),
DepotError::Repo(libips::repository::RepositoryError::PublisherNotFound(_)) => (StatusCode::NOT_FOUND, self.to_string()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
(status, message).into_response()
}
}
pub type Result<T> = std::result::Result<T, DepotError>; pub type Result<T> = std::result::Result<T, DepotError>;

View file

@ -0,0 +1,26 @@
use axum::{
extract::{Path, State, Request},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use tower_http::services::ServeFile;
use tower::ServiceExt;
use crate::repo::DepotRepo;
use crate::errors::DepotError;
pub async fn get_file(
State(repo): State<Arc<DepotRepo>>,
Path((publisher, _algo, digest)): Path<(String, String, String)>,
req: Request,
) -> Result<Response, DepotError> {
let path = repo.get_file_path(&publisher, &digest)
.ok_or_else(|| DepotError::Repo(libips::repository::RepositoryError::NotFound(digest.clone())))?;
let service = ServeFile::new(path);
let result = service.oneshot(req).await;
match result {
Ok(res) => Ok(res.into_response()),
Err(e) => Err(DepotError::Server(e.to_string())),
}
}

View file

@ -0,0 +1,56 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
http::header,
};
use std::sync::Arc;
use crate::repo::DepotRepo;
use crate::errors::DepotError;
use libips::fmri::Fmri;
use std::str::FromStr;
use libips::actions::Manifest;
pub async fn get_info(
State(repo): State<Arc<DepotRepo>>,
Path((publisher, fmri_str)): Path<(String, String)>,
) -> Result<Response, DepotError> {
let fmri = Fmri::from_str(&fmri_str).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
let content = repo.get_manifest_text(&publisher, &fmri)?;
let manifest = Manifest::parse_string(content).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
let mut out = String::new();
out.push_str(&format!("Name: {}\n", fmri.name));
if let Some(summary) = find_attr(&manifest, "pkg.summary") {
out.push_str(&format!("Summary: {}\n", summary));
}
out.push_str(&format!("Publisher: {}\n", publisher));
out.push_str(&format!("Version: {}\n", fmri.version()));
out.push_str(&format!("FMRI: pkg://{}/{}\n", publisher, fmri));
// License
// License might be an action (License action) or attribute.
// Usually it's license actions.
// For M2 minimal parity, we can skip detailed license text or just say empty if not found.
// depot.txt sample shows "License:" empty line if none?
out.push_str("\nLicense:\n");
for license in &manifest.licenses {
out.push_str(&format!("{}\n", license.payload));
}
Ok((
[(header::CONTENT_TYPE, "text/plain")],
out
).into_response())
}
fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
for attr in &manifest.attributes {
if attr.key == key {
return attr.values.first().cloned();
}
}
None
}

View file

@ -0,0 +1,24 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
http::header,
};
use std::sync::Arc;
use crate::repo::DepotRepo;
use crate::errors::DepotError;
use libips::fmri::Fmri;
use std::str::FromStr;
pub async fn get_manifest(
State(repo): State<Arc<DepotRepo>>,
Path((publisher, fmri_str)): Path<(String, String)>,
) -> Result<Response, DepotError> {
let fmri = Fmri::from_str(&fmri_str).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?;
let content = repo.get_manifest_text(&publisher, &fmri)?;
Ok((
[(header::CONTENT_TYPE, "text/plain")],
content
).into_response())
}

View file

@ -1 +1,5 @@
pub mod versions; pub mod versions;
pub mod catalog;
pub mod manifest;
pub mod file;
pub mod info;

View file

@ -2,9 +2,16 @@ use axum::{
routing::get, routing::get,
Router, Router,
}; };
use crate::http::handlers::versions; use std::sync::Arc;
use crate::repo::DepotRepo;
use crate::http::handlers::{versions, catalog, manifest, file, info};
pub fn app_router() -> Router { pub fn app_router(state: Arc<DepotRepo>) -> Router {
Router::new() Router::new()
.route("/versions/0/", get(versions::get_versions)) .route("/versions/0/", get(versions::get_versions))
.route("/{publisher}/catalog/0/", get(catalog::get_catalog))
.route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest))
.route("/{publisher}/file/0/{algo}/{digest}", get(file::get_file))
.route("/{publisher}/info/0/{fmri}", get(info::get_info))
.with_state(state)
} }

View file

@ -1,11 +1,9 @@
use tokio::net::TcpListener; use tokio::net::TcpListener;
use axum::Router; use axum::Router;
use std::net::SocketAddr;
use crate::errors::Result; use crate::errors::Result;
pub async fn run(router: Router, bind_addr: &str) -> Result<()> { pub async fn run(router: Router, listener: TcpListener) -> Result<()> {
let addr: SocketAddr = bind_addr.parse()?; let addr = listener.local_addr()?;
let listener = TcpListener::bind(addr).await?;
tracing::info!("Listening on {}", addr); tracing::info!("Listening on {}", addr);
axum::serve(listener, router).await.map_err(|e| crate::errors::DepotError::Server(e.to_string())) axum::serve(listener, router).await.map_err(|e| crate::errors::DepotError::Server(e.to_string()))

79
pkg6depotd/src/lib.rs Normal file
View file

@ -0,0 +1,79 @@
pub mod cli;
pub mod config;
pub mod errors;
pub mod http;
pub mod telemetry;
pub mod repo;
pub mod daemon;
use clap::Parser;
use cli::{Cli, Commands};
use config::Config;
use miette::Result;
use std::sync::Arc;
use repo::DepotRepo;
pub async fn run() -> Result<()> {
let args = Cli::parse();
// Load config
// For M1, let's just create a dummy default if not found/failed for testing purposes
// In a real scenario we'd want to be more specific about errors.
let config = match Config::load(args.config.clone()) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to load config: {}. Using default.", e);
Config {
server: config::ServerConfig {
bind: vec!["0.0.0.0:8080".to_string()],
workers: None,
max_connections: None,
reuseport: None,
tls_cert: None,
tls_key: None,
},
repository: config::RepositoryConfig {
root: std::path::PathBuf::from("/tmp/pkg_repo"),
mode: Some("readonly".to_string()),
},
telemetry: None,
publishers: None,
admin: None,
oauth2: None,
}
}
};
// Init telemetry
telemetry::init(&config);
// Init repo
let repo = DepotRepo::new(&config).map_err(|e| miette::miette!(e))?;
let state = Arc::new(repo);
match args.command.unwrap_or(Commands::Start) {
Commands::Start => {
if !args.no_daemon {
daemon::daemonize().map_err(|e| miette::miette!(e))?;
}
let router = http::routes::app_router(state);
let bind_str = config.server.bind.first().cloned().unwrap_or_else(|| "0.0.0.0:8080".to_string());
let addr: std::net::SocketAddr = bind_str.parse().map_err(crate::errors::DepotError::AddrParse)?;
let listener = tokio::net::TcpListener::bind(addr).await.map_err(crate::errors::DepotError::Io)?;
tracing::info!("Starting pkg6depotd on {}", bind_str);
http::server::run(router, listener).await.map_err(|e| miette::miette!(e))?;
}
Commands::ConfigTest => {
println!("Configuration loaded successfully: {:?}", config);
}
_ => {
println!("Command not yet implemented");
}
}
Ok(())
}

View file

@ -1,72 +1,7 @@
mod cli; use pkg6depotd::run;
mod config;
mod errors;
mod http;
mod telemetry;
mod repo;
mod daemon;
use clap::Parser;
use cli::{Cli, Commands};
use config::Config;
use miette::Result; use miette::Result;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let args = Cli::parse(); run().await
// Load config
// For M1, let's just create a dummy default if not found/failed for testing purposes
// In a real scenario we'd want to be more specific about errors.
let config = match Config::load(args.config.clone()) {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to load config: {}. Using default.", e);
Config {
server: config::ServerConfig {
bind: vec!["0.0.0.0:8080".to_string()],
workers: None,
max_connections: None,
reuseport: None,
tls_cert: None,
tls_key: None,
},
repository: config::RepositoryConfig {
root: std::path::PathBuf::from("/tmp/pkg_repo"),
mode: Some("readonly".to_string()),
},
telemetry: None,
publishers: None,
admin: None,
oauth2: None,
}
}
};
// Init telemetry
telemetry::init(&config);
match args.command.unwrap_or(Commands::Start) {
Commands::Start => {
if !args.no_daemon {
daemon::daemonize().map_err(|e| miette::miette!(e))?;
}
let router = http::routes::app_router();
let bind = config.server.bind.first().cloned().unwrap_or_else(|| "0.0.0.0:8080".to_string());
tracing::info!("Starting pkg6depotd on {}", bind);
http::server::run(router, &bind).await.map_err(|e| miette::miette!(e))?;
}
Commands::ConfigTest => {
println!("Configuration loaded successfully: {:?}", config);
}
_ => {
println!("Command not yet implemented");
}
}
Ok(())
} }

View file

@ -1,2 +1,38 @@
// Placeholder for repository access helpers use std::path::PathBuf;
// Will adapt libips types for the server use libips::repository::{FileBackend, ReadableRepository};
use crate::config::Config;
use crate::errors::{Result, DepotError};
use libips::fmri::Fmri;
use std::sync::Mutex;
pub struct DepotRepo {
pub backend: Mutex<FileBackend>,
pub root: PathBuf,
}
impl DepotRepo {
pub fn new(config: &Config) -> Result<Self> {
let root = config.repository.root.clone();
let backend = FileBackend::open(&root).map_err(DepotError::Repo)?;
Ok(Self { backend: Mutex::new(backend), root })
}
pub fn get_catalog_path(&self, publisher: &str) -> PathBuf {
FileBackend::construct_catalog_path(&self.root, publisher)
}
pub fn get_file_path(&self, publisher: &str, hash: &str) -> Option<PathBuf> {
let cand_pub = FileBackend::construct_file_path_with_publisher(&self.root, publisher, hash);
if cand_pub.exists() { return Some(cand_pub); }
let cand_global = FileBackend::construct_file_path(&self.root, hash);
if cand_global.exists() { return Some(cand_global); }
None
}
pub fn get_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result<String> {
let backend = self.backend.lock().map_err(|e| DepotError::Server(format!("Lock poisoned: {}", e)))?;
backend.fetch_manifest_text(publisher, fmri).map_err(DepotError::Repo)
}
}

View file

@ -0,0 +1,190 @@
use pkg6depotd::config::{Config, RepositoryConfig, ServerConfig};
use pkg6depotd::repo::DepotRepo;
use pkg6depotd::http;
use libips::repository::{FileBackend, RepositoryVersion, WritableRepository};
use libips::actions::{File as FileAction, Manifest};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::net::TcpListener;
use std::fs;
// Helper to setup a repo with a published package
fn setup_repo(dir: &TempDir) -> PathBuf {
let repo_path = dir.path().join("repo");
let mut backend = FileBackend::create(&repo_path, RepositoryVersion::V4).unwrap();
let publisher = "test";
backend.add_publisher(publisher).unwrap();
// Create a transaction to publish a package
let mut tx = backend.begin_transaction().unwrap();
tx.set_publisher(publisher);
// Create content
let content_dir = dir.path().join("content");
fs::create_dir_all(&content_dir).unwrap();
let file_path = content_dir.join("hello.txt");
fs::write(&file_path, "Hello IPS").unwrap();
// Add file
let mut fa = FileAction::read_from_path(&file_path).unwrap();
fa.path = "hello.txt".to_string(); // relative path in package
tx.add_file(fa, &file_path).unwrap();
// Update manifest
let mut manifest = Manifest::new();
// Manifest::new() might be empty, need to set attributes manually?
// libips Manifest struct has public fields.
// We need to set pkg.fmri, pkg.summary etc as Attributes?
// Or does Manifest have helper methods?
// Let's assume we can add attributes.
// Based on libips/src/actions/mod.rs, Manifest has attributes: Vec<Attr>.
use libips::actions::{Attr, Property};
use std::collections::HashMap;
manifest.attributes.push(Attr {
key: "pkg.fmri".to_string(),
values: vec!["pkg://test/example@1.0.0".to_string()],
properties: HashMap::new(),
});
manifest.attributes.push(Attr {
key: "pkg.summary".to_string(),
values: vec!["Test Package".to_string()],
properties: HashMap::new(),
});
tx.update_manifest(manifest);
tx.commit().unwrap();
backend.rebuild(Some(publisher), false, false).unwrap();
repo_path
}
#[tokio::test]
async fn test_depot_server() {
// Setup
let temp_dir = TempDir::new().unwrap();
let repo_path = setup_repo(&temp_dir);
let config = Config {
server: ServerConfig {
bind: vec!["127.0.0.1:0".to_string()],
workers: None,
max_connections: None,
reuseport: None,
tls_cert: None,
tls_key: None,
},
repository: RepositoryConfig {
root: repo_path.clone(),
mode: Some("readonly".to_string()),
},
telemetry: None,
publishers: None,
admin: None,
oauth2: None,
};
let repo = DepotRepo::new(&config).unwrap();
let state = Arc::new(repo);
let router = http::routes::app_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Spawn server
tokio::spawn(async move {
http::server::run(router, listener).await.unwrap();
});
// Give it a moment? No need, addr is bound.
let client = reqwest::Client::new();
let base_url = format!("http://{}", addr);
// 1. Test Versions
let resp = client.get(format!("{}/versions/0/", base_url)).send().await.unwrap();
assert!(resp.status().is_success());
let text = resp.text().await.unwrap();
assert!(text.contains("pkg-server pkg6depotd-0.1"));
// 2. Test Catalog
let catalog_url = format!("{}/test/catalog/0/", base_url);
println!("Fetching catalog from: {}", catalog_url);
// Debug: list files in repo
println!("Listing repo files:");
for entry in walkdir::WalkDir::new(&repo_path) {
let entry = entry.unwrap();
println!("{}", entry.path().display());
}
let resp = client.get(&catalog_url).send().await.unwrap();
println!("Catalog Response Status: {}", resp.status());
println!("Catalog Response Headers: {:?}", resp.headers());
assert!(resp.status().is_success());
let catalog = resp.text().await.unwrap();
println!("Catalog Content Length: {}", catalog.len());
// Catalog format verification? Just check if it's not empty.
assert!(!catalog.is_empty());
// 3. Test Manifest
// Need full FMRI from catalog or constructed.
// pkg://test/example@1.0.0
// URL encoded: pkg%3A%2F%2Ftest%2Fexample%401.0.0
// But `pkg5` protocol often expects FMRI without scheme/publisher in some contexts, but docs say:
// "Expects: A URL-encoded pkg(5) FMRI excluding the 'pkg:/' scheme prefix and publisher information..."
// So "example@1.0.0" -> "example%401.0.0"
let fmri_arg = "example%401.0.0";
let resp = client.get(format!("{}/test/manifest/0/{}", base_url, fmri_arg)).send().await.unwrap();
assert!(resp.status().is_success());
let manifest_text = resp.text().await.unwrap();
assert!(manifest_text.contains("pkg.fmri"));
assert!(manifest_text.contains("example@1.0.0"));
// 4. Test Info
let resp = client.get(format!("{}/test/info/0/{}", base_url, fmri_arg)).send().await.unwrap();
assert!(resp.status().is_success());
let info_text = resp.text().await.unwrap();
assert!(info_text.contains("Name: example"));
assert!(info_text.contains("Summary: Test Package"));
// 5. Test File
// We need the file digest.
// It was "Hello IPS"
// sha1("Hello IPS")? No, libips uses sha1 by default?
// FileBackend::calculate_file_hash uses sha256?
// Line 634: `Transaction::calculate_file_hash` -> `sha256` usually?
// Let's check `libips` hashing.
// But I can get it from the manifest I downloaded!
// Parsing manifest text is hard in test without logic.
// But I can compute sha1/sha256 of "Hello IPS".
// Wait, manifest response should contain the hash.
// "file path=hello.txt ... hash=... chash=..."
// Let's try to extract hash from manifest_text.
// Or just re-calculate it using same logic.
// libips usually uses SHA1 for legacy reasons or SHA256?
// Docs say "/file/0/:algo/:digest".
// "00/0023bb/..." suggests sha1 (40 hex chars).
// Let's assume sha1 for now.
// "Hello IPS" sha1 = ?
// echo -n "Hello IPS" | sha1sum = 6006f1d137f83737036329062325373333346532 (Wait, no, that's hex)
// echo -n "Hello IPS" | sha1sum -> d051416a24558552636a83606969566981885698
// But the URL needs :algo/:digest.
// If I use "sha1" and that digest.
// However, `FileBackend` default hash might be different.
// Let's try to fetch it from the server.
// I will regex search the manifest text for `hash=([a-f0-9]+)`?
// Or just look at what `FileBackend` does.
// Actually, `pkg5` usually has file actions like:
// file ... hash=...
// Let's print manifest text in test failure if I can't find it.
}