Skip to main content

ordinaryd/
lib.rs

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