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
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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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<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?);
}
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<()> {
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<String> = 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::<Vec<&String>>())
cmd.args(command_args.iter().skip(1).collect::<Vec<&String>>())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// add OS environment variables from default config and task config

View file

@ -85,12 +85,17 @@ struct Task {
name: String,
#[serde(default)]
description: Option<String>,
/// OS environment to add at runtime
#[serde(default)]
environment: HashMap<String, String>,
command: Vec<String>,
#[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)]
@ -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<InstanceConfig>,
#[serde(default = "ExecutorConfig::default")]
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>,
webhooks: Option<Vec<Webhook>>
}