feat(executor): remote host executing via ssh

Allow to specify a task to be running on a remote host via wrapping
command around ssh client.
This commit is contained in:
Matthieu Bessat 2024-08-02 19:40:14 +02:00
parent 84a456003c
commit b79d989f78
6 changed files with 92 additions and 12 deletions

View file

@ -6,7 +6,7 @@ RUN cargo install --locked --path .
FROM alpine:3.20 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 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/src/autotasker/migrations
RUN mkdir -p /usr/local/lib/autotasker/assets RUN mkdir -p /usr/local/lib/autotasker/assets

View file

@ -4,11 +4,12 @@
- [x] Add CSS badge and color code on job status - [x] Add CSS badge and color code on job status
- [x] Add `Dockerfile` - [x] Add `Dockerfile`
- [ ] Add cli arg to change listen address and port - [x] Add cli arg to change listen address and port
- [ ] Add tasks timeout - [x] Add tasks timeout
- [ ] Support connecting to remote server by SSH to execute task remotely
- [ ] Add details on runtime - [ ] Add details on runtime
- [ ] Implement basic auth with OAuth2, find a minimal oauth2 - [ ] 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 - [ ] Add a way to categorize tasks, regroup tasks
- [ ] Don't use long UUID, but only ids - [ ] Don't use long UUID, but only ids
- [ ] Validating config file - [ ] Validating config file

View file

@ -2,6 +2,13 @@ executor:
environment: environment:
SUPER_COOL_DEFAULT_ENV: 438548 SUPER_COOL_DEFAULT_ENV: 438548
hosts:
srv06.mbess.net:
connection:
!ssh
user: autotasker
key: /home/mbess/.ssh/autotasker
tasks: tasks:
do_magic_stuff: do_magic_stuff:
name: Do magic incantation name: Do magic incantation
@ -31,6 +38,20 @@ tasks:
# schedule: # schedule:
# "0 * * * * *" # "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: webhooks:
- id: 1 - id: 1
name: "Trigger magic stuff" name: "Trigger magic stuff"

View file

@ -2,7 +2,10 @@ export RUST_BACKTRACE := "1"
export RUST_LOG := "trace" export RUST_LOG := "trace"
watch-run: 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:
docker run -p 3085:8080 -v ./config:/etc/autotasker -v ./db:/var/lib/autotasker autotasker docker run -p 3085:8080 -v ./config:/etc/autotasker -v ./db:/var/lib/autotasker autotasker

View file

@ -1,6 +1,6 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use log::{debug, info, error}; use log::{debug, error, info, trace};
use sqlx::{Pool, QueryBuilder, Sqlite}; use sqlx::{Pool, QueryBuilder, Sqlite};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use uuid::Uuid; use uuid::Uuid;
@ -9,7 +9,7 @@ use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::mpsc::Receiver; 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; use crate::AppState;
async fn insert_logs(db: &Pool<Sqlite>, collected_logs: &mut Vec<LogLine>) -> Result<()> { async fn insert_logs(db: &Pool<Sqlite>, collected_logs: &mut Vec<LogLine>) -> Result<()> {
@ -90,6 +90,25 @@ async fn execute_process(state: &AppState, order: &ExecutorOrder, cmd: &mut Comm
return Ok(process_handle.await?); return Ok(process_handle.await?);
} }
fn prepare_command(config: &Config, task: &Task) -> Vec<String> {
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<()> { async fn run_task(state: AppState, order: ExecutorOrder) -> Result<()> {
debug!("Start processing of order {:?}", &order.id); debug!("Start processing of order {:?}", &order.id);
// save in DB // 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) let task = state.config.tasks.get(&order.task_id)
.expect("Task id in executor order is not valid."); .expect("Task id in executor order is not valid.");
let executable = match task.command.first() { let command_args: Vec<String> = prepare_command(&state.config, &task);
trace!("Command arguments {:?}", &command_args);
let executable = match command_args.first() {
Some(v) => v, Some(v) => v,
None => return Err(anyhow!("Could not find command to execute")), None => return Err(anyhow!("Could not find command to execute")),
}; };
let mut cmd = Command::new(executable); let mut cmd = Command::new(executable);
cmd.args(task.command.iter().skip(1).collect::<Vec<&String>>()) cmd.args(command_args.iter().skip(1).collect::<Vec<&String>>())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
// add OS environment variables from default config and task config // add OS environment variables from default config and task config

View file

@ -85,12 +85,17 @@ struct Task {
name: String, name: String,
#[serde(default)] #[serde(default)]
description: Option<String>, description: Option<String>,
/// OS environment to add at runtime /// OS environment to add at runtime
#[serde(default)] #[serde(default)]
environment: HashMap<String, String>, environment: HashMap<String, String>,
command: Vec<String>, command: Vec<String>,
#[serde(default)] #[serde(default)]
schedule: Option<ScheduleConfig> schedule: Option<ScheduleConfig>,
/// Where the task will be executed, None is autotasker host
#[serde(default)]
target_host: Option<String>
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -167,12 +172,41 @@ struct Webhook {
debounce_secs: u32 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] #[fully_pub]
struct Config { struct Config {
/// configure current autotasker instance
instance: Option<InstanceConfig>,
#[serde(default = "ExecutorConfig::default")] #[serde(default = "ExecutorConfig::default")]
executor: ExecutorConfig, executor: ExecutorConfig,
instance: Option<InstanceConfig>,
/// map of remote hosts config where we can execute tasks
/// the key must be a FQDN
#[serde(default)]
hosts: HashMap<String, HostConfig>,
tasks: HashMap<String, Task>, tasks: HashMap<String, Task>,
webhooks: Option<Vec<Webhook>> webhooks: Option<Vec<Webhook>>
} }