Skip to content

Commit 30af7b4

Browse files
FloSch62hellt
andauthored
containerlab events (#2898)
This PR add a containerlab events command. The `events` command streams lifecycle updates for every Containerlab resource and augments them with interface change notifications collected from the container network namespaces. The output combines Docker's event feed with the netlink information that powers `containerlab inspect interfaces`, so you can observe container activity and interface state changes in real time without selecting a specific lab. --------- Co-authored-by: hellt <[email protected]>
1 parent 28e5926 commit 30af7b4

File tree

18 files changed

+1867
-0
lines changed

18 files changed

+1867
-0
lines changed

cmd/events.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
clabevents "github.com/srl-labs/containerlab/core/events"
6+
clabutils "github.com/srl-labs/containerlab/utils"
7+
)
8+
9+
func eventsCmd(o *Options) (*cobra.Command, error) {
10+
c := &cobra.Command{
11+
Use: "events",
12+
Short: "stream lab lifecycle and interface events",
13+
Long: "stream container runtime events and interface updates for all running labs using the selected runtime\n" +
14+
"reference: https://containerlab.dev/cmd/events/",
15+
Aliases: []string{"ev"},
16+
PreRunE: func(*cobra.Command, []string) error {
17+
return clabutils.CheckAndGetRootPrivs()
18+
},
19+
RunE: func(cmd *cobra.Command, _ []string) error {
20+
return eventsFn(cmd, o)
21+
},
22+
}
23+
24+
c.Flags().StringVarP(
25+
&o.Events.Format,
26+
"format",
27+
"f",
28+
o.Events.Format,
29+
"output format. One of [plain, json]",
30+
)
31+
32+
c.Flags().BoolVarP(
33+
&o.Events.IncludeInitialState,
34+
"initial-state",
35+
"i",
36+
o.Events.IncludeInitialState,
37+
"emit the current container and interface states before streaming new events",
38+
)
39+
40+
c.Flags().BoolVar(
41+
&o.Events.IncludeInterfaceStats,
42+
"interface-stats",
43+
o.Events.IncludeInterfaceStats,
44+
"include interface statistics updates when streaming events",
45+
)
46+
47+
c.Flags().DurationVar(
48+
&o.Events.StatsInterval,
49+
"interface-stats-interval",
50+
o.Events.StatsInterval,
51+
"interval between interface statistics samples (requires --interface-stats)",
52+
)
53+
54+
c.Example = `# Stream container and interface events in plain text
55+
containerlab events
56+
57+
# Stream events as JSON
58+
containerlab events --format json`
59+
60+
return c, nil
61+
}
62+
63+
func eventsFn(cmd *cobra.Command, o *Options) error {
64+
opts := clabevents.Options{
65+
Format: o.Events.Format,
66+
Runtime: o.Global.Runtime,
67+
IncludeInitialState: o.Events.IncludeInitialState,
68+
IncludeInterfaceStats: o.Events.IncludeInterfaceStats,
69+
StatsInterval: o.Events.StatsInterval,
70+
ClabOptions: o.ToClabOptions(),
71+
Writer: cmd.OutOrStdout(),
72+
}
73+
74+
return clabevents.Stream(cmd.Context(), opts)
75+
}

