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#[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#[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 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 #[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
100fn 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
145pub 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
164fn 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
177fn parse_ready_timeout(value: &str) -> Result<u64, InstallCommandError> {
179 value
180 .parse::<u64>()
181 .map_err(|_| InstallCommandError::InvalidReadyTimeout(value.to_string()))
182}
183
184fn 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
192fn 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
201fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}