This commit is contained in:
selfhoster selfhoster 2023-10-05 17:58:41 +02:00
parent 7d9e3d63b7
commit 89ef66a694
19 changed files with 461 additions and 64 deletions

142
Cargo.lock generated
View File

@ -11,6 +11,73 @@ dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "crossbeam-channel"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
dependencies = [
"cfg-if",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
dependencies = [
"cfg-if",
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46bd5f3f85273295a9d14aedfb86f6aadbff6d8f5295c4a9edb08e819dcf5695"
dependencies = [
"autocfg",
"cfg-if",
"crossbeam-utils",
"memoffset",
"scopeguard",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b"
dependencies = [
"cfg-if",
]
[[package]]
name = "ctor"
version = "0.2.0"
@ -39,6 +106,12 @@ dependencies = [
"shared_child",
]
[[package]]
name = "either"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "erased-serde"
version = "0.3.25"
@ -65,6 +138,15 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7"
dependencies = [
"libc",
]
[[package]]
name = "inventory"
version = "0.3.5"
@ -93,6 +175,25 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "memoffset"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d61c719bcfbcf5d62b3a09efa6088de8c54bc0bfcd3ea7ae39fcc186108b8de1"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "once_cell"
version = "1.17.1"
@ -127,6 +228,28 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d2df5196e37bcc87abebc0053e20787d73847bb33134a69841207dd0a47f03b"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b8f95bd6966f5c87776639160a66bd8ab9895d9d4ab01ddba9fc60661aebe8d"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-utils",
"num_cpus",
]
[[package]]
name = "regex"
version = "1.7.3"
@ -144,12 +267,25 @@ version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "ron"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300a51053b1cb55c80b7a9fde4120726ddf25ca241a1cbb926626f62fb136bff"
dependencies = [
"base64",
"bitflags",
"serde",
]
[[package]]
name = "rustible"
version = "0.1.0"
dependencies = [
"duct",
"rayon",
"regex",
"ron",
"serde",
"serde_json",
"snafu",
@ -162,6 +298,12 @@ version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.160"

View File

@ -15,6 +15,8 @@ typetag = "0.2"
# Error management
#thiserror = "1"
snafu = "0.7"
ron = "0.8"
rayon = "1.7"
[lib]
name = "rustible"

View File

@ -1 +1,3 @@
WIP ansible-like system for sysadmin in rust
TODO: finish test.sh for testing things

6
examples/facts.rs Normal file
View File

@ -0,0 +1,6 @@
use rustible::Facts;
fn main() {
let facts = Facts::new();
println!("rustible running system: {}", facts.os.family().as_str());
}

View File

@ -1,5 +1,3 @@
use rustible::Facts;
use rustible::modules::{
PlaybookRun,
package::{PackageModule as Package, PackageState, PackageError},
@ -25,9 +23,6 @@ impl From<PackageError> for PlaybookError {
}
fn main() -> Result<(), PlaybookError> {
let facts = Facts::new();
println!("rustible running system: {}", facts.os.family().as_str());
let mut playbook = PlaybookRun::new();
let pkg = Package::new()
@ -39,7 +34,7 @@ fn main() -> Result<(), PlaybookError> {
let cmd = Command::new()
.program("hello")
.arg("-g")
.arg("\"Welcome to rustible!\"")
.arg("\"Welcome to rustible!\"")
.build();
let res = playbook.run(cmd)?;

View File

@ -1,11 +1,28 @@
use rustible::Facts;
use ron::value::Value;
use serde::{Serialize, Deserialize};
use std::env::args;
use rustible::Facts;
use rustible::modules::{
Module, PlaybookRun,
package::{PackageModule as Package, PackageState, PackageError},
command::{CommandModule as Command, CommandError},
Module, PlaybookRun, ModuleSetup,
package::{PackageModule as Package, PackageState, PackageError, PackageArgs},
command::{CommandModule as Command, CommandError, CommandArgs},
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclarativePlaybook {
modules: Vec<DeclarativeModule>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclarativeModule {
name: String,
module: String,
args: Value,
}
#[derive(Clone, Debug, serde::Serialize)]
pub enum PlaybookError {
Command(CommandError),
@ -26,48 +43,35 @@ impl From<PackageError> for PlaybookError {
fn main() -> Result<(), PlaybookError> {
let facts = Facts::new();
println!("Hello, world! Running system {}", facts.os.family().as_str());
let mut playbook = PlaybookRun::new();
let cmd = Command::new().program("echo").args(&vec!("lol".to_string())).build();
let res = cmd.run()?;
println!("{}", res.success());
let cmd = Command::new().program("echo").args(&vec!("lol".to_string())).build();
playbook.run(cmd)?;
let cmd = Command::new()
.creates("/tmp/lol")
.program("touch")
.arg("/tmp/lol")
.build();
playbook.run(cmd)?;
let mut cli_args = args();
let test_playbook = cli_args.nth(1).expect("First argument should be playbook.ron");
let test_shell = cli_args.nth(0).expect("Second argument should be test.sh");
let pkg = Package::new()
.name("hello")
.state(PackageState::Present)
.build();
playbook.run(pkg)?;
let test_playbook_content = std::fs::read_to_string(&test_playbook).unwrap();
let pkg = Package::new()
.name("hello")
.state(PackageState::Absent)
.build();
playbook.run(pkg)?;
let playbook_ron: DeclarativePlaybook = ron::from_str(&test_playbook_content).unwrap();
let pkg = Package::new()
.name(vec!("hello", "sl"))
.state(PackageState::Present)
.build();
playbook.run(pkg)?;
let pkg = Package::new()
.name(&[ "hello", "sl" ])
.state(PackageState::Absent)
.build();
playbook.run(pkg)?;
playbook.print_json_pretty();
for task in playbook_ron.modules {
println!("{}", task.name);
match task.module.as_str() {
"command" => {
let args: CommandArgs = task.args.into_rust().unwrap();
let module = args.with_facts(&facts);
playbook.run(module)?;
//println!("{:?}", module);
}, "package" => {
let args: PackageArgs = task.args.into_rust().unwrap();
let module = args.with_facts(&facts);
playbook.run(module)?;
//println!("{:?}", module);
}, _ => {
unimplemented!();
}
}
}
Ok(())
}
}

View File

@ -9,11 +9,11 @@ use crate::utils::cmd::Cmd;
pub struct NoCmd;
pub struct NoCondition;
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CreatesCondition(pub PathBuf);
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RemovesCondition(pub PathBuf);
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BothConditions(pub CreatesCondition, pub RemovesCondition);
/// Build a [`CommandModule`].

View File

@ -1,6 +1,6 @@
use super::builder::{NoCondition, CreatesCondition, RemovesCondition, BothConditions};
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum SomeCondition {
#[serde(rename="creates")]
Creates(CreatesCondition),

View File

@ -12,13 +12,24 @@ use crate::{Module, ModuleSetup, Facts};
/// A copy of the argument passed to the [`CommandBuilder`],
/// as returned serialized in the task run output
#[derive(Clone, Debug, Serialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommandArgs {
#[serde(flatten)]
pub args: Cmd,
pub condition: Option<SomeCondition>,
}
impl ModuleSetup<CommandModule, CommandArgs, CommandStatus, CommandError> for CommandArgs {
fn with_facts(self, _facts: &Facts) -> CommandModule {
CommandModule {
cmd: self.args,
condition: self.condition,
}
}
}
#[derive(Debug)]
pub struct CommandModule {
cmd: Cmd,
condition: Option<SomeCondition>,

View File

@ -1,6 +1,8 @@
use rayon::prelude::*;
use crate::utils::cmd::{Cmd, CmdOutput};
use super::{PackageError, PackageList, PackageManager};
use super::{PackageError, PackageList, PackageManager, IntoPackageList};
/// This structure is mostly used as an indirection for testing purposes.
/// But you can use it to build your own apt commands.
@ -40,6 +42,14 @@ impl AptCmd {
#[derive(Debug)]
pub struct AptManager;
impl AptManager {
fn is_installed_list(&self, list: &PackageList) -> Result<bool, PackageError> {
let res = Cmd::new("dpkg").arg("-s").args(list.list()).run()?;
Ok(res.success)
}
}
impl PackageManager for AptManager {
fn name(&self) -> &'static str {
"apt"
@ -54,7 +64,26 @@ impl PackageManager for AptManager {
Ok(())
}
fn is_installed(&self, pkg: &str) -> Result<bool, PackageError> {
let res = Cmd::new("dpkg").arg("-s").arg(pkg).run()?;
Ok(res.success)
}
fn install(&self, list: PackageList) -> Result<(), PackageError> {
// Filter out already installed packages
// let list: Vec<String> = list.list().par_iter().filter_map(|pkg| {
// if ! self.is_installed(&pkg).unwrap() {
// Some(pkg.to_string())
// } else {
// None
// }
// }).collect();
if self.is_installed_list(&list)? {
return Ok(());
}
//let res = AptCmd::install(list.into_package_list()).run()?;
let res = AptCmd::install(list).run()?;
if ! res.success {

View File

@ -1,8 +1,11 @@
use serde::Serialize;
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
use crate::utils::serde::string_or_seq_string;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PackageList {
#[serde(deserialize_with = "string_or_seq_string")]
pub list: Vec<String>,
}

View File

@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Serialize, Deserialize, Deserializer};
use crate::{Module, ModuleSetup};
use crate::facts::{Facts, os::OsFamily};
@ -19,12 +19,25 @@ pub use builder::{PackageArgsBuilder, NoManager};
pub type PackageStatus = ();
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageArgs {
name: PackageList,
state: PackageState,
}
impl ModuleSetup<PackageModule<Box<dyn PackageManager>>, PackageArgs, (), PackageError> for PackageArgs {
fn with_facts(self, facts: &Facts) -> PackageModule<Box<dyn PackageManager>> {
let manager: Box<dyn PackageManager> = match facts.os.family() {
OsFamily::Debian => Box::new(AptManager),
OsFamily::Archlinux => Box::new(PacmanManager),
};
PackageModule {
args: self,
manager,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum PackageState {
Present,
@ -32,6 +45,21 @@ pub enum PackageState {
Latest,
}
impl<'de> Deserialize<'de> for PackageState {
fn deserialize<D>(de: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let variant = String::deserialize(de)?;
Ok(match variant.as_str() {
"present" => PackageState::Present,
"absent" => PackageState::Absent,
"latest" => PackageState::Latest,
_other => unimplemented!(),
})
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct PackageModule<Manager> {
manager: Manager,
@ -78,6 +106,12 @@ impl ModuleSetup<PackageModule<Box<dyn PackageManager>>, PackageArgs, (), Packag
}
}
// Stupid impl just for tests
impl ModuleSetup<PackageModule<Box<dyn PackageManager>>, PackageArgs, (), PackageError> for PackageModule<Box<dyn PackageManager>> {
fn with_facts(self, _facts: &Facts) -> PackageModule<Box<dyn PackageManager>> {
self
}
}
impl PackageModule<NoManager> {
pub fn new() -> PackageArgsBuilder<builder::NoPackage, builder::NoState, builder::NoManager> {
@ -100,6 +134,7 @@ impl From<std::io::Error> for PackageError {
pub trait PackageManager: std::fmt::Debug {
fn name(&self) -> &'static str;
fn update(&self) -> Result<(), PackageError>;
fn is_installed(&self, pkg: &str) -> Result<bool, PackageError>;
fn install(&self, list: PackageList) -> Result<(), PackageError>;
fn remove(&self, list: PackageList) -> Result<(), PackageError>;
}

View File

@ -20,6 +20,12 @@ impl PacmanCmd {
cmd
}
pub fn is_installed(pkg: &str) -> PacmanCmd {
let mut cmd = Self::new();
cmd.0 = cmd.0.arg("-Qi").arg(pkg);
cmd
}
pub fn install(list: PackageList) -> PacmanCmd {
let mut cmd = Self::new();
cmd.0 = cmd.0.arg("-S").args(list.list());
@ -56,6 +62,12 @@ impl PackageManager for PacmanManager {
Ok(())
}
fn is_installed(&self, pkg: &str) -> Result<bool, PackageError> {
let res = PacmanCmd::is_installed(pkg).run()?;
Ok(res.success)
}
fn install(&self, list: PackageList) -> Result<(), PackageError> {
let res = PacmanCmd::install(list).run()?;

View File

@ -3,13 +3,17 @@ use duct::cmd as duct_cmd;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cmd {
pub program: String,
pub args: Vec<String>,
pub stdin: Option<String>,
pub chdir: Option<PathBuf>,
pub env: HashMap<String, String>,
program: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
stdin: Option<String>,
#[serde(default)]
chdir: Option<PathBuf>,
#[serde(default)]
env: HashMap<String, String>,
}
impl Cmd {

View File

@ -1 +1,2 @@
pub mod cmd;
pub mod serde;

34
src/utils/serde.rs Normal file
View File

@ -0,0 +1,34 @@
use serde::de::{Deserialize, Deserializer, SeqAccess, Visitor};
use serde::de::value::SeqAccessDeserializer;
use std::fmt;
use std::marker::PhantomData;
// https://stackoverflow.com/questions/41151080/deserialize-a-json-string-or-array-of-strings-into-a-vec
pub fn string_or_seq_string<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where D: Deserializer<'de>
{
struct StringOrVec(PhantomData<Vec<String>>);
impl<'de> Visitor<'de> for StringOrVec {
type Value = Vec<String>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("string or list of strings")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where E: serde::de::Error
{
Ok(vec![value.to_owned()])
}
fn visit_seq<S>(self, visitor: S) -> Result<Self::Value, S::Error>
where S: SeqAccess<'de>
{
Deserialize::deserialize(SeqAccessDeserializer::new(visitor))
}
}
deserializer.deserialize_any(StringOrVec(PhantomData))
}

91
test.sh Executable file
View File

@ -0,0 +1,91 @@
#! /usr/bin/env bash
help() {
echo "test.sh [SERVER]"
echo "Start the rustible test suite on a certain machine (localhost by default)"
echo "Requires lxc-ramdisk to be installed: https://kl.netlib.re/gitea/selfhoster1312/lxc-ramdisk"
}
build() {
if cargo build --release 2>&1 >/dev/null; then
echo "$(pwd)"/target/release/rustible
ret=0
else
echo "BUILD FAILED."
ret=1
fi
return $ret
}
gen_rand_name() {
echo rustible-"$RANDOM"
}
execute_localhost() {
while true; do
TEST_NAME="$(gen_rand_name)"
TEST_PATH=/tmp/"$TEST_NAME"
if [ ! -e "$TEST_PATH" ]; then
break
fi
done
echo "Start test $TEST_NAME"
cp target/release/rustible "$TEST_PATH"
}
execute_on_server() {
SERVER="$1"
if ssh "$SERVER" true >/dev/null 2>&1; then
echo "Connection to "$1" successful"
else
echo "ERROR: Connection failed to "$1""
return 1
fi
while true; do
TEST_NAME="$(gen_rand_name)"
TEST_PATH=/tmp/"$TEST_NAME"
if ! ssh "$1" ls "$TEST_PATH" >/dev/null 2>&1; then
break
fi
done
echo "Start test "$TEST_NAME" ("$TEST_PATH")"
if ! scp target/release/rustible "$SERVER":"$TEST_PATH"; then
echo "ERROR: Copy to "$SERVER" failed"
return 1
fi
}
# Move to crate directory
OLDDIR="$(pwd)"
cd "$(dirname "$0")"
# Figure out rustible test binary path
BIN_PATH="$(build)"
if [ ! $? -eq 0 ]; then
# Compilation failed, abort
echo "$BIN_PATH"
exit 1
fi
if [[ "$1" = "" ]]; then
execute_localhost
ret=$?
else
case "$1" in
"help" | "-h" | "--help")
help
ret=0
;;
*)
execute_on_server "$1"
ret=$?
;;
esac
fi
cd "$OLDDIR"
exit $ret

19
tests/hello/playbook.ron Normal file
View File

@ -0,0 +1,19 @@
(
modules: [
(
name: "install hello",
module: "package",
args: (
name: [ "hello", "cmake", "firefox-esr", "libreoffice", "make", "gcc", "zstd", "zlib1g", "zlib1g-dev", "zenity", "zenity-common", "yt-dlp", "yelp", "yelp-xsl", "xz-utils", "xserver-xorg", "xtrans-dev" ],
state: "present",
)
), (
name: "say hello",
module: "command",
args: (
program: "hello",
args: [ "-g", "welcome to rustible" ],
),
)
]
)

7
tests/hello/test.sh Normal file
View File

@ -0,0 +1,7 @@
#! /usr/bin/env bash
# Check that the playbook run returned "welcome to rustible"
echo "$STDOUT" | tail -n 3 | grep -P "\^"Welcome to rustible!\""
# Check that hello is effectively installed
hello -g "test" | grep -P "^test$"