33import signal
44import socket
55import time
6+ from abc import ABC , abstractmethod
67from dataclasses import dataclass , field
78from itertools import combinations
89from pathlib import Path
9- from typing import Protocol
10+ from typing import Literal
1011
1112from tools .contact_player .ccp import (
1213 Contact ,
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