diff --git a/Dockerfile b/Dockerfile index e3bd639..a8857c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN cargo install --locked --path . FROM alpine:3.20 -RUN apk add sqlite +RUN apk add sqlite openssh COPY --from=builder /usr/local/cargo/bin/autotasker /usr/local/bin/autotasker RUN mkdir -p /usr/local/src/autotasker/migrations RUN mkdir -p /usr/local/lib/autotasker/assets diff --git a/TODO.md b/TODO.md index 8ddce0a..420c7e1 100644 --- a/TODO.md +++ b/TODO.md @@ -4,11 +4,12 @@ - [x] Add CSS badge and color code on job status - [x] Add `Dockerfile` -- [ ] Add cli arg to change listen address and port -- [ ] Add tasks timeout +- [x] Add cli arg to change listen address and port +- [x] Add tasks timeout +- [ ] Support connecting to remote server by SSH to execute task remotely - [ ] Add details on runtime - [ ] Implement basic auth with OAuth2, find a minimal oauth2 -- [ ] Support connecting to remote server by SSH to execute task remotely +- [ ] Provide example of nginx config with basic auth support excluding webhook - [ ] Add a way to categorize tasks, regroup tasks - [ ] Don't use long UUID, but only ids - [ ] Validating config file diff --git a/config.yaml b/config.yaml index 9da77df..72b135f 100644 --- a/config.yaml +++ b/config.yaml @@ -2,6 +2,13 @@ executor: environment: SUPER_COOL_DEFAULT_ENV: 438548 +hosts: + srv06.mbess.net: + connection: + !ssh + user: autotasker + key: /home/mbess/.ssh/autotasker + tasks: do_magic_stuff: name: Do magic incantation @@ -31,6 +38,20 @@ tasks: # schedule: # "0 * * * * *" + remote_cmd: + name: Get remote infos + target_host: srv06.mbess.net + command: + - hostnamectl + + srv06_renew_certs: + name: Renew certs of srv06 + target_host: srv06.mbess.net + command: # I've setup sudo to let autotasker user only run certbot binary + - sudo + - certbot + - renew + webhooks: - id: 1 name: "Trigger magic stuff" diff --git a/justfile b/justfile index 0d8d32e..3f05c9d 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,10 @@ export RUST_BACKTRACE := "1" export RUST_LOG := "trace" watch-run: - cargo-watch -x 'run -- --config config.yaml' + cargo-watch -x 'run -- --config config.yaml --database ./tmp/dbs/autotasker.db' + +run: + cargo run -- --database ./tmp/dbs/autotasker.db --config ./config.yaml --static-assets ./assets docker-run: docker run -p 3085:8080 -v ./config:/etc/autotasker -v ./db:/var/lib/autotasker autotasker diff --git a/src/executor.rs b/src/executor.rs index 6c9865e..804806e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use chrono::{SecondsFormat, Utc}; -use log::{debug, info, error}; +use log::{debug, error, info, trace}; use sqlx::{Pool, QueryBuilder, Sqlite}; use tokio::task::JoinHandle; use uuid::Uuid; @@ -9,7 +9,7 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; use tokio::process::Command; use tokio::sync::mpsc::Receiver; -use crate::models::{ExecutorOrder, LogKind, LogLine, TaskStatus}; +use crate::models::{Config, ExecutorOrder, HostConnection, LogKind, LogLine, Task, TaskStatus}; use crate::AppState; async fn insert_logs(db: &Pool, collected_logs: &mut Vec) -> Result<()> { @@ -90,6 +90,25 @@ async fn execute_process(state: &AppState, order: &ExecutorOrder, cmd: &mut Comm return Ok(process_handle.await?); } +fn prepare_command(config: &Config, task: &Task) -> Vec { + if let Some(target_host_fqdn) = &task.target_host { + let host_config = config.hosts.get(target_host_fqdn).expect("Task definition contain invalid reference to target host"); + + return match &host_config.connection { + // wrap command to use ssh client + HostConnection::Ssh { user, key } => { + let ssh_authority = format!("{}@{}", user, target_host_fqdn); + debug!("Using ssh client for this command, remote ssh authority is: {:?}", &ssh_authority); + vec![ + "/usr/bin/ssh".into(), "-i".into(), format!("{}", key), ssh_authority, + task.command.join(" ") + ] + } + } + } + task.command.clone() +} + async fn run_task(state: AppState, order: ExecutorOrder) -> Result<()> { debug!("Start processing of order {:?}", &order.id); // save in DB @@ -104,13 +123,15 @@ async fn run_task(state: AppState, order: ExecutorOrder) -> Result<()> { let task = state.config.tasks.get(&order.task_id) .expect("Task id in executor order is not valid."); - let executable = match task.command.first() { + let command_args: Vec = prepare_command(&state.config, &task); + trace!("Command arguments {:?}", &command_args); + let executable = match command_args.first() { Some(v) => v, None => return Err(anyhow!("Could not find command to execute")), }; let mut cmd = Command::new(executable); - cmd.args(task.command.iter().skip(1).collect::>()) + cmd.args(command_args.iter().skip(1).collect::>()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); // add OS environment variables from default config and task config diff --git a/src/models.rs b/src/models.rs index a857ce1..6ad221f 100644 --- a/src/models.rs +++ b/src/models.rs @@ -85,12 +85,17 @@ struct Task { name: String, #[serde(default)] description: Option, + /// OS environment to add at runtime #[serde(default)] environment: HashMap, command: Vec, #[serde(default)] - schedule: Option + schedule: Option, + + /// Where the task will be executed, None is autotasker host + #[serde(default)] + target_host: Option } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -167,12 +172,41 @@ struct Webhook { debounce_secs: u32 } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[fully_pub] +// externally tagged +enum HostConnection { + #[serde(rename = "ssh")] + Ssh { + /// POSIX user + user: String, + /// path to SSH private key + key: String + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[fully_pub] +/// remote POSIX host infos +struct HostConfig { + connection: HostConnection +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[fully_pub] struct Config { + /// configure current autotasker instance + instance: Option, + #[serde(default = "ExecutorConfig::default")] executor: ExecutorConfig, - instance: Option, + + /// map of remote hosts config where we can execute tasks + /// the key must be a FQDN + #[serde(default)] + hosts: HashMap, + tasks: HashMap, + webhooks: Option> }