Skip to content

Commit 5e0f2cf

Browse files
persist: restore Azurite test coverage via Shared Key signing
The new Azure SDK dropped its built-in Azurite emulator support, so this commit re-implements it as a per-try `Policy` that signs each outgoing request with the Azure Storage "Shared Key" authentication scheme. - New `src/persist/src/azure/azurite.rs` submodule (cfg(test)): implements Shared Key HMAC-SHA256 signing per https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key. Builds the canonical string-to-sign (method, fixed HTTP headers in spec order, sorted `x-ms-*` headers, canonicalized resource), MACs it with the publicly-documented Azurite dev key, and attaches an `Authorization: SharedKey ...` header. Uses `insert_header` for `x-ms-date` / `x-ms-version` as well. - `AzureBlobConfig::new`: the `account == AZURITE_ACCOUNT` branch now installs `SharedKeyPolicy` via `add_shared_key_policy` in test builds and unconditionally errors in production builds. - `AzureBlob::open`: in test builds, if the client URL points at Azurite, create the container up front so freshly-started emulators work. - `AzureBlobConfig::new_for_test`: restored to the real impl that reads the container name from the `MZ_PERSIST_EXTERNAL_STORAGE_ TEST_AZURE_CONTAINER` env var and constructs a config pointing at `http://localhost:40111`. - `hmac` added to `[dev-dependencies]`; `sha2` is already a runtime dep. Everything new is gated on `#[cfg(test)]`, so production builds are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1f12af2 commit 5e0f2cf

4 files changed

Lines changed: 285 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/persist/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ workspace = true
7878
features = ["asm"]
7979

8080
[dev-dependencies]
81+
hmac.workspace = true
8182
mz-ore = { path = "../ore", default-features = false, features = ["test"] }
8283
serde_json.workspace = true
8384
tempfile.workspace = true

src/persist/src/azure.rs

Lines changed: 96 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
1212
use std::fmt::{Debug, Formatter};
1313
use std::sync::Arc;
14+
#[cfg(test)]
15+
use std::time::Duration;
1416

1517
use anyhow::anyhow;
1618
use async_trait::async_trait;
@@ -24,11 +26,17 @@ use azure_storage_blob::models::{
2426
};
2527
use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions};
2628
use futures_util::{StreamExt, TryStreamExt};
29+
#[cfg(test)]
30+
use tracing::info;
2731
use url::Url;
32+
#[cfg(test)]
33+
use uuid::Uuid;
2834

2935
use mz_dyncfg::ConfigSet;
3036
use mz_ore::bytes::SegmentedBytes;
3137
use mz_ore::cast::CastFrom;
38+
#[cfg(test)]
39+
use mz_ore::metrics::MetricsRegistry;
3240

3341
use crate::cfg::BlobKnobs;
3442
use crate::error::Error;
@@ -110,14 +118,21 @@ impl AzureBlobConfig {
110118
});
111119

112120
let client = if account == AZURITE_ACCOUNT {
113-
// Azurite test support is restored in a follow-up commit (it
114-
// needs the new SDK's `Policy` trait to implement Shared Key
115-
// signing, which the old SDK provided built-in).
116-
let _ = (url, container);
117-
return Err(Error::from(
118-
"Azurite emulator temporarily unsupported; restored in the \
119-
Azurite shared-key follow-up commit",
120-
));
121+
#[cfg(test)]
122+
{
123+
info!("Connecting to Azure emulator");
124+
azurite::add_shared_key_policy(&mut options);
125+
let container_url = azurite::container_url(&url, &container)?;
126+
BlobContainerClient::from_url(container_url, None, Some(options))
127+
.map_err(|e| Error::from(format!("azure container client: {e}")))?
128+
}
129+
#[cfg(not(test))]
130+
{
131+
let _ = (url, container);
132+
return Err(Error::from(
133+
"Azurite emulator is only supported in test builds",
134+
));
135+
}
121136
} else {
122137
let endpoint = format!("https://{account}.blob.core.windows.net/");
123138
if let Some(sas) = url.query() {
@@ -149,13 +164,61 @@ impl AzureBlobConfig {
149164
}
150165

151166
/// Returns a new [AzureBlobConfig] for use in unit tests.
152-
///
153-
/// Stubbed for now: Azurite requires Shared Key signing, which is
154-
/// restored in a follow-up commit. Returning `None` makes all
155-
/// Azurite-dependent tests skip.
156167
#[cfg(test)]
157168
pub fn new_for_test() -> Result<Option<Self>, Error> {
158-
Ok(None)
169+
struct TestBlobKnobs;
170+
impl Debug for TestBlobKnobs {
171+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
172+
f.debug_struct("TestBlobKnobs").finish_non_exhaustive()
173+
}
174+
}
175+
impl BlobKnobs for TestBlobKnobs {
176+
fn operation_timeout(&self) -> Duration {
177+
Duration::from_secs(30)
178+
}
179+
180+
fn operation_attempt_timeout(&self) -> Duration {
181+
Duration::from_secs(10)
182+
}
183+
184+
fn connect_timeout(&self) -> Duration {
185+
Duration::from_secs(5)
186+
}
187+
188+
fn read_timeout(&self) -> Duration {
189+
Duration::from_secs(5)
190+
}
191+
192+
fn is_cc_active(&self) -> bool {
193+
false
194+
}
195+
}
196+
197+
let container_name = match std::env::var(Self::EXTERNAL_TESTS_AZURE_CONTAINER) {
198+
Ok(container) => container,
199+
Err(_) => {
200+
assert!(
201+
!mz_ore::env::is_var_truthy("CI"),
202+
"CI is supposed to run this test but something has gone wrong!"
203+
);
204+
return Ok(None);
205+
}
206+
};
207+
208+
let prefix = Uuid::new_v4().to_string();
209+
let metrics = S3BlobMetrics::new(&MetricsRegistry::new());
210+
211+
let config = AzureBlobConfig::new(
212+
AZURITE_ACCOUNT.to_string(),
213+
container_name.clone(),
214+
prefix,
215+
metrics,
216+
Url::parse(&format!("http://localhost:40111/{}", container_name)).expect("valid url"),
217+
Box::new(TestBlobKnobs),
218+
Arc::new(ConfigSet::default()),
219+
)?;
220+
221+
Ok(Some(config))
159222
}
160223
}
161224

