blob: d49494996537efbc5026a054006562d642b48eec [file] [log] [blame]
// Copyright 2023 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow::{ensure, Context as _};
use async_stream::stream;
use diagnostics_data::LogsData;
use ffx_config::TestEnv;
use ffx_isolate::Isolate;
use fuchsia_async::TimeoutExt;
use futures::channel::mpsc::TrySendError;
use futures::{Stream, StreamExt};
use serde::Deserialize;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::Mutex;
use std::time::Duration;
use tempfile::TempDir;
use tracing::info;
struct EmuState {
emu: std::process::Child,
}
/// An isolated environment for testing ffx against a running emulator.
pub struct IsolatedEmulator {
emu_name: String,
ffx_isolate: Isolate,
emu_state: Mutex<Option<EmuState>>,
children: Mutex<Vec<std::process::Child>>,
// We need to hold the below variables but not interact with them.
_temp_dir: TempDir,
_test_env: TestEnv,
}
impl IsolatedEmulator {
/// Create an isolated ffx environment and start an emulator in it using the default product
/// bundle and package repository from the Fuchsia build directory. Streams logs in the
/// background and allows resolving packages from universe.
pub async fn start(name: &str) -> anyhow::Result<Self> {
let amber_files_path = std::env::var("PACKAGE_REPOSITORY_PATH")
.expect("PACKAGE_REPOSITORY_PATH env var must be set -- run this test with 'fx test'");
Self::start_internal(name, Some(&amber_files_path)).await
}
// This is private to be used for testing with a path to a different package repo. Path
// to amber-files is optional for testing to ensure that other successful tests are actually
// matching a developer workflow.
async fn start_internal(name: &str, amber_files_path: Option<&str>) -> anyhow::Result<Self> {
let emu_name = format!("{name}-emu");
info!(%name, "making ffx isolate");
let temp_dir = tempfile::TempDir::new().context("making temp dir")?;
let test_env = ffx_config::test_init().await.context("setting up ffx test config")?;
// Create paths to the files to hold the ssh key pair.
// The key is not generated here, since ffx will generate the
// key if it is missing when starting an emulator or flashing a device.
// If a private key is supplied, it is used, but the public key path
// is still in the temp dir.
let ssh_priv_key = temp_dir.path().join("ssh_private_key");
let ssh_pub_key = temp_dir.path().join("ssh_public_key");
let ffx_isolate = Isolate::new_in_test(name, ssh_priv_key.clone(), &test_env.context)
.await
.context("creating ffx isolate")?;
let this = Self {
emu_name,
ffx_isolate,
_temp_dir: temp_dir,
_test_env: test_env,
emu_state: Mutex::new(None),
children: Mutex::new(vec![]),
};
// now we have our isolate and can call ffx commands to configure our env and start an emu
this.ffx(&["config", "set", "ssh.priv", &ssh_priv_key.to_string_lossy()])
.await
.context("setting ssh private key config")?;
this.ffx(&["config", "set", "ssh.pub", &ssh_pub_key.to_string_lossy()])
.await
.context("setting ssh public key config")?;
this.ffx(&["config", "set", "log.level", "debug"])
.await
.context("setting ffx log level")?;
// Use the daemon based repo server. This will probably need to become a parameter.
this.ffx(&["config", "set", "repository.server.enabled", "true"])
.await
.context("setting ffx log level")?;
// TODO(slgrady) remove once we have debugged the flake in which the ssh
// connection is never made
this.ffx(&["config", "set", "daemon.host_pipe_ssh_timeout", "110"])
.await
.context("setting ffx daemon ssh timeout")?;
this.ffx_isolate.start_daemon().await?;
info!("starting emulator {}", this.emu_name);
let emulator_log = this.ffx_isolate.log_dir().join("emulator.log").display().to_string();
let product_bundle_path = std::env::var("PRODUCT_BUNDLE_PATH")
.expect("PRODUCT_BUNDLE_PATH env var must be set -- run this test with 'fx test'");
let mut emulator_cmd = this
.ffx_isolate
.ffx_cmd(&[
"emu",
"start",
"--headless",
"--net",
"user",
"--name",
&this.emu_name,
"--log",
&*emulator_log,
// TODO(slgrady) remove once we have debugged the flake in which the
// ssh connection is never made
"--startup-timeout",
"120",
"--kernel-args",
"TERM=dumb",
&product_bundle_path,
])
.await
.context("creating emulator command")?;
let emu = emulator_cmd.spawn().context("spawning emulator command")?;
this.ffx(&["target", "wait"])
.on_timeout(Duration::from_secs(120), || anyhow::bail!("emulator never started"))
.await?;
*this.emu_state.lock().unwrap() = Some(EmuState { emu });
info!("streaming system logs to output directory");
let mut system_logs_command = this
.ffx_isolate
.ffx_cmd(&this.make_args(&["log", "--severity", "TRACE", "--no-color"]))
.await
.context("creating log streaming command")?;
let emulator_system_log =
std::fs::File::create(this.ffx_isolate.log_dir().join("system.log"))
.context("creating system log file")?;
system_logs_command.stdout(emulator_system_log);
// ffx log prints lots of warnings about symbolization
system_logs_command.stderr(std::process::Stdio::null());
this.children
.lock()
.unwrap()
.push(system_logs_command.spawn().context("spawning log streaming command")?);
// serve packages by creating a repository and a server, then registering the server
if let Some(amber_files_path) = amber_files_path {
this.ffx(&["repository", "add-from-pm", &amber_files_path])
.await
.context("adding repository from build dir")?;
this.ffx(&[
"repository",
"server",
"start",
// ask the kernel to give us a random unused port
"--address",
"[::]:0",
])
.await
.context("starting repository server")?;
this.ffx(&["target", "repository", "register", "--alias", "fuchsia.com"])
.await
.context("registering repository")?;
}
Ok(this)
}
fn make_args<'a>(&'a self, args: &[&'a str]) -> Vec<&str> {
let mut prefixed = vec!["--target", &self.emu_name];
prefixed.extend(args);
prefixed
}
/// Run an ffx command, logging stdout & stderr as INFO messages.
pub async fn ffx(&self, args: &[&str]) -> anyhow::Result<()> {
info!("running `ffx {args:?}`");
let output = self.ffx_isolate.ffx(&self.make_args(args)).await.context("running ffx")?;
if !output.stdout.is_empty() {
info!("stdout:\n{}", output.stdout);
}
if !output.stderr.is_empty() {
info!("stderr:\n{}", output.stderr);
}
ensure!(output.status.success(), "ffx must complete successfully");
Ok(())
}
/// Run an ffx command, returning stdout and logging stderr as an INFO message.
pub async fn ffx_output(&self, args: &[&str]) -> anyhow::Result<String> {
info!("running `ffx {args:?}`");
let output = self.ffx_isolate.ffx(&self.make_args(args)).await.context("running ffx")?;
if !output.stderr.is_empty() {
info!("stderr:\n{}", output.stderr);
}
ensure!(
output.status.success(),
"ffx must complete successfully. stdout: {}",
output.stdout
);
Ok(output.stdout)
}
/// Create an ffx command, which allows for streaming stdout/stderr.
pub async fn ffx_cmd_capture(&self, args: &[&str]) -> anyhow::Result<Command> {
let mut cmd =
self.ffx_isolate.ffx_cmd(&self.make_args(args)).await.context("running ffx")?;
cmd.stdout(Stdio::piped());
Ok(cmd)
}
fn make_ssh_args<'a>(command: &[&'a str]) -> Vec<&'a str> {
let mut args = vec!["target", "ssh", "--"];
args.extend(command);
args
}
/// Run an ssh command, logging stdout & stderr as INFO messages.
pub async fn ssh(&self, command: &[&str]) -> anyhow::Result<()> {
self.ffx(&Self::make_ssh_args(command)).await
}
/// Run an ssh command, returning stdout and logging stderr as an INFO message.
pub async fn ssh_output(&self, command: &[&str]) -> anyhow::Result<String> {
self.ffx_output(&Self::make_ssh_args(command)).await
}
async fn log_stream(
&self,
mut receiver: futures::channel::mpsc::UnboundedReceiver<String>,
reader_task: fuchsia_async::Task<Result<(), TrySendError<String>>>,
) -> impl Stream<Item = anyhow::Result<LogsData>> {
/// ffx log wraps each line from archivist in its own JSON object, unwrap those here
#[derive(Deserialize)]
struct FfxMachineLogLine {
data: FfxTargetLog,
}
#[derive(Deserialize)]
struct FfxTargetLog {
#[serde(rename = "TargetLog")]
target_log: LogsData,
}
stream! {
while let Some(line) = receiver.next().await {
if line.is_empty() {
continue;
}
let ffx_message = serde_json::from_str::<FfxMachineLogLine>(&line)
.context("parsing log line from ffx")?;
yield Ok(ffx_message.data.target_log);
}
drop(reader_task)
}
}
/// Collect the logs for a particular component.
pub async fn log_stream_for_moniker(
&self,
moniker: &str,
) -> anyhow::Result<impl Stream<Item = anyhow::Result<LogsData>>> {
let mut output = self
.ffx_cmd_capture(&["--machine", "json", "log", "--moniker", moniker])
.await
.context("running ffx log")?;
let mut child = output.spawn()?;
let stdout = child.stdout.take().context("no stdout")?;
self.children.lock().unwrap().push(child);
let mut reader = BufReader::new(stdout);
let (sender, receiver) = futures::channel::mpsc::unbounded();
let reader_task = fuchsia_async::Task::local(fuchsia_async::unblock(move || {
let mut output = String::new();
while let Ok(_) = reader.read_line(&mut output) {
sender.unbounded_send(output)?;
output = String::new();
}
Result::<(), TrySendError<String>>::Ok(())
}));
Ok(self.log_stream(receiver, reader_task).await)
}
/// Collect the logs for a particular component.
pub async fn logs_for_moniker(&self, moniker: &str) -> anyhow::Result<Vec<LogsData>> {
/// ffx log wraps each line from archivist in its own JSON object, unwrap those here
#[derive(Deserialize)]
struct FfxMachineLogLine {
data: FfxTargetLog,
}
#[derive(Deserialize)]
struct FfxTargetLog {
#[serde(rename = "TargetLog")]
target_log: LogsData,
}
let output = self
.ffx_output(&["--machine", "json", "log", "--moniker", moniker, "dump"])
.await
.context("running ffx log")?;
let mut parsed = vec![];
for line in output.lines() {
if line.is_empty() {
continue;
}
let ffx_message = serde_json::from_str::<FfxMachineLogLine>(line)
.context("parsing log line from ffx")?;
parsed.push(ffx_message.data.target_log);
}
Ok(parsed)
}
// TODO(slgrady): remove when some variation of fxr/907483 gets added
pub async fn stop(&self) {
self.ffx(&["emu", "stop", &self.emu_name]).await.expect("emu stop failed");
let mut emu = self.emu_state.lock().unwrap();
if let Some(ref mut c) = emu.as_mut() {
c.emu.kill().ok();
}
}
}
impl Drop for IsolatedEmulator {
fn drop(&mut self) {
if !self.children.lock().unwrap().is_empty() {
// allow children to clean up, including streaming a few logs out
std::thread::sleep(std::time::Duration::from_secs(1));
let mut children = self.children.lock().unwrap();
for child in children.iter_mut() {
child.kill().ok();
}
}
let mut emu = self.emu_state.lock().unwrap();
if let Some(ref mut c) = emu.take() {
c.emu.kill().ok();
}
info!(
"Tearing down isolated emulator instance. Logs are in {}.",
self.ffx_isolate.log_dir().display()
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::pin;
#[fuchsia::test]
async fn public_apis_succeed() {
// TODO(slgrady) change back to start() when we have debugged the flake in which the ssh
// connection is never made
let amber_files_path = std::env::var("PACKAGE_REPOSITORY_PATH")
.expect("PACKAGE_REPOSITORY_PATH env var must be set -- run this test with 'fx test'");
let emu = IsolatedEmulator::start_internal("e2e_emu_public_apis", Some(&amber_files_path))
.await
.expect("Couldn't start emulator");
info!("Checking target monotonic time to ensure we can connect and get stdout");
let time = emu.ffx_output(&["target", "get-time"]).await.unwrap();
time.trim().parse::<u64>().expect("should have gotten a timestamp back");
info!("Checking that the emulator instance writes a system log.");
let system_log_path = emu.ffx_isolate.log_dir().join("system.log");
loop {
let contents = std::fs::read_to_string(&system_log_path).unwrap();
if !contents.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
info!("Checking that we can read streaming logs.");
let mut remote_control_logs =
pin!(emu.log_stream_for_moniker("/core/remote-control").await.unwrap());
remote_control_logs.next().await.unwrap().unwrap();
info!("Checking that we can read RCS' logs.");
let remote_control_logs = emu.logs_for_moniker("/core/remote-control").await.unwrap();
assert_eq!(remote_control_logs.is_empty(), false);
emu.stop().await;
}
#[fuchsia::test]
async fn resolve_package_from_server() {
let test_amber_files_path = std::env::var("TEST_PACKAGE_REPOSITORY_PATH").expect(
"TEST_PACKAGE_REPOSITORY_PATH env var must be set -- run this test with 'fx test'",
);
let test_package_name = std::env::var("TEST_PACKAGE_NAME")
.expect("TEST_PACKAGE_NAME env var must be set -- run this test with 'fx test'");
let test_package_url = format!("fuchsia-pkg://fuchsia.com/{test_package_name}");
let emu = IsolatedEmulator::start_internal("pkg_resolve", Some(&test_amber_files_path))
.await
.unwrap();
emu.ssh(&["pkgctl", "resolve", &test_package_url]).await.unwrap();
emu.stop().await;
}
/// This ensures the above test is actually resolving the package from the package server by
/// demonstrating that the same package is unavailable when there's no server running.
#[fuchsia::test]
async fn fail_to_resolve_package_when_no_package_server_running() {
let emu = IsolatedEmulator::start_internal("pkg_resolve_fail", None).await.unwrap();
let test_package_name = std::env::var("TEST_PACKAGE_NAME")
.expect("TEST_PACKAGE_NAME env var must be set -- run this test with 'fx test'");
let test_package_url = format!("fuchsia-pkg://fuchsia.com/{test_package_name}");
emu.ssh(&["pkgctl", "resolve", &test_package_url]).await.unwrap_err();
emu.stop().await;
}
}