Skip to content

Commit

Permalink
Add setters and getter for JS runtime options, and sample HTTP APIs (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
eddyashton authored Jun 10, 2024
1 parent e7464e4 commit a00a91b
Show file tree
Hide file tree
Showing 8 changed files with 299 additions and 15 deletions.
7 changes: 1 addition & 6 deletions doc/schemas/gov_openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,6 @@
"$ref": "#/components/schemas/boolean"
}
},
"required": [
"max_heap_bytes",
"max_stack_bytes",
"max_execution_time_ms"
],
"type": "object"
},
"JwtIssuerKeyFilter": {
Expand Down Expand Up @@ -1335,7 +1330,7 @@
"info": {
"description": "This API is used to submit and query proposals which affect CCF's public governance tables.",
"title": "CCF Governance API",
"version": "4.1.6"
"version": "4.1.7"
},
"openapi": "3.0.0",
"paths": {
Expand Down
15 changes: 15 additions & 0 deletions include/ccf/js/registry.h
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,21 @@ namespace ccf::js
ccf::ApiResult get_custom_endpoint_module_v1(
std::string& code, kv::ReadOnlyTx& tx, const std::string& module_name);

/**
* Set options to control JS execution. Some hard limits may be applied to
* bound any values specified here.
*/
ccf::ApiResult set_js_runtime_options_v1(
kv::Tx& tx, const ccf::JSRuntimeOptions& options);

/**
* Get the options which currently control JS execution. If no value has
* been populated in the KV, this will return the default runtime options
* which will be applied instead.
*/
ccf::ApiResult get_js_runtime_options_v1(
ccf::JSRuntimeOptions& options, kv::ReadOnlyTx& tx);

/// \defgroup Overrides for base EndpointRegistry functions, looking up JS
/// endpoints before delegating to base implementation.
///@{
Expand Down
72 changes: 64 additions & 8 deletions include/ccf/service/tables/jsengine.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#pragma once

#include "ccf/ds/json.h"
#include "ccf/ds/openapi.h"
#include "ccf/service/map.h"

namespace ccf
Expand Down Expand Up @@ -38,14 +39,69 @@ namespace ccf
size_t max_cached_interpreters = Defaults::max_cached_interpreters;
};

DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JSRuntimeOptions)
DECLARE_JSON_REQUIRED_FIELDS(
JSRuntimeOptions, max_heap_bytes, max_stack_bytes, max_execution_time_ms)
DECLARE_JSON_OPTIONAL_FIELDS(
JSRuntimeOptions,
log_exception_details,
return_exception_details,
max_cached_interpreters);
#define FOREACH_JSENGINE_FIELD(XX) \
XX(max_heap_bytes, decltype(JSRuntimeOptions::max_heap_bytes)) \
XX(max_stack_bytes, decltype(JSRuntimeOptions::max_stack_bytes)) \
XX(max_execution_time_ms, decltype(JSRuntimeOptions::max_execution_time_ms)) \
XX(log_exception_details, decltype(JSRuntimeOptions::log_exception_details)) \
XX( \
return_exception_details, \
decltype(JSRuntimeOptions::return_exception_details)) \
XX( \
max_cached_interpreters, \
decltype(JSRuntimeOptions::max_cached_interpreters))

// Manually implemented to_json and from_json, so that we are maximally
// permissive in deserialisation (use defaults), but maximally verbose in
// serialisation (describe all fields)
inline void to_json(nlohmann::json& j, const JSRuntimeOptions& options)
{
j = nlohmann::json::object();
#define XX(field, field_type) j[#field] = options.field;

FOREACH_JSENGINE_FIELD(XX)
#undef XX
}

