Skip to main content

canic_cli/install/
mod.rs

1use crate::{
2    args::{
3        first_arg_is_help, first_arg_is_version, parse_matches, string_option, string_values,
4        value_arg,
5    },
6    version_text,
7};
8use candid::Principal;
9use canic_host::install_root::{DEFAULT_FLEET_NAME, InstallRootOptions, install_root};
10use clap::{Arg, Command as ClapCommand};
11use std::{env, ffi::OsString};
12use thiserror::Error as ThisError;
13
14const DEFAULT_ROOT_TARGET: &str = "root";
15const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 120;
16
17///
18/// InstallCommandError
19///
20
21#[derive(Debug, ThisError)]
22pub enum InstallCommandError {
23    #[error("{0}")]
24    Usage(&'static str),
25
26    #[error("cannot provide both positional root target and --root")]
27    ConflictingRootTarget,
28
29    #[error("invalid --ready-timeout-seconds value {0}")]
30    InvalidReadyTimeout(String),
31
32    #[error(transparent)]
33    Install(#[from] Box<dyn std::error::Error>),
34}
35
36///
37/// InstallOptions
38///
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct InstallOptions {
42    pub fleet_name: String,
43    pub root_target: String,
44    pub root_build_target: String,
45    pub network: String,
46    pub ready_timeout_seconds: u64,
47    pub config_path: Option<String>,
48}
49
50impl InstallOptions {
51    /// Parse install options from CLI arguments and environment defaults.
52    pub fn parse<I>(args: I) -> Result<Self, InstallCommandError>
53    where
54        I: IntoIterator<Item = OsString>,
55    {
56        let matches = parse_matches(install_command(), args)
57            .map_err(|_| InstallCommandError::Usage(usage()))?;
58        let positional_targets = string_values(&matches, "root-target");
59        let flag_target = string_option(&matches, "root");
60        let root_target = resolve_root_target(positional_targets, flag_target)?;
61        let root_build_target = string_option(&matches, "root-build-target")
62            .unwrap_or_else(|| default_root_build_target(&root_target));
63        let ready_timeout_seconds = string_option(&matches, "ready-timeout-seconds")
64            .map(|value| parse_ready_timeout(&value))
65            .transpose()?
66            .unwrap_or_else(default_ready_timeout_seconds);
67
68        Ok(Self {
69            fleet_name: string_option(&matches, "fleet").unwrap_or_else(default_fleet_name),
70            root_target,
71            root_build_target,
72            network: string_option(&matches, "network").unwrap_or_else(default_network),
73            ready_timeout_seconds,
74            config_path: string_option(&matches, "config"),
75        })
76    }
77
78    /// Convert parsed CLI options into host install options.
79    #[must_use]
80    pub fn into_install_root_options(self) -> InstallRootOptions {
81        InstallRootOptions {
82            fleet_name: self.fleet_name,
83            root_canister: self.root_target,
84            root_build_target: self.root_build_target,
85            network: self.network,
86            ready_timeout_seconds: self.ready_timeout_seconds,
87            config_path: self.config_path,
88            interactive_config_selection: true,
89        }
90    }
91}
92
93// Build the install parser.
94fn install_command() -> ClapCommand {
95    ClapCommand::new("install")
96        .disable_help_flag(true)
97        .arg(Arg::new("root-target").num_args(0..))
98        .arg(value_arg("fleet").long("fleet"))
99        .arg(value_arg("root").long("root"))
100        .arg(value_arg("root-build-target").long("root-build-target"))
101        .arg(value_arg("config").long("config"))
102        .arg(value_arg("network").long("network"))
103        .arg(value_arg("ready-timeout-seconds").long("ready-timeout-seconds"))
104}
105
106/// Run the root install workflow.
107pub fn run<I>(args: I) -> Result<(), InstallCommandError>
108where
109    I: IntoIterator<Item = OsString>,
110{
111    let args = args.into_iter().collect::<Vec<_>>();
112    if first_arg_is_help(&args) {
113        println!("{}", usage());
114        return Ok(());
115    }
116    if first_arg_is_version(&args) {
117        println!("{}", version_text());
118        return Ok(());
119    }
120
121    let options = InstallOptions::parse(args)?;
122    install_root(options.into_install_root_options()).map_err(InstallCommandError::from)
123}
124
125// Resolve the install root target from positional and flag forms.
126fn resolve_root_target(
127    positional_targets: Vec<String>,
128    flag_target: Option<String>,
129) -> Result<String, InstallCommandError> {
130    match (positional_targets.as_slice(), flag_target) {
131        ([], None) => Ok(DEFAULT_ROOT_TARGET.to_string()),
132        ([], Some(target)) => Ok(target),
133        ([target], None) => Ok(target.clone()),
134        _ => Err(InstallCommandError::ConflictingRootTarget),
135    }
136}
137
138// Parse the operator-supplied readiness timeout.
139fn parse_ready_timeout(value: &str) -> Result<u64, InstallCommandError> {
140    value
141        .parse::<u64>()
142        .map_err(|_| InstallCommandError::InvalidReadyTimeout(value.to_string()))
143}
144
145// Resolve the install fleet name from environment defaults.
146fn default_fleet_name() -> String {
147    env::var("CANIC_FLEET").unwrap_or_else(|_| DEFAULT_FLEET_NAME.to_string())
148}
149
150// Resolve the DFX network from environment defaults.
151fn default_network() -> String {
152    env::var("DFX_NETWORK").unwrap_or_else(|_| "local".to_string())
153}
154
155// Resolve the readiness timeout from environment defaults.
156fn default_ready_timeout_seconds() -> u64 {
157    env::var("READY_TIMEOUT_SECONDS")
158        .ok()
159        .and_then(|value| value.parse::<u64>().ok())
160        .unwrap_or(DEFAULT_READY_TIMEOUT_SECONDS)
161}
162
163// Use the conventional root build target when the install target is a principal.
164fn default_root_build_target(root_target: &str) -> String {
165    if Principal::from_text(root_target).is_ok() {
166        DEFAULT_ROOT_TARGET.to_string()
167    } else {
168        root_target.to_string()
169    }
170}
171
172// Return install command usage text.
173const fn usage() -> &'static str {
174    "usage: canic install [root-target] [--fleet <name>] [--root <name-or-principal>] [--root-build-target <dfx-canister-name>] [--config <canic.toml>] [--network <name>] [--ready-timeout-seconds <seconds>]"
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    const ROOT_PRINCIPAL: &str = "uxrrr-q7777-77774-qaaaq-cai";
182
183    // Ensure install defaults to the conventional local root canister target.
184    #[test]
185    fn install_defaults_to_root_target() {
186        let options = InstallOptions::parse([]).expect("parse defaults");
187
188        assert_eq!(options.root_target, "root");
189        assert_eq!(options.root_build_target, "root");
190        assert_eq!(options.fleet_name, DEFAULT_FLEET_NAME);
191        assert_eq!(options.network, "local");
192        assert_eq!(options.ready_timeout_seconds, DEFAULT_READY_TIMEOUT_SECONDS);
193        assert_eq!(options.config_path, None);
194    }
195
196    // Ensure canister names are used for both build and install by default.
197    #[test]
198    fn install_accepts_positional_canister_name() {
199        let options =
200            InstallOptions::parse([OsString::from("custom_root")]).expect("parse root name");
201
202        assert_eq!(options.root_target, "custom_root");
203        assert_eq!(options.root_build_target, "custom_root");
204    }
205
206    // Ensure principal targets still build the conventional root artifact.
207    #[test]
208    fn install_accepts_principal_target() {
209        let options =
210            InstallOptions::parse([OsString::from(ROOT_PRINCIPAL)]).expect("parse principal");
211
212        assert_eq!(options.root_target, ROOT_PRINCIPAL);
213        assert_eq!(options.root_build_target, "root");
214    }
215
216    // Ensure --root accepts the same target syntax as the positional argument.
217    #[test]
218    fn install_accepts_root_flag() {
219        let options = InstallOptions::parse([
220            OsString::from("--root"),
221            OsString::from(ROOT_PRINCIPAL),
222            OsString::from("--network"),
223            OsString::from("local"),
224            OsString::from("--ready-timeout-seconds"),
225            OsString::from("30"),
226        ])
227        .expect("parse root flag");
228
229        assert_eq!(options.root_target, ROOT_PRINCIPAL);
230        assert_eq!(options.root_build_target, "root");
231        assert_eq!(options.network, "local");
232        assert_eq!(options.ready_timeout_seconds, 30);
233    }
234
235    // Ensure install accepts an explicit project config path.
236    #[test]
237    fn install_accepts_config_path() {
238        let options = InstallOptions::parse([
239            OsString::from("--config"),
240            OsString::from("canisters/demo/canic.toml"),
241            OsString::from("--fleet"),
242            OsString::from("demo"),
243        ])
244        .expect("parse config path");
245
246        assert_eq!(
247            options.config_path,
248            Some("canisters/demo/canic.toml".to_string())
249        );
250        assert_eq!(options.fleet_name, "demo");
251    }
252
253    // Ensure custom principal installs can override the build target explicitly.
254    #[test]
255    fn install_accepts_explicit_root_build_target() {
256        let options = InstallOptions::parse([
257            OsString::from("--root"),
258            OsString::from(ROOT_PRINCIPAL),
259            OsString::from("--root-build-target"),
260            OsString::from("custom_root"),
261        ])
262        .expect("parse build target");
263
264        assert_eq!(options.root_target, ROOT_PRINCIPAL);
265        assert_eq!(options.root_build_target, "custom_root");
266    }
267
268    // Ensure duplicate root target forms are rejected before mutation starts.
269    #[test]
270    fn install_rejects_duplicate_root_targets() {
271        let err = InstallOptions::parse([OsString::from("root"), OsString::from("--root=root")])
272            .expect_err("duplicate root target should fail");
273
274        assert!(matches!(err, InstallCommandError::ConflictingRootTarget));
275    }
276}