@@ -190,11 +253,24 @@ impl Debug for AzureBlob {
190253

191254
impl AzureBlob {
192255
/// Opens the given location for non-exclusive read-write access.
193-
// `open` is intentionally `async`: the follow-up commit adds a
194-
// `#[cfg(test)]` Azurite container-create call that awaits. The
195-
// production path currently performs no awaits.
196-
#[allow(clippy::unused_async)]
256+
// `open` is async only for the `#[cfg(test)]` Azurite container-create
257+
// call below; the production path performs no awaits.
258+
#[cfg_attr(not(test), allow(clippy::unused_async))]
197259
pub async fn open(config: AzureBlobConfig) -> Result<Self, ExternalError> {
260+
// In tests we may be pointed at a freshly-spun-up Azurite instance
261+
// where the container hasn't been created yet.
262+
#[cfg(test)]
263+
{
264+
if azurite::is_emulator_url(config.client.url()) {
265+
if let Err(error) = config.client.create(None).await {
266+
info!(
267+
?error,
268+
"failed to create emulator container; this is expected on repeat runs"
269+
);
270+
}
271+
}
272+
}
273+
198274
let ret = AzureBlob {
199275
metrics: config.metrics,
200276
client: config.client,
@@ -388,6 +464,9 @@ enum PreSizedBuffer {
388464
Unknown(SegmentedBytes),
389465
}
390466

467+
#[cfg(test)]
468+
mod azurite;
469+
391470
#[cfg(test)]
392471
mod tests {
393472
use tracing::info;

src/persist/src/azure/azurite.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
//! Azurite-only Shared Key request signing.
11+
//!
12+
//! Azurite doesn't accept Entra ID / OAuth tokens, only the Azure Storage
13+
//! "Shared Key" authentication scheme. The new Azure SDK dropped its
14+
//! built-in emulator support, so we install a `Policy` on the test client
15+
//! that signs each outgoing request.
16+
//!
17+
//! Spec: <https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key>
18+
//!
19+
//! This module is `#[cfg(test)]`-gated and never compiled into production
20+
//! builds; the Azurite dev key is a publicly documented constant.
21+
22+
use std::fmt;
23+
use std::sync::Arc;
24+
25+
use async_trait::async_trait;
26+
use azure_core::base64;
27+
use azure_core::http::policies::{Policy, PolicyResult};
28+
use azure_core::http::{Context, Request};
29+
use hmac::{Hmac, Mac};
30+
use sha2::Sha256;
31+
use url::Url;
32+
33+
use crate::error::Error;
34+
35+
use super::{AZURITE_ACCOUNT, BlobContainerClientOptions};
36+
37+
/// Default Azurite account key. Publicly documented, hard-coded.
38+
const AZURITE_KEY: &str =
39+
"Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==";
40+
41+
/// Build a container URL for Azurite from the emulator URL provided by
42+
/// the test harness.
43+
///
44+
/// Azurite expects URLs of the form
45+
/// `http://{host}:{port}/devstoreaccount1/{container}`.
46+
pub fn container_url(url: &Url, container: &str) -> Result<Url, Error> {
47+
let host = url
48+
.host_str()
49+
.ok_or_else(|| Error::from(format!("Azurite URL missing host: {url}")))?;
50+
let port = url
51+
.port()
52+
.ok_or_else(|| Error::from(format!("Azurite URL missing port: {url}")))?;
53+
Url::parse(&format!(
54+
"http://{host}:{port}/{AZURITE_ACCOUNT}/{container}"
55+
))
56+
.map_err(|e| Error::from(format!("invalid Azurite URL: {e}")))
57+
}
58+
59+
pub fn is_emulator_url(url: &Url) -> bool {
60+
url.path()
61+
.trim_start_matches('/')
62+
.starts_with(AZURITE_ACCOUNT)
63+
}
64+
65+
pub fn add_shared_key_policy(options: &mut BlobContainerClientOptions) {
66+
options
67+
.client_options
68+
.per_try_policies
69+
.push(Arc::new(SharedKeyPolicy));
70+
}
71+
72+
pub struct SharedKeyPolicy;
73+
74+
impl fmt::Debug for SharedKeyPolicy {
75+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76+
f.write_str("SharedKeyPolicy")
77+
}
78+
}
79+
80+
#[async_trait]
81+
impl Policy for SharedKeyPolicy {
82+
async fn send(
83+
&self,
84+
ctx: &Context,
85+
request: &mut Request,
86+
next: &[Arc<dyn Policy>],
87+
) -> PolicyResult {
88+
// `insert_header` stores the value with `'static` bounds, so we
89+
// have to hand it an owned `String`.
90+
let date = azure_core::time::to_rfc7231(&azure_core::time::OffsetDateTime::now_utc());
91+
request.insert_header("x-ms-date", date);
92+
request.insert_header("x-ms-version", "2024-08-04");
93+
94+
let string_to_sign = compute_signature(request);
95+
let key = base64::decode(AZURITE_KEY).expect("valid base64 Azurite key");
96+
let mut mac = Hmac::<Sha256>::new_from_slice(&key).expect("HMAC accepts any key");
97+
mac.update(string_to_sign.as_bytes());
98+
let auth = base64::encode(mac.finalize().into_bytes());
99+
request.insert_header(
100+
"authorization",
101+
format!("SharedKey {AZURITE_ACCOUNT}:{auth}"),
102+
);
103+
104+
next[0].send(ctx, request, &next[1..]).await
105+
}
106+
}
107+
108+
/// Build the canonical string-to-sign per Azure Shared Key spec.
109+
fn compute_signature(request: &Request) -> String {
110+
let method = match request.method() {
111+
azure_core::http::Method::Delete => "DELETE",
112+
azure_core::http::Method::Get => "GET",
113+
azure_core::http::Method::Head => "HEAD",
114+
azure_core::http::Method::Patch => "PATCH",
115+
azure_core::http::Method::Post => "POST",
116+
azure_core::http::Method::Put => "PUT",
117+
// `Method` is `#[non_exhaustive]` — unreachable today, but fall
118+
// back to the method's debug string if the SDK adds a new verb.
119+
other => return format!("{other:?}"),
120+
};
121+
122+
let headers = request.headers();
123+
let header = |name: &str| {
124+
headers
125+
.get_optional_str(&azure_core::http::headers::HeaderName::from(
126+
name.to_owned(),
127+
))
128+
.unwrap_or("")
129+
};
130+
131+
// Content-Length is signed as empty when zero per spec.
132+
let content_length = {
133+
let v = header("content-length");
134+
if v == "0" { "" } else { v }
135+
};
136+
137+
// x-ms-* headers, lowercased, sorted, joined as `name:value\n`.
138+
let mut x_ms_headers: Vec<(String, String)> = headers
139+
.iter()
140+
.filter_map(|(name, value)| {
141+
let n = name.as_str().to_ascii_lowercase();
142+
if n.starts_with("x-ms-") {
143+
Some((n, value.as_str().trim().to_owned()))
144+
} else {
145+
None
146+
}
147+
})
148+
.collect();
149+
x_ms_headers.sort_by(|a, b| a.0.cmp(&b.0));
150+
let canonicalized_headers = x_ms_headers
151+
.iter()
152+
.map(|(n, v)| format!("{n}:{v}"))
153+
.collect::<Vec<_>>()
154+
.join("\n");
155+
156+
// Canonicalized resource: `/account/path` + sorted query params
157+
// joined as `\nname:value`.
158+
let url = request.url();
159+
let mut canonicalized_resource = format!("/{AZURITE_ACCOUNT}{}", url.path());
160+
let mut query_pairs: Vec<(String, String)> = url
161+
.query_pairs()
162+
.map(|(k, v)| (k.to_ascii_lowercase(), v.into_owned()))
163+
.collect();
164+
query_pairs.sort_by(|a, b| a.0.cmp(&b.0));
165+
for (k, v) in query_pairs {
166+
canonicalized_resource.push_str(&format!("\n{k}:{v}"));
167+
}
168+
169+
[
170+
method,
171+
header("content-encoding"),
172+
header("content-language"),
173+
content_length,
174+
header("content-md5"),
175+
header("content-type"),
176+
// `Date` is empty because we always send `x-ms-date` instead.
177+
"",
178+
header("if-modified-since"),
179+
header("if-match"),
180+
header("if-none-match"),
181+
header("if-unmodified-since"),
182+
header("range"),
183+
&canonicalized_headers,
184+
&canonicalized_resource,
185+
]
186+
.join("\n")
187+
}

0 commit comments

Comments
 (0)