diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da8e929..b323a2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,23 +82,14 @@ jobs: name: Test (${{ matrix.name }}) runs-on: ${{ matrix.runner }} timeout-minutes: 60 - # manual_only lanes are GitHub-hosted and must not run on push or pull_request. - # Compared as a string: a boolean false in a matrix is easy to misread as the - # truthy string "false" once it crosses an expression boundary. - if: ${{ matrix.manual_only != 'yes' || github.event_name == 'workflow_dispatch' }} + # Select manual lanes before expansion; job-level conditions cannot use matrix. strategy: fail-fast: false matrix: - include: - - name: linux - runner: sm-standard-4 - manual_only: "no" - - name: macos-latest, manual - runner: macos-latest - manual_only: "yes" - - name: windows-latest, manual - runner: windows-latest - manual_only: "yes" + include: >- + ${{ fromJSON(github.event_name == 'workflow_dispatch' && + '[{"name":"linux","runner":"sm-standard-4","manual_only":"no"},{"name":"macos-latest, manual","runner":"macos-latest","manual_only":"yes"},{"name":"windows-latest, manual","runner":"windows-latest","manual_only":"yes"}]' || + '[{"name":"linux","runner":"sm-standard-4","manual_only":"no"}]') }} steps: - name: Flag billed GitHub-hosted runner if: matrix.manual_only == 'yes' @@ -121,20 +112,13 @@ jobs: name: Build (${{ matrix.name }}) runs-on: ${{ matrix.runner }} timeout-minutes: 90 - if: ${{ matrix.manual_only != 'yes' || github.event_name == 'workflow_dispatch' }} strategy: fail-fast: false matrix: - include: - - name: linux - runner: sm-standard-4 - manual_only: "no" - - name: macos-latest, manual - runner: macos-latest - manual_only: "yes" - - name: windows-latest, manual - runner: windows-latest - manual_only: "yes" + include: >- + ${{ fromJSON(github.event_name == 'workflow_dispatch' && + '[{"name":"linux","runner":"sm-standard-4","manual_only":"no"},{"name":"macos-latest, manual","runner":"macos-latest","manual_only":"yes"},{"name":"windows-latest, manual","runner":"windows-latest","manual_only":"yes"}]' || + '[{"name":"linux","runner":"sm-standard-4","manual_only":"no"}]') }} steps: - name: Flag billed GitHub-hosted runner if: matrix.manual_only == 'yes' diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e4f003d..8d2c804 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -10,6 +10,11 @@ on: description: 'Image tag to build and push' required: true default: 'latest' + publish_latest: + description: 'Also update latest for a stable version tag' + required: false + type: boolean + default: false permissions: contents: read @@ -40,6 +45,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} MANUAL_TAG: ${{ github.event.inputs.tag }} + PUBLISH_LATEST: ${{ github.event.inputs.publish_latest }} shell: bash run: | set -euo pipefail @@ -58,7 +64,7 @@ jobs: { echo 'tags< EnvFilter { } } +fn allow_log_metadata(metadata: &tracing::Metadata<'_>) -> bool { + // SDK debug events can include credentials and signed request headers. + *metadata.level() <= tracing::Level::WARN + || matches!( + metadata.target().split("::").next(), + Some("rustfs_cli" | "rc_core" | "rc_s3") + ) +} + /// Run the shared CLI launcher, preserving its worker-thread and exit behavior. pub fn run() { run_with_shutdown(|| async {}); @@ -52,6 +61,7 @@ where // Initialize tracing subscriber for logging tracing_subscriber::registry() + .with(filter_fn(allow_log_metadata)) .with(fmt::layer()) .with(env_filter) .init(); @@ -158,6 +168,35 @@ mod tests { assert_eq!(filter.to_string(), "debug"); } + #[test] + fn dependency_debug_logs_are_capped_without_hiding_application_diagnostics() { + let subscriber = tracing_subscriber::registry() + .with(filter_fn(allow_log_metadata)) + .with(EnvFilter::new("trace")); + tracing::subscriber::with_default(subscriber, || { + assert!(tracing::enabled!( + target: "rc_s3::client", + tracing::Level::DEBUG + )); + assert!(tracing::enabled!( + target: "rustfs_cli::launcher", + tracing::Level::TRACE + )); + assert!(tracing::enabled!( + target: "aws_smithy_runtime::client", + tracing::Level::WARN + )); + assert!(!tracing::enabled!( + target: "aws_smithy_runtime::client", + tracing::Level::DEBUG + )); + assert!(!tracing::enabled!( + target: "reqwest::connect", + tracing::Level::TRACE + )); + }); + } + #[test] fn command_launcher_preserves_command_exit_code() { let exit_code = run_async_command(|| async { ExitCode::UnsupportedFeature }, test_runtime) diff --git a/crates/cli/tests/recursive_remote_copy.rs b/crates/cli/tests/recursive_remote_copy.rs index d436125..c556b95 100644 --- a/crates/cli/tests/recursive_remote_copy.rs +++ b/crates/cli/tests/recursive_remote_copy.rs @@ -1,7 +1,7 @@ #![cfg(not(windows))] use std::collections::BTreeMap; -use std::io::{ErrorKind, Read, Write}; +use std::io::{BufRead, BufReader, ErrorKind, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; @@ -104,7 +104,7 @@ impl S3Mock { .lock() .expect("record S3 request") .push(request.clone()); - write_response(&mut stream, handler(&request)); + write_response(&mut stream, handler(&request), &request.method); } }); } @@ -193,7 +193,7 @@ fn read_request(stream: &mut TcpStream, pending: &mut Vec) -> Option PathBuf { if let Ok(path) = std::env::var("CARGO_BIN_EXE_rc") { return PathBuf::from(path); diff --git a/crates/cli/tests/session_credentials.rs b/crates/cli/tests/session_credentials.rs index d0f72e5..bcb47ed 100644 --- a/crates/cli/tests/session_credentials.rs +++ b/crates/cli/tests/session_credentials.rs @@ -9,7 +9,12 @@ const TOKEN: &str = "temporary-TOKEN+/="; const LIST: &str = "bucketfalse"; -fn run(endpoint: &str, config: &std::path::Path, args: &[&str]) -> Output { +fn run( + endpoint: &str, + config: &std::path::Path, + args: &[&str], + log_filter: Option<&str>, +) -> Output { let mut command = Command::new(rc_binary()); for (key, _) in std::env::vars_os() { if key.to_string_lossy().starts_with("RC_HOST_") { @@ -19,13 +24,16 @@ fn run(endpoint: &str, config: &std::path::Path, args: &[&str]) -> Output { let authority = endpoint.strip_prefix("http://").unwrap(); command .args(args) + .env_remove("RUST_LOG") .env("RC_CONFIG_DIR", config) .env("AWS_EC2_METADATA_DISABLED", "true") .env("RC_HOST_temporary", format!( "http://temporary-access:temporary-secret@{authority}?session_token=temporary-TOKEN%2B%2F%3D" - )) - .output() - .expect("run rc") + )); + if let Some(log_filter) = log_filter { + command.env("RUST_LOG", log_filter); + } + command.output().expect("run rc") } fn assert_redacted(output: &Output) { @@ -41,41 +49,48 @@ fn assert_redacted(output: &Output) { #[test] fn s3_and_admin_sign_with_the_same_process_only_session_token() { - for (args, body) in [ - (vec!["--debug", "ls", "temporary/bucket"], LIST), - (vec!["--debug", "admin", "user", "list", "temporary"], "{}"), + for log_filter in [ + None, + Some("debug"), + Some("trace"), + Some("aws_smithy_runtime::client::orchestrator=trace"), ] { - let config = tempfile::tempdir().unwrap(); - let (endpoint, requests, server) = - start_admin_response_test_server("200 OK", "application/xml", body.into()); - let output = run(&endpoint, config.path(), &args); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert_redacted(&output); - let request = requests - .recv_timeout(std::time::Duration::from_secs(2)) - .unwrap(); - assert!( - request.headers.lines().any(|line| { - line.split_once(':').is_some_and(|(name, value)| { - name.eq_ignore_ascii_case("x-amz-security-token") && value.trim() == TOKEN - }) - }), - "{}", - request.headers - ); - assert!( - request - .headers - .to_ascii_lowercase() - .contains("signedheaders=") - ); - assert!(!request.target.contains("session_token")); - assert!(!config.path().join("config.toml").exists()); - server.join().unwrap(); + for (args, body) in [ + (vec!["--debug", "ls", "temporary/bucket"], LIST), + (vec!["--debug", "admin", "user", "list", "temporary"], "{}"), + ] { + let config = tempfile::tempdir().unwrap(); + let (endpoint, requests, server) = + start_admin_response_test_server("200 OK", "application/xml", body.into()); + let output = run(&endpoint, config.path(), &args, log_filter); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_redacted(&output); + let request = requests + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + assert!( + request.headers.lines().any(|line| { + line.split_once(':').is_some_and(|(name, value)| { + name.eq_ignore_ascii_case("x-amz-security-token") && value.trim() == TOKEN + }) + }), + "{}", + request.headers + ); + assert!( + request + .headers + .to_ascii_lowercase() + .contains("signedheaders=") + ); + assert!(!request.target.contains("session_token")); + assert!(!config.path().join("config.toml").exists()); + server.join().unwrap(); + } } } @@ -101,7 +116,7 @@ fn expired_or_invalid_tokens_return_auth_error_without_changing_aliases() { } else { vec!["--json", "ls", "temporary/bucket"] }; - let output = run(&endpoint, config.path(), &args); + let output = run(&endpoint, config.path(), &args, None); assert_eq!( output.status.code(), Some(4),