Files
PrimAITE/src/primaite/game/agent/observations/observations.py

310 lines
12 KiB
Python
Raw Normal View History

"""Manages the observation space for the agent."""
from abc import ABC, abstractmethod
2024-01-25 09:27:08 +00:00
from ipaddress import IPv4Address
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
2023-10-10 21:01:09 +01:00
from gymnasium import spaces
2023-10-10 21:01:09 +01:00
from primaite import getLogger
from primaite.game.agent.utils import access_from_nested_dict, NOT_PRESENT_IN_STATE
2023-10-10 21:01:09 +01:00
_LOGGER = getLogger(__name__)
2023-10-09 18:35:30 +01:00
if TYPE_CHECKING:
2023-11-26 23:29:14 +00:00
from primaite.game.game import PrimaiteGame
2023-09-25 16:04:04 +01:00
2023-09-21 10:42:26 +01:00
2023-09-25 16:04:04 +01:00
class AbstractObservation(ABC):
"""Abstract class for an observation space component."""
@abstractmethod
2023-09-25 16:04:04 +01:00
def observe(self, state: Dict) -> Any:
"""
Return an observation based on the current state of the simulation.
:param state: Simulation state dictionary
:type state: Dict
:return: Observation
:rtype: Any
"""
pass
2023-09-21 10:42:26 +01:00
@property
@abstractmethod
def space(self) -> spaces.Space:
"""Gymnasium space object describing the observation space."""
pass
2023-09-21 10:42:26 +01:00
@classmethod
2023-10-08 17:57:45 +01:00
@abstractmethod
2023-11-26 23:29:14 +00:00
def from_config(cls, config: Dict, game: "PrimaiteGame"):
"""Create this observation space component form a serialised format.
The `game` parameter is for a the PrimaiteGame object that spawns this component.
"""
pass
2023-09-25 16:04:04 +01:00
class LinkObservation(AbstractObservation):
"""Observation of a link in the network."""
2023-10-24 11:07:25 +01:00
default_observation: spaces.Space = {"PROTOCOLS": {"ALL": 0}}
2023-09-25 16:04:04 +01:00
"Default observation is what should be returned when the link doesn't exist."
2023-10-09 17:29:50 +01:00
def __init__(self, where: Optional[Tuple[str]] = None) -> None:
"""Initialise link observation.
2023-09-25 16:04:04 +01:00
:param where: Store information about where in the simulation state dictionary to find the relevant information.
Optional. If None, this corresponds that the file does not exist and the observation will be populated with
zeroes.
A typical location for a service looks like this:
`['network','nodes',<node_hostname>,'servics', <service_name>]`
2023-09-25 16:04:04 +01:00
:type where: Optional[List[str]]
"""
super().__init__()
2023-10-09 17:29:50 +01:00
self.where: Optional[Tuple[str]] = where
2023-09-25 16:04:04 +01:00
def observe(self, state: Dict) -> Dict:
"""Generate observation based on the current state of the simulation.
:param state: Simulation state dictionary
:type state: Dict
:return: Observation
:rtype: Dict
"""
2023-10-02 17:21:43 +01:00
if self.where is None:
2023-09-25 16:04:04 +01:00
return self.default_observation
link_state = access_from_nested_dict(state, self.where)
if link_state is NOT_PRESENT_IN_STATE:
return self.default_observation
bandwidth = link_state["bandwidth"]
load = link_state["current_load"]
2024-01-10 18:38:37 +00:00
if load == 0:
utilisation_category = 0
else:
utilisation_fraction = load / bandwidth
# 0 is UNUSED, 1 is 0%-10%. 2 is 10%-20%. 3 is 20%-30%. And so on... 10 is exactly 100%
utilisation_category = int(utilisation_fraction * 9) + 1
2023-09-25 16:04:04 +01:00
# TODO: once the links support separte load per protocol, this needs amendment to reflect that.
2024-01-10 18:38:37 +00:00
return {"PROTOCOLS": {"ALL": min(utilisation_category, 10)}}
2023-09-25 16:04:04 +01:00
@property
def space(self) -> spaces.Space:
"""Gymnasium space object describing the observation space shape.
:return: Gymnasium space
:rtype: spaces.Space
"""
2023-10-24 11:07:25 +01:00
return spaces.Dict({"PROTOCOLS": spaces.Dict({"ALL": spaces.Discrete(11)})})
2023-09-25 16:04:04 +01:00
@classmethod
2023-11-26 23:29:14 +00:00
def from_config(cls, config: Dict, game: "PrimaiteGame") -> "LinkObservation":
"""Create link observation from a config.
:param config: Dictionary containing the configuration for this link observation.
:type config: Dict
2023-11-26 23:29:14 +00:00
:param game: Reference to the PrimaiteGame object that spawned this observation.
:type game: PrimaiteGame
:return: Constructed link observation
:rtype: LinkObservation
"""
2023-11-26 23:29:14 +00:00
return cls(where=["network", "links", game.ref_map_links[config["link_ref"]]])
2023-09-25 16:04:04 +01:00
class AclObservation(AbstractObservation):
"""Observation of an Access Control List (ACL) in the network."""
2023-09-25 16:04:04 +01:00
# TODO: should where be optional, and we can use where=None to pad the observation space?
# definitely the current approach does not support tracking files that aren't specified by name, for example
# if a file is created at runtime, we have currently got no way of telling the observation space to track it.
# this needs adding, but not for the MVP.
def __init__(
2023-10-09 18:35:30 +01:00
self,
node_ip_to_id: Dict[str, int],
ports: List[int],
protocols: List[str],
2023-10-09 18:35:30 +01:00
where: Optional[Tuple[str]] = None,
num_rules: int = 10,
2023-09-25 16:04:04 +01:00
) -> None:
"""Initialise ACL observation.
:param node_ip_to_id: Mapping between IP address and ID.
:type node_ip_to_id: Dict[str, int]
:param ports: List of ports which are part of the game that define the ordering when converting to an ID
:type ports: List[int]
:param protocols: List of protocols which are part of the game, defines ordering when converting to an ID
:type protocols: list[str]
:param where: Where in the simulation state dictionary to find the relevant information for this ACL. A typical
example may look like this:
['network','nodes',<router_hostname>,'acl','acl']
:type where: Optional[Tuple[str]], optional
:param num_rules: , defaults to 10
:type num_rules: int, optional
"""
2023-09-25 16:04:04 +01:00
super().__init__()
2023-10-09 17:29:50 +01:00
self.where: Optional[Tuple[str]] = where
2023-09-25 16:04:04 +01:00
self.num_rules: int = num_rules
2023-10-02 17:21:43 +01:00
self.node_to_id: Dict[str, int] = node_ip_to_id
2023-09-25 16:04:04 +01:00
"List of node IP addresses, order in this list determines how they are converted to an ID"
2023-10-02 17:21:43 +01:00
self.port_to_id: Dict[int, int] = {port: i + 2 for i, port in enumerate(ports)}
2023-09-25 16:04:04 +01:00
"List of ports which are part of the game that define the ordering when converting to an ID"
2023-10-02 17:21:43 +01:00
self.protocol_to_id: Dict[str, int] = {protocol: i + 2 for i, protocol in enumerate(protocols)}
2023-09-25 16:04:04 +01:00
"List of protocols which are part of the game, defines ordering when converting to an ID"
2023-10-02 17:21:43 +01:00
self.default_observation: Dict = {
2023-10-24 11:07:25 +01:00
i
+ 1: {
"position": i,
"permission": 0,
"source_node_id": 0,
"source_port": 0,
"dest_node_id": 0,
"dest_port": 0,
"protocol": 0,
2023-09-25 16:04:04 +01:00
}
2023-10-24 11:07:25 +01:00
for i in range(self.num_rules)
2023-10-02 17:21:43 +01:00
}
2023-09-25 16:04:04 +01:00
def observe(self, state: Dict) -> Dict:
"""Generate observation based on the current state of the simulation.
:param state: Simulation state dictionary
:type state: Dict
:return: Observation
:rtype: Dict
"""
2023-10-02 17:21:43 +01:00
if self.where is None:
2023-09-25 16:04:04 +01:00
return self.default_observation
acl_state: Dict = access_from_nested_dict(state, self.where)
if acl_state is NOT_PRESENT_IN_STATE:
return self.default_observation
2023-10-09 18:35:30 +01:00
# TODO: what if the ACL has more rules than num of max rules for obs space
2023-09-25 16:04:04 +01:00
obs = {}
2024-01-25 09:27:08 +00:00
acl_items = dict(acl_state.items())
i = 1 # don't show rule 0 for compatibility reasons.
while i < self.num_rules + 1:
rule_state = acl_items[i]
2023-09-25 16:04:04 +01:00
if rule_state is None:
2024-01-25 09:27:08 +00:00
obs[i] = {
"position": i - 1,
2023-09-25 16:04:04 +01:00
"permission": 0,
"source_node_id": 0,
"source_port": 0,
"dest_node_id": 0,
"dest_port": 0,
"protocol": 0,
}
else:
2024-01-25 09:27:08 +00:00
src_ip = rule_state["src_ip_address"]
src_node_id = 1 if src_ip is None else self.node_to_id[IPv4Address(src_ip)]
dst_ip = rule_state["dst_ip_address"]
dst_node_ip = 1 if dst_ip is None else self.node_to_id[IPv4Address(dst_ip)]
src_port = rule_state["src_port"]
src_port_id = 1 if src_port is None else self.port_to_id[src_port]
dst_port = rule_state["dst_port"]
dst_port_id = 1 if dst_port is None else self.port_to_id[dst_port]
protocol = rule_state["protocol"]
protocol_id = 1 if protocol is None else self.protocol_to_id[protocol]
obs[i] = {
"position": i - 1,
2023-09-25 16:04:04 +01:00
"permission": rule_state["action"],
2024-01-25 09:27:08 +00:00
"source_node_id": src_node_id,
"source_port": src_port_id,
"dest_node_id": dst_node_ip,
"dest_port": dst_port_id,
"protocol": protocol_id,
2023-09-25 16:04:04 +01:00
}
2024-01-25 09:27:08 +00:00
i += 1
2023-09-25 16:04:04 +01:00
return obs
@property
def space(self) -> spaces.Space:
"""Gymnasium space object describing the observation space shape.
:return: Gymnasium space
:rtype: spaces.Space
"""
2023-09-25 16:04:04 +01:00
return spaces.Dict(
{
2023-10-24 11:07:25 +01:00
i
+ 1: spaces.Dict(
2023-09-25 16:04:04 +01:00
{
2023-10-24 11:07:25 +01:00
"position": spaces.Discrete(self.num_rules),
"permission": spaces.Discrete(3),
# adding two to lengths is to account for reserved values 0 (unused) and 1 (any)
"source_node_id": spaces.Discrete(len(set(self.node_to_id.values())) + 2),
"source_port": spaces.Discrete(len(self.port_to_id) + 2),
"dest_node_id": spaces.Discrete(len(set(self.node_to_id.values())) + 2),
"dest_port": spaces.Discrete(len(self.port_to_id) + 2),
"protocol": spaces.Discrete(len(self.protocol_to_id) + 2),
2023-09-25 16:04:04 +01:00
}
)
2023-10-24 11:07:25 +01:00
for i in range(self.num_rules)
2023-09-25 16:04:04 +01:00
}
)
@classmethod
2023-11-26 23:29:14 +00:00
def from_config(cls, config: Dict, game: "PrimaiteGame") -> "AclObservation":
"""Generate ACL observation from a config.
:param config: Dictionary containing the configuration for this ACL observation.
:type config: Dict
2023-11-26 23:29:14 +00:00
:param game: Reference to the PrimaiteGame object that spawned this observation.
:type game: PrimaiteGame
:return: Observation object
:rtype: AclObservation
"""
2023-10-24 11:07:25 +01:00
max_acl_rules = config["options"]["max_acl_rules"]
node_ip_to_idx = {}
2023-10-10 21:01:09 +01:00
for ip_idx, ip_map_config in enumerate(config["ip_address_order"]):
node_ref = ip_map_config["node_hostname"]
2023-10-10 21:01:09 +01:00
nic_num = ip_map_config["nic_num"]
2023-11-26 23:29:14 +00:00
node_obj = game.simulation.network.nodes[game.ref_map_nodes[node_ref]]
nic_obj = node_obj.network_interface[nic_num]
2023-10-10 21:01:09 +01:00
node_ip_to_idx[nic_obj.ip_address] = ip_idx + 2
router_hostname = config["router_hostname"]
return cls(
node_ip_to_id=node_ip_to_idx,
2023-11-26 23:29:14 +00:00
ports=game.options.ports,
protocols=game.options.protocols,
where=["network", "nodes", router_hostname, "acl", "acl"],
2023-10-24 11:07:25 +01:00
num_rules=max_acl_rules,
2023-10-09 18:35:30 +01:00
)
2023-10-02 17:21:43 +01:00
class NullObservation(AbstractObservation):
"""Null observation, returns a single 0 value for the observation space."""
2023-10-09 18:35:30 +01:00
def __init__(self, where: Optional[List[str]] = None):
"""Initialise null observation."""
2023-10-02 17:21:43 +01:00
self.default_observation: Dict = {}
def observe(self, state: Dict) -> Dict:
"""Generate observation based on the current state of the simulation."""
return 0
2023-10-02 17:21:43 +01:00
@property
def space(self) -> spaces.Space:
"""Gymnasium space object describing the observation space shape."""
return spaces.Discrete(1)
2023-10-02 17:21:43 +01:00
@classmethod
2023-11-26 23:29:14 +00:00
def from_config(cls, config: Dict, game: Optional["PrimaiteGame"] = None) -> "NullObservation":
"""
Create null observation from a config.
The parameters are ignored, they are here to match the signature of the other observation classes.
"""
return cls()
2023-10-09 18:35:30 +01:00
class ICSObservation(NullObservation):
"""ICS observation placeholder, currently not implemented so always returns a single 0."""
2023-10-09 18:35:30 +01:00
pass