inline void from_json(const nlohmann::json& j, JSRuntimeOptions& options)
{
#define XX(field, field_type) \
{ \
const auto it = j.find(#field); \
if (it != j.end()) \
{ \
options.field = it->get<field_type>(); \
} \
}

FOREACH_JSENGINE_FIELD(XX)
#undef XX
}

inline std::string schema_name(const JSRuntimeOptions*)
{
return "JSRuntimeOptions";
}

inline void fill_json_schema(nlohmann::json& schema, const JSRuntimeOptions*)
{
schema = nlohmann::json::object();
schema["type"] = "object";

auto properties = nlohmann::json::object();
{
#define XX(field, field_type) \
properties[#field] = \
ds::openapi::components_ref_object(ds::json::schema_name<field_type>());

FOREACH_JSENGINE_FIELD(XX)
#undef XX
}

schema["properties"] = properties;
}

#undef FOREACH_JSENGINE_FIELD

using JSEngine = ServiceValue<JSRuntimeOptions>;

Expand Down
107 changes: 107 additions & 0 deletions samples/apps/basic/basic.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,113 @@ namespace basicapp
{ccf::empty_auth_policy})
.add_query_parameter<std::string>("module_name")
.install();

auto patch_runtime_options =
[this](ccf::endpoints::EndpointContext& ctx) {
const auto& caller_identity =
ctx.template get_caller<ccf::UserCOSESign1AuthnIdentity>();

// Authorization Check
nlohmann::json user_data = nullptr;
auto result =
get_user_data_v1(ctx.tx, caller_identity.user_id, user_data);
if (result == ccf::ApiResult::InternalError)
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
fmt::format(
"Failed to get user data for user {}: {}",
caller_identity.user_id,
ccf::api_result_to_str(result)));
return;
}
const auto is_admin_it = user_data.find("isAdmin");

// Not every user gets to define custom endpoints, only users with
// isAdmin
if (
!user_data.is_object() || is_admin_it == user_data.end() ||
!is_admin_it.value().get<bool>())
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_FORBIDDEN,
ccf::errors::AuthorizationFailed,
"Only admins may access this endpoint.");
return;
}
// End of Authorization Check

// Implement patch semantics.
// - Fetch current options
ccf::JSRuntimeOptions options;
get_js_runtime_options_v1(options, ctx.tx);

// - Convert current options to JSON
auto j_options = nlohmann::json(options);

// - Parse argument as JSON body
const auto arg_body = nlohmann::json::parse(
caller_identity.content.begin(), caller_identity.content.end());

// - Merge, to overwrite current options with anything from body. Note
// that nulls mean deletions, which results in resetting to a default
// value
j_options.merge_patch(arg_body);

// - Parse patched options from JSON
options = j_options.get<ccf::JSRuntimeOptions>();

result = set_js_runtime_options_v1(ctx.tx, options);
if (result != ccf::ApiResult::OK)
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
fmt::format(
"Failed to set options: {}", ccf::api_result_to_str(result)));
return;
}

ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
ctx.rpc_ctx->set_response_header(
http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON);
ctx.rpc_ctx->set_response_body(nlohmann::json(options).dump(2));
};
make_endpoint(
"/custom_endpoints/runtime_options",
HTTP_PATCH,
patch_runtime_options,
{ccf::user_cose_sign1_auth_policy})
.install();

auto get_runtime_options = [this](ccf::endpoints::EndpointContext& ctx) {
ccf::JSRuntimeOptions options;

auto result = get_js_runtime_options_v1(options, ctx.tx);
if (result != ccf::ApiResult::OK)
{
ctx.rpc_ctx->set_error(
HTTP_STATUS_INTERNAL_SERVER_ERROR,
ccf::errors::InternalError,
fmt::format(
"Failed to get runtime options: {}",
ccf::api_result_to_str(result)));
return;
}

ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
ctx.rpc_ctx->set_response_header(
http::headers::CONTENT_TYPE, http::headervalues::contenttype::JSON);
ctx.rpc_ctx->set_response_body(nlohmann::json(options).dump(2));
};
make_endpoint(
"/custom_endpoints/runtime_options",
HTTP_GET,
get_runtime_options,
{ccf::empty_auth_policy})
.set_auto_schema<void, ccf::JSRuntimeOptions>()
.install();
}
};
}
Expand Down
31 changes: 31 additions & 0 deletions src/js/registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,37 @@ namespace ccf::js
}
}

ccf::ApiResult DynamicJSEndpointRegistry::set_js_runtime_options_v1(
kv::Tx& tx, const ccf::JSRuntimeOptions& options)
{
try
{
tx.wo<ccf::JSEngine>(runtime_options_map)->put(options);
return ccf::ApiResult::OK;
}
catch (const std::exception& e)
{
return ccf::ApiResult::InternalError;
}
}

