mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 21:30:41 +00:00
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:
parent
d483e2a995
commit
a2645749b1
11 changed files with 1072 additions and 45 deletions
283
libips/src/actions/executors.rs
Normal file
283
libips/src/actions/executors.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
65
libips/src/image/action_plan.rs
Normal file
65
libips/src/image/action_plan.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
87
run_sample_install.sh
Executable 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"
|
||||||
Loading…
Add table
Reference in a new issue