Add action executors, action plans, and sample install script

- Implemented `executors` module for applying manifests with support for directories, files, and links.
- Added `action_plan` module to merge and execute install plans from manifests.
- Introduced `run_sample_install.sh` script for testing installations, including dry-run and real execution.
- Enhanced `pkg6` install logic to resolve and apply action plans.
- Improved manifest management with `save_manifest` and repository-based fetching.
This commit is contained in:
Till Wegmueller 2025-08-19 11:06:48 +02:00
parent d483e2a995
commit a2645749b1
No known key found for this signature in database
11 changed files with 1072 additions and 45 deletions

View file

@ -0,0 +1,283 @@
use std::fs::{self, File as FsFile};
use std::io::{self, Write};
use std::os::unix::fs as unix_fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Component, Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
use tracing::info;
use crate::actions::{Link as LinkAction, Manifest};
use crate::actions::{Dir as DirAction, File as FileAction};
#[derive(Error, Debug, Diagnostic)]
pub enum InstallerError {
#[error("I/O error while operating on {path}")]
#[diagnostic(code(ips::installer_error::io))]
Io {
#[source]
source: io::Error,
path: PathBuf,
},
#[error("Absolute paths are forbidden in actions: {path}")]
#[diagnostic(code(ips::installer_error::absolute_path_forbidden), help("Provide paths relative to the image root"))]
AbsolutePathForbidden { path: String },
#[error("Path escapes image root via traversal: {rel}")]
#[diagnostic(code(ips::installer_error::path_outside_image), help("Remove '..' components that escape the image root"))]
PathTraversalOutsideImage { rel: String },
#[error("Unsupported or not yet implemented action: {action} ({reason})")]
#[diagnostic(code(ips::installer_error::unsupported_action))]
UnsupportedAction { action: &'static str, reason: String },
}
fn parse_mode(mode: &str, default: u32) -> u32 {
if mode.is_empty() || mode.eq("0") {
return default;
}
// Accept strings like "0755" or "755"
let trimmed = mode.trim_start_matches('0');
u32::from_str_radix(if trimmed.is_empty() { "0" } else { trimmed }, 8).unwrap_or(default)
}
/// Join a manifest-provided path (must be relative) under image_root.
/// - Rejects absolute paths
/// - Rejects traversal that would escape the image root
pub fn safe_join(image_root: &Path, rel: &str) -> Result<PathBuf, InstallerError> {
if rel.is_empty() {
return Ok(image_root.to_path_buf());
}
let rel_path = Path::new(rel);
if rel_path.is_absolute() {
return Err(InstallerError::AbsolutePathForbidden {
path: rel.to_string(),
});
}
let mut stack: Vec<PathBuf> = Vec::new();
for c in rel_path.components() {
match c {
Component::CurDir => {}
Component::Normal(seg) => stack.push(PathBuf::from(seg)),
Component::ParentDir => {
if stack.pop().is_none() {
return Err(InstallerError::PathTraversalOutsideImage {
rel: rel.to_string(),
});
}
}
// Prefixes shouldn't appear on Unix; treat conservatively
Component::Prefix(_) | Component::RootDir => {
return Err(InstallerError::AbsolutePathForbidden {
path: rel.to_string(),
})
}
}
}
let mut out = PathBuf::from(image_root);
for seg in stack {
out.push(seg);
}
Ok(out)
}
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
pub enum ActionOrder {
Dir = 0,
File = 1,
Link = 2,
Other = 3,
}
impl ActionOrder {
fn for_manifest_section(section: &'static str) -> ActionOrder {
match section {
"dir" | "directories" => ActionOrder::Dir,
"file" | "files" => ActionOrder::File,
"link" | "links" => ActionOrder::Link,
_ => ActionOrder::Other,
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ApplyOptions {
pub dry_run: bool,
}
/// Apply a manifest to the filesystem rooted at image_root.
/// This function enforces ordering: directories, then files, then links, then others (no-ops for now).
pub fn apply_manifest(image_root: &Path, manifest: &Manifest, opts: &ApplyOptions) -> Result<(), InstallerError> {
// Directories first
for d in &manifest.directories {
apply_dir(image_root, d, opts)?;
}
// Files next
for f in &manifest.files {
apply_file(image_root, f, opts)?;
}
// Links
for l in &manifest.links {
apply_link(image_root, l, opts)?;
}
// Other action kinds are ignored for now and left for future extension.
Ok(())
}
fn apply_dir(image_root: &Path, d: &DirAction, opts: &ApplyOptions) -> Result<(), InstallerError> {
let full = safe_join(image_root, &d.path)?;
info!(?full, "creating directory");
if opts.dry_run {
return Ok(());
}
fs::create_dir_all(&full).map_err(|e| InstallerError::Io {
source: e,
path: full.clone(),
})?;
// Set permissions if provided
let mode = parse_mode(&d.mode, 0o755);
let perm = fs::Permissions::from_mode(mode);
fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io {
source: e,
path: full.clone(),
})?;
Ok(())
}
fn ensure_parent(image_root: &Path, p: &str, opts: &ApplyOptions) -> Result<(), InstallerError> {
let full = safe_join(image_root, p)?;
if let Some(parent) = full.parent() {
if opts.dry_run {
return Ok(());
}
fs::create_dir_all(parent).map_err(|e| InstallerError::Io {
source: e,
path: parent.to_path_buf(),
})?;
}
Ok(())
}
fn apply_file(image_root: &Path, f: &FileAction, opts: &ApplyOptions) -> Result<(), InstallerError> {
let full = safe_join(image_root, &f.path)?;
// Ensure parent exists (directories should already be applied, but be robust)
ensure_parent(image_root, &f.path, opts)?;
info!(?full, "creating file (payload handling TBD)");
if opts.dry_run {
return Ok(());
}
// For now, write empty content as a scaffold. Payload fetching/integration will follow later.
let mut file = FsFile::create(&full).map_err(|e| InstallerError::Io {
source: e,
path: full.clone(),
})?;
file.write_all(&[]).map_err(|e| InstallerError::Io {
source: e,
path: full.clone(),
})?;
// Set permissions if provided
let mode = parse_mode(&f.mode, 0o644);
let perm = fs::Permissions::from_mode(mode);
fs::set_permissions(&full, perm).map_err(|e| InstallerError::Io {
source: e,
path: full.clone(),
})?;
Ok(())
}
fn apply_link(image_root: &Path, l: &LinkAction, opts: &ApplyOptions) -> Result<(), InstallerError> {
let link_path = safe_join(image_root, &l.path)?;
// Determine link type (default to symlink). If properties contain type=hard, create hard link.
let mut is_hard = false;
if let Some(prop) = l.properties.get("type") {
let v = prop.value.to_ascii_lowercase();
if v == "hard" || v == "hardlink" {
is_hard = true;
}
}
// Target may be relative; keep it as-is for symlink. For hard links, target must resolve under image_root.
if opts.dry_run {
return Ok(());
}
if is_hard {
// Hard link needs a resolved, safe target within the image.
let target_full = safe_join(image_root, &l.target)?;
fs::hard_link(&target_full, &link_path).map_err(|e| InstallerError::Io {
source: e,
path: link_path.clone(),
})?;
} else {
// Symlink: require non-absolute target to avoid embedding full host paths
if Path::new(&l.target).is_absolute() {
return Err(InstallerError::AbsolutePathForbidden { path: l.target.clone() });
}
// Create relative symlink as provided (do not convert to absolute to avoid embedding full paths)
#[cfg(target_family = "unix")]
{
unix_fs::symlink(&l.target, &link_path).map_err(|e| InstallerError::Io {
source: e,
path: link_path.clone(),
})?;
}
#[cfg(not(target_family = "unix"))]
{
return Err(InstallerError::UnsupportedAction {
action: "link",
reason: "symlink not supported on this platform".to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_join_rejects_absolute() {
let root = Path::new("/tmp/image");
let err = safe_join(root, "/etc/passwd").unwrap_err();
match err {
InstallerError::AbsolutePathForbidden { .. } => {}
_ => panic!("expected AbsolutePathForbidden"),
}
}
#[test]
fn safe_join_rejects_escape() {
let root = Path::new("/tmp/image");
let err = safe_join(root, "../../etc").unwrap_err();
match err {
InstallerError::PathTraversalOutsideImage { .. } => {}
_ => panic!("expected PathTraversalOutsideImage"),
}
}
#[test]
fn safe_join_ok() {
let root = Path::new("/tmp/image");
let p = safe_join(root, "etc/pkg").unwrap();
assert!(p.starts_with(root));
assert!(p.ends_with("pkg"));
}
}

View file

@ -22,6 +22,8 @@ use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
use tracing::debug; use tracing::debug;
pub mod executors;
type Result<T> = StdResult<T, ActionError>; type Result<T> = StdResult<T, ActionError>;
#[derive(Debug, Error, Diagnostic)] #[derive(Debug, Error, Diagnostic)]

View file

@ -0,0 +1,65 @@
use std::path::Path;
use crate::actions::{Manifest, Dir, File, Link};
use crate::actions::executors::{apply_manifest, ApplyOptions, InstallerError};
use crate::solver::InstallPlan;
/// ActionPlan represents a merged list of actions across all manifests
/// that are to be installed together. It intentionally does not preserve
/// per-package boundaries; executors will run with proper ordering.
#[derive(Debug, Default, Clone)]
pub struct ActionPlan {
pub manifest: Manifest,
}
impl ActionPlan {
/// Build an ActionPlan by merging all actions from the install plan's add set.
/// Note: For now, only directory, file, and link actions are merged for execution.
pub fn from_install_plan(plan: &InstallPlan) -> Self {
// Merge all actions from the manifests in plan.add
let mut merged = Manifest::new();
for rp in &plan.add {
// directories
for d in &rp.manifest.directories {
merged.directories.push(d.clone());
}
// files
for f in &rp.manifest.files {
merged.files.push(f.clone());
}
// links
for l in &rp.manifest.links {
merged.links.push(l.clone());
}
// In the future we can merge other action kinds as executor support is added.
}
Self { manifest: merged }
}
/// Execute the action plan using the executors relative to the provided image root.
pub fn apply(&self, image_root: &Path, opts: &ApplyOptions) -> Result<(), InstallerError> {
apply_manifest(image_root, &self.manifest, opts)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::solver::{InstallPlan as SInstallPlan, ResolvedPkg};
use crate::fmri::{Fmri, Version};
#[test]
fn build_and_apply_empty_plan_dry_run() {
// Empty install plan should produce empty action plan and apply should be no-op.
let plan = SInstallPlan { add: vec![], remove: vec![], update: vec![], reasons: vec![] };
let ap = ActionPlan::from_install_plan(&plan);
assert!(ap.manifest.directories.is_empty());
assert!(ap.manifest.files.is_empty());
assert!(ap.manifest.links.is_empty());
let opts = ApplyOptions { dry_run: true };
let root = Path::new("/tmp/ips_image_test_nonexistent_root");
// Even if root doesn't exist, dry_run should not perform any IO and succeed.
let res = ap.apply(root, &opts);
assert!(res.is_ok());
}
}

View file

@ -535,7 +535,7 @@ impl ImageCatalog {
// Parse and add actions from the version entry // Parse and add actions from the version entry
if let Some(actions) = &version_entry.actions { if let Some(actions) = &version_entry.actions {
for action_str in actions { for action_str in actions {
// Parse each action string to extract attributes // Parse each action string to extract attributes we care about in the catalog
if action_str.starts_with("set ") { if action_str.starts_with("set ") {
// Format is typically "set name=pkg.key value=value" // Format is typically "set name=pkg.key value=value"
if let Some(name_part) = action_str.split_whitespace().nth(1) { if let Some(name_part) = action_str.split_whitespace().nth(1) {
@ -567,6 +567,41 @@ impl ImageCatalog {
} }
} }
} }
} else if action_str.starts_with("depend ") {
// Example: "depend fmri=desktop/mate/caja type=require"
let rest = &action_str[7..]; // strip leading "depend "
let mut dep_type: String = String::new();
let mut dep_predicate: Option<crate::fmri::Fmri> = None;
let mut dep_fmris: Vec<crate::fmri::Fmri> = Vec::new();
let mut root_image: String = String::new();
for tok in rest.split_whitespace() {
if let Some((k, v)) = tok.split_once('=') {
match k {
"type" => dep_type = v.to_string(),
"predicate" => {
if let Ok(f) = crate::fmri::Fmri::parse(v) { dep_predicate = Some(f); }
}
"fmri" => {
if let Ok(f) = crate::fmri::Fmri::parse(v) { dep_fmris.push(f); }
}
"root-image" => {
root_image = v.to_string();
}
_ => { /* ignore other props for catalog */ }
}
}
}
// For each fmri property, add a Dependency entry
for f in dep_fmris {
let mut d = crate::actions::Dependency::default();
d.fmri = Some(f);
d.dependency_type = dep_type.clone();
d.predicate = dep_predicate.clone();
d.root_image = root_image.clone();
manifest.dependencies.push(d);
}
} }
} }
} }
@ -612,7 +647,6 @@ impl ImageCatalog {
/// Check if a package is obsolete /// Check if a package is obsolete
fn is_package_obsolete(&self, manifest: &Manifest) -> bool { fn is_package_obsolete(&self, manifest: &Manifest) -> bool {
// Check for the pkg.obsolete attribute
manifest.attributes.iter().any(|attr| { manifest.attributes.iter().any(|attr| {
attr.key == "pkg.obsolete" && attr.values.get(0).map_or(false, |v| v == "true") attr.key == "pkg.obsolete" && attr.values.get(0).map_or(false, |v| v == "true")
}) })

View file

@ -10,7 +10,7 @@ use std::fs::{self, File};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use thiserror::Error; use thiserror::Error;
use crate::repository::{ReadableRepository, RepositoryError, RestBackend}; use crate::repository::{ReadableRepository, RepositoryError, RestBackend, FileBackend};
// Export the catalog module // Export the catalog module
pub mod catalog; pub mod catalog;
@ -18,6 +18,8 @@ use catalog::{ImageCatalog, PackageInfo};
// Export the installed packages module // Export the installed packages module
pub mod installed; pub mod installed;
// Export the action plan module
pub mod action_plan;
use installed::{InstalledPackageInfo, InstalledPackages}; use installed::{InstalledPackageInfo, InstalledPackages};
// Include tests // Include tests
@ -374,6 +376,75 @@ impl Image {
}) })
} }
/// Save a manifest into the metadata manifests directory for this image.
///
/// The original, unprocessed manifest text is downloaded from the repository
/// and stored under a flattened path:
/// manifests/<publisher>/<encoded_stem>@<encoded_version>.p5m
/// Missing publisher will fall back to the image default publisher, then "unknown".
pub fn save_manifest(&self, fmri: &crate::fmri::Fmri, _manifest: &crate::actions::Manifest) -> Result<std::path::PathBuf> {
// Determine publisher name
let pub_name = if let Some(p) = &fmri.publisher {
p.clone()
} else if let Ok(def) = self.default_publisher() {
def.name.clone()
} else {
"unknown".to_string()
};
// Build directory path manifests/<publisher> (flattened, no stem subfolders)
let dir_path = self.manifest_dir().join(&pub_name);
std::fs::create_dir_all(&dir_path)?;
// Encode helpers for filename parts
fn url_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'-' | b'_' | b'.' | b'~' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' => out.push(b as char),
b' ' => out.push('+'),
_ => {
out.push('%');
out.push_str(&format!("{:02X}", b));
}
}
}
out
}
let version = fmri.version();
let encoded_stem = url_encode(fmri.stem());
let encoded_version = url_encode(&version);
let file_path = dir_path.join(format!("{}@{}.p5m", encoded_stem, encoded_version));
// Fetch raw manifest text from repository
let publisher_name = pub_name.clone();
let raw_text = {
// Look up publisher configuration
let publisher = self.get_publisher(&publisher_name)?;
let origin = &publisher.origin;
if origin.starts_with("file://") {
let path_str = origin.trim_start_matches("file://");
let path = std::path::PathBuf::from(path_str);
let repo = crate::repository::FileBackend::open(&path)?;
repo.fetch_manifest_text(&publisher_name, fmri)?
} else {
let mut repo = crate::repository::RestBackend::open(origin)?;
// Set cache path for completeness
let publisher_catalog_dir = self.catalog_dir().join(&publisher.name);
repo.set_local_cache_path(&publisher_catalog_dir)?;
repo.fetch_manifest_text(&publisher_name, fmri)?
}
};
// Write atomically
let tmp_path = file_path.with_extension("p5m.tmp");
std::fs::write(&tmp_path, raw_text.as_bytes())?;
std::fs::rename(&tmp_path, &file_path)?;
Ok(file_path)
}
/// Initialize the catalog database /// Initialize the catalog database
pub fn init_catalog_db(&self) -> Result<()> { pub fn init_catalog_db(&self) -> Result<()> {
let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path()); let catalog = ImageCatalog::new(self.catalog_dir(), self.catalog_db_path());
@ -493,6 +564,45 @@ impl Image {
}) })
} }
/// Fetch a full manifest for the given FMRI directly from its repository origin.
///
/// This bypasses the local catalog database and retrieves the full manifest from
/// the configured publisher origin (REST for http/https origins; File backend for
/// file:// origins). A versioned FMRI is required.
pub fn get_manifest_from_repository(&self, fmri: &crate::fmri::Fmri) -> Result<crate::actions::Manifest> {
// Determine publisher: use FMRI's publisher if present, otherwise default publisher
let publisher_name = if let Some(p) = &fmri.publisher {
p.clone()
} else {
self.default_publisher()?.name.clone()
};
// Look up publisher configuration
let publisher = self.get_publisher(&publisher_name)?;
let origin = &publisher.origin;
// Require a concrete version in the FMRI
if fmri.version().is_empty() {
return Err(ImageError::Repository(RepositoryError::Other(
"FMRI must include a version to fetch manifest".to_string(),
)));
}
// Choose backend based on origin scheme
if origin.starts_with("file://") {
let path_str = origin.trim_start_matches("file://");
let path = PathBuf::from(path_str);
let mut repo = FileBackend::open(&path)?;
repo.fetch_manifest(&publisher_name, fmri).map_err(Into::into)
} else {
let mut repo = RestBackend::open(origin)?;
// Optionally set a per-publisher cache directory (used by other REST ops)
let publisher_catalog_dir = self.catalog_dir().join(&publisher.name);
repo.set_local_cache_path(&publisher_catalog_dir)?;
repo.fetch_manifest(&publisher_name, fmri).map_err(Into::into)
}
}
/// 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

