Skip to content

Commit 36a35ce

Browse files
authored
Netem events (srl-labs#2998)
This add netem tc qdisc events to clab events ``` {"timestamp":"2026-01-18T16:47:39.936740375+01:00","type":"interface","action":"snapshot","actor_id":"fc942abe32dc","actor_name":"clab-eda_tiny-dut1","actor_full_id":"fc942abe32dc7a6aefc4fb65fc3b78fd44d722d237940ed348aa7783edba4724","attributes":{"id":"fc942abe32dc7a6aefc4fb65fc3b78fd44d722d237940ed348aa7783edba4724","ifname":"e1-1","index":"766","lab":"eda_tiny","mac":"aa:c1:ab:88:d0:a1","mtu":"9232","name":"clab-eda_tiny-dut1","netem_delay":"100ms","origin":"netlink","state":"up","type":"veth"}} {"timestamp":"2026-01-18T16:47:51.938076311+01:00","type":"interface","action":"update","actor_id":"fc942abe32dc","actor_name":"clab-eda_tiny-dut1","actor_full_id":"fc942abe32dc7a6aefc4fb65fc3b78fd44d722d237940ed348aa7783edba4724","attributes":{"id":"fc942abe32dc7a6aefc4fb65fc3b78fd44d722d237940ed348aa7783edba4724","ifname":"e1-1","index":"766","lab":"eda_tiny","mac":"aa:c1:ab:88:d0:a1","mtu":"9232","name":"clab-eda_tiny-dut1","netem_delay":"222ms","origin":"netlink","state":"up","type":"veth"}} ```
1 parent a584ba0 commit 36a35ce

File tree

4 files changed

+237
-9
lines changed

4 files changed

+237
-9
lines changed

cmd/tools_netem.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import (
2323
"github.com/spf13/cobra"
2424
clabconstants "github.com/srl-labs/containerlab/constants"
2525
clabcore "github.com/srl-labs/containerlab/core"
26-
clabinternaltc "github.com/srl-labs/containerlab/internal/tc"
26+
clabnetem "github.com/srl-labs/containerlab/netem"
2727
clablinks "github.com/srl-labs/containerlab/links"
2828
clabruntime "github.com/srl-labs/containerlab/runtime"
2929
clabtypes "github.com/srl-labs/containerlab/types"
@@ -200,7 +200,7 @@ func netemSetFn(ctx context.Context, o *Options) error {
200200
return err
201201
}
202202

203-
tcnl, err := clabinternaltc.NewTC(int(nodeNs.Fd()))
203+
tcnl, err := clabnetem.NewTC(int(nodeNs.Fd()))
204204
if err != nil {
205205
return err
206206
}
@@ -225,7 +225,7 @@ func netemSetFn(ctx context.Context, o *Options) error {
225225
return err
226226
}
227227

228-
qdisc, err := clabinternaltc.SetImpairments(
228+
qdisc, err := clabnetem.SetImpairments(
229229
tcnl,
230230
o.ToolsNetem.ContainerName,
231231
link,
@@ -437,7 +437,7 @@ func netemShowFn(o *Options) error {
437437
return err
438438
}
439439

440-
tcnl, err := clabinternaltc.NewTC(int(nodeNs.Fd()))
440+
tcnl, err := clabnetem.NewTC(int(nodeNs.Fd()))
441441
if err != nil {
442442
return err
443443
}
@@ -449,7 +449,7 @@ func netemShowFn(o *Options) error {
449449
}()
450450

451451
err = nodeNs.Do(func(_ ns.NetNS) error {
452-
qdiscs, err := clabinternaltc.Impairments(tcnl)
452+
qdiscs, err := clabnetem.Impairments(tcnl)
453453
if err != nil {
454454
return err
455455
}
@@ -521,7 +521,7 @@ func netemResetFn(o *Options) error {
521521
return err
522522
}
523523

524-
tcnl, err := clabinternaltc.NewTC(int(nodeNs.Fd()))
524+
tcnl, err := clabnetem.NewTC(int(nodeNs.Fd()))
525525
if err != nil {
526526
return err
527527
}
@@ -544,7 +544,7 @@ func netemResetFn(o *Options) error {
544544
return err
545545
}
546546

547-
if err := clabinternaltc.DeleteImpairments(tcnl, netemIfIface); err != nil {
547+
if err := clabnetem.DeleteImpairments(tcnl, netemIfIface); err != nil {
548548
return err
549549
}
550550

core/events/netlink.go

Lines changed: 193 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ package events
33
import (
44
"context"
55
"fmt"
6+
"math"
67
"strconv"
78
"strings"
89
"sync"
910
"time"
1011

1112
"github.com/charmbracelet/log"
13+
gotc "github.com/florianl/go-tc"
1214
clabconstants "github.com/srl-labs/containerlab/constants"
15+
clabnetem "github.com/srl-labs/containerlab/netem"
1316
clabruntime "github.com/srl-labs/containerlab/runtime"
1417
clabutils "github.com/srl-labs/containerlab/utils"
1518
"github.com/vishvananda/netlink"
@@ -229,6 +232,7 @@ type netlinkWatcher struct {
229232
includeSnapshot bool
230233
includeStats bool
231234
statsInterval time.Duration
235+
nsHandle netns.NsHandle
232236
}
233237

234238
func (w *netlinkWatcher) run(ctx context.Context, registry *netlinkRegistry) {
@@ -261,6 +265,8 @@ func (w *netlinkWatcher) run(ctx context.Context, registry *netlinkRegistry) {
261265
}
262266
defer nsHandle.Close()
263267

268+
w.nsHandle = nsHandle
269+
264270
netHandle, err := netlink.NewHandleAt(nsHandle)
265271
if err != nil {
266272
log.Debugf("failed to create netlink handle for container %s: %v", containerName, err)
@@ -275,6 +281,10 @@ func (w *netlinkWatcher) run(ctx context.Context, registry *netlinkRegistry) {
275281
states = make(map[int]ifaceSnapshot)
276282
}
277283

284+
// Query and apply netem info to snapshots
285+
netemInfos := queryNetemInfo(nsHandle)
286+
applyNetemToSnapshots(states, netemInfos)
287+
278288
var statsSamples map[int]ifaceStatsSample
279289
if w.includeStats {
280290
statsSamples = make(map[int]ifaceStatsSample, len(states))
@@ -306,6 +316,14 @@ func (w *netlinkWatcher) run(ctx context.Context, registry *netlinkRegistry) {
306316
return
307317
}
308318

319+
// Set up netem polling interval (reuse stats interval or default to 1s)
320+
netemPollInterval := w.statsInterval
321+
if netemPollInterval == 0 {
322+
netemPollInterval = time.Second
323+
}
324+
netemTicker := time.NewTicker(netemPollInterval)
325+
defer netemTicker.Stop()
326+
309327
var (
310328
ticker *time.Ticker
311329
tickerC <-chan time.Time
@@ -320,6 +338,8 @@ func (w *netlinkWatcher) run(ctx context.Context, registry *netlinkRegistry) {
320338
select {
321339
case <-tickerC:
322340
w.collectAndEmitStats(netHandle, states, statsSamples, registry)
341+
case <-netemTicker.C:
342+
w.pollNetemChanges(states, registry)
323343
case <-ctx.Done():
324344
close(done)
325345

@@ -362,6 +382,17 @@ func (w *netlinkWatcher) processUpdate(
362382
delete(statsSamples, snapshot.Index)
363383
registry.emitInterfaceEvent(w.container, "delete", snapshot)
364384
case unix.RTM_NEWLINK:
385+
// Query netem info for this interface
386+
netemInfos := queryNetemInfo(w.nsHandle)
387+
if info, ok := netemInfos[snapshot.Index]; ok {
388+
snapshot.HasNetem = true
389+
snapshot.Delay = info.Delay
390+
snapshot.Jitter = info.Jitter
391+
snapshot.PacketLoss = info.PacketLoss
392+
snapshot.Rate = info.Rate
393+
snapshot.Corruption = info.Corruption
394+
}
395+
365396
if exists && snapshot.equal(previous) {
366397
return
367398
}
@@ -381,6 +412,41 @@ func (w *netlinkWatcher) processUpdate(
381412
}
382413
}
383414

415+
// pollNetemChanges checks for netem changes and emits update events.
416+
func (w *netlinkWatcher) pollNetemChanges(
417+
states map[int]ifaceSnapshot,
418+
registry *netlinkRegistry,
419+
) {
420+
netemInfos := queryNetemInfo(w.nsHandle)
421+
422+
for idx, snapshot := range states {
423+
previous := snapshot
424+
info, hasNetem := netemInfos[idx]
425+
426+
if hasNetem {
427+
snapshot.HasNetem = true
428+
snapshot.Delay = info.Delay
429+
snapshot.Jitter = info.Jitter
430+
snapshot.PacketLoss = info.PacketLoss
431+
snapshot.Rate = info.Rate
432+
snapshot.Corruption = info.Corruption
433+
} else if snapshot.HasNetem {
434+
// Netem was removed
435+
snapshot.HasNetem = false
436+
snapshot.Delay = ""
437+
snapshot.Jitter = ""
438+
snapshot.PacketLoss = 0
439+
snapshot.Rate = 0
440+
snapshot.Corruption = 0
441+
}
442+
443+
if !snapshot.equal(previous) {
444+
states[idx] = snapshot
445+
registry.emitInterfaceEvent(w.container, "update", snapshot)
446+
}
447+
}
448+
}
449+
384450
func firstContainerName(container *clabruntime.GenericContainer) string {
385451
if container == nil || len(container.Names) == 0 {
386452
return ""
@@ -426,6 +492,25 @@ func interfaceAttributes(
426492
attributes["name"] = name
427493
}
428494

495+
// Add netem attributes if present
496+
if snapshot.HasNetem {
497+
if snapshot.Delay != "" {
498+
attributes["netem_delay"] = snapshot.Delay
499+
}
500+
if snapshot.Jitter != "" {
501+
attributes["netem_jitter"] = snapshot.Jitter
502+
}
503+
if snapshot.PacketLoss != 0 {
504+
attributes["netem_loss"] = strconv.FormatFloat(snapshot.PacketLoss, 'f', 2, 64) + "%"
505+
}
506+
if snapshot.Rate != 0 {
507+
attributes["netem_rate"] = strconv.Itoa(snapshot.Rate) + "kbit"
508+
}
509+
if snapshot.Corruption != 0 {
510+
attributes["netem_corruption"] = strconv.FormatFloat(snapshot.Corruption, 'f', 2, 64) + "%"
511+
}
512+
}
513+
429514
return attributes
430515
}
431516

@@ -547,6 +632,21 @@ func snapshotInterfaces(netHandle *netlink.Handle) (map[int]ifaceSnapshot, error
547632
return states, nil
548633
}
549634

635+
// applyNetemToSnapshots applies netem information to interface snapshots.
636+
func applyNetemToSnapshots(states map[int]ifaceSnapshot, netemInfos map[int]netemInfo) {
637+
for idx, info := range netemInfos {
638+
if snapshot, ok := states[idx]; ok {
639+
snapshot.HasNetem = true
640+
snapshot.Delay = info.Delay
641+
snapshot.Jitter = info.Jitter
642+
snapshot.PacketLoss = info.PacketLoss
643+
snapshot.Rate = info.Rate
644+
snapshot.Corruption = info.Corruption
645+
states[idx] = snapshot
646+
}
647+
}
648+
}
649+
550650
func snapshotFromLink(link netlink.Link) ifaceSnapshot {
551651
attrs := link.Attrs()
552652

@@ -588,6 +688,13 @@ type ifaceSnapshot struct {
588688
TxBytes uint64
589689
RxPackets uint64
590690
TxPackets uint64
691+
// Netem fields
692+
HasNetem bool
693+
Delay string
694+
Jitter string
695+
PacketLoss float64
696+
Rate int
697+
Corruption float64
591698
}
592699

593700
func (s ifaceSnapshot) equal(other ifaceSnapshot) bool {
@@ -597,7 +704,13 @@ func (s ifaceSnapshot) equal(other ifaceSnapshot) bool {
597704
s.MTU == other.MTU &&
598705
s.MAC == other.MAC &&
599706
s.OperState == other.OperState &&
600-
s.Type == other.Type
707+
s.Type == other.Type &&
708+
s.HasNetem == other.HasNetem &&
709+
s.Delay == other.Delay &&
710+
s.Jitter == other.Jitter &&
711+
s.PacketLoss == other.PacketLoss &&
712+
s.Rate == other.Rate &&
713+
s.Corruption == other.Corruption
601714
}
602715

603716
type ifaceStatsSample struct {
@@ -749,3 +862,82 @@ func deltaCounter(previous, current uint64) uint64 {
749862

750863
return current
751864
}
865+
866+
const msPerSec = 1000
867+
868+
// queryNetemInfo queries the TC qdiscs in the namespace and returns a map
869+
// of interface index to netem data.
870+
func queryNetemInfo(nsHandle netns.NsHandle) map[int]netemInfo {
871+
result := make(map[int]netemInfo)
872+
873+
tcnl, err := clabnetem.NewTC(int(nsHandle))
874+
if err != nil {
875+
log.Debugf("failed to open tc socket for netem query: %v", err)
876+
return result
877+
}
878+
defer tcnl.Close()
879+
880+
qdiscs, err := clabnetem.Impairments(tcnl)
881+
if err != nil {
882+
log.Debugf("failed to query tc qdiscs: %v", err)
883+
return result
884+
}
885+
886+
for idx := range qdiscs {
887+
qdisc := &qdiscs[idx]
888+
if qdisc.Attribute.Kind != "netem" || qdisc.Netem == nil {
889+
continue
890+
}
891+
892+
info := netemInfoFromQdisc(qdisc)
893+
if info.hasValues() {
894+
result[int(qdisc.Ifindex)] = info
895+
}
896+
}
897+
898+
return result
899+
}
900+
901+
type netemInfo struct {
902+
Delay string
903+
Jitter string
904+
PacketLoss float64
905+
Rate int
906+
Corruption float64
907+
}
908+
909+
func (n netemInfo) hasValues() bool {
910+
return n.Delay != "" || n.Jitter != "" || n.PacketLoss != 0 || n.Rate != 0 || n.Corruption != 0
911+
}
912+
913+
func netemInfoFromQdisc(qdisc *gotc.Object) netemInfo {
914+
var info netemInfo
915+
916+
if qdisc.Netem == nil {
917+
return info
918+
}
919+
920+
if qdisc.Netem.Latency64 != nil && *qdisc.Netem.Latency64 != 0 {
921+
info.Delay = (time.Duration(*qdisc.Netem.Latency64) * time.Nanosecond).String()
922+
}
923+
924+
if qdisc.Netem.Jitter64 != nil && *qdisc.Netem.Jitter64 != 0 {
925+
info.Jitter = (time.Duration(*qdisc.Netem.Jitter64) * time.Nanosecond).String()
926+
}
927+
928+
if qdisc.Netem.Rate != nil && qdisc.Netem.Rate.Rate != 0 {
929+
info.Rate = int(qdisc.Netem.Rate.Rate * 8 / msPerSec)
930+
}
931+
932+
if qdisc.Netem.Corrupt != nil && qdisc.Netem.Corrupt.Probability != 0 {
933+
info.Corruption = math.Round((float64(qdisc.Netem.Corrupt.Probability)/
934+
float64(math.MaxUint32)*100)*100) / 100
935+
}
936+
937+
if qdisc.Netem.Qopt.Loss != 0 {
938+
info.PacketLoss = math.Round(
939+
(float64(qdisc.Netem.Qopt.Loss)/float64(math.MaxUint32)*100)*100) / 100
940+
}
941+
942+
return info
943+
}

internal/tc/tc.go renamed to netem/netem.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package tc
1+
package netem
22

33
import (
44
"fmt"

0 commit comments

Comments
 (0)