Skip to main content

ordinaryd/
lib.rs

1#![doc = include_str!("../docs/cli-reference.md")]
2#![warn(clippy::all, clippy::pedantic)]
3#![allow(clippy::missing_errors_doc)]
4
5// Copyright (C) 2026 Ordinary Labs, LLC.
6//
7// SPDX-License-Identifier: AGPL-3.0-only
8
9pub mod cmds;
10pub mod fmt;
11
12use clap::{Args, Parser, Subcommand};
13use ordinary_config::OrdinaryConfig;
14use std::fmt::Display;
15use std::fs::DirEntry;
16use std::path::Path;
17
18use crate::fmt::StdioLogFmt;
19use ordinary_monitor::LOG_FILE_FORMAT;
20use ordinary_monitor::tracing::logger::OrdinaryLogger;
21use tracing::level_filters::LevelFilter;
22
23#[derive(Clone, Debug)]
24pub enum LogLevel {
25    Error,
26    Warn,
27    Info,
28    Debug,
29    Trace,
30}
31
32impl LogLevel {
33    fn to_level_filter(&self) -> LevelFilter {
34        match self {
35            Self::Error => LevelFilter::ERROR,
36            Self::Warn => LevelFilter::WARN,
37            Self::Info => LevelFilter::INFO,
38            Self::Debug => LevelFilter::DEBUG,
39            Self::Trace => LevelFilter::TRACE,
40        }
41    }
42}
43
44impl clap::ValueEnum for LogLevel {
45    fn value_variants<'a>() -> &'a [Self] {
46        &[
47            Self::Error,
48            Self::Warn,
49            Self::Info,
50            Self::Debug,
51            Self::Trace,
52        ]
53    }
54
55    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
56        match self {
57            Self::Error => Some(clap::builder::PossibleValue::new("error")),
58            Self::Warn => Some(clap::builder::PossibleValue::new("warn")),
59            Self::Info => Some(clap::builder::PossibleValue::new("info")),
60            Self::Debug => Some(clap::builder::PossibleValue::new("debug")),
61            Self::Trace => Some(clap::builder::PossibleValue::new("trace")),
62        }
63    }
64}
65
66impl Display for LogLevel {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let str = match self {
69            Self::Error => String::from("error"),
70            Self::Warn => String::from("warn"),
71            Self::Info => String::from("info"),
72            Self::Debug => String::from("debug"),
73            Self::Trace => String::from("trace"),
74        };
75        write!(f, "{str}")
76    }
77}
78
79#[derive(Clone, Debug)]
80pub enum LogFileRotation {
81    Day,
82    Hour,
83    Minute,
84    Never,
85}
86
87impl clap::ValueEnum for LogFileRotation {
88    fn value_variants<'a>() -> &'a [Self] {
89        &[Self::Day, Self::Hour, Self::Minute, Self::Never]
90    }
91
92    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
93        match self {
94            Self::Day => Some(clap::builder::PossibleValue::new("day")),
95            Self::Hour => Some(clap::builder::PossibleValue::new("hour")),
96            Self::Minute => Some(clap::builder::PossibleValue::new("minute")),
97            Self::Never => Some(clap::builder::PossibleValue::new("never")),
98        }
99    }
100}
101
102impl Display for LogFileRotation {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        let str = match self {
105            Self::Day => String::from("day"),
106            Self::Hour => String::from("hour"),
107            Self::Minute => String::from("minute"),
108            Self::Never => String::from("never"),
109        };
110        write!(f, "{str}")
111    }
112}
113
114#[derive(Clone, Debug)]
115pub enum ProvisionMode {
116    Localhost,
117    Staging,
118    Production,
119}
120
121impl clap::ValueEnum for ProvisionMode {
122    fn value_variants<'a>() -> &'a [Self] {
123        &[Self::Staging, Self::Production, Self::Localhost]
124    }
125
126    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
127        match self {
128            Self::Localhost => Some(clap::builder::PossibleValue::new("localhost")),
129            Self::Staging => Some(clap::builder::PossibleValue::new("staging")),
130            Self::Production => Some(clap::builder::PossibleValue::new("production")),
131        }
132    }
133}
134
135impl Display for ProvisionMode {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        let str = match self {
138            Self::Localhost => String::from("localhost"),
139            Self::Staging => String::from("staging"),
140            Self::Production => String::from("production"),
141        };
142        write!(f, "{str}")
143    }
144}
145
146#[allow(clippy::struct_excessive_bools)]
147#[derive(Parser, Debug)]
148#[command(version, about, long_about = None)]
149#[command(propagate_version = true)]
150pub struct Cli {
151    #[command(subcommand)]
152    pub commands: Commands,
153
154    #[command(flatten)]
155    pub global_args: GlobalArgs,
156}
157
158#[allow(clippy::struct_excessive_bools)]
159#[derive(Debug, Args, Clone)]
160pub struct GlobalArgs {
161    /// specify the data directory
162    #[arg(long, global = true, default_value_t = std::env::home_dir().expect("failed to get home dir").join(".ordinary").to_str().expect("failed to convert to str").to_string())]
163    pub data_dir: String,
164
165    /// persists JSON formatted log lines to <data-dir>/logs/<domain>/
166    #[arg(long, global = true, default_value_t = false)]
167    pub stored_logs: bool,
168
169    /// logs events to stdio
170    #[arg(long, global = true, default_value_t = false)]
171    pub stdio_logs: bool,
172
173    /// how to format stdio logs
174    #[arg(long, global = true, default_value_t = StdioLogFmt::Json)]
175    pub stdio_logs_fmt: StdioLogFmt,
176
177    /// logs events to `journald` (only works on Linux distros that use `systemd`)
178    #[arg(long, global = true, default_value_t = false)]
179    pub journald_logs: bool,
180
181    // todo: allow setting log levels for api, app, storage, templates, actions and integrations, independently
182    /// base log level for every component
183    #[arg(long, global = true, default_value_t = LogLevel::Info)]
184    pub log_level: LogLevel,
185
186    /// whether storage and certain payload sizes are logged
187    #[arg(long, global = true, default_value_t = false)]
188    pub log_sizes: bool,
189
190    /// whether span timing is logged
191    #[arg(long, global = true, default_value_t = false)]
192    pub stdio_logs_timing: bool,
193}
194
195#[derive(Subcommand, Debug)]
196pub enum Commands {
197    /// initialize the environment for an Ordinary API server
198    Init {
199        #[command(flatten)]
200        api_init: ApiInit,
201
202        /// domain name for API console
203        #[arg(long)]
204        api_domain: String,
205
206        /// instance user password
207        #[arg(long, default_value_t = String::from("password"))]
208        password: String,
209
210        /// store the MFA key locally instead of copying a QR code
211        #[arg(long, default_value_t = false)]
212        mfa_stored: bool,
213
214        /// contacts for the API domain cert provisioning
215        #[arg(long, value_delimiter = ',', num_args = 1..)]
216        api_contacts: Vec<String>,
217
218        /// domains that apps can subdomain off of.
219        ///
220        /// i.e. when `example.com` is passed, `my.example.com` is
221        /// considered a valid app domain.
222        #[arg(long, value_delimiter = ',', num_args = 1..)]
223        app_domains: Vec<String>,
224
225        /// list of applications that have access to API server
226        /// level commands (i.e. API server invite token generation)
227        ///
228        /// currently intended to enable API server admins to set up a
229        /// web-based registration portal.
230        #[arg(long, value_delimiter = ',', num_args = 0..)]
231        privileged_domains: Option<Vec<String>>,
232    },
233    /// start the Ordinary API server
234    Api {
235        #[command(flatten)]
236        api_init: ApiInit,
237
238        #[command(flatten)]
239        app_api: AppApi,
240
241        /// give each app its own port
242        #[arg(long, default_value_t = false)]
243        dedicated_ports: bool,
244
245        /// Storage size in bytes (rounded up to nearest OS page size).
246        #[arg(long, default_value_t = false)]
247        swagger: bool,
248    },
249    /// start an Ordinary Application server
250    App {
251        #[command(flatten)]
252        app_api: AppApi,
253
254        /// for running a standalone project. (project must already be built)
255        #[arg(short, long, default_value = ".")]
256        project: String,
257
258        /// use a different domain than what's in the `ordinary.json`
259        #[arg(long)]
260        domain_override: Option<String>,
261    },
262}
263
264#[allow(clippy::struct_excessive_bools)]
265#[derive(Debug, Args)]
266pub struct AppApi {
267    /// what mode TLS certs should be provisioned in
268    #[arg(long, default_value_t = ProvisionMode::Localhost)]
269    pub provision: ProvisionMode,
270
271    /// specify HTTP(s) port for server
272    #[arg(long)]
273    pub port: Option<u16>,
274
275    /// specify HTTP port for server when
276    /// running in secure mode.
277    #[arg(long)]
278    pub redirect_port: Option<u16>,
279
280    /// run without HTTPS
281    #[arg(long, default_value_t = false)]
282    pub insecure: bool,
283
284    /// run with insecure cookies
285    #[arg(long, default_value_t = false)]
286    pub insecure_cookies: bool,
287
288    /// max period of time logs are stored
289    #[arg(long, default_value_t = 72)]
290    pub log_ttl_hours: u16,
291
292    /// max size (in bytes) per log file
293    #[arg(long, default_value_t = 10_000_000)]
294    pub log_rotation_file_size: u64,
295
296    /// max amount of time a log file is appended to
297    /// before being compressed and stored
298    #[arg(long, default_value_t = 60)]
299    pub log_rotation_mins: u16,
300
301    /// whether HTTP request and response headers are logged
302    #[arg(long, default_value_t = false)]
303    pub log_headers: bool,
304
305    /// whether IP Addresses are logged with HTTP requests
306    #[arg(long, default_value_t = false)]
307    pub log_ips: bool,
308
309    // todo: document and use enum
310    /// "none" | "blake2" | "blake3"
311    #[arg(long, default_value_t = String::from("none"))]
312    pub redacted_header_hash: String,
313}
314
315#[derive(Debug, Args)]
316pub struct ApiInit {
317    /// environment (e.g production, development, staging)
318    #[arg(long, default_value_t = String::from("staging"))]
319    pub environment: String,
320
321    /// Storage size in bytes (rounded up to nearest OS page size).
322    #[arg(long)]
323    pub storage_size: usize,
324}
325
326fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
327    if dir.is_dir() {
328        for entry in std::fs::read_dir(dir)? {
329            let entry = entry?;
330            let path = entry.path();
331            if path.is_dir() {
332                traverse(&path, cb)?;
333            } else {
334                cb(&entry);
335            }
336        }
337    }
338    Ok(())
339}
340
341#[allow(clippy::too_many_lines)]
342pub fn setup(cli: &Cli) -> anyhow::Result<Option<OrdinaryLogger>> {
343    let logs_dir = match &cli.commands {
344        Commands::App { project, .. } => {
345            let config = OrdinaryConfig::get(project)?;
346
347            Path::new(&cli.global_args.data_dir)
348                .join("apps")
349                .join(config.domain)
350                .join("logs")
351        }
352        Commands::Init { api_init, .. } | Commands::Api { api_init, .. } => {
353            Path::new(&cli.global_args.data_dir)
354                .join("environments")
355                .join(&api_init.environment)
356                .join("logs")
357        }
358    };
359
360    std::fs::create_dir_all(&logs_dir)?;
361
362    let log_level_str = cli.global_args.log_level.to_string();
363
364    let directives = [
365        ("ordinary_config", &log_level_str),      // config
366        ("ordinary_studio", &log_level_str),      // studio
367        ("ordinary_doctor", &log_level_str),      // doctor
368        ("ordinary_build", &log_level_str),       // build
369        ("ordinary_modify", &log_level_str),      // modify
370        ("ordinary_utils", &log_level_str),       // utils
371        ("ordinary_auth", &log_level_str),        // auth
372        ("ordinary_api", &log_level_str),         // api
373        ("ordinary_app", &log_level_str),         // app
374        ("ordinary_template", &log_level_str),    // templates
375        ("ordinary_action", &log_level_str),      // actions
376        ("ordinary_integration", &log_level_str), // integrations
377        ("ordinary_storage", &log_level_str),     // storage
378        ("ordinary_monitor", &log_level_str),     // storage
379        ("tower_http", &log_level_str),           // http
380        ("axum::rejection", &"trace".into()),     // http
381        ("axum::serve", &log_level_str),          // http
382    ];
383
384    let mut directives_string = format!("ordinaryd={}", &log_level_str);
385
386    for (lib, lvl) in directives {
387        directives_string = format!("{directives_string},{lib}={lvl}");
388    }
389
390    let filter = tracing_subscriber::EnvFilter::builder()
391        .with_default_directive(cli.global_args.log_level.to_level_filter().into())
392        .parse(directives_string)?;
393
394    let logger = if cli.global_args.stored_logs || cli.global_args.stdio_logs {
395        let mut args = None;
396
397        if let Commands::Api { app_api, .. } = &cli.commands {
398            args = Some(app_api);
399        }
400
401        if let Commands::App { app_api, .. } = &cli.commands {
402            args = Some(app_api);
403        }
404
405        if let Some(AppApi {
406            log_ttl_hours,
407            log_rotation_file_size,
408            log_rotation_mins,
409            ..
410        }) = args
411        {
412            Some(OrdinaryLogger::new(
413                cli.global_args.stored_logs,
414                cli.global_args.stdio_logs,
415                &cli.global_args.stdio_logs_fmt.to_string(),
416                cli.global_args.journald_logs,
417                &logs_dir,
418                filter,
419                *log_ttl_hours,
420                *log_rotation_mins,
421                usize::try_from(*log_rotation_file_size)?,
422                LOG_FILE_FORMAT,
423                cli.global_args.log_sizes,
424                cli.global_args.stdio_logs_timing,
425            )?)
426        } else {
427            None
428        }
429    } else {
430        None
431    };
432
433    std::panic::set_hook(Box::new(|info| {
434        if let Some(msg) = info.payload_as_str()
435            && let Some(loc) = info.location()
436        {
437            tracing::error!(%loc, msg, "panic");
438        } else if let Some(loc) = info.location() {
439            tracing::error!(%loc, "panic");
440        }
441    }));
442
443    Ok(logger)
444}
445
446#[allow(clippy::too_many_lines, clippy::missing_panics_doc)]
447pub async fn run(cli: &Cli, logger: Option<OrdinaryLogger>) -> anyhow::Result<()> {
448    match &cli.commands {
449        Commands::App {
450            app_api,
451            project,
452            domain_override,
453        } => {
454            cmds::app::run(
455                project,
456                domain_override,
457                &cli.global_args.data_dir,
458                cli.global_args.log_sizes,
459                app_api.insecure,
460                app_api.insecure_cookies,
461                app_api.log_headers,
462                app_api.log_ips,
463                app_api.port,
464                app_api.redirect_port,
465                &app_api.provision,
466            )
467            .await?;
468        }
469        Commands::Init {
470            api_init,
471            api_domain,
472            password,
473            mfa_stored,
474            api_contacts,
475            app_domains,
476            privileged_domains,
477        } => {
478            cmds::init::run(
479                &api_init.environment,
480                api_domain,
481                password,
482                &cli.global_args.data_dir,
483                api_init.storage_size,
484                *mfa_stored,
485                api_contacts,
486                app_domains,
487                privileged_domains,
488                logger,
489            )
490            .await?;
491        }
492        Commands::Api {
493            api_init,
494            app_api,
495            dedicated_ports,
496            swagger,
497            ..
498        } => {
499            cmds::api::run(
500                &api_init.environment,
501                &cli.global_args.data_dir,
502                api_init.storage_size,
503                cli.global_args.log_sizes,
504                app_api.insecure,
505                app_api.insecure_cookies,
506                app_api.log_headers,
507                app_api.log_ips,
508                app_api.port,
509                app_api.redirect_port,
510                &app_api.provision,
511                cli.global_args.stored_logs,
512                logger,
513                &app_api.redacted_header_hash,
514                *dedicated_ports,
515                *swagger,
516            )
517            .await?;
518        }
519    }
520
521    Ok(())
522}