ccf::ApiResult DynamicJSEndpointRegistry::get_js_runtime_options_v1(
ccf::JSRuntimeOptions& options, kv::ReadOnlyTx& tx)
{
try
{
options = tx.ro<ccf::JSEngine>(runtime_options_map)
->get()
.value_or(ccf::JSRuntimeOptions());

return ccf::ApiResult::OK;
}
catch (const std::exception& e)
{
return ccf::ApiResult::InternalError;
}
}

ccf::endpoints::EndpointDefinitionPtr DynamicJSEndpointRegistry::
find_endpoint(kv::Tx& tx, ccf::RpcContext& rpc_ctx)
{
Expand Down
2 changes: 1 addition & 1 deletion src/node/rpc/member_frontend.h
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ namespace ccf
openapi_info.description =
"This API is used to submit and query proposals which affect CCF's "
"public governance tables.";
openapi_info.document_version = "4.1.6";
openapi_info.document_version = "4.1.7";
}

static std::optional<MemberId> get_caller_member_id(
Expand Down
13 changes: 13 additions & 0 deletions tests/infra/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,19 @@ def options(self, *args, **kwargs) -> Response:
kwargs["http_verb"] = "OPTIONS"
return self.call(*args, **kwargs)

def patch(self, *args, **kwargs) -> Response:
"""
Issue ``PATCH`` request.
See :py:meth:`infra.clients.CCFClient.call`.
:return: :py:class:`infra.clients.Response`
"""
if "http_verb" in kwargs:
raise ValueError('"http_verb" should not be specified')

kwargs["http_verb"] = "PATCH"
return self.call(*args, **kwargs)

def wait_for_commit(
self, response: Response, timeout: int = DEFAULT_COMMIT_TIMEOUT_SEC
):
Expand Down
67 changes: 67 additions & 0 deletions tests/programmability.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,73 @@ def test_getters(c, expected_body):
return network


def test_custom_endpoints_js_options(network, args):
primary, _ = network.find_primary()

# Make user0 admin, so it can install custom endpoints
user = network.users[0]
network.consortium.set_user_data(
primary, user.service_id, user_data={"isAdmin": True}
)

def test_options_patch(c, **kwargs):
r = c.call(
"/app/custom_endpoints/runtime_options", {**kwargs}, http_verb="PATCH"
)
assert r.status_code == http.HTTPStatus.OK.value, r.status_code
new_options = r.body.json()

# Check get returns same updated options
get_r = c.get("/app/custom_endpoints/runtime_options")
assert get_r.status_code == http.HTTPStatus.OK.value, get_r.status_code
get_options = get_r.body.json()

assert new_options == get_options, f"{new_options} != {get_options}"
return new_options

with primary.client(None, None, user.local_id) as c:
r = c.get("/app/custom_endpoints/runtime_options")
assert r.status_code == http.HTTPStatus.OK.value, r.status_code

defaults = r.body.json()

same = test_options_patch(c)
assert same == defaults

reduced_heap = test_options_patch(c, max_heap_bytes=42)
assert reduced_heap == {**defaults, "max_heap_bytes": 42}

multiple_changes = test_options_patch(
c, max_execution_time_ms=5000, max_cached_interpreters=15
)
assert multiple_changes == {
**defaults,
"max_heap_bytes": 42,
"max_execution_time_ms": 5000,
"max_cached_interpreters": 15,
}

assign_and_reset = test_options_patch(
c, return_exception_details=True, max_execution_time_ms=None
)
assert assign_and_reset == {
**defaults,
"max_heap_bytes": 42,
"max_cached_interpreters": 15,
"return_exception_details": True,
}

reset_all = test_options_patch(
c,
max_cached_interpreters=None,
return_exception_details=None,
max_heap_bytes=None,
)
assert reset_all == defaults

return network


def test_custom_role_definitions(network, args):
primary, _ = network.find_primary()
member = network.consortium.get_any_active_member()
Expand Down

0 comments on commit a00a91b

Please sign in to comment.