mirror of https://github.com/smithy-lang/smithy-rs
Add `aws-smithy-wasm` crate with WASI http client (#3409)
## Motivation and Context <!--- Why is this change required? What problem does it solve? --> <!--- If it fixes an open issue, please link to the issue here --> This change adds a new crate, `aws-smithy-wasm`, that exports a SDK compatible WASI http client. This is a continuation of the work in #2520 using the now stabilized WASI 0.2.0 interfaces from the [wasi crate](https://crates.io/crates/wasi). This supports, but does not finalize the work for #2087 ## Description <!--- Describe your changes in detail --> Add a new crate, `aws-smithy-wasm` which exports a function `wasi_http_client` that will provide the user with a WASI compatible http client. This client is implemented by using the `wasi::http::outgoing_handler` [ref](https://docs.rs/wasi/0.12.0+wasi-0.2.0/wasi/http/outgoing_handler/index.html) along with some utility implementations of `TryFrom` to transform back and worth between the types from the `http` crate and the `wasi::http` types. It also exports a unit struct `WasmSleep` that impls the `AsyncSleep` trait needed by the SDK. ## Testing <!--- Please describe in detail how you tested your changes --> <!--- Include details of your testing environment, and the tests you ran to --> <!--- see how your change affects other areas of the code, etc. --> This is tested via an integration test in `aws/sdk/integration-tests/webassembly` that uses the wasi http-client to vuild a config and an operation (that is not sent). It is further tested in a new canary (`wasm_canary`) that calls the S3 `list_objects_v2` API. ## Checklist <!--- If a checkbox below is not applicable, then please DELETE it rather than leaving it unchecked --> - [X] I have updated `CHANGELOG.next.toml` if I made changes to the smithy-rs codegen or runtime crates ---- _By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice._ --------- Co-authored-by: Eduardo Rodrigues <eduardomourar@users.noreply.github.com> Co-authored-by: Eduardo de Moura Rodrigues <16357187+eduardomourar@users.noreply.github.com> Co-authored-by: ysaito1001 <awsaito@amazon.com> Co-authored-by: John DiSanti <jdisanti@amazon.com> Co-authored-by: Russell Cohen <rcoh@amazon.com> Co-authored-by: John DiSanti <john@vinylsquid.com>
This commit is contained in:
parent
5b9d0c0fff
commit
d95cc86400
|
@ -16,3 +16,15 @@ message = "EKS Pod Identity is now supported as part of the default ECS credenti
|
|||
references = ["smithy-rs#3416"]
|
||||
meta = { "breaking" = false, "bug" = false, "tada" = true }
|
||||
author = "jackkleeman"
|
||||
|
||||
[[aws-sdk-rust]]
|
||||
message = "Added aws-smithy-wasm crate to enable SDK use in WASI compliant environments"
|
||||
references = ["smithy-rs#2087", "smithy-rs#2520", "smithy-rs#3409", "aws-sdk-rust#59"]
|
||||
meta = { "breaking" = false, "tada" = true, "bug" = false }
|
||||
author = "landonxjames"
|
||||
|
||||
[[smithy-rs]]
|
||||
message = "Added aws-smithy-wasm crate to enable SDK use in WASI compliant environments"
|
||||
references = ["smithy-rs#2087", "smithy-rs#2520", "smithy-rs#3409"]
|
||||
meta = { "breaking" = false, "tada" = true, "bug" = false, "target" = "client"}
|
||||
author = "landonxjames"
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
[package]
|
||||
name = "aws-config"
|
||||
version = "0.0.0-smithy-rs-head"
|
||||
authors = ["AWS Rust SDK Team <aws-sdk-rust@amazon.com>", "Russell Cohen <rcoh@amazon.com>"]
|
||||
authors = [
|
||||
"AWS Rust SDK Team <aws-sdk-rust@amazon.com>",
|
||||
"Russell Cohen <rcoh@amazon.com>",
|
||||
]
|
||||
description = "AWS SDK config and credential provider implementations."
|
||||
edition = "2021"
|
||||
exclude = ["test-data/*", "integration-tests/*"]
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
Cargo.lock
|
||||
target/
|
||||
webassembly/src/bindings.rs
|
||||
|
|
|
@ -11,6 +11,12 @@ for f in *; do
|
|||
echo
|
||||
echo "Testing ${f}..."
|
||||
echo "###############"
|
||||
if [ "$f" != "webassembly" ]; then
|
||||
cargo test --manifest-path "${f}/Cargo.toml" --all-features
|
||||
else
|
||||
# The webassembly tests use a custom runner set in config.toml that
|
||||
# is not picked up when running the tests outside of the package
|
||||
cd webassembly && cargo component test --all-features --all-targets && cd ..
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
|
|
@ -3,3 +3,9 @@ target = "wasm32-wasi"
|
|||
|
||||
[target.wasm32-wasi]
|
||||
rustflags = ["-C", "opt-level=1"]
|
||||
runner = [
|
||||
"wasmtime",
|
||||
"-C", "cache=n",
|
||||
"-S", "preview2=y",
|
||||
"-S", "http=y"
|
||||
]
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
[package]
|
||||
name = "webassembly"
|
||||
version = "0.1.0"
|
||||
authors = ["Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com>"]
|
||||
authors = [
|
||||
"Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com>",
|
||||
]
|
||||
description = """
|
||||
These tests ensure that things will fail (or not fail) as expected
|
||||
when target is set to wasm32-wasi for all SDK and runtime crates.
|
||||
|
@ -12,20 +14,34 @@ license = "Apache-2.0"
|
|||
repository = "https://github.com/smithy-lang/smithy-rs"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[features]
|
||||
default = ["test-util"]
|
||||
test-util = []
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
aws-config = { path = "../../build/aws-sdk/sdk/aws-config", default-features = false, features = ["rt-tokio", "behavior-version-latest"]}
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
aws-config = { path = "../../build/aws-sdk/sdk/aws-config", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
"behavior-version-latest"
|
||||
] }
|
||||
aws-credential-types = { path = "../../build/aws-sdk/sdk/aws-credential-types", features = ["hardcoded-credentials"] }
|
||||
aws-sdk-s3 = { path = "../../build/aws-sdk/sdk/s3", default-features = false }
|
||||
aws-smithy-http = { path = "../../build/aws-sdk/sdk/aws-smithy-http" }
|
||||
aws-smithy-runtime = { path = "../../build/aws-sdk/sdk/aws-smithy-runtime", features = ["client"] }
|
||||
aws-smithy-runtime-api = { path = "../../build/aws-sdk/sdk/aws-smithy-runtime-api", features = ["client"] }
|
||||
aws-smithy-types = { path = "../../build/aws-sdk/sdk/aws-smithy-types" }
|
||||
aws-types = { path = "../../build/aws-sdk/sdk/aws-types" }
|
||||
http = "0.2.8"
|
||||
tokio = { version = "1.24.2", features = ["macros", "rt"] }
|
||||
tower = "0.4.13"
|
||||
aws-smithy-wasm = { path = "../../build/aws-sdk/sdk/aws-smithy-wasm" }
|
||||
http = "0.2.9"
|
||||
tokio = { version = "1.32.0", features = ["macros", "rt"] }
|
||||
|
||||
[target.'cfg(all(target_family = "wasm", target_os = "wasi"))'.dependencies]
|
||||
wit-bindgen = { version = "0.16.0", features = ["macros", "realloc"] }
|
||||
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# metadata used by cargo-component to identify which wit world to embed in the binary
|
||||
[package.metadata.component]
|
||||
package = "aws:component"
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
use crate::http::WasmHttpConnector;
|
||||
use aws_config::retry::RetryConfig;
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_smithy_types::timeout::TimeoutConfig;
|
||||
use aws_types::region::Region;
|
||||
use std::future::Future;
|
||||
|
||||
pub(crate) fn get_default_config() -> impl Future<Output = aws_config::SdkConfig> {
|
||||
aws_config::from_env()
|
||||
.region(Region::from_static("us-west-2"))
|
||||
.credentials_provider(Credentials::from_keys(
|
||||
"access_key",
|
||||
"secret_key",
|
||||
Some("session_token".to_string()),
|
||||
))
|
||||
.timeout_config(TimeoutConfig::disabled())
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.http_client(WasmHttpConnector::new())
|
||||
.load()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
pub async fn test_default_config() {
|
||||
let shared_config = get_default_config().await;
|
||||
let client = aws_sdk_s3::Client::new(&shared_config);
|
||||
assert_eq!(client.config().region().unwrap().to_string(), "us-west-2")
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
use aws_smithy_runtime_api::client::http::{
|
||||
HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector,
|
||||
};
|
||||
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
|
||||
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
|
||||
use aws_smithy_runtime_api::shared::IntoShared;
|
||||
use aws_smithy_types::body::SdkBody;
|
||||
|
||||
pub(crate) fn make_request(_req: HttpRequest) -> Result<HttpResponse, ()> {
|
||||
// Consumers here would pass the HTTP request to
|
||||
// the Wasm host in order to get the response back
|
||||
let body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<ListAllMyBucketsResult>
|
||||
<Buckets>
|
||||
<Bucket>
|
||||
<CreationDate>2023-01-23T11:59:03.575496Z</CreationDate>
|
||||
<Name>doc-example-bucket</Name>
|
||||
</Bucket>
|
||||
<Bucket>
|
||||
<CreationDate>2023-01-23T23:32:13.125238Z</CreationDate>
|
||||
<Name>doc-example-bucket2</Name>
|
||||
</Bucket>
|
||||
</Buckets>
|
||||
<Owner>
|
||||
<DisplayName>account-name</DisplayName>
|
||||
<ID>a3a42310-42d0-46d1-9745-0cee9f4fb851</ID>
|
||||
</Owner>
|
||||
</ListAllMyBucketsResult>";
|
||||
Ok(HttpResponse::try_from(http::Response::new(SdkBody::from(body))).unwrap())
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub(crate) struct WasmHttpConnector;
|
||||
impl WasmHttpConnector {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpConnector for WasmHttpConnector {
|
||||
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
|
||||
println!("Adapter: sending request...");
|
||||
let res = make_request(request).unwrap();
|
||||
println!("{:?}", res);
|
||||
HttpConnectorFuture::new(async move { Ok(res) })
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpClient for WasmHttpConnector {
|
||||
fn http_connector(
|
||||
&self,
|
||||
_settings: &HttpConnectorSettings,
|
||||
_components: &RuntimeComponents,
|
||||
) -> SharedHttpConnector {
|
||||
self.clone().into_shared()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
use aws_config::retry::RetryConfig;
|
||||
use aws_sdk_s3::operation::list_objects_v2::builders::ListObjectsV2FluentBuilder;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_smithy_types::timeout::TimeoutConfig;
|
||||
use aws_smithy_wasm::wasi::WasiHttpClientBuilder;
|
||||
|
||||
pub(crate) async fn get_default_wasi_config() -> aws_config::SdkConfig {
|
||||
let http_client = WasiHttpClientBuilder::new().build();
|
||||
aws_config::from_env()
|
||||
.region("us-east-2")
|
||||
.timeout_config(TimeoutConfig::disabled())
|
||||
.retry_config(RetryConfig::disabled())
|
||||
.no_credentials()
|
||||
.http_client(http_client)
|
||||
.load()
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
pub async fn test_default_config() {
|
||||
let shared_config = get_default_wasi_config().await;
|
||||
let client = aws_sdk_s3::Client::new(&shared_config);
|
||||
assert_eq!(client.config().region().unwrap().to_string(), "us-east-2")
|
||||
}
|
||||
|
||||
async fn s3_list_objects_operation() -> ListObjectsV2FluentBuilder {
|
||||
let shared_config = get_default_wasi_config().await;
|
||||
let client = Client::new(&shared_config);
|
||||
let operation = client
|
||||
.list_objects_v2()
|
||||
.bucket("nara-national-archives-catalog")
|
||||
.delimiter("/")
|
||||
.prefix("authority-records/organization/")
|
||||
.max_keys(5);
|
||||
|
||||
operation
|
||||
}
|
||||
|
||||
// Test constructing an operation using an SdkConfig with a WASI http client
|
||||
// We do not send the request to keep these tests sandboxable, a full test of
|
||||
// the client is in the SDK canary.
|
||||
#[tokio::test]
|
||||
pub async fn test_operation_construction() {
|
||||
let operation = s3_list_objects_operation().await;
|
||||
assert_eq!(
|
||||
operation.get_bucket(),
|
||||
&Some("nara-national-archives-catalog".to_string())
|
||||
);
|
||||
}
|
|
@ -3,11 +3,9 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
mod default_config;
|
||||
mod http;
|
||||
mod list_buckets;
|
||||
#![allow(dead_code)]
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn main() {
|
||||
crate::list_buckets::s3_list_buckets().await
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
mod http_client;
|
||||
#[cfg(all(target_family = "wasm", target_os = "wasi"))]
|
||||
mod wasi;
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
pub async fn s3_list_buckets() {
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
use crate::default_config::get_default_config;
|
||||
|
||||
let shared_config = get_default_config().await;
|
||||
let client = Client::new(&shared_config);
|
||||
let result = client.list_buckets().send().await.unwrap();
|
||||
assert_eq!(result.buckets().len(), 2)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
pub async fn test_s3_list_buckets() {
|
||||
s3_list_buckets().await
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Needed for WASI-compliant environment as it expects specific functions
|
||||
// to be exported such as `cabi_realloc`, `_start`, etc.
|
||||
|
||||
wit_bindgen::generate!({
|
||||
inline: "
|
||||
package aws:component;
|
||||
|
||||
interface run {
|
||||
run: func() -> result;
|
||||
}
|
||||
|
||||
world main {
|
||||
export run;
|
||||
}
|
||||
",
|
||||
exports: {
|
||||
"aws:component/run": Component
|
||||
}
|
||||
});
|
||||
|
||||
struct Component;
|
||||
|
||||
impl exports::aws::component::run::Guest for Component {
|
||||
fn run() -> Result<(), ()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -70,6 +70,7 @@ object CrateSet {
|
|||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-types-convert",
|
||||
"aws-smithy-wasm",
|
||||
"aws-smithy-xml",
|
||||
).map { Crate(it, version(it)) }
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ members = [
|
|||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-smithy-types-convert",
|
||||
"aws-smithy-wasm",
|
||||
"aws-smithy-mocks-experimental",
|
||||
"aws-smithy-xml",
|
||||
]
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "aws-smithy-wasm"
|
||||
version = "0.1.0"
|
||||
authors = [
|
||||
"AWS Rust SDK Team <aws-sdk-rust@amazon.com>",
|
||||
"Eduardo Rodrigues <16357187+eduardomourar@users.noreply.github.com>",
|
||||
]
|
||||
description = "Smithy WebAssembly configuration for smithy-rs."
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/awslabs/smithy-rs"
|
||||
|
||||
[dependencies]
|
||||
aws-smithy-runtime-api = { path = "../aws-smithy-runtime-api", features = ["http-1x"]}
|
||||
aws-smithy-http = { path = "../aws-smithy-http" }
|
||||
aws-smithy-types = { path = "../aws-smithy-types" }
|
||||
bytes = "1"
|
||||
http = "1.0.0"
|
||||
tracing = "0.1.40"
|
||||
# Note the wasi crate will only build for target wasm32-wasi, but having a target
|
||||
# statement here breaks some of the CI tests, so we leave it with the rest of the deps
|
||||
wasi = "0.12.0+wasi-0.2.0"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
targets = ["x86_64-unknown-linux-gnu"]
|
||||
cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"]
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
# End of docs.rs metadata
|
|
@ -0,0 +1,175 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
|
@ -0,0 +1,7 @@
|
|||
# aws-smithy-wasm
|
||||
|
||||
WebAssembly and WASI related configuration for service clients generated by [smithy-rs](https://github.com/awslabs/smithy-rs).
|
||||
|
||||
<!-- anchor_start:footer -->
|
||||
This crate is part of the [AWS SDK for Rust](https://awslabs.github.io/aws-sdk-rust/) and the [smithy-rs](https://github.com/smithy-lang/smithy-rs) code generator. In most cases, it should not be used directly.
|
||||
<!-- anchor_end:footer -->
|
|
@ -0,0 +1,5 @@
|
|||
allowed_external_types = [
|
||||
"aws_smithy_runtime_api::client::http::HttpClient",
|
||||
"aws_smithy_runtime_api::client::http::SharedHttpClient",
|
||||
"aws_smithy_runtime_api::client::http::HttpConnector"
|
||||
]
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* Automatically managed default lints */
|
||||
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
|
||||
/* End of automatically managed default lints */
|
||||
#![allow(clippy::derive_partial_eq_without_eq)]
|
||||
#![warn(
|
||||
missing_docs,
|
||||
rustdoc::missing_crate_level_docs,
|
||||
unreachable_pub,
|
||||
rust_2018_idioms
|
||||
)]
|
||||
|
||||
//! Smithy WebAssembly
|
||||
|
||||
/// Tools for using Smithy SDKs in WASI environments
|
||||
pub mod wasi;
|
|
@ -0,0 +1,350 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
//! WASI HTTP Adapter
|
||||
use aws_smithy_http::header::ParseError;
|
||||
use aws_smithy_runtime_api::{
|
||||
client::{
|
||||
http::{
|
||||
HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings,
|
||||
SharedHttpClient, SharedHttpConnector,
|
||||
},
|
||||
orchestrator::HttpRequest,
|
||||
result::ConnectorError,
|
||||
runtime_components::RuntimeComponents,
|
||||
},
|
||||
http::Response,
|
||||
shared::IntoShared,
|
||||
};
|
||||
use aws_smithy_types::body::SdkBody;
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use wasi::http::{
|
||||
outgoing_handler,
|
||||
types::{self as wasi_http, OutgoingBody, RequestOptions},
|
||||
};
|
||||
|
||||
/// Builder for [`WasiHttpClient`]. Currently empty, but allows for future
|
||||
/// config options to be added in a backwards compatible manner.
|
||||
#[derive(Default, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct WasiHttpClientBuilder {}
|
||||
|
||||
impl WasiHttpClientBuilder {
|
||||
/// Creates a new builder.
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Builds the [`WasiHttpClient`].
|
||||
pub fn build(self) -> SharedHttpClient {
|
||||
let client = WasiHttpClient {};
|
||||
client.into_shared()
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP client that can be used during instantiation of the client SDK in
|
||||
/// order to route the HTTP requests through the WebAssembly host. The host must
|
||||
/// support the WASI HTTP proposal as defined in the Preview 2 specification.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub struct WasiHttpClient {}
|
||||
|
||||
impl HttpClient for WasiHttpClient {
|
||||
fn http_connector(
|
||||
&self,
|
||||
settings: &HttpConnectorSettings,
|
||||
_components: &RuntimeComponents,
|
||||
) -> SharedHttpConnector {
|
||||
let options = WasiRequestOptions::from(settings);
|
||||
let connector = WasiHttpConnector { options };
|
||||
|
||||
connector.into_shared()
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP connector used in WASI environment
|
||||
#[derive(Debug, Clone)]
|
||||
struct WasiHttpConnector {
|
||||
options: WasiRequestOptions,
|
||||
}
|
||||
|
||||
impl HttpConnector for WasiHttpConnector {
|
||||
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
|
||||
tracing::trace!("WasiHttpConnector: sending request {request:?}");
|
||||
|
||||
let client = WasiDefaultClient::new(self.options.clone());
|
||||
let http_req = request.try_into_http1x().expect("Http request invalid");
|
||||
let converted_req = http_req.map(|body| match body.bytes() {
|
||||
Some(value) => Bytes::copy_from_slice(value),
|
||||
None => Bytes::new(),
|
||||
});
|
||||
|
||||
let fut_result = client.handle(converted_req);
|
||||
|
||||
HttpConnectorFuture::new(async move {
|
||||
let fut = fut_result?;
|
||||
let response = fut.map(|body| {
|
||||
if body.is_empty() {
|
||||
SdkBody::empty()
|
||||
} else {
|
||||
SdkBody::from(body)
|
||||
}
|
||||
});
|
||||
tracing::trace!("WasiHttpConnector: response received {response:?}");
|
||||
|
||||
let sdk_res = Response::try_from(response)
|
||||
.map_err(|err| ConnectorError::other(err.into(), None))?;
|
||||
|
||||
Ok(sdk_res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// WASI HTTP client containing the options passed to the outgoing_handler
|
||||
struct WasiDefaultClient {
|
||||
options: WasiRequestOptions,
|
||||
}
|
||||
|
||||
impl WasiDefaultClient {
|
||||
/// Create a new WASI HTTP client.
|
||||
fn new(options: WasiRequestOptions) -> Self {
|
||||
Self { options }
|
||||
}
|
||||
|
||||
/// Make outgoing HTTP request in a WASI environment
|
||||
fn handle(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>, ConnectorError> {
|
||||
let req =
|
||||
WasiRequest::try_from(req).map_err(|err| ConnectorError::other(err.into(), None))?;
|
||||
|
||||
let res = outgoing_handler::handle(req.0, self.options.clone().0)
|
||||
.map_err(|err| ConnectorError::other(err.into(), None))?;
|
||||
|
||||
// Right now only synchronous calls can be made through WASI, so we subscribe and
|
||||
// block on the FutureIncomingResponse
|
||||
let subscription = res.subscribe();
|
||||
subscription.block();
|
||||
|
||||
//The FutureIncomingResponse .get() method returns a
|
||||
//Option<Result<Result<IncomingResponse, ErrorCode>, ()>>.
|
||||
//The outer Option ensures readiness which we know is Some because we .block() waiting for it
|
||||
//The outer Result is just a singleton enforcer so we can only get the response once
|
||||
//The inner Result indicates whether the HTTP call was sent/received successfully (not the 200 succes of the call)
|
||||
let incoming_res = res
|
||||
.get()
|
||||
.expect("Http response not ready")
|
||||
.expect("Http response accessed more than once")
|
||||
.map_err(|err| ConnectorError::other(err.into(), None))?;
|
||||
|
||||
let response = http::Response::try_from(WasiResponse(incoming_res))
|
||||
.map_err(|err| ConnectorError::other(err.into(), None))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper for the WASI RequestOptions type to allow us to impl Clone
|
||||
#[derive(Debug)]
|
||||
struct WasiRequestOptions(Option<outgoing_handler::RequestOptions>);
|
||||
|
||||
impl From<&HttpConnectorSettings> for WasiRequestOptions {
|
||||
fn from(value: &HttpConnectorSettings) -> Self {
|
||||
//The WASI Duration is nanoseconds represented as u64
|
||||
//Note: that the HttpConnectorSettings provides nanoseconds as u128
|
||||
//so here we are clamping to u64::MAX if the value is above that
|
||||
let connect_timeout = value
|
||||
.connect_timeout()
|
||||
.map(|dur| u64::try_from(dur.as_nanos()).unwrap_or(u64::MAX));
|
||||
let read_timeout = value
|
||||
.read_timeout()
|
||||
.map(|dur| u64::try_from(dur.as_nanos()).unwrap_or(u64::MAX));
|
||||
|
||||
//Note: these only fail if setting this particular type of timeout is not
|
||||
//supported. Spec compliant runtimes should always support these so it is
|
||||
//unlikely to be an issue.
|
||||
let wasi_http_opts = wasi_http::RequestOptions::new();
|
||||
wasi_http_opts
|
||||
.set_connect_timeout(connect_timeout)
|
||||
.expect("Connect timeout not supported");
|
||||
wasi_http_opts
|
||||
.set_first_byte_timeout(read_timeout)
|
||||
.expect("Read timeout not supported");
|
||||
|
||||
WasiRequestOptions(Some(wasi_http_opts))
|
||||
}
|
||||
}
|
||||
//The WASI RequestOptions type doesn't impl copy or clone but the outgoing_handler::handle method
|
||||
//takes ownership, so we impl it on this wrapper type
|
||||
impl Clone for WasiRequestOptions {
|
||||
fn clone(&self) -> Self {
|
||||
//Note none of the expects here should ever trigger since all of the values passed in are from
|
||||
//the existing RequestOptions that is being cloned and should be valid
|
||||
let new_opts = if let Some(opts) = &self.0 {
|
||||
let new_opts = RequestOptions::new();
|
||||
new_opts
|
||||
.set_between_bytes_timeout(opts.between_bytes_timeout())
|
||||
.expect("Between bytes timeout");
|
||||
new_opts
|
||||
.set_connect_timeout(opts.connect_timeout())
|
||||
.expect("Connect timeout");
|
||||
new_opts
|
||||
.set_first_byte_timeout(opts.first_byte_timeout())
|
||||
.expect("First byte timeout");
|
||||
|
||||
Some(new_opts)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self(new_opts)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper to allow converting between HTTP Request types and WASI Request types
|
||||
#[derive(Debug)]
|
||||
struct WasiRequest(outgoing_handler::OutgoingRequest);
|
||||
|
||||
impl TryFrom<http::Request<Bytes>> for WasiRequest {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(value: http::Request<Bytes>) -> Result<Self, Self::Error> {
|
||||
let (parts, body) = value.into_parts();
|
||||
let method = WasiMethod::try_from(parts.method)?;
|
||||
let path_with_query = parts.uri.path_and_query().map(|path| path.as_str());
|
||||
let headers = WasiHeaders::try_from(parts.headers)?;
|
||||
let scheme = match parts.uri.scheme_str().unwrap_or("") {
|
||||
"http" => Some(&wasi_http::Scheme::Http),
|
||||
"https" => Some(&wasi_http::Scheme::Https),
|
||||
_ => None,
|
||||
};
|
||||
let authority = parts.uri.authority().map(|auth| auth.as_str());
|
||||
|
||||
let request = wasi_http::OutgoingRequest::new(headers.0);
|
||||
request
|
||||
.set_scheme(scheme)
|
||||
.map_err(|_| ParseError::new("Failed to set HTTP scheme"))?;
|
||||
request
|
||||
.set_method(&method.0)
|
||||
.map_err(|_| ParseError::new("Failed to set HTTP method"))?;
|
||||
request
|
||||
.set_path_with_query(path_with_query)
|
||||
.map_err(|_| ParseError::new("Failed to set HTTP path"))?;
|
||||
request
|
||||
.set_authority(authority)
|
||||
.map_err(|_| ParseError::new("Failed to set HTTP authority"))?;
|
||||
|
||||
let request_body = request.body().expect("Body accessed more than once");
|
||||
|
||||
let request_stream = request_body
|
||||
.write()
|
||||
.expect("Output stream accessed more than once");
|
||||
|
||||
request_stream
|
||||
.blocking_write_and_flush(&body)
|
||||
.map_err(|_| ParseError::new("Failed to write HTTP body"))?;
|
||||
|
||||
//The OutputStream is a child resource: it must be dropped
|
||||
//before the parent OutgoingBody resource is dropped (or finished),
|
||||
//otherwise the OutgoingBody drop or finish will trap.
|
||||
drop(request_stream);
|
||||
|
||||
OutgoingBody::finish(request_body, None)
|
||||
.map_err(|_| ParseError::new("Failed to finalize HTTP body"))?;
|
||||
|
||||
Ok(WasiRequest(request))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper to allow converting between HTTP Methods and WASI Methods
|
||||
struct WasiMethod(wasi_http::Method);
|
||||
|
||||
impl TryFrom<http::Method> for WasiMethod {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(method: http::Method) -> Result<Self, Self::Error> {
|
||||
Ok(Self(match method {
|
||||
http::Method::GET => wasi_http::Method::Get,
|
||||
http::Method::POST => wasi_http::Method::Post,
|
||||
http::Method::PUT => wasi_http::Method::Put,
|
||||
http::Method::DELETE => wasi_http::Method::Delete,
|
||||
http::Method::PATCH => wasi_http::Method::Patch,
|
||||
http::Method::CONNECT => wasi_http::Method::Connect,
|
||||
http::Method::TRACE => wasi_http::Method::Trace,
|
||||
http::Method::HEAD => wasi_http::Method::Head,
|
||||
http::Method::OPTIONS => wasi_http::Method::Options,
|
||||
_ => return Err(ParseError::new("failed due to unsupported method, currently supported methods are: GET, POST, PUT, DELETE, PATCH, CONNECT, TRACE, HEAD, and OPTIONS")),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper to allow converting between HTTP Response types and WASI Response types
|
||||
struct WasiResponse(wasi_http::IncomingResponse);
|
||||
|
||||
impl TryFrom<WasiResponse> for http::Response<Bytes> {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(value: WasiResponse) -> Result<Self, Self::Error> {
|
||||
let response = value.0;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
//This headers resource is a child: it must be dropped before the parent incoming-response is dropped.
|
||||
//The drop happens via the consuming iterator used below
|
||||
let headers = response.headers().entries();
|
||||
|
||||
let res_build = headers
|
||||
.into_iter()
|
||||
.fold(http::Response::builder().status(status), |rb, header| {
|
||||
rb.header(header.0, header.1)
|
||||
});
|
||||
|
||||
let body_incoming = response.consume().expect("Consume called more than once");
|
||||
|
||||
//The input-stream resource is a child: it must be dropped before the parent
|
||||
//incoming-body is dropped, or consumed by incoming-body.finish.
|
||||
//That drop is done explicitly below
|
||||
let body_stream = body_incoming
|
||||
.stream()
|
||||
.expect("Stream accessed more than once");
|
||||
|
||||
let mut body = BytesMut::new();
|
||||
|
||||
//blocking_read blocks until at least one byte is available
|
||||
while let Ok(stream_bytes) = body_stream.blocking_read(u64::MAX) {
|
||||
body.extend_from_slice(stream_bytes.as_slice())
|
||||
}
|
||||
|
||||
drop(body_stream);
|
||||
|
||||
let res = res_build
|
||||
.body(body.freeze())
|
||||
.map_err(|err| ParseError::new(err.to_string()))?;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper to allow converting between HTTP headers and WASI headers
|
||||
struct WasiHeaders(wasi_http::Fields);
|
||||
|
||||
impl TryFrom<http::HeaderMap> for WasiHeaders {
|
||||
type Error = ParseError;
|
||||
|
||||
fn try_from(headers: http::HeaderMap) -> Result<Self, Self::Error> {
|
||||
let entries = headers
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
(
|
||||
name.to_string(),
|
||||
value.to_str().unwrap().as_bytes().to_vec(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let fields = wasi_http::Fields::from_list(&entries)
|
||||
.map_err(|err| ParseError::new(err.to_string()))?;
|
||||
|
||||
Ok(Self(fields))
|
||||
}
|
||||
}
|
|
@ -121,17 +121,14 @@ ARG wasm_pack_version=0.11.0
|
|||
RUN cargo install wasm-pack --locked --version ${wasm_pack_version}
|
||||
|
||||
FROM install_rust AS wasmtime
|
||||
ARG wasmtime_precompiled_url=https://github.com/bytecodealliance/wasmtime/releases/download/v7.0.0/wasmtime-v7.0.0-x86_64-linux.tar.xz
|
||||
ARG wasmtime_precompiled_sha256=b8a1c97f9107c885ea73a5c38677d0d340a7c26879d366e8a5f3dce84cffec99
|
||||
RUN set -eux; \
|
||||
curl "${wasmtime_precompiled_url}" -L -o wasmtime.xz; \
|
||||
echo "${wasmtime_precompiled_sha256} wasmtime.xz" | sha256sum --check; \
|
||||
tar xf wasmtime.xz; \
|
||||
mv wasmtime-v*/wasmtime /opt;
|
||||
ARG cargo_wasmtime_version=18.0.1
|
||||
ARG rust_nightly_version
|
||||
RUN cargo install wasmtime-cli --features="component-model" --locked --version ${cargo_wasmtime_version}
|
||||
|
||||
FROM install_rust AS cargo_wasi
|
||||
ARG cargo_wasi_version=0.1.27
|
||||
RUN cargo install cargo-wasi --locked --version ${cargo_wasi_version}
|
||||
FROM install_rust AS cargo_component
|
||||
ARG cargo_component_version=0.7.1
|
||||
ARG rust_nightly_version
|
||||
RUN cargo +${rust_nightly_version} install cargo-component --locked --version ${cargo_component_version}
|
||||
|
||||
FROM install_rust AS cargo_semver_checks
|
||||
ARG cargo_semver_checks_version=0.24.1
|
||||
|
@ -182,8 +179,8 @@ COPY --chown=build:build --from=cargo_minimal_versions /opt/cargo/bin/cargo-mini
|
|||
COPY --chown=build:build --from=cargo_check_external_types /opt/cargo/bin/cargo-check-external-types /opt/cargo/bin/cargo-check-external-types
|
||||
COPY --chown=build:build --from=maturin /opt/cargo/bin/maturin /opt/cargo/bin/maturin
|
||||
COPY --chown=build:build --from=wasm_pack /opt/cargo/bin/wasm-pack /opt/cargo/bin/wasm-pack
|
||||
COPY --chown=build:build --from=wasmtime /opt/wasmtime /opt/cargo/bin/wasmtime
|
||||
COPY --chown=build:build --from=cargo_wasi /opt/cargo/bin/cargo-wasi /opt/cargo/bin/cargo-wasi
|
||||
COPY --chown=build:build --from=wasmtime /opt/cargo/bin/wasmtime /opt/cargo/bin/wasmtime
|
||||
COPY --chown=build:build --from=cargo_component /opt/cargo/bin/cargo-component /opt/cargo/bin/cargo-component
|
||||
COPY --chown=build:build --from=install_rust /opt/rustup /opt/rustup
|
||||
COPY --chown=build:build --from=cargo_semver_checks /opt/cargo/bin/cargo-semver-checks /opt/cargo/bin/cargo-semver-checks
|
||||
COPY --chown=build:build --from=cargo_mdbook /opt/cargo/bin/mdbook /opt/cargo/bin/mdbook
|
||||
|
|
|
@ -8,3 +8,6 @@ build
|
|||
.cdk.staging
|
||||
cdk.out
|
||||
cdk-outputs.json
|
||||
|
||||
# Generated Rust file
|
||||
canary-wasm/src/bindings.rs
|
||||
|
|
|
@ -24,7 +24,7 @@ cd canary-runner
|
|||
cargo run -- run --sdk-release-tag <version> --musl --cdk-output ../cdk-outputs.json
|
||||
```
|
||||
|
||||
__NOTE:__ You may want to add a `--profile` to the `deploy` command to select a specific credential
|
||||
**NOTE:** You may want to add a `--profile` to the deploy command to select a specific credential
|
||||
profile to deploy to if you don't want to use the default.
|
||||
|
||||
Also, if this is a new test AWS account, be sure it CDK bootstrap it before attempting to deploy.
|
||||
|
|
|
@ -12,7 +12,7 @@ use aws_config::SdkConfig;
|
|||
use tracing::{info_span, Instrument};
|
||||
|
||||
use crate::current_canary::paginator_canary;
|
||||
use crate::current_canary::{s3_canary, transcribe_canary};
|
||||
use crate::current_canary::{s3_canary, transcribe_canary, wasm_canary};
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! mk_canary {
|
||||
|
@ -35,6 +35,7 @@ pub fn get_canaries_to_run(
|
|||
paginator_canary::mk_canary(&sdk_config, &env),
|
||||
s3_canary::mk_canary(&sdk_config, &env),
|
||||
transcribe_canary::mk_canary(&sdk_config, &env),
|
||||
wasm_canary::mk_canary(&sdk_config, &env),
|
||||
];
|
||||
|
||||
canaries
|
||||
|
|
|
@ -6,3 +6,4 @@
|
|||
pub(crate) mod paginator_canary;
|
||||
pub(crate) mod s3_canary;
|
||||
pub(crate) mod transcribe_canary;
|
||||
pub(crate) mod wasm_canary;
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
use crate::{mk_canary, CanaryEnv};
|
||||
|
||||
use aws_config::SdkConfig;
|
||||
use wasmtime::component::{bindgen, Component, Linker};
|
||||
use wasmtime::{Engine, Store};
|
||||
use wasmtime_wasi::preview2::WasiCtxBuilder;
|
||||
|
||||
mk_canary!("wasm", |_sdk_config: &SdkConfig, _env: &CanaryEnv| {
|
||||
wasm_canary()
|
||||
});
|
||||
|
||||
//This macro creates bindings to call the wasm functions in Rust
|
||||
bindgen!({
|
||||
world: "canary-world",
|
||||
path: "../canary-wasm/wit/component.wit",
|
||||
async: true
|
||||
});
|
||||
|
||||
struct WasiHostCtx {
|
||||
preview2_ctx: wasmtime_wasi::preview2::WasiCtx,
|
||||
preview2_table: wasmtime::component::ResourceTable,
|
||||
wasi_http_ctx: wasmtime_wasi_http::WasiHttpCtx,
|
||||
}
|
||||
|
||||
impl wasmtime_wasi::preview2::WasiView for WasiHostCtx {
|
||||
fn table(&self) -> &wasmtime::component::ResourceTable {
|
||||
&self.preview2_table
|
||||
}
|
||||
|
||||
fn ctx(&self) -> &wasmtime_wasi::preview2::WasiCtx {
|
||||
&self.preview2_ctx
|
||||
}
|
||||
|
||||
fn table_mut(&mut self) -> &mut wasmtime::component::ResourceTable {
|
||||
&mut self.preview2_table
|
||||
}
|
||||
|
||||
fn ctx_mut(&mut self) -> &mut wasmtime_wasi::preview2::WasiCtx {
|
||||
&mut self.preview2_ctx
|
||||
}
|
||||
}
|
||||
|
||||
impl wasmtime_wasi_http::types::WasiHttpView for WasiHostCtx {
|
||||
fn ctx(&mut self) -> &mut wasmtime_wasi_http::WasiHttpCtx {
|
||||
&mut self.wasi_http_ctx
|
||||
}
|
||||
|
||||
fn table(&mut self) -> &mut wasmtime::component::ResourceTable {
|
||||
&mut self.preview2_table
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn wasm_canary() -> anyhow::Result<()> {
|
||||
let wasm_bin_path = std::env::current_dir()
|
||||
.expect("Current dir")
|
||||
.join("aws_sdk_rust_lambda_canary_wasm.wasm");
|
||||
|
||||
// Create a Wasmtime Engine configured to run Components
|
||||
let engine = Engine::new(
|
||||
wasmtime::Config::new()
|
||||
.wasm_component_model(true)
|
||||
.async_support(true),
|
||||
)?;
|
||||
|
||||
// Create our component from the wasm file
|
||||
let component = Component::from_file(&engine, wasm_bin_path)?;
|
||||
|
||||
// Create the linker and link in the necessary WASI bindings
|
||||
let mut linker: Linker<WasiHostCtx> = Linker::new(&engine);
|
||||
link_all_the_things(&mut linker);
|
||||
|
||||
// Configure and create a `WasiCtx`, which WASI functions need access to
|
||||
// through the host state of the store (which in this case is the host state
|
||||
// of the store)
|
||||
let wasi_ctx = WasiCtxBuilder::new()
|
||||
.inherit_stderr()
|
||||
.inherit_stdout()
|
||||
.build();
|
||||
|
||||
let host_ctx = WasiHostCtx {
|
||||
preview2_ctx: wasi_ctx,
|
||||
preview2_table: wasmtime_wasi::preview2::ResourceTable::new(),
|
||||
wasi_http_ctx: wasmtime_wasi_http::WasiHttpCtx {},
|
||||
};
|
||||
|
||||
let mut store: Store<WasiHostCtx> = Store::new(&engine, host_ctx);
|
||||
|
||||
// Instantiate our module with the bindgen! bindings
|
||||
let (bindings, _) = CanaryWorld::instantiate_async(&mut store, &component, &linker).await?;
|
||||
|
||||
let canary_interface = bindings.aws_component_canary_interface();
|
||||
let api_result = canary_interface
|
||||
.call_run_canary(store)
|
||||
.await?
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
// Asserting on the post FFI result to confirm everything in the wasm module worked
|
||||
assert!(!api_result.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This function adds all of the WASI bindings to the linker
|
||||
fn link_all_the_things(linker: &mut Linker<WasiHostCtx>) {
|
||||
//IO
|
||||
wasmtime_wasi::preview2::bindings::io::poll::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Poll");
|
||||
wasmtime_wasi::preview2::bindings::io::error::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Error");
|
||||
wasmtime_wasi::preview2::bindings::io::streams::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Streams");
|
||||
|
||||
//Random
|
||||
wasmtime_wasi::preview2::bindings::random::random::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Random");
|
||||
|
||||
//Clocks
|
||||
wasmtime_wasi::preview2::bindings::wasi::clocks::monotonic_clock::add_to_linker(linker, |cx| {
|
||||
cx
|
||||
})
|
||||
.expect("Failed to link Clock");
|
||||
wasmtime_wasi::preview2::bindings::wasi::clocks::wall_clock::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Wall Clock");
|
||||
|
||||
//Filesystem
|
||||
wasmtime_wasi::preview2::bindings::filesystem::types::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Filesystem Types");
|
||||
wasmtime_wasi::preview2::bindings::filesystem::preopens::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Filesystem Preopen");
|
||||
|
||||
//CLI
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::environment::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Environment");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::exit::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Environment");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::stdin::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Stdin");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::stdout::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Stdout");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::stderr::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Stderr");
|
||||
|
||||
// CLI Terminal
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::terminal_input::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Terminal Input");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::terminal_output::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Terminal Output");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::terminal_stdin::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Terminal Stdin");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::terminal_stdout::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Terminal Stdout");
|
||||
wasmtime_wasi::preview2::bindings::wasi::cli::terminal_stderr::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link Terminal Stderr");
|
||||
|
||||
//HTTP
|
||||
wasmtime_wasi_http::bindings::http::types::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link HTTP Types");
|
||||
wasmtime_wasi_http::bindings::http::outgoing_handler::add_to_linker(linker, |cx| cx)
|
||||
.expect("Failed to link HTTP Outgoing Handler");
|
||||
}
|
||||
|
||||
// #[ignore]
|
||||
#[cfg(test)]
|
||||
#[tokio::test]
|
||||
async fn test_wasm_canary() {
|
||||
wasm_canary().await.expect("Wasm return")
|
||||
}
|
|
@ -123,7 +123,7 @@ async fn lambda_main(sdk_config: SdkConfig) -> Result<Value, Error> {
|
|||
}
|
||||
|
||||
async fn canary_result(handle: JoinHandle<anyhow::Result<()>>) -> Result<(), String> {
|
||||
match timeout(Duration::from_secs(20), handle).await {
|
||||
match timeout(Duration::from_secs(180), handle).await {
|
||||
Err(_timeout) => Err("canary timed out".into()),
|
||||
Ok(Ok(result)) => match result {
|
||||
Ok(_) => Ok(()),
|
||||
|
|
|
@ -55,6 +55,10 @@ tokio-stream = "0"
|
|||
tracing-texray = "0.1.1"
|
||||
reqwest = { version = "0.11.14", features = ["rustls-tls"], default-features = false }
|
||||
edit-distance = "2"
|
||||
wit-bindgen = { version = "0.16.0", features = ["macros", "realloc"] }
|
||||
wasmtime = { version = "17.0.1", features = ["component-model"] }
|
||||
wasmtime-wasi = "17.0.1"
|
||||
wasmtime-wasi-http = "17.0.1"
|
||||
"#;
|
||||
|
||||
const REQUIRED_SDK_CRATES: &[&str] = &[
|
||||
|
@ -62,6 +66,7 @@ const REQUIRED_SDK_CRATES: &[&str] = &[
|
|||
"aws-sdk-s3",
|
||||
"aws-sdk-ec2",
|
||||
"aws-sdk-transcribestreaming",
|
||||
"aws-smithy-wasm",
|
||||
];
|
||||
|
||||
// The elements in this `Vec` should be sorted in an ascending order by the release date.
|
||||
|
@ -258,6 +263,10 @@ pub async fn build_bundle(opt: BuildBundleArgs) -> Result<Option<PathBuf>> {
|
|||
let crate_manifest_content = generate_crate_manifest(crate_source)?;
|
||||
fs::write(&manifest_path, crate_manifest_content).context("failed to write Cargo.toml")?;
|
||||
|
||||
let wasm_manifest_path = std::env::current_dir()
|
||||
.expect("Current dir")
|
||||
.join("../canary-wasm/Cargo.toml");
|
||||
|
||||
if !opt.manifest_only {
|
||||
// Compile the canary Lambda
|
||||
let mut command = Command::new("cargo");
|
||||
|
@ -271,6 +280,16 @@ pub async fn build_bundle(opt: BuildBundleArgs) -> Result<Option<PathBuf>> {
|
|||
}
|
||||
handle_failure("cargo build", &command.output()?)?;
|
||||
|
||||
// Compile the wasm canary to a .wasm binary
|
||||
let mut wasm_command = Command::new("cargo");
|
||||
wasm_command
|
||||
.arg("component")
|
||||
.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--manifest-path")
|
||||
.arg(&wasm_manifest_path);
|
||||
handle_failure("cargo component build", &wasm_command.output()?)?;
|
||||
|
||||
// Bundle the Lambda
|
||||
let repository_root = find_git_repository_root("smithy-rs", canary_path)?;
|
||||
let target_path = {
|
||||
|
@ -280,6 +299,14 @@ pub async fn build_bundle(opt: BuildBundleArgs) -> Result<Option<PathBuf>> {
|
|||
}
|
||||
path.join("release")
|
||||
};
|
||||
let wasm_bin_path = {
|
||||
repository_root
|
||||
.join("tools")
|
||||
.join("target")
|
||||
.join("wasm32-wasi")
|
||||
.join("release")
|
||||
.join("aws_sdk_rust_lambda_canary_wasm.wasm")
|
||||
};
|
||||
let bin_path = target_path.join("bootstrap");
|
||||
let bundle_path = target_path.join(name_bundle(
|
||||
&bin_path,
|
||||
|
@ -289,6 +316,7 @@ pub async fn build_bundle(opt: BuildBundleArgs) -> Result<Option<PathBuf>> {
|
|||
|
||||
let zip_file = fs::File::create(&bundle_path).context(here!())?;
|
||||
let mut zip = zip::ZipWriter::new(zip_file);
|
||||
//Write the canary bin to the zip
|
||||
zip.start_file(
|
||||
"bootstrap",
|
||||
zip::write::FileOptions::default().unix_permissions(0o755),
|
||||
|
@ -296,6 +324,15 @@ pub async fn build_bundle(opt: BuildBundleArgs) -> Result<Option<PathBuf>> {
|
|||
.context(here!())?;
|
||||
zip.write_all(&fs::read(&bin_path).context(here!("read target"))?)
|
||||
.context(here!())?;
|
||||
|
||||
// Write the wasm bin to the zip
|
||||
zip.start_file(
|
||||
"aws_sdk_rust_lambda_canary_wasm.wasm",
|
||||
zip::write::FileOptions::default().unix_permissions(0o644),
|
||||
)
|
||||
.context(here!())?;
|
||||
zip.write_all(&fs::read(wasm_bin_path).context(here!("read wasm bin"))?)
|
||||
.context(here!())?;
|
||||
zip.finish().context(here!())?;
|
||||
|
||||
println!(
|
||||
|
@ -453,10 +490,15 @@ tokio-stream = "0"
|
|||
tracing-texray = "0.1.1"
|
||||
reqwest = { version = "0.11.14", features = ["rustls-tls"], default-features = false }
|
||||
edit-distance = "2"
|
||||
wit-bindgen = { version = "0.16.0", features = ["macros", "realloc"] }
|
||||
wasmtime = { version = "17.0.1", features = ["component-model"] }
|
||||
wasmtime-wasi = "17.0.1"
|
||||
wasmtime-wasi-http = "17.0.1"
|
||||
aws-config = { path = "some/sdk/path/aws-config", features = ["behavior-version-latest"] }
|
||||
aws-sdk-s3 = { path = "some/sdk/path/s3" }
|
||||
aws-sdk-ec2 = { path = "some/sdk/path/ec2" }
|
||||
aws-sdk-transcribestreaming = { path = "some/sdk/path/transcribestreaming" }
|
||||
aws-smithy-wasm = { path = "some/sdk/path/aws-smithy-wasm" }
|
||||
|
||||
[features]
|
||||
latest = []
|
||||
|
@ -518,10 +560,15 @@ tokio-stream = "0"
|
|||
tracing-texray = "0.1.1"
|
||||
reqwest = { version = "0.11.14", features = ["rustls-tls"], default-features = false }
|
||||
edit-distance = "2"
|
||||
wit-bindgen = { version = "0.16.0", features = ["macros", "realloc"] }
|
||||
wasmtime = { version = "17.0.1", features = ["component-model"] }
|
||||
wasmtime-wasi = "17.0.1"
|
||||
wasmtime-wasi-http = "17.0.1"
|
||||
aws-config = { version = "0.46.0", features = ["behavior-version-latest"] }
|
||||
aws-sdk-s3 = "0.20.0"
|
||||
aws-sdk-ec2 = "0.19.0"
|
||||
aws-sdk-transcribestreaming = "0.16.0"
|
||||
aws-smithy-wasm = "0.1.0"
|
||||
|
||||
[features]
|
||||
latest = []
|
||||
|
@ -538,6 +585,7 @@ default = ["latest"]
|
|||
crate_version("aws-sdk-s3", "0.20.0"),
|
||||
crate_version("aws-sdk-ec2", "0.19.0"),
|
||||
crate_version("aws-sdk-transcribestreaming", "0.16.0"),
|
||||
crate_version("aws-smithy-wasm", "0.1.0"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
|
|
@ -388,7 +388,7 @@ async fn create_lambda_fn(
|
|||
)
|
||||
.publish(true)
|
||||
.environment(env_builder.build())
|
||||
.timeout(60)
|
||||
.timeout(180)
|
||||
.send()
|
||||
.await
|
||||
.context(here!("failed to create canary Lambda function"))?;
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,25 @@
|
|||
|
||||
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# IMPORTANT: Don't edit this file directly! Run `canary-runner build-bundle` to modify this file instead.
|
||||
[package]
|
||||
name = "aws-sdk-rust-lambda-canary-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.36.0", features = ["macros", "rt", "time"] }
|
||||
wit-bindgen = { version = "0.16.0", features = ["macros", "realloc"] }
|
||||
aws-config = { path = "../../../aws/sdk/build/aws-sdk/sdk/aws-config", default-features = false, features = ["behavior-version-latest"] }
|
||||
aws-sdk-s3 = { path = "../../../aws/sdk/build/aws-sdk/sdk/s3", default-features = false }
|
||||
aws-smithy-wasm = { path = "../../../aws/sdk/build/aws-sdk/sdk/aws-smithy-wasm" }
|
||||
aws-smithy-async = { path = "../../../aws/sdk/build/aws-sdk/sdk/aws-smithy-async", default-features = false, features = ["rt-tokio"]}
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
# metadata used by cargo-component to identify which wit world to embed in the binary
|
||||
[package.metadata.component]
|
||||
package = "aws:component"
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
use aws_config::Region;
|
||||
use aws_sdk_s3 as s3;
|
||||
use aws_smithy_async::rt::sleep::TokioSleep;
|
||||
use aws_smithy_wasm::wasi::WasiHttpClientBuilder;
|
||||
|
||||
//Generates the Rust bindings from the wit file
|
||||
wit_bindgen::generate!({
|
||||
world: "canary-world",
|
||||
exports: {
|
||||
"aws:component/canary-interface": Component
|
||||
}
|
||||
});
|
||||
|
||||
struct Component;
|
||||
|
||||
impl exports::aws::component::canary_interface::Guest for Component {
|
||||
fn run_canary() -> Result<Vec<String>, String> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.expect("Failed to generate runtime");
|
||||
let res = rt.block_on(run_canary())?;
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_canary() -> Result<Vec<String>, String> {
|
||||
let http_client = WasiHttpClientBuilder::new().build();
|
||||
let sleep = TokioSleep::new();
|
||||
let config = aws_config::from_env()
|
||||
.region(Region::new("us-east-2"))
|
||||
.no_credentials()
|
||||
.http_client(http_client)
|
||||
.sleep_impl(sleep)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
let client = s3::Client::new(&config);
|
||||
let result = client
|
||||
.list_objects_v2()
|
||||
.bucket("nara-national-archives-catalog")
|
||||
.delimiter("/")
|
||||
.prefix("authority-records/organization/")
|
||||
.max_keys(5)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to ListObjects");
|
||||
|
||||
//For ease of modeling the return we just extract the keys from the objects
|
||||
let object_names: Vec<String> = result
|
||||
.contents
|
||||
.expect("No S3 Objects")
|
||||
.iter()
|
||||
.map(|obj| obj.key().expect("Object has no name").to_string())
|
||||
.collect();
|
||||
|
||||
Ok(object_names)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package aws:component;
|
||||
|
||||
interface canary-interface {
|
||||
run-canary: func() -> result<list<string>, string>;
|
||||
}
|
||||
|
||||
world canary-world {
|
||||
export canary-interface;
|
||||
}
|
||||
|
|
@ -55,6 +55,6 @@ cargo check --target wasm32-wasi --no-default-features
|
|||
# TODO(https://github.com/smithy-lang/smithy-rs/issues/2499): Uncomment the following once aws-config tests compile for WASM
|
||||
# echo "${C_YELLOW}## Testing the wasm32-unknown-unknown and wasm32-wasi targets${C_RESET}"
|
||||
# wasm-pack test --node -- --no-default-features
|
||||
# cargo wasi test --no-default-features
|
||||
# cargo test --target wasm32-wasi --no-default-features
|
||||
|
||||
popd &>/dev/null
|
||||
|
|
|
@ -39,4 +39,10 @@ find "${tmp_dir}"
|
|||
|
||||
pushd "${tmp_dir}/aws/sdk/integration-tests"
|
||||
cargo check --tests --all-features
|
||||
|
||||
# Running WebAssembly (WASI) specific integration tests
|
||||
pushd "${tmp_dir}/aws/sdk/integration-tests/webassembly" &>/dev/null
|
||||
cargo check --tests --all-features
|
||||
|
||||
popd
|
||||
popd
|
||||
|
|
Loading…
Reference in New Issue