Skip to main content

canic_cli/install/
mod.rs

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