| // Copyright 2020 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 { |
| crate::{setup::DevhostConfig, storage::Storage}, |
| anyhow::{Context, Error}, |
| fidl::endpoints::ClientEnd, |
| fidl_fuchsia_io::DirectoryMarker, |
| fidl_fuchsia_paver as fpaver, fuchsia_async as fasync, |
| fuchsia_component::server::ServiceFs, |
| fuchsia_zircon as zx, |
| futures::prelude::*, |
| hyper::Uri, |
| isolated_ota::download_and_apply_update, |
| serde_json::{json, Value}, |
| std::{fs::File, str::FromStr}, |
| }; |
| |
| const BOARD_NAME_PATH: &str = "/config/build-info/board"; |
| |
| enum PaverType { |
| /// Use the real paver. |
| Real, |
| /// Use a fake paver, which can be connected to using the given connector. |
| #[allow(dead_code)] |
| Fake { connector: ClientEnd<DirectoryMarker> }, |
| } |
| |
| enum OtaType { |
| /// Ota from a devhost. |
| Devhost { cfg: DevhostConfig }, |
| /// Ota from a well-known location. TODO(simonshields): implement this. |
| WellKnown, |
| } |
| |
| enum BoardName { |
| /// Use board name from /config/build-info. |
| BuildInfo, |
| /// Override board name with given value. |
| #[allow(dead_code)] |
| Override { name: String }, |
| } |
| |
| enum StorageType { |
| /// Use real storage (i.e. wipe disk and use the real FVM) |
| Real, |
| /// Use the given DirectoryMarker for blobfs, and the given path for minfs. |
| #[allow(dead_code)] |
| Fake { blobfs_root: ClientEnd<DirectoryMarker>, minfs_path: String }, |
| } |
| |
| /// Helper for constructing OTAs. |
| pub struct OtaEnvBuilder { |
| board_name: BoardName, |
| ota_type: OtaType, |
| paver: PaverType, |
| ssl_certificates: String, |
| storage_type: StorageType, |
| } |
| |
| impl OtaEnvBuilder { |
| pub fn new() -> Self { |
| OtaEnvBuilder { |
| board_name: BoardName::BuildInfo, |
| ota_type: OtaType::WellKnown, |
| paver: PaverType::Real, |
| ssl_certificates: "/config/ssl".to_owned(), |
| storage_type: StorageType::Real, |
| } |
| } |
| |
| #[cfg(test)] |
| /// Override the board name for this OTA. |
| pub fn board_name(mut self, name: &str) -> Self { |
| self.board_name = BoardName::Override { name: name.to_owned() }; |
| self |
| } |
| |
| #[cfg(test)] |
| /// Use the given blobfs root and path for minfs. |
| pub fn fake_storage( |
| mut self, |
| blobfs_root: ClientEnd<DirectoryMarker>, |
| minfs_path: String, |
| ) -> Self { |
| self.storage_type = StorageType::Fake { blobfs_root, minfs_path }; |
| self |
| } |
| |
| /// Use the given |DevhostConfig| to run an OTA. |
| pub fn devhost(mut self, cfg: DevhostConfig) -> Self { |
| self.ota_type = OtaType::Devhost { cfg }; |
| self |
| } |
| |
| #[cfg(test)] |
| /// Use the given connector to connect to a paver service. |
| pub fn fake_paver(mut self, connector: ClientEnd<DirectoryMarker>) -> Self { |
| self.paver = PaverType::Fake { connector }; |
| self |
| } |
| |
| #[cfg(test)] |
| /// Use the given path for SSL certificates. |
| pub fn ssl_certificates(mut self, path: &str) -> Self { |
| self.ssl_certificates = path.to_owned(); |
| self |
| } |
| |
| /// Takes a devhost config, and converts into a pkg-resolver friendly format. |
| /// Returns SSH authorized keys and a |File| representing a directory with the repository |
| /// configuration in it. |
| async fn get_devhost_config( |
| &self, |
| cfg: &DevhostConfig, |
| ) -> Result<(Option<String>, File), Error> { |
| // Get the repository information from the devhost (including keys and repo URL). |
| let client = fuchsia_hyper::new_client(); |
| let response = client |
| .get(Uri::from_str(&cfg.url).context("Bad URL")?) |
| .await |
| .context("Fetching config from devhost")?; |
| let body = response |
| .into_body() |
| .try_fold(Vec::new(), |mut vec, b| async move { |
| vec.extend(b); |
| Ok(vec) |
| }) |
| .await |
| .context("into body")?; |
| let repo_info: Value = serde_json::from_slice(&body).context("Failed to parse JSON")?; |
| |
| // Convert into a pkg-resolver friendly format. |
| let config_for_resolver = json!({ |
| "version": "1", |
| "content": [ |
| { |
| "repo_url": "fuchsia-pkg://fuchsia.com", |
| "root_version": 1, |
| "root_threshold": 1, |
| "root_keys": repo_info["RootKeys"], |
| "mirrors":[{ |
| "mirror_url": repo_info["RepoURL"], |
| "subscribe": true |
| }], |
| "update_package_url": null |
| } |
| ] |
| }); |
| |
| // Set up a repo configuration folder for the resolver, and write out the config. |
| let tempdir = tempfile::tempdir().context("tempdir")?; |
| let file = tempdir.path().join("devhost.json"); |
| let tmp_file = File::create(file).context("Creating file")?; |
| serde_json::to_writer(tmp_file, &config_for_resolver).context("Writing JSON")?; |
| |
| Ok(( |
| Some(cfg.authorized_keys.clone()), |
| File::open(tempdir.into_path()).context("Opening tmpdir")?, |
| )) |
| } |
| |
| /// Wipe the system's disk and mount the clean minfs/blobfs partitions. |
| async fn init_real_storage( |
| &self, |
| ) -> Result<(Option<Storage>, ClientEnd<DirectoryMarker>, String), Error> { |
| let mut storage = Storage::new().await.context("initialising storage")?; |
| let blobfs_root = storage.get_blobfs().context("Opening blobfs")?; |
| storage.mount_minfs().context("Mounting minfs")?; |
| |
| Ok((Some(storage), blobfs_root, "/m".to_owned())) |
| } |
| |
| /// Construct an |OtaEnv| from this |OtaEnvBuilder|. |
| pub async fn build(self) -> Result<OtaEnv, Error> { |
| let (authorized_keys, repo_dir) = match &self.ota_type { |
| OtaType::Devhost { cfg } => { |
| self.get_devhost_config(cfg).await.context("Getting devhost config")? |
| } |
| OtaType::WellKnown => panic!("Not implemented"), |
| }; |
| |
| let ssl_certificates = |
| File::open(&self.ssl_certificates).context("Opening SSL certificate folder")?; |
| |
| let (storage, blobfs_root, minfs_root_path) = |
| if let StorageType::Fake { blobfs_root, minfs_path } = self.storage_type { |
| (None, blobfs_root, minfs_path) |
| } else { |
| self.init_real_storage().await? |
| }; |
| |
| let paver_connector = match self.paver { |
| PaverType::Real => { |
| let (paver_connector, remote) = zx::Channel::create()?; |
| let mut paver_fs = ServiceFs::new(); |
| paver_fs.add_proxy_service::<fpaver::PaverMarker, _>(); |
| paver_fs.serve_connection(remote).context("Failed to serve on channel")?; |
| fasync::Task::spawn(paver_fs.collect()).detach(); |
| ClientEnd::from(paver_connector) |
| } |
| PaverType::Fake { connector } => connector, |
| }; |
| |
| let board_name = match self.board_name { |
| BoardName::BuildInfo => { |
| std::fs::read_to_string(BOARD_NAME_PATH).context("Reading board name")? |
| } |
| BoardName::Override { name } => name, |
| }; |
| |
| Ok(OtaEnv { |
| authorized_keys, |
| blobfs_root, |
| board_name, |
| minfs_root_path, |
| paver_connector, |
| repo_dir, |
| ssl_certificates, |
| _storage: storage, |
| }) |
| } |
| } |
| |
| pub struct OtaEnv { |
| authorized_keys: Option<String>, |
| blobfs_root: ClientEnd<DirectoryMarker>, |
| board_name: String, |
| minfs_root_path: String, |
| paver_connector: ClientEnd<DirectoryMarker>, |
| repo_dir: File, |
| ssl_certificates: File, |
| _storage: Option<Storage>, |
| } |
| |
| impl OtaEnv { |
| /// Run the OTA, targeting the given channel and reporting the given version |
| /// as the current system version. |
| pub async fn do_ota(self, channel: &str, version: &str) -> Result<(), Error> { |
| download_and_apply_update( |
| self.blobfs_root, |
| self.paver_connector, |
| self.repo_dir, |
| self.ssl_certificates, |
| channel, |
| &self.board_name, |
| version, |
| None, |
| ) |
| .await |
| .context("Installing OTA")?; |
| |
| if let Some(keys) = self.authorized_keys { |
| OtaEnv::install_ssh_certificates(&self.minfs_root_path, &keys) |
| .context("Installing SSH authorized keys")?; |
| } |
| Ok(()) |
| } |
| |
| /// Install SSH certificates into the target minfs. |
| fn install_ssh_certificates(minfs_root: &str, keys: &str) -> Result<(), Error> { |
| std::fs::create_dir(&format!("{}/ssh", minfs_root)).context("Creating ssh dir")?; |
| std::fs::write(&format!("{}/ssh/authorized_keys", minfs_root), keys) |
| .context("Writing authorized_keys")?; |
| Ok(()) |
| } |
| } |
| |
| /// Run an OTA from a development host. Returns when the system and SSH keys have been installed. |
| pub async fn run_devhost_ota(cfg: DevhostConfig) -> Result<(), Error> { |
| let ota_env = OtaEnvBuilder::new().devhost(cfg).build().await.context("Creating OTA env")?; |
| ota_env.do_ota("devhost", "20200101.1.1").await |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use { |
| super::*, |
| blobfs_ramdisk::BlobfsRamdisk, |
| fidl_fuchsia_pkg_ext::RepositoryKey, |
| fuchsia_async as fasync, |
| fuchsia_pkg_testing::{serve::HttpResponder, Package, PackageBuilder, RepositoryBuilder}, |
| futures::future::{ready, BoxFuture}, |
| hyper::{header, Body, Request, Response, StatusCode}, |
| mock_paver::MockPaverServiceBuilder, |
| std::{ |
| collections::{BTreeSet, HashMap}, |
| sync::{Arc, Mutex}, |
| }, |
| }; |
| |
| /// Wrapper around a ramdisk blobfs and a temporary directory |
| /// we pretend is minfs. |
| struct FakeStorage { |
| blobfs: BlobfsRamdisk, |
| minfs: tempfile::TempDir, |
| } |
| |
| impl FakeStorage { |
| pub fn new() -> Result<Self, Error> { |
| let minfs = tempfile::tempdir().context("making tempdir")?; |
| let blobfs = BlobfsRamdisk::start().context("launching blobfs")?; |
| Ok(FakeStorage { blobfs, minfs }) |
| } |
| |
| /// Get all the blobs inside the blobfs. |
| pub fn list_blobs(&self) -> Result<BTreeSet<fuchsia_merkle::Hash>, Error> { |
| self.blobfs.list_blobs() |
| } |
| |
| /// Get the blobfs root directory. |
| pub fn blobfs_root(&self) -> Result<ClientEnd<DirectoryMarker>, Error> { |
| self.blobfs.root_dir_handle() |
| } |
| |
| /// Get the path to be used for minfs. |
| pub fn minfs_path(&self) -> String { |
| self.minfs.path().to_string_lossy().into_owned() |
| } |
| } |
| |
| /// This wraps a |FakeConfigHandler| in an |Arc| |
| /// so that we can implement UriPathHandler for it. |
| struct FakeConfigArc { |
| pub arc: Arc<FakeConfigHandler>, |
| } |
| |
| /// This class is used to provide the '/config.json' endpoint |
| /// which the OTA process uses to discover information about the devhost repo. |
| struct FakeConfigHandler { |
| repo_keys: BTreeSet<RepositoryKey>, |
| address: Mutex<String>, |
| } |
| |
| impl FakeConfigHandler { |
| pub fn new(repo_keys: BTreeSet<RepositoryKey>) -> Arc<Self> { |
| Arc::new(FakeConfigHandler { repo_keys, address: Mutex::new("unknown".to_owned()) }) |
| } |
| |
| pub fn set_repo_address(self: Arc<Self>, addr: String) { |
| let mut val = self.address.lock().unwrap(); |
| *val = addr; |
| } |
| } |
| |
| impl HttpResponder for FakeConfigArc { |
| fn respond( |
| &self, |
| request: &Request<Body>, |
| response: Response<Body>, |
| ) -> BoxFuture<'_, Response<Body>> { |
| if request.uri().path() != "/config.json" { |
| return ready(response).boxed(); |
| } |
| |
| // We don't expect any contention on this lock: we only need it |
| // because the test doesn't know the address of the server until it's running. |
| let val = self.arc.address.lock().unwrap(); |
| if *val == "unknown" { |
| panic!("Expected address to be set!"); |
| } |
| |
| // This emulates the format returned by `pm serve` running on a devhost. |
| let config = json!({ |
| "ID": &*val, |
| "RepoURL": &*val, |
| "BlobRepoURL": format!("{}/blobs", val), |
| "RatePeriod": 60, |
| "RootKeys": self.arc.repo_keys, |
| "StatusConfig": { |
| "Enabled": true |
| }, |
| "Auto": true, |
| "BlobKey": null, |
| }); |
| |
| let json_str = serde_json::to_string(&config).context("Serializing JSON").unwrap(); |
| let response = Response::builder() |
| .status(StatusCode::OK) |
| .header(header::CONTENT_LENGTH, json_str.len()) |
| .body(Body::from(json_str)) |
| .unwrap(); |
| |
| ready(response).boxed() |
| } |
| } |
| |
| const EMPTY_REPO_PATH: &str = "/pkg/empty-repo"; |
| const TEST_SSL_CERTS: &str = "/pkg/data/ssl"; |
| |
| /// Represents an OTA that is yet to be run. |
| struct TestOtaEnv { |
| authorized_keys: Option<String>, |
| images: HashMap<String, Vec<u8>>, |
| packages: Vec<Package>, |
| storage: FakeStorage, |
| } |
| |
| impl TestOtaEnv { |
| pub fn new() -> Result<Self, Error> { |
| Ok(TestOtaEnv { |
| authorized_keys: None, |
| images: HashMap::new(), |
| packages: vec![], |
| storage: FakeStorage::new().context("Starting fake storage")?, |
| }) |
| } |
| |
| /// Add a package to be installed by this OTA. |
| pub fn add_package(mut self, p: Package) -> Self { |
| self.packages.push(p); |
| self |
| } |
| |
| /// Add an image to include in the update package for this OTA. |
| pub fn add_image(mut self, name: &str, data: &str) -> Self { |
| self.images.insert(name.to_owned(), data.to_owned().into_bytes()); |
| self |
| } |
| |
| /// Set the authorized keys to be installed by the OTA. |
| pub fn authorized_keys(mut self, keys: &str) -> Self { |
| self.authorized_keys = Some(keys.to_owned()); |
| self |
| } |
| |
| /// Generates the packages.json file for the update package. |
| fn generate_packages_list(&self) -> String { |
| let package_urls: Vec<String> = self |
| .packages |
| .iter() |
| .map(|p| { |
| format!( |
| "fuchsia-pkg://fuchsia.com/{}/0?hash={}", |
| p.name(), |
| p.meta_far_merkle_root() |
| ) |
| }) |
| .collect(); |
| let packages = json!({ |
| "version": 1, |
| "content": package_urls, |
| }); |
| serde_json::to_string(&packages).unwrap() |
| } |
| |
| /// Build an update package from the list of packages and images included |
| /// in this update. |
| async fn make_update_package(&self) -> Result<Package, Error> { |
| let mut update = PackageBuilder::new("update") |
| .add_resource_at("packages.json", self.generate_packages_list().as_bytes()); |
| |
| for (name, data) in self.images.iter() { |
| update = update.add_resource_at(name, data.as_slice()); |
| } |
| |
| update.build().await.context("Building update package") |
| } |
| |
| /// Run the OTA. |
| pub async fn run_ota(&mut self) -> Result<(), Error> { |
| let update = self.make_update_package().await?; |
| // Create the repo. |
| let repo = Arc::new( |
| self.packages |
| .iter() |
| .fold( |
| RepositoryBuilder::from_template_dir(EMPTY_REPO_PATH).add_package(&update), |
| |repo, package| repo.add_package(package), |
| ) |
| .build() |
| .await |
| .context("Building repo")?, |
| ); |
| // We expect the update package to be in blobfs, so add it to the list of packages. |
| self.packages.push(update); |
| |
| // Add a hook to handle the config.json file, which is exposed by |
| // `pm serve` to enable autoconfiguration of repositories. |
| let request_handler = FakeConfigHandler::new(repo.root_keys()); |
| let served_repo = Arc::clone(&repo) |
| .server() |
| .response_overrider(FakeConfigArc { arc: Arc::clone(&request_handler) }) |
| .start() |
| .context("Starting repository")?; |
| |
| // Configure the address of the repository for config.json |
| let url = served_repo.local_url(); |
| let config_url = format!("{}/config.json", url); |
| request_handler.set_repo_address(url); |
| |
| // Set up the mock paver. |
| let mock_paver = Arc::new(MockPaverServiceBuilder::new().build()); |
| let (paver_connector, remote) = zx::Channel::create()?; |
| let mut paver_fs = ServiceFs::new(); |
| let paver_clone = Arc::clone(&mock_paver); |
| paver_fs.add_fidl_service(move |stream: fpaver::PaverRequestStream| { |
| fasync::Task::spawn( |
| Arc::clone(&paver_clone) |
| .run_paver_service(stream) |
| .unwrap_or_else(|e| panic!("Failed to run paver: {:?}", e)), |
| ) |
| .detach(); |
| }); |
| paver_fs.serve_connection(remote).context("serving paver svcfs")?; |
| fasync::Task::spawn(paver_fs.collect()).detach(); |
| let paver_connector = ClientEnd::from(paver_connector); |
| |
| // Get the devhost config |
| let cfg = DevhostConfig { |
| url: config_url, |
| authorized_keys: self |
| .authorized_keys |
| .as_ref() |
| .map(|p| p.clone()) |
| .unwrap_or("".to_owned()), |
| }; |
| |
| // Build the environment, and do the OTA. |
| let ota_env = OtaEnvBuilder::new() |
| .board_name("x64") |
| .fake_storage( |
| self.storage.blobfs_root().context("Opening blobfs root")?, |
| self.storage.minfs_path(), |
| ) |
| .fake_paver(paver_connector) |
| .ssl_certificates(TEST_SSL_CERTS) |
| .devhost(cfg) |
| .build() |
| .await |
| .context("Building environment")?; |
| |
| ota_env.do_ota("devhost", "20200101.1.1").await.context("Running OTA")?; |
| Ok(()) |
| } |
| |
| /// Check that the blobfs contains exactly the blobs we expect it to contain. |
| pub async fn check_blobs(&self) { |
| let written_blobs = self.storage.list_blobs().expect("Listing blobfs blobs"); |
| let mut all_package_blobs = BTreeSet::new(); |
| for package in self.packages.iter() { |
| all_package_blobs.append(&mut package.list_blobs().expect("Listing package blobs")); |
| } |
| |
| assert_eq!(written_blobs, all_package_blobs); |
| } |
| |
| /// Check that the authorized keys file is what we expect it to be. |
| pub async fn check_keys(&self) { |
| let keys_path = format!("{}/ssh/authorized_keys", self.storage.minfs_path()); |
| if let Some(expected) = &self.authorized_keys { |
| let result = |
| std::fs::read_to_string(keys_path).expect("Failed to read authorized keys!"); |
| assert_eq!(&result, expected); |
| } else { |
| assert_eq!(std::fs::read_to_string(keys_path).unwrap(), ""); |
| } |
| } |
| } |
| |
| #[fasync::run_singlethreaded(test)] |
| async fn test_run_devhost_ota() -> Result<(), Error> { |
| let package = PackageBuilder::new("test-package") |
| .add_resource_at("data/file1", "Hello, world!".as_bytes()) |
| .build() |
| .await |
| .unwrap(); |
| let mut env = TestOtaEnv::new()? |
| .add_package(package) |
| .add_image("zbi.signed", "zbi image") |
| .add_image("fuchsia.vbmeta", "fuchsia vbmeta") |
| .authorized_keys("test authorized keys file!"); |
| |
| env.run_ota().await?; |
| env.check_blobs().await; |
| env.check_keys().await; |
| Ok(()) |
| } |
| } |