From 98e75f131123d6fcd11fcbb91c7e06932f680fcb Mon Sep 17 00:00:00 2001 From: xtex Date: Thu, 23 Jan 2025 20:47:29 +0800 Subject: [PATCH] feat: initialize ciel crate and basic workspace functions --- Cargo.lock | 212 ++++++- Cargo.toml | 22 +- ciel/Cargo.toml | 20 + ciel/src/lib.rs | 135 ++++ ciel/src/workspace.rs | 575 ++++++++++++++++++ .../broken-workspace/.ciel/data/config.toml | 10 + ciel/testdata/broken-workspace/.ciel/version | 1 + .../.ciel/data/config.toml | 10 + .../incompat-ws-version/.ciel/version | 1 + .../.ciel/container/dist/.gitkeep | 0 .../instances/test/layers/diff/.gitkeep | 0 .../test/layers/local/etc/acbs/forest.conf | 2 + .../test/layers/local/etc/apt/sources.list | 1 + .../test/layers/local/etc/autobuild/ab4cfg.sh | 5 + .../layers/local/etc/systemd/resolved.conf | 2 + .../old-workspace/.ciel/data/config.toml | 10 + ciel/testdata/old-workspace/.ciel/version | 1 + .../.ciel/container/dist/.gitkeep | 0 .../instances/test/layers/diff/.gitkeep | 0 .../instances/test/layers/local/.gitkeep | 0 ciel/testdata/v2-workspace/.ciel/version | 1 + 21 files changed, 989 insertions(+), 19 deletions(-) create mode 100644 ciel/Cargo.toml create mode 100644 ciel/src/lib.rs create mode 100644 ciel/src/workspace.rs create mode 100644 ciel/testdata/broken-workspace/.ciel/data/config.toml create mode 100644 ciel/testdata/broken-workspace/.ciel/version create mode 100644 ciel/testdata/incompat-ws-version/.ciel/data/config.toml create mode 100644 ciel/testdata/incompat-ws-version/.ciel/version create mode 100644 ciel/testdata/old-workspace/.ciel/container/dist/.gitkeep create mode 100644 ciel/testdata/old-workspace/.ciel/container/instances/test/layers/diff/.gitkeep create mode 100644 ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/acbs/forest.conf create mode 100644 ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/apt/sources.list create mode 100644 ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/autobuild/ab4cfg.sh create mode 100644 ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/systemd/resolved.conf create mode 100644 ciel/testdata/old-workspace/.ciel/data/config.toml create mode 100644 ciel/testdata/old-workspace/.ciel/version create mode 100644 ciel/testdata/v2-workspace/.ciel/container/dist/.gitkeep create mode 100644 ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/diff/.gitkeep create mode 100644 ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/local/.gitkeep create mode 100644 ciel/testdata/v2-workspace/.ciel/version diff --git a/Cargo.lock b/Cargo.lock index 87cb6fc..79bb32a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.18" @@ -344,9 +353,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciel" +version = "4.0.0" +dependencies = [ + "libmount", + "log", + "nix 0.29.0", + "rand", + "serde", + "tempfile", + "test-log", + "thiserror 2.0.11", + "toml", + "walkdir", +] + [[package]] name = "ciel-rs" -version = "3.9.1" +version = "4.0.0" dependencies = [ "adler32", "anyhow", @@ -630,6 +655,27 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", +] + +[[package]] +name = "env_logger" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1356,9 +1402,9 @@ checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] name = "log" -version = "0.4.22" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "lzma-sys" @@ -1371,6 +1417,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1453,6 +1508,16 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1534,6 +1599,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking" version = "2.2.1" @@ -1713,6 +1784,50 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + [[package]] name = "reqwest" version = "0.12.9" @@ -1904,18 +2019,18 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ "proc-macro2", "quote", @@ -1977,6 +2092,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.0" @@ -2127,12 +2251,13 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.14.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", + "getrandom", "once_cell", "rustix", "windows-sys 0.59.0", @@ -2148,6 +2273,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "test-log" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f46083d221181166e5b6f6b1e5f1d499f3a76888826e6cb1d057554157cd0f" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888d0c3c6db53c0fdab160d2ed5e12ba745383d3e85813f2ea0f2b1475ab553f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2159,11 +2306,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.7" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" dependencies = [ - "thiserror-impl 2.0.7", + "thiserror-impl 2.0.11", ] [[package]] @@ -2179,9 +2326,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.7" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", @@ -2356,6 +2503,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2406,7 +2582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c233ab927e810aac155e6a22ecc44a6aaf8d66682bf7c287c80c68e2680110c9" dependencies = [ "pty-process", - "thiserror 2.0.7", + "thiserror 2.0.11", "which", ] @@ -2445,6 +2621,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index a58a244..44b57c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,27 @@ -[package] -name = "ciel-rs" -version = "3.9.1" +[workspace] +resolver = "2" +members = [ + "ciel/", + ".", "ciel", +] + +[workspace.package] +version = "4.0.0" description = "An nspawn container manager" license = "MIT" authors = ["liushuyu "] repository = "https://github.com/AOSC-Dev/ciel-rs" -resolver = "2" edition = "2021" +[package] +name = "ciel-rs" +version.workspace = true +description.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +edition.workspace = true + [dependencies] console = "0.15" zbus = "^5" diff --git a/ciel/Cargo.toml b/ciel/Cargo.toml new file mode 100644 index 0000000..cc0e15f --- /dev/null +++ b/ciel/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ciel" +version.workspace = true +description.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +edition.workspace = true + +[dependencies] +log = "0.4.25" +thiserror = "2.0.11" +libmount = { git = "https://github.com/liushuyu/libmount", rev = "6fe8dba03a6404dfe1013995dd17af1c4e21c97b" } +nix = { version = "0.29.0", features = ["fs", "hostname", "mount", "signal", "user"] } +walkdir = "2.5.0" +tempfile = "3.15.0" +rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } +toml = "0.8.19" +serde = { version = "1.0.217", features = ["derive"] } +test-log = { version = "0.2.17", features = ["log"] } diff --git a/ciel/src/lib.rs b/ciel/src/lib.rs new file mode 100644 index 0000000..70a65b3 --- /dev/null +++ b/ciel/src/lib.rs @@ -0,0 +1,135 @@ +//! Ciel (/sjɛl/) 3 is an integrated packaging environment for AOSC OS. +//! +//! Ciel uses `systemd-nspawn` as container backend and `overlay` file system +//! for layered filesystem. + +pub mod workspace; + +pub use workspace::{Workspace, WorkspaceConfig}; + +pub type Result = std::result::Result; + +/// An error produced by Ciel. +#[non_exhaustive] +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + #[error("Some Mutex/RwLock are poisoned")] + PoisonError, + #[error("Unable to parse mountinfo file: {0}")] + MountInfoParseError(#[from] libmount::mountinfo::ParseError), + #[error("Mount error: {0}")] + MountError(String), + #[error(transparent)] + SyscallError(#[from] nix::Error), + + #[error("Configuration file is not found at {0}")] + ConfigNotFound(std::path::PathBuf), + #[error("Invalid TOML: {0}")] + InvalidToml(#[from] toml::de::Error), + #[error("Unable to serialize into TOML: {0}")] + TomlSerializerError(#[from] toml::ser::Error), + #[error("Invalid maintainer information")] + InvalidMaintainerInfo, + #[error("Maintainer name is required")] + MaintainerNameNeeded, + + #[error("Not a Ciel workspace (.ciel directory does not exist)")] + NotAWorkspace, + #[error("A Ciel workspace is already initialized")] + WorkspaceAlreadyExists, + #[error("Ciel workspace is broken")] + BrokenWorkspace, + #[error("Unsupported workspace version: got {0}")] + UnsupportedWorkspaceVersion(usize), +} + +impl From> for Error { + fn from(_: std::sync::PoisonError) -> Self { + Self::PoisonError + } +} + +impl From for Error { + fn from(err: libmount::Error) -> Self { + // discard details so that Error can be converted into anyhow::Error simply + Self::MountError(format!("{:?}", err)) + } +} + +#[cfg(test)] +pub(crate) mod test { + use std::{fs, path::Path}; + + use tempfile::TempDir; + + use crate::{ + workspace::{Workspace, WorkspaceConfig}, + Result, + }; + + pub fn is_root() -> bool { + nix::unistd::geteuid().is_root() + } + + #[derive(Debug)] + pub struct TestDir(TempDir); + + impl AsRef for TestDir { + fn as_ref(&self) -> &Path { + self.0.path() + } + } + + impl From for TestDir { + fn from(value: TempDir) -> Self { + Self(value) + } + } + + fn copy_file(from: &Path, to: &Path) { + assert!(from.exists()); + if from.is_symlink() { + std::os::unix::fs::symlink(fs::read_link(from).unwrap(), to).unwrap(); + } else if from.is_file() { + fs::copy(from, to).unwrap(); + } else if from.is_dir() { + fs::create_dir_all(to).unwrap(); + fs::set_permissions(to, from.metadata().unwrap().permissions()).unwrap(); + for entry in fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + copy_file(&from.join(entry.file_name()), &to.join(entry.file_name())); + } + } else { + panic!("unsupported file type"); + } + } + + impl TestDir { + pub fn new() -> Self { + let dir = TempDir::with_prefix("ciel-").unwrap(); + println!("test data: {:?}", dir.path()); + dir.into() + } + + pub fn from(template: &str) -> Self { + let dir = Self::new(); + println!("copying test data: {} -> {:?}", template, dir.path()); + copy_file(&Path::new("testdata").join(template), dir.path()); + dir + } + + pub fn path(&self) -> &Path { + self.0.path() + } + + pub fn workspace(&self) -> Result { + Workspace::new(self.path()) + } + + pub fn init_workspace(&self, config: WorkspaceConfig) -> Result { + Workspace::init(self.path(), config) + } + } +} diff --git a/ciel/src/workspace.rs b/ciel/src/workspace.rs new file mode 100644 index 0000000..b902edf --- /dev/null +++ b/ciel/src/workspace.rs @@ -0,0 +1,575 @@ +use std::{ + fmt::Debug, + fs, + path::{Path, PathBuf}, + sync::Arc, + sync::RwLock, +}; + +use log::info; +use rand::Rng; +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// A Ciel workspace. +/// +/// A workspace is a directory containing the following things: +/// - A workspace configuration (`.ciel/data/config.toml`) +/// - A base system for all build containers (`.ciel/container/dist`) +/// - Some instances ([Instance]) +/// - (optional) Some OUTPUT directories for output deb files. +/// - (optional) A CACHE directory for caching source tarballs. +/// - (optional) A TREE directory for the default abbs tree. +/// +/// Workspaces may have their base system loaded or unloaded +/// (i.e. there is no base system) +/// +/// ```rust,no_run +/// use ciel::Workspace; +/// +/// let workspace = Workspace::current_dir().unwrap(); +/// ``` +#[derive(Clone)] +pub struct Workspace { + path: Arc, + config: Arc>, +} + +impl Workspace { + /// The current version of workspace format. + pub const CURRENT_VERSION: usize = 3; + + pub(crate) const CIEL_DIR: &str = ".ciel"; + pub(crate) const DATA_DIR: &str = ".ciel/data"; + pub(crate) const VERSION_PATH: &str = ".ciel/version"; + pub(crate) const DIST_DIR: &str = ".ciel/container/dist"; + pub(crate) const INSTANCES_DIR: &str = ".ciel/container/instances"; + + /// Begins an existing workspace at the given path. + /// + /// This does not initialize a new workspace if not. + /// To start a fully new workspace, see [Self::init]. + /// + /// If the workspace is a legacy workspace (version 2), a default + /// workspace configuration will be saved and the workspace will be + /// upgraded to the current version. + pub fn new>(path: P) -> Result { + let path = path.as_ref(); + + if !path.join(Self::CIEL_DIR).is_dir() { + return Err(Error::BrokenWorkspace); + } + if !path.join(Self::VERSION_PATH).is_file() { + return Err(Error::BrokenWorkspace); + } + + let version = fs::read_to_string(path.join(".ciel/version"))? + .trim() + .parse::() + .map_err(|_| Error::NotAWorkspace)?; + match version { + Self::CURRENT_VERSION => {} + 2 => { + fs::create_dir_all(path.join(Self::DATA_DIR))?; + fs::write( + path.join(WorkspaceConfig::PATH), + WorkspaceConfig::default().serialize()?, + )?; + fs::write( + path.join(Self::VERSION_PATH), + Self::CURRENT_VERSION.to_string(), + )?; + } + _ => return Err(Error::UnsupportedWorkspaceVersion(version)), + } + + for dir in [Self::DATA_DIR, Self::DIST_DIR, Self::INSTANCES_DIR] { + if !path.join(dir).is_dir() { + return Err(Error::BrokenWorkspace); + } + } + for dir in [WorkspaceConfig::PATH] { + if !path.join(dir).is_file() { + return Err(Error::BrokenWorkspace); + } + } + + let config = WorkspaceConfig::load(path.join(WorkspaceConfig::PATH))?; + + Ok(Self { + path: Arc::new(path.into()), + config: Arc::new(config.into()), + }) + } + + /// Begins an existing workspace at the current directory. + /// + /// This is equivalent to `Workspace::new(std::env::current_dir()?)`. + pub fn current_dir() -> Result { + Self::new(std::env::current_dir()?) + } + + /// Initializes a fully new workspace at the given directory, + /// with the given configuration. + /// + /// The newly initialized workspace has its base system unloaded. + /// To load a base system, extract files into [Self::system_rootfs]. + pub fn init>(path: P, config: WorkspaceConfig) -> Result { + let path = path.as_ref(); + + if path.join(".ciel").exists() { + return Err(Error::WorkspaceAlreadyExists); + } + + info!("Initializing new CIEL! workspace at {:?}", path); + + fs::create_dir_all(path.join(Self::CIEL_DIR))?; + fs::create_dir_all(path.join(Self::DATA_DIR))?; + fs::create_dir_all(path.join(Self::DIST_DIR))?; + fs::create_dir_all(path.join(Self::INSTANCES_DIR))?; + fs::write( + path.join(Self::VERSION_PATH), + Self::CURRENT_VERSION.to_string(), + )?; + fs::write(path.join(WorkspaceConfig::PATH), config.serialize()?)?; + + Ok(Self { + path: Arc::new(path.into()), + config: Arc::new(config.into()), + }) + } + + /// Gets the directory, at which this workspace is placed, as [Path]. + pub fn directory(&self) -> &Path { + &self.path + } + + /// Gets the workspace configuration. + pub fn config(&self) -> WorkspaceConfig { + self.config.read().unwrap().to_owned() + } + + /// Modifies the workspace configuration after validation. + pub fn set_config(&self, config: WorkspaceConfig) -> Result<()> { + config.validate()?; + fs::write( + self.directory().join(WorkspaceConfig::PATH), + config.serialize()?, + )?; + *self.config.write()? = config; + Ok(()) + } + + /// Returns the rootfs path of the base system. + pub fn system_rootfs(&self) -> PathBuf { + self.directory().join(Self::DIST_DIR) + } + + /// Returns if the base system has been loaded. + pub fn is_system_loaded(&self) -> bool { + self.system_rootfs() + .read_dir() + .map(|mut r| r.next().is_some()) + .unwrap_or_default() + } +} + +impl Debug for Workspace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("CIEL workspace `{:?}`", self.directory())) + } +} + +impl TryFrom<&Path> for Workspace { + type Error = crate::Error; + + fn try_from(value: &Path) -> std::result::Result { + Self::new(value) + } +} + +impl From for PathBuf { + fn from(value: Workspace) -> Self { + value.directory().to_owned() + } +} + +impl PartialEq for Workspace { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +/// A Ciel workspace configuration. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct WorkspaceConfig { + version: usize, + /// The maintainer information, for example, `Bot ` + pub maintainer: String, + /// Whether DNSSEC should be allowed in containers. + #[serde(default)] + pub dnssec: bool, + + // The old version of ciel-rs uses `apt_sources`, which is kept for compatibility. + // This is converted into [extra_apt_repos] when loaded. + #[serde(alias = "apt_sources", default)] + apt_sources: Option, + /// Extra APT repositories to use. + #[serde(default)] + pub apt_repos: Vec, + /// Whether local repository (the output directory) should be enabled in containers. + #[serde(alias = "local_repo", default)] + pub use_local_repo: bool, + /// Whether output directories should be branch-exclusive . + /// + /// This means using `OUTPUT-(branch)` instead of `OUTPUT` for outputs. + #[serde(default)] + pub branch_exclusive_output: bool, + + /// Whether to cache APT packages. + #[serde(default)] + pub no_cache_packages: bool, + /// Whether to cache sources. + #[serde(alias = "local_sources", default)] + pub cache_sources: bool, + + /// Extra options for systemd-nspawn + #[serde(alias = "nspawn-extra-options", default)] + pub extra_nspawn_options: Vec, + + /// Whether to mount the container filesystem as volatile + #[serde(default)] + pub volatile_mount: bool, + + /// Whether to use APT instead of oma. + /// + /// This is enabled by default on RISC-V hosts, because oma may run into + /// random lock-ups on RISC-V. + #[serde(alias = "force_use_apt", default = "WorkspaceConfig::default_use_apt")] + pub use_apt: bool, +} + +impl WorkspaceConfig { + const fn default_use_apt() -> bool { + cfg!(target_arch = "riscv64") + } +} + +impl Default for WorkspaceConfig { + fn default() -> Self { + Self { + version: Self::CURRENT_VERSION, + maintainer: "Bot ".to_string(), + dnssec: false, + apt_sources: None, + apt_repos: vec!["deb https://repo.aosc.io/debs/ stable main".to_string()], + use_local_repo: true, + branch_exclusive_output: true, + no_cache_packages: false, + cache_sources: true, + extra_nspawn_options: vec![], + volatile_mount: false, + use_apt: Self::default_use_apt(), + } + } +} + +impl WorkspaceConfig { + /// The default path for workspace configuration. + pub const PATH: &str = ".ciel/data/config.toml"; + + /// The current version of workspace configuration format. + pub const CURRENT_VERSION: usize = 3; + + /// Loads a workspace configuration from a given file path. + pub fn load>(path: P) -> Result { + let path = path.as_ref().to_path_buf(); + if path.exists() { + fs::read_to_string(&path)?.as_str().try_into() + } else { + Err(Error::ConfigNotFound(path)) + } + } + + /// Validate the configuration. + /// + /// This checks: + /// - Invalid maintainer string + pub fn validate(&self) -> Result<()> { + Self::validate_maintainer(&self.maintainer)?; + Ok(()) + } + + /// Validates a maintainer information string. + /// + /// This ensures the string has a valid maintainer name and email address. + pub fn validate_maintainer(maintainer: &str) -> Result<()> { + let mut lt = false; // "<" + let mut gt = false; // ">" + let mut at = false; // "@" + let mut name = false; + let mut nbsp = false; // space + // A simple FSM to match the states + for c in maintainer.as_bytes() { + match *c { + b'<' => { + if !nbsp { + return Err(Error::MaintainerNameNeeded); + } + lt = true; + } + b'>' => { + if !lt { + return Err(Error::InvalidMaintainerInfo); + } + gt = true; + } + b'@' => { + if !lt || gt { + return Err(Error::InvalidMaintainerInfo); + } + at = true; + } + b' ' | b'\t' => { + if !name { + return Err(Error::MaintainerNameNeeded); + } + nbsp = true; + } + _ => { + if !nbsp { + name = true; + continue; + } + } + } + } + + if name && gt && lt && at { + return Ok(()); + } + + Err(Error::InvalidMaintainerInfo) + } + + /// Deserializes a workspace configuration TOML. + pub fn parse(config: &str) -> Result { + let mut config = toml::from_str::(config)?; + + // Convert old `apt_sources` into `extra_apt_repos` + if let Some(sources) = config.apt_sources.take() { + config.apt_repos.extend( + sources + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()), + ); + } + + Ok(config) + } + + /// Serializes a workspace configuration into TOML. + pub fn serialize(&self) -> Result { + Ok(toml::to_string_pretty(&self)?) + } +} + +impl TryFrom<&str> for WorkspaceConfig { + type Error = crate::Error; + + fn try_from(value: &str) -> std::result::Result { + Self::parse(value) + } +} + +impl TryFrom<&WorkspaceConfig> for String { + type Error = crate::Error; + + fn try_from(value: &WorkspaceConfig) -> std::result::Result { + value.serialize() + } +} + +#[cfg(test)] +mod test { + use std::fs; + use test_log::test; + + use crate::{test::TestDir, Error}; + + use super::WorkspaceConfig; + + #[test] + fn test_config() { + let config = WorkspaceConfig::default(); + let serialized = config.serialize().unwrap(); + assert_eq!( + serialized, + r##"version = 3 +maintainer = "Bot " +dnssec = false +apt-repos = ["deb https://repo.aosc.io/debs/ stable main"] +use-local-repo = true +branch-exclusive-output = true +no-cache-packages = false +cache-sources = true +extra-nspawn-options = [] +volatile-mount = false +use-apt = false +"## + ); + assert_eq!( + WorkspaceConfig::try_from(serialized.as_str()).unwrap(), + config + ); + } + + #[test] + fn test_config_migration() { + assert_eq!( + WorkspaceConfig::parse( + r##" +version = 3 +maintainer = "AOSC OS Maintainers " +dnssec = false +apt_sources = "deb https://repo.aosc.io/debs/ stable main" +local_repo = true +local_sources = true +branch-exclusive-output = true +volatile-mount = false +nspawn-extra-options = ["-E", "NO_COLOR=1"] +"##, + ) + .unwrap(), + WorkspaceConfig { + version: 3, + maintainer: "AOSC OS Maintainers ".to_string(), + dnssec: false, + apt_sources: None, + apt_repos: vec!["deb https://repo.aosc.io/debs/ stable main".to_string(),], + use_local_repo: true, + branch_exclusive_output: true, + cache_sources: true, + extra_nspawn_options: vec!["-E".to_string(), "NO_COLOR=1".to_string()], + volatile_mount: false, + use_apt: false, + ..Default::default() + } + ); + + assert_eq!( + WorkspaceConfig::parse( + r##" +version = 3 +maintainer = "AOSC OS Maintainers " +dnssec = false +apt_sources = "deb https://repo.aosc.io/debs/ stable main\ndeb file:///test/ test test" +local_repo = true +local_sources = true +nspawn-extra-options = [] +branch-exclusive-output = true +volatile-mount = false +"##, + ) + .unwrap(), + WorkspaceConfig { + version: 3, + maintainer: "AOSC OS Maintainers ".to_string(), + dnssec: false, + apt_sources: None, + apt_repos: vec![ + "deb https://repo.aosc.io/debs/ stable main".to_string(), + "deb file:///test/ test test".to_string() + ], + use_local_repo: true, + branch_exclusive_output: true, + cache_sources: true, + extra_nspawn_options: vec![], + volatile_mount: false, + use_apt: false, + ..Default::default() + } + ); + } + + #[test] + fn test_validate_maintainer() { + assert!(matches!( + WorkspaceConfig::validate_maintainer("test "), + Ok(()) + )); + assert!(matches!( + WorkspaceConfig::validate_maintainer("test "), + Err(Error::MaintainerNameNeeded) + )); + assert!(matches!( + WorkspaceConfig::validate_maintainer(" "), + Err(Error::MaintainerNameNeeded) + )); + } + + #[test] + fn test_workspace_init() { + let testdir = TestDir::new(); + let ws = testdir.init_workspace(WorkspaceConfig::default()).unwrap(); + dbg!(&ws); + assert!(!ws.is_system_loaded()); + assert!(ws.config().apt_repos.len() == 1); + fs::write(ws.directory().join(".ciel/container/dist/init"), "").unwrap(); + let ws = testdir.workspace().unwrap(); + dbg!(&ws); + assert!(ws.is_system_loaded()); + // assert!(ws.instances().unwrap().is_empty()); + } + + #[test] + fn test_workspace_migration_v3() { + // migration from Ciel <= 3.6.0 + let testdir = TestDir::from("old-workspace"); + let ws = testdir.workspace().unwrap(); + dbg!(&ws); + assert!(ws.is_system_loaded()); + assert_eq!( + ws.config().apt_repos, + vec![ + "deb https://repo.aosc.io/debs/ stable main".to_string(), + "deb file:///test/ test test".to_string(), + ] + ); + assert!(ws.config().branch_exclusive_output); + } + + #[test] + fn test_workspace_migration_v2() { + // migration from Ciel 2.x.x + let testdir = TestDir::from("v2-workspace"); + let ws = testdir.workspace().unwrap(); + dbg!(&ws); + assert!(ws.is_system_loaded()); + assert!(ws.config().apt_repos.len() == 1); + assert!(ws.config().branch_exclusive_output); + } + + #[test] + fn test_incompatible_workspace() { + let testdir = TestDir::from("incompat-ws-version"); + assert!(matches!( + testdir.workspace(), + Err(Error::UnsupportedWorkspaceVersion(0)) + )); + } + + #[test] + fn test_broken_workspace() { + let testdir = TestDir::from("broken-workspace"); + assert!(matches!(testdir.workspace(), Err(Error::BrokenWorkspace))); + } +} diff --git a/ciel/testdata/broken-workspace/.ciel/data/config.toml b/ciel/testdata/broken-workspace/.ciel/data/config.toml new file mode 100644 index 0000000..4e55f73 --- /dev/null +++ b/ciel/testdata/broken-workspace/.ciel/data/config.toml @@ -0,0 +1,10 @@ +version = 3 +maintainer = "Bot " +dnssec = false +apt_sources = "deb https://repo.aosc.io/debs/ stable main" +local_repo = true +local_sources = true +nspawn-extra-options = [] +branch-exclusive-output = true +volatile-mount = false +force_use_apt = false diff --git a/ciel/testdata/broken-workspace/.ciel/version b/ciel/testdata/broken-workspace/.ciel/version new file mode 100644 index 0000000..e440e5c --- /dev/null +++ b/ciel/testdata/broken-workspace/.ciel/version @@ -0,0 +1 @@ +3 \ No newline at end of file diff --git a/ciel/testdata/incompat-ws-version/.ciel/data/config.toml b/ciel/testdata/incompat-ws-version/.ciel/data/config.toml new file mode 100644 index 0000000..4e55f73 --- /dev/null +++ b/ciel/testdata/incompat-ws-version/.ciel/data/config.toml @@ -0,0 +1,10 @@ +version = 3 +maintainer = "Bot " +dnssec = false +apt_sources = "deb https://repo.aosc.io/debs/ stable main" +local_repo = true +local_sources = true +nspawn-extra-options = [] +branch-exclusive-output = true +volatile-mount = false +force_use_apt = false diff --git a/ciel/testdata/incompat-ws-version/.ciel/version b/ciel/testdata/incompat-ws-version/.ciel/version new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/ciel/testdata/incompat-ws-version/.ciel/version @@ -0,0 +1 @@ +0 \ No newline at end of file diff --git a/ciel/testdata/old-workspace/.ciel/container/dist/.gitkeep b/ciel/testdata/old-workspace/.ciel/container/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/diff/.gitkeep b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/diff/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/acbs/forest.conf b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/acbs/forest.conf new file mode 100644 index 0000000..4cd6827 --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/acbs/forest.conf @@ -0,0 +1,2 @@ +[default] +location = /tree/ diff --git a/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/apt/sources.list b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/apt/sources.list new file mode 100644 index 0000000..fe70b97 --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/apt/sources.list @@ -0,0 +1 @@ +deb https://repo.aosc.io/debs/ stable main \ No newline at end of file diff --git a/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/autobuild/ab4cfg.sh b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/autobuild/ab4cfg.sh new file mode 100644 index 0000000..7541c4d --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/autobuild/ab4cfg.sh @@ -0,0 +1,5 @@ +#!/bin/bash +ABMPM=dpkg +ABAPMS= +ABINSTALL=dpkg +MTER="Bot " \ No newline at end of file diff --git a/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/systemd/resolved.conf b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/systemd/resolved.conf new file mode 100644 index 0000000..d43d54a --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/container/instances/test/layers/local/etc/systemd/resolved.conf @@ -0,0 +1,2 @@ +[Resolve] +DNSSEC=no diff --git a/ciel/testdata/old-workspace/.ciel/data/config.toml b/ciel/testdata/old-workspace/.ciel/data/config.toml new file mode 100644 index 0000000..9c4a314 --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/data/config.toml @@ -0,0 +1,10 @@ +version = 3 +maintainer = "Bot " +dnssec = false +apt_sources = "deb https://repo.aosc.io/debs/ stable main\ndeb file:///test/ test test" +local_repo = true +local_sources = true +nspawn-extra-options = [] +branch-exclusive-output = true +volatile-mount = false +force_use_apt = false diff --git a/ciel/testdata/old-workspace/.ciel/version b/ciel/testdata/old-workspace/.ciel/version new file mode 100644 index 0000000..e440e5c --- /dev/null +++ b/ciel/testdata/old-workspace/.ciel/version @@ -0,0 +1 @@ +3 \ No newline at end of file diff --git a/ciel/testdata/v2-workspace/.ciel/container/dist/.gitkeep b/ciel/testdata/v2-workspace/.ciel/container/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/diff/.gitkeep b/ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/diff/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/local/.gitkeep b/ciel/testdata/v2-workspace/.ciel/container/instances/test/layers/local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ciel/testdata/v2-workspace/.ciel/version b/ciel/testdata/v2-workspace/.ciel/version new file mode 100644 index 0000000..d8263ee --- /dev/null +++ b/ciel/testdata/v2-workspace/.ciel/version @@ -0,0 +1 @@ +2 \ No newline at end of file