diff --git a/docs/root/configuration/http/http_filters/lua_filter.rst b/docs/root/configuration/http/http_filters/lua_filter.rst index 363709b1eb2b0..64c78f1c48cc8 100644 --- a/docs/root/configuration/http/http_filters/lua_filter.rst +++ b/docs/root/configuration/http/http_filters/lua_filter.rst @@ -233,14 +233,17 @@ httpCall() .. code-block:: lua - headers, body = handle:httpCall(cluster, headers, body, timeout) + headers, body = handle:httpCall(cluster, headers, body, timeout, asynchronous) -Makes an HTTP call to an upstream host. Envoy will yield the script until the call completes or -has an error. *cluster* is a string which maps to a configured cluster manager cluster. *headers* +Makes an HTTP call to an upstream host. *cluster* is a string which maps to a configured cluster manager cluster. *headers* is a table of key/value pairs to send (the value can be a string or table of strings). Note that the *:method*, *:path*, and *:authority* headers must be set. *body* is an optional string of body data to send. *timeout* is an integer that specifies the call timeout in milliseconds. +*asynchronous* is a boolean flag. If asynchronous is set to true, Envoy will make the HTTP request and continue, +regardless of response success or failure. If this is set to false, or not set, Envoy will yield the script +until the call completes or has an error. + Returns *headers* which is a table of response headers. Returns *body* which is the string response body. May be nil if there is no body. diff --git a/docs/root/intro/version_history.rst b/docs/root/intro/version_history.rst index 8e2f2a667b237..c9d93953e2e84 100644 --- a/docs/root/intro/version_history.rst +++ b/docs/root/intro/version_history.rst @@ -20,6 +20,7 @@ Version history * listener filters: listener filter extensions use the "envoy.filters.listener" name space. A mapping of extension names is available in the :ref:`deprecated ` documentation. * listeners: fixed issue where :ref:`TLS inspector listener filter ` could have been bypassed by a client using only TLS 1.3. +* lua: added a parameter to `httpCall` that makes it possible to have the call be asynchronous. * mongo: the stat emitted for queries without a max time set in the :ref:`MongoDB filter` was modified to emit correctly for Mongo v3.2+. * network filters: network filter extensions use the "envoy.filters.network" name space. A mapping of extension names is available in the :ref:`deprecated ` documentation. diff --git a/source/extensions/filters/http/lua/lua_filter.cc b/source/extensions/filters/http/lua/lua_filter.cc index 28aa88a3d528d..b1522203a9fed 100644 --- a/source/extensions/filters/http/lua/lua_filter.cc +++ b/source/extensions/filters/http/lua/lua_filter.cc @@ -15,6 +15,79 @@ namespace Extensions { namespace HttpFilters { namespace Lua { +namespace { +// Okay to return non-const reference because this doesn't ever get changed. +NoopCallbacks& noopCallbacks() { + static NoopCallbacks* callbacks = new NoopCallbacks(); + return *callbacks; +} + +void buildHeadersFromTable(Http::HeaderMap& headers, lua_State* state, int table_index) { + // Build a header map to make the request. We iterate through the provided table to do this and + // check that we are getting strings. + lua_pushnil(state); + while (lua_next(state, table_index) != 0) { + // Uses 'key' (at index -2) and 'value' (at index -1). + const char* key = luaL_checkstring(state, -2); + // Check if the current value is a table, we iterate through the table and add each element of + // it as a header entry value for the current key. + if (lua_istable(state, -1)) { + lua_pushnil(state); + while (lua_next(state, -2) != 0) { + const char* value = luaL_checkstring(state, -1); + headers.addCopy(Http::LowerCaseString(key), value); + lua_pop(state, 1); + } + } else { + const char* value = luaL_checkstring(state, -1); + headers.addCopy(Http::LowerCaseString(key), value); + } + // Removes 'value'; keeps 'key' for next iteration. This is the input for lua_next() so that + // it can push the next key/value pair onto the stack. + lua_pop(state, 1); + } +} + +Http::AsyncClient::Request* makeHttpCall(lua_State* state, Filter& filter, + Http::AsyncClient::Callbacks& callbacks) { + const std::string cluster = luaL_checkstring(state, 2); + luaL_checktype(state, 3, LUA_TTABLE); + size_t body_size; + const char* body = luaL_optlstring(state, 4, nullptr, &body_size); + int timeout_ms = luaL_checkint(state, 5); + if (timeout_ms < 0) { + luaL_error(state, "http call timeout must be >= 0"); + } + + if (filter.clusterManager().get(cluster) == nullptr) { + luaL_error(state, "http call cluster invalid. Must be configured"); + } + + auto headers = std::make_unique(); + buildHeadersFromTable(*headers, state, 3); + Http::RequestMessagePtr message(new Http::RequestMessageImpl(std::move(headers))); + + // Check that we were provided certain headers. + if (message->headers().Path() == nullptr || message->headers().Method() == nullptr || + message->headers().Host() == nullptr) { + luaL_error(state, "http call headers must include ':path', ':method', and ':authority'"); + } + + if (body != nullptr) { + message->body() = std::make_unique(body, body_size); + message->headers().setContentLength(body_size); + } + + absl::optional timeout; + if (timeout_ms > 0) { + timeout = std::chrono::milliseconds(timeout_ms); + } + + return filter.clusterManager().httpAsyncClientForCluster(cluster).send( + std::move(message), callbacks, Http::AsyncClient::RequestOptions().setTimeout(timeout)); +} +} // namespace + StreamHandleWrapper::StreamHandleWrapper(Filters::Common::Lua::Coroutine& coroutine, Http::HeaderMap& headers, bool end_stream, Filter& filter, FilterCallbacks& callbacks) @@ -138,71 +211,23 @@ int StreamHandleWrapper::luaRespond(lua_State* state) { return lua_yield(state, 0); } -void StreamHandleWrapper::buildHeadersFromTable(Http::HeaderMap& headers, lua_State* state, - int table_index) { - // Build a header map to make the request. We iterate through the provided table to do this and - // check that we are getting strings. - lua_pushnil(state); - while (lua_next(state, table_index) != 0) { - // Uses 'key' (at index -2) and 'value' (at index -1). - const char* key = luaL_checkstring(state, -2); - // Check if the current value is a table, we iterate through the table and add each element of - // it as a header entry value for the current key. - if (lua_istable(state, -1)) { - lua_pushnil(state); - while (lua_next(state, -2) != 0) { - const char* value = luaL_checkstring(state, -1); - headers.addCopy(Http::LowerCaseString(key), value); - lua_pop(state, 1); - } - } else { - const char* value = luaL_checkstring(state, -1); - headers.addCopy(Http::LowerCaseString(key), value); - } - // Removes 'value'; keeps 'key' for next iteration. This is the input for lua_next() so that - // it can push the next key/value pair onto the stack. - lua_pop(state, 1); - } -} - int StreamHandleWrapper::luaHttpCall(lua_State* state) { ASSERT(state_ == State::Running); - const std::string cluster = luaL_checkstring(state, 2); - luaL_checktype(state, 3, LUA_TTABLE); - size_t body_size; - const char* body = luaL_optlstring(state, 4, nullptr, &body_size); - int timeout_ms = luaL_checkint(state, 5); - if (timeout_ms < 0) { - return luaL_error(state, "http call timeout must be >= 0"); - } - - if (filter_.clusterManager().get(cluster) == nullptr) { - return luaL_error(state, "http call cluster invalid. Must be configured"); - } - - auto headers = std::make_unique(); - buildHeadersFromTable(*headers, state, 3); - Http::RequestMessagePtr message(new Http::RequestMessageImpl(std::move(headers))); - - // Check that we were provided certain headers. - if (message->headers().Path() == nullptr || message->headers().Method() == nullptr || - message->headers().Host() == nullptr) { - return luaL_error(state, "http call headers must include ':path', ':method', and ':authority'"); - } - - if (body != nullptr) { - message->body() = std::make_unique(body, body_size); - message->headers().setContentLength(body_size); + const int async_flag_index = 6; + if (!lua_isnone(state, async_flag_index) && !lua_isboolean(state, async_flag_index)) { + luaL_error(state, "http call asynchronous flag must be 'true', 'false', or empty"); } - absl::optional timeout; - if (timeout_ms > 0) { - timeout = std::chrono::milliseconds(timeout_ms); + if (lua_toboolean(state, async_flag_index)) { + return luaHttpCallAsynchronous(state); + } else { + return luaHttpCallSynchronous(state); } +} - http_request_ = filter_.clusterManager().httpAsyncClientForCluster(cluster).send( - std::move(message), *this, Http::AsyncClient::RequestOptions().setTimeout(timeout)); +int StreamHandleWrapper::luaHttpCallSynchronous(lua_State* state) { + http_request_ = makeHttpCall(state, filter_, *this); if (http_request_) { state_ = State::HttpCall; return lua_yield(state, 0); @@ -213,6 +238,11 @@ int StreamHandleWrapper::luaHttpCall(lua_State* state) { } } +int StreamHandleWrapper::luaHttpCallAsynchronous(lua_State* state) { + makeHttpCall(state, filter_, noopCallbacks()); + return 0; +} + void StreamHandleWrapper::onSuccess(Http::ResponseMessagePtr&& response) { ASSERT(state_ == State::HttpCall || state_ == State::Running); ENVOY_LOG(debug, "async HTTP response complete"); diff --git a/source/extensions/filters/http/lua/lua_filter.h b/source/extensions/filters/http/lua/lua_filter.h index 792a7becfe8b8..9e07a8cee605f 100644 --- a/source/extensions/filters/http/lua/lua_filter.h +++ b/source/extensions/filters/http/lua/lua_filter.h @@ -156,6 +156,8 @@ class StreamHandleWrapper : public Filters::Common::Lua::BaseLuaObjectdecodeTrailers(request_trailers)); } -// Script asking for blocking body, request that is headers only. +// Script asking for synchronous body, request that is headers only. TEST_F(LuaHttpFilterTest, ScriptBodyRequestHeadersOnly) { InSequence s; setup(BODY_SCRIPT); @@ -376,7 +376,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyRequestHeadersOnly) { EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); } -// Script asking for blocking body, request that has a body. +// Script asking for synchronous body, request that has a body. TEST_F(LuaHttpFilterTest, ScriptBodyRequestBody) { InSequence s; setup(BODY_SCRIPT); @@ -391,7 +391,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyRequestBody) { EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(data, true)); } -// Script asking for blocking body, request that has a body in multiple frames. +// Script asking for synchronous body, request that has a body in multiple frames. TEST_F(LuaHttpFilterTest, ScriptBodyRequestBodyTwoFrames) { InSequence s; setup(BODY_SCRIPT); @@ -410,7 +410,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyRequestBodyTwoFrames) { EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(data2, true)); } -// Scripting asking for blocking body, request that has a body in multiple frames follows by +// Scripting asking for synchronous body, request that has a body in multiple frames follows by // trailers. TEST_F(LuaHttpFilterTest, ScriptBodyRequestBodyTwoFramesTrailers) { InSequence s; @@ -434,7 +434,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyRequestBodyTwoFramesTrailers) { EXPECT_EQ(Http::FilterTrailersStatus::Continue, filter_->decodeTrailers(request_trailers)); } -// Script asking for blocking body and trailers, request that is headers only. +// Script asking for synchronous body and trailers, request that is headers only. TEST_F(LuaHttpFilterTest, ScriptBodyTrailersRequestHeadersOnly) { InSequence s; setup(BODY_TRAILERS_SCRIPT); @@ -446,7 +446,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyTrailersRequestHeadersOnly) { EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); } -// Script asking for blocking body and trailers, request that has a body. +// Script asking for synchronous body and trailers, request that has a body. TEST_F(LuaHttpFilterTest, ScriptBodyTrailersRequestBody) { InSequence s; setup(BODY_TRAILERS_SCRIPT); @@ -462,7 +462,7 @@ TEST_F(LuaHttpFilterTest, ScriptBodyTrailersRequestBody) { EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(data, true)); } -// Script asking for blocking body and trailers, request that has a body and trailers. +// Script asking for synchronous body and trailers, request that has a body and trailers. TEST_F(LuaHttpFilterTest, ScriptBodyTrailersRequestBodyTrailers) { InSequence s; setup(BODY_TRAILERS_SCRIPT); @@ -708,8 +708,8 @@ TEST_F(LuaHttpFilterTest, RequestAndResponse) { EXPECT_EQ(Http::FilterTrailersStatus::Continue, filter_->encodeTrailers(response_trailers)); } -// Response blocking body. -TEST_F(LuaHttpFilterTest, ResponseBlockingBody) { +// Response synchronous body. +TEST_F(LuaHttpFilterTest, ResponseSynchronousBody) { const std::string SCRIPT{R"EOF( function envoy_on_response(response_handle) response_handle:logTrace(response_handle:headers():get(":status")) @@ -799,6 +799,119 @@ TEST_F(LuaHttpFilterTest, HttpCall) { callbacks->onSuccess(std::move(response_message)); } +// Basic HTTP request flow. Asynchronous flag set to false. +TEST_F(LuaHttpFilterTest, HttpCallAsyncFalse) { + const std::string SCRIPT{R"EOF( + function envoy_on_request(request_handle) + local headers, body = request_handle:httpCall( + "cluster", + { + [":method"] = "POST", + [":path"] = "/", + [":authority"] = "foo", + ["set-cookie"] = { "flavor=chocolate; Path=/", "variant=chewy; Path=/" } + }, + "hello world", + 5000, + false) + for key, value in pairs(headers) do + request_handle:logTrace(key .. " " .. value) + end + request_handle:logTrace(body) + end + )EOF"}; + + InSequence s; + setup(SCRIPT); + + Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}}; + Http::MockAsyncClientRequest request(&cluster_manager_.async_client_); + Http::AsyncClient::Callbacks* callbacks; + EXPECT_CALL(cluster_manager_, get(Eq("cluster"))); + EXPECT_CALL(cluster_manager_, httpAsyncClientForCluster("cluster")); + EXPECT_CALL(cluster_manager_.async_client_, send_(_, _, _)) + .WillOnce( + Invoke([&](Http::RequestMessagePtr& message, Http::AsyncClient::Callbacks& cb, + const Http::AsyncClient::RequestOptions&) -> Http::AsyncClient::Request* { + EXPECT_EQ((Http::TestHeaderMapImpl{{":path", "/"}, + {":method", "POST"}, + {":authority", "foo"}, + {"set-cookie", "flavor=chocolate; Path=/"}, + {"set-cookie", "variant=chewy; Path=/"}, + {"content-length", "11"}}), + message->headers()); + callbacks = &cb; + return &request; + })); + + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, false)); + + Buffer::OwnedImpl data("hello"); + EXPECT_EQ(Http::FilterDataStatus::StopIterationAndBuffer, filter_->decodeData(data, false)); + + Http::TestRequestTrailerMapImpl request_trailers{{"foo", "bar"}}; + EXPECT_EQ(Http::FilterTrailersStatus::StopIteration, filter_->decodeTrailers(request_trailers)); + + Http::ResponseMessagePtr response_message(new Http::ResponseMessageImpl( + Http::ResponseHeaderMapPtr{new Http::TestResponseHeaderMapImpl{{":status", "200"}}})); + response_message->body() = std::make_unique("response"); + EXPECT_CALL(*filter_, scriptLog(spdlog::level::trace, StrEq(":status 200"))); + EXPECT_CALL(*filter_, scriptLog(spdlog::level::trace, StrEq("response"))); + EXPECT_CALL(decoder_callbacks_, continueDecoding()); + callbacks->onSuccess(std::move(response_message)); +} + +// Basic asynchronous, fire-and-forget HTTP request flow. +TEST_F(LuaHttpFilterTest, HttpCallAsynchronous) { + const std::string SCRIPT{R"EOF( + function envoy_on_request(request_handle) + local headers, body = request_handle:httpCall( + "cluster", + { + [":method"] = "POST", + [":path"] = "/", + [":authority"] = "foo", + ["set-cookie"] = { "flavor=chocolate; Path=/", "variant=chewy; Path=/" } + }, + "hello world", + 5000, + true) + end + )EOF"}; + + InSequence s; + setup(SCRIPT); + + Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}}; + Http::MockAsyncClientRequest request(&cluster_manager_.async_client_); + Http::AsyncClient::Callbacks* callbacks; + EXPECT_CALL(cluster_manager_, get(Eq("cluster"))); + EXPECT_CALL(cluster_manager_, httpAsyncClientForCluster("cluster")); + EXPECT_CALL(cluster_manager_.async_client_, send_(_, _, _)) + .WillOnce( + Invoke([&](Http::RequestMessagePtr& message, Http::AsyncClient::Callbacks& cb, + const Http::AsyncClient::RequestOptions&) -> Http::AsyncClient::Request* { + EXPECT_EQ((Http::TestHeaderMapImpl{{":path", "/"}, + {":method", "POST"}, + {":authority", "foo"}, + {"set-cookie", "flavor=chocolate; Path=/"}, + {"set-cookie", "variant=chewy; Path=/"}, + {"content-length", "11"}}), + message->headers()); + callbacks = &cb; + return &request; + })); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); + + Buffer::OwnedImpl data("hello"); + EXPECT_EQ(Http::FilterDataStatus::Continue, filter_->decodeData(data, false)); + + Http::TestRequestTrailerMapImpl request_trailers{{"foo", "bar"}}; + EXPECT_EQ(Http::FilterTrailersStatus::Continue, filter_->decodeTrailers(request_trailers)); +} + // Double HTTP call. Responses before request body. TEST_F(LuaHttpFilterTest, DoubleHttpCall) { const std::string SCRIPT{R"EOF( @@ -1246,6 +1359,34 @@ TEST_F(LuaHttpFilterTest, HttpCallInvalidHeaders) { EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); } +// Invalid HTTP call asynchronous flag value. +TEST_F(LuaHttpFilterTest, HttpCallAsyncInvalidAsynchronousFlag) { + const std::string SCRIPT{R"EOF( + function envoy_on_request(request_handle) + request_handle:httpCall( + "cluster", + { + [":method"] = "POST", + [":path"] = "/", + [":authority"] = "foo", + ["set-cookie"] = { "flavor=chocolate; Path=/", "variant=chewy; Path=/" } + }, + "hello world", + 5000, + potato) + end + )EOF"}; + + InSequence s; + setup(SCRIPT); + + Http::TestRequestHeaderMapImpl request_headers{{":path", "/"}}; + EXPECT_CALL(*filter_, + scriptLog(spdlog::level::err, StrEq("[string \"...\"]:3: http call asynchronous flag " + "must be 'true', 'false', or empty"))); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, false)); +} + // Respond right away. // This is also a regression test for https://github.com/envoyproxy/envoy/issues/3570 which runs // the request flow 2000 times and does a GC at the end to make sure we don't leak memory. diff --git a/test/extensions/filters/http/lua/lua_integration_test.cc b/test/extensions/filters/http/lua/lua_integration_test.cc index 09272625b9525..70a02141e0a11 100644 --- a/test/extensions/filters/http/lua/lua_integration_test.cc +++ b/test/extensions/filters/http/lua/lua_integration_test.cc @@ -8,6 +8,8 @@ #include "gtest/gtest.h" +using Envoy::Http::HeaderValueOf; + namespace Envoy { namespace { @@ -355,6 +357,56 @@ name: envoy.filters.http.lua EXPECT_EQ("nope", response->body()); } +// Upstream fire and forget asynchronous call. +TEST_P(LuaIntegrationTest, UpstreamAsyncHttpCall) { + const std::string FILTER_AND_CODE = + R"EOF( +name: envoy.lua +typed_config: + "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua + inline_code: | + function envoy_on_request(request_handle) + local headers, body = request_handle:httpCall( + "lua_cluster", + { + [":method"] = "POST", + [":path"] = "/", + [":authority"] = "lua_cluster" + }, + "hello world", + 5000, + true) + end +)EOF"; + + initializeFilter(FILTER_AND_CODE); + + codec_client_ = makeHttpConnection(makeClientConnection(lookupPort("http"))); + Http::TestRequestHeaderMapImpl request_headers{{":method", "GET"}, + {":path", "/test/long/url"}, + {":scheme", "http"}, + {":authority", "host"}, + {"x-forwarded-for", "10.0.0.1"}}; + auto response = codec_client_->makeHeaderOnlyRequest(request_headers); + + ASSERT_TRUE(fake_upstreams_[1]->waitForHttpConnection(*dispatcher_, fake_lua_connection_)); + ASSERT_TRUE(fake_lua_connection_->waitForNewStream(*dispatcher_, lua_request_)); + ASSERT_TRUE(lua_request_->waitForEndStream(*dispatcher_)); + // Sanity checking that we sent the expected data. + EXPECT_THAT(lua_request_->headers(), HeaderValueOf(Http::Headers::get().Method, "POST")); + EXPECT_THAT(lua_request_->headers(), HeaderValueOf(Http::Headers::get().Path, "/")); + + waitForNextUpstreamRequest(); + + upstream_request_->encodeHeaders(default_response_headers_, true); + response->waitForEndStream(); + + cleanup(); + + EXPECT_TRUE(response->complete()); + EXPECT_EQ("200", response->headers().Status()->value().getStringView()); +} + // Filter alters headers and changes route. TEST_P(LuaIntegrationTest, ChangeRoute) { const std::string FILTER_AND_CODE =