diff --git a/Cargo.lock b/Cargo.lock index 89beff5..7678f98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "anstream" version = "0.6.18" @@ -177,6 +189,15 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +[[package]] +name = "cc" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -192,7 +213,9 @@ dependencies = [ "axum", "clap", "dotenv", + "nodrift", "notmad", + "rusqlite", "serde", "tokio", "tokio-util", @@ -254,6 +277,18 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fnv" version = "1.0.7" @@ -375,6 +410,24 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + [[package]] name = "heck" version = "0.5.0" @@ -492,6 +545,17 @@ version = "0.2.164" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.12" @@ -548,10 +612,24 @@ dependencies = [ ] [[package]] -name = "notmad" -version = "0.5.0" +name = "nodrift" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "508d2cebf89bcea5803868fc39ecefe647cd8aa6c6955e40987b4fb7c7604326" +checksum = "154be26c4796e549cab55b834bb8bf6cbcd24e759ecaa6f91155464520c616ba" +dependencies = [ + "anyhow", + "async-trait", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "notmad" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590b2938244df27d8e6b9989dd320db6499bdad8c0a2381b4d748134998f5515" dependencies = [ "anyhow", "async-trait", @@ -636,6 +714,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -702,6 +786,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -789,6 +887,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1054,6 +1158,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/crates/churn/Cargo.toml b/crates/churn/Cargo.toml index 01ec051..774a5c0 100644 --- a/crates/churn/Cargo.toml +++ b/crates/churn/Cargo.toml @@ -15,6 +15,8 @@ axum.workspace = true serde = { version = "1.0.197", features = ["derive"] } uuid = { version = "1.7.0", features = ["v4"] } tower-http = { version = "0.5.2", features = ["cors", "trace"] } -notmad = "0.5.0" +notmad = "0.6.0" tokio-util = "0.7.12" async-trait = "0.1.83" +nodrift = "0.2.0" +rusqlite = { version = "0.32.1", features = ["bundled"] } diff --git a/crates/churn/src/agent.rs b/crates/churn/src/agent.rs new file mode 100644 index 0000000..ed5d900 --- /dev/null +++ b/crates/churn/src/agent.rs @@ -0,0 +1,18 @@ +use agent_state::AgentState; +use refresh::AgentRefresh; + +mod agent_state; + +mod refresh; + +pub async fn execute(host: impl Into) -> anyhow::Result<()> { + let state = AgentState::new().await?; + + notmad::Mad::builder() + .add(AgentRefresh::new(&state, host)) + .cancellation(Some(std::time::Duration::from_secs(2))) + .run() + .await?; + + Ok(()) +} diff --git a/crates/churn/src/agent/agent_state.rs b/crates/churn/src/agent/agent_state.rs new file mode 100644 index 0000000..9941f33 --- /dev/null +++ b/crates/churn/src/agent/agent_state.rs @@ -0,0 +1,32 @@ +use std::{ops::Deref, sync::Arc}; + +#[derive(Clone)] +pub struct AgentState(Arc); + +impl AgentState { + pub async fn new() -> anyhow::Result { + Ok(Self(Arc::new(State::new().await?))) + } +} + +impl From<&AgentState> for AgentState { + fn from(value: &AgentState) -> Self { + value.clone() + } +} + +impl Deref for AgentState { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct State {} + +impl State { + pub async fn new() -> anyhow::Result { + Ok(Self {}) + } +} diff --git a/crates/churn/src/agent/refresh.rs b/crates/churn/src/agent/refresh.rs new file mode 100644 index 0000000..215b5bf --- /dev/null +++ b/crates/churn/src/agent/refresh.rs @@ -0,0 +1,119 @@ +use super::agent_state::AgentState; + +#[derive(Clone)] +pub struct AgentRefresh { + _state: AgentState, + host: String, +} + +impl AgentRefresh { + pub fn new(state: impl Into, host: impl Into) -> Self { + Self { + _state: state.into(), + host: host.into(), + } + } +} + +#[async_trait::async_trait] +impl notmad::Component for AgentRefresh { + async fn run( + &self, + cancellation_token: tokio_util::sync::CancellationToken, + ) -> Result<(), notmad::MadError> { + let cancel = nodrift::schedule_drifter(std::time::Duration::from_secs(5), self.clone()); + tokio::select! { + _ = cancel.cancelled() => {}, + _ = cancellation_token.cancelled() => { + tracing::debug!("cancelling agent refresh"); + cancel.cancel(); + } + } + + Ok(()) + } +} + +#[async_trait::async_trait] +impl nodrift::Drifter for AgentRefresh { + async fn execute(&self, _token: tokio_util::sync::CancellationToken) -> anyhow::Result<()> { + tracing::info!(host = self.host, "refreshing agent"); + + // Get plan + let plan = Plan::new(); + let tasks = plan.tasks().await?; + + // For task + for task in tasks { + // Check idempotency rules + if !task.should_run().await? { + tracing::debug!(task = task.id(), "skipping run"); + continue; + } + + // Run task if not valid + tracing::info!(task = task.id(), "executing task"); + task.execute().await?; + } + + Ok(()) + } +} + +pub struct Plan {} +impl Plan { + pub fn new() -> Self { + Self {} + } + + pub async fn tasks(&self) -> anyhow::Result> { + Ok(vec![Task::new()]) + } +} + +pub struct Task {} + +impl Task { + pub fn new() -> Self { + Self {} + } + + pub fn id(&self) -> String { + "apt".into() + } + + pub async fn should_run(&self) -> anyhow::Result { + Ok(true) + } + + pub async fn execute(&self) -> anyhow::Result<()> { + let mut cmd = tokio::process::Command::new("apt"); + cmd.args(["apt-get", "update", "-q"]); + let output = cmd.output().await?; + match output.status.success() { + true => tracing::info!("successfully ran apt update"), + false => { + anyhow::bail!( + "failed to run apt update: {}", + std::str::from_utf8(&output.stderr)? + ); + } + } + + let mut cmd = tokio::process::Command::new("apt"); + cmd.env("DEBIAN_FRONTEND", "noninteractive") + .args(["apt-get", "upgrade", "-y"]); + let output = cmd.output().await?; + match output.status.success() { + true => tracing::info!("successfully ran apt upgrade"), + false => { + anyhow::bail!( + "failed to run apt upgrade: {}", + std::str::from_utf8(&output.stderr)? + ); + } + } + + Ok(()) + } +} diff --git a/crates/churn/src/cli.rs b/crates/churn/src/cli.rs index 82e292c..72ab6dd 100644 --- a/crates/churn/src/cli.rs +++ b/crates/churn/src/cli.rs @@ -2,19 +2,28 @@ use std::net::SocketAddr; use clap::{Parser, Subcommand}; -use crate::{api, state::SharedState}; +use crate::{agent, api, state::SharedState}; pub async fn execute() -> anyhow::Result<()> { let state = SharedState::new().await?; let cli = Command::parse(); - if let Some(Commands::Serve { host }) = cli.command { - tracing::info!("Starting service"); + match cli.command.expect("to have a subcommand") { + Commands::Serve { host } => { + tracing::info!("Starting service"); - notmad::Mad::builder() - .add(api::Api::new(&state, host)) - .run() - .await?; + notmad::Mad::builder() + .add(api::Api::new(&state, host)) + .run() + .await?; + } + Commands::Agent { commands } => match commands { + AgentCommands::Start { host } => { + tracing::info!("starting agent"); + agent::execute(host).await?; + tracing::info!("shut down agent"); + } + }, } Ok(()) @@ -33,4 +42,16 @@ enum Commands { #[arg(env = "SERVICE_HOST", long, default_value = "127.0.0.1:3000")] host: SocketAddr, }, + Agent { + #[command(subcommand)] + commands: AgentCommands, + }, +} + +#[derive(Subcommand)] +enum AgentCommands { + Start { + #[arg(env = "SERVICE_HOST", long = "service-host")] + host: String, + }, } diff --git a/crates/churn/src/main.rs b/crates/churn/src/main.rs index 06df04f..ace2eb0 100644 --- a/crates/churn/src/main.rs +++ b/crates/churn/src/main.rs @@ -2,6 +2,8 @@ mod api; mod cli; mod state; +mod agent; + #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv::dotenv().ok(); diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..84c5e27 --- /dev/null +++ b/install.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -e + +# Configuration variables +GITEA_HOST="https://git.front.kjuulh.io" +REPO_OWNER="kjuulh" +REPO_NAME="churn-v2" +BINARY_NAME="churn" +SERVICE_NAME="churn-agent" +SERVICE_USER="churn" +RELEASE_TAG="latest" # or specific version like "v1.0.0" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + exit 1 +fi + +# Create service user if it doesn't exist +if ! id "$SERVICE_USER" &>/dev/null; then + useradd -r -s /bin/false "$SERVICE_USER" +fi + +# Function to get latest release if RELEASE_TAG is "latest" +get_latest_release() { + curl -s "https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/releases/latest" | \ + grep '"tag_name":' | \ + sed -E 's/.*"([^"]+)".*/\1/' +} + +# Determine the actual release tag +if [ "$RELEASE_TAG" = "latest" ]; then + RELEASE_TAG=$(get_latest_release) +fi + +echo "Installing $BINARY_NAME version $RELEASE_TAG..." + +# Download and install binary +TMP_DIR=$(mktemp -d) +cd "$TMP_DIR" + +# Download binary from Gitea +curl -L -o "$BINARY_NAME" \ + "https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/releases/download/$RELEASE_TAG/$BINARY_NAME" + +# Make binary executable and move to appropriate location +chmod +x "$BINARY_NAME" +mv "$BINARY_NAME" "/usr/local/bin/$BINARY_NAME" + +# Create systemd service file +cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF +[Unit] +Description=$SERVICE_NAME Service +After=network.target + +[Service] +Type=simple +User=$SERVICE_USER +ExecStart=/usr/local/bin/$BINARY_NAME +Restart=always +RestartSec=5 + +# Security hardening options +ProtectSystem=strict +ProtectHome=true +NoNewPrivileges=true +ReadWritePaths=/var/log/$SERVICE_NAME + +[Install] +WantedBy=multi-user.target +EOF + +# Create log directory if logging is needed +mkdir -p "/var/log/$SERVICE_NAME" +chown "$SERVICE_USER:$SERVICE_USER" "/var/log/$SERVICE_NAME" + +# Reload systemd and enable service +systemctl daemon-reload +systemctl enable "$SERVICE_NAME" +systemctl start "$SERVICE_NAME" + +# Clean up +cd +rm -rf "$TMP_DIR" + +echo "Installation complete! Service status:" +systemctl status "$SERVICE_NAME" + +# Provide some helpful commands +echo " +Useful commands: +- Check status: systemctl status $SERVICE_NAME +- View logs: journalctl -u $SERVICE_NAME +- Restart service: systemctl restart $SERVICE_NAME +- Stop service: systemctl stop $SERVICE_NAME +"