scheduling

This commit is contained in:
Robin Appelman 2021-03-28 19:23:54 +02:00
commit da0f12fb91
8 changed files with 456 additions and 39 deletions

View file

@ -59,7 +59,7 @@ impl From<reqwest::Error> for ResponseError {
pub type Result<T, E = CloudError> = std::result::Result<T, E>;
#[async_trait]
pub trait Cloud {
pub trait Cloud: Send + Sync + 'static {
/// List all running servers on this cloud
async fn list(&self) -> Result<Vec<Server>>;
/// Create a new server with the given parameter

View file

@ -4,6 +4,7 @@ use camino::Utf8PathBuf;
use serde::Deserialize;
use std::fs::read_to_string;
use std::path::Path;
use std::sync::Arc;
use thiserror::Error;
#[derive(Debug, Error)]
@ -31,6 +32,8 @@ impl From<toml::de::Error> for TomlError {
pub struct Config {
pub vultr: Option<VultrConfig>,
pub server: ServerConfig,
pub dyndns: Option<DynDnsConfig>,
pub schedule: ScheduleConfig,
}
impl Config {
@ -39,9 +42,9 @@ impl Config {
Ok(toml::from_str(&content).map_err(TomlError::from)?)
}
pub fn cloud(&self) -> Result<Box<dyn Cloud>, ConfigError> {
pub fn cloud(&self) -> Result<Arc<dyn Cloud>, ConfigError> {
if let Some(vultr) = &self.vultr {
Ok(Box::new(Vultr::new(
Ok(Arc::new(Vultr::new(
vultr.api_key.clone(),
vultr.region.clone(),
vultr.plan.clone(),
@ -103,3 +106,17 @@ pub struct VultrConfig {
fn vultr_default_plan() -> String {
String::from("vc2-1c-2gb")
}
#[derive(Deserialize, Debug)]
pub struct DynDnsConfig {
pub update_url: String,
pub hostname: String,
pub username: String,
pub password: String,
}
#[derive(Deserialize, Debug)]
pub struct ScheduleConfig {
pub start: String,
pub stop: String,
}

85
src/dns.rs Normal file
View file

@ -0,0 +1,85 @@
use reqwest::{Client, StatusCode};
use serde::Serialize;
use std::net::IpAddr;
use thiserror::Error;
pub type Result<T, E = DynDnsError> = std::result::Result<T, E>;
#[derive(Debug, Error)]
pub enum DynDnsError {
#[error("Invalid credentials")]
Unauthorized,
#[error("Network error: {0}")]
Network(#[from] NetworkError),
#[error("Network response from server: {0}")]
InvalidResponse(String),
#[error("Domain belongs to another user")]
NotYourDomain,
#[error("Invalid hostname")]
InvalidHostname,
}
impl DynDnsError {
fn from_status_code(status: StatusCode) -> Result<()> {
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return Err(DynDnsError::Unauthorized);
}
Ok(())
}
}
/// Intentionally opaque error
#[derive(Debug, Error)]
#[error("{0}")]
pub struct NetworkError(reqwest::Error);
pub struct DynDnsClient {
client: Client,
update_url: String,
username: String,
password: String,
}
impl DynDnsClient {
pub fn new(update_url: String, username: String, password: String) -> Self {
DynDnsClient {
client: Client::new(),
update_url,
username,
password,
}
}
pub async fn update(&self, hostname: &str, ip: IpAddr) -> Result<()> {
let response = self
.client
.get(&self.update_url)
.basic_auth(&self.username, Some(&self.password))
.query(&DynDnsParams { hostname, ip })
.send()
.await
.map_err(NetworkError)?;
let status = response.status();
DynDnsError::from_status_code(status)?;
let text = response.text().await.map_err(NetworkError)?;
match text.as_str() {
"badauth" => Err(DynDnsError::Unauthorized),
"!yours" => Err(DynDnsError::NotYourDomain),
"nochg" => Ok(()),
"good" => Ok(()),
"notfqdn" => Err(DynDnsError::InvalidHostname),
"nohost" => Err(DynDnsError::InvalidHostname),
"numhost" => Err(DynDnsError::InvalidHostname),
_ => Err(DynDnsError::InvalidResponse(text)),
}
}
}
#[derive(Serialize)]
struct DynDnsParams<'a> {
hostname: &'a str,
#[serde(rename = "myip")]
ip: IpAddr,
}

View file

@ -1,18 +1,20 @@
use std::env::args;
use thiserror::Error;
use ssh::SshSession;
use crate::cloud::CloudError;
use crate::cloud::{Cloud, CloudError};
use crate::config::{Config, ConfigError, ServerConfig};
use crate::dns::{DynDnsClient, DynDnsError};
use crate::ssh::SshError;
use ssh::SshSession;
use std::env::args;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use thiserror::Error;
use tokio::task::{spawn, JoinError};
use tokio::time::sleep;
use tokio_cron_scheduler::{Job, JobScheduler};
pub mod cloud;
pub mod config;
pub mod ssh;
mod cloud;
mod config;
mod dns;
mod ssh;
#[derive(Debug, Error)]
pub enum Error {
@ -24,6 +26,30 @@ pub enum Error {
Ssh(#[from] SshError),
#[error("Setup command returned an error: {0}")]
SetupError(String),
#[error("Error while updating dyndns: {0}")]
DynDns(#[from] DynDnsError),
#[error("Already running")]
AlreadyRunning,
#[error("{0}")]
Schedule(ScheduleError),
}
#[derive(Debug, Error)]
#[error("{0}")]
pub struct ScheduleError(ScheduleErrorImpl);
#[derive(Debug, Error)]
enum ScheduleErrorImpl {
#[error("Error setting up schedule")]
Schedule(String),
#[error("Error running schedule")]
Join(JoinError),
}
impl From<ScheduleErrorImpl> for Error {
fn from(e: ScheduleErrorImpl) -> Self {
Error::Schedule(ScheduleError(e))
}
}
async fn setup(ssh: &mut SshSession, config: &ServerConfig) -> Result<(), Error> {
@ -79,6 +105,8 @@ async fn setup(ssh: &mut SshSession, config: &ServerConfig) -> Result<(), Error>
#[tokio::main]
async fn main() -> Result<(), Error> {
pretty_env_logger::init();
let mut args = args();
let bin = args.next().unwrap();
@ -91,12 +119,85 @@ async fn main() -> Result<(), Error> {
};
let cloud = config.cloud()?;
let mut sched = JobScheduler::new();
let server_id: Arc<Mutex<Option<String>>> = Arc::default();
sched
.add(stop_job(cloud.clone(), &config, server_id.clone()))
.map_err(|e| ScheduleErrorImpl::Schedule(format!("{:#}", e)))?;
sched
.add(start_job(cloud, config, server_id))
.map_err(|e| ScheduleErrorImpl::Schedule(format!("{:#}", e)))?;
sched.start().await.map_err(ScheduleErrorImpl::Join)?;
Ok(())
}
fn stop_job(_cloud: Arc<dyn Cloud>, config: &Config, server_id: Arc<Mutex<Option<String>>>) -> Job {
Job::new(&config.schedule.stop, move |_uuid, _l| {
let server_id = server_id.clone();
spawn(async move {
println!("Stopping server");
if let Some(id) = server_id.lock().unwrap().take() {
println!("Would have killed {}", id);
// match cloud.kill(&id).await {
// Ok(_) => {}
// Err(e) => eprintln!("{:#}", e),
// };
}
});
})
.unwrap()
}
fn start_job(cloud: Arc<dyn Cloud>, config: Config, server_id: Arc<Mutex<Option<String>>>) -> Job {
let schedule = config.schedule.start.clone();
let config = Arc::new(config);
Job::new(&schedule, move |_uuid, _l| {
let cloud = cloud.clone();
let config = config.clone();
let server_id = server_id.clone();
spawn(async move {
let cloud = cloud.as_ref();
println!("Starting server");
match start(cloud, &config).await {
Ok(id) => *server_id.lock().unwrap() = Some(id),
Err(e) => eprintln!("{:#}", e),
};
});
})
.unwrap()
}
async fn start(cloud: &dyn Cloud, config: &Config) -> Result<String, Error> {
let list = cloud.list().await?;
if !list.is_empty() {
return Err(Error::AlreadyRunning);
}
let created = cloud.spawn().await?;
let server = cloud.wait_for_ip(&created.id).await?;
println!("Server is booting");
println!(" IP: {}", server.ip);
println!(" Password: {}", created.password);
println!(" Root Password: {}", created.password);
let connect_host = if let Some(dns_config) = config.dyndns.as_ref() {
let dns = DynDnsClient::new(
dns_config.update_url.to_string(),
dns_config.username.to_string(),
dns_config.password.to_string(),
);
println!(
"Updating DynDNS entry for {} to {}",
dns_config.hostname, server.ip
);
dns.update(&dns_config.hostname, server.ip).await?;
dns_config.hostname.to_string()
} else {
format!("{}", server.ip)
};
let mut ssh = SshSession::open(server.ip, &created.password).await?;
setup(&mut ssh, &config.server).await?;
@ -106,8 +207,7 @@ async fn main() -> Result<(), Error> {
println!("Connect using");
println!(
" connect {}; password {}",
server.ip, config.server.password
connect_host, config.server.password
);
Ok(())
Ok(server.id)
}