View file

@ -1660,6 +1660,36 @@ impl WritableRepository for FileBackend {
} }
impl FileBackend { impl FileBackend {
pub fn fetch_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result<String> {
// Require a concrete version
let version = fmri.version();
if version.is_empty() {
return Err(RepositoryError::Other("FMRI must include a version to fetch manifest".into()));
}
// Preferred path: publisher-scoped manifest path
let path = Self::construct_manifest_path(&self.path, publisher, fmri.stem(), &version);
if path.exists() {
return std::fs::read_to_string(&path).map_err(|e| RepositoryError::FileReadError(format!("{}", e)));
}
// Fallbacks: global pkg layout without publisher
let encoded_stem = Self::url_encode(fmri.stem());
let encoded_version = Self::url_encode(&version);
let alt1 = self.path.join("pkg").join(&encoded_stem).join(&encoded_version);
if alt1.exists() {
return std::fs::read_to_string(&alt1).map_err(|e| RepositoryError::FileReadError(format!("{}", e)));
}
let alt2 = self
.path
.join("publisher")
.join(publisher)
.join("pkg")
.join(&encoded_stem)
.join(&encoded_version);
if alt2.exists() {
return std::fs::read_to_string(&alt2).map_err(|e| RepositoryError::FileReadError(format!("{}", e)));
}
Err(RepositoryError::NotFound(format!("manifest for {} not found", fmri)))
}
/// Save the legacy pkg5.repository INI file for backward compatibility /// Save the legacy pkg5.repository INI file for backward compatibility
pub fn save_legacy_config(&self) -> Result<()> { pub fn save_legacy_config(&self) -> Result<()> {
let legacy_config_path = self.path.join("pkg5.repository"); let legacy_config_path = self.path.join("pkg5.repository");

View file

@ -613,12 +613,31 @@ impl ReadableRepository for RestBackend {
publisher: &str, publisher: &str,
fmri: &crate::fmri::Fmri, fmri: &crate::fmri::Fmri,
) -> Result<crate::actions::Manifest> { ) -> Result<crate::actions::Manifest> {
let text = self.fetch_manifest_text(publisher, fmri)?;
crate::actions::Manifest::parse_string(text).map_err(RepositoryError::from)
}
fn search(
&self,
_query: &str,
_publisher: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<PackageInfo>> {
todo!()
}
}
impl RestBackend {
pub fn fetch_manifest_text(
&mut self,
publisher: &str,
fmri: &crate::fmri::Fmri,
) -> Result<String> {
// Require versioned FMRI // Require versioned FMRI
let version = fmri.version(); let version = fmri.version();
if version.is_empty() { if version.is_empty() {
return Err(RepositoryError::Other("FMRI must include a version to fetch manifest".into())); return Err(RepositoryError::Other("FMRI must include a version to fetch manifest".into()));
} }
// URL-encode helper // URL-encode helper
let url_encode = |s: &str| -> String { let url_encode = |s: &str| -> String {
let mut out = String::new(); let mut out = String::new();
@ -634,11 +653,9 @@ impl ReadableRepository for RestBackend {
} }
out out
}; };
let encoded_fmri = url_encode(&format!("{}@{}", fmri.stem(), version)); let encoded_fmri = url_encode(&format!("{}@{}", fmri.stem(), version));
let encoded_stem = url_encode(fmri.stem()); let encoded_stem = url_encode(fmri.stem());
let encoded_version = url_encode(&version); let encoded_version = url_encode(&version);
let candidates = vec![ let candidates = vec![
format!("{}/manifest/0/{}", self.uri, encoded_fmri), format!("{}/manifest/0/{}", self.uri, encoded_fmri),
format!("{}/publisher/{}/manifest/0/{}", self.uri, publisher, encoded_fmri), format!("{}/publisher/{}/manifest/0/{}", self.uri, publisher, encoded_fmri),
@ -646,13 +663,12 @@ impl ReadableRepository for RestBackend {
format!("{}/pkg/{}/{}", self.uri, encoded_stem, encoded_version), format!("{}/pkg/{}/{}", self.uri, encoded_stem, encoded_version),
format!("{}/publisher/{}/pkg/{}/{}", self.uri, publisher, encoded_stem, encoded_version), format!("{}/publisher/{}/pkg/{}/{}", self.uri, publisher, encoded_stem, encoded_version),
]; ];
let mut last_err: Option<String> = None; let mut last_err: Option<String> = None;
for url in candidates { for url in candidates {
match self.client.get(&url).send() { match self.client.get(&url).send() {
Ok(resp) if resp.status().is_success() => { Ok(resp) if resp.status().is_success() => {
let text = resp.text().map_err(|e| RepositoryError::Other(format!("Failed to read manifest body: {}", e)))?; let text = resp.text().map_err(|e| RepositoryError::Other(format!("Failed to read manifest body: {}", e)))?;
return crate::actions::Manifest::parse_string(text).map_err(RepositoryError::from); return Ok(text);
} }
Ok(resp) => { Ok(resp) => {
last_err = Some(format!("HTTP {} for {}", resp.status(), url)); last_err = Some(format!("HTTP {} for {}", resp.status(), url));
@ -662,21 +678,8 @@ impl ReadableRepository for RestBackend {
} }
} }
} }
Err(RepositoryError::NotFound(last_err.unwrap_or_else(|| "manifest not found".to_string()))) Err(RepositoryError::NotFound(last_err.unwrap_or_else(|| "manifest not found".to_string())))
} }
fn search(
&self,
_query: &str,
_publisher: Option<&str>,
_limit: Option<usize>,
) -> Result<Vec<PackageInfo>> {
todo!()
}
}
impl RestBackend {
/// Sets the local path where catalog files will be cached. /// Sets the local path where catalog files will be cached.
/// ///
/// This method creates the directory if it doesn't exist. The local cache path /// This method creates the directory if it doesn't exist. The local cache path

View file

@ -16,13 +16,14 @@
//! solver, and assembles an InstallPlan from the chosen solvables. //! solver, and assembles an InstallPlan from the chosen solvables.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
// Begin resolvo wiring imports (names discovered by compiler) // Begin resolvo wiring imports (names discovered by compiler)
// We start broad and refine with compiler guidance. // We start broad and refine with compiler guidance.
use resolvo::{self, Candidates, Dependencies as RDependencies, DependencyProvider, Interner, KnownDependencies, Mapping, NameId, Problem as RProblem, Requirement as RRequirement, Solver as RSolver, SolverCache, SolvableId, StringId, VersionSetId, VersionSetUnionId}; use resolvo::{self, Candidates, Dependencies as RDependencies, DependencyProvider, Interner, KnownDependencies, Mapping, NameId, Problem as RProblem, Requirement as RRequirement, Solver as RSolver, SolverCache, SolvableId, StringId, VersionSetId, VersionSetUnionId, UnsolvableOrCancelled};
use miette::Diagnostic; use miette::Diagnostic;
use redb::ReadableTable;
use thiserror::Error; use thiserror::Error;
use crate::actions::Manifest; use crate::actions::Manifest;
@ -181,12 +182,29 @@ impl<'a> Interner for IpsProvider<'a> {
// Helper to evaluate if a candidate FMRI matches a VersionSetKind constraint // Helper to evaluate if a candidate FMRI matches a VersionSetKind constraint
fn fmri_matches_version_set(fmri: &Fmri, kind: &VersionSetKind) -> bool { fn fmri_matches_version_set(fmri: &Fmri, kind: &VersionSetKind) -> bool {
// Allow composite releases like "20,5.11": a requirement of single token (e.g., "5.11")
// matches any candidate whose comma-separated release segments contain that token.
// Multi-token requirements (contain a comma) require exact equality.
fn release_satisfies(req: &str, cand: &str) -> bool {
if req == cand {
return true;
}
if req.contains(',') {
// Multi-token requirement must match exactly
return false;
}
// Single token requirement: match if present among candidate segments
cand.split(',').any(|seg| seg.trim() == req)
}
match kind { match kind {
VersionSetKind::Any => true, VersionSetKind::Any => true,
VersionSetKind::ReleaseEq(req_rel) => fmri VersionSetKind::ReleaseEq(req_rel) => fmri
.version .version
.as_ref() .as_ref()
.map(|v| &v.release == req_rel) .map(|v| {
release_satisfies(req_rel, &v.release)
|| v.branch.as_deref() == Some(req_rel)
})
.unwrap_or(false), .unwrap_or(false),
VersionSetKind::BranchEq(req_branch) => fmri VersionSetKind::BranchEq(req_branch) => fmri
.version .version
@ -194,17 +212,14 @@ fn fmri_matches_version_set(fmri: &Fmri, kind: &VersionSetKind) -> bool {
.and_then(|v| v.branch.as_ref()) .and_then(|v| v.branch.as_ref())
.map(|b| b == req_branch) .map(|b| b == req_branch)
.unwrap_or(false), .unwrap_or(false),
VersionSetKind::ReleaseAndBranch { release, branch } => fmri VersionSetKind::ReleaseAndBranch { release, branch } => {
.version let (mut ok_rel, mut ok_branch) = (false, false);
.as_ref() if let Some(v) = fmri.version.as_ref() {
.map(|v| &v.release == release) ok_rel = release_satisfies(release, &v.release) || v.branch.as_deref() == Some(release);
.unwrap_or(false) ok_branch = v.branch.as_ref().map(|b| b == branch).unwrap_or(false);
&& fmri }
.version ok_rel && ok_branch
.as_ref() }
.and_then(|v| v.branch.as_ref())
.map(|b| b == branch)
.unwrap_or(false),
} }
} }
@ -473,9 +488,13 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
.map(|p| p.name.clone()) .map(|p| p.name.clone())
.unwrap_or_else(|_| String::new()); .unwrap_or_else(|_| String::new());
// Track each root's NameId with the originating constraint for diagnostics
let mut root_names: Vec<(NameId, Constraint)> = Vec::new();
for c in constraints.iter().cloned() { for c in constraints.iter().cloned() {
// Intern name // Intern name
let name_id = provider.intern_name(&c.stem); let name_id = provider.intern_name(&c.stem);
root_names.push((name_id, c.clone()));
// Store publisher preferences for this root // Store publisher preferences for this root
let mut prefs = c.preferred_publishers.clone(); let mut prefs = c.preferred_publishers.clone();
@ -501,7 +520,30 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
problem.requirements.push(RRequirement::from(vs_id)); problem.requirements.push(RRequirement::from(vs_id));
} }
// Before moving provider into the solver, capture a map from solvable id to fmri // Early diagnostic: detect roots with zero candidates before invoking solver
let mut missing: Vec<String> = Vec::new();
for (name_id, c) in &root_names {
let has = provider
.cands_by_name
.get(name_id)
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has {
let mut req = c.stem.clone();
if let Some(v) = &c.version_req { req.push('@'); req.push_str(v); }
missing.push(req);
}
}
if !missing.is_empty() {
let pubs: Vec<String> = image.publishers().iter().map(|p| p.name.clone()).collect();
return Err(SolverError::new(format!(
"No candidates found for requested package(s): {}.\nChecked publishers: {}.\nRun 'pkg6 refresh' to update catalogs or verify the package names.",
missing.join(", "),
pubs.join(", ")
)));
}
// Before moving provider into the solver, capture useful snapshots for diagnostics
let mut sid_to_fmri: HashMap<SolvableId, Fmri> = HashMap::new(); let mut sid_to_fmri: HashMap<SolvableId, Fmri> = HashMap::new();
for ids in provider.cands_by_name.values() { for ids in provider.cands_by_name.values() {
for sid in ids { for sid in ids {
@ -509,22 +551,57 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
sid_to_fmri.insert(*sid, fmri); sid_to_fmri.insert(*sid, fmri);
} }
} }
// Snapshot: NameId -> name string
let mut name_to_string: HashMap<NameId, String> = HashMap::new();
for (name_id, _cands) in provider.cands_by_name.iter() {
name_to_string.insert(*name_id, provider.display_name(*name_id).to_string());
}
// Reverse: stem string -> NameId
let mut stem_to_nameid: HashMap<String, NameId> = HashMap::new();
for (nid, nstr) in name_to_string.iter() {
stem_to_nameid.insert(nstr.clone(), *nid);
}
// Snapshot: NameId -> candidate FMRIs
let mut name_to_fmris: HashMap<NameId, Vec<Fmri>> = HashMap::new();
for (name_id, sids) in provider.cands_by_name.iter() {
let mut v: Vec<Fmri> = Vec::new();
for sid in sids {
if let Some(pc) = provider.solvables.get(*sid) {
v.push(pc.fmri.clone());
}
}
name_to_fmris.insert(*name_id, v);
}
// Run the solver // Run the solver
let mut solver = RSolver::new(provider); let mut solver = RSolver::new(provider);
let solution_ids = solver let solution_ids = solver.solve(problem).map_err(|conflict_or_cancelled| {
.solve(problem) match conflict_or_cancelled {
.map_err(|e| SolverError::new(format!("dependency solving failed: {e:?}")))?; UnsolvableOrCancelled::Unsolvable(u) => {
SolverError::new(u.display_user_friendly(&solver).to_string())
}
UnsolvableOrCancelled::Cancelled(_) => {
SolverError::new("dependency resolution cancelled".to_string())
}
}
})?;
// Build plan from solution // Build plan from solution
let image_ref = image; let image_ref = image;
let mut plan = InstallPlan::default(); let mut plan = InstallPlan::default();
for sid in solution_ids { for sid in solution_ids {
if let Some(fmri) = sid_to_fmri.get(&sid).cloned() { if let Some(fmri) = sid_to_fmri.get(&sid).cloned() {
let manifest = image_ref // Fetch full manifest from repository; fallback to catalog if repo fetch fails (useful for tests/offline)
.get_manifest_from_catalog(&fmri) let manifest = match image_ref.get_manifest_from_repository(&fmri) {
.map_err(|e| SolverError::new(format!("failed to load manifest for {}: {e}", fmri)))? Ok(m) => m,
.ok_or_else(|| SolverError::new(format!("manifest not found in catalog for {}", fmri)))?; Err(repo_err) => {
// Try catalog as a fallback
match image_ref.get_manifest_from_catalog(&fmri) {
Ok(Some(m)) => m,
_ => return Err(SolverError::new(format!("failed to obtain manifest for {}: {}", fmri, repo_err))),
}
}
};
plan.reasons.push(format!("selected {} via solver", fmri)); plan.reasons.push(format!("selected {} via solver", fmri));
plan.add.push(ResolvedPkg { fmri, manifest }); plan.add.push(ResolvedPkg { fmri, manifest });
} }
@ -683,6 +760,52 @@ mod solver_integration_tests {
assert_eq!(chosen.version.as_ref().unwrap().release, "0.9"); assert_eq!(chosen.version.as_ref().unwrap().release, "0.9");
} }
#[test]
fn resolve_uses_repo_manifest_after_solving() {
use crate::image::ImageType;
use crate::repository::{FileBackend, WritableRepository, RepositoryVersion};
use std::fs;
// Create a temp image
let td_img = tempfile::tempdir().expect("tempdir img");
let img_path = td_img.path().to_path_buf();
let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image");
// Create a temp file-based repository and add publisher
let td_repo = tempfile::tempdir().expect("tempdir repo");
let repo_path = td_repo.path().to_path_buf();
let mut repo = FileBackend::create(&repo_path, RepositoryVersion::V4).expect("create repo");
repo.add_publisher("pubA").expect("add publisher");
// Configure image publisher to point to file:// repo
let origin = format!("file://{}", repo_path.display());
img.add_publisher("pubA", &origin, vec![], true).expect("add publisher to image");
// Define FMRI and limited manifest in catalog (deps only)
let fmri = mk_fmri("pubA", "pkg/alpha", mk_version("1.0", None, Some("20200401T000000Z")));
let limited = mk_manifest(&fmri, &[]); // no files/dirs
write_manifest_to_catalog(&img, &fmri, &limited);
// Write full manifest into repository at expected path
let repo_manifest_path = FileBackend::construct_manifest_path(&repo_path, "pubA", fmri.stem(), &fmri.version());
if let Some(parent) = repo_manifest_path.parent() { fs::create_dir_all(parent).unwrap(); }
let full_manifest_text = format!(
"set name=pkg.fmri value={}\n\
dir path=opt/test owner=root group=bin mode=0755\n\
file path=opt/test/hello owner=root group=bin mode=0644\n",
fmri
);
fs::write(&repo_manifest_path, full_manifest_text).expect("write manifest to repo");
// Resolve and ensure we got the repo (full) manifest with file/dir actions
let c = Constraint { stem: "pkg/alpha".to_string(), version_req: None, preferred_publishers: vec![], branch: None };
let plan = resolve_install(&img, &[c]).expect("resolve");
assert_eq!(plan.add.len(), 1);
let man = &plan.add[0].manifest;
assert!(man.directories.len() >= 1, "expected directories from repo manifest");
assert!(man.files.len() >= 1, "expected files from repo manifest");
}
#[test] #[test]
fn dependency_sticks_to_parent_branch() { fn dependency_sticks_to_parent_branch() {
let img = make_image_with_publishers(&[("pubA", true)]); let img = make_image_with_publishers(&[("pubA", true)]);
@ -750,3 +873,188 @@ mod solver_integration_tests {
assert_eq!(v.timestamp.as_deref(), Some("20200201T000000Z")); assert_eq!(v.timestamp.as_deref(), Some("20200201T000000Z"));
} }
} }
#[cfg(test)]
mod no_candidate_error_tests {
use super::*;
use crate::image::ImageType;
#[test]
fn error_message_includes_no_candidates() {
// Create a temporary image with a publisher but no packages
let td = tempfile::tempdir().expect("tempdir");
let img_path = td.path().to_path_buf();
let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image");
img.add_publisher("pubA", "https://example.com/pubA", vec![], true).expect("add publisher");
// Request a non-existent package so the root has zero candidates
let c = Constraint { stem: "pkg/does-not-exist".to_string(), version_req: None, preferred_publishers: vec![], branch: None };
let err = resolve_install(&img, &[c]).err().expect("expected error");
let msg = err.message;
assert!(msg.contains("No candidates") || msg.contains("no candidates"), "unexpected message: {}", msg);
}
}
#[cfg(test)]
mod solver_error_message_tests {
use super::*;
use crate::actions::{Dependency, Manifest};
use crate::fmri::{Fmri, Version};
use crate::image::ImageType;
use redb::Database;
use crate::image::catalog::CATALOG_TABLE;
fn mk_version(release: &str, branch: Option<&str>, timestamp: Option<&str>) -> Version {
let mut v = Version::new(release);
if let Some(b) = branch { v.branch = Some(b.to_string()); }
if let Some(t) = timestamp { v.timestamp = Some(t.to_string()); }
v
}
fn mk_fmri(publisher: &str, name: &str, v: Version) -> Fmri {
Fmri::with_publisher(publisher, name, Some(v))
}
fn mk_manifest_with_dep(parent: &Fmri, dep: &Fmri) -> Manifest {
let mut m = Manifest::new();
let mut attr = crate::actions::Attr::default();
attr.key = "pkg.fmri".to_string();
attr.values = vec![parent.to_string()];
m.attributes.push(attr);
let mut d = Dependency::default();
d.fmri = Some(dep.clone());
d.dependency_type = "require".to_string();
m.dependencies.push(d);
m
}
fn write_manifest_to_catalog(image: &Image, fmri: &Fmri, manifest: &Manifest) {
let db = Database::open(image.catalog_db_path()).expect("open catalog db");
let tx = db.begin_write().expect("begin write");
{
let mut table = tx.open_table(CATALOG_TABLE).expect("open catalog table");
let key = format!("{}@{}", fmri.stem(), fmri.version());
let val = serde_json::to_vec(manifest).expect("serialize manifest");
table.insert(key.as_str(), val.as_slice()).expect("insert manifest");
}
tx.commit().expect("commit");
}
#[test]
fn unsatisfied_dependency_message_no_clause_ids() {
let td = tempfile::tempdir().expect("tempdir");
let img_path = td.path().to_path_buf();
let mut img = Image::create_image(&img_path, ImageType::Partial).expect("create image");
img.add_publisher("pubA", "https://example.com/pubA", vec![], true).expect("add publisher");
// Parent requires child@2.0 but only child@1.0 exists in catalog (unsatisfiable)
let parent = mk_fmri("pubA", "pkg/root", mk_version("1.0", None, Some("20200101T000000Z")));
let child_req = Fmri::with_version("pkg/child", Version::new("2.0"));
let parent_manifest = mk_manifest_with_dep(&parent, &child_req);
write_manifest_to_catalog(&img, &parent, &parent_manifest);
// Add a child candidate with non-matching release
let child_present = mk_fmri("pubA", "pkg/child", mk_version("1.0", None, Some("20200101T000000Z")));
write_manifest_to_catalog(&img, &child_present, &Manifest::new());
let c = Constraint { stem: "pkg/root".to_string(), version_req: None, preferred_publishers: vec![], branch: None };
let err = resolve_install(&img, &[c]).err().expect("expected solver error");
let msg = err.message;
assert!(!msg.contains("ClauseId("), "message should not include ClauseId identifiers: {}", msg);
assert!(msg.to_lowercase().contains("rejected because"), "expected rejection explanation in message: {}", msg);
assert!(msg.to_lowercase().contains("unsatisfied dependency"), "expected unsatisfied dependency in message: {}", msg);
}
}
#[cfg(test)]
mod composite_release_tests {
use super::*;
use crate::actions::{Dependency, Manifest};
use crate::fmri::{Fmri, Version};
use crate::image::catalog::CATALOG_TABLE;
use crate::image::ImageType;
use redb::Database;
fn mk_version(release: &str, branch: Option<&str>, timestamp: Option<&str>) -> Version {
let mut v = Version::new(release);
if let Some(b) = branch { v.branch = Some(b.to_string()); }
if let Some(t) = timestamp { v.timestamp = Some(t.to_string()); }
v
}
fn mk_fmri(publisher: &str, name: &str, v: Version) -> Fmri {
Fmri::with_publisher(publisher, name, Some(v))
}
fn write_manifest_to_catalog(image: &Image, fmri: &Fmri, manifest: &Manifest) {
let db = Database::open(image.catalog_db_path()).expect("open catalog db");
let tx = db.begin_write().expect("begin write");
{
let mut table = tx.open_table(CATALOG_TABLE).expect("open catalog table");
let key = format!("{}@{}", fmri.stem(), fmri.version());
let val = serde_json::to_vec(manifest).expect("serialize manifest");
table.insert(key.as_str(), val.as_slice()).expect("insert manifest");
}
tx.commit().expect("commit");
}
fn make_image_with_publishers(pubs: &[(&str, bool)]) -> Image {
let td = tempfile::tempdir().expect("tempdir");
// Persist the directory for the duration of the test
let path = td.keep();
let mut img = Image::create_image(&path, ImageType::Partial).expect("create image");
for (name, is_default) in pubs.iter().copied() {
img.add_publisher(name, &format!("https://example.com/{name}"), vec![], is_default)
.expect("add publisher");
}
img
}
#[test]
fn require_5_11_matches_candidate_20_5_11() {
let img = make_image_with_publishers(&[("pubA", true)]);
// Parent requires child@5.11
let parent = mk_fmri("pubA", "pkg/root", mk_version("1.0", None, Some("20200101T000000Z")));
let child_req = Fmri::with_version("pkg/child", Version::new("5.11"));
let mut man = Manifest::new();
// add pkg.fmri attribute
let mut attr = crate::actions::Attr::default();
attr.key = "pkg.fmri".to_string();
attr.values = vec![parent.to_string()];
man.attributes.push(attr);
// add require dep
let mut d = Dependency::default();
d.fmri = Some(child_req);
d.dependency_type = "require".to_string();
man.dependencies.push(d);
write_manifest_to_catalog(&img, &parent, &man);
// Only child candidate is release "20,5.11"
let child = mk_fmri("pubA", "pkg/child", mk_version("20,5.11", None, Some("20200401T000000Z")));
write_manifest_to_catalog(&img, &child, &Manifest::new());
let c = Constraint { stem: "pkg/root".to_string(), version_req: None, preferred_publishers: vec![], branch: None };
let plan = resolve_install(&img, &[c]).expect("should resolve by matching composite release");
let dep_pkg = plan.add.iter().find(|p| p.fmri.stem() == "pkg/child").expect("child present");
let v = dep_pkg.fmri.version.as_ref().unwrap();
assert_eq!(v.release, "20");
assert_eq!(v.branch.as_deref(), Some("5.11"));
}
#[test]
fn require_20_5_11_does_not_match_candidate_5_11() {
let img = make_image_with_publishers(&[("pubA", true)]);
// Only candidate for stem is 5.11
let only = mk_fmri("pubA", "pkg/alpha", mk_version("5.11", None, Some("20200101T000000Z")));
write_manifest_to_catalog(&img, &only, &Manifest::new());
// Top-level constraint requires composite 20,5.11
let c = Constraint { stem: "pkg/alpha".to_string(), version_req: Some("20,5.11".to_string()), preferred_publishers: vec![], branch: None };
let err = resolve_install(&img, &[c]).err().expect("expected unsatisfiable");
assert!(err.message.contains("No candidates") || err.message.contains("dependency solving failed"));
}
}

View file

@ -1,5 +1,7 @@
use libips::fmri::FmriError; use libips::fmri::FmriError;
use libips::image::ImageError; use libips::image::ImageError;
use libips::solver::SolverError;
use libips::actions::executors::InstallerError as LibInstallerError;
use miette::Diagnostic; use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
@ -37,6 +39,20 @@ pub enum Pkg6Error {
)] )]
ImageError(#[from] ImageError), ImageError(#[from] ImageError),
#[error("Solver error: {0}")]
#[diagnostic(
code(pkg6::solver_error),
help("Resolve constraints or check catalogs; try 'pkg6 refresh' then retry.")
)]
Solver(#[from] SolverError),
#[error("Installer error: {0}")]
#[diagnostic(
code(pkg6::installer_error),
help("See details; you can retry with --dry-run for diagnostics")
)]
Installer(#[from] LibInstallerError),
#[error("logging environment setup error: {0}")] #[error("logging environment setup error: {0}")]
#[diagnostic( #[diagnostic(
code(pkg6::logging_env_error), code(pkg6::logging_env_error),

View file

@ -568,8 +568,97 @@ fn main() -> Result<()> {
debug!("Show licenses: {}", licenses); debug!("Show licenses: {}", licenses);
debug!("No index update: {}", no_index); debug!("No index update: {}", no_index);
debug!("No refresh: {}", no_refresh); debug!("No refresh: {}", no_refresh);
// Stub implementation // Determine the image path using the -R argument or default rules
let image_path = determine_image_path(cli.image_path.clone());
if !quiet { println!("Using image at: {}", image_path.display()); }
// Load the image
let image = match libips::image::Image::load(&image_path) {
Ok(img) => img,
Err(e) => {
error!("Failed to load image from {}: {}", image_path.display(), e);
return Err(e.into());
}
};
// Note: Install now relies on existing redb databases and does not perform
// a full import or refresh automatically. Run `pkg6 refresh` explicitly
// to update catalogs before installing if needed.
if !*quiet {
eprintln!("Install uses existing catalogs in redb; run 'pkg6 refresh' to update catalogs if needed.");
}
// Build solver constraints from the provided pkg specs
if pkg_fmri_patterns.is_empty() {
if !quiet { eprintln!("No packages specified to install"); }
return Err(Pkg6Error::Other("no packages specified".to_string()));
}
let mut constraints: Vec<libips::solver::Constraint> = Vec::new();
for spec in pkg_fmri_patterns {
let mut preferred_publishers: Vec<String> = Vec::new();
let mut name_part = spec.as_str();
// parse optional publisher prefix pkg://<pub>/
if let Some(rest) = name_part.strip_prefix("pkg://") {
if let Some((pubr, rest2)) = rest.split_once('/') {
preferred_publishers.push(pubr.to_string());
name_part = rest2;
}
}
// split version requirement after '@'
let (stem, version_req) = if let Some((s, v)) = name_part.split_once('@') {
(s.to_string(), Some(v.to_string()))
} else {
(name_part.to_string(), None)
};
constraints.push(libips::solver::Constraint { stem, version_req, preferred_publishers, branch: None });
}
// Resolve install plan
let plan = match libips::solver::resolve_install(&image, &constraints) {
Ok(p) => p,
Err(e) => {
error!("Failed to resolve install plan: {}", e);
return Err(e.into());
}
};
if !quiet { println!("Resolved {} package(s) to install", plan.add.len()); }
// Build and apply action plan
let ap = libips::image::action_plan::ActionPlan::from_install_plan(&plan);
let apply_opts = libips::actions::executors::ApplyOptions { dry_run: *dry_run };
if !quiet { println!("Applying action plan (dry-run: {})", dry_run); }
ap.apply(image.path(), &apply_opts)?;
// Update installed DB after success (skip on dry-run)
if !*dry_run {
for rp in &plan.add {
image.install_package(&rp.fmri, &rp.manifest)?;
// Save full manifest into manifests directory for reproducibility
match image.save_manifest(&rp.fmri, &rp.manifest) {
Ok(path) => {
if *verbose && !*quiet {
eprintln!("Saved manifest for {} to {}", rp.fmri, path.display());
}
}
Err(e) => {
// Non-fatal: log error but continue install
error!("Failed to save manifest for {}: {}", rp.fmri, e);
}
}
}
if !quiet { println!("Installed {} package(s)", plan.add.len()); }
// Dump installed database to make changes visible
let installed = libips::image::installed::InstalledPackages::new(image.installed_db_path());
if let Err(e) = installed.dump_installed_table() {
error!("Failed to dump installed database: {}", e);
}
} else if !quiet {
println!("Dry-run completed: {} package(s) would be installed", plan.add.len());
}
info!("Installation completed successfully"); info!("Installation completed successfully");
Ok(()) Ok(())
}, },

87
run_sample_install.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Run a sample installation into sample_data/test-image with dry-run and real run.
#
# This script will:
# 1) Build the pkg6 CLI
# 2) Create or reset a test image at sample_data/test-image
# 3) Configure the openindiana.org publisher
# - If sample_data/pkg6-repo exists, use it via file:// origin
# - Otherwise, use the OpenIndiana network origin (requires internet)
# 4) Refresh catalogs for the image
# 5) Install a package first with dry-run, then for real
#
# Usage:
# ./run_sample_install.sh [PKG_NAME]
# Environment variables:
# PKG_NAME Package stem/FMRI pattern to install (default: database/postgres/connector/jdbc)
# RUST_LOG Rust log level (default: info)
#
# Notes:
# - The current installer writes empty files as payloads (scaffold). It does create dirs/links.
# - All file system operations are performed relative to the image root (sample_data/test-image).
# - If you need to seed a local sample repo, see: ./run_local_import_test.sh
set -euo pipefail
set -x
export RUST_LOG="${RUST_LOG:-info}"
IMG_PATH="sample_data/test-image"
PUBLISHER="openindiana.org"
LOCAL_REPO_DIR="sample_data/pkg6-repo"
PKG6_BIN="./target/debug/pkg6"
# Package to install
PKG_NAME="${1:-${PKG_NAME:-database/postgres/connector/jdbc}}"
# Determine origin: use local file repo if present, otherwise network origin
if [ -d "$LOCAL_REPO_DIR" ]; then
ORIGIN="file://$(pwd)/$LOCAL_REPO_DIR"
else
ORIGIN="https://pkg.openindiana.org/hipster"
fi
echo "Using origin: $ORIGIN"
echo "Building pkg6 (debug)"
cargo build -p pkg6
# Prepare image path
mkdir -p "$(dirname "$IMG_PATH")"
if [ -d "$IMG_PATH" ]; then
rm -rf "$IMG_PATH"
fi
# 1) Create image and add publisher
"$PKG6_BIN" image-create \
-F "$IMG_PATH" \
-p "$PUBLISHER" \
-g "$ORIGIN"
# 2) Refresh catalogs (also downloads per-publisher catalogs)
"$PKG6_BIN" -R "$IMG_PATH" refresh "$PUBLISHER"
# 3) Show publishers for confirmation (table output)
"$PKG6_BIN" -R "$IMG_PATH" publisher -o table
# 4) Dry-run install
# clap short flag for --dry-run is -d in this CLI
"$PKG6_BIN" -R "$IMG_PATH" install -d "pkg://$PUBLISHER/$PKG_NAME" || {
echo "Dry-run install failed" >&2
exit 1
}
# 5) Real install
"$PKG6_BIN" -R "$IMG_PATH" install "pkg://$PUBLISHER/$PKG_NAME" || {
echo "Real install failed" >&2
exit 1
}
# 6) Show installed packages
"$PKG6_BIN" -R "$IMG_PATH" list
# 7) Dump installed database
"$PKG6_BIN" -R "$IMG_PATH" debug-db --dump-table installed
echo "Sample installation completed successfully at $IMG_PATH"