blob: 5c0a2dc29da9705825f390260d3aa8c9398077b9 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
mod worker;
use futures_util::future::BoxFuture;
use maka_agent_facts::tools::ToolError;
use maka_capabilities::call::Scope;
use maka_capabilities::http::Body;
use maka_capabilities::http::Client;
use maka_capabilities::http::Error;
use maka_capabilities::http::Head;
use maka_capabilities::http::Method;
use maka_capabilities::http::Request;
use maka_capabilities::http::Response;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use std::{sync::Arc, time::Duration};
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::sync::CancellationToken;
/// Authority and worker ownership supplied by the actual embedding. HTTP data
/// cannot choose a Root, grant, invocation, or lifecycle owner.
pub trait Authority: Send + Sync {
fn admit(&self, call: &Scope) -> Result<Box<dyn Send>, Error>;
fn policy<'a>(
&'a self,
call: &'a Scope,
destination: &'a maka_sandbox::Destination,
origin: String,
) -> BoxFuture<'a, Result<maka_network::Policy, Error>>;
fn check<'a>(
&'a self,
call: &'a Scope,
destination: &'a maka_sandbox::Destination,
) -> BoxFuture<'a, Result<(), Error>>;
fn record(
&self,
call: Scope,
operation: maka_event_log::effects::Operation,
destination: maka_sandbox::Destination,
effect: BoxFuture<'static, Result<serde_json::Value, ToolError>>,
) -> BoxFuture<'static, Result<serde_json::Value, ToolError>>;
fn spawn(&self, worker: Worker) -> Result<(), Error>;
}
pub type Worker =
Box<dyn FnOnce(CancellationToken) -> BoxFuture<'static, Result<(), String>> + Send>;
pub struct Http {
authority: Arc<dyn Authority>,
client: tokio::sync::Mutex<Option<(maka_network::Policy, reqwest::Client)>>,
}
impl Http {
pub fn new(authority: Arc<dyn Authority>) -> Self {
Self {
authority,
client: Default::default(),
}
}
}
impl Client for Http {
fn request(&self, call: Scope, input: Request) -> BoxFuture<'_, Result<Response, Error>> {
Box::pin(async move {
let _lease = self.authority.admit(&call)?;
if call.cancellation.is_cancelled() {
return Err(Error::Denied);
}
let url = reqwest::Url::parse(&input.url).map_err(invalid)?;
if !matches!(url.scheme(), "http" | "https")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return Err(invalid("invalid HTTP request"));
}
let mut headers = HeaderMap::new();
let destination = maka_sandbox::Destination::new(
url.host_str().ok_or_else(|| invalid("missing HTTP host"))?,
url.port_or_known_default()
.ok_or_else(|| invalid("missing HTTP port"))?,
)
.map_err(invalid)?;
for (name, value) in input.headers {
let name = HeaderName::from_bytes(name.as_bytes()).map_err(invalid)?;
if matches!(
name.as_str(),
"host"
| "connection"
| "content-length"
| "transfer-encoding"
| "upgrade"
| "proxy-authorization"
| "proxy-connection"
) {
return Err(invalid("HTTP framing and proxy headers are owned by Host"));
}
headers
.try_append(name, HeaderValue::from_str(&value).map_err(invalid)?)
.map_err(invalid)?;
}
let policy = self
.authority
.policy(&call, &destination, url.origin().ascii_serialization())
.await?;
policy
.check_request_headers(&url, &headers, !input.body.is_empty(), false)
.map_err(invalid)?;
let client = {
let mut cached = self.client.lock().await;
if cached
.as_ref()
.is_none_or(|(previous, _)| previous != &policy)
{
let client = policy
.client_builder()
.read_timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(failed)?;
*cached = Some((policy, client));
}
cached.as_ref().unwrap().1.clone()
};
let method = match input.method {
Method::Get => reqwest::Method::GET,
Method::Head => reqwest::Method::HEAD,
Method::Post => reqwest::Method::POST,
Method::Put => reqwest::Method::PUT,
Method::Patch => reqwest::Method::PATCH,
Method::Delete => reqwest::Method::DELETE,
Method::Options => reqwest::Method::OPTIONS,
};
let operation = maka_event_log::effects::Operation::Http {
method: input.method,
url: url.to_string(),
body_digest: maka_agent_facts::artifact::content_digest(&input.body),
body_bytes: input.body.len(),
};
let request = client
.request(method, url)
.headers(headers)
.body(input.body);
let mut ticket = call.resources.reserve().map_err(failed)?;
let (head, response) = oneshot::channel();
let (send, output) = mpsc::channel(1);
let (ended, finished) = watch::channel(None);
let stop = CancellationToken::new();
let body = Arc::new(ResponseBody {
authority: self.authority.clone(),
call: call.clone(),
destination: destination.clone(),
stop: stop.clone(),
output: tokio::sync::Mutex::new(output),
ended: finished,
});
let authority = self.authority.clone();
self.authority.spawn(Box::new(move |retiring| Box::pin(async move {
ticket.start();
let cancellation = call.cancellation.clone();
let effect = Box::pin(async move {
tokio::select! {
biased;
_ = stop.cancelled() => Err("HTTP response closed".into()),
_ = retiring.cancelled() => Err("plugin retired during HTTP response".into()),
_ = cancellation.cancelled() => Err("invocation closed".into()),
result = worker::run(request, head, send) => result,
}.map_err(ToolError::OutcomeUnknown)
});
let result = authority.record(call, operation, destination, effect).await;
// Cancelling local I/O cannot roll back remote side effects.
let settled = match &result {
Err(error @ (ToolError::Persistence(_) | ToolError::CleanupUnconfirmed(_))) =>
Err(error.to_string()),
_ => Ok(()),
};
ticket.complete(settled.clone());
ended.send_replace(Some(result.map(|_| ()).map_err(|error| error.to_string())));
settled
})))?;
match response.await {
Ok(Ok(head)) => Ok(Response { head, body }),
result => {
body.close().await?;
Err(failed(match result {
Ok(Err(error)) => error,
_ => "HTTP request interrupted; remote outcome may be unknown".into(),
}))
}
}
})
}
}
struct ResponseBody {
authority: Arc<dyn Authority>,
call: Scope,
destination: maka_sandbox::Destination,
stop: CancellationToken,
output: tokio::sync::Mutex<mpsc::Receiver<Vec<u8>>>,
ended: watch::Receiver<Option<Result<(), String>>>,
}
impl Drop for ResponseBody {
fn drop(&mut self) {
self.stop.cancel();
}
}
impl Body for ResponseBody {
fn next(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async move {
let _lease = self.authority.admit(&self.call)?;
if self.call.cancellation.is_cancelled() {
return Err(Error::Denied);
}
self.authority.check(&self.call, &self.destination).await?;
let mut output = self
.output
.try_lock()
.map_err(|_| invalid("HTTP response already has a reader"))?;
tokio::select! {
biased;
_ = self.call.cancellation.cancelled() => Err(Error::Denied),
_ = self.stop.cancelled() => Err(Error::Denied),
chunk = output.recv() => match chunk {
Some(bytes) => Ok(Some(bytes)),
None => {
let mut ended = self.ended.clone();
ended.wait_for(Option::is_some).await.map_err(failed)?;
let result = ended.borrow().as_ref().unwrap().clone();
result.map(|()| None).map_err(failed)
}
},
}
})
}
fn cancel(&self) {
self.stop.cancel();
}
fn close(&self) -> BoxFuture<'_, Result<(), Error>> {
self.cancel();
Box::pin(async move {
let mut ended = self.ended.clone();
tokio::time::timeout(Duration::from_secs(5), ended.wait_for(Option::is_some))
.await
.map_err(|_| Error::CleanupUnconfirmed)?
.map_err(|_| Error::CleanupUnconfirmed)?;
Ok(())
})
}
}
fn invalid(error: impl ToString) -> Error {
Error::Invalid(error.to_string())
}
fn failed(error: impl ToString) -> Error {
Error::Failed(error.to_string())
}
fn message(error: impl ToString) -> String {
error.to_string()
}