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