feat: added flexible configuration system using figment

main
Jef Roosens 2025-03-08 22:07:57 +01:00
parent f16cdfdfff
commit f9ffc21a3f
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
11 changed files with 227 additions and 74 deletions

View File

@ -14,3 +14,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* subscriptions
* episode changes
* devices
* Flexible configuration via a TOML config file, environment variables or CLI
arguments

87
Cargo.lock generated
View File

@ -94,6 +94,15 @@ dependencies = [
"password-hash",
]
[[package]]
name = "atomic"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994"
dependencies = [
"bytemuck",
]
[[package]]
name = "autocfg"
version = "1.4.0"
@ -246,6 +255,12 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "bytemuck"
version = "1.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
[[package]]
name = "byteorder"
version = "1.5.0"
@ -496,6 +511,20 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "figment"
version = "0.10.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3"
dependencies = [
"atomic",
"pear",
"serde",
"toml",
"uncased",
"version_check",
]
[[package]]
name = "fnv"
version = "1.0.7"
@ -727,6 +756,12 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "inlinable_string"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -899,6 +934,7 @@ dependencies = [
"cookie",
"diesel",
"diesel_migrations",
"figment",
"libsqlite3-sys",
"rand",
"serde",
@ -948,6 +984,29 @@ dependencies = [
"subtle",
]
[[package]]
name = "pear"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467"
dependencies = [
"inlinable_string",
"pear_codegen",
"yansi",
]
[[package]]
name = "pear_codegen"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147"
dependencies = [
"proc-macro2",
"proc-macro2-diagnostics",
"quote",
"syn",
]
[[package]]
name = "percent-encoding"
version = "2.3.1"
@ -996,6 +1055,19 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proc-macro2-diagnostics"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
dependencies = [
"proc-macro2",
"quote",
"syn",
"version_check",
"yansi",
]
[[package]]
name = "quote"
version = "1.0.38"
@ -1443,6 +1515,15 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "uncased"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697"
dependencies = [
"version_check",
]
[[package]]
name = "unicode-ident"
version = "1.0.17"
@ -1659,6 +1740,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zerocopy"
version = "0.7.35"

View File

@ -12,6 +12,7 @@ clap = { version = "4.5.30", features = ["derive", "env"] }
cookie = "0.18.1"
diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
figment = { version = "0.10.19", features = ["env", "toml"] }
libsqlite3-sys = { version = "0.31.0", features = ["bundled"] }
rand = "0.8.5"
serde = { version = "1.0.218", features = ["derive"] }

View File

@ -11,3 +11,24 @@ Gpodder, suitable for low-power devices.
* Devices API
* Subscriptions API
* Episode actions API
## Configuration
Otter is configurable via a TOML config file, environment variables and CLI
arguments thanks to the [Figment](https://docs.rs/figment/latest/figment/)
library. Each variable in the configuration file below can be provided via an
environment variable (prefixed using `OTTER_`) or a CLI argument.
```toml
data_dir = "./data"
domain = "127.0.0.1"
port = 8080
```
Otter can be told to read a config variable using the `--config/-c` flag (see
the `help` command for other useful CLI commands). Each variable needs to be
provided either from the config file, as an environment variable (e.g.
`OTTER_DATA_DIR`) or a CLI argument (e.g. `--data-dir`).
If a variable is provided in multiple ways, environment variables overwrite
configuration file variables, and CLI arguments overwrite both.

3
otter.toml 100644
View File

@ -0,0 +1,3 @@
data_dir = "./data"
domain = "127.0.0.1"
port = 8080

View File

@ -16,16 +16,16 @@ pub enum AddCommand {
}
impl DbCommand {
pub fn run(&self, cli: &super::Cli) -> u8 {
pub fn run(&self, config: &crate::config::Config) -> u8 {
match self {
DbCommand::Add(cmd) => cmd.run(cli),
DbCommand::Add(cmd) => cmd.run(config),
}
}
}
impl AddCommand {
pub fn run(&self, cli: &super::Cli) -> u8 {
match self.run_err(cli) {
pub fn run(&self, config: &crate::config::Config) -> u8 {
match self.run_err(config) {
Ok(()) => 0,
Err(err) => {
eprintln!("An error occured: {}", err.stack());
@ -35,8 +35,8 @@ impl AddCommand {
}
}
pub fn run_err(&self, cli: &super::Cli) -> DbResult<()> {
let pool = crate::db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), false)?;
pub fn run_err(&self, config: &crate::config::Config) -> DbResult<()> {
let pool = crate::db::initialize_db(config.data_dir.join(crate::DB_FILENAME), false)?;
match self {
Self::User { username, password } => {

View File

@ -3,36 +3,78 @@ mod serve;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use clap::{Args, Parser, Subcommand};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::Serialize;
/// Otter is a lightweight implementation of the Gpodder API, designed to be used for small
/// personal deployments.
#[derive(Parser)]
pub struct Cli {
#[arg(
long = "data",
default_value = "./data",
value_name = "DATA_DIR",
env = "OTTER_DATA_DIR"
)]
pub data_dir: PathBuf,
#[command(flatten)]
pub config: ClapConfig,
#[command(subcommand)]
pub cmd: Command,
}
#[derive(Serialize, Args, Clone)]
pub struct ClapConfig {
#[arg(
short,
long = "config",
env = "OTTER_CONFIG_FILE",
value_name = "CONFIG_FILE"
)]
config_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long = "data", value_name = "DATA_DIR")]
data_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "DOMAIN")]
domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "PORT")]
port: Option<u16>,
}
#[derive(Subcommand)]
pub enum Command {
Serve(serve::ServeCommand),
Serve,
#[command(subcommand)]
Db(db::DbCommand),
}
impl Cli {
pub fn run(&self) -> u8 {
let mut figment = Figment::new();
if let Some(config_path) = &self.config.config_file {
figment = figment.merge(Toml::file(config_path));
}
let config: crate::config::Config = match figment
.merge(Env::prefixed("OTTER_"))
.merge(Serialized::defaults(self.config.clone()))
.extract()
{
Ok(config) => config,
Err(err) => {
eprintln!("{}", err);
return 1;
}
};
match &self.cmd {
Command::Serve(cmd) => cmd.run(self),
Command::Db(cmd) => cmd.run(self),
Command::Serve => serve::serve(&config),
Command::Db(cmd) => cmd.run(&config),
}
}
}

