Skip to main content

asimov_cli/commands/module/
config.rs

1// This is free and unencumbered software released into the public domain.
2
3use crate::BoxError;
4use asimov_env::paths::asimov_root;
5use asimov_module::{ConfigurationVariable, ModuleManifest, ModuleName};
6use clientele::{
7    StandardOptions,
8    SysexitsError::*,
9    crates::clap::{Subcommand, builder::PossibleValuesParser},
10};
11use color_print::ceprintln;
12use std::{path::PathBuf, string::String, vec::Vec};
13
14#[derive(Debug, Subcommand)]
15pub enum ConfigCommand {
16    /// Show a module's configuration variables and their status
17    #[clap(alias = "list")]
18    Show {
19        /// The name of the module
20        name: ModuleName,
21
22        /// Set the output format [default: cli] [possible values: cli, json]
23        #[arg(value_name = "FORMAT", short = 'o', long)]
24        #[arg(value_parser = PossibleValuesParser::new(["cli", "json"]), hide_possible_values = true)]
25        output: Option<String>,
26    },
27
28    /// Print the value of a configuration variable
29    Get {
30        /// The name of the module
31        name: ModuleName,
32
33        /// The configuration variable to read
34        key: String,
35
36        /// Read the stored value only, ignoring the environment and any default
37        #[arg(long)]
38        stored: bool,
39    },
40
41    /// Set configuration variables
42    Set {
43        /// The name of the module
44        name: ModuleName,
45
46        /// The variables to set, as `key=value` pairs.
47        /// With --stdin, a single bare key instead.
48        #[arg(value_name = "KEY=VALUE", required_unless_present = "from_json")]
49        assignments: Vec<String>,
50
51        /// Read the value for a single key from standard input,
52        /// keeping it out of the command line
53        #[arg(long, conflicts_with = "from_json")]
54        stdin: bool,
55
56        /// Read a JSON object of key-value pairs from standard input
57        #[arg(long, conflicts_with = "assignments")]
58        from_json: bool,
59    },
60
61    /// Unset configuration variables
62    Unset {
63        /// The name of the module
64        name: ModuleName,
65
66        /// The configuration variables to unset
67        #[arg(required_unless_present = "all")]
68        keys: Vec<String>,
69
70        /// Unset every configuration variable of the module
71        #[arg(long, conflicts_with = "keys")]
72        all: bool,
73    },
74
75    /// Configure a module interactively
76    Setup {
77        /// The name of the module
78        name: ModuleName,
79    },
80}
81
82impl ConfigCommand {
83    pub async fn run(&self, flags: &StandardOptions) -> Result<(), BoxError> {
84        use ConfigCommand::*;
85        match self {
86            Show { name, output } => {
87                show::show(name, output.as_deref().unwrap_or("cli"), flags).await
88            },
89            Get { name, key, stored } => get::get(name, key, *stored, flags).await,
90            Set {
91                name,
92                assignments,
93                stdin,
94                from_json,
95            } => set::set(name, assignments, *stdin, *from_json, flags).await,
96            Unset { name, keys, all } => unset::unset(name, keys, *all, flags).await,
97            Setup { name } => setup::setup(name, flags).await,
98        }
99    }
100}
101
102mod get;
103mod set;
104mod setup;
105mod show;
106mod unset;
107
108/// Stands in for secret values, which are never displayed unless requested
109/// explicitly by name.
110pub(super) const MASK: &str = "******";
111
112/// An installed module together with the location of its configuration.
113pub(super) struct Module {
114    pub name: ModuleName,
115    pub manifest: ModuleManifest,
116    pub profile: &'static str,
117    pub conf_dir: PathBuf,
118}
119
120/// Reads the manifest of an installed module, rejecting manifests whose
121/// variable names cannot be used as file names.
122pub(super) async fn open(module_name: &ModuleName) -> Result<Module, BoxError> {
123    let manifest = asimov_registry::Registry::default()
124        .read_manifest(module_name)
125        .await
126        .map_err(|e| {
127            tracing::error!("failed to read manifest for module `{module_name}`: {e}");
128            if let asimov_registry::error::ManifestError::NotInstalled = e {
129                ceprintln!(
130                    "<s,dim>hint:</> Check if the module is installed with: <s>asimov module list</>"
131                );
132            }
133            EX_UNAVAILABLE
134        })?
135        .manifest;
136
137    // Variable names become file names under the configuration directory;
138    // reject anything that could escape it or hide files.
139    let is_valid_name = |name: &str| {
140        !name.is_empty()
141            && !name.starts_with('.')
142            && name
143                .chars()
144                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
145    };
146
147    let variables = manifest
148        .config
149        .as_ref()
150        .map(|c| c.variables.as_slice())
151        .unwrap_or_default();
152
153    if let Some(var) = variables.iter().find(|var| !is_valid_name(&var.name)) {
154        ceprintln!(
155            "<s,r>error:</> module <s>{module_name}</> declares an invalid configuration variable name: `{}`",
156            var.name
157        );
158        return Err(EX_DATAERR.into());
159    }
160
161    let profile = "default"; // TODO
162    let conf_dir = asimov_root()
163        .join("configs")
164        .join(profile)
165        .join(module_name.as_str());
166
167    Ok(Module {
168        name: module_name.clone(),
169        manifest,
170        profile,
171        conf_dir,
172    })
173}
174
175impl Module {
176    pub fn variables(&self) -> &[ConfigurationVariable] {
177        self.manifest
178            .config
179            .as_ref()
180            .map(|c| c.variables.as_slice())
181            .unwrap_or_default()
182    }
183
184    /// Looks up a declared variable, reporting unknown keys as a usage error.
185    pub fn variable(&self, key: &str) -> Result<&ConfigurationVariable, BoxError> {
186        self.variables()
187            .iter()
188            .find(|var| var.name == key)
189            .ok_or_else(|| {
190                ceprintln!(
191                    "<s,r>error:</> `{key}` is not the name of a configuration variable for <s>{}</> module",
192                    self.name
193                );
194                EX_USAGE.into()
195            })
196    }
197
198    /// Reports modules that declare no configuration variables as a usage
199    /// error, so that operating on their variables is never silently a no-op.
200    pub fn require_variables(&self) -> Result<&[ConfigurationVariable], BoxError> {
201        let variables = self.variables();
202        if variables.is_empty() {
203            ceprintln!(
204                "<s,r>error:</> module <s>{}</> has no configuration variables",
205                self.name
206            );
207            return Err(EX_USAGE.into());
208        }
209        Ok(variables)
210    }
211
212    pub fn var_file(&self, key: &str) -> PathBuf {
213        self.conf_dir.join(key)
214    }
215
216    /// Where the effective value of a variable comes from, in the same
217    /// precedence the SDK resolves them: environment, then stored, then default.
218    pub async fn source(&self, var: &ConfigurationVariable) -> Source {
219        if let Some(env_name) = var.environment.as_deref()
220            && std::env::var(env_name).is_ok()
221        {
222            return Source::Environment;
223        }
224        if tokio::fs::try_exists(self.var_file(&var.name))
225            .await
226            .unwrap_or(false)
227        {
228            return Source::Stored;
229        }
230        if var.default_value.is_some() {
231            return Source::Default;
232        }
233        Source::Unset
234    }
235
236    pub async fn create_conf_dir(&self) -> tokio::io::Result<()> {
237        tokio::fs::create_dir_all(&self.conf_dir)
238            .await
239            .inspect_err(|e| {
240                tracing::error!(
241                    "failed to create configuration directory for module `{}`: {e}",
242                    self.name
243                )
244            })
245    }
246
247    #[cfg(not(unix))]
248    pub async fn set_permissions(&self) -> tokio::io::Result<()> {
249        Ok(())
250    }
251
252    #[cfg(unix)]
253    pub async fn set_permissions(&self) -> tokio::io::Result<()> {
254        async {
255            use std::os::unix::fs::PermissionsExt;
256
257            let metadata = match tokio::fs::symlink_metadata(&self.conf_dir).await {
258                Ok(metadata) => metadata,
259                Err(e) if e.kind() == tokio::io::ErrorKind::NotFound => return Ok(()),
260                Err(e) => return Err(e),
261            };
262            if metadata.is_symlink() {
263                return Ok(());
264            }
265
266            let mut directories = vec![self.conf_dir.clone()];
267            while let Some(directory) = directories.pop() {
268                tokio::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
269                    .await?;
270
271                let mut entries = tokio::fs::read_dir(&directory).await?;
272                while let Some(entry) = entries.next_entry().await? {
273                    let path = entry.path();
274                    let metadata = tokio::fs::symlink_metadata(&path).await?;
275                    if metadata.is_dir() {
276                        directories.push(path);
277                    } else if metadata.is_file() {
278                        tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
279                            .await?;
280                    }
281                }
282            }
283
284            Ok(())
285        }
286        .await
287        .inspect_err(|e| {
288            tracing::error!(
289                "failed to set configuration permissions for module `{}`: {e}",
290                self.name
291            )
292        })
293    }
294}
295
296#[derive(Clone, Copy, PartialEq, Eq)]
297pub(super) enum Source {
298    Environment,
299    Stored,
300    Default,
301    Unset,
302}
303
304impl Source {
305    pub fn as_str(self) -> &'static str {
306        match self {
307            Source::Environment => "environment",
308            Source::Stored => "stored",
309            Source::Default => "default",
310            Source::Unset => "unset",
311        }
312    }
313}
314
315/// Prompts for one value on the terminal, hiding what is typed when the value
316/// is secret. Reads via the terminal rather than stdin, so that a buffered read
317/// cannot consume bytes a later prompt needs.
318pub(super) fn prompt_for_value(prompt: String, secret: bool) -> Result<String, BoxError> {
319    let input = if secret {
320        dialoguer::Password::new()
321            .with_prompt(prompt)
322            .allow_empty_password(true)
323            .interact()
324    } else {
325        dialoguer::Input::<String>::new()
326            .with_prompt(prompt)
327            .allow_empty(true)
328            .interact_text()
329    };
330
331    input.map_err(|e| {
332        tracing::error!("failed to read a value from the terminal: {e}");
333        EX_IOERR.into()
334    })
335}