mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat: implement traffic insight marker backend (ubridge mark filter)
Add compute-side marker subsystem that receives ubridge UDP MARK signals
and forwards them as project-scoped notifications to the web UI for
real-time traffic coloring. Matched packets are always saved to per-link
pcaps for future replay.
Key components:
- gns3server/compute/marker/: MarkerManager (singleton, UDP listener +
O(1) registry keyed by (node_id, filter_name)) + MarkerListener
(DatagramProtocol parsing MARK lines per ubridge integration contract)
- gns3server/compute/base_node.py: marker sink/node config at ubridge
startup; shared _ubridge_add_marker_filter / _ubridge_delete_marker_filter
- Per-node start_marker/stop_marker: VPCS (VPCS-{id}), QEMU
(QEMU-{id}-{adapter}), Docker (bridge{adapter})
- Compute REST /markers/start + /markers/stop (vpcs/qemu/docker route files)
- Controller Link._markers state + UDPLink.start_marker/stop_marker/
update_marker (mirror capture pattern: BPF validation, _choose_capture_side,
node.post forwarding, topology persistence)
- Controller REST GET/POST/DELETE/PUT /v3/projects/{p}/links/{l}/markers
- Config: marker_listen_host / marker_listen_port in ServerSettings
- Signal routing: creation-time registry O(1) lookup, no node-table scan;
project-scoped WS stream (not global); event payload always carries
project_id for frontend scoping
Tests: 14 unit tests (registry, listener parsing, UDP round-trip);
562 existing tests pass with zero regressions.
This commit is contained in:
21 files changed
+1135
-1
No files matched your search
@@ -935,9 +935,38 @@ class BaseNode:
|
||||
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
|
||||
)
|
||||
await self._ubridge_hypervisor.connect()
|
||||
# Tell this uBridge where to send MARK signals and which node id to
|
||||
# tag them with. Marker is opt-in and inert until a `mark` filter is
|
||||
# added, so this never disturbs the data plane.
|
||||
await self._ubridge_configure_marker_sink()
|
||||
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
|
||||
self._ubridge_require_privileged_access = require_privileged_access
|
||||
|
||||
async def _ubridge_configure_marker_sink(self):
|
||||
"""
|
||||
Point this node's uBridge at the compute's marker UDP sink and tag its
|
||||
signals with this node's id. Safe to call before any marker filter
|
||||
exists — uBridge stays inert until a ``mark`` filter is configured.
|
||||
|
||||
Old uBridge builds without the marker module are tolerated: the failure
|
||||
is downgraded to a warning so node start is not blocked by an opt-in
|
||||
observability feature.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
if not manager.running or not manager.host or not manager.port:
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f"marker sink {manager.host} {manager.port}")
|
||||
await self._ubridge_send(f"marker node {self._id}")
|
||||
except UbridgeError:
|
||||
log.warning(
|
||||
"uBridge does not support the marker module; traffic insight disabled for node %r",
|
||||
self.name,
|
||||
)
|
||||
|
||||
async def _stop_ubridge(self):
|
||||
"""
|
||||
Stops uBridge.
|
||||
@@ -1042,6 +1071,47 @@ class BaseNode:
|
||||
)
|
||||
i += 1
|
||||
|
||||
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None):
|
||||
"""
|
||||
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
|
||||
|
||||
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
|
||||
and (b) appends the packet to ``pcap_path``. Unlike the impairment
|
||||
filters, this is an observability tap: it never drops or alters traffic,
|
||||
and it is added/removed on its own (not via reset_packet_filters) so the
|
||||
pcap is not closed/reopened on unrelated filter changes.
|
||||
|
||||
:param bridge_name: uBridge bridge carrying the link's traffic
|
||||
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
|
||||
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
|
||||
bridge=bridge_name, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
# Let BPF compile errors propagate — the marker is the user's intent, so a
|
||||
# bad expression must surface instead of being silently dropped.
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
"""
|
||||
Remove a `mark` filter from a uBridge bridge.
|
||||
|
||||
uBridge closes and flushes the filter's pcap on delete; the file itself
|
||||
persists on disk for later replay.
|
||||
|
||||
:param bridge_name: uBridge bridge the filter is attached to
|
||||
:param name: filter name previously passed to _ubridge_add_marker_filter
|
||||
"""
|
||||
|
||||
await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}")
|
||||
|
||||
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
|
||||
"""
|
||||
Creates a connection with an Ethernet interface in uBridge.
|
||||
|
||||
@@ -1440,6 +1440,44 @@ class DockerVM(BaseNode):
|
||||
)
|
||||
)
|
||||
|
||||
async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None):
|
||||
"""
|
||||
Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge.
|
||||
On BPF match uBridge emits a MARK signal and appends the packet to the pcap.
|
||||
|
||||
:param adapter_number: adapter number
|
||||
:param name: stable filter name — pcap identity and echoed in MARK signals
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
if self.status == "started" and self.ubridge:
|
||||
adapter = f"bridge{adapter_number}"
|
||||
await self._ubridge_add_marker_filter(adapter, name, bpf, pcap_path, tag)
|
||||
log.info(
|
||||
"Docker VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format(
|
||||
name=self.name, id=self.id, marker=name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_marker(self, adapter_number, name):
|
||||
"""
|
||||
Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge.
|
||||
|
||||
:param adapter_number: adapter number
|
||||
:param name: filter name previously passed to start_marker
|
||||
"""
|
||||
|
||||
if self.status == "started" and self.ubridge:
|
||||
adapter = f"bridge{adapter_number}"
|
||||
await self._ubridge_delete_marker_filter(adapter, name)
|
||||
log.info(
|
||||
"Docker VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format(
|
||||
name=self.name, id=self.id, marker=name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
async def _get_log(self):
|
||||
"""
|
||||
Returns the log from the container
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
#
|
||||
# Traffic-insight marker subsystem (compute side).
|
||||
#
|
||||
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
|
||||
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
|
||||
# This package owns the compute-side UDP sink: one listener per compute process
|
||||
# serves every ubridge on that host, disambiguated by ``node=<id>``.
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerListener(asyncio.DatagramProtocol):
|
||||
"""
|
||||
Receives ubridge ``MARK`` signal datagrams and turns each into a
|
||||
``marker.match`` notification.
|
||||
|
||||
Signal format (one datagram per match, ASCII)::
|
||||
|
||||
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
|
||||
|
||||
The signal carries metadata only (no packet bytes). The compute-side
|
||||
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
|
||||
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
|
||||
event can be emitted on the right project-scoped notification stream.
|
||||
"""
|
||||
|
||||
def __init__(self, manager):
|
||||
# MarkerManager owns this listener and the registry.
|
||||
self._manager = manager
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
try:
|
||||
self._handle(data)
|
||||
except Exception:
|
||||
# Never let a malformed datagram kill the listener.
|
||||
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
|
||||
|
||||
def _handle(self, data):
|
||||
line = data.decode("utf-8", errors="replace").strip()
|
||||
if not line.startswith("MARK"):
|
||||
return
|
||||
|
||||
parts = line.split()
|
||||
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
try:
|
||||
ts = float(parts[1])
|
||||
except ValueError:
|
||||
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
|
||||
return
|
||||
|
||||
kv = {}
|
||||
for token in parts[2:]:
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
kv[key] = value
|
||||
|
||||
node_id = kv.get("node")
|
||||
filter_name = kv.get("filter")
|
||||
if not node_id or not filter_name:
|
||||
return
|
||||
|
||||
# "-" means the field was unset on the ubridge side (see contract §3.3).
|
||||
tag = kv.get("tag")
|
||||
length = kv.get("len")
|
||||
|
||||
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
|
||||
if project_id is None:
|
||||
log.warning(
|
||||
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
|
||||
)
|
||||
return
|
||||
|
||||
event = {
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"link_id": link_id,
|
||||
"filter": filter_name,
|
||||
# Prefer the value carried in the signal; fall back to the one we registered.
|
||||
"tag": tag if tag and tag != "-" else registered_tag,
|
||||
"ts": ts,
|
||||
"len": int(length) if length and length.isdigit() else 0,
|
||||
}
|
||||
self._manager.emit_match(project_id, event)
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.compute.marker.marker_listener import MarkerListener
|
||||
from gns3server.compute.notification_manager import NotificationManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerManager:
|
||||
"""
|
||||
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
|
||||
the registry that maps each ``(node_id, filter_name)`` back to its
|
||||
``(project_id, link_id, tag)``.
|
||||
|
||||
The registry is populated when a marker is created on a link (the compute
|
||||
endpoint has project_id + node_id from its route path and link_id/name/tag
|
||||
from the request body) and cleared when the marker is deleted or the project
|
||||
closed. At signal time it is an O(1) lookup — no node-table scan, and the
|
||||
signal payload is untouched.
|
||||
|
||||
One listener per compute process serves every ubridge on that host; source
|
||||
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._listener = None
|
||||
self._transport = None
|
||||
self._host = None
|
||||
self._port = None
|
||||
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
|
||||
self._entries = {}
|
||||
# Reverse index for O(1) per-project teardown: project_id -> set of keys
|
||||
self._by_project = {}
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""The host the UDP sink is reachable on (for ``marker sink``)."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""The UDP port the sink is bound on (for ``marker sink``)."""
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self._transport is not None
|
||||
|
||||
async def start(self, host="127.0.0.1", port=0):
|
||||
"""
|
||||
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
|
||||
then read back and exposed via :attr:`port` for ``marker sink`` commands.
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._listener = MarkerListener(self)
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, port)
|
||||
)
|
||||
sock = self._transport.get_extra_info("socket")
|
||||
self._host = host
|
||||
self._port = sock.getsockname()[1] if sock else port
|
||||
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
|
||||
|
||||
async def stop(self):
|
||||
"""Close the UDP sink and drop the whole registry."""
|
||||
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._listener = None
|
||||
self._entries.clear()
|
||||
self._by_project.clear()
|
||||
self._host = None
|
||||
self._port = None
|
||||
|
||||
def register(self, project_id, node_id, filter_name, link_id, tag=None):
|
||||
"""
|
||||
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
|
||||
``link_id``. Called from the compute marker-start endpoint.
|
||||
|
||||
Re-registering the same key updates the stored tag (e.g. on re-add).
|
||||
"""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
|
||||
self._by_project.setdefault(project_id, set()).add(key)
|
||||
|
||||
def unregister(self, node_id, filter_name):
|
||||
"""Forget a single marker. Returns True if something was removed."""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is None:
|
||||
return False
|
||||
project_entries = self._by_project.get(entry["project_id"])
|
||||
if project_entries is not None:
|
||||
project_entries.discard(key)
|
||||
if not project_entries:
|
||||
self._by_project.pop(entry["project_id"], None)
|
||||
return True
|
||||
|
||||
def unregister_project(self, project_id):
|
||||
"""Drop every marker belonging to ``project_id`` (project close)."""
|
||||
|
||||
keys = self._by_project.pop(project_id, None)
|
||||
if not keys:
|
||||
return
|
||||
for key in keys:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def lookup(self, node_id, filter_name):
|
||||
"""
|
||||
O(1) resolution of an incoming signal to its project/link/tag.
|
||||
|
||||
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
|
||||
"""
|
||||
|
||||
entry = self._entries.get((node_id, filter_name))
|
||||
if entry is None:
|
||||
return None, None, None
|
||||
return entry["project_id"], entry["link_id"], entry["tag"]
|
||||
|
||||
def emit_match(self, project_id, event):
|
||||
"""
|
||||
Forward a parsed match as a project-scoped ``marker.match`` notification.
|
||||
Flows compute -> controller dispatch -> project_emit -> web UI WS.
|
||||
"""
|
||||
|
||||
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
|
||||
|
||||
_instance = None
|
||||
|
||||
@staticmethod
|
||||
def instance():
|
||||
if MarkerManager._instance is None:
|
||||
MarkerManager._instance = MarkerManager()
|
||||
return MarkerManager._instance
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
MarkerManager._instance = None
|
||||
@@ -246,6 +246,22 @@ class Project:
|
||||
raise ComputeError(f"Could not create the capture working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def markers_working_directory(self):
|
||||
"""
|
||||
Returns the working directory where uBridge writes per-link marker pcaps
|
||||
(matched packets, kept for later replay).
|
||||
|
||||
:returns: path to the directory
|
||||
"""
|
||||
|
||||
workdir = os.path.join(self._path, "project-files", "markers")
|
||||
if not self._deleted:
|
||||
try:
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ComputeError(f"Could not create the markers working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def add_node(self, node):
|
||||
"""
|
||||
Adds a node to the project.
|
||||
|
||||
@@ -1697,6 +1697,44 @@ class QemuVM(BaseNode):
|
||||
)
|
||||
)
|
||||
|
||||
async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None):
|
||||
"""
|
||||
Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge.
|
||||
On BPF match uBridge emits a MARK signal and appends the packet to the pcap.
|
||||
|
||||
:param adapter_number: adapter number
|
||||
:param name: stable filter name — pcap identity and echoed in MARK signals
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_add_marker_filter(
|
||||
f"QEMU-{self._id}-{adapter_number}", name, bpf, pcap_path, tag
|
||||
)
|
||||
log.info(
|
||||
"QEMU VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format(
|
||||
name=self.name, id=self.id, marker=name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_marker(self, adapter_number, name):
|
||||
"""
|
||||
Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge.
|
||||
|
||||
:param adapter_number: adapter number
|
||||
:param name: filter name previously passed to start_marker
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_delete_marker_filter(f"QEMU-{self._id}-{adapter_number}", name)
|
||||
log.info(
|
||||
"QEMU VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format(
|
||||
name=self.name, id=self.id, marker=name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
async def create_disk_image(self, disk_name, options):
|
||||
"""
|
||||
Create a Qemu disk
|
||||
|
||||
@@ -512,6 +512,42 @@ class VPCSVM(BaseNode):
|
||||
)
|
||||
)
|
||||
|
||||
async def start_marker(self, port_number, name, bpf, pcap_path, tag=None):
|
||||
"""
|
||||
Attach a traffic-insight ``mark`` filter to this node's uBridge bridge.
|
||||
On BPF match uBridge emits a MARK signal and appends the packet to the pcap.
|
||||
|
||||
:param port_number: port number (kept for API symmetry; VPCS has a single bridge)
|
||||
:param name: stable filter name — pcap identity and echoed in MARK signals
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_add_marker_filter(f"VPCS-{self._id}", name, bpf, pcap_path, tag)
|
||||
log.info(
|
||||
"VPCS '{name}' [{id}]: starting marker '{marker}' on port {port_number}".format(
|
||||
name=self.name, id=self.id, marker=name, port_number=port_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_marker(self, port_number, name):
|
||||
"""
|
||||
Remove a traffic-insight ``mark`` filter from this node's uBridge bridge.
|
||||
|
||||
:param port_number: port number (kept for API symmetry)
|
||||
:param name: filter name previously passed to start_marker
|
||||
"""
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_delete_marker_filter(f"VPCS-{self._id}", name)
|
||||
log.info(
|
||||
"VPCS '{name}' [{id}]: stopping marker '{marker}' on port {port_number}".format(
|
||||
name=self.name, id=self.id, marker=name, port_number=port_number
|
||||
)
|
||||
)
|
||||
|
||||
def _build_command(self):
|
||||
"""
|
||||
Command to start the VPCS process.
|
||||
|
||||
Reference in new issue
Block a user