View File

@ -1,57 +1,31 @@
use clap::Args;
use crate::{db, server};
/// Run the Otter web server
#[derive(Args)]
pub struct ServeCommand {
#[arg(
short,
long,
default_value = "127.0.0.1",
value_name = "DOMAIN",
env = "OTTER_DOMAIN"
)]
domain: String,
pub fn serve(config: &crate::config::Config) -> u8 {
tracing_subscriber::fmt::init();
#[arg(
short,
long,
default_value = "8080",
value_name = "PORT",
env = "OTTER_PORT"
)]
port: u16,
}
impl ServeCommand {
pub fn run(&self, cli: &super::Cli) -> u8 {
tracing_subscriber::fmt::init();
tracing::info!("Initializing database and running migrations");
let pool = db::initialize_db(cli.data_dir.join(crate::DB_FILENAME), true).unwrap();
let ctx = server::Context {
repo: db::SqliteRepository::from(pool),
};
let app = server::app(ctx);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let address = format!("{}:{}", self.domain, self.port);
tracing::info!("Starting server on {address}");
rt.block_on(async {
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap()
});
0
}
tracing::info!("Initializing database and running migrations");
let pool = db::initialize_db(config.data_dir.join(crate::DB_FILENAME), true).unwrap();
let ctx = server::Context {
repo: db::SqliteRepository::from(pool),
};
let app = server::app(ctx);
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let address = format!("{}:{}", config.domain, config.port);
tracing::info!("Starting server on {address}");
rt.block_on(async {
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap()
});
0
}

25
src/config.rs 100644
View File

@ -0,0 +1,25 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_data_dir")]
pub data_dir: PathBuf,
#[serde(default = "default_domain")]
pub domain: String,
#[serde(default = "default_port")]
pub port: u16,
}
fn default_data_dir() -> PathBuf {
PathBuf::from("./data")
}
fn default_domain() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
8080
}

View File

@ -1,4 +1,5 @@
mod cli;
mod config;
mod db;
mod gpodder;
mod server;

View File

@ -11,10 +11,7 @@ use axum::{
RequestExt, Router,
};
use axum_extra::{
extract::{
cookie::{Cookie, Expiration},
CookieJar,
},
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
TypedHeader,
};