summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--interface-definitions/include/qos/hfsc-m1.xml.i1
-rw-r--r--interface-definitions/include/qos/hfsc-m2.xml.i1
-rw-r--r--python/vyos/ifconfig/interface.py15
-rw-r--r--python/vyos/qos/base.py10
-rw-r--r--python/vyos/qos/trafficshaper.py116
-rwxr-xr-xsmoketest/scripts/cli/test_qos.py157
-rwxr-xr-xsrc/conf_mode/qos.py55
7 files changed, 279 insertions, 76 deletions
diff --git a/interface-definitions/include/qos/hfsc-m1.xml.i b/interface-definitions/include/qos/hfsc-m1.xml.i
index 21b9c4f32..ca37f6ecf 100644
--- a/interface-definitions/include/qos/hfsc-m1.xml.i
+++ b/interface-definitions/include/qos/hfsc-m1.xml.i
@@ -27,6 +27,5 @@
<description>bps(8),kbps(8*10^3),mbps(8*10^6), gbps, tbps - Byte/sec</description>
</valueHelp>
</properties>
- <defaultValue>0bit</defaultValue>
</leafNode>
<!-- include end -->
diff --git a/interface-definitions/include/qos/hfsc-m2.xml.i b/interface-definitions/include/qos/hfsc-m2.xml.i
index 24e8f5d63..816546657 100644
--- a/interface-definitions/include/qos/hfsc-m2.xml.i
+++ b/interface-definitions/include/qos/hfsc-m2.xml.i
@@ -27,6 +27,5 @@
<description>bps(8),kbps(8*10^3),mbps(8*10^6), gbps, tbps - Byte/sec</description>
</valueHelp>
</properties>
- <defaultValue>100%</defaultValue>
</leafNode>
<!-- include end -->
diff --git a/python/vyos/ifconfig/interface.py b/python/vyos/ifconfig/interface.py
index 002d3da9e..cd562e1fe 100644
--- a/python/vyos/ifconfig/interface.py
+++ b/python/vyos/ifconfig/interface.py
@@ -98,6 +98,10 @@ class Interface(Control):
'shellcmd': 'ip -json -detail link list dev {ifname}',
'format': lambda j: jmespath.search('[*].ifalias | [0]', json.loads(j)) or '',
},
+ 'ifindex': {
+ 'shellcmd': 'ip -json -detail link list dev {ifname}',
+ 'format': lambda j: jmespath.search('[*].ifindex | [0]', json.loads(j)) or '',
+ },
'mac': {
'shellcmd': 'ip -json -detail link list dev {ifname}',
'format': lambda j: jmespath.search('[*].address | [0]', json.loads(j)),
@@ -428,6 +432,17 @@ class Interface(Control):
nft_command = f'add element inet vrf_zones ct_iface_map {{ "{self.ifname}" : {vrf_table_id} }}'
self._nft_check_and_run(nft_command)
+ def get_ifindex(self):
+ """
+ Get interface index by name
+
+ Example:
+ >>> from vyos.ifconfig import Interface
+ >>> Interface('eth0').get_ifindex()
+ '2'
+ """
+ return int(self.get_interface('ifindex'))
+
def get_min_mtu(self):
"""
Get hardware minimum supported MTU
diff --git a/python/vyos/qos/base.py b/python/vyos/qos/base.py
index 322cdca44..12d940e3c 100644
--- a/python/vyos/qos/base.py
+++ b/python/vyos/qos/base.py
@@ -17,6 +17,7 @@ import os
import jmespath
from vyos.base import Warning
+from vyos.ifconfig import Interface
from vyos.utils.process import cmd
from vyos.utils.dict import dict_search
from vyos.utils.file import read_file
@@ -253,19 +254,24 @@ class QoSBase:
for index, (match, match_config) in enumerate(cls_config['match'].items(), start=1):
filter_cmd = filter_cmd_base
if not has_filter:
- for key in ['mark', 'vif', 'ip', 'ipv6']:
+ for key in ['mark', 'vif', 'ip', 'ipv6', 'interface']:
if key in match_config:
has_filter = True
break
- if self.qostype == 'shaper' and 'prio ' not in filter_cmd:
+ if self.qostype in ['shaper', 'shaper_hfsc'] and 'prio ' not in filter_cmd:
filter_cmd += f' prio {index}'
if 'mark' in match_config:
mark = match_config['mark']
filter_cmd += f' handle {mark} fw'
+
if 'vif' in match_config:
vif = match_config['vif']
filter_cmd += f' basic match "meta(vlan mask 0xfff eq {vif})"'
+ elif 'interface' in match_config:
+ iif_name = match_config['interface']
+ iif = Interface(iif_name).get_ifindex()
+ filter_cmd += f' basic match "meta(rt_iif eq {iif})"'
for af in ['ip', 'ipv6']:
tc_af = af
diff --git a/python/vyos/qos/trafficshaper.py b/python/vyos/qos/trafficshaper.py
index 8b0333c21..9f92ccd8b 100644
--- a/python/vyos/qos/trafficshaper.py
+++ b/python/vyos/qos/trafficshaper.py
@@ -126,91 +126,71 @@ class TrafficShaper(QoSBase):
# call base class
super().update(config, direction)
+
class TrafficShaperHFSC(QoSBase):
+ """
+ Traffic shaper using Hierarchical Fair Service Curve (HFSC).
+ Documentation: https://man7.org/linux/man-pages/man8/tc-hfsc.8.html
+ """
+
_parent = 1
qostype = 'shaper_hfsc'
- # https://man7.org/linux/man-pages/man8/tc-hfsc.8.html
- def update(self, config, direction):
- class_id_max = 0
- if 'class' in config:
- tmp = list(config['class'])
- tmp.sort()
- class_id_max = tmp[-1]
+ criteria = ['linkshare', 'realtime', 'upperlimit']
+ short_criterion = {
+ 'linkshare': 'ls',
+ 'realtime': 'rt',
+ 'upperlimit': 'ul',
+ }
+
+ def _gen_class(self, cls: int, cls_config: dict):
+ """
+ Generate HFSC class and add Stochastic Fair Queueing (SFQ) qdisc.
+
+ Args:
+ cls (int): Class ID
+ cls_config (dict): Configuration for the class
+ """
+ tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc'
+
+ for crit in self.criteria:
+ param = cls_config.get(crit)
+ if param:
+ tmp += (
+ f' {self.short_criterion[crit]}'
+ f' m1 {self._rate_convert(param["m1"]) if param.get("m1") else 0}'
+ f' d {param.get("d", 0)}ms'
+ f' m2 {self._rate_convert(param["m2"])}'
+ )
- r2q = 10
- # bandwidth is a mandatory CLI node
- speed = self._rate_convert(config['bandwidth'])
- speed_bps = int(speed) // 8
+ self._cmd(tmp)
- # need a bigger r2q if going fast than 16 mbits/sec
- if (speed_bps // r2q) >= MAXQUANTUM: # integer division
- r2q = ceil(speed_bps // MAXQUANTUM)
- else:
- # if there is a slow class then may need smaller value
- if 'class' in config:
- min_speed = speed_bps
- for cls, cls_options in config['class'].items():
- # find class with the lowest bandwidth used
- if 'bandwidth' in cls_options:
- bw_bps = int(self._rate_convert(cls_options['bandwidth'])) // 8 # bandwidth in bytes per second
- if bw_bps < min_speed:
- min_speed = bw_bps
+ tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10'
+ self._cmd(tmp)
- while (r2q > 1) and (min_speed // r2q) < MINQUANTUM:
- tmp = r2q -1
- if (speed_bps // tmp) >= MAXQUANTUM:
- break
- r2q = tmp
+ def update(self, config, direction):
+ class_id_max = self._get_class_max_id(config)
+ default_cls_id = int(class_id_max) + 1 if class_id_max else 2
- default_minor_id = int(class_id_max) +1
- tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_minor_id:x}' # default is in hex
+ speed = self._rate_convert(config['bandwidth'])
+
+ tmp = f'tc qdisc replace dev {self._interface} root handle {self._parent:x}: hfsc default {default_cls_id:x}' # default is in hex
self._cmd(tmp)
tmp = f'tc class replace dev {self._interface} parent {self._parent:x}: classid {self._parent:x}:1 hfsc sc rate {speed} ul rate {speed}'
self._cmd(tmp)
+ # tmp = f'tc qdisc add dev {self._interface} parent {self._parent:x}:1 handle f1: sfq perturb 10'
+ # self._cmd(tmp)
+
if 'class' in config:
for cls, cls_config in config['class'].items():
- # class id is used later on and passed as hex, thus this needs to be an int
- cls = int(cls)
- # ls m1
- if cls_config.get('linkshare', {}).get('m1').endswith('%'):
- percent = cls_config['linkshare']['m1'].rstrip('%')
- m_one_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
- else:
- m_one_rate = cls_config['linkshare']['m1']
- # ls m2
- if cls_config.get('linkshare', {}).get('m2').endswith('%'):
- percent = cls_config['linkshare']['m2'].rstrip('%')
- m_two_rate = self._rate_convert(config['bandwidth']) * int(percent) // 100
- else:
- m_two_rate = self._rate_convert(cls_config['linkshare']['m2'])
-
- tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{cls:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} '
- self._cmd(tmp)
-
- tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{cls:x} sfq perturb 10'
- self._cmd(tmp)
+ self._gen_class(cls=int(cls), cls_config=cls_config)
if 'default' in config:
- # ls m1
- if config.get('default', {}).get('linkshare', {}).get('m1').endswith('%'):
- percent = config['default']['linkshare']['m1'].rstrip('%')
- m_one_rate = self._rate_convert(config['default']['linkshare']['m1']) * int(percent) // 100
- else:
- m_one_rate = config['default']['linkshare']['m1']
- # ls m2
- if config.get('default', {}).get('linkshare', {}).get('m2').endswith('%'):
- percent = config['default']['linkshare']['m2'].rstrip('%')
- m_two_rate = self._rate_convert(config['default']['linkshare']['m2']) * int(percent) // 100
- else:
- m_two_rate = self._rate_convert(config['default']['linkshare']['m2'])
- tmp = f'tc class replace dev {self._interface} parent {self._parent:x}:1 classid {self._parent:x}:{default_minor_id:x} hfsc ls m1 {m_one_rate} m2 {m_two_rate} '
- self._cmd(tmp)
-
- tmp = f'tc qdisc replace dev {self._interface} parent {self._parent:x}:{default_minor_id:x} sfq perturb 10'
- self._cmd(tmp)
+ self._gen_class(
+ cls=int(default_cls_id), cls_config=config.get('default', {})
+ )
# call base class
super().update(config, direction)
diff --git a/smoketest/scripts/cli/test_qos.py b/smoketest/scripts/cli/test_qos.py
index 9c3e848cd..79b791288 100755
--- a/smoketest/scripts/cli/test_qos.py
+++ b/smoketest/scripts/cli/test_qos.py
@@ -21,7 +21,7 @@ from json import loads
from base_vyostest_shim import VyOSUnitTestSHIM
from vyos.configsession import ConfigSessionError
-from vyos.ifconfig import Section
+from vyos.ifconfig import Section, Interface
from vyos.qos import CAKE
from vyos.utils.process import cmd
@@ -985,6 +985,137 @@ class TestQoS(VyOSUnitTestSHIM.TestCase):
tmp[2]['options'],
)
+ def test_21_shaper_hfsc(self):
+ interface = self._interfaces[0]
+ policy_name = f'qos-policy-{interface}'
+ ul = {
+ 'm1': '100kbit',
+ 'm2': '150kbit',
+ 'd': '100',
+ }
+ ls = {'m2': '120kbit'}
+ rt = {
+ 'm1': '110kbit',
+ 'm2': '130kbit',
+ 'd': '75',
+ }
+ self.cli_set(base_path + ['interface', interface, 'egress', policy_name])
+ self.cli_set(base_path + ['policy', 'shaper-hfsc', policy_name])
+
+ # Policy {policy_name} misses "default" class!
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'upperlimit']
+ )
+
+ # At least one m2 value needs to be set for class: {class_name}
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'upperlimit', 'm1', ul['m1']]
+ )
+ # {class_name} upperlimit m1 value is set, but no m2 was found!
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'upperlimit', 'm2', ul['m2']]
+ )
+ # {class_name} upperlimit m1 value is set, but no d was found!
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'upperlimit', 'd', ul['d']]
+ )
+ # Linkshare m2 needs to be defined to use upperlimit m2 for class: {class_name}
+ with self.assertRaises(ConfigSessionError):
+ self.cli_commit()
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'linkshare', 'm2', ls['m2']]
+ )
+ self.cli_commit()
+
+ # use raw because tc json is incorrect here
+ tmp = cmd(f'tc -details qdisc show dev {interface}')
+ for rec in tmp.split('\n'):
+ rec = rec.strip()
+ if 'root' in rec:
+ self.assertEqual(rec, 'qdisc hfsc 1: root refcnt 2 default 2')
+ else:
+ self.assertRegex(
+ rec,
+ r'qdisc sfq \S+: parent 1:2 limit 127p quantum 1514b depth 127 flows 128 divisor 1024 perturb 10sec',
+ )
+ # use raw because tc json is incorrect here
+ tmp = cmd(f'tc -details class show dev {interface}')
+ for rec in tmp.split('\n'):
+ rec = rec.strip().lower()
+ if 'root' in rec:
+ self.assertEqual(rec, 'class hfsc 1: root')
+ elif 'hfsc 1:1' in rec:
+ # m2 \S+bit is auto bandwidth
+ self.assertRegex(
+ rec,
+ r'class hfsc 1:1 parent 1: sc m1 0bit d 0us m2 \S+bit ul m1 0bit d 0us m2 \S+bit',
+ )
+ else:
+ self.assertRegex(
+ rec,
+ rf'class hfsc 1:2 parent 1:1 leaf \S+: ls m1 0bit d 0us m2 {ls["m2"]} ul m1 {ul["m1"]} d {ul["d"]}ms m2 {ul["m2"]}',
+ )
+
+ for key, val in rt.items():
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'default', 'realtime', key, val]
+ )
+ self.cli_commit()
+
+ tmp = cmd(f'tc -details class show dev {interface}')
+ for rec in tmp.split('\n'):
+ rec = rec.strip().lower()
+ if 'hfsc 1:2' in rec:
+ self.assertTrue(
+ f'rt m1 {rt["m1"]} d {rt["d"]}ms m2 {rt["m2"]} ls m1 0bit d 0us m2 {ls["m2"]} ul m1 {ul["m1"]} d {ul["d"]}ms m2 {ul["m2"]}'
+ in rec
+ )
+
+ # add some class
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'class', '10', 'linkshare', 'm2', '300kbit']
+ )
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'class', '10', 'match', 'tst', 'ip', 'dscp', 'internet']
+ )
+
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'class', '30', 'realtime', 'm2', '250kbit']
+ )
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'class', '30', 'realtime', 'd', '77']
+ )
+ self.cli_set(
+ base_path + ['policy', 'shaper-hfsc', policy_name, 'class', '30', 'match', 'tst30', 'ip', 'dscp', 'critical']
+ )
+ self.cli_commit()
+
+ tmp = cmd(f'tc -details qdisc show dev {interface}')
+ self.assertEqual(4, len(tmp.split('\n')))
+
+ tmp = cmd(f'tc -details class show dev {interface}')
+ tmp = tmp.lower()
+
+ self.assertTrue(
+ f'rt m1 {rt["m1"]} d {rt["d"]}ms m2 {rt["m2"]} ls m1 0bit d 0us m2 {ls["m2"]} ul m1 {ul["m1"]} d {ul["d"]}ms m2 {ul["m2"]}'
+ in tmp
+ )
+ self.assertTrue(': ls m1 0bit d 0us m2 300kbit' in tmp)
+ self.assertTrue(': rt m1 0bit d 77ms m2 250kbit' in tmp)
+
def test_22_rate_control_default(self):
interface = self._interfaces[0]
policy_name = f'qos-policy-{interface}'
@@ -1006,6 +1137,30 @@ class TestQoS(VyOSUnitTestSHIM.TestCase):
# TC store rates as a 32-bit unsigned integer in bps (Bytes per second)
self.assertEqual(int(bandwidth * 125), tmp['options']['rate'])
+ def test_23_policy_limiter_iif_filter(self):
+ policy_name = 'smoke_test'
+ base_policy_path = ['qos', 'policy', 'limiter', policy_name]
+
+ self.cli_set(['qos', 'interface', self._interfaces[0], 'ingress', policy_name])
+ self.cli_set(base_policy_path + ['class', '100', 'bandwidth', '20gbit'])
+ self.cli_set(base_policy_path + ['class', '100', 'burst', '3760k'])
+ self.cli_set(base_policy_path + ['class', '100', 'match', 'test', 'interface', self._interfaces[0]])
+ self.cli_set(base_policy_path + ['class', '100', 'priority', '20'])
+ self.cli_set(base_policy_path + ['default', 'bandwidth', '1gbit'])
+ self.cli_set(base_policy_path + ['default', 'burst', '125000000b'])
+ self.cli_commit()
+
+ iif = Interface(self._interfaces[0]).get_ifindex()
+ tc_filters = cmd(f'tc filter show dev {self._interfaces[0]} ingress')
+
+ # class 100
+ self.assertIn('filter parent ffff: protocol all pref 20 basic chain 0', tc_filters)
+ self.assertIn(f'meta(rt_iif eq {iif})', tc_filters)
+ self.assertIn('action order 1: police 0x1 rate 20Gbit burst 3847500b mtu 2Kb action drop overhead 0b', tc_filters)
+ # default
+ self.assertIn('filter parent ffff: protocol all pref 255 basic chain 0', tc_filters)
+ self.assertIn('action order 1: police 0x2 rate 1Gbit burst 125000000b mtu 2Kb action drop overhead 0b', tc_filters)
+
if __name__ == '__main__':
unittest.main(verbosity=2)
diff --git a/src/conf_mode/qos.py b/src/conf_mode/qos.py
index a4d5f44e7..59e307a39 100755
--- a/src/conf_mode/qos.py
+++ b/src/conf_mode/qos.py
@@ -198,10 +198,16 @@ def get_config(config=None):
def _verify_match(cls_config: dict) -> None:
if 'match' in cls_config:
for match, match_config in cls_config['match'].items():
- if {'ip', 'ipv6'} <= set(match_config):
+ filters = set(match_config)
+ if {'ip', 'ipv6'} <= filters:
raise ConfigError(
f'Can not use both IPv6 and IPv4 in one match ({match})!')
+ if {'interface', 'vif'} & filters:
+ if {'ip', 'ipv6', 'ether'} & filters:
+ raise ConfigError(
+ f'Can not combine protocol and interface or vlan tag match ({match})!')
+
def _verify_match_group_exist(cls_config, qos):
if 'match_group' in cls_config:
@@ -210,6 +216,46 @@ def _verify_match_group_exist(cls_config, qos):
Warning(f'Match group "{group}" does not exist!')
+def _verify_default_policy_exist(policy, policy_config):
+ if 'default' not in policy_config:
+ raise ConfigError(f'Policy {policy} misses "default" class!')
+
+
+def _check_shaper_hfsc_rate(cls, cls_conf):
+ is_m2_exist = False
+ for crit in TrafficShaperHFSC.criteria:
+ if cls_conf.get(crit, {}).get('m2') is not None:
+ is_m2_exist = True
+
+ if cls_conf.get(crit, {}).get('m1') is not None:
+ for crit_val in ['m2', 'd']:
+ if cls_conf.get(crit, {}).get(crit_val) is None:
+ raise ConfigError(
+ f'{cls} {crit} m1 value is set, but no {crit_val} was found!'
+ )
+
+ if not is_m2_exist:
+ raise ConfigError(f'At least one m2 value needs to be set for class: {cls}')
+
+ if (
+ cls_conf.get('upperlimit', {}).get('m2') is not None
+ and cls_conf.get('linkshare', {}).get('m2') is None
+ ):
+ raise ConfigError(
+ f'Linkshare m2 needs to be defined to use upperlimit m2 for class: {cls}'
+ )
+
+
+def _verify_shaper_hfsc(policy, policy_config):
+ _verify_default_policy_exist(policy, policy_config)
+
+ _check_shaper_hfsc_rate('default', policy_config.get('default'))
+
+ if 'class' in policy_config:
+ for cls, cls_conf in policy_config['class'].items():
+ _check_shaper_hfsc_rate(cls, cls_conf)
+
+
def verify(qos):
if not qos or 'interface' not in qos:
return None
@@ -253,11 +299,13 @@ def verify(qos):
if queue_lim < max_tr:
raise ConfigError(f'Policy "{policy}" uses queue-limit "{queue_lim}" < max-threshold "{max_tr}"!')
if policy_type in ['priority_queue']:
- if 'default' not in policy_config:
- raise ConfigError(f'Policy {policy} misses "default" class!')
+ _verify_default_policy_exist(policy, policy_config)
if policy_type in ['rate_control']:
if 'bandwidth' not in policy_config:
raise ConfigError('Bandwidth not defined')
+ if policy_type in ['shaper_hfsc']:
+ _verify_shaper_hfsc(policy, policy_config)
+
if 'default' in policy_config:
if 'bandwidth' not in policy_config['default'] and policy_type not in ['priority_queue', 'round_robin', 'shaper_hfsc']:
raise ConfigError('Bandwidth not defined for default traffic!')
@@ -293,6 +341,7 @@ def generate(qos):
return None
+
def apply(qos):
# Always delete "old" shapers first
for interface in interfaces():