diff --git a/opamp/opentelemetry-opamp-client/.changelog/4897.added b/opamp/opentelemetry-opamp-client/.changelog/4897.added new file mode 100644 index 0000000000..61e8904e4c --- /dev/null +++ b/opamp/opentelemetry-opamp-client/.changelog/4897.added @@ -0,0 +1 @@ +`opentelemetry-opamp-client`: allow configuring agent capabilities diff --git a/opamp/opentelemetry-opamp-client/src/opentelemetry/_opamp/client.py b/opamp/opentelemetry-opamp-client/src/opentelemetry/_opamp/client.py index 63ecee57c0..f0ca6e54a0 100644 --- a/opamp/opentelemetry-opamp-client/src/opentelemetry/_opamp/client.py +++ b/opamp/opentelemetry-opamp-client/src/opentelemetry/_opamp/client.py @@ -30,7 +30,7 @@ "User-Agent": "OTel-OpAMP-Python/" + __version__, } -_HANDLED_CAPABILITIES = ( +_DEFAULT_CAPABILITIES = ( opamp_pb2.AgentCapabilities.AgentCapabilities_ReportsStatus | opamp_pb2.AgentCapabilities.AgentCapabilities_ReportsHeartbeat | opamp_pb2.AgentCapabilities.AgentCapabilities_AcceptsRemoteConfig @@ -52,12 +52,21 @@ def __init__( timeout_millis: int = _DEFAULT_OPAMP_TIMEOUT_MS, agent_identifying_attributes: Mapping[str, AnyValue], agent_non_identifying_attributes: Mapping[str, AnyValue] | None = None, + capabilities: int = _DEFAULT_CAPABILITIES, transport: HttpTransport | None = None, # this matches requests but can be mapped to other http libraries APIs tls_certificate: str | bool = True, tls_client_certificate: str | None = None, tls_client_key: str | None = None, ): + # ReportsStatus is required by the OpAMP specification: + # https://opentelemetry.io/docs/specs/opamp/#agenttoservercapabilities + if not capabilities & ( + opamp_pb2.AgentCapabilities.AgentCapabilities_ReportsStatus + ): + raise ValueError("OpAMP capabilities must include ReportsStatus") + + self._capabilities = capabilities self._timeout_millis = timeout_millis self._transport = ( RequestsTransport() if transport is None else transport @@ -83,7 +92,7 @@ def build_agent_disconnect_message(self) -> bytes: message = messages.build_agent_disconnect_message( instance_uid=self._instance_uid, sequence_num=self._sequence_num, - capabilities=_HANDLED_CAPABILITIES, + capabilities=self._capabilities, ) data = messages.encode_message(message) return data @@ -92,7 +101,7 @@ def build_heartbeat_message(self) -> bytes: message = messages.build_heartbeat_message( instance_uid=self._instance_uid, sequence_num=self._sequence_num, - capabilities=_HANDLED_CAPABILITIES, + capabilities=self._capabilities, ) data = messages.encode_message(message) return data @@ -143,7 +152,7 @@ def build_remote_config_status_response_message( message = messages.build_remote_config_status_response_message( instance_uid=self._instance_uid, sequence_num=self._sequence_num, - capabilities=_HANDLED_CAPABILITIES, + capabilities=self._capabilities, remote_config_status=remote_config_status, ) data = messages.encode_message(message) @@ -156,7 +165,7 @@ def build_full_state_message(self) -> bytes: remote_config_status=self._remote_config_status, sequence_num=self._sequence_num, effective_config=self._effective_config, - capabilities=_HANDLED_CAPABILITIES, + capabilities=self._capabilities, ) data = messages.encode_message(message) return data diff --git a/opamp/opentelemetry-opamp-client/tests/opamp/test_client.py b/opamp/opentelemetry-opamp-client/tests/opamp/test_client.py index 7492d83039..35e2d109cc 100644 --- a/opamp/opentelemetry-opamp-client/tests/opamp/test_client.py +++ b/opamp/opentelemetry-opamp-client/tests/opamp/test_client.py @@ -10,7 +10,7 @@ import pytest from opentelemetry._opamp import messages -from opentelemetry._opamp.client import _HANDLED_CAPABILITIES, OpAMPClient +from opentelemetry._opamp.client import _DEFAULT_CAPABILITIES, OpAMPClient from opentelemetry._opamp.exceptions import ( OpAMPRemoteConfigDecodeException, OpAMPRemoteConfigParseException, @@ -25,6 +25,12 @@ from opentelemetry._opamp.transport.requests import RequestsTransport from opentelemetry._opamp.version import __version__ +_EFFECTIVE_CONFIG_CAPABILITIES = ( + opamp_pb2.AgentCapabilities_ReportsStatus + | opamp_pb2.AgentCapabilities_ReportsEffectiveConfig + | opamp_pb2.AgentCapabilities_ReportsHeartbeat +) + @pytest.fixture(name="client") def client_fixture(): @@ -48,6 +54,7 @@ def test_can_instantiate_opamp_client_with_defaults(): assert client._tls_client_key is None assert client._timeout_millis == 1_000 assert client._sequence_num == 0 + assert client._capabilities == _DEFAULT_CAPABILITIES assert isinstance(client._instance_uid, bytes) assert isinstance(client._agent_description, opamp_pb2.AgentDescription) assert client._agent_description.identifying_attributes == [ @@ -56,6 +63,25 @@ def test_can_instantiate_opamp_client_with_defaults(): assert client._agent_description.non_identifying_attributes == [] +def test_default_capabilities_remain_unchanged(): + assert _DEFAULT_CAPABILITIES == ( + opamp_pb2.AgentCapabilities_ReportsStatus + | opamp_pb2.AgentCapabilities_ReportsHeartbeat + | opamp_pb2.AgentCapabilities_AcceptsRemoteConfig + | opamp_pb2.AgentCapabilities_ReportsRemoteConfig + | opamp_pb2.AgentCapabilities_ReportsEffectiveConfig + ) + + +def test_rejects_capabilities_without_reports_status(): + with pytest.raises(ValueError, match="ReportsStatus"): + OpAMPClient( + endpoint="url", + agent_identifying_attributes={"foo": "bar"}, + capabilities=opamp_pb2.AgentCapabilities_ReportsHeartbeat, + ) + + def test_can_instantiate_opamp_client_all_params(): transport = RequestsTransport() client = OpAMPClient( @@ -145,7 +171,7 @@ def test_can_serialize_agent_identifying_attributes(): PB2KeyValue(key="float", value=PB2AnyValue(double_value=2.0)), ] assert message.agent_description.non_identifying_attributes == [] - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES def test_build_agent_disconnect_message(client): @@ -158,7 +184,7 @@ def test_build_agent_disconnect_message(client): assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 assert message.agent_disconnect == opamp_pb2.AgentDisconnect() - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES def test_build_heartbeat_message(client): @@ -170,7 +196,58 @@ def test_build_heartbeat_message(client): assert message assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES + + +@pytest.mark.parametrize( + "builder_name", + [ + "build_full_state_message", + "build_heartbeat_message", + "build_agent_disconnect_message", + ], +) +def test_message_uses_custom_capabilities(builder_name): + client = OpAMPClient( + endpoint="url", + agent_identifying_attributes={"foo": "bar"}, + capabilities=_EFFECTIVE_CONFIG_CAPABILITIES, + ) + + data = getattr(client, builder_name)() + message = opamp_pb2.AgentToServer() + message.ParseFromString(data) + + assert message.capabilities == _EFFECTIVE_CONFIG_CAPABILITIES + assert not ( + message.capabilities & opamp_pb2.AgentCapabilities_AcceptsRemoteConfig + ) + assert not ( + message.capabilities & opamp_pb2.AgentCapabilities_ReportsRemoteConfig + ) + assert not ( + message.capabilities & opamp_pb2.AgentCapabilities_ReportsHealth + ) + + +def test_clients_use_capabilities_independently(): + default_client = OpAMPClient( + endpoint="url", + agent_identifying_attributes={"foo": "bar"}, + ) + custom_client = OpAMPClient( + endpoint="url", + agent_identifying_attributes={"foo": "bar"}, + capabilities=_EFFECTIVE_CONFIG_CAPABILITIES, + ) + + default_message = opamp_pb2.AgentToServer() + default_message.ParseFromString(default_client.build_full_state_message()) + custom_message = opamp_pb2.AgentToServer() + custom_message.ParseFromString(custom_client.build_full_state_message()) + + assert default_message.capabilities == _DEFAULT_CAPABILITIES + assert custom_message.capabilities == _EFFECTIVE_CONFIG_CAPABILITIES def test_update_remote_config_status_without_previous_config(client): @@ -253,7 +330,7 @@ def test_build_remote_config_status_response_message_no_error_message(client): assert message assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES assert message.remote_config_status assert message.remote_config_status.last_remote_config_hash == b"12345678" assert ( @@ -281,7 +358,7 @@ def test_build_remote_config_status_response_message_with_error_message( assert message assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES assert message.remote_config_status assert message.remote_config_status.last_remote_config_hash == b"12345678" assert ( @@ -291,6 +368,26 @@ def test_build_remote_config_status_response_message_with_error_message( assert message.remote_config_status.error_message == "an error message" +def test_remote_config_status_response_uses_custom_capabilities(): + client = OpAMPClient( + endpoint="url", + agent_identifying_attributes={"foo": "bar"}, + capabilities=_EFFECTIVE_CONFIG_CAPABILITIES, + ) + remote_config_status = messages.build_remote_config_status_message( + last_remote_config_hash=b"12345678", + status=opamp_pb2.RemoteConfigStatuses_APPLIED, + ) + + data = client.build_remote_config_status_response_message( + remote_config_status + ) + message = opamp_pb2.AgentToServer() + message.ParseFromString(data) + + assert message.capabilities == _EFFECTIVE_CONFIG_CAPABILITIES + + def test_update_effective_config_json_content_type(client): config = {"filename": {"a": "config"}} client.update_effective_config(config, content_type="application/json") @@ -386,7 +483,7 @@ def test_build_full_state_message(client): assert message assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES assert message.agent_description.identifying_attributes == [ PB2KeyValue(key="foo", value=PB2AnyValue(string_value="bar")), ] @@ -412,7 +509,7 @@ def test_build_full_state_message_no_config(client): assert message assert message.instance_uid == client._instance_uid assert message.sequence_num == 0 - assert message.capabilities == _HANDLED_CAPABILITIES + assert message.capabilities == _DEFAULT_CAPABILITIES assert message.agent_description.identifying_attributes == [ PB2KeyValue(key="foo", value=PB2AnyValue(string_value="bar")), ]