Skip to content

Commit 5dd37f3

Browse files
committed
feat(contact_player): move from Protocol to ABC to reduce code duplication
1 parent 13ef62d commit 5dd37f3

1 file changed

Lines changed: 129 additions & 157 deletions

File tree

tools/contact_player/contact_player.py

Lines changed: 129 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import signal
44
import socket
55
import time
6+
from abc import ABC, abstractmethod
67
from dataclasses import dataclass, field
78
from itertools import combinations
89
from pathlib import Path
9-
from typing import Protocol
10+
from typing import Literal
1011

1112
from tools.contact_player.ccp import (
1213
Contact,
@@ -21,118 +22,135 @@
2122
)
2223

2324

24-
class ContactHandler(Protocol):
25-
"""Common interface for contact-plan side effects.
25+
@dataclass
26+
class ContactHandler(ABC):
27+
"""Base class for contact-plan side effects.
2628
2729
A handler owns one ContactPlan and reacts to simulation-time transitions.
28-
Different handlers may apply physical effects, notify containers, or expose
30+
Different handlers may apply physical effects, notify containers or expose
2931
topology state for external outputs.
32+
Subclasses must implement setup, cleanup, and command generation for contact transitions.
3033
"""
3134

3235
plan: ContactPlan
3336
nodes: dict[str, Node]
3437

3538
@property
36-
def unique_contact_links(self) -> set[tuple[Node, Node, str]]: ...
39+
def unique_contact_links(self) -> set[tuple[Node, Node, str]]:
40+
"""Unique directed contact links as source node, destination node, and network."""
41+
return {(c.src, c.dst, c.network) for c in self.plan.contacts}
3742

38-
"""Unique directed contact links as source node, destination node, and network."""
43+
@property
44+
def static_links(self) -> set[frozenset[Node]]:
45+
"""Undirected physical links not controlled by dynamic contacts, but defined by the compose file."""
46+
all_physical_links = {
47+
frozenset((a, b))
48+
for a, b in combinations(self.nodes.values(), 2)
49+
if a.interfaces.keys() & b.interfaces.keys()
50+
}
51+
dynamic_links = {
52+
frozenset((c.src, c.dst)) for c in self.plan.contacts if c.end != -1
53+
}
54+
return all_physical_links - dynamic_links
3955

4056
@property
41-
def static_links(self) -> set[frozenset[Node]]: ...
57+
def active_dynamic_links(self) -> set[frozenset[Node]]:
58+
"""Currently active dynamic links in the topology."""
59+
return {
60+
frozenset((c.src, c.dst))
61+
for c, state in self.plan.contacts.items()
62+
if state == ContactState.ACTIVE and c.end != -1
63+
}
4264

43-
"""Undirected physical links not controlled by dynamic contacts, but defined by the compose file."""
65+
@abstractmethod
66+
def setup(self) -> None:
67+
pass
4468

45-
@property
46-
def active_dynamic_links(self) -> set[frozenset[Node]]: ...
69+
def process_time(self, time: int) -> None:
70+
"""Process contact transitions due at the given simulation time."""
71+
transitions = (
72+
(self.plan.contacts_to_activate(time), ContactState.ACTIVE),
73+
(self.plan.contacts_to_deactivate(time), ContactState.INACTIVE),
74+
)
4775

48-
"""Currently active dynamic links in the topology."""
76+
for contacts, target_state in transitions:
77+
commands = [
78+
self._transition_command(time, contact, target_state)
79+
for contact in contacts
80+
]
81+
run_in_containers_parallel(commands)
4982

50-
def setup(self) -> None: ...
83+
for contact in contacts:
84+
self.plan.contacts[contact] = target_state
5185

52-
def process_time(self, time: int) -> None: ...
86+
@abstractmethod
87+
def _transition_command(
88+
self,
89+
time: int,
90+
contact: Contact,
91+
target_state: ContactState,
92+
) -> ContainerCommand:
93+
"""Build and log the command for one contact transition."""
5394

54-
def next_event(self, after: int) -> int | None: ...
95+
def next_event(self, after: int) -> int | None:
96+
return self.plan.next_contact_event(after)
5597

56-
def cleanup(self) -> None: ...
98+
@abstractmethod
99+
def cleanup(self) -> None:
100+
pass
57101

58102

59103
@dataclass
60-
class CommandContactHandler:
104+
class CommandContactHandler(ContactHandler):
61105
"""Notifies containers about planned contact changes.
62106
63107
For each directed contact, the configured command is executed inside the
64108
source container. Contact/link metadata is passed via environment variables.
65109
"""
66110

67-
plan: ContactPlan
68-
nodes: dict[str, Node]
69111
command: str
70112

71-
@property
72-
def unique_contact_links(self) -> set[tuple[Node, Node, str]]:
73-
"""Unique directed contact links as source node, destination node, and network."""
74-
return {(c.src, c.dst, c.network) for c in self.plan.contacts}
75-
76-
@property
77-
def static_links(self) -> set[frozenset[Node]]:
78-
raise NotImplementedError
79-
80-
@property
81-
def active_dynamic_links(self) -> set[frozenset[Node]]:
82-
raise NotImplementedError
83-
84113
def setup(self) -> None:
85114
"""Notify containers about all known planned links before playback starts."""
86-
commands: list[ContainerCommand] = []
87-
for src, dst, net in self.unique_contact_links:
88-
commands.append((src.name, self.command, self._env("setup", src, dst, net)))
115+
commands = [
116+
(src.name, self.command, self._env("setup", src, dst, net))
117+
for src, dst, net in self.unique_contact_links
118+
]
89119
run_in_containers_parallel(commands)
90120

121+
def _transition_command(
122+
self,
123+
time: int,
124+
contact: Contact,
125+
target_state: ContactState,
126+
) -> ContainerCommand:
127+
event = "activate" if target_state == ContactState.ACTIVE else "deactivate"
128+
129+
print(f"[ {time} ] Signal {event.upper()} to {contact}")
130+
131+
return (
132+
contact.src.name,
133+
self.command,
134+
self._env(
135+
event,
136+
contact.src,
137+
contact.dst,
138+
contact.network,
139+
contact,
140+
),
141+
)
142+
91143
def cleanup(self) -> None:
92144
"""Notify containers about all known planned links before shutdown."""
93-
commands: list[ContainerCommand] = []
94-
for src, dst, net in self.unique_contact_links:
95-
commands.append(
96-
(src.name, self.command, self._env("cleanup", src, dst, net))
97-
)
145+
commands = [
146+
(src.name, self.command, self._env("cleanup", src, dst, net))
147+
for src, dst, net in self.unique_contact_links
148+
]
98149
run_in_containers_parallel(commands, raise_on_error=False)
99150

100-
def process_time(self, time: int) -> None:
101-
"""Emit activate/deactivate notifications due at the given simulation time."""
102-
# Activations
103-
commands: list[ContainerCommand] = []
104-
for c in self.plan.contacts_to_activate(time):
105-
print(f"[ {time} ] Signal ACTIVATE to {c}")
106-
commands.append(
107-
(
108-
c.src.name,
109-
self.command,
110-
self._env("activate", c.src, c.dst, c.network, c),
111-
)
112-
)
113-
self.plan.contacts[c] = ContactState.ACTIVE
114-
run_in_containers_parallel(commands)
115-
116-
# Deactivations
117-
commands: list[ContainerCommand] = []
118-
for c in self.plan.contacts_to_deactivate(time):
119-
print(f"[ {time} ] Signal DEACTIVATE to {c}")
120-
commands.append(
121-
(
122-
c.src.name,
123-
self.command,
124-
self._env("deactivate", c.src, c.dst, c.network, c),
125-
)
126-
)
127-
self.plan.contacts[c] = ContactState.INACTIVE
128-
run_in_containers_parallel(commands)
129-
130-
def next_event(self, after: int) -> int | None:
131-
return self.plan.next_contact_event(after)
132-
133151
def _env(
134152
self,
135-
event: str,
153+
event: Literal["setup", "cleanup", "activate", "deactivate"],
136154
src: Node,
137155
dst: Node,
138156
network: str,
@@ -156,54 +174,22 @@ def _env(
156174
"NSE2_DELAY": str(contact.props.delay),
157175
"NSE2_JITTER": str(contact.props.jitter),
158176
}
177+
159178
return env
160179

161180

162181
@dataclass
163-
class TcNetemContactHandler:
182+
class TcNetemContactHandler(ContactHandler):
164183
"""Applies actual contact changes to Docker interfaces using tc/netem.
165184
166-
This handler defines the physical emulated topology. Interfaces used by
167-
contacts are initially blocked and later changed according to contact state.
185+
Interfaces used by contacts are initially blocked and later changed
186+
according to contact state.
168187
"""
169188

170-
plan: ContactPlan
171-
nodes: dict[str, Node]
172-
173-
# derived topology state
174-
@property
175-
def unique_contact_links(self) -> set[tuple[Node, Node, str]]:
176-
"""Unique directed contact links as source node, destination node, and network."""
177-
return {(c.src, c.dst, c.network) for c in self.plan.contacts}
178-
179-
# the following two link properties are used for generating the netmap and
180-
# answer the socket. This implementation and the API should see some reworking
181-
# to allow more things, like drawing deactivated connections etc
182-
@property
183-
def static_links(self) -> set[frozenset[Node]]:
184-
"""Undirected physical links not controlled by dynamic contacts, but defined by the compose file."""
185-
all_physical_links = {
186-
frozenset((a, b))
187-
for a, b in combinations(self.nodes.values(), 2)
188-
if a.interfaces.keys() & b.interfaces.keys()
189-
}
190-
dynamic_links = {
191-
frozenset((c.src, c.dst)) for c in self.plan.contacts if c.end != -1
192-
}
193-
return all_physical_links - dynamic_links
194-
195-
@property
196-
def active_dynamic_links(self) -> set[frozenset[Node]]:
197-
"""Currently active dynamic links in the topology."""
198-
return {
199-
frozenset((c.src, c.dst))
200-
for c, s in self.plan.contacts.items()
201-
if s == ContactState.ACTIVE and c.end != -1
202-
}
203-
204189
def setup(self) -> None:
205190
"""Initialize all managed interfaces as blocking."""
206191
commands: list[ContainerCommand] = []
192+
207193
for src, _, net in self.unique_contact_links:
208194
commands.append(
209195
(
@@ -213,60 +199,46 @@ def setup(self) -> None:
213199
)
214200
)
215201
print(
216-
f"[INIT] Initialize contact: interface {src.interfaces[net].dev} on node {src.name}"
202+
"[INIT] Initialize contact: interface",
203+
f"{src.interfaces[net].dev} on node {src.name}",
217204
)
205+
218206
run_in_containers_parallel(commands)
219207

220-
def cleanup(self) -> None:
221-
"""Remove qdiscs from all interfaces managed by this handler."""
222-
commands: list[ContainerCommand] = []
223-
for src, _, net in self.unique_contact_links:
224-
commands.append(
225-
(src.name, make_tc_command(src.interfaces[net].dev, "del"), None)
208+
def _transition_command(
209+
self,
210+
time: int,
211+
contact: Contact,
212+
target_state: ContactState,
213+
) -> ContainerCommand:
214+
interface = contact.src.interfaces[contact.network].dev
215+
216+
if target_state == ContactState.ACTIVE:
217+
print(f"[ {time} ] Activating {contact}")
218+
command = make_tc_command(
219+
interface,
220+
loss=contact.props.loss,
221+
delay=contact.props.delay,
222+
jitter=contact.props.jitter,
223+
bandwidth=contact.props.bandwidth,
226224
)
227-
run_in_containers_parallel(commands, raise_on_error=False)
225+
else:
226+
print(f"[ {time} ] Deactivating {contact}")
227+
command = make_tc_command(interface, loss=100)
228228

229-
def process_time(self, time: int) -> None:
230-
"""Apply actual contact transitions due at the given simulation time."""
231-
# Activations
232-
commands: list[ContainerCommand] = []
233-
for c in self.plan.contacts_to_activate(time):
234-
print(f"[ {time} ] Activating {c}")
235-
commands.append(
236-
(
237-
c.src.name,
238-
make_tc_command(
239-
c.src.interfaces[c.network].dev,
240-
loss=c.props.loss,
241-
delay=c.props.delay,
242-
jitter=c.props.jitter,
243-
bandwidth=c.props.bandwidth,
244-
),
245-
None,
246-
)
247-
)
248-
self.plan.contacts[c] = ContactState.ACTIVE
249-
run_in_containers_parallel(commands)
229+
return contact.src.name, command, None
250230

251-
# Deactivations
252-
commands: list[ContainerCommand] = []
253-
for c in self.plan.contacts_to_deactivate(time):
254-
print(f"[ {time} ] Deactivating {c}")
255-
commands.append(
256-
(
257-
c.src.name,
258-
make_tc_command(
259-
c.src.interfaces[c.network].dev,
260-
loss=100,
261-
),
262-
None,
263-
)
231+
def cleanup(self) -> None:
232+
"""Remove qdiscs from all interfaces managed by this handler."""
233+
commands = [
234+
(
235+
src.name,
236+
make_tc_command(src.interfaces[net].dev, "del"),
237+
None,
264238
)
265-
self.plan.contacts[c] = ContactState.INACTIVE
266-
run_in_containers_parallel(commands)
267-
268-
def next_event(self, after: int) -> int | None:
269-
return self.plan.next_contact_event(after)
239+
for src, _, net in self.unique_contact_links
240+
]
241+
run_in_containers_parallel(commands, raise_on_error=False)
270242

271243

272244
@dataclass

0 commit comments

Comments
 (0)