Skip to content

Commit ffbc2aa

Browse files
authored
feat: implement emulator tests. (stjude-rust-labs#4)
* feat: implement emulator tests. This commit implements tests for the Azure Storage and AWS S3 backends to use local storage emulators for tests. Also fixes a panic due to a missing `break` in `cloud-copy`'s main function that was caused by introducing a loop to drive the main future to completion upon cancellation. Note that Google Cloud Storage tests were not added as there currently isn't an officially supported emulator. * fix: remove `macos` from test matrix until it supports Docker. * chore: fix CI. * chore: code review feedback. Include how long the retry waited in the warning. * chore: code review feedback. * Removed the test environment variables in favor of proper configuration. * The configuration now enables additional support for Azurite/Localstack and does not attempt to rewrite every URL (`az` and `s3` schemed URLs are rewritten when enabled, though). * Updated the URLs in the tests to use Azurite/Localstack where appropriate.
1 parent 2a14c07 commit ffbc2aa

File tree

15 files changed

+850
-310
lines changed

15 files changed

+850
-310
lines changed

.github/workflows/CI.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,29 @@ jobs:
3131
runs-on: ${{ matrix.os }}
3232
strategy:
3333
matrix:
34-
os: [ubuntu-latest, macos-latest]
34+
os: [ubuntu-latest]
3535
steps:
3636
- uses: actions/checkout@v4
37+
- name: Install Azure CLI
38+
run: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
39+
- name: Start LocalStack (AWS S3)
40+
run: |
41+
container=$(docker create -p 4566:4566 localstack/localstack:s3-latest)
42+
docker start $container
43+
sleep 5
44+
docker logs $container
45+
- name: Start Azurite (Azure Storage)
46+
run: |
47+
container=$(docker create -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite -l /data --blobHost 0.0.0.0 --loose)
48+
docker start $container
49+
sleep 5
50+
docker logs $container
51+
- name: Create test AWS S3 bucket
52+
run: |
53+
AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=${DEFAULT_REGION:-$AWS_DEFAULT_REGION} aws --endpoint-url=http://localhost:4566 s3api create-bucket --bucket cloud-copy-test
54+
- name: Create test Azure Storage container
55+
run: |
56+
az storage container create --name cloud-copy-test --connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1"
3757
- name: Update Rust
3858
run: rustup update stable && rustup default stable
3959
- run: cargo test --all-features

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
#### Added
1111

12+
* Added testing with emulators Azurite and localstack ([#4](https://github.com/stjude-rust-labs/cloud-copy/pull/4)).
1213
* Added initial implementation of `cloud-copy` ([#1](https://github.com/stjude-rust-labs/cloud-copy/pull/1)).
1314

1415
#### Changed
@@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1718

1819
#### Fixed
1920

21+
* Fixed a panic when running `cloud-copy` ([#4](https://github.com/stjude-rust-labs/cloud-copy/pull/4)).
2022
* Fixed remote content modification check with ranged downloads to use the
2123
`if-match` header ([#3](https://github.com/stjude-rust-labs/cloud-copy/pull/3)).
2224
* Fixed graceful cancellation (i.e. SIGINT) to cancel operations without

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ url = { version = "2.5.4", features = ["serde"] }
4141
urlencoding = "2.1.3"
4242
walkdir = "2.5.0"
4343

44+
[dev-dependencies]
45+
anyhow = { version = "1.0.98" }
46+
4447
[lints.rust]
4548
missing_docs = "warn"
4649
nonstandard-style = "warn"

README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,47 @@ path to copy the file or directory to.
127127

128128
## 🧠 Running Automated Tests
129129

130-
This section coming soon!
130+
Automated tests rely on having the following cloud service emulators installed:
131+
132+
* [Azurite](https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage)
133+
for Azure Blob Storage.
134+
* [Localstack](https://github.com/localstack/localstack) for AWS S3.
135+
136+
Use the following command to run Azurite in a Docker container:
137+
138+
```bash
139+
docker run -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite -l /data --blobHost 0.0.0.0 --loose
140+
```
141+
142+
Use the following command to run Localstack in a Docker container:
143+
144+
```bash
145+
docker run -p 4566:4566 localstack/localstack:s3-latest
146+
```
147+
148+
The tests expect a container/bucket with the name `cloud-copy-test` to be
149+
present.
150+
151+
To create the container with Azurite, use the Azure CLI:
152+
153+
```bash
154+
az storage container create --name cloud-copy-test --connection-string "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1"
155+
```
156+
157+
To create the bucket with Localstack, use the `awslocal` tool:
158+
159+
```bash
160+
awslocal s3api create-bucket --bucket cloud-copy-test
161+
```
162+
163+
Finally, run the tests:
164+
165+
```bash
166+
cargo test --all
167+
```
168+
169+
Note: the Azure tests expect `*.blob.core.windows.net.localhost` to resolve to
170+
`127.0.0.1`.
131171

132172
## ✅ Submitting Pull Requests
133173

src/backend.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ pub trait StorageBackend {
5050
/// Gets the block size given the size of a file.
5151
fn block_size(&self, file_size: u64) -> Result<u64>;
5252

53+
/// Whether or not the URL is supported by this backend.
54+
fn is_supported_url(config: &Config, url: &Url) -> bool;
55+
56+
/// Rewrites the given URL.
57+
///
58+
/// If the URL is using a cloud-specific scheme, the URL is rewritten to a `https` schemed URL.
59+
///
60+
/// Otherwise, the given URL is returned as-is.
61+
fn rewrite_url(&self, url: Url) -> Result<Url>;
62+
5363
/// Joins segments to a URL to form a new URL.
5464
fn join_url<'a>(&self, url: Url, segments: impl Iterator<Item = &'a str>) -> Result<Url>;
5565

src/backend/azure.rs

Lines changed: 81 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ use crate::streams::TransferStream;
3636
/// The Azure Blob Storage domain suffix.
3737
const AZURE_BLOB_STORAGE_ROOT_DOMAIN: &str = "blob.core.windows.net";
3838

39+
/// The Azurite root domain suffix.
40+
const AZURITE_ROOT_DOMAIN: &str = "blob.core.windows.net.localhost";
41+
3942
/// The default block size in bytes (4 MiB).
4043
const DEFAULT_BLOCK_SIZE: u64 = 4 * ONE_MEBIBYTE;
4144

@@ -94,66 +97,6 @@ pub enum AzureError {
9497
BlobNameMissing,
9598
}
9699

97-
/// Determines if the given URL is an Azure Blob Storage URL.
98-
pub fn is_azure_url(url: &Url) -> bool {
99-
match url.scheme() {
100-
"az" => true,
101-
"https" => {
102-
let Some(domain) = url.domain() else {
103-
return false;
104-
};
105-
106-
// Virtual host style URL of the form https://<account>.blob.core.windows.net/<container>/<path>
107-
let Some((_, domain)) = domain.split_once('.') else {
108-
return false;
109-
};
110-
domain.eq_ignore_ascii_case(AZURE_BLOB_STORAGE_ROOT_DOMAIN)
111-
}
112-
_ => false,
113-
}
114-
}
115-
116-
/// Rewrites an Azure Blob Storage URL (az://) into a HTTPS URL.
117-
///
118-
/// If the URL is not `az` schemed, the given URL is returned as-is.
119-
pub fn rewrite_url(url: Url) -> Result<Url> {
120-
match url.scheme() {
121-
"az" => {
122-
let account = url.host_str().ok_or(AzureError::InvalidScheme)?;
123-
124-
if url.path() == "/" {
125-
return Err(AzureError::InvalidScheme.into());
126-
}
127-
128-
match (url.query(), url.fragment()) {
129-
(None, None) => format!(
130-
"https://{account}.{AZURE_BLOB_STORAGE_ROOT_DOMAIN}{path}",
131-
path = url.path()
132-
),
133-
(None, Some(fragment)) => {
134-
format!(
135-
"https://{account}.{AZURE_BLOB_STORAGE_ROOT_DOMAIN}{path}#{fragment}",
136-
path = url.path()
137-
)
138-
}
139-
(Some(query), None) => format!(
140-
"https://{account}.{AZURE_BLOB_STORAGE_ROOT_DOMAIN}{path}?{query}",
141-
path = url.path()
142-
),
143-
(Some(query), Some(fragment)) => {
144-
format!(
145-
"https://{account}.{AZURE_BLOB_STORAGE_ROOT_DOMAIN}{path}?{query}#{fragment}",
146-
path = url.path()
147-
)
148-
}
149-
}
150-
.parse()
151-
.map_err(|_| AzureError::InvalidScheme.into())
152-
}
153-
_ => Ok(url),
154-
}
155-
}
156-
157100
/// Represents information about a blob.
158101
#[derive(Debug, Deserialize)]
159102
struct Blob {
@@ -421,6 +364,69 @@ impl StorageBackend for AzureBlobStorageBackend {
421364
Ok(block_size)
422365
}
423366

367+
fn is_supported_url(config: &Config, url: &Url) -> bool {
368+
match url.scheme() {
369+
"az" => true,
370+
"http" | "https" => {
371+
let Some(domain) = url.domain() else {
372+
return false;
373+
};
374+
375+
// Virtual host style URL of the form https://<account>.blob.core.windows.net/<container>/<path>
376+
let Some((_, domain)) = domain.split_once('.') else {
377+
return false;
378+
};
379+
380+
domain.eq_ignore_ascii_case(AZURE_BLOB_STORAGE_ROOT_DOMAIN)
381+
| (config.azure.use_azurite && domain.eq_ignore_ascii_case(AZURITE_ROOT_DOMAIN))
382+
}
383+
_ => false,
384+
}
385+
}
386+
387+
fn rewrite_url(&self, url: Url) -> Result<Url> {
388+
match url.scheme() {
389+
"az" => {
390+
let account = url.host_str().ok_or(AzureError::InvalidScheme)?;
391+
392+
if url.path() == "/" {
393+
return Err(AzureError::InvalidScheme.into());
394+
}
395+
396+
let (scheme, root, port) = if self.config.azure.use_azurite {
397+
("http", AZURITE_ROOT_DOMAIN, ":10000")
398+
} else {
399+
("https", AZURE_BLOB_STORAGE_ROOT_DOMAIN, "")
400+
};
401+
402+
match (url.query(), url.fragment()) {
403+
(None, None) => {
404+
format!("{scheme}://{account}.{root}{port}{path}", path = url.path())
405+
}
406+
(None, Some(fragment)) => {
407+
format!(
408+
"{scheme}://{account}.{root}{port}{path}#{fragment}",
409+
path = url.path()
410+
)
411+
}
412+
(Some(query), None) => format!(
413+
"{scheme}://{account}.{root}{port}{path}?{query}",
414+
path = url.path()
415+
),
416+
(Some(query), Some(fragment)) => {
417+
format!(
418+
"{scheme}://{account}.{root}{port}{path}?{query}#{fragment}",
419+
path = url.path()
420+
)
421+
}
422+
}
423+
.parse()
424+
.map_err(|_| AzureError::InvalidScheme.into())
425+
}
426+
_ => Ok(url),
427+
}
428+
}
429+
424430
fn join_url<'a>(&self, mut url: Url, segments: impl Iterator<Item = &'a str>) -> Result<Url> {
425431
let mut segments = segments.peekable();
426432

@@ -446,8 +452,9 @@ impl StorageBackend for AzureBlobStorageBackend {
446452

447453
async fn head(&self, url: Url) -> Result<Response> {
448454
debug_assert!(
449-
is_azure_url(&url) && url.scheme() == "https",
450-
"expected Azure HTTPS URL"
455+
Self::is_supported_url(&self.config, &url),
456+
"{url} is not a supported Azure URL",
457+
url = url.as_str()
451458
);
452459

453460
debug!("sending HEAD request for `{url}`", url = url.display());
@@ -470,8 +477,9 @@ impl StorageBackend for AzureBlobStorageBackend {
470477

471478
async fn get(&self, url: Url) -> Result<Response> {
472479
debug_assert!(
473-
is_azure_url(&url) && url.scheme() == "https",
474-
"expected Azure HTTPS URL"
480+
Self::is_supported_url(&self.config, &url),
481+
"{url} is not a supported Azure URL",
482+
url = url.as_str()
475483
);
476484

477485
debug!("sending GET request for `{url}`", url = url.display());
@@ -494,8 +502,9 @@ impl StorageBackend for AzureBlobStorageBackend {
494502

495503
async fn get_range(&self, url: Url, etag: &str, range: Range<u64>) -> Result<Response> {
496504
debug_assert!(
497-
is_azure_url(&url) && url.scheme() == "https",
498-
"expected Azure HTTPS URL"
505+
Self::is_supported_url(&self.config, &url),
506+
"{url} is not a supported Azure URL",
507+
url = url.as_str()
499508
);
500509

501510
debug!(
@@ -541,8 +550,9 @@ impl StorageBackend for AzureBlobStorageBackend {
541550

542551
async fn walk(&self, url: Url) -> Result<Vec<String>> {
543552
debug_assert!(
544-
is_azure_url(&url) && url.scheme() == "https",
545-
"expected Azure HTTPS URL"
553+
Self::is_supported_url(&self.config, &url),
554+
"{url} is not a supported Azure URL",
555+
url = url.as_str()
546556
);
547557

548558
debug!("walking `{url}` as a directory", url = url.display());
@@ -671,8 +681,9 @@ impl StorageBackend for AzureBlobStorageBackend {
671681

672682
async fn new_upload(&self, url: Url) -> Result<Self::Upload> {
673683
debug_assert!(
674-
is_azure_url(&url) && url.scheme() == "https",
675-
"expected Azure HTTPS URL"
684+
Self::is_supported_url(&self.config, &url),
685+
"{url} is not a supported Azure URL",
686+
url = url.as_str()
676687
);
677688

678689
Ok(AzureBlobUpload::new(

src/backend/generic.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@ impl StorageBackend for GenericStorageBackend {
108108
Ok(4 * 1024 * 1024)
109109
}
110110

111+
fn is_supported_url(_: &Config, _: &Url) -> bool {
112+
true
113+
}
114+
115+
fn rewrite_url(&self, url: Url) -> Result<Url> {
116+
Ok(url)
117+
}
118+
111119
fn join_url<'a>(&self, mut url: Url, segments: impl Iterator<Item = &'a str>) -> Result<Url> {
112120
// Append on the segments
113121
{

0 commit comments

Comments
 (0)