cmd/options.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ func GetOptions() *Options {
5252
MermaidDirection: "TD",
5353
DrawIOVersion: "latest",
5454
},
55+
Events: &EventsOptions{
56+
Format: "plain",
57+
IncludeInterfaceStats: false,
58+
StatsInterval: time.Second,
59+
},
5560
ToolsAPI: &ToolsApiOptions{
5661
Image: "ghcr.io/srl-labs/clab-api-server/clab-api-server:latest",
5762
Name: "clab-api-server",
@@ -121,6 +126,7 @@ type Options struct {
121126
Exec *ExecOptions
122127
Inspect *InspectOptions
123128
Graph *GraphOptions
129+
Events *EventsOptions
124130
ToolsAPI *ToolsApiOptions
125131
ToolsCert *ToolsCertOptions
126132
ToolsTxOffload *ToolsDisableTxOffloadOptions
@@ -353,6 +359,13 @@ type GraphOptions struct {
353359
StaticDirectory string
354360
}
355361

362+
type EventsOptions struct {
363+
Format string
364+
IncludeInitialState bool
365+
IncludeInterfaceStats bool
366+
StatsInterval time.Duration
367+
}
368+
356369
type ToolsApiOptions struct {
357370
Image string
358371
Name string

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func subcommandRegisterFuncs() []func(*Options) (*cobra.Command, error) {
2929
execCmd,
3030
generateCmd,
3131
graphCmd,
32+
eventsCmd,
3233
inspectCmd,
3334
redeployCmd,
3435
saveCmd,

core/events/formatter.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package events
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"sort"
8+
"strings"
9+
"time"
10+
)
11+
12+
type formatter func(aggregatedEvent) error
13+
14+
func newFormatter(format string, w io.Writer) (formatter, error) {
15+
normalized := strings.TrimSpace(strings.ToLower(format))
16+
if normalized == "" {
17+
normalized = "plain"
18+
}
19+
20+
switch normalized {
21+
case "plain":
22+
return plainFormatter(w), nil
23+
case "json":
24+
return jsonFormatter(w), nil
25+
default:
26+
return nil, fmt.Errorf("output format %q is not supported, use 'plain' or 'json'", format)
27+
}
28+
}
29+
30+
func plainFormatter(w io.Writer) formatter {
31+
return func(ev aggregatedEvent) error {
32+
ts := ev.Timestamp
33+
if ts.IsZero() {
34+
ts = time.Now()
35+
}
36+
ts = ts.UTC()
37+
38+
actor := ev.ActorID
39+
if actor == "" {
40+
actor = ev.ActorName
41+
}
42+
if actor == "" {
43+
actor = "-"
44+
}
45+
46+
attrs := mergedEventAttributes(ev)
47+
keys := make([]string, 0, len(attrs))
48+
for k := range attrs {
49+
keys = append(keys, k)
50+
}
51+
sort.Strings(keys)
52+
53+
parts := make([]string, 0, len(keys))
54+
for _, k := range keys {
55+
parts = append(parts, fmt.Sprintf("%s=%s", k, attrs[k]))
56+
}
57+
58+
suffix := ""
59+
if len(parts) > 0 {
60+
suffix = " (" + strings.Join(parts, ", ") + ")"
61+
}
62+
63+
_, err := fmt.Fprintf(
64+
w,
65+
"%s %s %s %s%s\n",
66+
ts.Format(time.RFC3339Nano),
67+
ev.Type,
68+
ev.Action,
69+
actor,
70+
suffix,
71+
)
72+
73+
return err
74+
}
75+
}
76+
77+
func jsonFormatter(w io.Writer) formatter {
78+
encoder := json.NewEncoder(w)
79+
encoder.SetEscapeHTML(false)
80+
81+
return func(ev aggregatedEvent) error {
82+
copy := ev
83+
copy.Attributes = mergedEventAttributes(ev)
84+
85+
return encoder.Encode(copy)
86+
}
87+
}
88+
89+
func mergedEventAttributes(ev aggregatedEvent) map[string]string {
90+
if len(ev.Attributes) == 0 && ev.ActorName == "" && ev.ActorFullID == "" {
91+
return nil
92+
}
93+
94+
attrs := make(map[string]string, len(ev.Attributes)+2)
95+
for k, v := range ev.Attributes {
96+
if v == "" {
97+
continue
98+
}
99+
100+
attrs[k] = v
101+
}
102+
103+
if ev.ActorName != "" {
104+
attrs["name"] = ev.ActorName
105+
}
106+
107+
if ev.ActorFullID != "" {
108+
attrs["id"] = ev.ActorFullID
109+
}
110+
111+
if len(attrs) == 0 {
112+
return nil
113+
}
114+
115+
return attrs
116+
}

0 commit comments

Comments
 (0)