nimbus_cli/
cmd.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5#[cfg(feature = "server")]
6use crate::output::server;
7use crate::{
8    output::{deeplink, fml_cli},
9    protocol::StartAppProtocol,
10    sources::ManifestSource,
11    value_utils::{
12        self, prepare_experiment, prepare_rollout, try_find_branches_from_experiment,
13        try_find_features_from_branch, CliUtils,
14    },
15    AppCommand, AppOpenArgs, ExperimentListSource, ExperimentSource, LaunchableApp, NimbusApp,
16};
17use anyhow::{bail, Result};
18use nimbus_fml::intermediate_representation::FeatureManifest;
19use serde_json::{json, Value};
20use std::io::Write;
21use std::path::PathBuf;
22use std::process::Command;
23use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
24
25pub(crate) fn process_cmd(cmd: &AppCommand) -> Result<bool> {
26    let status = match cmd {
27        AppCommand::ApplyFile {
28            app,
29            open,
30            list,
31            preserve_nimbus_db,
32        } => app.apply_list(open, list, preserve_nimbus_db)?,
33        AppCommand::CaptureLogs { app, file } => app.capture_logs(file)?,
34        AppCommand::Defaults {
35            manifest,
36            feature_id,
37            output,
38        } => manifest.print_defaults(feature_id.as_ref(), output.as_ref())?,
39        AppCommand::Enroll {
40            app,
41            params,
42            experiment,
43            rollouts,
44            branch,
45            preserve_targeting,
46            preserve_bucketing,
47            preserve_nimbus_db,
48            open,
49            ..
50        } => app.enroll(
51            params,
52            experiment,
53            rollouts,
54            branch,
55            preserve_targeting,
56            preserve_bucketing,
57            preserve_nimbus_db,
58            open,
59        )?,
60        AppCommand::ExtractFeatures {
61            experiment,
62            branch,
63            manifest,
64            feature_id,
65            validate,
66            multi,
67            output,
68        } => experiment.print_features(
69            branch,
70            manifest,
71            feature_id.as_ref(),
72            *validate,
73            *multi,
74            output.as_ref(),
75        )?,
76
77        AppCommand::FetchList { list, file } => list.fetch_list(file.as_ref())?,
78        AppCommand::FmlPassthrough { args, cwd } => fml_cli(args, cwd)?,
79        AppCommand::Info { experiment, output } => experiment.print_info(output.as_ref())?,
80        AppCommand::Kill { app } => app.kill_app()?,
81        AppCommand::List { list, .. } => list.print_list()?,
82        AppCommand::LogState { app, open } => app.log_state(open)?,
83        AppCommand::NoOp => true,
84        AppCommand::Open {
85            app, open: args, ..
86        } => app.open(args)?,
87        AppCommand::Reset { app } => app.reset_app()?,
88        #[cfg(feature = "server")]
89        AppCommand::StartServer => server::start_server()?,
90        AppCommand::TailLogs { app } => app.tail_logs()?,
91        AppCommand::Unenroll { app, open } => app.unenroll_all(open)?,
92        AppCommand::ValidateExperiment {
93            params,
94            manifest,
95            experiment,
96        } => params.validate_experiment(manifest, experiment)?,
97    };
98
99    Ok(status)
100}
101
102fn prompt(stream: &mut StandardStream, command: &str) -> Result<()> {
103    stream.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)))?;
104    write!(stream, "$ ")?;
105    stream.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
106    writeln!(stream, "{}", command)?;
107    stream.reset()?;
108
109    Ok(())
110}
111
112fn output_ok(stream: &mut StandardStream, title: &str) -> Result<()> {
113    write!(stream, "✅ ")?;
114    stream.set_color(ColorSpec::new().set_fg(Some(Color::Green)))?;
115    writeln!(stream, "{title}")?;
116    stream.reset()?;
117
118    Ok(())
119}
120
121fn output_err(stream: &mut StandardStream, title: &str, detail: &str) -> Result<()> {
122    write!(stream, "❎ ")?;
123    stream.set_color(ColorSpec::new().set_fg(Some(Color::Red)))?;
124    write!(stream, "{title}")?;
125    stream.reset()?;
126    writeln!(stream, ": {detail}")?;
127
128    Ok(())
129}
130
131impl LaunchableApp {
132    #[cfg(feature = "server")]
133    fn platform(&self) -> &str {
134        match self {
135            Self::Android { .. } => "android",
136            Self::Ios { .. } => "ios",
137        }
138    }
139
140    fn exe(&self) -> Result<Command> {
141        Ok(match self {
142            Self::Android { device_id, .. } => {
143                let adb_name = if std::env::consts::OS != "windows" {
144                    "adb"
145                } else {
146                    "adb.exe"
147                };
148                let adb = std::env::var("ADB_PATH").unwrap_or_else(|_| adb_name.to_string());
149                let mut cmd = Command::new(adb);
150                if let Some(id) = device_id {
151                    cmd.args(["-s", id]);
152                }
153                cmd
154            }
155            Self::Ios { .. } => {
156                if std::env::consts::OS != "macos" {
157                    panic!("Cannot run commands for iOS on anything except macOS");
158                }
159                let xcrun = std::env::var("XCRUN_PATH").unwrap_or_else(|_| "xcrun".to_string());
160                let mut cmd = Command::new(xcrun);
161                cmd.arg("simctl");
162                cmd
163            }
164        })
165    }
166
167    fn kill_app(&self) -> Result<bool> {
168        Ok(match self {
169            Self::Android { package_name, .. } => self
170                .exe()?
171                .arg("shell")
172                .arg(format!("am force-stop {}", package_name))
173                .spawn()?
174                .wait()?
175                .success(),
176            Self::Ios {
177                app_id, device_id, ..
178            } => {
179                let _ = self
180                    .exe()?
181                    .args(["terminate", device_id, app_id])
182                    .output()?;
183                true
184            }
185        })
186    }
187
188    fn unenroll_all(&self, open: &AppOpenArgs) -> Result<bool> {
189        let payload = TryFrom::try_from(&ExperimentListSource::Empty)?;
190        let protocol = StartAppProtocol {
191            log_state: true,
192            experiments: Some(&payload),
193            ..Default::default()
194        };
195        self.start_app(protocol, open)
196    }
197
198    fn reset_app(&self) -> Result<bool> {
199        Ok(match self {
200            Self::Android { package_name, .. } => self
201                .exe()?
202                .arg("shell")
203                .arg(format!("pm clear {}", package_name))
204                .spawn()?
205                .wait()?
206                .success(),
207            Self::Ios {
208                app_id, device_id, ..
209            } => {
210                self.exe()?
211                    .args(["privacy", device_id, "reset", "all", app_id])
212                    .status()?;
213                let data = self.ios_app_container("data")?;
214                let groups = self.ios_app_container("groups")?;
215                self.ios_reset(data, groups)?;
216                true
217            }
218        })
219    }
220
221    fn tail_logs(&self) -> Result<bool> {
222        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
223
224        Ok(match self {
225            Self::Android { .. } => {
226                let mut args = logcat_args();
227                args.append(&mut vec!["-v", "color"]);
228                prompt(&mut stdout, &format!("adb {}", args.join(" ")))?;
229                self.exe()?.args(args).spawn()?.wait()?.success()
230            }
231            Self::Ios { .. } => {
232                prompt(
233                    &mut stdout,
234                    &format!("{} | xargs tail -f", self.ios_log_file_command()),
235                )?;
236                let log = self.ios_log_file()?;
237
238                Command::new("tail")
239                    .arg("-f")
240                    .arg(log.as_path().to_str().unwrap())
241                    .spawn()?
242                    .wait()?
243                    .success()
244            }
245        })
246    }
247
248    fn capture_logs(&self, file: &PathBuf) -> Result<bool> {
249        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
250
251        Ok(match self {
252            Self::Android { .. } => {
253                let mut args = logcat_args();
254                args.append(&mut vec!["-d"]);
255                prompt(
256                    &mut stdout,
257                    &format!(
258                        "adb {} > {}",
259                        args.join(" "),
260                        file.as_path().to_str().unwrap()
261                    ),
262                )?;
263                let output = self.exe()?.args(args).output()?;
264                std::fs::write(file, String::from_utf8_lossy(&output.stdout).to_string())?;
265                true
266            }
267
268            Self::Ios { .. } => {
269                let log = self.ios_log_file()?;
270                prompt(
271                    &mut stdout,
272                    &format!(
273                        "{} | xargs -J %log_file% cp %log_file% {}",
274                        self.ios_log_file_command(),
275                        file.as_path().to_str().unwrap()
276                    ),
277                )?;
278                std::fs::copy(log, file)?;
279                true
280            }
281        })
282    }
283
284    fn ios_log_file(&self) -> Result<PathBuf> {
285        let data = self.ios_app_container("data")?;
286        let mut files = glob::glob(&format!("{}/**/*.log", data))?;
287        let log = files.next();
288        Ok(log.ok_or_else(|| {
289            anyhow::Error::msg(
290                "Logs are not available before the app is started for the first time",
291            )
292        })??)
293    }
294
295    fn ios_log_file_command(&self) -> String {
296        if let Self::Ios {
297            device_id, app_id, ..
298        } = self
299        {
300            format!(
301                "find $(xcrun simctl get_app_container {0} {1} data) -name \\*.log",
302                device_id, app_id
303            )
304        } else {
305            unreachable!()
306        }
307    }
308
309    fn log_state(&self, open: &AppOpenArgs) -> Result<bool> {
310        let protocol = StartAppProtocol {
311            log_state: true,
312            ..Default::default()
313        };
314        self.start_app(protocol, open)
315    }
316
317    #[allow(clippy::too_many_arguments)]
318    fn enroll(
319        &self,
320        params: &NimbusApp,
321        experiment: &ExperimentSource,
322        rollouts: &Vec<ExperimentSource>,
323        branch: &str,
324        preserve_targeting: &bool,
325        preserve_bucketing: &bool,
326        preserve_nimbus_db: &bool,
327        open: &AppOpenArgs,
328    ) -> Result<bool> {
329        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
330
331        let experiment = Value::try_from(experiment)?;
332        let slug = experiment.get_str("slug")?.to_string();
333
334        let mut recipes = vec![prepare_experiment(
335            &experiment,
336            params,
337            branch,
338            *preserve_targeting,
339            *preserve_bucketing,
340        )?];
341        prompt(
342            &mut stdout,
343            &format!("# Enrolling in the '{0}' branch of '{1}'", branch, &slug),
344        )?;
345
346        for r in rollouts {
347            let rollout = Value::try_from(r)?;
348            let slug = rollout.get_str("slug")?.to_string();
349            recipes.push(prepare_rollout(
350                &rollout,
351                params,
352                *preserve_targeting,
353                *preserve_bucketing,
354            )?);
355            prompt(
356                &mut stdout,
357                &format!("# Enrolling into the '{0}' rollout", &slug),
358            )?;
359        }
360
361        let payload = json! {{ "data": recipes }};
362        let protocol = StartAppProtocol {
363            reset_db: !preserve_nimbus_db,
364            experiments: Some(&payload),
365            log_state: true,
366        };
367        self.start_app(protocol, open)
368    }
369
370    fn apply_list(
371        &self,
372        open: &AppOpenArgs,
373        list: &ExperimentListSource,
374        preserve_nimbus_db: &bool,
375    ) -> Result<bool> {
376        let value: Value = list.try_into()?;
377
378        let protocol = StartAppProtocol {
379            reset_db: !preserve_nimbus_db,
380            experiments: Some(&value),
381            log_state: true,
382        };
383        self.start_app(protocol, open)
384    }
385
386    fn ios_app_container(&self, container: &str) -> Result<String> {
387        if let Self::Ios {
388            app_id, device_id, ..
389        } = self
390        {
391            // We need to get the app container directories, and delete them.
392            let output = self
393                .exe()?
394                .args(["get_app_container", device_id, app_id, container])
395                .output()
396                .expect("Expected an app-container from the simulator");
397            let string = String::from_utf8_lossy(&output.stdout).to_string();
398            Ok(string.trim().to_string())
399        } else {
400            unreachable!()
401        }
402    }
403
404    fn ios_reset(&self, data_dir: String, groups_string: String) -> Result<bool> {
405        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
406        prompt(&mut stdout, "# Resetting the app")?;
407        if !data_dir.is_empty() {
408            prompt(&mut stdout, &format!("rm -Rf {}/* 2>/dev/null", data_dir))?;
409            let _ = std::fs::remove_dir_all(&data_dir);
410            let _ = std::fs::create_dir_all(&data_dir);
411        }
412        let lines = groups_string.split('\n');
413
414        for line in lines {
415            let words = line.splitn(2, '\t').collect::<Vec<_>>();
416            if let [_, dir] = words.as_slice() {
417                if !dir.is_empty() {
418                    prompt(&mut stdout, &format!("rm -Rf {}/* 2>/dev/null", dir))?;
419                    let _ = std::fs::remove_dir_all(dir);
420                    let _ = std::fs::create_dir_all(dir);
421                }
422            }
423        }
424        Ok(true)
425    }
426
427    fn open(&self, open: &AppOpenArgs) -> Result<bool> {
428        self.start_app(Default::default(), open)
429    }
430
431    fn start_app(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<bool> {
432        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
433        if open.pbcopy {
434            let len = self.copy_to_clipboard(&app_protocol, open)?;
435            prompt(
436                &mut stdout,
437                &format!("# Copied a deeplink URL ({len} characters) in to the clipboard"),
438            )?;
439        }
440        #[cfg(feature = "server")]
441        if open.pbpaste {
442            let url = self.longform_url(&app_protocol, open)?;
443            let addr = server::get_address()?;
444            match server::post_deeplink(self.platform(), &url, app_protocol.experiments) {
445                Err(_) => output_err(
446                    &mut stdout,
447                    "Cannot post to the server",
448                    "Start the server with `nimbus-cli start-server`",
449                )?,
450                _ => output_ok(&mut stdout, &format!("Posted to server at http://{addr}"))?,
451            };
452        }
453        if let Some(file) = &open.output {
454            let ex = app_protocol.experiments;
455            if let Some(contents) = ex {
456                value_utils::write_to_file_or_print(Some(file), contents)?;
457                output_ok(
458                    &mut stdout,
459                    &format!(
460                        "Written to JSON to file {}",
461                        file.to_str().unwrap_or_default()
462                    ),
463                )?;
464            } else {
465                output_err(
466                    &mut stdout,
467                    "No content",
468                    &format!("File {} not written", file.to_str().unwrap_or_default()),
469                )?;
470            }
471        }
472        if open.pbcopy || open.pbpaste || open.output.is_some() {
473            return Ok(true);
474        }
475
476        Ok(match self {
477            Self::Android { .. } => self
478                .android_start(app_protocol, open)?
479                .spawn()?
480                .wait()?
481                .success(),
482            Self::Ios { .. } => self
483                .ios_start(app_protocol, open)?
484                .spawn()?
485                .wait()?
486                .success(),
487        })
488    }
489
490    fn android_start(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<Command> {
491        if let Self::Android {
492            package_name,
493            activity_name,
494            ..
495        } = self
496        {
497            let mut args: Vec<String> = Vec::new();
498
499            let (start_args, ending_args) = open.args();
500            args.extend_from_slice(start_args);
501
502            if let Some(deeplink) = self.deeplink(open)? {
503                args.extend([
504                    "-a android.intent.action.VIEW".to_string(),
505                    "-c android.intent.category.DEFAULT".to_string(),
506                    "-c android.intent.category.BROWSABLE".to_string(),
507                    format!("-d {}", deeplink),
508                ]);
509            } else {
510                args.extend([
511                    format!("-n {}/{}", package_name, activity_name),
512                    "-a android.intent.action.MAIN".to_string(),
513                    "-c android.intent.category.LAUNCHER".to_string(),
514                ]);
515            }
516
517            let StartAppProtocol {
518                reset_db,
519                experiments,
520                log_state,
521            } = app_protocol;
522
523            if log_state || experiments.is_some() || reset_db {
524                args.extend(["--esn nimbus-cli".to_string(), "--ei version 1".to_string()]);
525            }
526
527            if reset_db {
528                args.push("--ez reset-db true".to_string());
529            }
530            if let Some(s) = experiments {
531                let json = s.to_string().replace('\'', "&apos;");
532                args.push(format!("--es experiments '{}'", json))
533            }
534            if log_state {
535                args.push("--ez log-state true".to_string());
536            };
537            args.extend_from_slice(ending_args);
538
539            let sh = format!(r#"am start {}"#, args.join(" \\\n        "),);
540            let mut stdout = StandardStream::stdout(ColorChoice::Auto);
541            prompt(&mut stdout, &format!("adb shell \"{}\"", sh))?;
542            let mut cmd = self.exe()?;
543            cmd.arg("shell").arg(&sh);
544            Ok(cmd)
545        } else {
546            unreachable!();
547        }
548    }
549
550    fn ios_start(&self, app_protocol: StartAppProtocol, open: &AppOpenArgs) -> Result<Command> {
551        if let Self::Ios {
552            app_id, device_id, ..
553        } = self
554        {
555            let mut args: Vec<String> = Vec::new();
556
557            let (starting_args, ending_args) = open.args();
558
559            if let Some(deeplink) = self.deeplink(open)? {
560                let deeplink = deeplink::longform_deeplink_url(&deeplink, &app_protocol)?;
561                if deeplink.len() >= 2047 {
562                    anyhow::bail!("Deeplink is too long for xcrun simctl openurl. Use --pbcopy to copy the URL to the clipboard")
563                }
564                args.push("openurl".to_string());
565                args.extend_from_slice(starting_args);
566                args.extend([device_id.to_string(), deeplink]);
567            } else {
568                args.push("launch".to_string());
569                args.extend_from_slice(starting_args);
570                args.extend([device_id.to_string(), app_id.to_string()]);
571
572                let StartAppProtocol {
573                    log_state,
574                    experiments,
575                    reset_db,
576                } = app_protocol;
577
578                if log_state || experiments.is_some() || reset_db {
579                    args.extend([
580                        "--nimbus-cli".to_string(),
581                        "--version".to_string(),
582                        "1".to_string(),
583                    ]);
584                }
585
586                if reset_db {
587                    // We don't check launch here, because reset-db is never used
588                    // without enroll.
589                    args.push("--reset-db".to_string());
590                }
591                if let Some(s) = experiments {
592                    args.extend([
593                        "--experiments".to_string(),
594                        s.to_string().replace('\'', "&apos;"),
595                    ]);
596                }
597                if log_state {
598                    args.push("--log-state".to_string());
599                }
600            }
601            args.extend_from_slice(ending_args);
602
603            let mut cmd = self.exe()?;
604            cmd.args(args.clone());
605
606            let sh = format!(r#"xcrun simctl {}"#, args.join(" \\\n        "),);
607            let mut stdout = StandardStream::stdout(ColorChoice::Auto);
608            prompt(&mut stdout, &sh)?;
609            Ok(cmd)
610        } else {
611            unreachable!()
612        }
613    }
614}
615
616fn logcat_args<'a>() -> Vec<&'a str> {
617    vec!["logcat", "-b", "main"]
618}
619
620impl NimbusApp {
621    fn validate_experiment(
622        &self,
623        manifest_source: &ManifestSource,
624        experiment: &ExperimentSource,
625    ) -> Result<bool> {
626        let mut stdout = StandardStream::stdout(ColorChoice::Auto);
627        let value: Value = experiment.try_into()?;
628
629        let manifest = match TryInto::<FeatureManifest>::try_into(manifest_source) {
630            Ok(manifest) => {
631                output_ok(
632                    &mut stdout,
633                    &format!("Loaded manifest from {manifest_source}"),
634                )?;
635                manifest
636            }
637            Err(err) => {
638                output_err(
639                    &mut stdout,
640                    &format!("Problem with manifest from {manifest_source}"),
641                    &err.to_string(),
642                )?;
643                bail!("Error when loading and validating the manifest");
644            }
645        };
646
647        let mut is_valid = true;
648        for b in try_find_branches_from_experiment(&value)? {
649            let branch = b.get_str("slug")?;
650            for f in try_find_features_from_branch(&b)? {
651                let id = f.get_str("featureId")?;
652                let value = f
653                    .get("value")
654                    .unwrap_or_else(|| panic!("Branch {branch} feature {id} has no value"));
655                let res = manifest.validate_feature_config(id, value.clone());
656                match res {
657                    Ok(_) => output_ok(&mut stdout, &format!("{branch: <15} {id}"))?,
658                    Err(err) => {
659                        is_valid = false;
660                        output_err(
661                            &mut stdout,
662                            &format!("{branch: <15} {id}"),
663                            &err.to_string(),
664                        )?
665                    }
666                }
667            }
668        }
669        if !is_valid {
670            bail!("At least one error detected");
671        }
672        Ok(true)
673    }
674}