Skip to main content

acme_proxy/cli/
profile.rs

1//! `profile list` — the ACME endpoints this configuration mounts.
2//!
3//! The terminal's half of `GET /api/profiles`. Both render
4//! [`crate::admin::render_profile_json`], and they reach it from opposite
5//! directions: the API describes a **mounted** [`crate::Profile`], where this
6//! describes what the configuration on disk *would* mount.
7//!
8//! That difference is deliberate rather than a shortcut. Building the real
9//! thing means `Profile::build_all`, which constructs every signer backend —
10//! generating a CA key that does not exist yet, and contacting a relay's
11//! upstream — which is not a price a read-only listing should make an operator
12//! pay. `filter show` already draws the same line, and it cuts both ways: the
13//! panel is right about what is running, and this is the only one of the two
14//! that can be pointed at a configuration the server would refuse to start on.
15
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::admin::{self, ProfileSummary};
21use crate::cli::CliError;
22use crate::cli::render;
23use crate::cli::style::Palette;
24use crate::config::Config;
25
26#[derive(Subcommand)]
27pub enum ProfileCommand {
28    /// List the ACME endpoints this configuration mounts, name-sorted.
29    List {
30        #[arg(long)]
31        json: bool,
32    },
33}
34
35pub async fn run_profile_command(
36    command: ProfileCommand,
37    palette: Palette,
38    config: &Arc<Config>,
39) -> Result<(), CliError> {
40    match command {
41        ProfileCommand::List { json } => {
42            // The same call `serve` makes, so every startup refusal reaches an
43            // operator here too -- `filter show`'s reason for building rather
44            // than reading back. `resolve_profiles` has already dropped anything
45            // `enabled = false`, so there is no filter here: the list is the
46            // mounted set.
47            let resolved = config
48                .resolve_profiles()
49                .map_err(|error| CliError(format!("configuration error: {error}")))?;
50
51            let profiles: Vec<ProfileSummary> = resolved
52                .iter()
53                .map(|profile| ProfileSummary::configured(&config.server.base_url, profile))
54                .collect();
55
56            // Not `print_page`: this is a list an operator writes by hand in one
57            // file, so it has no page and nothing to report a total against --
58            // the argument the three paged listings had outgrown and this one
59            // has not.
60            if json {
61                let rendered: Vec<_> = profiles.iter().map(admin::render_profile_json).collect();
62                println!("{}", serde_json::Value::Array(rendered));
63            } else {
64                for profile in &profiles {
65                    println!("{}", render::render_profile_line(profile, palette));
66                }
67            }
68        }
69    }
70    Ok(())
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::config::ENV_LOCK;
77
78    /// Loads a `Config` the way the server does, so `resolve_profiles` has the
79    /// raw sources per-key inheritance needs — `cli::upstream`'s helper, and
80    /// for its reason.
81    fn config_from(body: &str) -> Arc<Config> {
82        let _lock = ENV_LOCK
83            .lock()
84            .unwrap_or_else(std::sync::PoisonError::into_inner);
85        let dir = crate::testutil::TempDir::new("profile");
86        std::fs::write(dir.join("config.toml"), body).unwrap();
87        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
88        unsafe {
89            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
90        }
91        let config = Config::load().expect("the configuration must load");
92        unsafe {
93            std::env::remove_var("ACME_PROXY_CONFIG");
94        }
95        Arc::new(config)
96    }
97
98    /// Name-sorted, `enabled = false` absent, and each summary carrying the
99    /// profile's *own* merged sections rather than the global ones.
100    #[test]
101    fn the_listing_is_the_profiles_this_configuration_would_mount() {
102        let config = config_from(
103            r#"
104            [server]
105            base_url = "https://ca.example.com"
106
107            [challenge]
108            bypass = false
109
110            [profiles.staging]
111            challenge.bypass = true
112
113            [profiles.le]
114            eab.enabled = true
115
116            [profiles.parked]
117            enabled = false
118            "#,
119        );
120
121        let resolved = config.resolve_profiles().unwrap();
122        let summaries: Vec<ProfileSummary> = resolved
123            .iter()
124            .map(|profile| ProfileSummary::configured(&config.server.base_url, profile))
125            .collect();
126
127        let names: Vec<&str> = summaries.iter().map(|p| p.name.as_str()).collect();
128        assert_eq!(names, ["le", "staging"], "parked is not mounted");
129
130        let le = &summaries[0];
131        assert_eq!(le.base_url, "https://ca.example.com/profile/le");
132        assert_eq!(
133            le.directory_url(),
134            "https://ca.example.com/profile/le/directory"
135        );
136        assert!(le.eab_enabled);
137        // Inherited from the global section, not reverted to the compiled
138        // default -- the per-key merge `Config::merged_sections` performs.
139        assert!(!le.challenge_bypass);
140        assert!(summaries[1].challenge_bypass, "staging overrode it");
141    }
142
143    /// Both output shapes run, and a configuration that resolves no profiles is
144    /// reported in words rather than printing an empty list -- `resolve_profiles`
145    /// refuses it, and this command is meant to surface exactly the startup
146    /// refusals `serve` would hit.
147    #[tokio::test]
148    async fn both_shapes_render_and_a_profileless_configuration_is_refused() {
149        let config = config_from("[profiles.default]\n");
150        for json in [false, true] {
151            run_profile_command(ProfileCommand::List { json }, Palette::plain(), &config)
152                .await
153                .unwrap();
154        }
155
156        let empty = Arc::new(Config::default());
157        let error = run_profile_command(
158            ProfileCommand::List { json: false },
159            Palette::plain(),
160            &empty,
161        )
162        .await
163        .expect_err("a configuration mounting nothing is not a listing of nothing");
164        assert!(
165            error.to_string().starts_with("configuration error: "),
166            "{error}"
167        );
168    }
169}