diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd2291bcce..2668b44b1d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Nodes now have a free-form `node_data` field, to match users and members. This can be set when the node is launched, or modified by governance. It is intended to store correlation IDs describing the node's deployment, such as a VM name or Pod identifier. + ## [2.0.0-rc4] ### Added diff --git a/doc/host_config_schema/cchost_config.json b/doc/host_config_schema/cchost_config.json index 5cfcee7ac59..46331814371 100644 --- a/doc/host_config_schema/cchost_config.json +++ b/doc/host_config_schema/cchost_config.json @@ -247,6 +247,10 @@ }, "description": "This section includes configuration for the node x509 identity certificate" }, + "node_data_json_file": { + "type": "string", + "description": "Path to file (JSON) containing initial node data. It is intended to store correlation IDs describing the node's deployment, such as a VM name or Pod identifier." + }, "ledger": { "type": "object", "properties": { diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index f8fa80fa555..a4b4d421b0a 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -240,6 +240,12 @@ }, "GetNode__NodeInfo": { "properties": { + "last_written": { + "$ref": "#/components/schemas/uint64" + }, + "node_data": { + "$ref": "#/components/schemas/json" + }, "node_id": { "$ref": "#/components/schemas/NodeId" }, @@ -257,7 +263,9 @@ "node_id", "status", "primary", - "rpc_interfaces" + "rpc_interfaces", + "node_data", + "last_written" ], "type": "object" }, @@ -738,7 +746,7 @@ "info": { "description": "This API provides public, uncredentialed access to service and node state.", "title": "CCF Public Node API", - "version": "2.14.1" + "version": "2.15.0" }, "openapi": "3.0.0", "paths": { diff --git a/include/ccf/service/node_info.h b/include/ccf/service/node_info.h index 898698f828e..a6aeebbdbbd 100644 --- a/include/ccf/service/node_info.h +++ b/include/ccf/service/node_info.h @@ -56,6 +56,10 @@ namespace ccf /// Public key std::optional public_key = std::nullopt; + /// Free-form user data, can be used to store operator correlation + /// IDs/labels for the node for example + nlohmann::json node_data = nullptr; + /** * Fields below are deprecated */ @@ -75,7 +79,8 @@ namespace ccf ledger_secret_seqno, code_digest, certificate_signing_request, - public_key); + public_key, + node_data); } FMT_BEGIN_NAMESPACE diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 5030072b650..30f482d7139 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -907,7 +907,25 @@ const actions = new Map([ } ), ], - + [ + "set_node_data", + new Action( + function (args) { + checkEntityId(args.node_id, "node_id"); + }, + function (args) { + let node_id = ccf.strToBuf(args.node_id); + let nodes_info = ccf.kv["public:ccf.gov.nodes.info"]; + let node_info = nodes_info.get(node_id); + if (node_info === undefined) { + throw new Error(`Node ${node_id} does not exist`); + } + let ni = ccf.bufToJsonCompatible(node_info); + ni.node_data = args.node_data; + nodes_info.set(node_id, ccf.jsonCompatibleToBuf(ni)); + } + ), + ], [ "transition_node_to_trusted", new Action( diff --git a/src/common/configuration.h b/src/common/configuration.h index a4c5b99965b..be7607a62ff 100644 --- a/src/common/configuration.h +++ b/src/common/configuration.h @@ -133,6 +133,8 @@ struct StartupConfig : CCFConfig // Only if starting or recovering size_t initial_service_certificate_validity_days = 1; + nlohmann::json node_data = nullptr; + struct Start { std::vector members; @@ -177,6 +179,7 @@ DECLARE_JSON_REQUIRED_FIELDS( startup_host_time, snapshot_tx_interval, initial_service_certificate_validity_days, + node_data, start, join, recover); diff --git a/src/host/configuration.h b/src/host/configuration.h index 8ad4659f231..3ef57cf55bf 100644 --- a/src/host/configuration.h +++ b/src/host/configuration.h @@ -60,6 +60,7 @@ namespace host ds::TimeString slow_io_logging_threshold = {"10ms"}; std::optional node_client_interface = std::nullopt; ds::TimeString client_connection_timeout = {"2000ms"}; + std::optional node_data_json_file = std::nullopt; struct OutputFiles { @@ -210,6 +211,7 @@ namespace host slow_io_logging_threshold, node_client_interface, client_connection_timeout, + node_data_json_file, output_files, ledger, snapshots, diff --git a/src/host/main.cpp b/src/host/main.cpp index 9868417b3a7..960f5ac456b 100644 --- a/src/host/main.cpp +++ b/src/host/main.cpp @@ -354,6 +354,12 @@ int main(int argc, char** argv) startup_config.worker_threads = config.worker_threads; startup_config.node_certificate = config.node_certificate; + if (config.node_data_json_file.has_value()) + { + startup_config.node_data = + files::slurp_json(config.node_data_json_file.value()); + } + auto startup_host_time = std::chrono::system_clock::now(); LOG_INFO_FMT("Startup host time: {}", startup_host_time); diff --git a/src/node/node_state.h b/src/node/node_state.h index 3d81c14d4b6..108250aabe0 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -671,6 +671,7 @@ namespace ccf join_params.startup_seqno = startup_seqno; join_params.certificate_signing_request = node_sign_kp->create_csr( config.node_certificate.subject_name, subject_alt_names); + join_params.node_data = config.node_data; LOG_DEBUG_FMT( "Sending join request to {}", config.join.target_rpc_address); @@ -1729,6 +1730,7 @@ namespace ccf create_params.public_encryption_key = node_encrypt_kp->public_key_pem(); create_params.code_digest = node_code_id; create_params.node_info_network = config.network; + create_params.node_data = config.node_data; const auto body = serdes::pack(create_params, serdes::Pack::Text); diff --git a/src/node/rpc/call_types.h b/src/node/rpc/call_types.h index 5460a87afb6..e28abf4154c 100644 --- a/src/node/rpc/call_types.h +++ b/src/node/rpc/call_types.h @@ -69,6 +69,8 @@ namespace ccf NodeStatus status; bool primary; ccf::NodeInfoNetwork::RpcInterfaces rpc_interfaces; + nlohmann::json node_data; + ccf::SeqNo last_written; }; using Out = NodeInfo; diff --git a/src/node/rpc/node_call_types.h b/src/node/rpc/node_call_types.h index 3d3459f2900..c56ae6000de 100644 --- a/src/node/rpc/node_call_types.h +++ b/src/node/rpc/node_call_types.h @@ -60,6 +60,7 @@ namespace ccf crypto::Pem public_encryption_key; CodeDigest code_digest; NodeInfoNetwork node_info_network; + nlohmann::json node_data; // Only set on genesis transaction, but not on recovery std::optional genesis_info = std::nullopt; @@ -76,6 +77,7 @@ namespace ccf ConsensusType consensus_type = ConsensusType::CFT; std::optional startup_seqno = std::nullopt; std::optional certificate_signing_request = std::nullopt; + nlohmann::json node_data = nullptr; }; struct Out diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index d7ce6987bde..64ebd127da6 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -281,7 +281,8 @@ namespace ccf ledger_secret_seqno, ds::to_hex(code_digest.data), in.certificate_signing_request, - client_public_key_pem}; + client_public_key_pem, + in.node_data}; // Because the certificate signature scheme is non-deterministic, only // self-signed node certificate is recorded in the node info table @@ -347,7 +348,7 @@ namespace ccf openapi_info.description = "This API provides public, uncredentialed access to service and node " "state."; - openapi_info.document_version = "2.14.1"; + openapi_info.document_version = "2.15.0"; } void init_handlers() override @@ -842,7 +843,7 @@ namespace ccf GetNodes::Out out; auto nodes = args.tx.ro(this->network.nodes); - nodes->foreach([this, host, port, status, &out]( + nodes->foreach([this, host, port, status, &out, nodes]( const NodeId& nid, const NodeInfo& ni) { if (status.has_value() && status.value() != ni.status) { @@ -876,7 +877,13 @@ namespace ccf is_primary = consensus->primary() == nid; } - out.nodes.push_back({nid, ni.status, is_primary, ni.rpc_interfaces}); + out.nodes.push_back( + {nid, + ni.status, + is_primary, + ni.rpc_interfaces, + ni.node_data, + nodes->get_version_of_previous_write(nid).value_or(0)}); return true; }); @@ -932,8 +939,13 @@ namespace ccf } } auto& ni = info.value(); - return make_success( - GetNode::Out{node_id, ni.status, is_primary, ni.rpc_interfaces}); + return make_success(GetNode::Out{ + node_id, + ni.status, + is_primary, + ni.rpc_interfaces, + ni.node_data, + nodes->get_version_of_previous_write(node_id).value_or(0)}); }; make_read_only_endpoint( "/network/nodes/{node_id}", diff --git a/src/node/rpc/serialization.h b/src/node/rpc/serialization.h index f9460e17d09..93aae14c56f 100644 --- a/src/node/rpc/serialization.h +++ b/src/node/rpc/serialization.h @@ -28,7 +28,7 @@ namespace ccf consensus_type, startup_seqno) DECLARE_JSON_OPTIONAL_FIELDS( - JoinNetworkNodeToNode::In, certificate_signing_request) + JoinNetworkNodeToNode::In, certificate_signing_request, node_data) DECLARE_JSON_ENUM( ccf::IdentityType, @@ -72,7 +72,8 @@ namespace ccf public_encryption_key, code_digest, node_info_network) - DECLARE_JSON_OPTIONAL_FIELDS(CreateNetworkNodeToNode::In, genesis_info) + DECLARE_JSON_OPTIONAL_FIELDS( + CreateNetworkNodeToNode::In, genesis_info, node_data) DECLARE_JSON_TYPE(GetCommit::Out) DECLARE_JSON_REQUIRED_FIELDS(GetCommit::Out, transaction_id) @@ -90,7 +91,13 @@ namespace ccf DECLARE_JSON_TYPE(GetNode::NodeInfo) DECLARE_JSON_REQUIRED_FIELDS( - GetNode::NodeInfo, node_id, status, primary, rpc_interfaces) + GetNode::NodeInfo, + node_id, + status, + primary, + rpc_interfaces, + node_data, + last_written) DECLARE_JSON_TYPE(GetNodes::Out) DECLARE_JSON_REQUIRED_FIELDS(GetNodes::Out, nodes) diff --git a/tests/config.jinja b/tests/config.jinja index a503e68f1ca..f8631d643a2 100644 --- a/tests/config.jinja +++ b/tests/config.jinja @@ -14,6 +14,7 @@ "curve_id": "{{ curve_id }}", "initial_validity_days": {{ initial_node_cert_validity_days }} }, + "node_data_json_file": {{ node_data_json_file|tojson }}, "command": { "type": "{{ start_type }}", "start": diff --git a/tests/governance.py b/tests/governance.py index 38d79842519..c77241765ca 100644 --- a/tests/governance.py +++ b/tests/governance.py @@ -18,6 +18,7 @@ import governance_js from infra.runner import ConcurrentRunner import governance_history +import tempfile from loguru import logger as LOG @@ -190,6 +191,71 @@ def test_no_quote(network, args): return network +@reqs.description("Test node data set at node construction, and updated by governance") +def test_node_data(network, args): + with tempfile.NamedTemporaryFile(mode="w+") as ntf: + primary, _ = network.find_primary() + with primary.client() as c: + + def get_nodes(): + r = c.get("/node/network/nodes") + assert r.status_code == 200, (r.status_code, r.body.text()) + return { + node_info["node_id"]: node_info + for node_info in r.body.json()["nodes"] + } + + new_node_data = {"my_id": "0xdeadbeef", "location": "The Moon"} + json.dump(new_node_data, ntf) + ntf.flush() + untrusted_node = network.create_node( + infra.interfaces.HostSpec( + rpc_interfaces={ + infra.interfaces.PRIMARY_RPC_INTERFACE: infra.interfaces.RPCInterface( + endorsement=infra.interfaces.Endorsement(authority="Node") + ) + } + ), + node_data_json_file=ntf.name, + ) + + # NB: This new node joins but is never trusted + network.join_node(untrusted_node, args.package, args) + + nodes = get_nodes() + assert untrusted_node.node_id in nodes, nodes + new_node_info = nodes[untrusted_node.node_id] + assert new_node_info["node_data"] == new_node_data, new_node_info + + # Set modified node data + new_node_data["previous_locations"] = [new_node_data["location"]] + new_node_data["location"] = "Secret Base" + + network.consortium.set_node_data( + primary, untrusted_node.node_id, new_node_data + ) + + nodes = get_nodes() + assert untrusted_node.node_id in nodes, nodes + new_node_info = nodes[untrusted_node.node_id] + assert new_node_info["node_data"] == new_node_data, new_node_info + + # Set modified node data on trusted primary + primary_node_data = "Some plain JSON string" + network.consortium.set_node_data( + primary, primary.node_id, primary_node_data + ) + + nodes = get_nodes() + assert primary.node_id in nodes, nodes + primary_node_info = nodes[primary.node_id] + assert ( + primary_node_info["node_data"] == primary_node_data + ), primary_node_info + + return network + + @reqs.description("Check member data") def test_member_data(network, args): assert args.initial_operator_count > 0 @@ -462,6 +528,7 @@ def gov(args): test_user(network, args) test_jinja_templates(network, args) test_no_quote(network, args) + test_node_data(network, args) test_ack_state_digest_update(network, args) test_invalid_client_signature(network, args) test_each_node_cert_renewal(network, args) @@ -484,7 +551,6 @@ def js_gov(args): governance_js.test_vote_failure_reporting(network, args) governance_js.test_operator_proposals_and_votes(network, args) governance_js.test_apply(network, args) - governance_js.test_actions(network, args) governance_js.test_set_constitution(network, args) diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index fb4a4565c65..ca3bfef50f3 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -648,6 +648,15 @@ def retire_code(self, remote_node, code_id): proposal = self.get_any_active_member().propose(remote_node, proposal_body) return self.vote_using_majority(remote_node, proposal, careful_vote) + def set_node_data(self, remote_node, node_service_id, node_data): + proposal, careful_vote = self.make_proposal( + "set_node_data", + node_id=node_service_id, + node_data=node_data, + ) + proposal = self.get_any_active_member().propose(remote_node, proposal) + return self.vote_using_majority(remote_node, proposal, careful_vote) + def set_node_certificate_validity( self, remote_node, node_to_renew, valid_from, validity_period_days ): diff --git a/tests/infra/network.py b/tests/infra/network.py index 484c09acc2b..f670624a6e9 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -190,9 +190,7 @@ def _get_next_local_node_id(self): self.next_node_id += 1 return next_node_id - def create_node( - self, host, binary_dir=None, library_dir=None, node_port=0, version=None - ): + def create_node(self, host, binary_dir=None, library_dir=None, **kwargs): node_id = self._get_next_local_node_id() debug = ( (str(node_id) in self.dbg_nodes) if self.dbg_nodes is not None else False @@ -207,8 +205,7 @@ def create_node( library_dir or self.library_dir, debug, perf, - node_port=node_port, - version=version, + **kwargs, ) self.nodes.append(node) return node diff --git a/tests/infra/node.py b/tests/infra/node.py index 74c296f498e..39bedeb88d9 100644 --- a/tests/infra/node.py +++ b/tests/infra/node.py @@ -103,6 +103,7 @@ def __init__( perf=False, node_port=0, version=None, + node_data_json_file=None, ): self.local_node_id = local_node_id self.binary_dir = binary_dir @@ -127,6 +128,7 @@ def __init__( self.consensus = None self.certificate_valid_from = None self.certificate_validity_days = None + self.initial_node_data_json_file = node_data_json_file if os.getenv("CONTAINER_NODES"): self.remote_shim = infra.remote_shim.DockerShim @@ -273,6 +275,7 @@ def _start( members_info=members_info, version=self.version, major_version=self.major_version, + node_data_json_file=self.initial_node_data_json_file, **kwargs, ) self.remote.setup() diff --git a/tests/infra/remote.py b/tests/infra/remote.py index 4f4d4bcd928..1264998d657 100644 --- a/tests/infra/remote.py +++ b/tests/infra/remote.py @@ -588,6 +588,7 @@ def __init__( sig_ms_interval=None, jwt_key_refresh_interval_s=None, election_timeout_ms=None, + node_data_json_file=None, **kwargs, ): """ @@ -677,6 +678,7 @@ def __init__( signature_interval_duration=f"{sig_ms_interval}ms", jwt_key_refresh_interval=f"{jwt_key_refresh_interval_s}s", election_timeout=f"{election_timeout_ms}ms", + node_data_json_file=node_data_json_file, **kwargs, )