mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
Add ImageCatalog and InstalledPackages modules for package management
- Introduced `ImageCatalog` for handling catalog-related operations, including initialization, building from publishers, querying, and manifest management. - Added `InstalledPackages` for managing installed package operations, such as adding, removing, querying, and retrieving manifests. - Defined database schemas and error types to support both modules. - Refactored table handling, ensuring consistent transaction scoping with `redb`.
This commit is contained in:
parent
e33ccbe6ec
commit
e27cd35d8d
7 changed files with 1158 additions and 138 deletions
494
libips/src/image/catalog.rs
Normal file
494
libips/src/image/catalog.rs
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
use crate::actions::{Manifest};
|
||||||
|
use crate::fmri::Fmri;
|
||||||
|
use crate::repository::catalog::{CatalogManager, CatalogPart, PackageVersionEntry};
|
||||||
|
use miette::Diagnostic;
|
||||||
|
use redb::{Database, ReadableTable, TableDefinition};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Table definition for the catalog database
|
||||||
|
/// Key: stem@version
|
||||||
|
/// Value: serialized Manifest
|
||||||
|
pub const CATALOG_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("catalog");
|
||||||
|
|
||||||
|
/// Table definition for the obsoleted packages catalog
|
||||||
|
/// Key: full FMRI including publisher (pkg://publisher/stem@version)
|
||||||
|
/// Value: nothing
|
||||||
|
pub const OBSOLETED_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("obsoleted");
|
||||||
|
|
||||||
|
/// Table definition for the installed packages database
|
||||||
|
/// Key: full FMRI including publisher (pkg://publisher/stem@version)
|
||||||
|
/// Value: serialized Manifest
|
||||||
|
pub const INSTALLED_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("installed");
|
||||||
|
|
||||||
|
/// Errors that can occur when working with the image catalog
|
||||||
|
#[derive(Error, Debug, Diagnostic)]
|
||||||
|
pub enum CatalogError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::io))]
|
||||||
|
IO(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::json))]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::database))]
|
||||||
|
Database(String),
|
||||||
|
|
||||||
|
#[error("Repository error: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::repository))]
|
||||||
|
Repository(#[from] crate::repository::RepositoryError),
|
||||||
|
|
||||||
|
#[error("Action error: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::action))]
|
||||||
|
Action(#[from] crate::actions::ActionError),
|
||||||
|
|
||||||
|
#[error("Publisher not found: {0}")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::publisher_not_found))]
|
||||||
|
PublisherNotFound(String),
|
||||||
|
|
||||||
|
#[error("No publishers configured")]
|
||||||
|
#[diagnostic(code(ips::catalog_error::no_publishers))]
|
||||||
|
NoPublishers,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type for catalog operations
|
||||||
|
pub type Result<T> = std::result::Result<T, CatalogError>;
|
||||||
|
|
||||||
|
/// Information about a package in the catalog
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PackageInfo {
|
||||||
|
/// The FMRI of the package
|
||||||
|
pub fmri: Fmri,
|
||||||
|
|
||||||
|
/// Whether the package is obsolete
|
||||||
|
pub obsolete: bool,
|
||||||
|
|
||||||
|
/// The publisher of the package
|
||||||
|
pub publisher: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The image catalog, which merges catalogs from all publishers
|
||||||
|
pub struct ImageCatalog {
|
||||||
|
/// Path to the catalog database
|
||||||
|
db_path: PathBuf,
|
||||||
|
|
||||||
|
/// Path to the catalog directory
|
||||||
|
catalog_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCatalog {
|
||||||
|
/// Create a new image catalog
|
||||||
|
pub fn new<P: AsRef<Path>>(catalog_dir: P, db_path: P) -> Self {
|
||||||
|
ImageCatalog {
|
||||||
|
db_path: db_path.as_ref().to_path_buf(),
|
||||||
|
catalog_dir: catalog_dir.as_ref().to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the catalog database
|
||||||
|
pub fn init_db(&self) -> Result<()> {
|
||||||
|
// Create a parent directory if it doesn't exist
|
||||||
|
if let Some(parent) = self.db_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open or create the database
|
||||||
|
let db = Database::create(&self.db_path)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to create database: {}", e)))?;
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
let tx = db.begin_write()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
tx.open_table(CATALOG_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to create catalog table: {}", e)))?;
|
||||||
|
|
||||||
|
tx.open_table(OBSOLETED_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to create obsoleted table: {}", e)))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the catalog from downloaded catalogs
|
||||||
|
pub fn build_catalog(&self, publishers: &[String]) -> Result<()> {
|
||||||
|
if publishers.is_empty() {
|
||||||
|
return Err(CatalogError::NoPublishers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a writing transaction
|
||||||
|
let tx = db.begin_write()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the catalog table
|
||||||
|
let mut catalog_table = tx.open_table(CATALOG_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open catalog table: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the obsoleted table
|
||||||
|
let mut obsoleted_table = tx.open_table(OBSOLETED_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open obsoleted table: {}", e)))?;
|
||||||
|
|
||||||
|
// Process each publisher
|
||||||
|
for publisher in publishers {
|
||||||
|
let publisher_catalog_dir = self.catalog_dir.join(publisher);
|
||||||
|
|
||||||
|
// Skip if the publisher catalog directory doesn't exist
|
||||||
|
if !publisher_catalog_dir.exists() {
|
||||||
|
warn!("Publisher catalog directory not found: {}", publisher_catalog_dir.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a catalog manager for this publisher
|
||||||
|
let mut catalog_manager = CatalogManager::new(&publisher_catalog_dir, publisher)
|
||||||
|
.map_err(|e| CatalogError::Repository(crate::repository::RepositoryError::Other(format!("Failed to create catalog manager: {}", e))))?;
|
||||||
|
|
||||||
|
// Get all catalog parts
|
||||||
|
let parts = catalog_manager.attrs().parts.clone();
|
||||||
|
|
||||||
|
// Load all catalog parts
|
||||||
|
for part_name in parts.keys() {
|
||||||
|
catalog_manager.load_part(part_name)
|
||||||
|
.map_err(|e| CatalogError::Repository(crate::repository::RepositoryError::Other(format!("Failed to load catalog part: {}", e))))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each catalog part
|
||||||
|
for (part_name, _) in parts {
|
||||||
|
if let Some(part) = catalog_manager.get_part(&part_name) {
|
||||||
|
self.process_catalog_part(&mut catalog_table, &mut obsoleted_table, part, publisher)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the tables to release the borrow on tx
|
||||||
|
drop(catalog_table);
|
||||||
|
drop(obsoleted_table);
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
info!("Catalog built successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a catalog part and add its packages to the catalog
|
||||||
|
fn process_catalog_part(
|
||||||
|
&self,
|
||||||
|
catalog_table: &mut redb::Table<&str, &[u8]>,
|
||||||
|
obsoleted_table: &mut redb::Table<&str, &[u8]>,
|
||||||
|
part: &CatalogPart,
|
||||||
|
publisher: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Get packages for this publisher
|
||||||
|
if let Some(publisher_packages) = part.packages.get(publisher) {
|
||||||
|
// Process each package stem
|
||||||
|
for (stem, versions) in publisher_packages {
|
||||||
|
// Process each package version
|
||||||
|
for version_entry in versions {
|
||||||
|
// Create the FMRI
|
||||||
|
let version = if !version_entry.version.is_empty() {
|
||||||
|
match crate::fmri::Version::parse(&version_entry.version) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse version '{}': {}", version_entry.version, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let fmri = Fmri::with_publisher(publisher, stem, version);
|
||||||
|
|
||||||
|
// Create the key for the catalog table (stem@version)
|
||||||
|
let catalog_key = format!("{}@{}", stem, version_entry.version);
|
||||||
|
|
||||||
|
// Create the key for the obsoleted table (full FMRI including publisher)
|
||||||
|
let obsoleted_key = fmri.to_string();
|
||||||
|
|
||||||
|
// Check if we already have this package in the catalog
|
||||||
|
let existing_manifest = if let Ok(bytes) = catalog_table.get(catalog_key.as_str()) {
|
||||||
|
if let Some(bytes) = bytes {
|
||||||
|
Some(serde_json::from_slice::<Manifest>(bytes.value())?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create or update the manifest
|
||||||
|
let manifest = self.create_or_update_manifest(existing_manifest, version_entry, stem, publisher)?;
|
||||||
|
|
||||||
|
// Check if the package is obsolete
|
||||||
|
let is_obsolete = self.is_package_obsolete(&manifest);
|
||||||
|
|
||||||
|
// Serialize the manifest
|
||||||
|
let manifest_bytes = serde_json::to_vec(&manifest)?;
|
||||||
|
|
||||||
|
// Store the package in the appropriate table
|
||||||
|
if is_obsolete {
|
||||||
|
// Store obsolete packages in the obsoleted table with the full FMRI as key
|
||||||
|
// We don't store any meaningful values in the obsoleted table as per requirements,
|
||||||
|
// but we need to provide a valid byte slice
|
||||||
|
let empty_bytes: &[u8] = &[0u8; 0];
|
||||||
|
obsoleted_table.insert(obsoleted_key.as_str(), empty_bytes)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to insert into obsoleted table: {}", e)))?;
|
||||||
|
} else {
|
||||||
|
// Store non-obsolete packages in the catalog table with stem@version as a key
|
||||||
|
catalog_table.insert(catalog_key.as_str(), manifest_bytes.as_slice())
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to insert into catalog table: {}", e)))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create or update a manifest from a package version entry
|
||||||
|
fn create_or_update_manifest(
|
||||||
|
&self,
|
||||||
|
existing_manifest: Option<Manifest>,
|
||||||
|
version_entry: &PackageVersionEntry,
|
||||||
|
stem: &str,
|
||||||
|
publisher: &str,
|
||||||
|
) -> Result<Manifest> {
|
||||||
|
// Start with the existing manifest or create a new one
|
||||||
|
let mut manifest = existing_manifest.unwrap_or_else(Manifest::new);
|
||||||
|
|
||||||
|
// Note: We're skipping the action parsing step as the actions should already be in the manifest
|
||||||
|
// from the catalog part. The original code tried to parse actions using Action::from_str,
|
||||||
|
// but this method doesn't exist and add_action is private.
|
||||||
|
|
||||||
|
// Ensure the manifest has the correct FMRI attribute
|
||||||
|
// Create a Version object from the version string
|
||||||
|
let version = if !version_entry.version.is_empty() {
|
||||||
|
match crate::fmri::Version::parse(&version_entry.version) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(e) => {
|
||||||
|
// Map the FmriError to a CatalogError
|
||||||
|
return Err(CatalogError::Repository(
|
||||||
|
crate::repository::RepositoryError::Other(
|
||||||
|
format!("Invalid version format: {}", e)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the FMRI with publisher, stem, and version
|
||||||
|
let fmri = Fmri::with_publisher(publisher, stem, version);
|
||||||
|
self.ensure_fmri_attribute(&mut manifest, &fmri);
|
||||||
|
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the manifest has the correct FMRI attribute
|
||||||
|
fn ensure_fmri_attribute(&self, manifest: &mut Manifest, fmri: &Fmri) {
|
||||||
|
// Check if the manifest already has an FMRI attribute
|
||||||
|
let has_fmri = manifest.attributes.iter().any(|attr| attr.key == "pkg.fmri");
|
||||||
|
|
||||||
|
// If not, add it
|
||||||
|
if !has_fmri {
|
||||||
|
let mut attr = crate::actions::Attr::default();
|
||||||
|
attr.key = "pkg.fmri".to_string();
|
||||||
|
attr.values = vec![fmri.to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a package is obsolete
|
||||||
|
fn is_package_obsolete(&self, manifest: &Manifest) -> bool {
|
||||||
|
// Check for the pkg.obsolete attribute
|
||||||
|
manifest.attributes.iter().any(|attr| {
|
||||||
|
attr.key == "pkg.obsolete" && attr.values.get(0).map_or(false, |v| v == "true")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the catalog for packages matching a pattern
|
||||||
|
pub fn query_packages(&self, pattern: Option<&str>) -> Result<Vec<PackageInfo>> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a read transaction
|
||||||
|
let tx = db.begin_read()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the catalog table
|
||||||
|
let catalog_table = tx.open_table(CATALOG_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open catalog table: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the obsoleted table
|
||||||
|
let obsoleted_table = tx.open_table(OBSOLETED_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open obsoleted table: {}", e)))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Process the catalog table (non-obsolete packages)
|
||||||
|
// Iterate through all entries in the table
|
||||||
|
for entry_result in catalog_table.iter().map_err(|e| CatalogError::Database(format!("Failed to iterate catalog table: {}", e)))? {
|
||||||
|
let (key, value) = entry_result.map_err(|e| CatalogError::Database(format!("Failed to get entry from catalog table: {}", e)))?;
|
||||||
|
let key_str = key.value();
|
||||||
|
|
||||||
|
// Skip if the key doesn't match the pattern
|
||||||
|
if let Some(pattern) = pattern {
|
||||||
|
if !key_str.contains(pattern) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the key to get stem and version
|
||||||
|
let parts: Vec<&str> = key_str.split('@').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
warn!("Invalid key format: {}", key_str);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = parts[0];
|
||||||
|
let version = parts[1];
|
||||||
|
|
||||||
|
// Deserialize the manifest
|
||||||
|
let manifest: Manifest = serde_json::from_slice(value.value())?;
|
||||||
|
|
||||||
|
// Extract the publisher from the FMRI attribute
|
||||||
|
let publisher = manifest.attributes.iter()
|
||||||
|
.find(|attr| attr.key == "pkg.fmri")
|
||||||
|
.map(|attr| {
|
||||||
|
if let Some(fmri_str) = attr.values.get(0) {
|
||||||
|
// Parse the FMRI string
|
||||||
|
match Fmri::parse(fmri_str) {
|
||||||
|
Ok(fmri) => fmri.publisher.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
Err(_) => "unknown".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"unknown".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Create a Version object from the version string
|
||||||
|
let version_obj = if !version.is_empty() {
|
||||||
|
match crate::fmri::Version::parse(version) {
|
||||||
|
Ok(v) => Some(v),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the FMRI with publisher, stem, and version
|
||||||
|
let fmri = Fmri::with_publisher(&publisher, stem, version_obj);
|
||||||
|
|
||||||
|
// Add to results (non-obsolete)
|
||||||
|
results.push(PackageInfo {
|
||||||
|
fmri,
|
||||||
|
obsolete: false,
|
||||||
|
publisher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the obsoleted table (obsolete packages)
|
||||||
|
// Iterate through all entries in the table
|
||||||
|
for entry_result in obsoleted_table.iter().map_err(|e| CatalogError::Database(format!("Failed to iterate obsoleted table: {}", e)))? {
|
||||||
|
let (key, _) = entry_result.map_err(|e| CatalogError::Database(format!("Failed to get entry from obsoleted table: {}", e)))?;
|
||||||
|
let key_str = key.value();
|
||||||
|
|
||||||
|
// Skip if the key doesn't match the pattern
|
||||||
|
if let Some(pattern) = pattern {
|
||||||
|
if !key_str.contains(pattern) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the key to get the FMRI
|
||||||
|
match Fmri::parse(key_str) {
|
||||||
|
Ok(fmri) => {
|
||||||
|
// Extract the publisher
|
||||||
|
let publisher = fmri.publisher.clone().unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Add to results (obsolete)
|
||||||
|
results.push(PackageInfo {
|
||||||
|
fmri,
|
||||||
|
obsolete: true,
|
||||||
|
publisher,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse FMRI from obsoleted table key: {}: {}", key_str, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a manifest from the catalog
|
||||||
|
pub fn get_manifest(&self, fmri: &Fmri) -> Result<Option<Manifest>> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a read transaction
|
||||||
|
let tx = db.begin_read()
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the catalog table
|
||||||
|
let catalog_table = tx.open_table(CATALOG_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open catalog table: {}", e)))?;
|
||||||
|
|
||||||
|
// Open the obsoleted table
|
||||||
|
let obsoleted_table = tx.open_table(OBSOLETED_TABLE)
|
||||||
|
.map_err(|e| CatalogError::Database(format!("Failed to open obsoleted table: {}", e)))?;
|
||||||
|
|
||||||
|
// Create the key for the catalog table (stem@version)
|
||||||
|
let catalog_key = format!("{}@{}", fmri.stem(), fmri.version());
|
||||||
|
|
||||||
|
// Create the key for the obsoleted table (full FMRI including publisher)
|
||||||
|
let obsoleted_key = fmri.to_string();
|
||||||
|
|
||||||
|
// Try to get the manifest from the catalog table
|
||||||
|
if let Ok(Some(bytes)) = catalog_table.get(catalog_key.as_str()) {
|
||||||
|
return Ok(Some(serde_json::from_slice(bytes.value())?));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the package is in the obsoleted table
|
||||||
|
if let Ok(Some(_)) = obsoleted_table.get(obsoleted_key.as_str()) {
|
||||||
|
// The package is obsolete, but we don't store the manifest in the obsoleted table
|
||||||
|
// We could return a minimal manifest with just the FMRI and obsolete flag
|
||||||
|
let mut manifest = Manifest::new();
|
||||||
|
|
||||||
|
// Add the FMRI attribute
|
||||||
|
let mut attr = crate::actions::Attr::default();
|
||||||
|
attr.key = "pkg.fmri".to_string();
|
||||||
|
attr.values = vec![fmri.to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
// Add the obsolete attribute
|
||||||
|
let mut attr = crate::actions::Attr::default();
|
||||||
|
attr.key = "pkg.obsolete".to_string();
|
||||||
|
attr.values = vec!["true".to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
return Ok(Some(manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest not found
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
291
libips/src/image/installed.rs
Normal file
291
libips/src/image/installed.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
use crate::actions::Manifest;
|
||||||
|
use crate::fmri::Fmri;
|
||||||
|
use miette::Diagnostic;
|
||||||
|
use redb::{Database, ReadableTable, TableDefinition};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tracing::{info};
|
||||||
|
|
||||||
|
/// Table definition for the installed packages database
|
||||||
|
/// Key: full FMRI including publisher (pkg://publisher/stem@version)
|
||||||
|
/// Value: serialized Manifest
|
||||||
|
pub const INSTALLED_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("installed");
|
||||||
|
|
||||||
|
/// Errors that can occur when working with the installed packages database
|
||||||
|
#[derive(Error, Debug, Diagnostic)]
|
||||||
|
pub enum InstalledError {
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
#[diagnostic(code(ips::installed_error::io))]
|
||||||
|
IO(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
#[diagnostic(code(ips::installed_error::json))]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
#[diagnostic(code(ips::installed_error::database))]
|
||||||
|
Database(String),
|
||||||
|
|
||||||
|
#[error("FMRI error: {0}")]
|
||||||
|
#[diagnostic(code(ips::installed_error::fmri))]
|
||||||
|
Fmri(#[from] crate::fmri::FmriError),
|
||||||
|
|
||||||
|
#[error("Package not found: {0}")]
|
||||||
|
#[diagnostic(code(ips::installed_error::package_not_found))]
|
||||||
|
PackageNotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type for installed packages operations
|
||||||
|
pub type Result<T> = std::result::Result<T, InstalledError>;
|
||||||
|
|
||||||
|
/// Information about an installed package
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct InstalledPackageInfo {
|
||||||
|
/// The FMRI of the package
|
||||||
|
pub fmri: Fmri,
|
||||||
|
|
||||||
|
/// The publisher of the package
|
||||||
|
pub publisher: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The installed packages database
|
||||||
|
pub struct InstalledPackages {
|
||||||
|
/// Path to the installed packages database
|
||||||
|
db_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstalledPackages {
|
||||||
|
// Note on borrowing and redb:
|
||||||
|
// When using redb, there's a potential borrowing issue when working with transactions and tables.
|
||||||
|
// The issue occurs because:
|
||||||
|
// 1. Tables borrow from the transaction they were opened from
|
||||||
|
// 2. When committing a transaction with tx.commit(), the transaction is moved
|
||||||
|
// 3. If a table is still borrowing from the transaction when commit() is called, Rust's borrow checker will prevent the move
|
||||||
|
//
|
||||||
|
// To fix this issue, we use block scopes {} around table operations to ensure that the table
|
||||||
|
// objects are dropped (and their borrows released) before committing the transaction.
|
||||||
|
// This pattern is used in all methods that commit transactions after table operations.
|
||||||
|
|
||||||
|
/// Create a new installed packages database
|
||||||
|
pub fn new<P: AsRef<Path>>(db_path: P) -> Self {
|
||||||
|
InstalledPackages {
|
||||||
|
db_path: db_path.as_ref().to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the installed packages database
|
||||||
|
pub fn init_db(&self) -> Result<()> {
|
||||||
|
// Create a parent directory if it doesn't exist
|
||||||
|
if let Some(parent) = self.db_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open or create the database
|
||||||
|
let db = Database::create(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to create database: {}", e)))?;
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
let tx = db.begin_write()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to create installed table: {}", e)))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a package to the installed packages database
|
||||||
|
pub fn add_package(&self, fmri: &Fmri, manifest: &Manifest) -> Result<()> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a writing transaction
|
||||||
|
let tx = db.begin_write()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Create the key (full FMRI including publisher)
|
||||||
|
let key = fmri.to_string();
|
||||||
|
|
||||||
|
// Serialize the manifest
|
||||||
|
let manifest_bytes = serde_json::to_vec(manifest)?;
|
||||||
|
|
||||||
|
// Use a block scope to ensure the table is dropped before committing the transaction
|
||||||
|
{
|
||||||
|
// Open the installed table
|
||||||
|
let mut installed_table = tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// Insert the package into the installed table
|
||||||
|
installed_table.insert(key.as_str(), manifest_bytes.as_slice())
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to insert into installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// The table is dropped at the end of this block, releasing its borrow of tx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
info!("Added package to installed database: {}", key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a package from the installed packages database
|
||||||
|
pub fn remove_package(&self, fmri: &Fmri) -> Result<()> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a writing transaction
|
||||||
|
let tx = db.begin_write()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Create the key (full FMRI including publisher)
|
||||||
|
let key = fmri.to_string();
|
||||||
|
|
||||||
|
// Use a block scope to ensure the table is dropped before committing the transaction
|
||||||
|
{
|
||||||
|
// Open the installed table
|
||||||
|
let mut installed_table = tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// Check if the package exists
|
||||||
|
if let Ok(None) = installed_table.get(key.as_str()) {
|
||||||
|
return Err(InstalledError::PackageNotFound(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the package from the installed table
|
||||||
|
installed_table.remove(key.as_str())
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to remove from installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// The table is dropped at the end of this block, releasing its borrow of tx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to commit transaction: {}", e)))?;
|
||||||
|
|
||||||
|
info!("Removed package from installed database: {}", key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the installed packages database for packages matching a pattern
|
||||||
|
pub fn query_packages(&self, pattern: Option<&str>) -> Result<Vec<InstalledPackageInfo>> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a read transaction
|
||||||
|
let tx = db.begin_read()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Use a block scope to ensure the table is dropped when no longer needed
|
||||||
|
let results = {
|
||||||
|
// Open the installed table
|
||||||
|
let installed_table = tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open installed table: {}", e)))?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Process the installed table
|
||||||
|
// Iterate through all entries in the table
|
||||||
|
for entry_result in installed_table.iter().map_err(|e| InstalledError::Database(format!("Failed to iterate installed table: {}", e)))? {
|
||||||
|
let (key, _) = entry_result.map_err(|e| InstalledError::Database(format!("Failed to get entry from installed table: {}", e)))?;
|
||||||
|
let key_str = key.value();
|
||||||
|
|
||||||
|
// Skip if the key doesn't match the pattern
|
||||||
|
if let Some(pattern) = pattern {
|
||||||
|
if !key_str.contains(pattern) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the key to get the FMRI
|
||||||
|
let fmri = Fmri::from_str(key_str)?;
|
||||||
|
|
||||||
|
// Get the publisher (handling the Option<String>)
|
||||||
|
let publisher = fmri.publisher.clone().unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Add to results
|
||||||
|
results.push(InstalledPackageInfo {
|
||||||
|
fmri,
|
||||||
|
publisher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
// The table is dropped at the end of this block
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a manifest from the installed packages database
|
||||||
|
pub fn get_manifest(&self, fmri: &Fmri) -> Result<Option<Manifest>> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a read transaction
|
||||||
|
let tx = db.begin_read()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Create the key (full FMRI including publisher)
|
||||||
|
let key = fmri.to_string();
|
||||||
|
|
||||||
|
// Use a block scope to ensure the table is dropped when no longer needed
|
||||||
|
let manifest_option = {
|
||||||
|
// Open the installed table
|
||||||
|
let installed_table = tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// Try to get the manifest from the installed table
|
||||||
|
if let Ok(Some(bytes)) = installed_table.get(key.as_str()) {
|
||||||
|
Some(serde_json::from_slice(bytes.value())?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// The table is dropped at the end of this block
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(manifest_option)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a package is installed
|
||||||
|
pub fn is_installed(&self, fmri: &Fmri) -> Result<bool> {
|
||||||
|
// Open the database
|
||||||
|
let db = Database::open(&self.db_path)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open database: {}", e)))?;
|
||||||
|
|
||||||
|
// Begin a read transaction
|
||||||
|
let tx = db.begin_read()
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to begin transaction: {}", e)))?;
|
||||||
|
|
||||||
|
// Create the key (full FMRI including publisher)
|
||||||
|
let key = fmri.to_string();
|
||||||
|
|
||||||
|
// Use a block scope to ensure the table is dropped when no longer needed
|
||||||
|
let is_installed = {
|
||||||
|
// Open the installed table
|
||||||
|
let installed_table = tx.open_table(INSTALLED_TABLE)
|
||||||
|
.map_err(|e| InstalledError::Database(format!("Failed to open installed table: {}", e)))?;
|
||||||
|
|
||||||
|
// Check if the package exists
|
||||||
|
if let Ok(Some(_)) = installed_table.get(key.as_str()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
// The table is dropped at the end of this block
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(is_installed)
|
||||||
|
}
|
||||||
|
}
|
||||||
121
libips/src/image/installed_tests.rs
Normal file
121
libips/src/image/installed_tests.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
use super::*;
|
||||||
|
use crate::actions::{Attr, Manifest};
|
||||||
|
use crate::fmri::Fmri;
|
||||||
|
use redb::ReadableTable;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_installed_packages() {
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let image_path = temp_dir.path().join("image");
|
||||||
|
|
||||||
|
// Create the image
|
||||||
|
let image = Image::create_image(&image_path).unwrap();
|
||||||
|
|
||||||
|
// Verify that the installed packages database was initialized
|
||||||
|
assert!(image.installed_db_path().exists());
|
||||||
|
|
||||||
|
// Create a test manifest
|
||||||
|
let mut manifest = Manifest::new();
|
||||||
|
|
||||||
|
// Add some attributes to the manifest
|
||||||
|
let mut attr = Attr::default();
|
||||||
|
attr.key = "pkg.fmri".to_string();
|
||||||
|
attr.values = vec!["pkg://test/example/package@1.0".to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
let mut attr = Attr::default();
|
||||||
|
attr.key = "pkg.summary".to_string();
|
||||||
|
attr.values = vec!["Example package".to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
let mut attr = Attr::default();
|
||||||
|
attr.key = "pkg.description".to_string();
|
||||||
|
attr.values = vec!["An example package for testing".to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
// Create an FMRI for the package
|
||||||
|
let fmri = Fmri::from_str("pkg://test/example/package@1.0").unwrap();
|
||||||
|
|
||||||
|
// Install the package
|
||||||
|
image.install_package(&fmri, &manifest).unwrap();
|
||||||
|
|
||||||
|
// Verify that the package is installed
|
||||||
|
assert!(image.is_package_installed(&fmri).unwrap());
|
||||||
|
|
||||||
|
// Query the installed packages
|
||||||
|
let packages = image.query_installed_packages(None).unwrap();
|
||||||
|
|
||||||
|
// Verify that the package is in the results
|
||||||
|
assert_eq!(packages.len(), 1);
|
||||||
|
assert_eq!(packages[0].fmri.to_string(), "pkg://test/example/package@1.0");
|
||||||
|
assert_eq!(packages[0].publisher, "test");
|
||||||
|
|
||||||
|
// Get the manifest from the installed packages database
|
||||||
|
let installed_manifest = image.get_manifest_from_installed(&fmri).unwrap().unwrap();
|
||||||
|
|
||||||
|
// Verify that the manifest is correct
|
||||||
|
assert_eq!(installed_manifest.attributes.len(), 3);
|
||||||
|
|
||||||
|
// Uninstall the package
|
||||||
|
image.uninstall_package(&fmri).unwrap();
|
||||||
|
|
||||||
|
// Verify that the package is no longer installed
|
||||||
|
assert!(!image.is_package_installed(&fmri).unwrap());
|
||||||
|
|
||||||
|
// Query the installed packages again
|
||||||
|
let packages = image.query_installed_packages(None).unwrap();
|
||||||
|
|
||||||
|
// Verify that there are no packages
|
||||||
|
assert_eq!(packages.len(), 0);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
temp_dir.close().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_installed_packages_key_format() {
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let db_path = temp_dir.path().join("installed.redb");
|
||||||
|
|
||||||
|
// Create the installed packages database
|
||||||
|
let installed = InstalledPackages::new(&db_path);
|
||||||
|
installed.init_db().unwrap();
|
||||||
|
|
||||||
|
// Create a test manifest
|
||||||
|
let mut manifest = Manifest::new();
|
||||||
|
|
||||||
|
// Add some attributes to the manifest
|
||||||
|
let mut attr = Attr::default();
|
||||||
|
attr.key = "pkg.fmri".to_string();
|
||||||
|
attr.values = vec!["pkg://test/example/package@1.0".to_string()];
|
||||||
|
manifest.attributes.push(attr);
|
||||||
|
|
||||||
|
// Create an FMRI for the package
|
||||||
|
let fmri = Fmri::from_str("pkg://test/example/package@1.0").unwrap();
|
||||||
|
|
||||||
|
// Add the package to the database
|
||||||
|
installed.add_package(&fmri, &manifest).unwrap();
|
||||||
|
|
||||||
|
// Open the database directly to check the key format
|
||||||
|
let db = Database::open(&db_path).unwrap();
|
||||||
|
let tx = db.begin_read().unwrap();
|
||||||
|
let table = tx.open_table(installed::INSTALLED_TABLE).unwrap();
|
||||||
|
|
||||||
|
// Iterate through the keys
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for entry in table.iter().unwrap() {
|
||||||
|
let (key, _) = entry.unwrap();
|
||||||
|
keys.push(key.value().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that there is one key and it has the correct format
|
||||||
|
assert_eq!(keys.len(), 1);
|
||||||
|
assert_eq!(keys[0], "pkg://test/example/package@1.0");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
temp_dir.close().unwrap();
|
||||||
|
}
|
||||||
|
|
@ -4,14 +4,26 @@ mod tests;
|
||||||
|
|
||||||
use miette::Diagnostic;
|
use miette::Diagnostic;
|
||||||
use properties::*;
|
use properties::*;
|
||||||
|
use redb::Database;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::{self, File};
|
use std::fs::{self, File};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use redb::{Database, TableDefinition};
|
|
||||||
|
|
||||||
use crate::repository::{RestBackend, ReadableRepository, RepositoryError};
|
use crate::repository::{ReadableRepository, RepositoryError, RestBackend};
|
||||||
|
|
||||||
|
// Export the catalog module
|
||||||
|
pub mod catalog;
|
||||||
|
use catalog::{ImageCatalog, PackageInfo};
|
||||||
|
|
||||||
|
// Export the installed packages module
|
||||||
|
pub mod installed;
|
||||||
|
use installed::{InstalledPackageInfo, InstalledPackages};
|
||||||
|
|
||||||
|
// Include tests
|
||||||
|
#[cfg(test)]
|
||||||
|
mod installed_tests;
|
||||||
|
|
||||||
#[derive(Debug, Error, Diagnostic)]
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
pub enum ImageError {
|
pub enum ImageError {
|
||||||
|
|
@ -273,6 +285,11 @@ impl Image {
|
||||||
pub fn catalog_dir(&self) -> PathBuf {
|
pub fn catalog_dir(&self) -> PathBuf {
|
||||||
self.metadata_dir().join("catalog")
|
self.metadata_dir().join("catalog")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the catalog database
|
||||||
|
pub fn catalog_db_path(&self) -> PathBuf {
|
||||||
|
self.metadata_dir().join("catalog.redb")
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates the metadata directory if it doesn't exist
|
/// Creates the metadata directory if it doesn't exist
|
||||||
pub fn create_metadata_dir(&self) -> Result<()> {
|
pub fn create_metadata_dir(&self) -> Result<()> {
|
||||||
|
|
@ -311,31 +328,62 @@ impl Image {
|
||||||
pub fn init_installed_db(&self) -> Result<()> {
|
pub fn init_installed_db(&self) -> Result<()> {
|
||||||
let db_path = self.installed_db_path();
|
let db_path = self.installed_db_path();
|
||||||
|
|
||||||
// Create the database if it doesn't exist
|
// Create the installed packages database
|
||||||
let db = Database::create(&db_path).map_err(|e| {
|
let installed = InstalledPackages::new(&db_path);
|
||||||
ImageError::Database(format!("Failed to create installed packages database: {}", e))
|
installed.init_db().map_err(|e| {
|
||||||
})?;
|
ImageError::Database(format!("Failed to initialize installed packages database: {}", e))
|
||||||
|
})
|
||||||
// Define tables
|
|
||||||
let packages_table = TableDefinition::<&str, &[u8]>::new("packages");
|
|
||||||
|
|
||||||
// Create tables
|
|
||||||
let tx = db.begin_write().map_err(|e| {
|
|
||||||
ImageError::Database(format!("Failed to begin transaction: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tx.open_table(packages_table).map_err(|e| {
|
|
||||||
ImageError::Database(format!("Failed to create packages table: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
tx.commit().map_err(|e| {
|
|
||||||
ImageError::Database(format!("Failed to commit transaction: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download catalogs from all configured publishers
|
/// Add a package to the installed packages database
|
||||||
|
pub fn install_package(&self, fmri: &crate::fmri::Fmri, manifest: &crate::actions::Manifest) -> Result<()> {
|
||||||
|
let installed = InstalledPackages::new(self.installed_db_path());
|
||||||
|
installed.add_package(fmri, manifest).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to add package to installed database: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a package from the installed packages database
|
||||||
|
pub fn uninstall_package(&self, fmri: &crate::fmri::Fmri) -> Result<()> {
|
||||||
|
let installed = InstalledPackages::new(self.installed_db_path());
|
||||||
|
installed.remove_package(fmri).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to remove package from installed database: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the installed packages database for packages matching a pattern
|
||||||
|
pub fn query_installed_packages(&self, pattern: Option<&str>) -> Result<Vec<InstalledPackageInfo>> {
|
||||||
|
let installed = InstalledPackages::new(self.installed_db_path());
|
||||||
|
installed.query_packages(pattern).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to query installed packages: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a manifest from the installed packages database
|
||||||
|
pub fn get_manifest_from_installed(&self, fmri: &crate::fmri::Fmri) -> Result<Option<crate::actions::Manifest>> {
|
||||||
|
let installed = InstalledPackages::new(self.installed_db_path());
|
||||||
|
installed.get_manifest(fmri).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to get manifest from installed database: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a package is installed
|
||||||
|
pub fn is_package_installed(&self, fmri: &crate::fmri::Fmri) -> Result<bool> {
|
||||||
|
let installed = InstalledPackages::new(self.installed_db_path());
|
||||||
|
installed.is_installed(fmri).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to check if package is installed: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the catalog database
|
||||||
|
pub fn init_catalog_db(&self) -> Result<()> {
|
||||||
|
let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path());
|
||||||
|
catalog.init_db().map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to initialize catalog database: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download catalogs from all configured publishers and build the merged catalog
|
||||||
pub fn download_catalogs(&self) -> Result<()> {
|
pub fn download_catalogs(&self) -> Result<()> {
|
||||||
// Create catalog directory if it doesn't exist
|
// Create catalog directory if it doesn't exist
|
||||||
self.create_catalog_dir()?;
|
self.create_catalog_dir()?;
|
||||||
|
|
@ -345,9 +393,45 @@ impl Image {
|
||||||
self.download_publisher_catalog(&publisher.name)?;
|
self.download_publisher_catalog(&publisher.name)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build the merged catalog
|
||||||
|
self.build_catalog()?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the merged catalog from downloaded catalogs
|
||||||
|
pub fn build_catalog(&self) -> Result<()> {
|
||||||
|
// Initialize the catalog database if it doesn't exist
|
||||||
|
self.init_catalog_db()?;
|
||||||
|
|
||||||
|
// Get publisher names
|
||||||
|
let publisher_names: Vec<String> = self.publishers.iter()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Create the catalog and build it
|
||||||
|
let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path());
|
||||||
|
catalog.build_catalog(&publisher_names).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to build catalog: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the catalog for packages matching a pattern
|
||||||
|
pub fn query_catalog(&self, pattern: Option<&str>) -> Result<Vec<PackageInfo>> {
|
||||||
|
let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path());
|
||||||
|
catalog.query_packages(pattern).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to query catalog: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a manifest from the catalog
|
||||||
|
pub fn get_manifest_from_catalog(&self, fmri: &crate::fmri::Fmri) -> Result<Option<crate::actions::Manifest>> {
|
||||||
|
let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path());
|
||||||
|
catalog.get_manifest(fmri).map_err(|e| {
|
||||||
|
ImageError::Database(format!("Failed to get manifest from catalog: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Download catalog for a specific publisher
|
/// Download catalog for a specific publisher
|
||||||
pub fn download_publisher_catalog(&self, publisher_name: &str) -> Result<()> {
|
pub fn download_publisher_catalog(&self, publisher_name: &str) -> Result<()> {
|
||||||
// Get the publisher
|
// Get the publisher
|
||||||
|
|
@ -367,10 +451,13 @@ impl Image {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new image with the specified publisher
|
/// Create a new image with the basic directory structure
|
||||||
pub fn create_image<P: AsRef<Path>>(path: P, publisher_name: &str, origin: &str) -> Result<Self> {
|
///
|
||||||
|
/// This method only creates the image structure without adding publishers or downloading catalogs.
|
||||||
|
/// Publisher addition and catalog downloading should be handled separately.
|
||||||
|
pub fn create_image<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||||
// Create a new image
|
// Create a new image
|
||||||
let mut image = Image::new_full(path.as_ref().to_path_buf());
|
let image = Image::new_full(path.as_ref().to_path_buf());
|
||||||
|
|
||||||
// Create the directory structure
|
// Create the directory structure
|
||||||
image.create_metadata_dir()?;
|
image.create_metadata_dir()?;
|
||||||
|
|
@ -380,11 +467,11 @@ impl Image {
|
||||||
// Initialize the installed packages database
|
// Initialize the installed packages database
|
||||||
image.init_installed_db()?;
|
image.init_installed_db()?;
|
||||||
|
|
||||||
// Add the publisher
|
// Initialize the catalog database
|
||||||
image.add_publisher(publisher_name, origin, vec![], true)?;
|
image.init_catalog_db()?;
|
||||||
|
|
||||||
// Download the catalog
|
// Save the image
|
||||||
image.download_publisher_catalog(publisher_name)?;
|
image.save()?;
|
||||||
|
|
||||||
Ok(image)
|
Ok(image)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,118 @@
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::path::Path;
|
use std::fs;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_image_types() {
|
fn test_image_catalog() {
|
||||||
let full_image = Image::new_full("/");
|
// Create a temporary directory for the test
|
||||||
let partial_image = Image::new_partial("/tmp/partial");
|
let temp_dir = tempdir().unwrap();
|
||||||
|
let image_path = temp_dir.path().join("image");
|
||||||
assert_eq!(*full_image.image_type(), ImageType::Full);
|
|
||||||
assert_eq!(*partial_image.image_type(), ImageType::Partial);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_metadata_paths() {
|
|
||||||
let full_image = Image::new_full("/");
|
|
||||||
let partial_image = Image::new_partial("/tmp/partial");
|
|
||||||
|
|
||||||
assert_eq!(full_image.metadata_dir(), Path::new("/var/pkg"));
|
|
||||||
assert_eq!(partial_image.metadata_dir(), Path::new("/tmp/partial/.pkg"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
full_image.image_json_path(),
|
|
||||||
Path::new("/var/pkg/pkg6.image.json")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
partial_image.image_json_path(),
|
|
||||||
Path::new("/tmp/partial/.pkg/pkg6.image.json")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_save_and_load() -> Result<()> {
|
|
||||||
// Create a temporary directory for testing
|
|
||||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
|
||||||
let temp_path = temp_dir.path();
|
|
||||||
|
|
||||||
// Create a full image
|
|
||||||
let mut full_image = Image::new_full(temp_path);
|
|
||||||
|
|
||||||
// Add some test data
|
// Create the image
|
||||||
full_image.props.push(ImageProperty::String("test_prop".to_string()));
|
let image = Image::create_image(&image_path).unwrap();
|
||||||
|
|
||||||
// Save the image
|
// Verify that the catalog database was initialized
|
||||||
full_image.save()?;
|
assert!(image.catalog_db_path().exists());
|
||||||
|
|
||||||
// Check that the metadata directory and JSON file were created
|
|
||||||
let metadata_dir = temp_path.join("var/pkg");
|
|
||||||
let json_path = metadata_dir.join("pkg6.image.json");
|
|
||||||
|
|
||||||
assert!(metadata_dir.exists());
|
|
||||||
assert!(json_path.exists());
|
|
||||||
|
|
||||||
// Load the image
|
|
||||||
let loaded_image = Image::load(temp_path)?;
|
|
||||||
|
|
||||||
// Check that the loaded image matches the original
|
|
||||||
assert_eq!(*loaded_image.image_type(), ImageType::Full);
|
|
||||||
assert_eq!(loaded_image.path, full_image.path);
|
|
||||||
assert_eq!(loaded_image.props.len(), 1);
|
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
temp_dir.close().expect("Failed to clean up temp directory");
|
temp_dir.close().unwrap();
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_partial_image() -> Result<()> {
|
fn test_catalog_methods() {
|
||||||
// Create a temporary directory for testing
|
// Create a temporary directory for the test
|
||||||
let temp_dir = tempdir().expect("Failed to create temp directory");
|
let temp_dir = tempdir().unwrap();
|
||||||
let temp_path = temp_dir.path();
|
let image_path = temp_dir.path().join("image");
|
||||||
|
|
||||||
// Create a partial image
|
// Create the image
|
||||||
let mut partial_image = Image::new_partial(temp_path);
|
let mut image = Image::create_image(&image_path).unwrap();
|
||||||
|
|
||||||
// Add some test data
|
// Add a publisher
|
||||||
partial_image.props.push(ImageProperty::Boolean(true));
|
image.add_publisher("test", "http://example.com/repo", vec![], true).unwrap();
|
||||||
|
|
||||||
// Save the image
|
// Create the catalog directory structure
|
||||||
partial_image.save()?;
|
let catalog_dir = image.catalog_dir();
|
||||||
|
let publisher_dir = catalog_dir.join("test");
|
||||||
|
fs::create_dir_all(&publisher_dir).unwrap();
|
||||||
|
|
||||||
// Check that the metadata directory and JSON file were created
|
// Create a simple catalog.attrs file
|
||||||
let metadata_dir = temp_path.join(".pkg");
|
let attrs_content = r#"{
|
||||||
let json_path = metadata_dir.join("pkg6.image.json");
|
"parts": {
|
||||||
|
"base": {}
|
||||||
|
},
|
||||||
|
"version": 1
|
||||||
|
}"#;
|
||||||
|
fs::write(publisher_dir.join("catalog.attrs"), attrs_content).unwrap();
|
||||||
|
|
||||||
assert!(metadata_dir.exists());
|
// Create a simple base catalog part
|
||||||
assert!(json_path.exists());
|
let base_content = r#"{
|
||||||
|
"packages": {
|
||||||
|
"test": {
|
||||||
|
"example/package": [
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"actions": [
|
||||||
|
"set name=pkg.fmri value=pkg://test/example/package@1.0",
|
||||||
|
"set name=pkg.summary value=\"Example package\"",
|
||||||
|
"set name=pkg.description value=\"An example package for testing\""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"example/obsolete": [
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"actions": [
|
||||||
|
"set name=pkg.fmri value=pkg://test/example/obsolete@1.0",
|
||||||
|
"set name=pkg.summary value=\"Obsolete package\"",
|
||||||
|
"set name=pkg.obsolete value=true"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
fs::write(publisher_dir.join("base"), base_content).unwrap();
|
||||||
|
|
||||||
// Load the image
|
// Build the catalog
|
||||||
let loaded_image = Image::load(temp_path)?;
|
image.build_catalog().unwrap();
|
||||||
|
|
||||||
// Check that the loaded image matches the original
|
// Query the catalog
|
||||||
assert_eq!(*loaded_image.image_type(), ImageType::Partial);
|
let packages = image.query_catalog(None).unwrap();
|
||||||
assert_eq!(loaded_image.path, partial_image.path);
|
|
||||||
assert_eq!(loaded_image.props.len(), 1);
|
// Verify that both non-obsolete and obsolete packages are in the results
|
||||||
|
assert_eq!(packages.len(), 2);
|
||||||
|
|
||||||
|
// Verify that one package is marked as obsolete
|
||||||
|
let obsolete_packages: Vec<_> = packages.iter().filter(|p| p.obsolete).collect();
|
||||||
|
assert_eq!(obsolete_packages.len(), 1);
|
||||||
|
assert_eq!(obsolete_packages[0].fmri.stem(), "example/obsolete");
|
||||||
|
|
||||||
|
// Verify that the obsolete package has the full FMRI as key
|
||||||
|
// This is indirectly verified by checking that the publisher is included in the FMRI
|
||||||
|
assert_eq!(obsolete_packages[0].fmri.publisher, Some("test".to_string()));
|
||||||
|
|
||||||
|
// Verify that one package is not marked as obsolete
|
||||||
|
let non_obsolete_packages: Vec<_> = packages.iter().filter(|p| !p.obsolete).collect();
|
||||||
|
assert_eq!(non_obsolete_packages.len(), 1);
|
||||||
|
assert_eq!(non_obsolete_packages[0].fmri.stem(), "example/package");
|
||||||
|
|
||||||
|
// Get the manifest for the non-obsolete package
|
||||||
|
let fmri = &non_obsolete_packages[0].fmri;
|
||||||
|
let manifest = image.get_manifest_from_catalog(fmri).unwrap();
|
||||||
|
assert!(manifest.is_some());
|
||||||
|
|
||||||
|
// Get the manifest for the obsolete package
|
||||||
|
let fmri = &obsolete_packages[0].fmri;
|
||||||
|
let manifest = image.get_manifest_from_catalog(fmri).unwrap();
|
||||||
|
assert!(manifest.is_some());
|
||||||
|
|
||||||
|
// Verify that the obsolete package's manifest has the obsolete attribute
|
||||||
|
let manifest = manifest.unwrap();
|
||||||
|
let is_obsolete = manifest.attributes.iter().any(|attr| {
|
||||||
|
attr.key == "pkg.obsolete" && attr.values.get(0).map_or(false, |v| v == "true")
|
||||||
|
});
|
||||||
|
assert!(is_obsolete);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
temp_dir.close().expect("Failed to clean up temp directory");
|
temp_dir.close().unwrap();
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_path() {
|
|
||||||
let result = Image::load("/nonexistent/path");
|
|
||||||
assert!(result.is_err());
|
|
||||||
|
|
||||||
if let Err(ImageError::InvalidPath(_)) = result {
|
|
||||||
// Expected error
|
|
||||||
} else {
|
|
||||||
panic!("Expected InvalidPath error, got {:?}", result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -198,7 +198,7 @@ impl From<bincode::Error> for RepositoryError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod catalog;
|
pub mod catalog;
|
||||||
mod file_backend;
|
mod file_backend;
|
||||||
mod obsoleted;
|
mod obsoleted;
|
||||||
pub mod progress;
|
pub mod progress;
|
||||||
|
|
|
||||||
|
|
@ -398,18 +398,19 @@ enum Commands {
|
||||||
/// Create an image
|
/// Create an image
|
||||||
///
|
///
|
||||||
/// The image-create command creates a new image.
|
/// The image-create command creates a new image.
|
||||||
|
/// If publisher and origin are provided, the publisher will be added to the image.
|
||||||
ImageCreate {
|
ImageCreate {
|
||||||
/// Full path to the image to create
|
/// Full path to the image to create
|
||||||
#[clap(short = 'F')]
|
#[clap(short = 'F')]
|
||||||
full_path: PathBuf,
|
full_path: PathBuf,
|
||||||
|
|
||||||
/// Publisher to use
|
/// Publisher to use (optional)
|
||||||
#[clap(short = 'p')]
|
#[clap(short = 'p')]
|
||||||
publisher: String,
|
publisher: Option<String>,
|
||||||
|
|
||||||
/// Publisher origin URL
|
/// Publisher origin URL (required if publisher is specified)
|
||||||
#[clap(short = 'g')]
|
#[clap(short = 'g', requires = "publisher")]
|
||||||
origin: String,
|
origin: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -654,6 +655,14 @@ fn main() -> Result<()> {
|
||||||
// Remove the publisher
|
// Remove the publisher
|
||||||
image.remove_publisher(&publisher)?;
|
image.remove_publisher(&publisher)?;
|
||||||
|
|
||||||
|
// Refresh the catalog to reflect the current state of all available packages
|
||||||
|
if let Err(e) = image.download_catalogs() {
|
||||||
|
error!("Failed to refresh catalog after removing publisher: {}", e);
|
||||||
|
// Continue execution even if catalog refresh fails
|
||||||
|
} else {
|
||||||
|
info!("Catalog refreshed successfully");
|
||||||
|
}
|
||||||
|
|
||||||
info!("Publisher {} removed successfully", publisher);
|
info!("Publisher {} removed successfully", publisher);
|
||||||
info!("Unset-publisher completed successfully");
|
info!("Unset-publisher completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -801,15 +810,29 @@ fn main() -> Result<()> {
|
||||||
},
|
},
|
||||||
Commands::ImageCreate { full_path, publisher, origin } => {
|
Commands::ImageCreate { full_path, publisher, origin } => {
|
||||||
info!("Creating image at: {}", full_path.display());
|
info!("Creating image at: {}", full_path.display());
|
||||||
debug!("Publisher: {}", publisher);
|
debug!("Publisher: {:?}", publisher);
|
||||||
debug!("Origin: {}", origin);
|
debug!("Origin: {:?}", origin);
|
||||||
|
|
||||||
// Create the image
|
|
||||||
let image = libips::image::Image::create_image(&full_path, &publisher, &origin)?;
|
|
||||||
|
|
||||||
|
// Create the image (only creates the basic structure)
|
||||||
|
let mut image = libips::image::Image::create_image(&full_path)?;
|
||||||
info!("Image created successfully at: {}", full_path.display());
|
info!("Image created successfully at: {}", full_path.display());
|
||||||
info!("Publisher {} configured with origin: {}", publisher, origin);
|
|
||||||
info!("Catalog downloaded from publisher: {}", publisher);
|
// If publisher and origin are provided, add the publisher and download the catalog
|
||||||
|
if let (Some(publisher_name), Some(origin_url)) = (publisher.as_ref(), origin.as_ref()) {
|
||||||
|
info!("Adding publisher {} with origin {}", publisher_name, origin_url);
|
||||||
|
|
||||||
|
// Add the publisher
|
||||||
|
image.add_publisher(publisher_name, origin_url, vec![], true)?;
|
||||||
|
|
||||||
|
// Download the catalog
|
||||||
|
image.download_publisher_catalog(publisher_name)?;
|
||||||
|
|
||||||
|
info!("Publisher {} configured with origin: {}", publisher_name, origin_url);
|
||||||
|
info!("Catalog downloaded from publisher: {}", publisher_name);
|
||||||
|
} else {
|
||||||
|
info!("No publisher configured. Use 'pkg6 set-publisher' to add a publisher.");
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue