From b6bbc741777c2012bd60e2c84d0b613f93c55fa4 Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Thu, 12 Dec 2024 14:50:02 -0800 Subject: [PATCH] Bootstrap test-proxy service (#1956) * Bootstrap test-proxy service Relates to #1874 but does not complete it. * Elide recorded tests and supporting types for wasm32 * Resolve PR feedback --- .gitignore | 7 + Cargo.lock | 6 + sdk/core/azure_core_test/Cargo.toml | 12 +- .../azure_core_test/examples/test_proxy.rs | 83 ++++++++ sdk/core/azure_core_test/src/lib.rs | 84 +++++++- sdk/core/azure_core_test/src/proxy.rs | 180 ++++++++++++++++++ sdk/core/azure_core_test/src/recorded.rs | 37 ++++ sdk/core/azure_core_test/src/sanitizers.rs | 122 ++++++++++++ sdk/core/azure_core_test/src/transport.rs | 33 ++++ sdk/core/azure_core_test_macros/src/test.rs | 4 +- 10 files changed, 556 insertions(+), 12 deletions(-) create mode 100644 sdk/core/azure_core_test/examples/test_proxy.rs create mode 100644 sdk/core/azure_core_test/src/proxy.rs create mode 100644 sdk/core/azure_core_test/src/recorded.rs create mode 100644 sdk/core/azure_core_test/src/sanitizers.rs create mode 100644 sdk/core/azure_core_test/src/transport.rs diff --git a/.gitignore b/.gitignore index b3939f1a7f..7e0ddd5ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ # Build output. target/ +# Track only workspace Cargo.lock +Cargo.lock +!/Cargo.lock + # User secrets. .env +# Test artifacts. +.assets/ + # Editor user customizations. .vscode/launch.json .idea/ diff --git a/Cargo.lock b/Cargo.lock index 3f5c37918d..2fa61848f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,9 +364,14 @@ dependencies = [ name = "azure_core_test" version = "0.1.0" dependencies = [ + "async-trait", "azure_core", "azure_core_test_macros", + "clap", + "serde", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -2776,6 +2781,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.52.0", diff --git a/sdk/core/azure_core_test/Cargo.toml b/sdk/core/azure_core_test/Cargo.toml index e5b5485098..bd073f9e58 100644 --- a/sdk/core/azure_core_test/Cargo.toml +++ b/sdk/core/azure_core_test/Cargo.toml @@ -14,8 +14,18 @@ edition.workspace = true rust-version.workspace = true [dependencies] +async-trait.workspace = true azure_core = { workspace = true, features = ["test"] } azure_core_test_macros.workspace = true +serde.workspace = true +tracing.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["io-util", "process", "sync"] } [dev-dependencies] -tokio.workspace = true +clap.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +tokio = { workspace = true, features = ["signal"] } diff --git a/sdk/core/azure_core_test/examples/test_proxy.rs b/sdk/core/azure_core_test/examples/test_proxy.rs new file mode 100644 index 0000000000..fdc3c9e05c --- /dev/null +++ b/sdk/core/azure_core_test/examples/test_proxy.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use clap::Parser; + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::main] +async fn main() -> Result<(), Box> { + use azure_core_test::proxy; + + // cspell:ignore ECANCELED ECHILD + const ECANCELED: i32 = 4; + const ECHILD: i32 = 5; + + let args = Args::parse(); + + tracing_subscriber::fmt() + // Default trace level based on command line arguments. + .with_max_level(args.trace_level()) + // RUST_LOG environment variable can override trace level. + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let mut proxy = proxy::start(env!("CARGO_MANIFEST_DIR"), Some(args.into())).await?; + + let code = tokio::select! { + _ = tokio::signal::ctrl_c() => { + // Try to shutdown the test-proxy. + proxy.stop().await?; + + ECANCELED + }, + v = proxy.wait() => { + let code = v.map_or_else(|_| ECHILD, |v| v.code().unwrap_or_default()); + println!("test-proxy exited with status code {code}"); + + code + }, + }; + + if code != 0 { + std::process::exit(code); + } + + Ok(()) +} + +#[derive(Debug, Parser)] +#[command(about = "Starts the Test-Proxy service", version)] +struct Args { + /// Allow insecure upstream SSL certs. + #[arg(long)] + insecure: bool, + + /// Enable verbose logging. + #[arg(short, long)] + verbose: bool, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Args { + fn trace_level(&self) -> tracing::level_filters::LevelFilter { + if self.verbose { + return tracing::level_filters::LevelFilter::DEBUG; + } + tracing::level_filters::LevelFilter::INFO + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for azure_core_test::proxy::ProxyOptions { + fn from(args: Args) -> Self { + Self { + insecure: args.insecure, + } + } +} + +#[cfg(target_arch = "wasm32")] +fn main() { + let _ = Args::parse(); + println!("wasm32 target architecture not supported"); +} diff --git a/sdk/core/azure_core_test/src/lib.rs b/sdk/core/azure_core_test/src/lib.rs index 5ae2720878..8a2a5c3019 100644 --- a/sdk/core/azure_core_test/src/lib.rs +++ b/sdk/core/azure_core_test/src/lib.rs @@ -3,12 +3,22 @@ #![doc = include_str!("../README.md")] -/// Live recording and playing back of client library tests. -pub mod recorded { - pub use azure_core_test_macros::test; -} +#[cfg(not(target_arch = "wasm32"))] +pub mod proxy; +#[cfg(not(target_arch = "wasm32"))] +pub mod recorded; +mod sanitizers; +mod transport; pub use azure_core::test::TestMode; +use azure_core::{ClientOptions, TransportOptions}; +pub use sanitizers::*; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +const SPAN_TARGET: &str = "test-proxy"; /// Context information required by recorded client library tests. /// @@ -17,7 +27,7 @@ pub use azure_core::test::TestMode; #[derive(Clone, Debug)] pub struct TestContext { test_mode: TestMode, - crate_dir: &'static str, + crate_dir: &'static Path, test_name: &'static str, } @@ -27,27 +37,79 @@ impl TestContext { pub fn new(test_mode: TestMode, crate_dir: &'static str, test_name: &'static str) -> Self { Self { test_mode, - crate_dir, + crate_dir: Path::new(crate_dir), test_name, } } - /// Gets the current [`TestMode`]. - pub fn test_mode(&self) -> TestMode { - self.test_mode + /// Instruments the [`ClientOptions`] to support recording and playing back of session records. + /// + /// # Examples + /// + /// ```no_run + /// use azure_core_test::{recorded, TestContext}; + /// + /// # struct MyClient; + /// # #[derive(Default)] + /// # struct MyClientOptions { client_options: azure_core::ClientOptions }; + /// # impl MyClient { + /// # fn new(endpoint: impl AsRef, options: Option) -> Self { todo!() } + /// # async fn invoke(&self) -> azure_core::Result<()> { todo!() } + /// # } + /// #[recorded::test] + /// async fn test_invoke(ctx: TestContext) -> azure_core::Result<()> { + /// let mut options = MyClientOptions::default(); + /// ctx.instrument(&mut options.client_options); + /// + /// let client = MyClient::new("https://azure.net", Some(options)); + /// client.invoke().await + /// } + /// ``` + pub fn instrument(&self, options: &mut ClientOptions) { + let transport = options.transport.clone().unwrap_or_default(); + options.transport = Some(TransportOptions::new_custom_policy(Arc::new( + transport::ProxyTransportPolicy { + inner: transport, + mode: self.test_mode, + }, + ))); } /// Gets the root directory of the crate under test. - pub fn crate_dir(&self) -> &'static str { + pub fn crate_dir(&self) -> &'static Path { self.crate_dir } + /// Gets the test data directory under [`Self::crate_dir`]. + pub fn test_data_dir(&self) -> PathBuf { + self.crate_dir.join("tests/data") + } + + /// Gets the current [`TestMode`]. + pub fn test_mode(&self) -> TestMode { + self.test_mode + } + /// Gets the current test function name. pub fn test_name(&self) -> &'static str { self.test_name } } +#[cfg(not(target_arch = "wasm32"))] +fn find_ancestor(dir: impl AsRef, name: &str) -> azure_core::Result { + for dir in dir.as_ref().ancestors() { + let path = dir.join(name); + if path.exists() { + return Ok(path); + } + } + Err(azure_core::Error::new::( + azure_core::error::ErrorKind::Io, + std::io::ErrorKind::NotFound.into(), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -62,6 +124,8 @@ mod tests { assert_eq!(ctx.test_mode(), TestMode::Playback); assert!(ctx .crate_dir() + .to_str() + .unwrap() .replace("\\", "/") .ends_with("sdk/core/azure_core_test")); assert_eq!(ctx.test_name(), "test_content_new"); diff --git a/sdk/core/azure_core_test/src/proxy.rs b/sdk/core/azure_core_test/src/proxy.rs new file mode 100644 index 0000000000..f80ee134cb --- /dev/null +++ b/sdk/core/azure_core_test/src/proxy.rs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//! Wrappers for the [Test Proxy](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md) service. + +use azure_core::{error::ErrorKind, Result, Url}; +use std::{ + env, io, + path::Path, + process::{ExitStatus, Stdio}, + sync::Arc, +}; +use tokio::{ + io::{AsyncBufReadExt, BufReader}, + process::{Child, ChildStdout, Command}, +}; +use tracing::{Level, Span}; + +// cspell:ignore aspnetcore devcert testproxy +const KESTREL_CERT_PATH_ENV: &str = "ASPNETCORE_Kestrel__Certificates__Default__Path"; +const KESTREL_CERT_PASSWORD_ENV: &str = "ASPNETCORE_Kestrel__Certificates__Default__Password"; +const KESTREL_CERT_PASSWORD: &str = "password"; +const PROXY_MANUAL_START: &str = "PROXY_MANUAL_START"; + +pub async fn start( + test_data_dir: impl AsRef, + options: Option, +) -> Result { + if env::var(PROXY_MANUAL_START).is_ok_and(|v| v.to_ascii_lowercase() == "true") { + tracing::event!(target: crate::SPAN_TARGET, Level::WARN, "environment variable {PROXY_MANUAL_START} is 'true'; not starting test proxy"); + return Ok(Proxy::default()); + } + + // Find root of git repo or work tree: a ".git" directory or file will exist either way. + let git_dir = crate::find_ancestor(test_data_dir, ".git")?; + let git_dir = git_dir.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "parent git repository not found") + })?; + + let mut args: Vec = Vec::new(); + args.extend_from_slice(&[ + "start".into(), + "--storage-location".into(), + git_dir + .to_str() + .ok_or_else(|| ErrorKind::Other.into_error())? + .into(), + ]); + options.unwrap_or_default().copy_to(&mut args); + args.extend_from_slice(&["--", "--urls", "http://0.0.0.0:0"].map(Into::into)); + + let mut command = Command::new("test-proxy") + .args(args.iter()) + .env( + KESTREL_CERT_PATH_ENV, + git_dir.join("eng/common/testproxy/dotnet-devcert.pfx"), + ) + .env(KESTREL_CERT_PASSWORD_ENV, KESTREL_CERT_PASSWORD) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| azure_core::Error::full(ErrorKind::Io, e, "test-proxy not found"))?; + + // Wait until the service is listening on a port. + let mut stdout = command.stdout.take(); + let url = wait_till_listening(&mut stdout).await?; + + Ok(Proxy { + command: Some(command), + url, + }) +} + +async fn wait_till_listening(stdout: &mut Option) -> Result { + let Some(stdout) = stdout else { + return Err(azure_core::Error::message( + ErrorKind::Io, + "test-proxy stdout not captured", + )); + }; + + // cspell:ignore teamprojectid + let _max_seconds = env::var("SYSTEM_TEAMPROJECTID").map_or(5, |_| 20); + + // Wait for the process to respond to requests and check output until pattern: "Now listening on: http://0.0.0.0:60583" (random port). + let mut reader = BufReader::new(stdout).lines(); + while let Some(line) = reader.next_line().await? { + const PATTERN: &str = "Now listening on: "; + + if let Some(idx) = line.find(PATTERN) { + let idx = idx + PATTERN.len(); + let url = line[idx..].parse()?; + tracing::event!(target: crate::SPAN_TARGET, Level::INFO, "listening on {}", url); + + return Ok(url); + } + } + + Err(azure_core::Error::message( + ErrorKind::Io, + "timed out waiting for test-proxy to start", + )) +} + +/// Represents the running `test-proxy` service. +#[derive(Debug)] +pub struct Proxy { + command: Option, + url: Url, +} + +impl Proxy { + /// Waits for the Test Proxy service to exit, return the process exit code when completed. + pub async fn wait(&mut self) -> Result { + if let Some(command) = &mut self.command { + return Ok(command.wait().await?); + } + Ok(ExitStatus::default()) + } + + /// Attempts to stop the service. + /// + /// Waits until the process is killed. + pub async fn stop(&mut self) -> Result<()> { + if let Some(command) = &mut self.command { + tracing::event!(target: crate::SPAN_TARGET, Level::DEBUG, "stopping"); + return Ok(command.kill().await?); + } + Ok(()) + } + + /// Gets the [`Url`] to which the test proxy is listening. + pub fn url(&self) -> &Url { + &self.url + } +} + +impl Default for Proxy { + fn default() -> Self { + Self { + command: None, + url: "http://localhost:5000".parse().unwrap(), + } + } +} + +impl Drop for Proxy { + /// Attempts to stop the service. + /// + /// Does not wait until the process is killed. + fn drop(&mut self) { + if let Some(command) = &mut self.command { + let _ = command.start_kill(); + } + } +} + +/// Options for starting the [`Proxy`]. +#[derive(Clone, Debug, Default)] +pub struct ProxyOptions { + /// Allow insecure upstream SSL certs. + pub insecure: bool, +} + +impl ProxyOptions { + fn copy_to(&self, args: &mut Vec) { + if self.insecure { + args.push("--insecure".into()); + } + } +} + +/// Represents a playback or recording session using the [`Proxy`]. +pub struct Session { + #[allow(dead_code)] + pub(crate) proxy: Arc, + + #[allow(dead_code)] + pub(crate) span: Span, +} diff --git a/sdk/core/azure_core_test/src/recorded.rs b/sdk/core/azure_core_test/src/recorded.rs new file mode 100644 index 0000000000..8de048c848 --- /dev/null +++ b/sdk/core/azure_core_test/src/recorded.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//! Live recording and playing back of client library tests. +use crate::{ + proxy::{self, Proxy, ProxyOptions, Session}, + TestContext, +}; +use azure_core::Result; +pub use azure_core_test_macros::test; +use std::sync::Arc; +use tokio::sync::OnceCell; +use tracing::debug_span; + +static TEST_PROXY: OnceCell>> = OnceCell::const_new(); + +/// Starts playback or recording of live sessions. +/// +/// The [Test Proxy](https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md) service will be started as needed. +/// Every `#[recorded::test]` will call this automatically, but it can also be called manually by any other test e.g., those attributed with `#[tokio::test]`. +pub async fn start(ctx: &TestContext, options: Option) -> Result { + let proxy = TEST_PROXY + .get_or_init(|| async move { + proxy::start(ctx.test_data_dir(), options) + .await + .map(Arc::new) + }) + .await + .as_ref() + .map_err(|err| azure_core::Error::new(err.kind().clone(), err))?; + + let span = debug_span!(target: crate::SPAN_TARGET, "session", mode = ?ctx.test_mode(), test = ?ctx.test_name()); + Ok(Session { + proxy: proxy.clone(), + span, + }) +} diff --git a/sdk/core/azure_core_test/src/sanitizers.rs b/sdk/core/azure_core_test/src/sanitizers.rs new file mode 100644 index 0000000000..60362b4344 --- /dev/null +++ b/sdk/core/azure_core_test/src/sanitizers.rs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::headers::{AsHeaders, HeaderName, HeaderValue}; +use serde::Serialize; +#[cfg(test)] +use std::collections::HashMap; +use std::{ + convert::Infallible, + fmt, + iter::{once, Once}, +}; + +/// Default sanitization replacement value, "Sanitized"; +pub const SANITIZED_VALUE: &str = "Sanitized"; +const ABSTRACTION_IDENTIFIER: HeaderName = HeaderName::from_static("x-abstraction-identifier"); + +/// Represents a sanitizer. +pub trait Sanitizer: AsHeaders + fmt::Debug + Serialize {} + +macro_rules! impl_sanitizer { + ($name:ident) => { + impl Sanitizer for $name {} + + impl AsHeaders for $name { + type Error = Infallible; + type Iter = Once<(HeaderName, HeaderValue)>; + fn as_headers(&self) -> Result { + Ok(once(( + ABSTRACTION_IDENTIFIER, + HeaderValue::from_static(stringify!($name)), + ))) + } + } + }; + + ($($name:ident),+) => { + $(impl_sanitizer!($name))* + + }; +} + +/// This sanitizer offers regular expression replacements within a returned JSON body for a specific JSONPath. +/// +/// This sanitizer only applies to JSON bodies. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BodyKeySanitizer { + /// The JSONPath that will be checked for replacements. + pub json_path: String, + + /// The substitution value. The default is [`SANITIZED_VALUE`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// The regular expression to search for. + /// + /// Can be defined as a simple regular expression replacement or, if [`BodyKeySanitizer::group_for_replace`] is set, a substitution operation. + /// Defaults to replacing the entire string. + #[serde(skip_serializing_if = "Option::is_none")] + pub regex: Option, + + /// The regular expression capture group to substitute. + /// + /// Do not set if you're invoking a simple replacement operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_for_replace: Option, +} +impl_sanitizer!(BodyKeySanitizer); + +#[test] +fn test_body_key_sanitizer_as_headers() { + let sut = BodyKeySanitizer { + json_path: String::from("$.values"), + value: None, + regex: None, + group_for_replace: None, + }; + let actual = sut.as_headers().expect("expect headers"); + let expected: HashMap = HashMap::from_iter(vec![( + "x-abstraction-identifier".into(), + "BodyKeySanitizer".into(), + )]); + assert!(actual.eq(expected.into_iter())); +} + +/// This sanitizer offers regular expression replacements within raw request and response bodies. +/// +/// Specifically, this means the regular expression applies to the raw JSON. +/// If you are attempting to simply replace a specific JSON key, the [`BodyKeySanitizer`] is probably what you want to use. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BodyRegexSanitizer { + /// The substitution value. The default is [`SANITIZED_VALUE`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// The regular expression to search for or the entire body if `None`. + /// + /// Can be defined as a simple regular expression replacement or, if [`BodyRegexSanitizer::group_for_replace`] is set, a substitution operation. + /// Defaults to replacing the entire string. + #[serde(skip_serializing_if = "Option::is_none")] + pub regex: Option, + + /// The regular expression capture group to substitute. + /// + /// Do not set if you're invoking a simple replacement operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_for_replace: Option, +} +impl_sanitizer!(BodyRegexSanitizer); + +#[test] +fn test_body_regex_sanitizer_as_headers() { + let sut = BodyRegexSanitizer::default(); + let actual = sut.as_headers().expect("expect headers"); + let expected: HashMap = HashMap::from_iter(vec![( + "x-abstraction-identifier".into(), + "BodyRegexSanitizer".into(), + )]); + assert!(actual.eq(expected.into_iter())); +} diff --git a/sdk/core/azure_core_test/src/transport.rs b/sdk/core/azure_core_test/src/transport.rs new file mode 100644 index 0000000000..3e9a02939d --- /dev/null +++ b/sdk/core/azure_core_test/src/transport.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use async_trait::async_trait; +use azure_core::{test::TestMode, Context, Policy, PolicyResult, Request, TransportOptions}; +use std::sync::Arc; +use tracing::{debug_span, Instrument}; + +/// Wraps the original [`TransportOptions`] and records or plays back session records for testing. +#[derive(Debug)] +pub struct ProxyTransportPolicy { + pub(crate) inner: TransportOptions, + pub(crate) mode: TestMode, +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl Policy for ProxyTransportPolicy { + async fn send( + &self, + ctx: &Context, + request: &mut Request, + next: &[Arc], + ) -> PolicyResult { + // There must be no other policies since we're encapsulating the original TransportPolicy. + assert_eq!(0, next.len()); + + let span = debug_span!(target: crate::SPAN_TARGET, "request", mode = ?self.mode); + async move { { self.inner.send(ctx, request) }.await } + .instrument(span) + .await + } +} diff --git a/sdk/core/azure_core_test_macros/src/test.rs b/sdk/core/azure_core_test_macros/src/test.rs index 1d0ef41f63..91f1e510ce 100644 --- a/sdk/core/azure_core_test_macros/src/test.rs +++ b/sdk/core/azure_core_test_macros/src/test.rs @@ -50,7 +50,8 @@ pub fn parse_test(attr: TokenStream, item: TokenStream) -> Result { let test_mode = test_mode_to_tokens(test_mode); quote! { #[allow(dead_code)] - let ctx = #ty::new(#test_mode, env!("CARGO_MANIFEST_DIR"), stringify!(#fn_name)); + let ctx = ::azure_core_test::TestContext::new(#test_mode, env!("CARGO_MANIFEST_DIR"), stringify!(#fn_name)); + let _session = ::azure_core_test::recorded::start(&ctx, ::std::option::Option::None).await?; #fn_name(ctx).await } } @@ -74,6 +75,7 @@ pub fn parse_test(attr: TokenStream, item: TokenStream) -> Result { outer_sig.inputs.clear(); Ok(quote! { + #[cfg(not(target_arch = "wasm32"))] #test_attr #(#attrs)* #vis #outer_sig {