From 8e938e2a60d0b2e5a83cb8cf9b1adb541d194f42 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 10:27:16 +0200 Subject: [PATCH 01/21] first set of cmds for c9s runtime --- cmd/deploy.go | 4 + cmd/destroy.go | 4 + cmd/events.go | 11 + cmd/inspect.go | 7 + cmd/options.go | 4 +- cmd/redeploy.go | 4 + cmd/restart.go | 4 + cmd/root.go | 10 +- cmd/start.go | 4 + cmd/stop.go | 4 + core/clab.go | 67 +++- core/deploy.go | 4 + core/destroy.go | 4 + core/exec.go | 4 + core/file.go | 2 + core/labruntime.go | 264 +++++++++++++++ core/options_clab.go | 28 +- core/restart.go | 4 + core/save.go | 4 + core/start.go | 4 + core/stop.go | 4 + go.mod | 11 + go.sum | 56 ++++ labruntime/all/all.go | 7 + labruntime/clabernetes/clabernetes.go | 452 ++++++++++++++++++++++++++ labruntime/runtime.go | 96 ++++++ 26 files changed, 1050 insertions(+), 17 deletions(-) create mode 100644 core/labruntime.go create mode 100644 labruntime/all/all.go create mode 100644 labruntime/clabernetes/clabernetes.go create mode 100644 labruntime/runtime.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 706907e2a6..16f7f696b5 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -27,6 +27,10 @@ func deployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen Aliases: []string{"dep"}, SilenceUsage: true, PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cobraCmd *cobra.Command, _ []string) error { diff --git a/cmd/destroy.go b/cmd/destroy.go index 221d7ee69a..b4c3fcf428 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -20,6 +20,10 @@ func destroyCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/destroy/", Aliases: []string{"des"}, PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cobraCmd *cobra.Command, _ []string) error { diff --git a/cmd/events.go b/cmd/events.go index 5114558118..fcdb7eb58e 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -1,8 +1,11 @@ package cmd import ( + "fmt" + "github.com/spf13/cobra" clabevents "github.com/srl-labs/containerlab/core/events" + "github.com/srl-labs/containerlab/labruntime" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -14,6 +17,10 @@ func eventsCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/events/", Aliases: []string{"ev"}, PreRunE: func(*cobra.Command, []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cmd *cobra.Command, _ []string) error { @@ -61,6 +68,10 @@ containerlab events --format json` } func eventsFn(cmd *cobra.Command, o *Options) error { + if labruntime.IsLabRuntimeName(o.Global.Runtime) { + return fmt.Errorf("events is not supported for lab runtime %q yet", o.Global.Runtime) + } + opts := clabevents.Options{ Format: o.Events.Format, Runtime: o.Global.Runtime, diff --git a/cmd/inspect.go b/cmd/inspect.go index dad002626d..5730120cc8 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -165,6 +165,10 @@ func listContainers( c *clabcore.CLab, o *Options, ) ([]clabruntime.GenericContainer, error) { + if c.LabRuntime != nil { + return c.ListLabRuntimeContainers(ctx, o.Destroy.All) + } + var containers []clabruntime.GenericContainer var err error @@ -248,6 +252,9 @@ func getShortestTopologyPath(p string) (string, error) { if p == "" { return "", nil } + if strings.Contains(p, "://") { + return p, nil + } // get topo file path relative of the cwd cwd, err := os.Getwd() diff --git a/cmd/options.go b/cmd/options.go index 4e9685fd61..0143cf8cbe 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -7,6 +7,7 @@ import ( clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" ) @@ -279,7 +280,8 @@ func (o *GlobalOptions) toClabOptions() []clabcore.ClabOption { options = append(options, clabcore.WithTopologyName(o.TopologyName)) } - if o.TopologyFile == "" && o.TopologyName != "" { + if o.TopologyFile == "" && o.TopologyName != "" && + !labruntime.IsLabRuntimeName(o.Runtime) { options = append(options, clabcore.WithTopologyFromLab(o.TopologyName, o.VarsFiles)) } diff --git a/cmd/redeploy.go b/cmd/redeploy.go index 3223cdf318..bc5a0cda9b 100644 --- a/cmd/redeploy.go +++ b/cmd/redeploy.go @@ -13,6 +13,10 @@ func redeployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen "reference: https://containerlab.dev/cmd/redeploy/", Aliases: []string{"rdep"}, PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/restart.go b/cmd/restart.go index 643331359c..344fe019f0 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -13,6 +13,10 @@ func restartCmd(o *Options) (*cobra.Command, error) { Use: "restart", Short: "Restart one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/root.go b/cmd/root.go index 5001a6110a..badd2f108e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/log" "github.com/spf13/cobra" clabgit "github.com/srl-labs/containerlab/git" + "github.com/srl-labs/containerlab/labruntime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -155,8 +156,7 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { if err != nil { return err } - // Rootless operations only supported for Docker runtime - if o.Global.Runtime != "" && o.Global.Runtime != clabruntimedocker.RuntimeName { + if runtimeRequiresRoot(o.Global.Runtime) { err := clabutils.CheckAndGetRootPrivs() if err != nil { return err @@ -166,6 +166,12 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { return getTopoFilePath(cobraCmd, o) } +func runtimeRequiresRoot(name string) bool { + return name != "" && + name != clabruntimedocker.RuntimeName && + !labruntime.IsLabRuntimeName(name) +} + // getTopoFilePath finds *.clab.y*ml file in the current working directory // if the file was not specified. // If the topology file refers to a git repository, it will be cloned to the current directory. diff --git a/cmd/start.go b/cmd/start.go index d140f7b974..fa9d2da2d4 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,6 +13,10 @@ func startCmd(o *Options) (*cobra.Command, error) { Use: "start", Short: "Start one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/stop.go b/cmd/stop.go index b3d5ad7d38..08941c25c3 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -13,6 +13,10 @@ func stopCmd(o *Options) (*cobra.Command, error) { Use: "stop", Short: "Stop one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if !runtimeRequiresRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/core/clab.go b/core/clab.go index b064f9b482..f4097140f9 100644 --- a/core/clab.go +++ b/core/clab.go @@ -20,6 +20,8 @@ import ( clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" claberrors "github.com/srl-labs/containerlab/errors" clabexec "github.com/srl-labs/containerlab/exec" + "github.com/srl-labs/containerlab/labruntime" + _ "github.com/srl-labs/containerlab/labruntime/all" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" @@ -33,12 +35,13 @@ import ( var ErrNodeNotFound = errors.New("node not found") type CLab struct { - Config *Config `json:"config,omitempty"` - TopoPaths *clabtypes.TopoPaths - Nodes map[string]clabnodes.Node `json:"nodes,omitempty"` - Links map[int]clablinks.Link `json:"links,omitempty"` - Endpoints []clablinks.Endpoint - Runtimes map[string]clabruntime.ContainerRuntime `json:"runtimes,omitempty"` + Config *Config `json:"config,omitempty"` + TopoPaths *clabtypes.TopoPaths + Nodes map[string]clabnodes.Node `json:"nodes,omitempty"` + Links map[int]clablinks.Link `json:"links,omitempty"` + Endpoints []clablinks.Endpoint + Runtimes map[string]clabruntime.ContainerRuntime `json:"runtimes,omitempty"` + LabRuntime labruntime.LabRuntime `json:"-"` // reg is a registry of node kinds Reg *clabnodes.NodeRegistry Cert *clabcert.Cert @@ -63,6 +66,8 @@ type CLab struct { // to avoid repeated repository opens. Empty strings indicate not yet cached. gitBranch string gitHash string + + renderedTopology []byte } // NewContainerLab function defines a new container lab. @@ -94,7 +99,11 @@ func NewContainerLab(opts ...ClabOption) (*CLab, error) { var err error if c.TopoPaths.TopologyFileIsSet() { - err = c.parseTopology() + if c.LabRuntime != nil { + err = c.prepareLabRuntimeTopology() + } else { + err = c.parseTopology() + } } // Extract the host systems DNS servers and populate the @@ -110,23 +119,55 @@ func NewContainerLab(opts ...ClabOption) (*CLab, error) { // RuntimeInitializer returns a runtime initializer function for a provided runtime name. // Order of preference: cli flag -> env var -> default value of docker. func RuntimeInitializer(name string) (string, clabruntime.Initializer, error) { + name = resolveRuntimeName(name) + + runtimeInitializer, ok := clabruntime.ContainerRuntimes[name] + if !ok { + return name, nil, fmt.Errorf("unknown container runtime %q", name) + } + + return name, runtimeInitializer, nil +} + +func resolveRuntimeName(name string) string { envN := os.Getenv("CLAB_RUNTIME") log.Debugf("env runtime var value is %v", envN) switch { case name != "": + return name case envN != "": - name = envN + return envN default: - name = clabruntimedocker.RuntimeName + return clabruntimedocker.RuntimeName } +} - runtimeInitializer, ok := clabruntime.ContainerRuntimes[name] - if !ok { - return name, nil, fmt.Errorf("unknown container runtime %q", name) +func (c *CLab) prepareLabRuntimeTopology() error { + log.Info("Parsing & checking topology", "file", c.TopoPaths.TopologyFilenameBase()) + + if strings.Contains(c.Config.Name, gitBranchVar) || + strings.Contains(c.Config.Name, gitHashVar) { + r := c.magicTopoNameReplacer() + oldName := c.Config.Name + c.Config.Name = r.Replace(c.Config.Name) + log.Debugf( + "Topology name contains Git variables, substituted topology name: %q -> %q", + oldName, + c.Config.Name, + ) } - return name, runtimeInitializer, nil + if err := c.TopoPaths.SetLabDirByPrefix(c.Config.Name); err != nil { + return err + } + + if c.Config.Prefix == nil { + c.Config.Prefix = new(string) + *c.Config.Prefix = defaultPrefix + } + + return nil } // ProcessTopoPath takes a topology path, which might be the path to a directory or a file diff --git a/core/deploy.go b/core/deploy.go index e911580f78..63fc1819a8 100644 --- a/core/deploy.go +++ b/core/deploy.go @@ -25,6 +25,10 @@ func (c *CLab) Deploy( //nolint: funlen ctx context.Context, options *DeployOptions, ) ([]clabruntime.GenericContainer, error) { + if c.LabRuntime != nil { + return c.deployWithLabRuntime(ctx, options) + } + var err error err = c.ResolveLinks() diff --git a/core/destroy.go b/core/destroy.go index 1b7ac6dd30..a7d41f3b24 100644 --- a/core/destroy.go +++ b/core/destroy.go @@ -28,6 +28,10 @@ func (c *CLab) Destroy(ctx context.Context, options ...DestroyOption) (err error opt(opts) } + if c.LabRuntime != nil { + return c.destroyWithLabRuntime(ctx, opts) + } + var containers []clabruntime.GenericContainer switch { diff --git a/core/exec.go b/core/exec.go index 6874f5b0a9..7d677ab0eb 100644 --- a/core/exec.go +++ b/core/exec.go @@ -16,6 +16,10 @@ func (c *CLab) Exec( cmds []string, listOptions ...ListOption, ) (*clabexec.ExecCollection, error) { + if c.LabRuntime != nil { + return nil, c.unsupportedLabRuntimeOperation("exec") + } + err := clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) if err != nil { return nil, err diff --git a/core/file.go b/core/file.go index 8ec46fa55d..d2ca0f2bd3 100644 --- a/core/file.go +++ b/core/file.go @@ -88,6 +88,8 @@ func (c *CLab) LoadTopologyFromFile(topo string, varsFiles []string) error { return err } + c.renderedTopology = append(c.renderedTopology[:0], yamlFile...) + // save the rendered topology to disk if requested if ExportRenderedTopology != "" { if err := os.WriteFile(ExportRenderedTopology, yamlFile, 0644); err != nil { diff --git a/core/labruntime.go b/core/labruntime.go new file mode 100644 index 0000000000..4b3084182f --- /dev/null +++ b/core/labruntime.go @@ -0,0 +1,264 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "sort" + + "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" + "github.com/srl-labs/containerlab/labruntime" + clabruntime "github.com/srl-labs/containerlab/runtime" + "golang.org/x/term" +) + +func (c *CLab) deployWithLabRuntime( + ctx context.Context, + options *DeployOptions, +) ([]clabruntime.GenericContainer, error) { + if len(c.nodeFilter) != 0 { + return nil, fmt.Errorf("node-filter is not supported for lab runtime %q", + c.globalRuntimeName) + } + + if len(c.renderedTopology) == 0 { + return nil, fmt.Errorf("rendered topology is empty") + } + + if options != nil && options.reconfigure { + err := c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + Name: c.Config.Name, + Wait: true, + Timeout: c.timeout, + }) + if err != nil { + return nil, err + } + } + + state, err := c.LabRuntime.Deploy(ctx, labruntime.DeployRequest{ + Name: c.Config.Name, + TopologyDefinition: c.renderedTopology, + Wait: true, + Timeout: c.timeout, + }) + if err != nil { + return nil, err + } + + return c.containersFromLabState(state), nil +} + +func (c *CLab) destroyWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { + if opts.all { + return c.destroyAllWithLabRuntime(ctx, opts) + } + + if c.Config.Name == "" { + return fmt.Errorf("topology name is required") + } + + return c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + Name: c.Config.Name, + Wait: true, + Timeout: c.timeout, + }) +} + +func (c *CLab) destroyAllWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { + states, err := c.LabRuntime.List(ctx, labruntime.ListRequest{AllNamespaces: true}) + if err != nil { + return err + } + + if len(states) == 0 { + log.Info("no clabernetes topologies found") + return nil + } + + topos := make(map[string]string, len(states)) + for _, state := range states { + topos[state.TopologyPath] = "" + } + + if opts.terminalPrompt && term.IsTerminal(int(os.Stdin.Fd())) { + if err := cliPromptToDestroyAll(topos); err != nil { + return err + } + } + + var errs []error + for _, state := range states { + if err := c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + Name: state.Name, + Namespace: state.Namespace, + Wait: true, + Timeout: c.timeout, + }); err != nil { + errs = append(errs, err) + } + } + + if len(errs) != 0 { + return fmt.Errorf("error(s) occurred during clabernetes topology deletion: %w", + errors.Join(errs...)) + } + + return nil +} + +func (c *CLab) unsupportedLabRuntimeOperation(operation string) error { + return fmt.Errorf("%s is not supported for lab runtime %q yet", + operation, c.globalRuntimeName) +} + +func (c *CLab) ListLabRuntimeContainers( + ctx context.Context, + all bool, +) ([]clabruntime.GenericContainer, error) { + if all { + states, err := c.LabRuntime.List(ctx, labruntime.ListRequest{AllNamespaces: true}) + if err != nil { + return nil, err + } + + var containers []clabruntime.GenericContainer + for _, state := range states { + containers = append(containers, c.containersFromLabState(state)...) + } + + return containers, nil + } + + if c.Config.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + state, err := c.LabRuntime.Inspect(ctx, labruntime.InspectRequest{Name: c.Config.Name}) + if err != nil { + return nil, err + } + + return c.containersFromLabState(state), nil +} + +func (c *CLab) containersFromLabState(state *labruntime.LabState) []clabruntime.GenericContainer { + if state == nil { + return nil + } + + nodes := state.Nodes + if len(nodes) == 0 && c.Config.Topology != nil { + nodeNames := make([]string, 0, len(c.Config.Topology.Nodes)) + for nodeName := range c.Config.Topology.Nodes { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + nodes = make([]labruntime.NodeState, 0, len(nodeNames)) + for _, nodeName := range nodeNames { + nodes = append(nodes, labruntime.NodeState{ + Name: nodeName, + Kind: c.Config.Topology.GetNodeKind(nodeName), + Image: c.Config.Topology.GetNodeImage(nodeName), + State: state.State, + Ready: state.Ready, + }) + } + } + + containers := make([]clabruntime.GenericContainer, 0, len(nodes)) + for _, node := range nodes { + containers = append(containers, c.containerFromLabNode(state, node)) + } + + return containers +} + +func (c *CLab) containerFromLabNode( + state *labruntime.LabState, + node labruntime.NodeState, +) clabruntime.GenericContainer { + nodeName := fmt.Sprintf("%s-%s", state.Name, node.Name) + containerState := state.State + containerStatus := node.State + if node.Ready { + containerState = "running" + containerStatus = "healthy" + } + if containerState == "" { + containerState = node.State + } + + labels := map[string]string{ + clabconstants.Containerlab: state.Name, + clabconstants.NodeName: node.Name, + clabconstants.TopoFile: labRuntimeTopologyPath(c, state), + } + + var image string + if c.Config.Topology != nil { + labels[clabconstants.NodeKind] = c.Config.Topology.GetNodeKind(node.Name) + image = c.Config.Topology.GetNodeImage(node.Name) + + if group := c.Config.Topology.GetNodeGroup(node.Name); group != "" { + labels[clabconstants.NodeGroup] = group + } + } + if node.Kind != "" { + labels[clabconstants.NodeKind] = node.Kind + } + if node.Image != "" { + image = node.Image + } + + if c.customOwner != "" { + labels[clabconstants.Owner] = c.customOwner + } + + return clabruntime.GenericContainer{ + Names: []string{nodeName}, + ID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), + ShortID: "c9s", + Image: image, + State: containerState, + Status: containerStatus, + Labels: labels, + NetworkName: state.Namespace, + NetworkSettings: managementAddress(node.LoadBalancerAddress), + } +} + +func managementAddress(addr string) clabruntime.GenericMgmtIPs { + ip := net.ParseIP(addr) + if ip == nil { + return clabruntime.GenericMgmtIPs{} + } + + if ip.To4() != nil { + return clabruntime.GenericMgmtIPs{ + IPv4addr: addr, + IPv4pLen: 32, + } + } + + return clabruntime.GenericMgmtIPs{ + IPv6addr: addr, + IPv6pLen: 128, + } +} + +func labRuntimeTopologyPath(c *CLab, state *labruntime.LabState) string { + if c.TopoPaths.TopologyFileIsSet() { + return c.TopoPaths.TopologyFilenameAbsPath() + } + + if state.TopologyPath != "" { + return state.TopologyPath + } + + return fmt.Sprintf("k8s://%s/topologies/%s", state.Namespace, state.Name) +} diff --git a/core/options_clab.go b/core/options_clab.go index 7cd8fd3c08..409ba68006 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" + "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -127,11 +128,28 @@ func WithDebug(debug bool) ClabOption { // WithRuntime option sets a container runtime to be used by containerlab. func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { return func(c *CLab) error { + name = resolveRuntimeName(name) + + if labruntime.IsLabRuntimeName(name) { + c.globalRuntimeName = name + + lr, err := labruntime.Init(name, labruntime.Config{ + Debug: rtconfig != nil && rtconfig.Debug, + Timeout: runtimeTimeout(rtconfig), + }) + if err != nil { + return fmt.Errorf("failed to init the lab runtime: %w", err) + } + + c.LabRuntime = lr + + return nil + } + name, rInit, err := RuntimeInitializer(name) if err != nil { return err } - c.globalRuntimeName = name r := rInit() @@ -154,6 +172,14 @@ func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { } } +func runtimeTimeout(rtconfig *clabruntime.RuntimeConfig) time.Duration { + if rtconfig == nil { + return 0 + } + + return rtconfig.Timeout +} + func WithKeepMgmtNet() ClabOption { return func(c *CLab) error { c.globalRuntime().WithKeepMgmtNet() diff --git a/core/restart.go b/core/restart.go index e752dbd1e3..60e134b73d 100644 --- a/core/restart.go +++ b/core/restart.go @@ -6,6 +6,10 @@ import ( // RestartNodes performs stop+start for each node, restoring parked interfaces. func (c *CLab) RestartNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.unsupportedLabRuntimeOperation("restart") + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/save.go b/core/save.go index 1b2dfce585..a231115d22 100644 --- a/core/save.go +++ b/core/save.go @@ -20,6 +20,10 @@ func (c *CLab) Save( ctx context.Context, options ...SaveOption, ) error { + if c.LabRuntime != nil { + return c.unsupportedLabRuntimeOperation("save") + } + opts := NewSaveOptions() for _, opt := range options { opt(opts) diff --git a/core/start.go b/core/start.go index ff16ec21ed..298591e396 100644 --- a/core/start.go +++ b/core/start.go @@ -7,6 +7,10 @@ import ( // StartNodes starts one or more stopped nodes and restores their parked interfaces back into the // container network namespace. func (c *CLab) StartNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.unsupportedLabRuntimeOperation("start") + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/stop.go b/core/stop.go index 0b51db8178..42c56340e2 100644 --- a/core/stop.go +++ b/core/stop.go @@ -7,6 +7,10 @@ import ( // StopNodes stops one or more deployed nodes without losing their dataplane links by parking // the node's interfaces in a dedicated network namespace before stopping the container. func (c *CLab) StopNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.unsupportedLabRuntimeOperation("stop") + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/go.mod b/go.mod index 9a991d8804..5d98900b19 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( golang.org/x/sys v0.41.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v2 v2.4.0 + k8s.io/client-go v0.34.3 sigs.k8s.io/kind v0.31.0 ) @@ -81,10 +82,12 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/fs v0.1.0 // indirect @@ -106,6 +109,7 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/cgroups v0.0.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -130,6 +134,7 @@ require ( github.com/tinylib/msgp v1.6.1 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/vbauerster/mpb/v8 v8.10.2 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect @@ -138,8 +143,14 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect + golang.org/x/oauth2 v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index c3fcc99920..71a3b27113 100644 --- a/go.sum +++ b/go.sum @@ -154,6 +154,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= @@ -169,6 +171,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -190,6 +194,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -200,10 +210,14 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -260,6 +274,8 @@ github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= @@ -279,6 +295,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -305,6 +323,8 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr7f2i0= github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -485,6 +505,8 @@ github.com/steiler/acls v0.1.5 h1:BjnpIqK3TIov+fq6fK80SXBrd/oDMSEgOpVLllAB78A= github.com/steiler/acls v0.1.5/go.mod h1:lFfnRSiSCWLgKiuxu7PFgaUPVanCn7o6m4dHe/cz128= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -515,6 +537,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= @@ -525,6 +549,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= @@ -561,6 +587,7 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -575,6 +602,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -588,7 +617,9 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -606,7 +637,11 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -687,6 +722,8 @@ golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -694,6 +731,7 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= @@ -710,6 +748,10 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -723,12 +765,26 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc= diff --git a/labruntime/all/all.go b/labruntime/all/all.go new file mode 100644 index 0000000000..456705145f --- /dev/null +++ b/labruntime/all/all.go @@ -0,0 +1,7 @@ +// Copyright 2020 Nokia +// Licensed under the BSD 3-Clause License. +// SPDX-License-Identifier: BSD-3-Clause + +package all + +import _ "github.com/srl-labs/containerlab/labruntime/clabernetes" diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go new file mode 100644 index 0000000000..96c9661157 --- /dev/null +++ b/labruntime/clabernetes/clabernetes.go @@ -0,0 +1,452 @@ +package clabernetes + +import ( + "context" + "fmt" + "net" + "os" + "sort" + "time" + + "github.com/charmbracelet/log" + "github.com/srl-labs/containerlab/labruntime" + "gopkg.in/yaml.v2" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + defaultNamespace = "default" + pollInterval = 2 * time.Second + + envKubeconfig = "CLAB_KUBECONFIG" + envContext = "CLAB_KUBE_CONTEXT" + envNamespace = "CLAB_KUBE_NAMESPACE" +) + +var topologyGVR = schema.GroupVersionResource{ + Group: "clabernetes.containerlab.dev", + Version: "v1alpha1", + Resource: "topologies", +} + +type Runtime struct { + client dynamic.Interface + namespace string + timeout time.Duration +} + +func init() { + labruntime.Register(labruntime.ClabernetesRuntimeName, New) +} + +func New(cfg labruntime.Config) (labruntime.LabRuntime, error) { + kubeConfig, namespace, err := kubeClientConfig() + if err != nil { + return nil, err + } + + client, err := dynamic.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes dynamic client: %w", err) + } + + if namespace == "" { + namespace = defaultNamespace + } + + return &Runtime{ + client: client, + namespace: namespace, + timeout: cfg.Timeout, + }, nil +} + +func (r *Runtime) Capabilities() labruntime.RuntimeCapabilities { + return labruntime.RuntimeCapabilities{ + Deploy: true, + Destroy: true, + Inspect: true, + List: true, + } +} + +func (r *Runtime) Deploy( + ctx context.Context, + req labruntime.DeployRequest, +) (*labruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + if len(req.TopologyDefinition) == 0 { + return nil, fmt.Errorf("rendered containerlab topology is required") + } + + namespace := r.namespaceFor(req.Namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + desired := topologyObject(req.Name, namespace, string(req.TopologyDefinition)) + + existing, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) + _, err = resource.Create(ctx, desired, metav1.CreateOptions{}) + case err != nil: + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + default: + log.Info("Updating clabernetes topology", "name", req.Name, "namespace", namespace) + desired.SetResourceVersion(existing.GetResourceVersion()) + _, err = resource.Update(ctx, desired, metav1.UpdateOptions{}) + } + if err != nil { + return nil, fmt.Errorf("failed to apply clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if !req.Wait { + return r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + } + + if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + return nil, err + } + + return r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) +} + +func (r *Runtime) Destroy(ctx context.Context, req labruntime.DestroyRequest) error { + if req.Name == "" { + return fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + log.Info("Deleting clabernetes topology", "name", req.Name, "namespace", namespace) + + err := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + log.Info("clabernetes topology not found", "name", req.Name, "namespace", namespace) + return nil + } + if err != nil { + return fmt.Errorf("failed to delete clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if !req.Wait { + return nil + } + + return r.waitDeleted(ctx, req.Name, namespace, req.Timeout) +} + +func (r *Runtime) Inspect( + ctx context.Context, + req labruntime.InspectRequest, +) (*labruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + obj, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to inspect clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + return stateFromTopology(obj, namespace), nil +} + +func (r *Runtime) List( + ctx context.Context, + req labruntime.ListRequest, +) ([]*labruntime.LabState, error) { + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + list, err := r.client.Resource(topologyGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) + } + + states := make([]*labruntime.LabState, 0, len(list.Items)) + for idx := range list.Items { + states = append(states, stateFromTopology(&list.Items[idx], namespace)) + } + + sort.Slice(states, func(i, j int) bool { + if states[i].Namespace == states[j].Namespace { + return states[i].Name < states[j].Name + } + return states[i].Namespace < states[j].Namespace + }) + + return states, nil +} + +func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + } + + state := stateFromTopology(obj, namespace) + if state.Ready { + return true, nil + } + if state.State == "deployfailed" { + return false, fmt.Errorf("clabernetes topology %s/%s reported deployfailed", + namespace, name) + } + + log.Debug("Waiting for clabernetes topology", + "name", name, + "namespace", namespace, + "state", state.State, + ) + + return false, nil + }) +} + +func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + _, err := resource.Get(ctx, name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + return true, nil + case err != nil: + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + default: + return false, nil + } + }) +} + +func (r *Runtime) namespaceFor(namespace string) string { + if namespace != "" { + return namespace + } + if r.namespace != "" { + return r.namespace + } + return defaultNamespace +} + +func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { + if timeout > 0 { + return timeout + } + if r.timeout > 0 { + return r.timeout + } + return 10 * time.Minute +} + +func topologyObject(name, namespace, definition string) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "clabernetes.containerlab.dev/v1alpha1", + "kind": "Topology", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + "labels": map[string]any{ + "containerlab.dev/runtime": labruntime.ClabernetesRuntimeName, + }, + }, + "spec": map[string]any{ + "definition": map[string]any{ + "containerlab": definition, + }, + }, + }, + } +} + +func kubeClientConfig() (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig := os.Getenv(envKubeconfig); kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + + overrides := &clientcmd.ConfigOverrides{} + if contextName := os.Getenv(envContext); contextName != "" { + overrides.CurrentContext = contextName + } + if namespace := os.Getenv(envNamespace); namespace != "" { + overrides.Context.Namespace = namespace + } + + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + overrides, + ) + + namespace, _, err := clientConfig.Namespace() + if err != nil { + namespace = defaultNamespace + } + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, "", fmt.Errorf("failed to load Kubernetes client config: %w", err) + } + + return restConfig, namespace, nil +} + +func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labruntime.LabState { + if obj.GetNamespace() != "" { + namespace = obj.GetNamespace() + } + + ready, _, _ := unstructured.NestedBool(obj.Object, "status", "topologyReady") + state, _, _ := unstructured.NestedString(obj.Object, "status", "topologyState") + nodeReadiness, _, _ := unstructured.NestedStringMap( + obj.Object, + "status", + "nodeReadiness", + ) + exposedPorts, _, _ := unstructured.NestedMap(obj.Object, "status", "exposedPorts") + nodeSpecs := nodeSpecsFromTopology(obj) + + nodeNames := make([]string, 0, len(nodeSpecs)+len(nodeReadiness)) + seenNodes := map[string]struct{}{} + for nodeName := range nodeSpecs { + nodeNames = append(nodeNames, nodeName) + seenNodes[nodeName] = struct{}{} + } + for nodeName := range nodeReadiness { + if _, ok := seenNodes[nodeName]; ok { + continue + } + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + nodes := make([]labruntime.NodeState, 0, len(nodeNames)) + for _, nodeName := range nodeNames { + nodeState := nodeReadiness[nodeName] + spec := nodeSpecs[nodeName] + nodes = append(nodes, labruntime.NodeState{ + Name: nodeName, + Kind: spec.Kind, + Image: spec.Image, + State: nodeState, + Ready: nodeState == "ready", + LoadBalancerAddress: loadBalancerAddress(exposedPorts, nodeName), + }) + } + + return &labruntime.LabState{ + Name: obj.GetName(), + Namespace: namespace, + TopologyPath: fmt.Sprintf("k8s://%s/topologies/%s", namespace, obj.GetName()), + State: state, + Ready: ready, + Nodes: nodes, + } +} + +type nodeSpec struct { + Kind string `yaml:"kind"` + Image string `yaml:"image"` +} + +type containerlabDefinition struct { + Topology struct { + Nodes map[string]nodeSpec `yaml:"nodes"` + } `yaml:"topology"` +} + +func nodeSpecsFromTopology(obj *unstructured.Unstructured) map[string]nodeSpec { + specs := map[string]nodeSpec{} + + statusConfigs, _, _ := unstructured.NestedStringMap(obj.Object, "status", "configs") + for _, config := range statusConfigs { + mergeNodeSpecs(specs, config) + } + + if len(specs) != 0 { + return specs + } + + definition, _, _ := unstructured.NestedString( + obj.Object, + "spec", + "definition", + "containerlab", + ) + mergeNodeSpecs(specs, definition) + + return specs +} + +func mergeNodeSpecs(specs map[string]nodeSpec, definition string) { + if definition == "" { + return + } + + var parsed containerlabDefinition + if err := yaml.Unmarshal([]byte(definition), &parsed); err != nil { + log.Debug("failed to parse clabernetes topology definition", "error", err) + return + } + + for nodeName, spec := range parsed.Topology.Nodes { + specs[nodeName] = spec + } +} + +func loadBalancerAddress(exposedPorts map[string]any, nodeName string) string { + raw, ok := exposedPorts[nodeName] + if !ok { + return "" + } + + nodeExpose, ok := raw.(map[string]any) + if !ok { + return "" + } + + addr, ok := nodeExpose["loadBalancerAddress"].(string) + if !ok { + return "" + } + + if net.ParseIP(addr) == nil { + return "" + } + + return addr +} diff --git a/labruntime/runtime.go b/labruntime/runtime.go new file mode 100644 index 0000000000..3cadf7ab59 --- /dev/null +++ b/labruntime/runtime.go @@ -0,0 +1,96 @@ +package labruntime + +import ( + "context" + "fmt" + "time" +) + +const ( + ClabernetesRuntimeName = "clabernetes" +) + +type Config struct { + Debug bool + Timeout time.Duration +} + +type DeployRequest struct { + Name string + Namespace string + TopologyDefinition []byte + Wait bool + Timeout time.Duration +} + +type DestroyRequest struct { + Name string + Namespace string + Wait bool + Timeout time.Duration +} + +type InspectRequest struct { + Name string + Namespace string +} + +type ListRequest struct { + Namespace string + AllNamespaces bool +} + +type RuntimeCapabilities struct { + Deploy bool + Destroy bool + Inspect bool + List bool +} + +type NodeState struct { + Name string + Kind string + Image string + State string + Ready bool + LoadBalancerAddress string +} + +type LabState struct { + Name string + Namespace string + TopologyPath string + State string + Ready bool + Nodes []NodeState +} + +type LabRuntime interface { + Deploy(context.Context, DeployRequest) (*LabState, error) + Destroy(context.Context, DestroyRequest) error + Inspect(context.Context, InspectRequest) (*LabState, error) + List(context.Context, ListRequest) ([]*LabState, error) + Capabilities() RuntimeCapabilities +} + +type Initializer func(Config) (LabRuntime, error) + +var LabRuntimes = map[string]Initializer{} + +func Register(name string, init Initializer) { + LabRuntimes[name] = init +} + +func IsLabRuntimeName(name string) bool { + _, ok := LabRuntimes[name] + return ok +} + +func Init(name string, cfg Config) (LabRuntime, error) { + init, ok := LabRuntimes[name] + if !ok { + return nil, fmt.Errorf("unknown lab runtime %q", name) + } + + return init(cfg) +} From dd7b06d0fcc6d37dc88aaa3c3fdd91e878fb0491 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 10:58:24 +0200 Subject: [PATCH 02/21] complete most relevant commands --- cmd/events.go | 7 - core/clab.go | 5 + core/events/stream.go | 57 ++ core/exec.go | 2 +- core/labruntime.go | 275 +++++- core/restart.go | 2 +- core/save.go | 14 +- core/start.go | 2 +- core/stop.go | 2 +- go.mod | 13 + go.sum | 10 + labruntime/clabernetes/clabernetes.go | 1163 ++++++++++++++++++++++++- labruntime/runtime.go | 65 ++ 13 files changed, 1586 insertions(+), 31 deletions(-) diff --git a/cmd/events.go b/cmd/events.go index fcdb7eb58e..e6842bd5c1 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -1,11 +1,8 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" clabevents "github.com/srl-labs/containerlab/core/events" - "github.com/srl-labs/containerlab/labruntime" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -68,10 +65,6 @@ containerlab events --format json` } func eventsFn(cmd *cobra.Command, o *Options) error { - if labruntime.IsLabRuntimeName(o.Global.Runtime) { - return fmt.Errorf("events is not supported for lab runtime %q yet", o.Global.Runtime) - } - opts := clabevents.Options{ Format: o.Events.Format, Runtime: o.Global.Runtime, diff --git a/core/clab.go b/core/clab.go index f4097140f9..62ff3db257 100644 --- a/core/clab.go +++ b/core/clab.go @@ -225,6 +225,11 @@ func (c *CLab) filterClabNodes(nodeFilter []string) error { c.nodeFilter = nodeFilter + if c.LabRuntime != nil { + log.Infof("Applying node filter: %q", nodeFilter) + return nil + } + // ensure that the node filter is a subset of the nodes in the topology for _, n := range nodeFilter { if _, ok := c.Config.Topology.Nodes[n]; !ok { diff --git a/core/events/stream.go b/core/events/stream.go index fd7082207f..21af37ef1e 100644 --- a/core/events/stream.go +++ b/core/events/stream.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -27,6 +28,10 @@ func Stream(ctx context.Context, opts Options) error { return err } + if clab.LabRuntime != nil { + return streamLabRuntimeEvents(ctx, clab, opts) + } + runtime, ok := clab.Runtimes[opts.Runtime] if !ok { return fmt.Errorf("runtime %q is not initialized", opts.Runtime) @@ -102,6 +107,58 @@ func Stream(ctx context.Context, opts Options) error { } } +func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Options) error { + printer, err := newFormatter(opts.Format, opts.writer()) + if err != nil { + return err + } + + runtimeEvents, runtimeErrs, err := clab.LabRuntime.StreamEvents( + ctx, + labruntime.EventStreamRequest{ + AllNamespaces: true, + IncludeInitialState: opts.IncludeInitialState, + IncludeInterfaceStats: opts.IncludeInterfaceStats, + StatsInterval: opts.StatsInterval, + }, + ) + if err != nil { + return fmt.Errorf("failed to stream events for lab runtime %q: %w", opts.Runtime, err) + } + + for { + select { + case ev := <-runtimeEvents: + if err := printer(aggregatedEventFromLabRuntimeEvent(ev)); err != nil { + log.Debugf("failed to write event: %v", err) + } + case err := <-runtimeErrs: + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + case <-ctx.Done(): + return nil + } + } +} + +func aggregatedEventFromLabRuntimeEvent(ev labruntime.Event) aggregatedEvent { + ts := ev.Timestamp + if ts.IsZero() { + ts = time.Now() + } + + return aggregatedEvent{ + Timestamp: ts, + Type: ev.Type, + Action: ev.Action, + ActorID: ev.ActorID, + ActorName: ev.ActorName, + ActorFullID: ev.ActorFullID, + Attributes: cloneStringMap(ev.Attributes), + } +} + func forwardRuntimeEvents( ctx context.Context, runtime clabruntime.ContainerRuntime, diff --git a/core/exec.go b/core/exec.go index 7d677ab0eb..3346be4fa8 100644 --- a/core/exec.go +++ b/core/exec.go @@ -17,7 +17,7 @@ func (c *CLab) Exec( listOptions ...ListOption, ) (*clabexec.ExecCollection, error) { if c.LabRuntime != nil { - return nil, c.unsupportedLabRuntimeOperation("exec") + return c.execWithLabRuntime(ctx, cmds, listOptions...) } err := clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) diff --git a/core/labruntime.go b/core/labruntime.go index 4b3084182f..d280df431a 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -6,12 +6,16 @@ import ( "fmt" "net" "os" + "path/filepath" "sort" + "strings" "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" + clabexec "github.com/srl-labs/containerlab/exec" "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" + clabtypes "github.com/srl-labs/containerlab/types" "golang.org/x/term" ) @@ -118,8 +122,14 @@ func (c *CLab) unsupportedLabRuntimeOperation(operation string) error { func (c *CLab) ListLabRuntimeContainers( ctx context.Context, all bool, + options ...ListOption, ) ([]clabruntime.GenericContainer, error) { - if all { + opts := NewListOptions() + for _, opt := range options { + opt(opts) + } + + if all || c.Config.Name == "" { states, err := c.LabRuntime.List(ctx, labruntime.ListRequest{AllNamespaces: true}) if err != nil { return nil, err @@ -130,7 +140,7 @@ func (c *CLab) ListLabRuntimeContainers( containers = append(containers, c.containersFromLabState(state)...) } - return containers, nil + return filterLabRuntimeContainers(containers, opts.ToFilters()), nil } if c.Config.Name == "" { @@ -142,7 +152,105 @@ func (c *CLab) ListLabRuntimeContainers( return nil, err } - return c.containersFromLabState(state), nil + return filterLabRuntimeContainers(c.containersFromLabState(state), opts.ToFilters()), nil +} + +func (c *CLab) execWithLabRuntime( + ctx context.Context, + cmds []string, + listOptions ...ListOption, +) (*clabexec.ExecCollection, error) { + containers, err := c.ListLabRuntimeContainers(ctx, false, listOptions...) + if err != nil { + return nil, err + } + if len(containers) == 0 { + return nil, fmt.Errorf("filter did not match any containers") + } + + var execCmds []*clabexec.ExecCmd + for _, execCmdStr := range cmds { + execCmd, err := clabexec.NewExecCmdFromString(execCmdStr) + if err != nil { + return nil, err + } + execCmds = append(execCmds, execCmd) + } + + resultCollection := clabexec.NewExecCollection() + for idx := range containers { + namespace, labName, nodeName, err := labRuntimeContainerParts(containers[idx]) + if err != nil { + log.Warnf("exec target %s is invalid: %v", containers[idx].Names[0], err) + continue + } + + for _, execCmd := range execCmds { + result, err := c.LabRuntime.Exec(ctx, labruntime.ExecRequest{ + Name: labName, + Namespace: namespace, + NodeName: nodeName, + Command: execCmd.GetCmd(), + }) + if err != nil { + log.Warnf("exec on %s failed: %v", containers[idx].Names[0], err) + continue + } + + resultCollection.Add(containers[idx].Names[0], result) + } + } + + return resultCollection, nil +} + +func (c *CLab) startNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Start(ctx, labruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) stopNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Stop(ctx, labruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) restartNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Restart(ctx, labruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) saveWithLabRuntime(ctx context.Context, opts *SaveOptions) error { + if opts.copyDst != "" { + resolvedDst, err := c.resolveCopyOutDst(opts.copyDst) + if err != nil { + return err + } + opts.copyDst = resolvedDst + } + + result, err := c.LabRuntime.Save(ctx, labruntime.SaveRequest{ + Name: c.Config.Name, + Nodes: c.nodeFilter, + Copy: opts.copyDst != "", + }) + if err != nil { + return err + } + + if opts.copyDst == "" || result == nil { + return nil + } + + return c.copyLabRuntimeSavedFiles(result, opts.copyDst) } func (c *CLab) containersFromLabState(state *labruntime.LabState) []clabruntime.GenericContainer { @@ -183,19 +291,20 @@ func (c *CLab) containerFromLabNode( node labruntime.NodeState, ) clabruntime.GenericContainer { nodeName := fmt.Sprintf("%s-%s", state.Name, node.Name) - containerState := state.State + containerState := node.State containerStatus := node.State + if containerState == "" { + containerState = state.State + } if node.Ready { containerState = "running" containerStatus = "healthy" } - if containerState == "" { - containerState = node.State - } labels := map[string]string{ clabconstants.Containerlab: state.Name, clabconstants.NodeName: node.Name, + clabconstants.LongName: nodeName, clabconstants.TopoFile: labRuntimeTopologyPath(c, state), } @@ -262,3 +371,155 @@ func labRuntimeTopologyPath(c *CLab, state *labruntime.LabState) string { return fmt.Sprintf("k8s://%s/topologies/%s", state.Namespace, state.Name) } + +func labRuntimeContainerParts(c clabruntime.GenericContainer) (string, string, string, error) { + parts := strings.Split(c.ID, "/") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("expected namespace/lab/node id, got %q", c.ID) + } + + return parts[0], parts[1], parts[2], nil +} + +func filterLabRuntimeContainers( + containers []clabruntime.GenericContainer, + filters []*clabtypes.GenericFilter, +) []clabruntime.GenericContainer { + if len(filters) == 0 { + return containers + } + + filtered := make([]clabruntime.GenericContainer, 0, len(containers)) + for _, container := range containers { + if labRuntimeContainerMatches(container, filters) { + filtered = append(filtered, container) + } + } + + return filtered +} + +func labRuntimeContainerMatches( + container clabruntime.GenericContainer, + filters []*clabtypes.GenericFilter, +) bool { + for _, filter := range filters { + switch filter.FilterType { + case "name": + if !labRuntimeNameMatches(container.Names, filter.Match) { + return false + } + case "id": + if container.ID != filter.Match && !strings.HasPrefix(container.ID, filter.Match) { + return false + } + case "label": + if !labRuntimeLabelMatches(container.Labels, filter) { + return false + } + } + } + + return true +} + +func labRuntimeNameMatches(names []string, match string) bool { + for _, name := range names { + if name == match || strings.Contains(name, match) { + return true + } + } + + return false +} + +func labRuntimeLabelMatches(labels map[string]string, filter *clabtypes.GenericFilter) bool { + value, ok := labels[filter.Field] + + switch filter.Operator { + case "exists": + return ok + case "!=": + return !ok || value != filter.Match + default: + return ok && value == filter.Match + } +} + +func (c *CLab) copyLabRuntimeSavedFiles( + result *labruntime.SaveResult, + dstRoot string, +) error { + for _, file := range result.Files { + if file.NodeName == "" || file.Name == "" { + continue + } + + relPath, ok := cleanLabRuntimeSavedPath(file.Name) + if !ok { + return fmt.Errorf("refusing to copy unsafe saved config path %q", file.Name) + } + + nodeDstDir := filepath.Join(dstRoot, file.NodeName) + dstPath := filepath.Join(nodeDstDir, relPath) + if err := os.MkdirAll(filepath.Dir(dstPath), clabconstants.PermissionsDirDefault); err != nil { + return fmt.Errorf("failed to create save dst directory %q: %w", + filepath.Dir(dstPath), err) + } + + if file.LinkTarget != "" { + linkTarget, ok := cleanLabRuntimeSavedLinkTarget(file.LinkTarget) + if !ok { + return fmt.Errorf("refusing to create unsafe saved config symlink %q -> %q", + file.Name, file.LinkTarget) + } + + _ = os.Remove(dstPath) + if err := os.Symlink(linkTarget, dstPath); err != nil { + return fmt.Errorf("failed to create symlink %q -> %q: %w", + dstPath, linkTarget, err) + } + + continue + } + + mode := os.FileMode(file.Mode) + if mode == 0 { + mode = clabconstants.PermissionsFileDefault + } + + if err := os.WriteFile(dstPath, file.Data, mode); err != nil { + return fmt.Errorf("failed to write saved config %q: %w", dstPath, err) + } + + log.Info( + "copied saved config", + "node", file.NodeName, + "dst", dstPath, + ) + } + + return nil +} + +func cleanLabRuntimeSavedPath(name string) (string, bool) { + cleaned := filepath.Clean(name) + if cleaned == "." || filepath.IsAbs(cleaned) || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || + cleaned == ".." { + return "", false + } + + return cleaned, true +} + +func cleanLabRuntimeSavedLinkTarget(target string) (string, bool) { + cleaned := filepath.Clean(target) + if filepath.IsAbs(cleaned) || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || + cleaned == ".." { + return "", false + } + + return cleaned, true +} diff --git a/core/restart.go b/core/restart.go index 60e134b73d..273c4acf0f 100644 --- a/core/restart.go +++ b/core/restart.go @@ -7,7 +7,7 @@ import ( // RestartNodes performs stop+start for each node, restoring parked interfaces. func (c *CLab) RestartNodes(ctx context.Context, nodeNames []string) error { if c.LabRuntime != nil { - return c.unsupportedLabRuntimeOperation("restart") + return c.restartNodesWithLabRuntime(ctx, nodeNames) } if err := c.ResolveLinks(); err != nil { diff --git a/core/save.go b/core/save.go index a231115d22..4b7876114d 100644 --- a/core/save.go +++ b/core/save.go @@ -20,15 +20,15 @@ func (c *CLab) Save( ctx context.Context, options ...SaveOption, ) error { - if c.LabRuntime != nil { - return c.unsupportedLabRuntimeOperation("save") - } - opts := NewSaveOptions() for _, opt := range options { opt(opts) } + if c.LabRuntime != nil { + return c.saveWithLabRuntime(ctx, opts) + } + err := clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) if err != nil { return err @@ -84,7 +84,11 @@ func (c *CLab) resolveCopyOutDst(dst string) (string, error) { labDir := c.TopoPaths.TopologyLabDir() labDirName := filepath.Base(labDir) if labDirName == "" || labDirName == "." { - return "", fmt.Errorf("failed to resolve save dst: lab directory is empty") + if c.Config != nil && c.Config.Name != "" { + labDirName = "clab-" + c.Config.Name + } else { + return "", fmt.Errorf("failed to resolve save dst: lab directory is empty") + } } dstLabDir := filepath.Join(resolvedDst, labDirName) diff --git a/core/start.go b/core/start.go index 298591e396..89ac3779db 100644 --- a/core/start.go +++ b/core/start.go @@ -8,7 +8,7 @@ import ( // container network namespace. func (c *CLab) StartNodes(ctx context.Context, nodeNames []string) error { if c.LabRuntime != nil { - return c.unsupportedLabRuntimeOperation("start") + return c.startNodesWithLabRuntime(ctx, nodeNames) } if err := c.ResolveLinks(); err != nil { diff --git a/core/stop.go b/core/stop.go index 42c56340e2..79b7e5c39a 100644 --- a/core/stop.go +++ b/core/stop.go @@ -8,7 +8,7 @@ import ( // the node's interfaces in a dedicated network namespace before stopping the container. func (c *CLab) StopNodes(ctx context.Context, nodeNames []string) error { if c.LabRuntime != nil { - return c.unsupportedLabRuntimeOperation("stop") + return c.stopNodesWithLabRuntime(ctx, nodeNames) } if err := c.ResolveLinks(); err != nil { diff --git a/go.mod b/go.mod index 5d98900b19..0e15e5c025 100644 --- a/go.mod +++ b/go.mod @@ -51,6 +51,7 @@ require ( golang.org/x/sys v0.41.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.34.3 k8s.io/client-go v0.34.3 sigs.k8s.io/kind v0.31.0 ) @@ -80,25 +81,34 @@ require ( github.com/containerd/platforms v1.0.0-rc.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect github.com/mistifyio/go-zfs/v3 v3.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect @@ -110,6 +120,7 @@ require ( github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/cgroups v0.0.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -146,8 +157,10 @@ require ( golang.org/x/oauth2 v0.32.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/go.sum b/go.sum index 71a3b27113..9605713184 100644 --- a/go.sum +++ b/go.sum @@ -194,10 +194,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -248,6 +250,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -364,6 +368,8 @@ github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1 github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -400,6 +406,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE= @@ -505,6 +513,7 @@ github.com/steiler/acls v0.1.5 h1:BjnpIqK3TIov+fq6fK80SXBrd/oDMSEgOpVLllAB78A= github.com/steiler/acls v0.1.5/go.mod h1:lFfnRSiSCWLgKiuxu7PFgaUPVanCn7o6m4dHe/cz128= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -513,6 +522,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 96c9661157..584b2f7008 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -1,24 +1,40 @@ package clabernetes import ( + "archive/tar" + "bytes" "context" + "errors" "fmt" + "io" "net" "os" + "path" "sort" + "strconv" + "strings" "time" "github.com/charmbracelet/log" + clabexec "github.com/srl-labs/containerlab/exec" "github.com/srl-labs/containerlab/labruntime" "gopkg.in/yaml.v2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/remotecommand" + kubeexec "k8s.io/client-go/util/exec" ) const ( @@ -28,6 +44,13 @@ const ( envKubeconfig = "CLAB_KUBECONFIG" envContext = "CLAB_KUBE_CONTEXT" envNamespace = "CLAB_KUBE_NAMESPACE" + + labelApp = "clabernetes/app" + labelTopologyOwner = "clabernetes/topologyOwner" + labelTopologyNode = "clabernetes/topologyNode" + labelIgnoreReconcile = "clabernetes/ignoreReconcile" + clabernetesAppValue = "clabernetes" + restartedAtAnnotation = "kubectl.kubernetes.io/restartedAt" ) var topologyGVR = schema.GroupVersionResource{ @@ -37,9 +60,11 @@ var topologyGVR = schema.GroupVersionResource{ } type Runtime struct { - client dynamic.Interface - namespace string - timeout time.Duration + client dynamic.Interface + kubeClient kubernetes.Interface + restConfig *rest.Config + namespace string + timeout time.Duration } func init() { @@ -57,14 +82,21 @@ func New(cfg labruntime.Config) (labruntime.LabRuntime, error) { return nil, fmt.Errorf("failed to create Kubernetes dynamic client: %w", err) } + kubeClient, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + if namespace == "" { namespace = defaultNamespace } return &Runtime{ - client: client, - namespace: namespace, - timeout: cfg.Timeout, + client: client, + kubeClient: kubeClient, + restConfig: kubeConfig, + namespace: namespace, + timeout: cfg.Timeout, }, nil } @@ -74,6 +106,12 @@ func (r *Runtime) Capabilities() labruntime.RuntimeCapabilities { Destroy: true, Inspect: true, List: true, + Exec: true, + Start: true, + Stop: true, + Restart: true, + Save: true, + Events: true, } } @@ -165,7 +203,12 @@ func (r *Runtime) Inspect( namespace, req.Name, err) } - return stateFromTopology(obj, namespace), nil + state := stateFromTopology(obj, namespace) + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes topology state", "error", err) + } + + return state, nil } func (r *Runtime) List( @@ -185,7 +228,15 @@ func (r *Runtime) List( states := make([]*labruntime.LabState, 0, len(list.Items)) for idx := range list.Items { - states = append(states, stateFromTopology(&list.Items[idx], namespace)) + state := stateFromTopology(&list.Items[idx], namespace) + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes topology state", + "name", state.Name, + "namespace", state.Namespace, + "error", err, + ) + } + states = append(states, state) } sort.Slice(states, func(i, j int) bool { @@ -198,6 +249,177 @@ func (r *Runtime) List( return states, nil } +func (r *Runtime) Exec( + ctx context.Context, + req labruntime.ExecRequest, +) (*clabexec.ExecResult, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + if req.NodeName == "" { + return nil, fmt.Errorf("node name is required") + } + if len(req.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + pod, err := r.launcherPod(ctx, req.Name, req.Namespace, req.NodeName) + if err != nil { + return nil, err + } + + execCmd := clabexec.NewExecCmdFromSlice(req.Command) + result := clabexec.NewExecResult(execCmd) + cmd := append([]string{"docker", "exec", req.NodeName}, req.Command...) + + stdout, stderr, rc, err := r.execInPod(ctx, pod, cmd) + if err != nil { + return nil, err + } + + result.SetReturnCode(rc) + result.SetStdOut(stdout) + result.SetStdErr(stderr) + + return result, nil +} + +func (r *Runtime) Start(ctx context.Context, req labruntime.NodeRequest) error { + return r.setNodesReplicas(ctx, req, 1) +} + +func (r *Runtime) Stop(ctx context.Context, req labruntime.NodeRequest) error { + if err := r.setTopologyIgnoreReconcile(ctx, req.Name, req.Namespace, true); err != nil { + return err + } + + return r.setNodesReplicas(ctx, req, 0) +} + +func (r *Runtime) Restart(ctx context.Context, req labruntime.NodeRequest) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + + now := time.Now().UTC().Format(time.RFC3339) + for _, nodeName := range targets { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + if deployment.Spec.Template.ObjectMeta.Annotations == nil { + deployment.Spec.Template.ObjectMeta.Annotations = map[string]string{} + } + deployment.Spec.Template.ObjectMeta.Annotations[restartedAtAnnotation] = now + + if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { + replicas := int32(1) + deployment.Spec.Replicas = &replicas + } + + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to restart clabernetes node %s/%s/%s: %w", + namespace, req.Name, nodeName, err) + } + + if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, 1, req.Timeout); err != nil { + return err + } + } + + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) +} + +func (r *Runtime) Save( + ctx context.Context, + req labruntime.SaveRequest, +) (*labruntime.SaveResult, error) { + targets, namespace, err := r.targetNodes(ctx, labruntime.NodeRequest{ + Name: req.Name, + Namespace: req.Namespace, + Nodes: req.Nodes, + }) + if err != nil { + return nil, err + } + + result := &labruntime.SaveResult{} + for _, nodeName := range targets { + pod, err := r.launcherPod(ctx, req.Name, namespace, nodeName) + if err != nil { + return nil, err + } + + copyDir := "" + command := []string{"containerlab", "save", "-t", "/clabernetes/topo.clab.yaml"} + if req.Copy { + copyDir = fmt.Sprintf("/tmp/clab-save-copy-%s-%s-%d", + req.Name, nodeName, time.Now().UnixNano()) + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", copyDir}) + command = append(command, "--copy", copyDir) + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, command) + if err != nil { + return nil, err + } + + if len(stdout) != 0 { + log.Info("clabernetes save output", "node", nodeName, "stdout", strings.TrimSpace(string(stdout))) + } + if len(stderr) != 0 { + log.Info("clabernetes save output", "node", nodeName, "stderr", strings.TrimSpace(string(stderr))) + } + if rc != 0 { + return nil, fmt.Errorf("save failed for clabernetes node %s/%s/%s: rc=%d", + namespace, req.Name, nodeName, rc) + } + + if req.Copy { + files, err := r.collectSavedFiles(ctx, pod, nodeName, copyDir) + if cleanupDir := copyDir; cleanupDir != "" { + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", cleanupDir}) + } + if err != nil { + return nil, err + } + result.Files = append(result.Files, files...) + } + } + + return result, nil +} + +func (r *Runtime) StreamEvents( + ctx context.Context, + req labruntime.EventStreamRequest, +) (<-chan labruntime.Event, <-chan error, error) { + events := make(chan labruntime.Event, 128) + errs := make(chan error, 2) + + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + if req.IncludeInitialState { + go r.emitInitialEvents(ctx, namespace, events, errs) + } + + if req.IncludeInterfaceStats { + go r.pollInterfaceStats(ctx, namespace, req.StatsInterval, events) + } + + go r.watchTopologies(ctx, namespace, events, errs) + go r.watchPods(ctx, namespace, events, errs) + + return events, errs, nil +} + func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout time.Duration) error { waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) defer cancel() @@ -252,6 +474,931 @@ func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeo }) } +func (r *Runtime) targetNodes( + ctx context.Context, + req labruntime.NodeRequest, +) ([]string, string, error) { + if req.Name == "" { + return nil, "", fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, "", err + } + + known := map[string]struct{}{} + for idx := range deployments.Items { + nodeName := deployments.Items[idx].Labels[labelTopologyNode] + if nodeName != "" { + known[nodeName] = struct{}{} + } + } + + if len(known) == 0 { + state, err := r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + if err != nil { + return nil, "", err + } + for _, node := range state.Nodes { + known[node.Name] = struct{}{} + } + } + + if len(known) == 0 { + return nil, "", fmt.Errorf("topology %s/%s has no nodes", namespace, req.Name) + } + + var targets []string + if len(req.Nodes) == 0 { + targets = make([]string, 0, len(known)) + for nodeName := range known { + targets = append(targets, nodeName) + } + sort.Strings(targets) + + return targets, namespace, nil + } + + for _, nodeName := range req.Nodes { + if _, ok := known[nodeName]; !ok { + return nil, "", fmt.Errorf("node %q was not found in topology %s/%s", + nodeName, namespace, req.Name) + } + targets = append(targets, nodeName) + } + + return targets, namespace, nil +} + +func (r *Runtime) setNodesReplicas( + ctx context.Context, + req labruntime.NodeRequest, + replicas int32, +) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + + for _, nodeName := range targets { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + deployment.Spec.Replicas = &replicas + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to set clabernetes node %s/%s/%s replicas to %d: %w", + namespace, req.Name, nodeName, replicas, err) + } + + if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, replicas, req.Timeout); err != nil { + return err + } + } + + if replicas > 0 { + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) + } + + return nil +} + +func (r *Runtime) clearIgnoreWhenAllStarted(ctx context.Context, name, namespace string) error { + deployments, err := r.deploymentsForTopology(ctx, name, namespace) + if err != nil { + return err + } + + for idx := range deployments.Items { + replicas := int32(1) + if deployments.Items[idx].Spec.Replicas != nil { + replicas = *deployments.Items[idx].Spec.Replicas + } + if replicas == 0 { + return nil + } + } + + return r.setTopologyIgnoreReconcile(ctx, name, namespace, false) +} + +func (r *Runtime) setTopologyIgnoreReconcile( + ctx context.Context, + name, + namespace string, + enabled bool, +) error { + if name == "" { + return fmt.Errorf("topology name is required") + } + + namespace = r.namespaceFor(namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + } + + labelsMap := obj.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + + if enabled { + labelsMap[labelIgnoreReconcile] = "true" + } else { + delete(labelsMap, labelIgnoreReconcile) + } + + obj.SetLabels(labelsMap) + + _, err = resource.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update clabernetes topology %s/%s labels: %w", + namespace, name, err) + } + + return nil +} + +func (r *Runtime) waitDeploymentReplicas( + ctx context.Context, + namespace, + name string, + replicas int32, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + deployment, err := r.kubeClient.AppsV1().Deployments(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("failed to get clabernetes deployment %s/%s: %w", + namespace, name, err) + } + + if replicas == 0 { + return deployment.Status.Replicas == 0 && + deployment.Status.AvailableReplicas == 0, nil + } + + return deployment.Status.ReadyReplicas >= replicas && + deployment.Status.AvailableReplicas >= replicas, nil + }) +} + +func (r *Runtime) deploymentsForTopology( + ctx context.Context, + name, + namespace string, +) (*appsv1.DeploymentList, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployments for topology %s/%s: %w", + namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) deploymentForNode( + ctx context.Context, + name, + namespace, + nodeName string, +) (*appsv1.Deployment, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: nodeName, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployment for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes deployment for node %s/%s/%s was not found", + namespace, name, nodeName) + } + + return &list.Items[0], nil +} + +func (r *Runtime) launcherPod( + ctx context.Context, + name, + namespace, + nodeName string, +) (*corev1.Pod, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: nodeName, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes launcher pods for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes launcher pod for node %s/%s/%s was not found", + namespace, name, nodeName) + } + + for idx := range list.Items { + if list.Items[idx].Status.Phase == corev1.PodRunning { + return &list.Items[idx], nil + } + } + + return &list.Items[0], nil +} + +func (r *Runtime) execInPod( + ctx context.Context, + pod *corev1.Pod, + command []string, +) ([]byte, []byte, int, error) { + if pod == nil { + return nil, nil, 0, fmt.Errorf("launcher pod is nil") + } + if len(command) == 0 { + return nil, nil, 0, fmt.Errorf("command is required") + } + + containerName := "" + if len(pod.Spec.Containers) != 0 { + containerName = pod.Spec.Containers[0].Name + } + + req := r.kubeClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod.Name). + Namespace(pod.Namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: command, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(r.restConfig, "POST", req.URL()) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to create Kubernetes exec executor: %w", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + Tty: false, + }) + + rc := 0 + if err != nil { + var exitErr kubeexec.ExitError + if errors.As(err, &exitErr) { + rc = exitErr.ExitStatus() + err = nil + } + } + if err != nil { + return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf("failed to execute command in pod %s/%s: %w", + pod.Namespace, pod.Name, err) + } + + return stdout.Bytes(), stderr.Bytes(), rc, nil +} + +func (r *Runtime) collectSavedFiles( + ctx context.Context, + pod *corev1.Pod, + nodeName, + copyDir string, +) ([]labruntime.SavedFile, error) { + if copyDir == "" { + return nil, nil + } + + nodeCopyDir := path.Join(copyDir, "clab-clabernetes-"+nodeName, nodeName) + _, _, rc, err := r.execInPod(ctx, pod, []string{"test", "-d", nodeCopyDir}) + if err != nil { + return nil, err + } + if rc != 0 { + log.Debug("no clabernetes saved config copy directory found", + "node", nodeName, + "path", nodeCopyDir, + ) + + return nil, nil + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"tar", "cf", "-", "-C", nodeCopyDir, "."}) + if err != nil { + return nil, err + } + if rc != 0 { + return nil, fmt.Errorf("failed to archive saved config copy for node %s: rc=%d stderr=%s", + nodeName, rc, strings.TrimSpace(string(stderr))) + } + + files, err := savedFilesFromTar(nodeName, stdout) + if err != nil { + return nil, fmt.Errorf("failed to read saved config archive for node %s: %w", + nodeName, err) + } + + return files, nil +} + +func savedFilesFromTar(nodeName string, data []byte) ([]labruntime.SavedFile, error) { + reader := tar.NewReader(bytes.NewReader(data)) + var files []labruntime.SavedFile + + for { + header, err := reader.Next() + switch { + case errors.Is(err, io.EOF): + return files, nil + case err != nil: + return nil, err + } + + name, ok := cleanTarPath(header.Name) + if !ok || name == "." { + continue + } + + switch header.Typeflag { + case tar.TypeReg, tar.TypeRegA: + content, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + files = append(files, labruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Data: content, + Mode: header.Mode, + }) + case tar.TypeSymlink: + files = append(files, labruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Mode: header.Mode, + LinkTarget: header.Linkname, + }) + } + } +} + +func cleanTarPath(name string) (string, bool) { + name = strings.TrimPrefix(name, "./") + cleaned := path.Clean(name) + if cleaned == "." || cleaned == "" { + return cleaned, true + } + if strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || cleaned == ".." { + return "", false + } + + return cleaned, true +} + +func (r *Runtime) enrichState(ctx context.Context, state *labruntime.LabState) error { + if state == nil || state.Name == "" { + return nil + } + + deployments, err := r.deploymentsForTopology(ctx, state.Name, state.Namespace) + if err != nil { + return err + } + + pods, err := r.kubeClient.CoreV1().Pods(state.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: state.Name, + }.String(), + }) + if err != nil { + return fmt.Errorf("failed to list clabernetes pods for topology %s/%s: %w", + state.Namespace, state.Name, err) + } + + nodesByName := map[string]labruntime.NodeState{} + for _, node := range state.Nodes { + nodesByName[node.Name] = node + } + + podsByNode := map[string]*corev1.Pod{} + for idx := range pods.Items { + nodeName := pods.Items[idx].Labels[labelTopologyNode] + if nodeName == "" { + continue + } + if pods.Items[idx].Status.Phase == corev1.PodRunning { + podsByNode[nodeName] = &pods.Items[idx] + continue + } + if _, ok := podsByNode[nodeName]; !ok { + podsByNode[nodeName] = &pods.Items[idx] + } + } + + for idx := range deployments.Items { + deployment := &deployments.Items[idx] + nodeName := deployment.Labels[labelTopologyNode] + if nodeName == "" { + continue + } + + node := nodesByName[nodeName] + node.Name = nodeName + replicas := int32(1) + if deployment.Spec.Replicas != nil { + replicas = *deployment.Spec.Replicas + } + + switch { + case replicas == 0: + node.State = "stopped" + node.Ready = false + case deployment.Status.ReadyReplicas > 0: + node.State = "ready" + node.Ready = true + case podsByNode[nodeName] != nil && podsByNode[nodeName].Status.Phase != "": + node.State = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) + node.Ready = false + default: + node.State = "notready" + node.Ready = false + } + + nodesByName[nodeName] = node + } + + nodeNames := make([]string, 0, len(nodesByName)) + for nodeName := range nodesByName { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + state.Nodes = make([]labruntime.NodeState, 0, len(nodeNames)) + allReady := len(nodeNames) > 0 + allStopped := len(nodeNames) > 0 + for _, nodeName := range nodeNames { + node := nodesByName[nodeName] + state.Nodes = append(state.Nodes, node) + allReady = allReady && node.Ready + allStopped = allStopped && node.State == "stopped" + } + + switch { + case allReady: + state.State = "running" + state.Ready = true + case allStopped: + state.State = "stopped" + state.Ready = false + case len(nodeNames) != 0: + state.State = "partial" + state.Ready = false + } + + return nil +} + +func (r *Runtime) emitInitialEvents( + ctx context.Context, + namespace string, + eventSink chan<- labruntime.Event, + errSink chan<- error, +) { + states, err := r.List(ctx, labruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + sendEventError(ctx, errSink, err) + return + } + + for _, state := range states { + for _, node := range state.Nodes { + action := node.State + if node.Ready { + action = "running" + } + if action == "" { + action = state.State + } + r.sendEvent(ctx, eventSink, labruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: action, + ActorID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), + ActorName: fmt.Sprintf("%s-%s", state.Name, node.Name), + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "state": node.State, + }, + }) + } + } +} + +func (r *Runtime) watchTopologies( + ctx context.Context, + namespace string, + eventSink chan<- labruntime.Event, + errSink chan<- error, +) { + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + watcher, err := resource.Watch(ctx, metav1.ListOptions{}) + if err != nil { + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) + return + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-watcher.ResultChan(): + if !ok { + return + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("clabernetes topology watch returned an error")) + continue + } + + obj, ok := ev.Object.(*unstructured.Unstructured) + if !ok { + continue + } + state := stateFromTopology(obj, namespace) + r.sendEvent(ctx, eventSink, labruntime.Event{ + Timestamp: time.Now(), + Type: "topology", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s", state.Namespace, state.Name), + ActorName: state.Name, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "state": state.State, + "ready": fmt.Sprintf("%t", state.Ready), + }, + }) + } + } +} + +func (r *Runtime) watchPods( + ctx context.Context, + namespace string, + eventSink chan<- labruntime.Event, + errSink chan<- error, +) { + watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) + return + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-watcher.ResultChan(): + if !ok { + return + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("clabernetes pod watch returned an error")) + continue + } + + pod, ok := ev.Object.(*corev1.Pod) + if !ok { + continue + } + + labName := pod.Labels[labelTopologyOwner] + nodeName := pod.Labels[labelTopologyNode] + if labName == "" || nodeName == "" { + continue + } + + r.sendEvent(ctx, eventSink, labruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s/%s", pod.Namespace, labName, nodeName), + ActorName: fmt.Sprintf("%s-%s", labName, nodeName), + ActorFullID: pod.Name, + Attributes: map[string]string{ + "namespace": pod.Namespace, + "lab": labName, + "node": nodeName, + "pod": pod.Name, + "phase": string(pod.Status.Phase), + "pod_ip": pod.Status.PodIP, + }, + }) + } + } +} + +func (r *Runtime) pollInterfaceStats( + ctx context.Context, + namespace string, + interval time.Duration, + eventSink chan<- labruntime.Event, +) { + if interval <= 0 { + interval = time.Second + } + + samples := map[string]c9sIfaceStatsSample{} + + sample := func() { + states, err := r.List(ctx, labruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + log.Debug("failed to list clabernetes topologies for interface stats", "error", err) + return + } + + now := time.Now() + for _, state := range states { + for _, node := range state.Nodes { + if !node.Ready { + continue + } + + pod, err := r.launcherPod(ctx, state.Name, state.Namespace, node.Name) + if err != nil { + log.Debug("failed to resolve clabernetes launcher pod for interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"docker", "exec", node.Name, "cat", "/proc/net/dev"}) + if err != nil { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + if rc != 0 { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "rc", rc, + "stderr", strings.TrimSpace(string(stderr)), + ) + continue + } + + stats, err := parseProcNetDev(stdout) + if err != nil { + log.Debug("failed to parse clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + for _, stat := range stats { + key := c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name) + current := c9sIfaceStatsSample{ + Stats: stat, + Timestamp: now, + } + + if previous, ok := samples[key]; ok { + event := c9sIfaceStatsEvent(state, node, pod, stat, previous, current) + r.sendEvent(ctx, eventSink, event) + } + + samples[key] = current + } + } + } + } + + sample() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sample() + } + } +} + +type c9sIfaceStats struct { + Name string + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} + +type c9sIfaceStatsSample struct { + Stats c9sIfaceStats + Timestamp time.Time +} + +func parseProcNetDev(data []byte) ([]c9sIfaceStats, error) { + lines := strings.Split(string(data), "\n") + stats := make([]c9sIfaceStats, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, ":") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + ifName := strings.TrimSpace(parts[0]) + fields := strings.Fields(parts[1]) + if len(fields) < 16 { + return nil, fmt.Errorf("unexpected /proc/net/dev line for %q: %q", ifName, line) + } + + rxBytes, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx bytes for %q: %w", ifName, err) + } + rxPackets, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx packets for %q: %w", ifName, err) + } + txBytes, err := strconv.ParseUint(fields[8], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx bytes for %q: %w", ifName, err) + } + txPackets, err := strconv.ParseUint(fields[9], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx packets for %q: %w", ifName, err) + } + + stats = append(stats, c9sIfaceStats{ + Name: ifName, + RxBytes: rxBytes, + RxPackets: rxPackets, + TxBytes: txBytes, + TxPackets: txPackets, + }) + } + + return stats, nil +} + +func c9sIfaceStatsKey(namespace, lab, node, ifName string) string { + return namespace + "/" + lab + "/" + node + "/" + ifName +} + +func c9sIfaceStatsEvent( + state *labruntime.LabState, + node labruntime.NodeState, + pod *corev1.Pod, + stat c9sIfaceStats, + previous, + current c9sIfaceStatsSample, +) labruntime.Event { + interval := current.Timestamp.Sub(previous.Timestamp) + if interval <= 0 { + interval = time.Second + } + + seconds := interval.Seconds() + rxBytesDelta := counterDelta(stat.RxBytes, previous.Stats.RxBytes) + txBytesDelta := counterDelta(stat.TxBytes, previous.Stats.TxBytes) + rxPacketsDelta := counterDelta(stat.RxPackets, previous.Stats.RxPackets) + txPacketsDelta := counterDelta(stat.TxPackets, previous.Stats.TxPackets) + + actorName := fmt.Sprintf("%s-%s", state.Name, node.Name) + podName := "" + if pod != nil { + podName = pod.Name + } + + return labruntime.Event{ + Timestamp: current.Timestamp, + Type: "interface", + Action: "stats", + ActorID: c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name), + ActorName: actorName, + ActorFullID: podName, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "name": actorName, + "pod": podName, + "ifname": stat.Name, + "origin": "clabernetes", + "rx_bytes": strconv.FormatUint(stat.RxBytes, 10), + "tx_bytes": strconv.FormatUint(stat.TxBytes, 10), + "rx_packets": strconv.FormatUint(stat.RxPackets, 10), + "tx_packets": strconv.FormatUint(stat.TxPackets, 10), + "rx_bps": strconv.FormatFloat(float64(rxBytesDelta*8)/seconds, 'f', -1, 64), + "tx_bps": strconv.FormatFloat(float64(txBytesDelta*8)/seconds, 'f', -1, 64), + "rx_pps": strconv.FormatFloat(float64(rxPacketsDelta)/seconds, 'f', -1, 64), + "tx_pps": strconv.FormatFloat(float64(txPacketsDelta)/seconds, 'f', -1, 64), + "interval_seconds": strconv.FormatFloat(seconds, 'f', -1, 64), + }, + } +} + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + + return current - previous +} + +func (r *Runtime) sendEvent( + ctx context.Context, + eventSink chan<- labruntime.Event, + event labruntime.Event, +) { + select { + case eventSink <- event: + case <-ctx.Done(): + } +} + +func sendEventError(ctx context.Context, errSink chan<- error, err error) { + select { + case errSink <- err: + case <-ctx.Done(): + } +} + func (r *Runtime) namespaceFor(namespace string) string { if namespace != "" { return namespace diff --git a/labruntime/runtime.go b/labruntime/runtime.go index 3cadf7ab59..f38a59bf0f 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "time" + + clabexec "github.com/srl-labs/containerlab/exec" ) const ( @@ -40,11 +42,58 @@ type ListRequest struct { AllNamespaces bool } +type NodeRequest struct { + Name string + Namespace string + Nodes []string + Timeout time.Duration +} + +type ExecRequest struct { + Name string + Namespace string + NodeName string + Command []string +} + +type SaveRequest struct { + Name string + Namespace string + Nodes []string + Copy bool +} + +type EventStreamRequest struct { + Namespace string + AllNamespaces bool + IncludeInitialState bool + IncludeInterfaceStats bool + StatsInterval time.Duration +} + +type SavedFile struct { + NodeName string + Name string + Data []byte + Mode int64 + LinkTarget string +} + +type SaveResult struct { + Files []SavedFile +} + type RuntimeCapabilities struct { Deploy bool Destroy bool Inspect bool List bool + Exec bool + Start bool + Stop bool + Restart bool + Save bool + Events bool } type NodeState struct { @@ -65,11 +114,27 @@ type LabState struct { Nodes []NodeState } +type Event struct { + Timestamp time.Time + Type string + Action string + ActorID string + ActorName string + ActorFullID string + Attributes map[string]string +} + type LabRuntime interface { Deploy(context.Context, DeployRequest) (*LabState, error) Destroy(context.Context, DestroyRequest) error Inspect(context.Context, InspectRequest) (*LabState, error) List(context.Context, ListRequest) ([]*LabState, error) + Exec(context.Context, ExecRequest) (*clabexec.ExecResult, error) + Start(context.Context, NodeRequest) error + Stop(context.Context, NodeRequest) error + Restart(context.Context, NodeRequest) error + Save(context.Context, SaveRequest) (*SaveResult, error) + StreamEvents(context.Context, EventStreamRequest) (<-chan Event, <-chan error, error) Capabilities() RuntimeCapabilities } From 2c2454bc0c5fcf6824fb03533ee553aa897f184c Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 11:15:39 +0200 Subject: [PATCH 03/21] add lab owner --- core/config.go | 23 +++++++++------- core/labruntime.go | 5 +++- labruntime/clabernetes/clabernetes.go | 39 ++++++++++++++++++++------- labruntime/runtime.go | 2 ++ 4 files changed, 49 insertions(+), 20 deletions(-) diff --git a/core/config.go b/core/config.go index 52daf5b821..8949ae8f00 100644 --- a/core/config.go +++ b/core/config.go @@ -674,16 +674,7 @@ func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { cfg.Labels[clabconstants.NodeLabDir] = cfg.LabDir cfg.Labels[clabconstants.TopoFile] = c.TopoPaths.TopologyFilenameAbsPath() - // Use custom owner if set, otherwise use current user - owner := c.customOwner - if owner == "" { - owner = os.Getenv("SUDO_USER") - if owner == "" { - owner = os.Getenv("USER") - } - } - - cfg.Labels[clabconstants.Owner] = owner + cfg.Labels[clabconstants.Owner] = c.labOwner() gitBranch, gitHash := c.getGitInfo() @@ -695,6 +686,18 @@ func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { } } +func (c *CLab) labOwner() string { + if c.customOwner != "" { + return c.customOwner + } + + if owner := os.Getenv("SUDO_USER"); owner != "" { + return owner + } + + return os.Getenv("USER") +} + // labelsToEnvVars adds labels to env vars with CLAB_LABEL_ prefix added // and labels value sanitized. func labelsToEnvVars(n *clabtypes.NodeConfig) { diff --git a/core/labruntime.go b/core/labruntime.go index d280df431a..209fd1583c 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -45,6 +45,7 @@ func (c *CLab) deployWithLabRuntime( state, err := c.LabRuntime.Deploy(ctx, labruntime.DeployRequest{ Name: c.Config.Name, + Owner: c.labOwner(), TopologyDefinition: c.renderedTopology, Wait: true, Timeout: c.timeout, @@ -324,7 +325,9 @@ func (c *CLab) containerFromLabNode( image = node.Image } - if c.customOwner != "" { + if state.Owner != "" { + labels[clabconstants.Owner] = state.Owner + } else if c.customOwner != "" { labels[clabconstants.Owner] = c.customOwner } diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 584b2f7008..1f0d3b003e 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -16,6 +16,7 @@ import ( "time" "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" "github.com/srl-labs/containerlab/labruntime" "gopkg.in/yaml.v2" @@ -26,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" @@ -129,7 +131,7 @@ func (r *Runtime) Deploy( namespace := r.namespaceFor(req.Namespace) resource := r.client.Resource(topologyGVR).Namespace(namespace) - desired := topologyObject(req.Name, namespace, string(req.TopologyDefinition)) + desired := topologyObject(req.Name, namespace, req.Owner, string(req.TopologyDefinition)) existing, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) switch { @@ -1419,18 +1421,32 @@ func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { return 10 * time.Minute } -func topologyObject(name, namespace, definition string) *unstructured.Unstructured { +func topologyObject(name, namespace, owner, definition string) *unstructured.Unstructured { + topologyLabels := map[string]any{ + "containerlab.dev/runtime": labruntime.ClabernetesRuntimeName, + } + topologyAnnotations := map[string]any{} + if owner != "" { + topologyAnnotations[clabconstants.Owner] = owner + if len(validation.IsValidLabelValue(owner)) == 0 { + topologyLabels[clabconstants.Owner] = owner + } + } + + metadata := map[string]any{ + "name": name, + "namespace": namespace, + "labels": topologyLabels, + } + if len(topologyAnnotations) != 0 { + metadata["annotations"] = topologyAnnotations + } + return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "clabernetes.containerlab.dev/v1alpha1", "kind": "Topology", - "metadata": map[string]any{ - "name": name, - "namespace": namespace, - "labels": map[string]any{ - "containerlab.dev/runtime": labruntime.ClabernetesRuntimeName, - }, - }, + "metadata": metadata, "spec": map[string]any{ "definition": map[string]any{ "containerlab": definition, @@ -1479,6 +1495,10 @@ func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labrun ready, _, _ := unstructured.NestedBool(obj.Object, "status", "topologyReady") state, _, _ := unstructured.NestedString(obj.Object, "status", "topologyState") + owner := obj.GetLabels()[clabconstants.Owner] + if owner == "" { + owner = obj.GetAnnotations()[clabconstants.Owner] + } nodeReadiness, _, _ := unstructured.NestedStringMap( obj.Object, "status", @@ -1518,6 +1538,7 @@ func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labrun return &labruntime.LabState{ Name: obj.GetName(), Namespace: namespace, + Owner: owner, TopologyPath: fmt.Sprintf("k8s://%s/topologies/%s", namespace, obj.GetName()), State: state, Ready: ready, diff --git a/labruntime/runtime.go b/labruntime/runtime.go index f38a59bf0f..6253b9e836 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -20,6 +20,7 @@ type Config struct { type DeployRequest struct { Name string Namespace string + Owner string TopologyDefinition []byte Wait bool Timeout time.Duration @@ -108,6 +109,7 @@ type NodeState struct { type LabState struct { Name string Namespace string + Owner string TopologyPath string State string Ready bool From 20eab8a4db0f946bbae701ff41cce0bf55cf2e7e Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 11:42:45 +0200 Subject: [PATCH 04/21] c9s runtime docs --- docs/cmd/deploy.md | 31 +- docs/cmd/events.md | 11 +- docs/cmd/exec.md | 4 + docs/cmd/inspect/index.md | 4 + docs/cmd/save.md | 4 + docs/manual/clabernetes/index.md | 6 + docs/manual/clabernetes/install.md | 9 +- docs/manual/clabernetes/quickstart.md | 8 + docs/manual/clabernetes/runtime.md | 591 ++++++++++++++++++++++++++ docs/manual/nodes.md | 14 +- mkdocs.yml | 1 + 11 files changed, 667 insertions(+), 16 deletions(-) create mode 100644 docs/manual/clabernetes/runtime.md diff --git a/docs/cmd/deploy.md b/docs/cmd/deploy.md index 9bdfdcf5c9..79cb72a112 100644 --- a/docs/cmd/deploy.md +++ b/docs/cmd/deploy.md @@ -103,16 +103,21 @@ With `--max-workers` flag, it is possible to limit the number of concurrent work #### runtime -Containerlab nodes can be started by different runtimes, with `docker` being the default one. Besides that, containerlab has experimental support for `podman` runtime. +Containerlab nodes can be started by different local container runtimes, with `docker` being the default one. Besides that, containerlab has experimental support for `podman` runtime. -A global runtime can be selected with a global `--runtime | -r` flag that will select a runtime to use. The possible value are: +A global runtime can be selected with the `--runtime | -r` flag. The possible values are: -* `docker` - default -* `podman` - experimental support +* `docker` - default local container runtime +* `podman` - experimental local container runtime +* `clabernetes` - Clabernetes lab runtime that deploys the whole topology to a kubernetes cluster + +/// note +`clabernetes` is a lab runtime, not a per-node container runtime. When it is selected, containerlab renders the topology and creates a Clabernetes `Topology` custom resource. See [Containerlab runtime](../manual/clabernetes/runtime.md) for details. +/// #### timeout -A global `--timeout` flag drives the timeout of API requests that containerlab send toward external resources. Currently the only external resource is the container runtime (i.e. docker). +A global `--timeout` flag drives the timeout of API requests that containerlab sends toward external resources, such as the selected container runtime or the kubernetes API used by the Clabernetes lab runtime. In a busy compute the runtime may respond longer than anticipated, in that case increasing the timeout may help. @@ -151,6 +156,10 @@ When a subset of nodes is specified, containerlab will only deploy those nodes a Read more about [node filtering](../manual/node-filtering.md) in the documentation. +/// warning | Clabernetes runtime +`deploy --node-filter` is not supported with `--runtime clabernetes`. Clabernetes reconciles the complete topology stored in a `Topology` custom resource. After the topology exists, use node filtering with commands such as `start`, `stop`, `restart`, `exec`, or `save`. +/// + #### skip-post-deploy The `--skip-post-deploy` flag skips the post-deploy phase of the lab deployment, affecting all nodes. @@ -245,12 +254,16 @@ In this example: #### `CLAB_RUNTIME` -Default value of "runtime" key for nodes, same as global `--runtime | -r` flag described above. -Affects all containerlab commands in the same way, not just `deploy`. +Default value for the global `--runtime | -r` flag described above. It affects all containerlab commands in the same way, not just `deploy`. + +For `docker` or `podman`, it selects the default local container runtime. For `clabernetes`, it selects the whole-lab Clabernetes runtime. -Intended to be set in environments where non-default container runtime should be used, to avoid needing to specify it for every command invocation or in every configuration file. +Example command-line usage: -Example command-line usage: `CLAB_RUNTIME=podman containerlab deploy` +```bash +CLAB_RUNTIME=podman containerlab deploy +CLAB_RUNTIME=clabernetes containerlab deploy -t topo.clab.yml +``` #### `CLAB_VERSION_CHECK` diff --git a/docs/cmd/events.md b/docs/cmd/events.md index 85c2b91480..2051d2e14f 100644 --- a/docs/cmd/events.md +++ b/docs/cmd/events.md @@ -71,10 +71,17 @@ Statistics are disabled by default. Enabling them augments the feed with periodi Containerlab streams events from the runtime selected via the global `--runtime` flag. -> **Currently supported runtime:** `docker` -> Runtimes that do not implement the `events` API (or are not yet supported by Containerlab) will exit with an explanatory error. +Currently supported runtimes: + +- `docker` +- `clabernetes` + +With `--runtime clabernetes`, events are backed by kubernetes watches for Clabernetes `Topology` resources and labeled Pods. Interface statistics are sampled through the launcher pods, not through local host netlink. + +Runtimes that do not implement the `events` API, or are not yet supported by Containerlab, will exit with an explanatory error. ## See also - [`inspect interfaces`](inspect/interfaces.md) – produces a point-in-time view of the same interface details that `events` reports continuously. +- [Clabernetes containerlab runtime](../manual/clabernetes/runtime.md) - explains how c9s events differ from Docker events. - `docker events` – the raw runtime feed that Containerlab builds upon. diff --git a/docs/cmd/exec.md b/docs/cmd/exec.md index cb082bac4b..21f0533fb6 100644 --- a/docs/cmd/exec.md +++ b/docs/cmd/exec.md @@ -10,6 +10,10 @@ This command is similar to `docker exec`, but it allows a user to run the same c Like `docker exec`, `exec` runs inside the node's container namespace. For VM-based kinds (vrnetlab integration, e.g. `sonic-vm`), that container is the QEMU launcher wrapping the VM, not the guest VM itself, so guest network-OS commands are not reachable via `exec` and fail with `executable file not found in $PATH`. Use SSH to the node's management address (or its native CLI) to run guest-OS commands. See the [`exec` node property](../manual/nodes.md#exec) for details. /// +/// note | Clabernetes runtime +With `--runtime clabernetes`, containerlab reaches the node through kubernetes pod exec into the launcher pod and then runs the command in the nested node container. The kube identity must be allowed to create `pods/exec`. See [Containerlab runtime](../manual/clabernetes/runtime.md#exec) for details. +/// + --8<-- "docs/cmd/deploy.md:env-vars-flags" ## Usage diff --git a/docs/cmd/inspect/index.md b/docs/cmd/inspect/index.md index c8561ec42b..dbbbd20e0c 100644 --- a/docs/cmd/inspect/index.md +++ b/docs/cmd/inspect/index.md @@ -4,6 +4,10 @@ The `inspect` command provides the information about the deployed labs. +/// note | Clabernetes runtime +With `--runtime clabernetes`, `inspect` reads Clabernetes `Topology` resources and their related kubernetes objects instead of local Docker containers. `inspect --all` lists c9s topologies across all namespaces. See [Containerlab runtime](../../manual/clabernetes/runtime.md#inspect) for details. +/// + ### Usage `containerlab [global-flags] inspect [local-flags]` diff --git a/docs/cmd/save.md b/docs/cmd/save.md index ee49d34a42..e1aece8b65 100644 --- a/docs/cmd/save.md +++ b/docs/cmd/save.md @@ -4,6 +4,10 @@ The `save` command perform configuration save for all the containers running in a lab. +/// note | Clabernetes runtime +With `--runtime clabernetes`, `save` runs the inner `containerlab save` command inside each launcher pod. `save --copy` streams the resulting files back to the local destination using the same copy layout as Docker labs. See [Containerlab runtime](../manual/clabernetes/runtime.md#save) for details. +/// + The exact command that performs configuration save depends on a given kind. The below table explains the method used for each kind: | Kind | Command | Notes | diff --git a/docs/manual/clabernetes/index.md b/docs/manual/clabernetes/index.md index e7052f7f4b..2c52727587 100644 --- a/docs/manual/clabernetes/index.md +++ b/docs/manual/clabernetes/index.md @@ -17,6 +17,11 @@ Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Clabernetes deploys containerlab topologies into a kubernetes cluster. The goal of Clabernetes is to scale Containerlab beyond a single node while keeping the user experience you love. +You can use clabernetes in two ways: + +- with the native [`containerlab --runtime clabernetes`](runtime.md) workflow +- with [`clabverter`](install.md#clabverter), which converts topology files into kubernetes manifests + If all goes to plan, Clabernetes is going to be one of the solutions to enable [multi-node labs](../multi-node.md) and allow its users to create large topologies powered by a k8s cluster. Eager to try it out? Check out the [Quickstart](quickstart.md)! Have questions, join our [Discord](https://discord.gg/2A8ZxM7hD9). @@ -31,4 +36,5 @@ In the beta release we focus on the core topology constructs working our way tow * [Helm chart on ArtifactHub](https://artifacthub.io/packages/helm/clabernetes/clabernetes) * [CRD reference](https://crds.r8y.page/repo/github.com/srl-labs/clabernetes) +* [Native containerlab runtime](runtime.md) * Source code on [GitHub](https://github.com/srl-labs/clabernetes) diff --git a/docs/manual/clabernetes/install.md b/docs/manual/clabernetes/install.md index 350131fa6c..bed05e3fd6 100644 --- a/docs/manual/clabernetes/install.md +++ b/docs/manual/clabernetes/install.md @@ -2,11 +2,18 @@ Clabernetes runs on a Kubernetes cluster and hence requires one to be available before you start your Clabernetes journey. Although we don't have a strict requirement on the k8s version, we recommend using the version 1.21 or higher. -Clabernetes project consists of two components: +Clabernetes project consists of two main components: - Clabernetes manager (a.k.a. controller) - a k8s controller that watches for the Clabernetes topology resources and deploys them to the cluster. - Clabverter - a CLI tool that converts containerlab topology files into Clabernetes topology resources. +/// note | Using the containerlab runtime +When you use [`containerlab --runtime clabernetes`](runtime.md), containerlab +renders the topology and creates the `Topology` custom resource directly. In +that workflow you still need the Clabernetes manager and CRDs installed in the +cluster, but you don't need to run `clabverter` for every deployment. +/// + ## Clabernetes Manager Clabernetes manager (a.k.a. controller) is packaged as a [Helm chart][chart-artifact]; this means if you don't have Helm - [install it](https://helm.sh/docs/intro/install/) or use it in a container packaging: diff --git a/docs/manual/clabernetes/quickstart.md b/docs/manual/clabernetes/quickstart.md index f8d4bb8552..d1838e64ce 100644 --- a/docs/manual/clabernetes/quickstart.md +++ b/docs/manual/clabernetes/quickstart.md @@ -156,6 +156,14 @@ To make sure you have a smooth sailing in the clabernetes waters we've created a Clabverter is not a requirement to run clabernetes, but it is a helper tool to convert containerlab topologies to clabernetes resources and kubernetes objects. +/// note | Native containerlab runtime +Recent containerlab versions can also deploy to clabernetes directly with +`containerlab --runtime clabernetes`. This quickstart keeps using `clabverter` +because it shows the generated kubernetes manifests explicitly. If you want the +regular containerlab CLI lifecycle, see the [Containerlab runtime](runtime.md) +page. +/// + As per clabverter's [installation instructions](install.md#clabverter) we will setup an alias that uses the latest available clabverter container image: --8<-- "docs/manual/clabernetes/install.md:cv-install" diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md new file mode 100644 index 0000000000..6a9fcf000c --- /dev/null +++ b/docs/manual/clabernetes/runtime.md @@ -0,0 +1,591 @@ +# Containerlab runtime + +Containerlab can use Clabernetes as a lab runtime. With the c9s runtime selected, +containerlab keeps the familiar CLI workflow, but the actual lab runs in a +kubernetes cluster. + +```bash +containerlab --runtime clabernetes deploy -t topo.clab.yml +``` + +or, if you prefer environment variables: + +```bash +export CLAB_RUNTIME=clabernetes +containerlab deploy -t topo.clab.yml +``` + +/// note | Runtime, not converter +This page describes the native `containerlab --runtime clabernetes` workflow. +The [Quickstart](quickstart.md) still shows the manifest-driven `clabverter` +workflow, which remains useful when you want to generate and apply kubernetes +manifests yourself. +/// + +## How it works + +When the c9s runtime is selected, containerlab does not create local Docker or +Podman containers for the lab nodes. Instead, it renders the final topology and +stores it in a Clabernetes `Topology` custom resource: + +```yaml +apiVersion: clabernetes.containerlab.dev/v1alpha1 +kind: Topology +metadata: + name: + namespace: +spec: + definition: + containerlab: | + +``` + +The Clabernetes manager then reconciles this resource into kubernetes objects, +usually one launcher Deployment and Pod per topology node. Each launcher pod +runs containerlab inside the pod and starts the real node container there. + +/// note +The node containers are nested inside the launcher pods. A `docker ps` on the +machine where you ran the outer `containerlab` command is not the source of +truth for c9s labs. +/// + +The c9s runtime currently supports the main lab lifecycle and node operations: + +| Command | c9s behavior | +| ------- | ------------ | +| `deploy` | creates or updates the Clabernetes `Topology` resource and waits for readiness | +| `destroy` | deletes the `Topology` resource | +| `inspect` | reads `Topology`, Deployment, Pod, and service status | +| `exec` | execs through the launcher pod into the nested node container | +| `start` | scales node Deployments to `1` | +| `stop` | scales node Deployments to `0` and pauses reconciliation | +| `restart` | restarts node Deployments | +| `save` | runs `containerlab save` inside launcher pods | +| `events` | watches Clabernetes resources and pods | + +## Requirements + +The c9s runtime expects: + +- a reachable kubernetes cluster +- Clabernetes CRDs installed in the cluster +- the Clabernetes manager running and watching the lab namespace +- a namespace that already exists for the lab +- kubernetes RBAC allowing containerlab to manage the required resources + +/// warning +The c9s runtime does not create namespaces for you. Create the target namespace +first, or select an existing namespace. +/// + +## Selecting the cluster + +The runtime uses the kubernetes client-go configuration loader. It selects the +kubeconfig in this order: + +1. `CLAB_KUBECONFIG`, when set +2. normal client-go kubeconfig loading rules + +You can override the kube context with: + +```bash +export CLAB_KUBE_CONTEXT= +``` + +You can override the lab namespace with: + +```bash +export CLAB_KUBE_NAMESPACE= +``` + +If no namespace is set with `CLAB_KUBE_NAMESPACE` or the selected kube context, +containerlab uses `default`. + +/// tip +`CLAB_RUNTIME=clabernetes` and `CLAB_KUBE_NAMESPACE=` are often the +two variables worth exporting in shell profiles, CI jobs, or automation +environments that always target c9s. +/// + +## Namespace rules + +For normal single-lab commands, containerlab operates in one namespace: + +1. an internal request namespace, when the caller provides one +2. `CLAB_KUBE_NAMESPACE` +3. the namespace from the selected kube context +4. `default` + +For example: + +```bash +CLAB_KUBE_NAMESPACE=lab-a \ + containerlab --runtime clabernetes deploy -t topo.clab.yml +``` + +creates or updates the `Topology` resource in the `lab-a` namespace. + +Some commands intentionally look across namespaces: + +- `inspect --all` +- `destroy --all` +- `events` + +For these commands, containerlab uses all-namespaces kubernetes listing or +watching. The synthetic c9s container ID includes the namespace so follow-up +actions can still target the right lab: + +```text +// +``` + +For example: + +```text +default/clos/srl1 +``` + +## Deploy + +Deploying with the c9s runtime looks like a regular containerlab deployment: + +```bash +containerlab --runtime clabernetes deploy -t topo.clab.yml +``` + +The deploy flow is: + +1. containerlab parses and checks the topology file. +2. It renders the final topology YAML. +3. It creates or updates a Clabernetes `Topology` resource. +4. It waits until Clabernetes reports the topology as ready. +5. It inspects the resulting kubernetes state and prints the node table. + +`deploy --reconfigure` first deletes the existing `Topology` resource and then +deploys it again. + +/// warning | Node filtering +`deploy --node-filter` is not supported with the c9s runtime. Clabernetes owns +reconciliation of the complete topology stored in the `Topology` resource. +Deploy the full topology, then use node filtering with commands such as +`start`, `stop`, `restart`, `exec`, or `save` after the topology exists. +/// + +Deploy is create-or-update. If a `Topology` with the same name already exists in +the same namespace, containerlab updates it: + +```text +Updating clabernetes topology name= namespace= +``` + +Use a different lab name or namespace when you want a separate lab: + +```bash +containerlab --runtime clabernetes --name deploy -t topo.clab.yml +``` + +## Inspect + +Inspect works with a topology file, a lab name, or all known c9s labs: + +```bash +containerlab --runtime clabernetes inspect -t topo.clab.yml +containerlab --runtime clabernetes inspect --name clos +containerlab --runtime clabernetes inspect --all +``` + +For c9s labs, inspect reads kubernetes resources instead of local container +runtime state. It collects the topology name, namespace, topology state, node +readiness, node kind and image, and load-balancer management address when +Clabernetes exposes one. + +/// note +`inspect --all` lists c9s topologies across all namespaces. A single-lab +inspect uses the selected namespace. +/// + +Useful kubernetes checks for the same state are: + +```bash +kubectl get topologies -A +kubectl -n get topology -o yaml +kubectl -n get deploy,pod,svc,cm,pvc \ + -l clabernetes/topologyOwner= +``` + +## Exec + +`exec` runs the user command in the nested node container: + +```bash +containerlab --runtime clabernetes exec -t topo.clab.yml --cmd 'ip addr' +``` + +Under the hood, containerlab: + +1. resolves the target nodes from the Clabernetes lab state +2. finds the launcher pod for each node +3. uses kubernetes pod exec into the launcher pod +4. runs `docker exec ` inside that launcher pod + +/// note +The command executes in the node container, not in the launcher pod shell. RBAC +must allow `pods/exec`, and the launcher pod must be ready. +/// + +## Start, stop, and restart + +Node lifecycle commands operate on the kubernetes Deployments created by +Clabernetes. + +```bash +containerlab --runtime clabernetes stop -t topo.clab.yml +containerlab --runtime clabernetes start -t topo.clab.yml +containerlab --runtime clabernetes restart -t topo.clab.yml +``` + +`stop` sets the Clabernetes ignore-reconcile label and scales the selected node +Deployments to `0`: + +```text +clabernetes/ignoreReconcile=true +``` + +The label prevents the Clabernetes manager from immediately reconciling the +nodes back to the running state. + +`start` scales the selected Deployments back to `1` and clears the +ignore-reconcile label when all nodes are running again. + +`restart` patches each selected Deployment with a restart annotation and waits +for it to become ready: + +```text +kubectl.kubernetes.io/restartedAt= +``` + +## Save + +Saving a c9s lab uses the containerlab process running inside each launcher pod: + +```bash +containerlab --runtime clabernetes save -t topo.clab.yml +``` + +For each selected node, the outer containerlab process finds the launcher pod +and runs: + +```bash +containerlab save -t /clabernetes/topo.clab.yaml +``` + +inside that pod. + +`save --copy` streams the saved files back to the machine where the outer +containerlab command runs: + +```bash +containerlab --runtime clabernetes save -t topo.clab.yml --copy ./startup-configs +``` + +The copied files follow the normal containerlab copy layout: + +```text +/// +``` + +For example: + +```text +./startup-configs/clab-clos/srl1/config-260605_085424.json +./startup-configs/clab-clos/srl1/config.json -> config-260605_085424.json +``` + +/// note +`save` still depends on node kind support. If a node kind does not produce saved +files, the c9s runtime has nothing to copy for that node. +/// + +## Events + +The c9s runtime can stream topology, pod, and interface-stat events: + +```bash +containerlab --runtime clabernetes events --format json +containerlab --runtime clabernetes events --initial-state +containerlab --runtime clabernetes events --interface-stats --format json +``` + +For c9s, events do not come from Docker events on the outer host. Containerlab +watches: + +- Clabernetes `Topology` resources +- Pods labeled with `clabernetes/topologyOwner` + +With `--initial-state`, the stream starts with synthetic events for the current +c9s node state and then continues with live watches. + +With `--interface-stats`, containerlab periodically execs through the launcher +pod and reads `/proc/net/dev` from the nested node container: + +```bash +docker exec cat /proc/net/dev +``` + +/// note | Polling, not netlink +c9s interface statistics are sampled periodically. The first sample seeds the +counters, and rates start with the second sample. Short-lived changes between +samples can be missed. +/// + +## Lab artifacts + +With c9s, the primary artifacts are kubernetes resources and files inside the +launcher pods. + +The main kubernetes resource is: + +```bash +kubectl -n get topology -o yaml +``` + +Related resources are selected with Clabernetes labels: + +```bash +kubectl -n get deploy,pod,svc,cm,pvc \ + -l clabernetes/topologyOwner= +``` + +To find one node launcher pod: + +```bash +kubectl -n get pod \ + -l clabernetes/topologyOwner=,clabernetes/topologyNode= +``` + +Inside each launcher pod, Clabernetes uses: + +```text +/clabernetes +``` + +The topology used by the inner containerlab process lives at: + +```text +/clabernetes/topo.clab.yaml +``` + +Per-node containerlab artifacts commonly live under: + +```text +/clabernetes/clab-clabernetes-// +``` + +/// tip +When debugging from inside a launcher pod, the usual containerlab and Docker +commands are useful again: + +```bash +containerlab inspect +docker ps +docker exec ip addr +ls -la /clabernetes +``` +/// + +## RBAC requirements + +The kube identity used by the outer containerlab process must be able to: + +- create, get, list, watch, update, and delete Clabernetes `Topology` resources +- list and watch Pods +- list, get, and update Deployments +- exec into launcher Pods with `pods/exec` + +Useful checks: + +```bash +kubectl auth can-i get topologies.clabernetes.containerlab.dev -n +kubectl auth can-i create topologies.clabernetes.containerlab.dev -n +kubectl auth can-i update topologies.clabernetes.containerlab.dev -n +kubectl auth can-i delete topologies.clabernetes.containerlab.dev -n +kubectl auth can-i list pods -n +kubectl auth can-i watch pods -A +kubectl auth can-i create pods/exec -n +kubectl auth can-i update deployments -n +``` + +## Troubleshooting + +### No kubeconfig or wrong context + +Typical symptoms: + +```text +failed to init the lab runtime: failed to load Kubernetes client config: ... +``` + +Check: + +```bash +kubectl config current-context +kubectl cluster-info +echo "$CLAB_KUBECONFIG" +echo "$CLAB_KUBE_CONTEXT" +``` + +Fix the kubeconfig, context, or cluster access, then run the containerlab command +again. + +### Namespace does not exist + +Typical symptoms: + +```text +failed to apply clabernetes topology /: namespaces "" not found +``` + +Check: + +```bash +kubectl get namespace +echo "$CLAB_KUBE_NAMESPACE" +kubectl config view --minify --output 'jsonpath={..namespace}{"\n"}' +``` + +Create the namespace or select an existing one: + +```bash +kubectl create namespace +export CLAB_KUBE_NAMESPACE= +``` + +### CRDs are missing + +The c9s runtime talks to: + +```text +topologies.clabernetes.containerlab.dev +``` + +Typical symptoms: + +```text +the server could not find the requested resource +``` + +Check: + +```bash +kubectl api-resources | grep -i clabernetes +kubectl get crd topologies.clabernetes.containerlab.dev +``` + +Install Clabernetes and its CRDs before using `--runtime clabernetes`. + +### Manager is not reconciling + +The CRD may exist and the `Topology` resource may be created, but no node +Deployments or Pods appear. + +Check: + +```bash +kubectl get pods -A | grep -i clabernetes +kubectl -n get topology -o yaml +kubectl -n get deploy,pod,svc,cm,pvc \ + -l clabernetes/topologyOwner= +``` + +If deploy waits until timeout, check the Clabernetes manager logs and verify +that it watches the namespace where the `Topology` was created. + +### Topology reports deployfailed + +During deploy, containerlab fails immediately if Clabernetes reports: + +```text +status.topologyState=deployfailed +``` + +Check: + +```bash +kubectl -n get topology -o yaml +kubectl -n describe topology +kubectl -n get deploy,pod,svc,cm,pvc \ + -l clabernetes/topologyOwner= +``` + +Common causes include bad topology data, image pull failures, missing pull +secrets, unsupported node settings, pod security policy, or a launcher pod that +cannot run nested Docker. + +### Inspect shows no containers + +For c9s, `inspect` looks for Clabernetes topologies, not local Docker +containers. + +Check: + +```bash +containerlab --runtime clabernetes inspect --all +kubectl get topologies -A +echo "$CLAB_KUBE_NAMESPACE" +``` + +If `docker ps` on the outer host is empty, that can be perfectly normal for c9s. +The node containers live inside launcher pods. + +### Exec, save, or stats cannot reach a node + +`exec`, `save`, `save --copy`, and `events --interface-stats` need pod exec into +the launcher pod. + +Check: + +```bash +kubectl -n get pod \ + -l clabernetes/topologyOwner=,clabernetes/topologyNode= \ + -o wide +kubectl auth can-i create pods/exec -n +kubectl -n exec -it -- sh +``` + +From inside the launcher pod: + +```bash +docker ps +docker exec true +ls -la /clabernetes/topo.clab.yaml +``` + +## Current limitations + +The c9s runtime is not a complete drop-in replacement for the local Docker or +Podman runtime. Several containerlab features still assume local containers, +local network namespaces, or direct access to the host container runtime. + +Known differences: + +- `deploy --node-filter` is not supported. +- Local Docker commands on the outer host are not authoritative for c9s labs. +- Local network namespace features are not equivalent in c9s. +- `inspect interfaces` and host-side `tc` or netem operations do not have the + same local namespace access they have with Docker labs. +- Some `tools` commands create local helper containers and are not modeled as + Clabernetes `Topology` resources. +- Per-node `runtime: docker` or `runtime: podman` is not the same as selecting + the global `clabernetes` lab runtime. +- Two c9s labs can have the same lab name in different namespaces. + +/// note +Use kubernetes and launcher-pod state as the source of truth for c9s labs: + +```bash +kubectl get topologies -A +kubectl -n get deploy,pod,svc,cm,pvc \ + -l clabernetes/topologyOwner= +``` +/// diff --git a/docs/manual/nodes.md b/docs/manual/nodes.md index 462d811295..077695f79a 100644 --- a/docs/manual/nodes.md +++ b/docs/manual/nodes.md @@ -576,16 +576,22 @@ If you want to completely disable the networking stack on a container, you can u ### runtime -By default containerlab nodes will be started by `docker` container runtime. Besides that, containerlab has experimental support for `podman` runtime. +By default containerlab nodes will be started by the `docker` container runtime. Besides that, containerlab has experimental support for the `podman` runtime. -It is possible to specify a global runtime with a global `--runtime` flag, or set the runtime on a per-node basis: +It is possible to specify a global local container runtime with the global `--runtime` flag, or set the runtime on a per-node basis: -Options for the runtime parameter are: +Options for the per-node `runtime` parameter are: - `docker` - `podman` -The default runtime can also be influenced via the `CLAB_RUNTIME` environment variable, which takes the same values as mentioned above. +The default runtime can also be influenced via the `CLAB_RUNTIME` environment variable. + +/// note | Clabernetes lab runtime +The global `--runtime` flag and `CLAB_RUNTIME` environment variable also accept `clabernetes`. This is a whole-lab runtime that sends the topology to kubernetes as a Clabernetes `Topology` resource. It is not a valid per-node `runtime:` value. + +See [Containerlab runtime](clabernetes/runtime.md) for details. +/// ```yaml # example node definition with per-node runtime definition diff --git a/mkdocs.yml b/mkdocs.yml index 9ef378783b..cddd889ded 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - manual/clabernetes/index.md - Install: manual/clabernetes/install.md - Quickstart: manual/clabernetes/quickstart.md + - Containerlab runtime: manual/clabernetes/runtime.md - Configuration: manual/clabernetes/configuration.md - Packet capture in c9s: manual/clabernetes/pcap.md - Node filtering: manual/node-filtering.md From 8f912cc7f717a3129c1c6b9084e12f81b05e8bff Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 15:24:05 +0200 Subject: [PATCH 05/21] tests for c9s runtime --- .github/workflows/cicd.yml | 11 ++ .github/workflows/clabernetes-tests.yml | 99 +++++++++++ Makefile | 1 + docs/manual/dev/test.md | 26 ++- .../01-linux-lifecycle.clab.yml | 20 +++ tests/14-clabernetes/01-linux-lifecycle.robot | 155 ++++++++++++++++++ tests/rf-run.sh | 42 ++++- 7 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/clabernetes-tests.yml create mode 100644 tests/14-clabernetes/01-linux-lifecycle.clab.yml create mode 100644 tests/14-clabernetes/01-linux-lifecycle.robot diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 5885010e5e..2599f6975e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -372,6 +372,15 @@ jobs: py_ver: ${{ needs.process-gitref.outputs.py_ver }} runtime: ${{ needs.process-gitref.outputs.runtime }} + clabernetes-tests: + uses: ./.github/workflows/clabernetes-tests.yml + needs: + - file-changes + - build-containerlab + - process-gitref + with: + py_ver: ${{ needs.process-gitref.outputs.py_ver }} + # sros-tests: # uses: ./.github/workflows/sros-tests.yml # needs: @@ -452,6 +461,7 @@ jobs: - ixiac-one-basic-tests - vxlan-tests - kind-tests + - clabernetes-tests - srsim-tests - fortigate-tests - cisco_iol-tests @@ -534,6 +544,7 @@ jobs: - ext-container-tests - vxlan-tests - kind-tests + - clabernetes-tests - srsim-tests - cisco_iol-tests - fortigate-tests diff --git a/.github/workflows/clabernetes-tests.yml b/.github/workflows/clabernetes-tests.yml new file mode 100644 index 0000000000..0b30ed00f0 --- /dev/null +++ b/.github/workflows/clabernetes-tests.yml @@ -0,0 +1,99 @@ +name: clabernetes-tests + +"on": + workflow_call: + inputs: + py_ver: + required: true + type: string + +jobs: + clabernetes-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v7 + with: + name: containerlab + + - name: Move containerlab to usr/bin + run: sudo mv ./containerlab /usr/bin/containerlab && sudo chown root:root /usr/bin/containerlab && sudo chmod 4755 /usr/bin/containerlab + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.5.28" + enable-cache: true + + - uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Install kubectl + run: | + curl -L -o ./kubectl "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x ./kubectl + sudo mv ./kubectl /usr/local/bin/kubectl + + - name: Install kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Create kind cluster + run: | + cat > /tmp/c9s-kind-config.yaml <<'EOF' + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + nodes: + - role: control-plane + - role: worker + containerdConfigPatches: + - |- + [plugins."io.containerd.grpc.v1.cri".containerd] + discard_unpacked_layers = false + EOF + + kind create cluster --name c9s --config /tmp/c9s-kind-config.yaml + kubectl wait --for=condition=Ready nodes --all --timeout=120s + + - name: Install Clabernetes + run: | + docker run --network host --rm \ + -v "${HOME}/.kube:/root/.kube" \ + -v "${HOME}/.helm:/root/.helm" \ + -v "${HOME}/.config/helm:/root/.config/helm" \ + -v "${HOME}/.cache/helm:/root/.cache/helm" \ + alpine/helm:3.12.3 \ + upgrade --install --create-namespace --namespace c9s \ + clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + + kubectl -n c9s rollout status deploy/clabernetes-manager --timeout=180s + + - name: Run Clabernetes Robot tests + run: | + bash ./tests/rf-run.sh clabernetes ./tests/14-clabernetes + + - name: Upload test report + uses: actions/upload-artifact@v6 + if: always() + with: + name: 14-clabernetes-log + path: ./tests/out/*.html + + - name: Upload coverage + uses: actions/upload-artifact@v6 + if: always() + with: + name: coverage-clabernetes-tests + path: /tmp/clab-tests/coverage/* + retention-days: 7 diff --git a/Makefile b/Makefile index c954431509..09f4b8fdbe 100644 --- a/Makefile +++ b/Makefile @@ -76,6 +76,7 @@ endif ifndef suite override suite = . endif +.PHONY: robot-test robot-test: build-with-podman-debug sudo chown root:root $(BINARY) && sudo chmod 4755 $(BINARY) CLAB_BIN=$(BINARY) $$PWD/tests/rf-run.sh $(runtime) $$PWD/tests/$(suite) diff --git a/docs/manual/dev/test.md b/docs/manual/dev/test.md index bfb53f8fe4..9d41ecc3c2 100644 --- a/docs/manual/dev/test.md +++ b/docs/manual/dev/test.md @@ -41,7 +41,12 @@ CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh ``` /// note -The test runner script requires you to specify the runtime as its first argument. The runtime can be either `docker` or `podman`. Containerlab primarily uses Docker as the default runtime, hence the number of tests written for docker outnumber the podman tests. +The test runner script requires you to specify the runtime as its first +argument. The runtime can be `docker`, `podman`, or `clabernetes`. Containerlab +primarily uses Docker as the default runtime, hence the number of tests written +for Docker outnumber the Podman and Clabernetes tests. When `clabernetes` is +selected, the runner automatically includes only Robot tests tagged +`clabernetes`. /// #### Selecting the test suite @@ -64,6 +69,25 @@ CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh docker tests/01-smoke/01-basi Selecting a specific test case in a test suite is not supported, since test suites are written in a way that test cases depend on previous ones. /// +#### Running Clabernetes tests + +The c9s/Clabernetes Robot tests require the same Kubernetes prerequisites as +the `containerlab --runtime clabernetes` command: a reachable cluster, +Clabernetes CRDs and manager installed, an existing target namespace, and RBAC +for the selected kubeconfig. + +To run all currently supported Clabernetes Robot tests: + +```bash +CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh clabernetes tests +``` + +or with the existing Makefile target: + +```bash +make robot-test runtime=clabernetes +``` + #### Inspecting the test results RobotFramework generates a detailed report in HTML and XML formats that can be found in the `tests/out` directory. The exact paths to the reports are printed to the console after the test run. diff --git a/tests/14-clabernetes/01-linux-lifecycle.clab.yml b/tests/14-clabernetes/01-linux-lifecycle.clab.yml new file mode 100644 index 0000000000..c1316416b4 --- /dev/null +++ b/tests/14-clabernetes/01-linux-lifecycle.clab.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=../../schemas/clab.schema.json +name: c9s-linux-lifecycle + +topology: + nodes: + client: + kind: linux + image: ghcr.io/srl-labs/network-multitool + exec: + - ip link set dev eth1 up + - ip addr add dev eth1 10.10.10.1/30 + server: + kind: linux + image: ghcr.io/srl-labs/network-multitool + exec: + - ip link set dev eth1 up + - ip addr add dev eth1 10.10.10.2/30 + + links: + - endpoints: ["client:eth1", "server:eth1"] diff --git a/tests/14-clabernetes/01-linux-lifecycle.robot b/tests/14-clabernetes/01-linux-lifecycle.robot new file mode 100644 index 0000000000..75048e6a63 --- /dev/null +++ b/tests/14-clabernetes/01-linux-lifecycle.robot @@ -0,0 +1,155 @@ +*** Settings *** +Library OperatingSystem +Library Process +Library String +Resource ../common.robot + +Suite Setup Setup +Suite Teardown Teardown +Test Tags clabernetes c9s + + +*** Variables *** +${runtime} clabernetes +${lab-name} c9s-linux-lifecycle +${lab-file} 01-linux-lifecycle.clab.yml +${topo} ${CURDIR}/${lab-file} +${client-label} clab-node-name\=client +${events-log} /tmp/clab-c9s-events.log +${events-err} /tmp/clab-c9s-events.err +${recovery-timeout} 180s +${retry-interval} 5s + + +*** Test Cases *** +Deploy c9s linux lab + ${output} = Run Clab Command deploy -t ${topo} + Should Be Equal As Integers ${output.rc} 0 + +Inspect c9s linux lab by topology and name + ${topology_inspect} = Run Clab Command inspect -t ${topo} + Should Be Equal As Integers ${topology_inspect.rc} 0 + Should Contain ${topology_inspect.stdout} ${lab-name} + Should Contain ${topology_inspect.stdout} client + Should Contain ${topology_inspect.stdout} server + + ${name_inspect} = Run Clab Command inspect --name ${lab-name} + Should Be Equal As Integers ${name_inspect.rc} 0 + Should Contain ${name_inspect.stdout} ${lab-name} + Should Contain ${name_inspect.stdout} client + Should Contain ${name_inspect.stdout} server + +Exec into client and verify dataplane + Wait Until Keyword Succeeds 60s ${retry-interval} Client Eth1 Should Be Visible + Wait Until Keyword Succeeds 60s ${retry-interval} Ping From Client Should Succeed + +Stop server and verify dataplane interruption + ${output} = Run Clab Command stop -t ${topo} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds 60s ${retry-interval} Ping From Client Should Fail + +Start server by lab name and verify dataplane restore + ${output} = Run Clab Command start --name ${lab-name} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds ${recovery-timeout} ${retry-interval} Ping From Client Should Succeed + +Restart server and keep dataplane working + ${output} = Run Clab Command restart -t ${topo} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds ${recovery-timeout} ${retry-interval} Ping From Client Should Succeed + +Events command emits c9s initial state + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + TRY + ${cmd} = Set Variable ${CLAB_BIN} --runtime ${runtime} events --format json --initial-state --interface-stats=false + Start Process ${cmd} + ... shell=True + ... alias=c9s_events + ... stdout=${events-log} + ... stderr=${events-err} + Sleep 5s + Stop Events Process + ${events} = Get File ${events-log} + Log ${events} + Should Contain ${events} "type":"container" + Should Contain ${events} ${lab-name}/client + Should Contain ${events} ${lab-name}/server + Validate JSON Lines ${events-log} + FINALLY + Stop Events Process + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + END + +Destroy c9s linux lab + ${output} = Run Clab Command destroy -t ${topo} --cleanup + Should Be Equal As Integers ${output.rc} 0 + + ${inspect_all} = Run Clab Command inspect --all + Should Not Contain ${inspect_all.stdout} ${lab-name} + + +*** Keywords *** +Setup + Skip If '${runtime}' != 'clabernetes' This suite targets the clabernetes runtime. + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + ${output} = Run Clab Command destroy -t ${topo} --cleanup + Log Cleanup return code: ${output.rc} + +Teardown + Stop Events Process + Run Clab Command destroy -t ${topo} --cleanup + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + +Run Clab Command + [Arguments] ${args} + ${output} = Process.Run Process + ... ${CLAB_BIN} --runtime ${runtime} ${args} + ... shell=True + Log stdout:${\n}${output.stdout} console=${True} + Log stderr:${\n}${output.stderr} console=${True} + RETURN ${output} + +Ping From Client Should Succeed + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ping -c 1 -W 2 10.10.10.2' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Be Equal As Integers ${output.rc} 0 + Should Match Regexp ${combined} (?s).*1 (packets )?received, 0% packet loss.* + +Ping From Client Should Fail + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ping -c 1 -W 2 10.10.10.2' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Match Regexp ${combined} (?s).*0 (packets )?received, 100% packet loss.* + +Client Eth1 Should Be Visible + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ip link show eth1' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Be Equal As Integers ${output.rc} 0 + Should Contain ${combined} eth1 + +Remove File If Exists + [Arguments] ${path} + Run Keyword And Ignore Error Remove File ${path} + +Stop Events Process + Run Keyword And Ignore Error Terminate Process c9s_events kill=True + Run Keyword And Ignore Error Wait For Process c9s_events + +Validate JSON Lines + [Arguments] ${path} + ${contents} = Get File ${path} + @{lines} = Split To Lines ${contents} + FOR ${line} IN @{lines} + ${stripped} = Strip String ${line} + IF $stripped == '' CONTINUE + Evaluate json.loads($stripped) modules=json + END diff --git a/tests/rf-run.sh b/tests/rf-run.sh index 1368aa15b3..e655298574 100755 --- a/tests/rf-run.sh +++ b/tests/rf-run.sh @@ -4,24 +4,41 @@ # SPDX-License-Identifier: BSD-3-Clause # arguments -# $1 - container runtime: [docker, podman] +# $1 - runtime: [docker, podman, clabernetes] # $2 - test suite to execute +# $3... - optional arguments passed to robot + +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: $0 " + exit 1 +fi + +runtime=$1 +suite=$2 +shift 2 +extra_robot_args=("$@") + +if [ "${runtime}" = "c9s" ]; then + runtime=clabernetes +fi # set containerlab binary path to a value of CLAB_BIN env variable # unless it is not set, then use 'containerlab' as a default value -if [ -z "${CLAB_BIN}" ]; then +if [ -z "${CLAB_BIN:-}" ]; then CLAB_BIN=containerlab fi export AWS_ACCESS_KEY_ID export AWS_SECRET_ACCESS_KEY -echo "Running tests with containerlab binary at $(which ${CLAB_BIN}) path and selected runtime: $1" +echo "Running tests with containerlab binary at $(command -v "${CLAB_BIN}") path and selected runtime: ${runtime}" COV_DIR=/tmp/clab-tests/coverage # coverage output directory -mkdir -p ${COV_DIR} +mkdir -p "${COV_DIR}" # parses the dir or file name passed to the rf-run.sh script # and in case of a directory, it returns the name of the directory @@ -38,7 +55,18 @@ function get_logname() { fi } -# activate venv -source .venv/bin/activate +robot_args=() +if [ "${runtime}" = "clabernetes" ]; then + robot_args+=(--include clabernetes) +fi -GOCOVERDIR=${COV_DIR} robot --consolecolors on -r none --variable CLAB_BIN:${CLAB_BIN} --variable runtime:$1 -l ./tests/out/$(get_logname $2)-$1-log --output ./tests/out/$(basename $2)-$1-out.xml $2 +GOCOVERDIR=${COV_DIR} uv run --project . -m robot \ + --consolecolors on \ + -r none \ + --variable CLAB_BIN:${CLAB_BIN} \ + --variable runtime:${runtime} \ + -l ./tests/out/$(get_logname "${suite}")-${runtime}-log \ + --output ./tests/out/$(basename "${suite}")-${runtime}-out.xml \ + "${extra_robot_args[@]}" \ + "${robot_args[@]}" \ + "${suite}" From 3d9141449f4807b10cbb11a917768570c6e12a82 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 15:56:20 +0200 Subject: [PATCH 06/21] fix: preserve root escalation for container runtimes --- cmd/deploy.go | 2 +- cmd/destroy.go | 2 +- cmd/events.go | 2 +- cmd/redeploy.go | 2 +- cmd/restart.go | 2 +- cmd/root.go | 10 +++++++--- cmd/root_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/start.go | 2 +- cmd/stop.go | 2 +- 9 files changed, 65 insertions(+), 10 deletions(-) create mode 100644 cmd/root_test.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 16f7f696b5..1df620780c 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -27,7 +27,7 @@ func deployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen Aliases: []string{"dep"}, SilenceUsage: true, PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/destroy.go b/cmd/destroy.go index b4c3fcf428..10d07f8fac 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -20,7 +20,7 @@ func destroyCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/destroy/", Aliases: []string{"des"}, PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/events.go b/cmd/events.go index e6842bd5c1..ae79894d16 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -14,7 +14,7 @@ func eventsCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/events/", Aliases: []string{"ev"}, PreRunE: func(*cobra.Command, []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/redeploy.go b/cmd/redeploy.go index bc5a0cda9b..ff9ab47a03 100644 --- a/cmd/redeploy.go +++ b/cmd/redeploy.go @@ -13,7 +13,7 @@ func redeployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen "reference: https://containerlab.dev/cmd/redeploy/", Aliases: []string{"rdep"}, PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/restart.go b/cmd/restart.go index 344fe019f0..54291b0da6 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -13,7 +13,7 @@ func restartCmd(o *Options) (*cobra.Command, error) { Use: "restart", Short: "Restart one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/root.go b/cmd/root.go index badd2f108e..61ef48287f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -156,7 +156,7 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { if err != nil { return err } - if runtimeRequiresRoot(o.Global.Runtime) { + if globalRuntimeRequiresRoot(o.Global.Runtime) { err := clabutils.CheckAndGetRootPrivs() if err != nil { return err @@ -166,10 +166,14 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { return getTopoFilePath(cobraCmd, o) } -func runtimeRequiresRoot(name string) bool { +func globalRuntimeRequiresRoot(name string) bool { return name != "" && name != clabruntimedocker.RuntimeName && - !labruntime.IsLabRuntimeName(name) + !commandSkipsRoot(name) +} + +func commandSkipsRoot(name string) bool { + return labruntime.IsLabRuntimeName(name) } // getTopoFilePath finds *.clab.y*ml file in the current working directory diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000..f083ac4ed3 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,51 @@ +package cmd + +import "testing" + +func TestRootRequirementHelpers(t *testing.T) { + tests := []struct { + name string + runtime string + wantGlobalRoot bool + wantCommandSkipped bool + }{ + { + name: "default runtime", + runtime: "", + wantGlobalRoot: false, + wantCommandSkipped: false, + }, + { + name: "docker runtime", + runtime: "docker", + wantGlobalRoot: false, + wantCommandSkipped: false, + }, + { + name: "podman runtime", + runtime: "podman", + wantGlobalRoot: true, + wantCommandSkipped: false, + }, + { + name: "clabernetes runtime", + runtime: "clabernetes", + wantGlobalRoot: false, + wantCommandSkipped: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := globalRuntimeRequiresRoot(tt.runtime); got != tt.wantGlobalRoot { + t.Fatalf("globalRuntimeRequiresRoot(%q) = %v, want %v", + tt.runtime, got, tt.wantGlobalRoot) + } + + if got := commandSkipsRoot(tt.runtime); got != tt.wantCommandSkipped { + t.Fatalf("commandSkipsRoot(%q) = %v, want %v", + tt.runtime, got, tt.wantCommandSkipped) + } + }) + } +} diff --git a/cmd/start.go b/cmd/start.go index fa9d2da2d4..f1f3afaba4 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,7 +13,7 @@ func startCmd(o *Options) (*cobra.Command, error) { Use: "start", Short: "Start one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } diff --git a/cmd/stop.go b/cmd/stop.go index 08941c25c3..ef31f93ab1 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -13,7 +13,7 @@ func stopCmd(o *Options) (*cobra.Command, error) { Use: "stop", Short: "Stop one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { - if !runtimeRequiresRoot(o.Global.Runtime) { + if commandSkipsRoot(o.Global.Runtime) { return nil } From 7cabfef58cc4e5560878936c545a07a6f6bbc0ed Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 17:10:35 +0200 Subject: [PATCH 07/21] fix clabernetes runtime edge cases --- core/config.go | 3 - core/destroy_test.go | 18 ++ core/events/stream.go | 20 +- core/options_clab.go | 14 +- labruntime/clabernetes/clabernetes.go | 92 +++++-- labruntime/clabernetes/clabernetes_test.go | 297 +++++++++++++++++++++ 6 files changed, 421 insertions(+), 23 deletions(-) create mode 100644 labruntime/clabernetes/clabernetes_test.go diff --git a/core/config.go b/core/config.go index 8949ae8f00..62e4e7fa43 100644 --- a/core/config.go +++ b/core/config.go @@ -657,9 +657,6 @@ func (c *CLab) HasKind(k string) bool { return false } -// addDefaultLabels adds default labels to node's config struct. -// Update the addDefaultLabels function in clab/config.go -// addDefaultLabels adds default labels to node's config struct. func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { if cfg.Labels == nil { cfg.Labels = map[string]string{} diff --git a/core/destroy_test.go b/core/destroy_test.go index e1b4cb714e..fe54c635fc 100644 --- a/core/destroy_test.go +++ b/core/destroy_test.go @@ -10,6 +10,7 @@ import ( "testing" claberrors "github.com/srl-labs/containerlab/errors" + "github.com/srl-labs/containerlab/labruntime" ) // makeCopyForDestroy must apply WithTopoPath (or WithLabNameOnly) before WithNodeFilter so that @@ -72,3 +73,20 @@ func TestWithLabNameOnly_setsNameWithoutTopologyFile(t *testing.T) { t.Fatal("topology file should not be set for lab-name-only init") } } + +type noopLabRuntime struct { + labruntime.LabRuntime +} + +func TestWithKeepMgmtNet_noopsForLabRuntime(t *testing.T) { + t.Parallel() + + c := &CLab{ + LabRuntime: noopLabRuntime{}, + globalRuntimeName: labruntime.ClabernetesRuntimeName, + } + + if err := WithKeepMgmtNet()(c); err != nil { + t.Fatalf("WithKeepMgmtNet returned error for lab runtime: %v", err) + } +} diff --git a/core/events/stream.go b/core/events/stream.go index 21af37ef1e..65d5b71761 100644 --- a/core/events/stream.go +++ b/core/events/stream.go @@ -126,13 +126,25 @@ func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Optio return fmt.Errorf("failed to stream events for lab runtime %q: %w", opts.Runtime, err) } - for { + for runtimeEvents != nil || runtimeErrs != nil { select { - case ev := <-runtimeEvents: + case ev, ok := <-runtimeEvents: + if !ok { + runtimeEvents = nil + + continue + } + if err := printer(aggregatedEventFromLabRuntimeEvent(ev)); err != nil { log.Debugf("failed to write event: %v", err) } - case err := <-runtimeErrs: + case err, ok := <-runtimeErrs: + if !ok { + runtimeErrs = nil + + continue + } + if err != nil && !errors.Is(err, context.Canceled) { return err } @@ -140,6 +152,8 @@ func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Optio return nil } } + + return nil } func aggregatedEventFromLabRuntimeEvent(ev labruntime.Event) aggregatedEvent { diff --git a/core/options_clab.go b/core/options_clab.go index 409ba68006..01b3ee6629 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -182,7 +182,19 @@ func runtimeTimeout(rtconfig *clabruntime.RuntimeConfig) time.Duration { func WithKeepMgmtNet() ClabOption { return func(c *CLab) error { - c.globalRuntime().WithKeepMgmtNet() + if c.LabRuntime != nil { + log.Debug("Ignoring keep management network option for lab runtime", + "runtime", c.globalRuntimeName) + + return nil + } + + r := c.globalRuntime() + if r == nil { + return fmt.Errorf("container runtime %q is not initialized", c.globalRuntimeName) + } + + r.WithKeepMgmtNet() return nil } diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 1f0d3b003e..eed55260f6 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -1048,30 +1048,55 @@ func (r *Runtime) watchTopologies( ) { resource := r.client.Resource(topologyGVR).Namespace(namespace) - watcher, err := resource.Watch(ctx, metav1.ListOptions{}) - if err != nil { - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) - return + for { + watcher, err := resource.Watch(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) + return + } + + if !r.forwardTopologyWatch(ctx, namespace, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } } +} + +func (r *Runtime) forwardTopologyWatch( + ctx context.Context, + namespace string, + watcher watch.Interface, + eventSink chan<- labruntime.Event, + errSink chan<- error, +) bool { defer watcher.Stop() for { select { case <-ctx.Done(): - return + return false case ev, ok := <-watcher.ResultChan(): if !ok { - return + log.Debug("clabernetes topology watch closed, reconnecting") + return true } if ev.Type == watch.Error { sendEventError(ctx, errSink, fmt.Errorf("clabernetes topology watch returned an error")) - continue + return false } obj, ok := ev.Object.(*unstructured.Unstructured) if !ok { continue } + state := stateFromTopology(obj, namespace) r.sendEvent(ctx, eventSink, labruntime.Event{ Timestamp: time.Now(), @@ -1096,26 +1121,49 @@ func (r *Runtime) watchPods( eventSink chan<- labruntime.Event, errSink chan<- error, ) { - watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ - LabelSelector: labelTopologyOwner, - }) - if err != nil { - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) - return + for { + watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) + return + } + + if !r.forwardPodWatch(ctx, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } } +} + +func (r *Runtime) forwardPodWatch( + ctx context.Context, + watcher watch.Interface, + eventSink chan<- labruntime.Event, + errSink chan<- error, +) bool { defer watcher.Stop() for { select { case <-ctx.Done(): - return + return false case ev, ok := <-watcher.ResultChan(): if !ok { - return + log.Debug("clabernetes pod watch closed, reconnecting") + return true } if ev.Type == watch.Error { sendEventError(ctx, errSink, fmt.Errorf("clabernetes pod watch returned an error")) - continue + return false } pod, ok := ev.Object.(*corev1.Pod) @@ -1149,6 +1197,18 @@ func (r *Runtime) watchPods( } } +func sleepContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + func (r *Runtime) pollInterfaceStats( ctx context.Context, namespace string, diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go new file mode 100644 index 0000000000..f8e3e41ac3 --- /dev/null +++ b/labruntime/clabernetes/clabernetes_test.go @@ -0,0 +1,297 @@ +package clabernetes + +import ( + "archive/tar" + "bytes" + "context" + "testing" + "time" + + clabconstants "github.com/srl-labs/containerlab/constants" + "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" +) + +func TestParseProcNetDev(t *testing.T) { + t.Parallel() + + data := []byte(`Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + eth0: 1234 12 0 0 0 0 0 0 5678 56 0 0 0 0 0 0 +`) + + stats, err := parseProcNetDev(data) + if err != nil { + t.Fatal(err) + } + if len(stats) != 1 { + t.Fatalf("len(stats) = %d, want 1", len(stats)) + } + + got := stats[0] + if got.Name != "eth0" || + got.RxBytes != 1234 || + got.RxPackets != 12 || + got.TxBytes != 5678 || + got.TxPackets != 56 { + t.Fatalf("unexpected stats: %+v", got) + } +} + +func TestParseProcNetDevRejectsMalformedLine(t *testing.T) { + t.Parallel() + + _, err := parseProcNetDev([]byte("eth0: 1 2 3\n")) + if err == nil { + t.Fatal("expected malformed /proc/net/dev line to return an error") + } +} + +func TestCleanTarPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + ok bool + }{ + {name: "relative path", in: "./configs/startup.json", want: "configs/startup.json", ok: true}, + {name: "current directory", in: ".", want: ".", ok: true}, + {name: "parent path", in: "../secret", ok: false}, + {name: "absolute path", in: "/etc/passwd", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := cleanTarPath(tt.in) + if ok != tt.ok || got != tt.want { + t.Fatalf("cleanTarPath(%q) = %q, %v; want %q, %v", + tt.in, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestSavedFilesFromTarSkipsUnsafeEntries(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + writeTarEntry(t, tw, &tar.Header{ + Name: "config.txt", + Mode: 0o644, + Size: int64(len("startup")), + }, []byte("startup")) + writeTarEntry(t, tw, &tar.Header{ + Name: "latest", + Typeflag: tar.TypeSymlink, + Mode: 0o777, + Linkname: "config.txt", + }, nil) + writeTarEntry(t, tw, &tar.Header{ + Name: "../secret", + Mode: 0o644, + Size: int64(len("secret")), + }, []byte("secret")) + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + files, err := savedFilesFromTar("node1", buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("len(files) = %d, want 2: %+v", len(files), files) + } + if files[0].NodeName != "node1" || files[0].Name != "config.txt" || + string(files[0].Data) != "startup" { + t.Fatalf("unexpected regular file: %+v", files[0]) + } + if files[1].Name != "latest" || files[1].LinkTarget != "config.txt" { + t.Fatalf("unexpected symlink: %+v", files[1]) + } +} + +func TestStateFromTopology(t *testing.T) { + t.Parallel() + + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "topologyReady": true, + "topologyState": "ready", + "nodeReadiness": map[string]any{ + "client": "ready", + "server": "notready", + }, + "exposedPorts": map[string]any{ + "client": map[string]any{ + "loadBalancerAddress": "192.0.2.10", + }, + "server": map[string]any{ + "loadBalancerAddress": "not-an-ip", + }, + }, + }, + "spec": map[string]any{ + "definition": map[string]any{ + "containerlab": `topology: + nodes: + client: + kind: linux + image: client:latest + server: + kind: srl + image: server:latest +`, + }, + }, + }, + } + obj.SetName("lab1") + obj.SetNamespace("lab-ns") + obj.SetLabels(map[string]string{clabconstants.Owner: "alice"}) + + state := stateFromTopology(obj, "fallback-ns") + if state.Name != "lab1" || + state.Namespace != "lab-ns" || + state.Owner != "alice" || + state.TopologyPath != "k8s://lab-ns/topologies/lab1" || + !state.Ready || + state.State != "ready" { + t.Fatalf("unexpected state metadata: %+v", state) + } + if len(state.Nodes) != 2 { + t.Fatalf("len(state.Nodes) = %d, want 2: %+v", len(state.Nodes), state.Nodes) + } + + client := state.Nodes[0] + if client.Name != "client" || + client.Kind != "linux" || + client.Image != "client:latest" || + client.State != "ready" || + !client.Ready || + client.LoadBalancerAddress != "192.0.2.10" { + t.Fatalf("unexpected client state: %+v", client) + } + + server := state.Nodes[1] + if server.Name != "server" || + server.Kind != "srl" || + server.Image != "server:latest" || + server.Ready || + server.LoadBalancerAddress != "" { + t.Fatalf("unexpected server state: %+v", server) + } +} + +func TestForwardPodWatchReconnectsOnClosedChannel(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + watcher.Stop() + + r := &Runtime{} + if !r.forwardPodWatch( + context.Background(), + watcher, + make(chan labruntime.Event, 1), + make(chan error, 1), + ) { + t.Fatal("expected closed pod watch to request reconnect") + } +} + +func TestForwardTopologyWatchReconnectsOnClosedChannel(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + watcher.Stop() + + r := &Runtime{} + if !r.forwardTopologyWatch( + context.Background(), + "default", + watcher, + make(chan labruntime.Event, 1), + make(chan error, 1), + ) { + t.Fatal("expected closed topology watch to request reconnect") + } +} + +func TestForwardPodWatchEmitsPodEvent(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + events := make(chan labruntime.Event, 1) + errs := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r := &Runtime{} + done := make(chan bool, 1) + go func() { + done <- r.forwardPodWatch(ctx, watcher, events, errs) + }() + + watcher.Add(&corev1.Pod{}) + watcher.Add(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "lab-ns", + Labels: map[string]string{ + labelTopologyOwner: "lab1", + labelTopologyNode: "node1", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + }, + }) + + got := <-events + if got.ActorID != "lab-ns/lab1/node1" || + got.ActorName != "lab1-node1" || + got.ActorFullID != "pod1" || + got.Attributes["phase"] != string(corev1.PodRunning) || + got.Attributes["pod_ip"] != "10.0.0.1" { + t.Fatalf("unexpected pod event: %+v", got) + } + + cancel() + watcher.Stop() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("forwardPodWatch did not exit after cancellation") + } +} + +func writeTarEntry(t *testing.T, tw *tar.Writer, hdr *tar.Header, data []byte) { + t.Helper() + + if hdr.Typeflag == 0 { + hdr.Typeflag = tar.TypeReg + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if len(data) == 0 { + return + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } +} From 5381d29f0ee01f4672b186b5315e25ec716c9952 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 17:18:57 +0200 Subject: [PATCH 08/21] align labruntime imports with style --- cmd/options.go | 4 +- cmd/root.go | 4 +- core/clab.go | 4 +- core/destroy_test.go | 6 +- core/events/stream.go | 6 +- core/labruntime.go | 40 +++---- core/options_clab.go | 6 +- labruntime/clabernetes/clabernetes.go | 118 ++++++++++----------- labruntime/clabernetes/clabernetes_test.go | 8 +- 9 files changed, 98 insertions(+), 98 deletions(-) diff --git a/cmd/options.go b/cmd/options.go index 0143cf8cbe..817cc96996 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -7,7 +7,7 @@ import ( clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" ) @@ -281,7 +281,7 @@ func (o *GlobalOptions) toClabOptions() []clabcore.ClabOption { } if o.TopologyFile == "" && o.TopologyName != "" && - !labruntime.IsLabRuntimeName(o.Runtime) { + !clablabruntime.IsLabRuntimeName(o.Runtime) { options = append(options, clabcore.WithTopologyFromLab(o.TopologyName, o.VarsFiles)) } diff --git a/cmd/root.go b/cmd/root.go index 61ef48287f..8ca23378c9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/log" "github.com/spf13/cobra" clabgit "github.com/srl-labs/containerlab/git" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -173,7 +173,7 @@ func globalRuntimeRequiresRoot(name string) bool { } func commandSkipsRoot(name string) bool { - return labruntime.IsLabRuntimeName(name) + return clablabruntime.IsLabRuntimeName(name) } // getTopoFilePath finds *.clab.y*ml file in the current working directory diff --git a/core/clab.go b/core/clab.go index 62ff3db257..2840f1b66d 100644 --- a/core/clab.go +++ b/core/clab.go @@ -20,7 +20,7 @@ import ( clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" claberrors "github.com/srl-labs/containerlab/errors" clabexec "github.com/srl-labs/containerlab/exec" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" _ "github.com/srl-labs/containerlab/labruntime/all" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" @@ -41,7 +41,7 @@ type CLab struct { Links map[int]clablinks.Link `json:"links,omitempty"` Endpoints []clablinks.Endpoint Runtimes map[string]clabruntime.ContainerRuntime `json:"runtimes,omitempty"` - LabRuntime labruntime.LabRuntime `json:"-"` + LabRuntime clablabruntime.LabRuntime `json:"-"` // reg is a registry of node kinds Reg *clabnodes.NodeRegistry Cert *clabcert.Cert diff --git a/core/destroy_test.go b/core/destroy_test.go index fe54c635fc..589deda1ae 100644 --- a/core/destroy_test.go +++ b/core/destroy_test.go @@ -10,7 +10,7 @@ import ( "testing" claberrors "github.com/srl-labs/containerlab/errors" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" ) // makeCopyForDestroy must apply WithTopoPath (or WithLabNameOnly) before WithNodeFilter so that @@ -75,7 +75,7 @@ func TestWithLabNameOnly_setsNameWithoutTopologyFile(t *testing.T) { } type noopLabRuntime struct { - labruntime.LabRuntime + clablabruntime.LabRuntime } func TestWithKeepMgmtNet_noopsForLabRuntime(t *testing.T) { @@ -83,7 +83,7 @@ func TestWithKeepMgmtNet_noopsForLabRuntime(t *testing.T) { c := &CLab{ LabRuntime: noopLabRuntime{}, - globalRuntimeName: labruntime.ClabernetesRuntimeName, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, } if err := WithKeepMgmtNet()(c); err != nil { diff --git a/core/events/stream.go b/core/events/stream.go index 65d5b71761..c96ea57610 100644 --- a/core/events/stream.go +++ b/core/events/stream.go @@ -11,7 +11,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -115,7 +115,7 @@ func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Optio runtimeEvents, runtimeErrs, err := clab.LabRuntime.StreamEvents( ctx, - labruntime.EventStreamRequest{ + clablabruntime.EventStreamRequest{ AllNamespaces: true, IncludeInitialState: opts.IncludeInitialState, IncludeInterfaceStats: opts.IncludeInterfaceStats, @@ -156,7 +156,7 @@ func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Optio return nil } -func aggregatedEventFromLabRuntimeEvent(ev labruntime.Event) aggregatedEvent { +func aggregatedEventFromLabRuntimeEvent(ev clablabruntime.Event) aggregatedEvent { ts := ev.Timestamp if ts.IsZero() { ts = time.Now() diff --git a/core/labruntime.go b/core/labruntime.go index 209fd1583c..469a294f1b 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -13,7 +13,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" "golang.org/x/term" @@ -33,7 +33,7 @@ func (c *CLab) deployWithLabRuntime( } if options != nil && options.reconfigure { - err := c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + err := c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ Name: c.Config.Name, Wait: true, Timeout: c.timeout, @@ -43,7 +43,7 @@ func (c *CLab) deployWithLabRuntime( } } - state, err := c.LabRuntime.Deploy(ctx, labruntime.DeployRequest{ + state, err := c.LabRuntime.Deploy(ctx, clablabruntime.DeployRequest{ Name: c.Config.Name, Owner: c.labOwner(), TopologyDefinition: c.renderedTopology, @@ -66,7 +66,7 @@ func (c *CLab) destroyWithLabRuntime(ctx context.Context, opts *DestroyOptions) return fmt.Errorf("topology name is required") } - return c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + return c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ Name: c.Config.Name, Wait: true, Timeout: c.timeout, @@ -74,7 +74,7 @@ func (c *CLab) destroyWithLabRuntime(ctx context.Context, opts *DestroyOptions) } func (c *CLab) destroyAllWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { - states, err := c.LabRuntime.List(ctx, labruntime.ListRequest{AllNamespaces: true}) + states, err := c.LabRuntime.List(ctx, clablabruntime.ListRequest{AllNamespaces: true}) if err != nil { return err } @@ -97,7 +97,7 @@ func (c *CLab) destroyAllWithLabRuntime(ctx context.Context, opts *DestroyOption var errs []error for _, state := range states { - if err := c.LabRuntime.Destroy(ctx, labruntime.DestroyRequest{ + if err := c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ Name: state.Name, Namespace: state.Namespace, Wait: true, @@ -131,7 +131,7 @@ func (c *CLab) ListLabRuntimeContainers( } if all || c.Config.Name == "" { - states, err := c.LabRuntime.List(ctx, labruntime.ListRequest{AllNamespaces: true}) + states, err := c.LabRuntime.List(ctx, clablabruntime.ListRequest{AllNamespaces: true}) if err != nil { return nil, err } @@ -148,7 +148,7 @@ func (c *CLab) ListLabRuntimeContainers( return nil, fmt.Errorf("topology name is required") } - state, err := c.LabRuntime.Inspect(ctx, labruntime.InspectRequest{Name: c.Config.Name}) + state, err := c.LabRuntime.Inspect(ctx, clablabruntime.InspectRequest{Name: c.Config.Name}) if err != nil { return nil, err } @@ -187,7 +187,7 @@ func (c *CLab) execWithLabRuntime( } for _, execCmd := range execCmds { - result, err := c.LabRuntime.Exec(ctx, labruntime.ExecRequest{ + result, err := c.LabRuntime.Exec(ctx, clablabruntime.ExecRequest{ Name: labName, Namespace: namespace, NodeName: nodeName, @@ -206,7 +206,7 @@ func (c *CLab) execWithLabRuntime( } func (c *CLab) startNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { - return c.LabRuntime.Start(ctx, labruntime.NodeRequest{ + return c.LabRuntime.Start(ctx, clablabruntime.NodeRequest{ Name: c.Config.Name, Nodes: nodeNames, Timeout: c.timeout, @@ -214,7 +214,7 @@ func (c *CLab) startNodesWithLabRuntime(ctx context.Context, nodeNames []string) } func (c *CLab) stopNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { - return c.LabRuntime.Stop(ctx, labruntime.NodeRequest{ + return c.LabRuntime.Stop(ctx, clablabruntime.NodeRequest{ Name: c.Config.Name, Nodes: nodeNames, Timeout: c.timeout, @@ -222,7 +222,7 @@ func (c *CLab) stopNodesWithLabRuntime(ctx context.Context, nodeNames []string) } func (c *CLab) restartNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { - return c.LabRuntime.Restart(ctx, labruntime.NodeRequest{ + return c.LabRuntime.Restart(ctx, clablabruntime.NodeRequest{ Name: c.Config.Name, Nodes: nodeNames, Timeout: c.timeout, @@ -238,7 +238,7 @@ func (c *CLab) saveWithLabRuntime(ctx context.Context, opts *SaveOptions) error opts.copyDst = resolvedDst } - result, err := c.LabRuntime.Save(ctx, labruntime.SaveRequest{ + result, err := c.LabRuntime.Save(ctx, clablabruntime.SaveRequest{ Name: c.Config.Name, Nodes: c.nodeFilter, Copy: opts.copyDst != "", @@ -254,7 +254,7 @@ func (c *CLab) saveWithLabRuntime(ctx context.Context, opts *SaveOptions) error return c.copyLabRuntimeSavedFiles(result, opts.copyDst) } -func (c *CLab) containersFromLabState(state *labruntime.LabState) []clabruntime.GenericContainer { +func (c *CLab) containersFromLabState(state *clablabruntime.LabState) []clabruntime.GenericContainer { if state == nil { return nil } @@ -267,9 +267,9 @@ func (c *CLab) containersFromLabState(state *labruntime.LabState) []clabruntime. } sort.Strings(nodeNames) - nodes = make([]labruntime.NodeState, 0, len(nodeNames)) + nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) for _, nodeName := range nodeNames { - nodes = append(nodes, labruntime.NodeState{ + nodes = append(nodes, clablabruntime.NodeState{ Name: nodeName, Kind: c.Config.Topology.GetNodeKind(nodeName), Image: c.Config.Topology.GetNodeImage(nodeName), @@ -288,8 +288,8 @@ func (c *CLab) containersFromLabState(state *labruntime.LabState) []clabruntime. } func (c *CLab) containerFromLabNode( - state *labruntime.LabState, - node labruntime.NodeState, + state *clablabruntime.LabState, + node clablabruntime.NodeState, ) clabruntime.GenericContainer { nodeName := fmt.Sprintf("%s-%s", state.Name, node.Name) containerState := node.State @@ -363,7 +363,7 @@ func managementAddress(addr string) clabruntime.GenericMgmtIPs { } } -func labRuntimeTopologyPath(c *CLab, state *labruntime.LabState) string { +func labRuntimeTopologyPath(c *CLab, state *clablabruntime.LabState) string { if c.TopoPaths.TopologyFileIsSet() { return c.TopoPaths.TopologyFilenameAbsPath() } @@ -450,7 +450,7 @@ func labRuntimeLabelMatches(labels map[string]string, filter *clabtypes.GenericF } func (c *CLab) copyLabRuntimeSavedFiles( - result *labruntime.SaveResult, + result *clablabruntime.SaveResult, dstRoot string, ) error { for _, file := range result.Files { diff --git a/core/options_clab.go b/core/options_clab.go index 01b3ee6629..7be321d823 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -10,7 +10,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -130,10 +130,10 @@ func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { return func(c *CLab) error { name = resolveRuntimeName(name) - if labruntime.IsLabRuntimeName(name) { + if clablabruntime.IsLabRuntimeName(name) { c.globalRuntimeName = name - lr, err := labruntime.Init(name, labruntime.Config{ + lr, err := clablabruntime.Init(name, clablabruntime.Config{ Debug: rtconfig != nil && rtconfig.Debug, Timeout: runtimeTimeout(rtconfig), }) diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index eed55260f6..04dcdff3f3 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -18,7 +18,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" "gopkg.in/yaml.v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -70,10 +70,10 @@ type Runtime struct { } func init() { - labruntime.Register(labruntime.ClabernetesRuntimeName, New) + clablabruntime.Register(clablabruntime.ClabernetesRuntimeName, New) } -func New(cfg labruntime.Config) (labruntime.LabRuntime, error) { +func New(cfg clablabruntime.Config) (clablabruntime.LabRuntime, error) { kubeConfig, namespace, err := kubeClientConfig() if err != nil { return nil, err @@ -102,8 +102,8 @@ func New(cfg labruntime.Config) (labruntime.LabRuntime, error) { }, nil } -func (r *Runtime) Capabilities() labruntime.RuntimeCapabilities { - return labruntime.RuntimeCapabilities{ +func (r *Runtime) Capabilities() clablabruntime.RuntimeCapabilities { + return clablabruntime.RuntimeCapabilities{ Deploy: true, Destroy: true, Inspect: true, @@ -119,8 +119,8 @@ func (r *Runtime) Capabilities() labruntime.RuntimeCapabilities { func (r *Runtime) Deploy( ctx context.Context, - req labruntime.DeployRequest, -) (*labruntime.LabState, error) { + req clablabruntime.DeployRequest, +) (*clablabruntime.LabState, error) { if req.Name == "" { return nil, fmt.Errorf("topology name is required") } @@ -152,17 +152,17 @@ func (r *Runtime) Deploy( } if !req.Wait { - return r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) } if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { return nil, err } - return r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) } -func (r *Runtime) Destroy(ctx context.Context, req labruntime.DestroyRequest) error { +func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { if req.Name == "" { return fmt.Errorf("topology name is required") } @@ -191,8 +191,8 @@ func (r *Runtime) Destroy(ctx context.Context, req labruntime.DestroyRequest) er func (r *Runtime) Inspect( ctx context.Context, - req labruntime.InspectRequest, -) (*labruntime.LabState, error) { + req clablabruntime.InspectRequest, +) (*clablabruntime.LabState, error) { if req.Name == "" { return nil, fmt.Errorf("topology name is required") } @@ -215,8 +215,8 @@ func (r *Runtime) Inspect( func (r *Runtime) List( ctx context.Context, - req labruntime.ListRequest, -) ([]*labruntime.LabState, error) { + req clablabruntime.ListRequest, +) ([]*clablabruntime.LabState, error) { namespace := r.namespaceFor(req.Namespace) if req.AllNamespaces { namespace = metav1.NamespaceAll @@ -228,7 +228,7 @@ func (r *Runtime) List( return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) } - states := make([]*labruntime.LabState, 0, len(list.Items)) + states := make([]*clablabruntime.LabState, 0, len(list.Items)) for idx := range list.Items { state := stateFromTopology(&list.Items[idx], namespace) if err := r.enrichState(ctx, state); err != nil { @@ -253,7 +253,7 @@ func (r *Runtime) List( func (r *Runtime) Exec( ctx context.Context, - req labruntime.ExecRequest, + req clablabruntime.ExecRequest, ) (*clabexec.ExecResult, error) { if req.Name == "" { return nil, fmt.Errorf("topology name is required") @@ -286,11 +286,11 @@ func (r *Runtime) Exec( return result, nil } -func (r *Runtime) Start(ctx context.Context, req labruntime.NodeRequest) error { +func (r *Runtime) Start(ctx context.Context, req clablabruntime.NodeRequest) error { return r.setNodesReplicas(ctx, req, 1) } -func (r *Runtime) Stop(ctx context.Context, req labruntime.NodeRequest) error { +func (r *Runtime) Stop(ctx context.Context, req clablabruntime.NodeRequest) error { if err := r.setTopologyIgnoreReconcile(ctx, req.Name, req.Namespace, true); err != nil { return err } @@ -298,7 +298,7 @@ func (r *Runtime) Stop(ctx context.Context, req labruntime.NodeRequest) error { return r.setNodesReplicas(ctx, req, 0) } -func (r *Runtime) Restart(ctx context.Context, req labruntime.NodeRequest) error { +func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) error { targets, namespace, err := r.targetNodes(ctx, req) if err != nil { return err @@ -338,9 +338,9 @@ func (r *Runtime) Restart(ctx context.Context, req labruntime.NodeRequest) error func (r *Runtime) Save( ctx context.Context, - req labruntime.SaveRequest, -) (*labruntime.SaveResult, error) { - targets, namespace, err := r.targetNodes(ctx, labruntime.NodeRequest{ + req clablabruntime.SaveRequest, +) (*clablabruntime.SaveResult, error) { + targets, namespace, err := r.targetNodes(ctx, clablabruntime.NodeRequest{ Name: req.Name, Namespace: req.Namespace, Nodes: req.Nodes, @@ -349,7 +349,7 @@ func (r *Runtime) Save( return nil, err } - result := &labruntime.SaveResult{} + result := &clablabruntime.SaveResult{} for _, nodeName := range targets { pod, err := r.launcherPod(ctx, req.Name, namespace, nodeName) if err != nil { @@ -398,9 +398,9 @@ func (r *Runtime) Save( func (r *Runtime) StreamEvents( ctx context.Context, - req labruntime.EventStreamRequest, -) (<-chan labruntime.Event, <-chan error, error) { - events := make(chan labruntime.Event, 128) + req clablabruntime.EventStreamRequest, +) (<-chan clablabruntime.Event, <-chan error, error) { + events := make(chan clablabruntime.Event, 128) errs := make(chan error, 2) namespace := r.namespaceFor(req.Namespace) @@ -478,7 +478,7 @@ func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeo func (r *Runtime) targetNodes( ctx context.Context, - req labruntime.NodeRequest, + req clablabruntime.NodeRequest, ) ([]string, string, error) { if req.Name == "" { return nil, "", fmt.Errorf("topology name is required") @@ -499,7 +499,7 @@ func (r *Runtime) targetNodes( } if len(known) == 0 { - state, err := r.Inspect(ctx, labruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) if err != nil { return nil, "", err } @@ -536,7 +536,7 @@ func (r *Runtime) targetNodes( func (r *Runtime) setNodesReplicas( ctx context.Context, - req labruntime.NodeRequest, + req clablabruntime.NodeRequest, replicas int32, ) error { targets, namespace, err := r.targetNodes(ctx, req) @@ -802,7 +802,7 @@ func (r *Runtime) collectSavedFiles( pod *corev1.Pod, nodeName, copyDir string, -) ([]labruntime.SavedFile, error) { +) ([]clablabruntime.SavedFile, error) { if copyDir == "" { return nil, nil } @@ -840,9 +840,9 @@ func (r *Runtime) collectSavedFiles( return files, nil } -func savedFilesFromTar(nodeName string, data []byte) ([]labruntime.SavedFile, error) { +func savedFilesFromTar(nodeName string, data []byte) ([]clablabruntime.SavedFile, error) { reader := tar.NewReader(bytes.NewReader(data)) - var files []labruntime.SavedFile + var files []clablabruntime.SavedFile for { header, err := reader.Next() @@ -865,14 +865,14 @@ func savedFilesFromTar(nodeName string, data []byte) ([]labruntime.SavedFile, er return nil, err } - files = append(files, labruntime.SavedFile{ + files = append(files, clablabruntime.SavedFile{ NodeName: nodeName, Name: name, Data: content, Mode: header.Mode, }) case tar.TypeSymlink: - files = append(files, labruntime.SavedFile{ + files = append(files, clablabruntime.SavedFile{ NodeName: nodeName, Name: name, Mode: header.Mode, @@ -895,7 +895,7 @@ func cleanTarPath(name string) (string, bool) { return cleaned, true } -func (r *Runtime) enrichState(ctx context.Context, state *labruntime.LabState) error { +func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabState) error { if state == nil || state.Name == "" { return nil } @@ -916,7 +916,7 @@ func (r *Runtime) enrichState(ctx context.Context, state *labruntime.LabState) e state.Namespace, state.Name, err) } - nodesByName := map[string]labruntime.NodeState{} + nodesByName := map[string]clablabruntime.NodeState{} for _, node := range state.Nodes { nodesByName[node.Name] = node } @@ -974,7 +974,7 @@ func (r *Runtime) enrichState(ctx context.Context, state *labruntime.LabState) e } sort.Strings(nodeNames) - state.Nodes = make([]labruntime.NodeState, 0, len(nodeNames)) + state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) allReady := len(nodeNames) > 0 allStopped := len(nodeNames) > 0 for _, nodeName := range nodeNames { @@ -1002,10 +1002,10 @@ func (r *Runtime) enrichState(ctx context.Context, state *labruntime.LabState) e func (r *Runtime) emitInitialEvents( ctx context.Context, namespace string, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, errSink chan<- error, ) { - states, err := r.List(ctx, labruntime.ListRequest{ + states, err := r.List(ctx, clablabruntime.ListRequest{ Namespace: namespace, AllNamespaces: namespace == metav1.NamespaceAll, }) @@ -1023,7 +1023,7 @@ func (r *Runtime) emitInitialEvents( if action == "" { action = state.State } - r.sendEvent(ctx, eventSink, labruntime.Event{ + r.sendEvent(ctx, eventSink, clablabruntime.Event{ Timestamp: time.Now(), Type: "container", Action: action, @@ -1043,7 +1043,7 @@ func (r *Runtime) emitInitialEvents( func (r *Runtime) watchTopologies( ctx context.Context, namespace string, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, errSink chan<- error, ) { resource := r.client.Resource(topologyGVR).Namespace(namespace) @@ -1073,7 +1073,7 @@ func (r *Runtime) forwardTopologyWatch( ctx context.Context, namespace string, watcher watch.Interface, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, errSink chan<- error, ) bool { defer watcher.Stop() @@ -1098,7 +1098,7 @@ func (r *Runtime) forwardTopologyWatch( } state := stateFromTopology(obj, namespace) - r.sendEvent(ctx, eventSink, labruntime.Event{ + r.sendEvent(ctx, eventSink, clablabruntime.Event{ Timestamp: time.Now(), Type: "topology", Action: strings.ToLower(string(ev.Type)), @@ -1118,7 +1118,7 @@ func (r *Runtime) forwardTopologyWatch( func (r *Runtime) watchPods( ctx context.Context, namespace string, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, errSink chan<- error, ) { for { @@ -1147,7 +1147,7 @@ func (r *Runtime) watchPods( func (r *Runtime) forwardPodWatch( ctx context.Context, watcher watch.Interface, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, errSink chan<- error, ) bool { defer watcher.Stop() @@ -1177,7 +1177,7 @@ func (r *Runtime) forwardPodWatch( continue } - r.sendEvent(ctx, eventSink, labruntime.Event{ + r.sendEvent(ctx, eventSink, clablabruntime.Event{ Timestamp: time.Now(), Type: "container", Action: strings.ToLower(string(ev.Type)), @@ -1213,7 +1213,7 @@ func (r *Runtime) pollInterfaceStats( ctx context.Context, namespace string, interval time.Duration, - eventSink chan<- labruntime.Event, + eventSink chan<- clablabruntime.Event, ) { if interval <= 0 { interval = time.Second @@ -1222,7 +1222,7 @@ func (r *Runtime) pollInterfaceStats( samples := map[string]c9sIfaceStatsSample{} sample := func() { - states, err := r.List(ctx, labruntime.ListRequest{ + states, err := r.List(ctx, clablabruntime.ListRequest{ Namespace: namespace, AllNamespaces: namespace == metav1.NamespaceAll, }) @@ -1383,13 +1383,13 @@ func c9sIfaceStatsKey(namespace, lab, node, ifName string) string { } func c9sIfaceStatsEvent( - state *labruntime.LabState, - node labruntime.NodeState, + state *clablabruntime.LabState, + node clablabruntime.NodeState, pod *corev1.Pod, stat c9sIfaceStats, previous, current c9sIfaceStatsSample, -) labruntime.Event { +) clablabruntime.Event { interval := current.Timestamp.Sub(previous.Timestamp) if interval <= 0 { interval = time.Second @@ -1407,7 +1407,7 @@ func c9sIfaceStatsEvent( podName = pod.Name } - return labruntime.Event{ + return clablabruntime.Event{ Timestamp: current.Timestamp, Type: "interface", Action: "stats", @@ -1445,8 +1445,8 @@ func counterDelta(current, previous uint64) uint64 { func (r *Runtime) sendEvent( ctx context.Context, - eventSink chan<- labruntime.Event, - event labruntime.Event, + eventSink chan<- clablabruntime.Event, + event clablabruntime.Event, ) { select { case eventSink <- event: @@ -1483,7 +1483,7 @@ func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { func topologyObject(name, namespace, owner, definition string) *unstructured.Unstructured { topologyLabels := map[string]any{ - "containerlab.dev/runtime": labruntime.ClabernetesRuntimeName, + "containerlab.dev/runtime": clablabruntime.ClabernetesRuntimeName, } topologyAnnotations := map[string]any{} if owner != "" { @@ -1548,7 +1548,7 @@ func kubeClientConfig() (*rest.Config, string, error) { return restConfig, namespace, nil } -func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labruntime.LabState { +func stateFromTopology(obj *unstructured.Unstructured, namespace string) *clablabruntime.LabState { if obj.GetNamespace() != "" { namespace = obj.GetNamespace() } @@ -1581,11 +1581,11 @@ func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labrun } sort.Strings(nodeNames) - nodes := make([]labruntime.NodeState, 0, len(nodeNames)) + nodes := make([]clablabruntime.NodeState, 0, len(nodeNames)) for _, nodeName := range nodeNames { nodeState := nodeReadiness[nodeName] spec := nodeSpecs[nodeName] - nodes = append(nodes, labruntime.NodeState{ + nodes = append(nodes, clablabruntime.NodeState{ Name: nodeName, Kind: spec.Kind, Image: spec.Image, @@ -1595,7 +1595,7 @@ func stateFromTopology(obj *unstructured.Unstructured, namespace string) *labrun }) } - return &labruntime.LabState{ + return &clablabruntime.LabState{ Name: obj.GetName(), Namespace: namespace, Owner: owner, diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index f8e3e41ac3..43084b7c18 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -8,7 +8,7 @@ import ( "time" clabconstants "github.com/srl-labs/containerlab/constants" - "github.com/srl-labs/containerlab/labruntime" + clablabruntime "github.com/srl-labs/containerlab/labruntime" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -204,7 +204,7 @@ func TestForwardPodWatchReconnectsOnClosedChannel(t *testing.T) { if !r.forwardPodWatch( context.Background(), watcher, - make(chan labruntime.Event, 1), + make(chan clablabruntime.Event, 1), make(chan error, 1), ) { t.Fatal("expected closed pod watch to request reconnect") @@ -222,7 +222,7 @@ func TestForwardTopologyWatchReconnectsOnClosedChannel(t *testing.T) { context.Background(), "default", watcher, - make(chan labruntime.Event, 1), + make(chan clablabruntime.Event, 1), make(chan error, 1), ) { t.Fatal("expected closed topology watch to request reconnect") @@ -233,7 +233,7 @@ func TestForwardPodWatchEmitsPodEvent(t *testing.T) { t.Parallel() watcher := watch.NewFake() - events := make(chan labruntime.Event, 1) + events := make(chan clablabruntime.Event, 1) errs := make(chan error, 1) ctx, cancel := context.WithCancel(context.Background()) defer cancel() From 1c002431c117ea7fd9225f3a901c0a7eef9dc6df Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 17:50:32 +0200 Subject: [PATCH 09/21] Prevent clabernetes deploy from updating existing labs --- docs/manual/clabernetes/runtime.md | 18 +-- labruntime/clabernetes/clabernetes.go | 28 ++-- labruntime/clabernetes/clabernetes_test.go | 148 +++++++++++++++++++++ 3 files changed, 177 insertions(+), 17 deletions(-) diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 6a9fcf000c..40dd4e70a3 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -54,7 +54,7 @@ The c9s runtime currently supports the main lab lifecycle and node operations: | Command | c9s behavior | | ------- | ------------ | -| `deploy` | creates or updates the Clabernetes `Topology` resource and waits for readiness | +| `deploy` | creates the Clabernetes `Topology` resource and waits for readiness | | `destroy` | deletes the `Topology` resource | | `inspect` | reads `Topology`, Deployment, Pod, and service status | | `exec` | execs through the launcher pod into the nested node container | @@ -124,7 +124,8 @@ CLAB_KUBE_NAMESPACE=lab-a \ containerlab --runtime clabernetes deploy -t topo.clab.yml ``` -creates or updates the `Topology` resource in the `lab-a` namespace. +creates the `Topology` resource in the `lab-a` namespace when a topology with +the same name does not already exist there. Some commands intentionally look across namespaces: @@ -158,7 +159,7 @@ The deploy flow is: 1. containerlab parses and checks the topology file. 2. It renders the final topology YAML. -3. It creates or updates a Clabernetes `Topology` resource. +3. It creates a Clabernetes `Topology` resource. 4. It waits until Clabernetes reports the topology as ready. 5. It inspects the resulting kubernetes state and prints the node table. @@ -172,14 +173,15 @@ Deploy the full topology, then use node filtering with commands such as `start`, `stop`, `restart`, `exec`, or `save` after the topology exists. /// -Deploy is create-or-update. If a `Topology` with the same name already exists in -the same namespace, containerlab updates it: +Deploy is create-only. If a `Topology` with the same name already exists in the +same namespace, containerlab fails the deployment: ```text -Updating clabernetes topology name= namespace= +the '' lab has already been deployed in namespace ''. ``` -Use a different lab name or namespace when you want a separate lab: +Use `deploy --reconfigure` to replace the existing lab, or use a different lab +name or namespace when you want a separate lab: ```bash containerlab --runtime clabernetes --name deploy -t topo.clab.yml @@ -443,7 +445,7 @@ again. Typical symptoms: ```text -failed to apply clabernetes topology /: namespaces "" not found +failed to create clabernetes topology /: namespaces "" not found ``` Check: diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 04dcdff3f3..15d7a0b9a4 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -133,22 +133,22 @@ func (r *Runtime) Deploy( resource := r.client.Resource(topologyGVR).Namespace(namespace) desired := topologyObject(req.Name, namespace, req.Owner, string(req.TopologyDefinition)) - existing, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + _, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) switch { case apierrors.IsNotFound(err): log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) - _, err = resource.Create(ctx, desired, metav1.CreateOptions{}) + if _, err = resource.Create(ctx, desired, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil, duplicateTopologyError(req.Name, namespace) + } + return nil, fmt.Errorf("failed to create clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } case err != nil: return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", namespace, req.Name, err) default: - log.Info("Updating clabernetes topology", "name", req.Name, "namespace", namespace) - desired.SetResourceVersion(existing.GetResourceVersion()) - _, err = resource.Update(ctx, desired, metav1.UpdateOptions{}) - } - if err != nil { - return nil, fmt.Errorf("failed to apply clabernetes topology %s/%s: %w", - namespace, req.Name, err) + return nil, duplicateTopologyError(req.Name, namespace) } if !req.Wait { @@ -162,6 +162,16 @@ func (r *Runtime) Deploy( return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) } +func duplicateTopologyError(name, namespace string) error { + return fmt.Errorf( + "the '%s' lab has already been deployed in namespace '%s'. "+ + "Destroy the lab before deploying a lab with the same name, "+ + "or use '--reconfigure' to redeploy it", + name, + namespace, + ) +} + func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { if req.Name == "" { return fmt.Errorf("topology name is required") diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index 43084b7c18..b0b5259243 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "context" + "strings" "testing" "time" @@ -12,7 +13,10 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/watch" + dynamicfake "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" ) func TestParseProcNetDev(t *testing.T) { @@ -194,6 +198,101 @@ func TestStateFromTopology(t *testing.T) { } } +func TestDeployCreatesTopology(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + Owner: "alice", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" || state.Owner != "alice" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + obj := getTestTopology(t, r, "lab-ns", "lab1") + if got := topologyDefinition(t, obj); got != definition { + t.Fatalf("topology definition = %q, want %q", got, definition) + } +} + +func TestDeployFailsWhenTopologyAlreadyExists(t *testing.T) { + t.Parallel() + + const existingDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.20 +` + const newDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.21 +` + + r := newTestRuntime(topologyObject("lab1", "lab-ns", "", existingDefinition)) + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(newDefinition), + Wait: false, + }) + if err == nil { + t.Fatal("expected duplicate topology deploy to fail") + } + if !strings.Contains(err.Error(), "already been deployed in namespace 'lab-ns'") { + t.Fatalf("unexpected error: %v", err) + } + + obj := getTestTopology(t, r, "lab-ns", "lab1") + if got := topologyDefinition(t, obj); got != existingDefinition { + t.Fatalf("topology definition was updated to %q, want %q", got, existingDefinition) + } +} + +func TestDeployDuplicateCheckIsNamespaceScoped(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime(topologyObject("lab1", "lab-a", "", definition)) + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-b", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-b" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + _ = getTestTopology(t, r, "lab-a", "lab1") + _ = getTestTopology(t, r, "lab-b", "lab1") +} + func TestForwardPodWatchReconnectsOnClosedChannel(t *testing.T) { t.Parallel() @@ -279,6 +378,55 @@ func TestForwardPodWatchEmitsPodEvent(t *testing.T) { } } +func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { + runtimeObjects := make([]k8sruntime.Object, 0, len(objects)) + for _, obj := range objects { + runtimeObjects = append(runtimeObjects, obj) + } + + return &Runtime{ + client: dynamicfake.NewSimpleDynamicClient(k8sruntime.NewScheme(), runtimeObjects...), + kubeClient: kubefake.NewSimpleClientset(), + namespace: defaultNamespace, + } +} + +func getTestTopology( + t *testing.T, + r *Runtime, + namespace string, + name string, +) *unstructured.Unstructured { + t.Helper() + + obj, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return obj +} + +func topologyDefinition(t *testing.T, obj *unstructured.Unstructured) string { + t.Helper() + + definition, found, err := unstructured.NestedString( + obj.Object, + "spec", + "definition", + "containerlab", + ) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("topology definition was not found") + } + + return definition +} + func writeTarEntry(t *testing.T, tw *tar.Writer, hdr *tar.Header, data []byte) { t.Helper() From 0fe8db16e493496cd30404946873c6f039b7e318 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 18:41:32 +0200 Subject: [PATCH 10/21] Split clabernetes runtime implementation --- labruntime/clabernetes/clabernetes.go | 1600 ------------------------- labruntime/clabernetes/config.go | 62 + labruntime/clabernetes/events.go | 269 +++++ labruntime/clabernetes/exec.go | 144 +++ labruntime/clabernetes/iface_stats.go | 248 ++++ labruntime/clabernetes/lifecycle.go | 212 ++++ labruntime/clabernetes/nodes.go | 293 +++++ labruntime/clabernetes/save.go | 175 +++ labruntime/clabernetes/state.go | 117 ++ labruntime/clabernetes/topology.go | 180 +++ 10 files changed, 1700 insertions(+), 1600 deletions(-) create mode 100644 labruntime/clabernetes/config.go create mode 100644 labruntime/clabernetes/events.go create mode 100644 labruntime/clabernetes/exec.go create mode 100644 labruntime/clabernetes/iface_stats.go create mode 100644 labruntime/clabernetes/lifecycle.go create mode 100644 labruntime/clabernetes/nodes.go create mode 100644 labruntime/clabernetes/save.go create mode 100644 labruntime/clabernetes/state.go create mode 100644 labruntime/clabernetes/topology.go diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 15d7a0b9a4..151f896cad 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -1,42 +1,14 @@ package clabernetes import ( - "archive/tar" - "bytes" - "context" - "errors" "fmt" - "io" - "net" - "os" - "path" - "sort" - "strconv" - "strings" "time" - "github.com/charmbracelet/log" - clabconstants "github.com/srl-labs/containerlab/constants" - clabexec "github.com/srl-labs/containerlab/exec" clablabruntime "github.com/srl-labs/containerlab/labruntime" - "gopkg.in/yaml.v2" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" - "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/remotecommand" - kubeexec "k8s.io/client-go/util/exec" ) const ( @@ -116,1575 +88,3 @@ func (r *Runtime) Capabilities() clablabruntime.RuntimeCapabilities { Events: true, } } - -func (r *Runtime) Deploy( - ctx context.Context, - req clablabruntime.DeployRequest, -) (*clablabruntime.LabState, error) { - if req.Name == "" { - return nil, fmt.Errorf("topology name is required") - } - - if len(req.TopologyDefinition) == 0 { - return nil, fmt.Errorf("rendered containerlab topology is required") - } - - namespace := r.namespaceFor(req.Namespace) - resource := r.client.Resource(topologyGVR).Namespace(namespace) - desired := topologyObject(req.Name, namespace, req.Owner, string(req.TopologyDefinition)) - - _, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) - switch { - case apierrors.IsNotFound(err): - log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) - if _, err = resource.Create(ctx, desired, metav1.CreateOptions{}); err != nil { - if apierrors.IsAlreadyExists(err) { - return nil, duplicateTopologyError(req.Name, namespace) - } - return nil, fmt.Errorf("failed to create clabernetes topology %s/%s: %w", - namespace, req.Name, err) - } - case err != nil: - return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", - namespace, req.Name, err) - default: - return nil, duplicateTopologyError(req.Name, namespace) - } - - if !req.Wait { - return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) - } - - if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { - return nil, err - } - - return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) -} - -func duplicateTopologyError(name, namespace string) error { - return fmt.Errorf( - "the '%s' lab has already been deployed in namespace '%s'. "+ - "Destroy the lab before deploying a lab with the same name, "+ - "or use '--reconfigure' to redeploy it", - name, - namespace, - ) -} - -func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { - if req.Name == "" { - return fmt.Errorf("topology name is required") - } - - namespace := r.namespaceFor(req.Namespace) - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - log.Info("Deleting clabernetes topology", "name", req.Name, "namespace", namespace) - - err := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}) - if apierrors.IsNotFound(err) { - log.Info("clabernetes topology not found", "name", req.Name, "namespace", namespace) - return nil - } - if err != nil { - return fmt.Errorf("failed to delete clabernetes topology %s/%s: %w", - namespace, req.Name, err) - } - - if !req.Wait { - return nil - } - - return r.waitDeleted(ctx, req.Name, namespace, req.Timeout) -} - -func (r *Runtime) Inspect( - ctx context.Context, - req clablabruntime.InspectRequest, -) (*clablabruntime.LabState, error) { - if req.Name == "" { - return nil, fmt.Errorf("topology name is required") - } - - namespace := r.namespaceFor(req.Namespace) - obj, err := r.client.Resource(topologyGVR).Namespace(namespace). - Get(ctx, req.Name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to inspect clabernetes topology %s/%s: %w", - namespace, req.Name, err) - } - - state := stateFromTopology(obj, namespace) - if err := r.enrichState(ctx, state); err != nil { - log.Debug("failed to enrich clabernetes topology state", "error", err) - } - - return state, nil -} - -func (r *Runtime) List( - ctx context.Context, - req clablabruntime.ListRequest, -) ([]*clablabruntime.LabState, error) { - namespace := r.namespaceFor(req.Namespace) - if req.AllNamespaces { - namespace = metav1.NamespaceAll - } - - list, err := r.client.Resource(topologyGVR).Namespace(namespace). - List(ctx, metav1.ListOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) - } - - states := make([]*clablabruntime.LabState, 0, len(list.Items)) - for idx := range list.Items { - state := stateFromTopology(&list.Items[idx], namespace) - if err := r.enrichState(ctx, state); err != nil { - log.Debug("failed to enrich clabernetes topology state", - "name", state.Name, - "namespace", state.Namespace, - "error", err, - ) - } - states = append(states, state) - } - - sort.Slice(states, func(i, j int) bool { - if states[i].Namespace == states[j].Namespace { - return states[i].Name < states[j].Name - } - return states[i].Namespace < states[j].Namespace - }) - - return states, nil -} - -func (r *Runtime) Exec( - ctx context.Context, - req clablabruntime.ExecRequest, -) (*clabexec.ExecResult, error) { - if req.Name == "" { - return nil, fmt.Errorf("topology name is required") - } - if req.NodeName == "" { - return nil, fmt.Errorf("node name is required") - } - if len(req.Command) == 0 { - return nil, fmt.Errorf("command is required") - } - - pod, err := r.launcherPod(ctx, req.Name, req.Namespace, req.NodeName) - if err != nil { - return nil, err - } - - execCmd := clabexec.NewExecCmdFromSlice(req.Command) - result := clabexec.NewExecResult(execCmd) - cmd := append([]string{"docker", "exec", req.NodeName}, req.Command...) - - stdout, stderr, rc, err := r.execInPod(ctx, pod, cmd) - if err != nil { - return nil, err - } - - result.SetReturnCode(rc) - result.SetStdOut(stdout) - result.SetStdErr(stderr) - - return result, nil -} - -func (r *Runtime) Start(ctx context.Context, req clablabruntime.NodeRequest) error { - return r.setNodesReplicas(ctx, req, 1) -} - -func (r *Runtime) Stop(ctx context.Context, req clablabruntime.NodeRequest) error { - if err := r.setTopologyIgnoreReconcile(ctx, req.Name, req.Namespace, true); err != nil { - return err - } - - return r.setNodesReplicas(ctx, req, 0) -} - -func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) error { - targets, namespace, err := r.targetNodes(ctx, req) - if err != nil { - return err - } - - now := time.Now().UTC().Format(time.RFC3339) - for _, nodeName := range targets { - deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) - if err != nil { - return err - } - - if deployment.Spec.Template.ObjectMeta.Annotations == nil { - deployment.Spec.Template.ObjectMeta.Annotations = map[string]string{} - } - deployment.Spec.Template.ObjectMeta.Annotations[restartedAtAnnotation] = now - - if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { - replicas := int32(1) - deployment.Spec.Replicas = &replicas - } - - _, err = r.kubeClient.AppsV1().Deployments(namespace). - Update(ctx, deployment, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("failed to restart clabernetes node %s/%s/%s: %w", - namespace, req.Name, nodeName, err) - } - - if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, 1, req.Timeout); err != nil { - return err - } - } - - return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) -} - -func (r *Runtime) Save( - ctx context.Context, - req clablabruntime.SaveRequest, -) (*clablabruntime.SaveResult, error) { - targets, namespace, err := r.targetNodes(ctx, clablabruntime.NodeRequest{ - Name: req.Name, - Namespace: req.Namespace, - Nodes: req.Nodes, - }) - if err != nil { - return nil, err - } - - result := &clablabruntime.SaveResult{} - for _, nodeName := range targets { - pod, err := r.launcherPod(ctx, req.Name, namespace, nodeName) - if err != nil { - return nil, err - } - - copyDir := "" - command := []string{"containerlab", "save", "-t", "/clabernetes/topo.clab.yaml"} - if req.Copy { - copyDir = fmt.Sprintf("/tmp/clab-save-copy-%s-%s-%d", - req.Name, nodeName, time.Now().UnixNano()) - _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", copyDir}) - command = append(command, "--copy", copyDir) - } - - stdout, stderr, rc, err := r.execInPod(ctx, pod, command) - if err != nil { - return nil, err - } - - if len(stdout) != 0 { - log.Info("clabernetes save output", "node", nodeName, "stdout", strings.TrimSpace(string(stdout))) - } - if len(stderr) != 0 { - log.Info("clabernetes save output", "node", nodeName, "stderr", strings.TrimSpace(string(stderr))) - } - if rc != 0 { - return nil, fmt.Errorf("save failed for clabernetes node %s/%s/%s: rc=%d", - namespace, req.Name, nodeName, rc) - } - - if req.Copy { - files, err := r.collectSavedFiles(ctx, pod, nodeName, copyDir) - if cleanupDir := copyDir; cleanupDir != "" { - _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", cleanupDir}) - } - if err != nil { - return nil, err - } - result.Files = append(result.Files, files...) - } - } - - return result, nil -} - -func (r *Runtime) StreamEvents( - ctx context.Context, - req clablabruntime.EventStreamRequest, -) (<-chan clablabruntime.Event, <-chan error, error) { - events := make(chan clablabruntime.Event, 128) - errs := make(chan error, 2) - - namespace := r.namespaceFor(req.Namespace) - if req.AllNamespaces { - namespace = metav1.NamespaceAll - } - - if req.IncludeInitialState { - go r.emitInitialEvents(ctx, namespace, events, errs) - } - - if req.IncludeInterfaceStats { - go r.pollInterfaceStats(ctx, namespace, req.StatsInterval, events) - } - - go r.watchTopologies(ctx, namespace, events, errs) - go r.watchPods(ctx, namespace, events, errs) - - return events, errs, nil -} - -func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout time.Duration) error { - waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) - defer cancel() - - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - return wait.PollUntilContextCancel(waitCtx, pollInterval, true, - func(ctx context.Context) (bool, error) { - obj, err := resource.Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", - namespace, name, err) - } - - state := stateFromTopology(obj, namespace) - if state.Ready { - return true, nil - } - if state.State == "deployfailed" { - return false, fmt.Errorf("clabernetes topology %s/%s reported deployfailed", - namespace, name) - } - - log.Debug("Waiting for clabernetes topology", - "name", name, - "namespace", namespace, - "state", state.State, - ) - - return false, nil - }) -} - -func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeout time.Duration) error { - waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) - defer cancel() - - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - return wait.PollUntilContextCancel(waitCtx, pollInterval, true, - func(ctx context.Context) (bool, error) { - _, err := resource.Get(ctx, name, metav1.GetOptions{}) - switch { - case apierrors.IsNotFound(err): - return true, nil - case err != nil: - return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", - namespace, name, err) - default: - return false, nil - } - }) -} - -func (r *Runtime) targetNodes( - ctx context.Context, - req clablabruntime.NodeRequest, -) ([]string, string, error) { - if req.Name == "" { - return nil, "", fmt.Errorf("topology name is required") - } - - namespace := r.namespaceFor(req.Namespace) - deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) - if err != nil { - return nil, "", err - } - - known := map[string]struct{}{} - for idx := range deployments.Items { - nodeName := deployments.Items[idx].Labels[labelTopologyNode] - if nodeName != "" { - known[nodeName] = struct{}{} - } - } - - if len(known) == 0 { - state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) - if err != nil { - return nil, "", err - } - for _, node := range state.Nodes { - known[node.Name] = struct{}{} - } - } - - if len(known) == 0 { - return nil, "", fmt.Errorf("topology %s/%s has no nodes", namespace, req.Name) - } - - var targets []string - if len(req.Nodes) == 0 { - targets = make([]string, 0, len(known)) - for nodeName := range known { - targets = append(targets, nodeName) - } - sort.Strings(targets) - - return targets, namespace, nil - } - - for _, nodeName := range req.Nodes { - if _, ok := known[nodeName]; !ok { - return nil, "", fmt.Errorf("node %q was not found in topology %s/%s", - nodeName, namespace, req.Name) - } - targets = append(targets, nodeName) - } - - return targets, namespace, nil -} - -func (r *Runtime) setNodesReplicas( - ctx context.Context, - req clablabruntime.NodeRequest, - replicas int32, -) error { - targets, namespace, err := r.targetNodes(ctx, req) - if err != nil { - return err - } - - for _, nodeName := range targets { - deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) - if err != nil { - return err - } - - deployment.Spec.Replicas = &replicas - _, err = r.kubeClient.AppsV1().Deployments(namespace). - Update(ctx, deployment, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("failed to set clabernetes node %s/%s/%s replicas to %d: %w", - namespace, req.Name, nodeName, replicas, err) - } - - if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, replicas, req.Timeout); err != nil { - return err - } - } - - if replicas > 0 { - return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) - } - - return nil -} - -func (r *Runtime) clearIgnoreWhenAllStarted(ctx context.Context, name, namespace string) error { - deployments, err := r.deploymentsForTopology(ctx, name, namespace) - if err != nil { - return err - } - - for idx := range deployments.Items { - replicas := int32(1) - if deployments.Items[idx].Spec.Replicas != nil { - replicas = *deployments.Items[idx].Spec.Replicas - } - if replicas == 0 { - return nil - } - } - - return r.setTopologyIgnoreReconcile(ctx, name, namespace, false) -} - -func (r *Runtime) setTopologyIgnoreReconcile( - ctx context.Context, - name, - namespace string, - enabled bool, -) error { - if name == "" { - return fmt.Errorf("topology name is required") - } - - namespace = r.namespaceFor(namespace) - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - obj, err := resource.Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("failed to get clabernetes topology %s/%s: %w", - namespace, name, err) - } - - labelsMap := obj.GetLabels() - if labelsMap == nil { - labelsMap = map[string]string{} - } - - if enabled { - labelsMap[labelIgnoreReconcile] = "true" - } else { - delete(labelsMap, labelIgnoreReconcile) - } - - obj.SetLabels(labelsMap) - - _, err = resource.Update(ctx, obj, metav1.UpdateOptions{}) - if err != nil { - return fmt.Errorf("failed to update clabernetes topology %s/%s labels: %w", - namespace, name, err) - } - - return nil -} - -func (r *Runtime) waitDeploymentReplicas( - ctx context.Context, - namespace, - name string, - replicas int32, - timeout time.Duration, -) error { - waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) - defer cancel() - - return wait.PollUntilContextCancel(waitCtx, pollInterval, true, - func(ctx context.Context) (bool, error) { - deployment, err := r.kubeClient.AppsV1().Deployments(namespace). - Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return false, fmt.Errorf("failed to get clabernetes deployment %s/%s: %w", - namespace, name, err) - } - - if replicas == 0 { - return deployment.Status.Replicas == 0 && - deployment.Status.AvailableReplicas == 0, nil - } - - return deployment.Status.ReadyReplicas >= replicas && - deployment.Status.AvailableReplicas >= replicas, nil - }) -} - -func (r *Runtime) deploymentsForTopology( - ctx context.Context, - name, - namespace string, -) (*appsv1.DeploymentList, error) { - namespace = r.namespaceFor(namespace) - list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.Set{ - labelApp: clabernetesAppValue, - labelTopologyOwner: name, - }.String(), - }) - if err != nil { - return nil, fmt.Errorf("failed to list clabernetes deployments for topology %s/%s: %w", - namespace, name, err) - } - - return list, nil -} - -func (r *Runtime) deploymentForNode( - ctx context.Context, - name, - namespace, - nodeName string, -) (*appsv1.Deployment, error) { - namespace = r.namespaceFor(namespace) - list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.Set{ - labelApp: clabernetesAppValue, - labelTopologyOwner: name, - labelTopologyNode: nodeName, - }.String(), - }) - if err != nil { - return nil, fmt.Errorf("failed to list clabernetes deployment for node %s/%s/%s: %w", - namespace, name, nodeName, err) - } - if len(list.Items) == 0 { - return nil, fmt.Errorf("clabernetes deployment for node %s/%s/%s was not found", - namespace, name, nodeName) - } - - return &list.Items[0], nil -} - -func (r *Runtime) launcherPod( - ctx context.Context, - name, - namespace, - nodeName string, -) (*corev1.Pod, error) { - namespace = r.namespaceFor(namespace) - list, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.Set{ - labelApp: clabernetesAppValue, - labelTopologyOwner: name, - labelTopologyNode: nodeName, - }.String(), - }) - if err != nil { - return nil, fmt.Errorf("failed to list clabernetes launcher pods for node %s/%s/%s: %w", - namespace, name, nodeName, err) - } - if len(list.Items) == 0 { - return nil, fmt.Errorf("clabernetes launcher pod for node %s/%s/%s was not found", - namespace, name, nodeName) - } - - for idx := range list.Items { - if list.Items[idx].Status.Phase == corev1.PodRunning { - return &list.Items[idx], nil - } - } - - return &list.Items[0], nil -} - -func (r *Runtime) execInPod( - ctx context.Context, - pod *corev1.Pod, - command []string, -) ([]byte, []byte, int, error) { - if pod == nil { - return nil, nil, 0, fmt.Errorf("launcher pod is nil") - } - if len(command) == 0 { - return nil, nil, 0, fmt.Errorf("command is required") - } - - containerName := "" - if len(pod.Spec.Containers) != 0 { - containerName = pod.Spec.Containers[0].Name - } - - req := r.kubeClient.CoreV1().RESTClient().Post(). - Resource("pods"). - Name(pod.Name). - Namespace(pod.Namespace). - SubResource("exec"). - VersionedParams(&corev1.PodExecOptions{ - Container: containerName, - Command: command, - Stdout: true, - Stderr: true, - TTY: false, - }, scheme.ParameterCodec) - - executor, err := remotecommand.NewSPDYExecutor(r.restConfig, "POST", req.URL()) - if err != nil { - return nil, nil, 0, fmt.Errorf("failed to create Kubernetes exec executor: %w", err) - } - - var stdout bytes.Buffer - var stderr bytes.Buffer - - err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ - Stdout: &stdout, - Stderr: &stderr, - Tty: false, - }) - - rc := 0 - if err != nil { - var exitErr kubeexec.ExitError - if errors.As(err, &exitErr) { - rc = exitErr.ExitStatus() - err = nil - } - } - if err != nil { - return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf("failed to execute command in pod %s/%s: %w", - pod.Namespace, pod.Name, err) - } - - return stdout.Bytes(), stderr.Bytes(), rc, nil -} - -func (r *Runtime) collectSavedFiles( - ctx context.Context, - pod *corev1.Pod, - nodeName, - copyDir string, -) ([]clablabruntime.SavedFile, error) { - if copyDir == "" { - return nil, nil - } - - nodeCopyDir := path.Join(copyDir, "clab-clabernetes-"+nodeName, nodeName) - _, _, rc, err := r.execInPod(ctx, pod, []string{"test", "-d", nodeCopyDir}) - if err != nil { - return nil, err - } - if rc != 0 { - log.Debug("no clabernetes saved config copy directory found", - "node", nodeName, - "path", nodeCopyDir, - ) - - return nil, nil - } - - stdout, stderr, rc, err := r.execInPod(ctx, pod, - []string{"tar", "cf", "-", "-C", nodeCopyDir, "."}) - if err != nil { - return nil, err - } - if rc != 0 { - return nil, fmt.Errorf("failed to archive saved config copy for node %s: rc=%d stderr=%s", - nodeName, rc, strings.TrimSpace(string(stderr))) - } - - files, err := savedFilesFromTar(nodeName, stdout) - if err != nil { - return nil, fmt.Errorf("failed to read saved config archive for node %s: %w", - nodeName, err) - } - - return files, nil -} - -func savedFilesFromTar(nodeName string, data []byte) ([]clablabruntime.SavedFile, error) { - reader := tar.NewReader(bytes.NewReader(data)) - var files []clablabruntime.SavedFile - - for { - header, err := reader.Next() - switch { - case errors.Is(err, io.EOF): - return files, nil - case err != nil: - return nil, err - } - - name, ok := cleanTarPath(header.Name) - if !ok || name == "." { - continue - } - - switch header.Typeflag { - case tar.TypeReg, tar.TypeRegA: - content, err := io.ReadAll(reader) - if err != nil { - return nil, err - } - - files = append(files, clablabruntime.SavedFile{ - NodeName: nodeName, - Name: name, - Data: content, - Mode: header.Mode, - }) - case tar.TypeSymlink: - files = append(files, clablabruntime.SavedFile{ - NodeName: nodeName, - Name: name, - Mode: header.Mode, - LinkTarget: header.Linkname, - }) - } - } -} - -func cleanTarPath(name string) (string, bool) { - name = strings.TrimPrefix(name, "./") - cleaned := path.Clean(name) - if cleaned == "." || cleaned == "" { - return cleaned, true - } - if strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || cleaned == ".." { - return "", false - } - - return cleaned, true -} - -func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabState) error { - if state == nil || state.Name == "" { - return nil - } - - deployments, err := r.deploymentsForTopology(ctx, state.Name, state.Namespace) - if err != nil { - return err - } - - pods, err := r.kubeClient.CoreV1().Pods(state.Namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labels.Set{ - labelApp: clabernetesAppValue, - labelTopologyOwner: state.Name, - }.String(), - }) - if err != nil { - return fmt.Errorf("failed to list clabernetes pods for topology %s/%s: %w", - state.Namespace, state.Name, err) - } - - nodesByName := map[string]clablabruntime.NodeState{} - for _, node := range state.Nodes { - nodesByName[node.Name] = node - } - - podsByNode := map[string]*corev1.Pod{} - for idx := range pods.Items { - nodeName := pods.Items[idx].Labels[labelTopologyNode] - if nodeName == "" { - continue - } - if pods.Items[idx].Status.Phase == corev1.PodRunning { - podsByNode[nodeName] = &pods.Items[idx] - continue - } - if _, ok := podsByNode[nodeName]; !ok { - podsByNode[nodeName] = &pods.Items[idx] - } - } - - for idx := range deployments.Items { - deployment := &deployments.Items[idx] - nodeName := deployment.Labels[labelTopologyNode] - if nodeName == "" { - continue - } - - node := nodesByName[nodeName] - node.Name = nodeName - replicas := int32(1) - if deployment.Spec.Replicas != nil { - replicas = *deployment.Spec.Replicas - } - - switch { - case replicas == 0: - node.State = "stopped" - node.Ready = false - case deployment.Status.ReadyReplicas > 0: - node.State = "ready" - node.Ready = true - case podsByNode[nodeName] != nil && podsByNode[nodeName].Status.Phase != "": - node.State = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) - node.Ready = false - default: - node.State = "notready" - node.Ready = false - } - - nodesByName[nodeName] = node - } - - nodeNames := make([]string, 0, len(nodesByName)) - for nodeName := range nodesByName { - nodeNames = append(nodeNames, nodeName) - } - sort.Strings(nodeNames) - - state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) - allReady := len(nodeNames) > 0 - allStopped := len(nodeNames) > 0 - for _, nodeName := range nodeNames { - node := nodesByName[nodeName] - state.Nodes = append(state.Nodes, node) - allReady = allReady && node.Ready - allStopped = allStopped && node.State == "stopped" - } - - switch { - case allReady: - state.State = "running" - state.Ready = true - case allStopped: - state.State = "stopped" - state.Ready = false - case len(nodeNames) != 0: - state.State = "partial" - state.Ready = false - } - - return nil -} - -func (r *Runtime) emitInitialEvents( - ctx context.Context, - namespace string, - eventSink chan<- clablabruntime.Event, - errSink chan<- error, -) { - states, err := r.List(ctx, clablabruntime.ListRequest{ - Namespace: namespace, - AllNamespaces: namespace == metav1.NamespaceAll, - }) - if err != nil { - sendEventError(ctx, errSink, err) - return - } - - for _, state := range states { - for _, node := range state.Nodes { - action := node.State - if node.Ready { - action = "running" - } - if action == "" { - action = state.State - } - r.sendEvent(ctx, eventSink, clablabruntime.Event{ - Timestamp: time.Now(), - Type: "container", - Action: action, - ActorID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), - ActorName: fmt.Sprintf("%s-%s", state.Name, node.Name), - Attributes: map[string]string{ - "namespace": state.Namespace, - "lab": state.Name, - "node": node.Name, - "state": node.State, - }, - }) - } - } -} - -func (r *Runtime) watchTopologies( - ctx context.Context, - namespace string, - eventSink chan<- clablabruntime.Event, - errSink chan<- error, -) { - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - for { - watcher, err := resource.Watch(ctx, metav1.ListOptions{}) - if err != nil { - if ctx.Err() != nil { - return - } - - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) - return - } - - if !r.forwardTopologyWatch(ctx, namespace, watcher, eventSink, errSink) { - return - } - - if !sleepContext(ctx, pollInterval) { - return - } - } -} - -func (r *Runtime) forwardTopologyWatch( - ctx context.Context, - namespace string, - watcher watch.Interface, - eventSink chan<- clablabruntime.Event, - errSink chan<- error, -) bool { - defer watcher.Stop() - - for { - select { - case <-ctx.Done(): - return false - case ev, ok := <-watcher.ResultChan(): - if !ok { - log.Debug("clabernetes topology watch closed, reconnecting") - return true - } - if ev.Type == watch.Error { - sendEventError(ctx, errSink, fmt.Errorf("clabernetes topology watch returned an error")) - return false - } - - obj, ok := ev.Object.(*unstructured.Unstructured) - if !ok { - continue - } - - state := stateFromTopology(obj, namespace) - r.sendEvent(ctx, eventSink, clablabruntime.Event{ - Timestamp: time.Now(), - Type: "topology", - Action: strings.ToLower(string(ev.Type)), - ActorID: fmt.Sprintf("%s/%s", state.Namespace, state.Name), - ActorName: state.Name, - Attributes: map[string]string{ - "namespace": state.Namespace, - "lab": state.Name, - "state": state.State, - "ready": fmt.Sprintf("%t", state.Ready), - }, - }) - } - } -} - -func (r *Runtime) watchPods( - ctx context.Context, - namespace string, - eventSink chan<- clablabruntime.Event, - errSink chan<- error, -) { - for { - watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ - LabelSelector: labelTopologyOwner, - }) - if err != nil { - if ctx.Err() != nil { - return - } - - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) - return - } - - if !r.forwardPodWatch(ctx, watcher, eventSink, errSink) { - return - } - - if !sleepContext(ctx, pollInterval) { - return - } - } -} - -func (r *Runtime) forwardPodWatch( - ctx context.Context, - watcher watch.Interface, - eventSink chan<- clablabruntime.Event, - errSink chan<- error, -) bool { - defer watcher.Stop() - - for { - select { - case <-ctx.Done(): - return false - case ev, ok := <-watcher.ResultChan(): - if !ok { - log.Debug("clabernetes pod watch closed, reconnecting") - return true - } - if ev.Type == watch.Error { - sendEventError(ctx, errSink, fmt.Errorf("clabernetes pod watch returned an error")) - return false - } - - pod, ok := ev.Object.(*corev1.Pod) - if !ok { - continue - } - - labName := pod.Labels[labelTopologyOwner] - nodeName := pod.Labels[labelTopologyNode] - if labName == "" || nodeName == "" { - continue - } - - r.sendEvent(ctx, eventSink, clablabruntime.Event{ - Timestamp: time.Now(), - Type: "container", - Action: strings.ToLower(string(ev.Type)), - ActorID: fmt.Sprintf("%s/%s/%s", pod.Namespace, labName, nodeName), - ActorName: fmt.Sprintf("%s-%s", labName, nodeName), - ActorFullID: pod.Name, - Attributes: map[string]string{ - "namespace": pod.Namespace, - "lab": labName, - "node": nodeName, - "pod": pod.Name, - "phase": string(pod.Status.Phase), - "pod_ip": pod.Status.PodIP, - }, - }) - } - } -} - -func sleepContext(ctx context.Context, d time.Duration) bool { - timer := time.NewTimer(d) - defer timer.Stop() - - select { - case <-timer.C: - return true - case <-ctx.Done(): - return false - } -} - -func (r *Runtime) pollInterfaceStats( - ctx context.Context, - namespace string, - interval time.Duration, - eventSink chan<- clablabruntime.Event, -) { - if interval <= 0 { - interval = time.Second - } - - samples := map[string]c9sIfaceStatsSample{} - - sample := func() { - states, err := r.List(ctx, clablabruntime.ListRequest{ - Namespace: namespace, - AllNamespaces: namespace == metav1.NamespaceAll, - }) - if err != nil { - log.Debug("failed to list clabernetes topologies for interface stats", "error", err) - return - } - - now := time.Now() - for _, state := range states { - for _, node := range state.Nodes { - if !node.Ready { - continue - } - - pod, err := r.launcherPod(ctx, state.Name, state.Namespace, node.Name) - if err != nil { - log.Debug("failed to resolve clabernetes launcher pod for interface stats", - "namespace", state.Namespace, - "lab", state.Name, - "node", node.Name, - "error", err, - ) - continue - } - - stdout, stderr, rc, err := r.execInPod(ctx, pod, - []string{"docker", "exec", node.Name, "cat", "/proc/net/dev"}) - if err != nil { - log.Debug("failed to collect clabernetes interface stats", - "namespace", state.Namespace, - "lab", state.Name, - "node", node.Name, - "error", err, - ) - continue - } - if rc != 0 { - log.Debug("failed to collect clabernetes interface stats", - "namespace", state.Namespace, - "lab", state.Name, - "node", node.Name, - "rc", rc, - "stderr", strings.TrimSpace(string(stderr)), - ) - continue - } - - stats, err := parseProcNetDev(stdout) - if err != nil { - log.Debug("failed to parse clabernetes interface stats", - "namespace", state.Namespace, - "lab", state.Name, - "node", node.Name, - "error", err, - ) - continue - } - - for _, stat := range stats { - key := c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name) - current := c9sIfaceStatsSample{ - Stats: stat, - Timestamp: now, - } - - if previous, ok := samples[key]; ok { - event := c9sIfaceStatsEvent(state, node, pod, stat, previous, current) - r.sendEvent(ctx, eventSink, event) - } - - samples[key] = current - } - } - } - } - - sample() - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - sample() - } - } -} - -type c9sIfaceStats struct { - Name string - RxBytes uint64 - RxPackets uint64 - TxBytes uint64 - TxPackets uint64 -} - -type c9sIfaceStatsSample struct { - Stats c9sIfaceStats - Timestamp time.Time -} - -func parseProcNetDev(data []byte) ([]c9sIfaceStats, error) { - lines := strings.Split(string(data), "\n") - stats := make([]c9sIfaceStats, 0, len(lines)) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || !strings.Contains(line, ":") { - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - continue - } - - ifName := strings.TrimSpace(parts[0]) - fields := strings.Fields(parts[1]) - if len(fields) < 16 { - return nil, fmt.Errorf("unexpected /proc/net/dev line for %q: %q", ifName, line) - } - - rxBytes, err := strconv.ParseUint(fields[0], 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse rx bytes for %q: %w", ifName, err) - } - rxPackets, err := strconv.ParseUint(fields[1], 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse rx packets for %q: %w", ifName, err) - } - txBytes, err := strconv.ParseUint(fields[8], 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse tx bytes for %q: %w", ifName, err) - } - txPackets, err := strconv.ParseUint(fields[9], 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse tx packets for %q: %w", ifName, err) - } - - stats = append(stats, c9sIfaceStats{ - Name: ifName, - RxBytes: rxBytes, - RxPackets: rxPackets, - TxBytes: txBytes, - TxPackets: txPackets, - }) - } - - return stats, nil -} - -func c9sIfaceStatsKey(namespace, lab, node, ifName string) string { - return namespace + "/" + lab + "/" + node + "/" + ifName -} - -func c9sIfaceStatsEvent( - state *clablabruntime.LabState, - node clablabruntime.NodeState, - pod *corev1.Pod, - stat c9sIfaceStats, - previous, - current c9sIfaceStatsSample, -) clablabruntime.Event { - interval := current.Timestamp.Sub(previous.Timestamp) - if interval <= 0 { - interval = time.Second - } - - seconds := interval.Seconds() - rxBytesDelta := counterDelta(stat.RxBytes, previous.Stats.RxBytes) - txBytesDelta := counterDelta(stat.TxBytes, previous.Stats.TxBytes) - rxPacketsDelta := counterDelta(stat.RxPackets, previous.Stats.RxPackets) - txPacketsDelta := counterDelta(stat.TxPackets, previous.Stats.TxPackets) - - actorName := fmt.Sprintf("%s-%s", state.Name, node.Name) - podName := "" - if pod != nil { - podName = pod.Name - } - - return clablabruntime.Event{ - Timestamp: current.Timestamp, - Type: "interface", - Action: "stats", - ActorID: c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name), - ActorName: actorName, - ActorFullID: podName, - Attributes: map[string]string{ - "namespace": state.Namespace, - "lab": state.Name, - "node": node.Name, - "name": actorName, - "pod": podName, - "ifname": stat.Name, - "origin": "clabernetes", - "rx_bytes": strconv.FormatUint(stat.RxBytes, 10), - "tx_bytes": strconv.FormatUint(stat.TxBytes, 10), - "rx_packets": strconv.FormatUint(stat.RxPackets, 10), - "tx_packets": strconv.FormatUint(stat.TxPackets, 10), - "rx_bps": strconv.FormatFloat(float64(rxBytesDelta*8)/seconds, 'f', -1, 64), - "tx_bps": strconv.FormatFloat(float64(txBytesDelta*8)/seconds, 'f', -1, 64), - "rx_pps": strconv.FormatFloat(float64(rxPacketsDelta)/seconds, 'f', -1, 64), - "tx_pps": strconv.FormatFloat(float64(txPacketsDelta)/seconds, 'f', -1, 64), - "interval_seconds": strconv.FormatFloat(seconds, 'f', -1, 64), - }, - } -} - -func counterDelta(current, previous uint64) uint64 { - if current < previous { - return 0 - } - - return current - previous -} - -func (r *Runtime) sendEvent( - ctx context.Context, - eventSink chan<- clablabruntime.Event, - event clablabruntime.Event, -) { - select { - case eventSink <- event: - case <-ctx.Done(): - } -} - -func sendEventError(ctx context.Context, errSink chan<- error, err error) { - select { - case errSink <- err: - case <-ctx.Done(): - } -} - -func (r *Runtime) namespaceFor(namespace string) string { - if namespace != "" { - return namespace - } - if r.namespace != "" { - return r.namespace - } - return defaultNamespace -} - -func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { - if timeout > 0 { - return timeout - } - if r.timeout > 0 { - return r.timeout - } - return 10 * time.Minute -} - -func topologyObject(name, namespace, owner, definition string) *unstructured.Unstructured { - topologyLabels := map[string]any{ - "containerlab.dev/runtime": clablabruntime.ClabernetesRuntimeName, - } - topologyAnnotations := map[string]any{} - if owner != "" { - topologyAnnotations[clabconstants.Owner] = owner - if len(validation.IsValidLabelValue(owner)) == 0 { - topologyLabels[clabconstants.Owner] = owner - } - } - - metadata := map[string]any{ - "name": name, - "namespace": namespace, - "labels": topologyLabels, - } - if len(topologyAnnotations) != 0 { - metadata["annotations"] = topologyAnnotations - } - - return &unstructured.Unstructured{ - Object: map[string]any{ - "apiVersion": "clabernetes.containerlab.dev/v1alpha1", - "kind": "Topology", - "metadata": metadata, - "spec": map[string]any{ - "definition": map[string]any{ - "containerlab": definition, - }, - }, - }, - } -} - -func kubeClientConfig() (*rest.Config, string, error) { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - if kubeconfig := os.Getenv(envKubeconfig); kubeconfig != "" { - loadingRules.ExplicitPath = kubeconfig - } - - overrides := &clientcmd.ConfigOverrides{} - if contextName := os.Getenv(envContext); contextName != "" { - overrides.CurrentContext = contextName - } - if namespace := os.Getenv(envNamespace); namespace != "" { - overrides.Context.Namespace = namespace - } - - clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - loadingRules, - overrides, - ) - - namespace, _, err := clientConfig.Namespace() - if err != nil { - namespace = defaultNamespace - } - - restConfig, err := clientConfig.ClientConfig() - if err != nil { - return nil, "", fmt.Errorf("failed to load Kubernetes client config: %w", err) - } - - return restConfig, namespace, nil -} - -func stateFromTopology(obj *unstructured.Unstructured, namespace string) *clablabruntime.LabState { - if obj.GetNamespace() != "" { - namespace = obj.GetNamespace() - } - - ready, _, _ := unstructured.NestedBool(obj.Object, "status", "topologyReady") - state, _, _ := unstructured.NestedString(obj.Object, "status", "topologyState") - owner := obj.GetLabels()[clabconstants.Owner] - if owner == "" { - owner = obj.GetAnnotations()[clabconstants.Owner] - } - nodeReadiness, _, _ := unstructured.NestedStringMap( - obj.Object, - "status", - "nodeReadiness", - ) - exposedPorts, _, _ := unstructured.NestedMap(obj.Object, "status", "exposedPorts") - nodeSpecs := nodeSpecsFromTopology(obj) - - nodeNames := make([]string, 0, len(nodeSpecs)+len(nodeReadiness)) - seenNodes := map[string]struct{}{} - for nodeName := range nodeSpecs { - nodeNames = append(nodeNames, nodeName) - seenNodes[nodeName] = struct{}{} - } - for nodeName := range nodeReadiness { - if _, ok := seenNodes[nodeName]; ok { - continue - } - nodeNames = append(nodeNames, nodeName) - } - sort.Strings(nodeNames) - - nodes := make([]clablabruntime.NodeState, 0, len(nodeNames)) - for _, nodeName := range nodeNames { - nodeState := nodeReadiness[nodeName] - spec := nodeSpecs[nodeName] - nodes = append(nodes, clablabruntime.NodeState{ - Name: nodeName, - Kind: spec.Kind, - Image: spec.Image, - State: nodeState, - Ready: nodeState == "ready", - LoadBalancerAddress: loadBalancerAddress(exposedPorts, nodeName), - }) - } - - return &clablabruntime.LabState{ - Name: obj.GetName(), - Namespace: namespace, - Owner: owner, - TopologyPath: fmt.Sprintf("k8s://%s/topologies/%s", namespace, obj.GetName()), - State: state, - Ready: ready, - Nodes: nodes, - } -} - -type nodeSpec struct { - Kind string `yaml:"kind"` - Image string `yaml:"image"` -} - -type containerlabDefinition struct { - Topology struct { - Nodes map[string]nodeSpec `yaml:"nodes"` - } `yaml:"topology"` -} - -func nodeSpecsFromTopology(obj *unstructured.Unstructured) map[string]nodeSpec { - specs := map[string]nodeSpec{} - - statusConfigs, _, _ := unstructured.NestedStringMap(obj.Object, "status", "configs") - for _, config := range statusConfigs { - mergeNodeSpecs(specs, config) - } - - if len(specs) != 0 { - return specs - } - - definition, _, _ := unstructured.NestedString( - obj.Object, - "spec", - "definition", - "containerlab", - ) - mergeNodeSpecs(specs, definition) - - return specs -} - -func mergeNodeSpecs(specs map[string]nodeSpec, definition string) { - if definition == "" { - return - } - - var parsed containerlabDefinition - if err := yaml.Unmarshal([]byte(definition), &parsed); err != nil { - log.Debug("failed to parse clabernetes topology definition", "error", err) - return - } - - for nodeName, spec := range parsed.Topology.Nodes { - specs[nodeName] = spec - } -} - -func loadBalancerAddress(exposedPorts map[string]any, nodeName string) string { - raw, ok := exposedPorts[nodeName] - if !ok { - return "" - } - - nodeExpose, ok := raw.(map[string]any) - if !ok { - return "" - } - - addr, ok := nodeExpose["loadBalancerAddress"].(string) - if !ok { - return "" - } - - if net.ParseIP(addr) == nil { - return "" - } - - return addr -} diff --git a/labruntime/clabernetes/config.go b/labruntime/clabernetes/config.go new file mode 100644 index 0000000000..21ca0b27be --- /dev/null +++ b/labruntime/clabernetes/config.go @@ -0,0 +1,62 @@ +package clabernetes + +import ( + "fmt" + "os" + "time" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +func (r *Runtime) namespaceFor(namespace string) string { + if namespace != "" { + return namespace + } + if r.namespace != "" { + return r.namespace + } + return defaultNamespace +} + +func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { + if timeout > 0 { + return timeout + } + if r.timeout > 0 { + return r.timeout + } + return 10 * time.Minute +} + +func kubeClientConfig() (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig := os.Getenv(envKubeconfig); kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + + overrides := &clientcmd.ConfigOverrides{} + if contextName := os.Getenv(envContext); contextName != "" { + overrides.CurrentContext = contextName + } + if namespace := os.Getenv(envNamespace); namespace != "" { + overrides.Context.Namespace = namespace + } + + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + overrides, + ) + + namespace, _, err := clientConfig.Namespace() + if err != nil { + namespace = defaultNamespace + } + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, "", fmt.Errorf("failed to load Kubernetes client config: %w", err) + } + + return restConfig, namespace, nil +} diff --git a/labruntime/clabernetes/events.go b/labruntime/clabernetes/events.go new file mode 100644 index 0000000000..b54f792a83 --- /dev/null +++ b/labruntime/clabernetes/events.go @@ -0,0 +1,269 @@ +package clabernetes + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" +) + +func (r *Runtime) StreamEvents( + ctx context.Context, + req clablabruntime.EventStreamRequest, +) (<-chan clablabruntime.Event, <-chan error, error) { + events := make(chan clablabruntime.Event, 128) + errs := make(chan error, 2) + + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + if req.IncludeInitialState { + go r.emitInitialEvents(ctx, namespace, events, errs) + } + + if req.IncludeInterfaceStats { + go r.pollInterfaceStats(ctx, namespace, req.StatsInterval, events) + } + + go r.watchTopologies(ctx, namespace, events, errs) + go r.watchPods(ctx, namespace, events, errs) + + return events, errs, nil +} + +func (r *Runtime) emitInitialEvents( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + states, err := r.List(ctx, clablabruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + sendEventError(ctx, errSink, err) + return + } + + for _, state := range states { + for _, node := range state.Nodes { + action := node.State + if node.Ready { + action = "running" + } + if action == "" { + action = state.State + } + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: action, + ActorID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), + ActorName: fmt.Sprintf("%s-%s", state.Name, node.Name), + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "state": node.State, + }, + }) + } + } +} + +func (r *Runtime) watchTopologies( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + for { + watcher, err := resource.Watch(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) + return + } + + if !r.forwardTopologyWatch(ctx, namespace, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardTopologyWatch( + ctx context.Context, + namespace string, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("clabernetes topology watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("clabernetes topology watch returned an error")) + return false + } + + obj, ok := ev.Object.(*unstructured.Unstructured) + if !ok { + continue + } + + state := stateFromTopology(obj, namespace) + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "topology", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s", state.Namespace, state.Name), + ActorName: state.Name, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "state": state.State, + "ready": fmt.Sprintf("%t", state.Ready), + }, + }) + } + } +} + +func (r *Runtime) watchPods( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + for { + watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) + return + } + + if !r.forwardPodWatch(ctx, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardPodWatch( + ctx context.Context, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("clabernetes pod watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("clabernetes pod watch returned an error")) + return false + } + + pod, ok := ev.Object.(*corev1.Pod) + if !ok { + continue + } + + labName := pod.Labels[labelTopologyOwner] + nodeName := pod.Labels[labelTopologyNode] + if labName == "" || nodeName == "" { + continue + } + + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s/%s", pod.Namespace, labName, nodeName), + ActorName: fmt.Sprintf("%s-%s", labName, nodeName), + ActorFullID: pod.Name, + Attributes: map[string]string{ + "namespace": pod.Namespace, + "lab": labName, + "node": nodeName, + "pod": pod.Name, + "phase": string(pod.Status.Phase), + "pod_ip": pod.Status.PodIP, + }, + }) + } + } +} + +func sleepContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +func (r *Runtime) sendEvent( + ctx context.Context, + eventSink chan<- clablabruntime.Event, + event clablabruntime.Event, +) { + select { + case eventSink <- event: + case <-ctx.Done(): + } +} + +func sendEventError(ctx context.Context, errSink chan<- error, err error) { + select { + case errSink <- err: + case <-ctx.Done(): + } +} diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go new file mode 100644 index 0000000000..0bd9b29226 --- /dev/null +++ b/labruntime/clabernetes/exec.go @@ -0,0 +1,144 @@ +package clabernetes + +import ( + "bytes" + "context" + "errors" + "fmt" + + clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/remotecommand" + kubeexec "k8s.io/client-go/util/exec" +) + +func (r *Runtime) Exec( + ctx context.Context, + req clablabruntime.ExecRequest, +) (*clabexec.ExecResult, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + if req.NodeName == "" { + return nil, fmt.Errorf("node name is required") + } + if len(req.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + pod, err := r.launcherPod(ctx, req.Name, req.Namespace, req.NodeName) + if err != nil { + return nil, err + } + + execCmd := clabexec.NewExecCmdFromSlice(req.Command) + result := clabexec.NewExecResult(execCmd) + cmd := append([]string{"docker", "exec", req.NodeName}, req.Command...) + + stdout, stderr, rc, err := r.execInPod(ctx, pod, cmd) + if err != nil { + return nil, err + } + + result.SetReturnCode(rc) + result.SetStdOut(stdout) + result.SetStdErr(stderr) + + return result, nil +} + +func (r *Runtime) launcherPod( + ctx context.Context, + name, + namespace, + nodeName string, +) (*corev1.Pod, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: nodeName, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes launcher pods for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes launcher pod for node %s/%s/%s was not found", + namespace, name, nodeName) + } + + for idx := range list.Items { + if list.Items[idx].Status.Phase == corev1.PodRunning { + return &list.Items[idx], nil + } + } + + return &list.Items[0], nil +} + +func (r *Runtime) execInPod( + ctx context.Context, + pod *corev1.Pod, + command []string, +) ([]byte, []byte, int, error) { + if pod == nil { + return nil, nil, 0, fmt.Errorf("launcher pod is nil") + } + if len(command) == 0 { + return nil, nil, 0, fmt.Errorf("command is required") + } + + containerName := "" + if len(pod.Spec.Containers) != 0 { + containerName = pod.Spec.Containers[0].Name + } + + req := r.kubeClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod.Name). + Namespace(pod.Namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: command, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(r.restConfig, "POST", req.URL()) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to create Kubernetes exec executor: %w", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + Tty: false, + }) + + rc := 0 + if err != nil { + var exitErr kubeexec.ExitError + if errors.As(err, &exitErr) { + rc = exitErr.ExitStatus() + err = nil + } + } + if err != nil { + return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf("failed to execute command in pod %s/%s: %w", + pod.Namespace, pod.Name, err) + } + + return stdout.Bytes(), stderr.Bytes(), rc, nil +} diff --git a/labruntime/clabernetes/iface_stats.go b/labruntime/clabernetes/iface_stats.go new file mode 100644 index 0000000000..e7fbf82201 --- /dev/null +++ b/labruntime/clabernetes/iface_stats.go @@ -0,0 +1,248 @@ +package clabernetes + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (r *Runtime) pollInterfaceStats( + ctx context.Context, + namespace string, + interval time.Duration, + eventSink chan<- clablabruntime.Event, +) { + if interval <= 0 { + interval = time.Second + } + + samples := map[string]c9sIfaceStatsSample{} + + sample := func() { + states, err := r.List(ctx, clablabruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + log.Debug("failed to list clabernetes topologies for interface stats", "error", err) + return + } + + now := time.Now() + for _, state := range states { + for _, node := range state.Nodes { + if !node.Ready { + continue + } + + pod, err := r.launcherPod(ctx, state.Name, state.Namespace, node.Name) + if err != nil { + log.Debug("failed to resolve clabernetes launcher pod for interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"docker", "exec", node.Name, "cat", "/proc/net/dev"}) + if err != nil { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + if rc != 0 { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "rc", rc, + "stderr", strings.TrimSpace(string(stderr)), + ) + continue + } + + stats, err := parseProcNetDev(stdout) + if err != nil { + log.Debug("failed to parse clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + for _, stat := range stats { + key := c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name) + current := c9sIfaceStatsSample{ + Stats: stat, + Timestamp: now, + } + + if previous, ok := samples[key]; ok { + event := c9sIfaceStatsEvent(state, node, pod, stat, previous, current) + r.sendEvent(ctx, eventSink, event) + } + + samples[key] = current + } + } + } + } + + sample() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sample() + } + } +} + +type c9sIfaceStats struct { + Name string + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} + +type c9sIfaceStatsSample struct { + Stats c9sIfaceStats + Timestamp time.Time +} + +func parseProcNetDev(data []byte) ([]c9sIfaceStats, error) { + lines := strings.Split(string(data), "\n") + stats := make([]c9sIfaceStats, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, ":") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + ifName := strings.TrimSpace(parts[0]) + fields := strings.Fields(parts[1]) + if len(fields) < 16 { + return nil, fmt.Errorf("unexpected /proc/net/dev line for %q: %q", ifName, line) + } + + rxBytes, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx bytes for %q: %w", ifName, err) + } + rxPackets, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx packets for %q: %w", ifName, err) + } + txBytes, err := strconv.ParseUint(fields[8], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx bytes for %q: %w", ifName, err) + } + txPackets, err := strconv.ParseUint(fields[9], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx packets for %q: %w", ifName, err) + } + + stats = append(stats, c9sIfaceStats{ + Name: ifName, + RxBytes: rxBytes, + RxPackets: rxPackets, + TxBytes: txBytes, + TxPackets: txPackets, + }) + } + + return stats, nil +} + +func c9sIfaceStatsKey(namespace, lab, node, ifName string) string { + return namespace + "/" + lab + "/" + node + "/" + ifName +} + +func c9sIfaceStatsEvent( + state *clablabruntime.LabState, + node clablabruntime.NodeState, + pod *corev1.Pod, + stat c9sIfaceStats, + previous, + current c9sIfaceStatsSample, +) clablabruntime.Event { + interval := current.Timestamp.Sub(previous.Timestamp) + if interval <= 0 { + interval = time.Second + } + + seconds := interval.Seconds() + rxBytesDelta := counterDelta(stat.RxBytes, previous.Stats.RxBytes) + txBytesDelta := counterDelta(stat.TxBytes, previous.Stats.TxBytes) + rxPacketsDelta := counterDelta(stat.RxPackets, previous.Stats.RxPackets) + txPacketsDelta := counterDelta(stat.TxPackets, previous.Stats.TxPackets) + + actorName := fmt.Sprintf("%s-%s", state.Name, node.Name) + podName := "" + if pod != nil { + podName = pod.Name + } + + return clablabruntime.Event{ + Timestamp: current.Timestamp, + Type: "interface", + Action: "stats", + ActorID: c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name), + ActorName: actorName, + ActorFullID: podName, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "name": actorName, + "pod": podName, + "ifname": stat.Name, + "origin": "clabernetes", + "rx_bytes": strconv.FormatUint(stat.RxBytes, 10), + "tx_bytes": strconv.FormatUint(stat.TxBytes, 10), + "rx_packets": strconv.FormatUint(stat.RxPackets, 10), + "tx_packets": strconv.FormatUint(stat.TxPackets, 10), + "rx_bps": strconv.FormatFloat(float64(rxBytesDelta*8)/seconds, 'f', -1, 64), + "tx_bps": strconv.FormatFloat(float64(txBytesDelta*8)/seconds, 'f', -1, 64), + "rx_pps": strconv.FormatFloat(float64(rxPacketsDelta)/seconds, 'f', -1, 64), + "tx_pps": strconv.FormatFloat(float64(txPacketsDelta)/seconds, 'f', -1, 64), + "interval_seconds": strconv.FormatFloat(seconds, 'f', -1, 64), + }, + } +} + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + + return current - previous +} diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go new file mode 100644 index 0000000000..4154eb956b --- /dev/null +++ b/labruntime/clabernetes/lifecycle.go @@ -0,0 +1,212 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (r *Runtime) Deploy( + ctx context.Context, + req clablabruntime.DeployRequest, +) (*clablabruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + if len(req.TopologyDefinition) == 0 { + return nil, fmt.Errorf("rendered containerlab topology is required") + } + + namespace := r.namespaceFor(req.Namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + desired := topologyObject(req.Name, namespace, req.Owner, string(req.TopologyDefinition)) + + _, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) + if _, err = resource.Create(ctx, desired, metav1.CreateOptions{}); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil, duplicateTopologyError(req.Name, namespace) + } + return nil, fmt.Errorf("failed to create clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + case err != nil: + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + default: + return nil, duplicateTopologyError(req.Name, namespace) + } + + if !req.Wait { + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + } + + if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + return nil, err + } + + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) +} + +func duplicateTopologyError(name, namespace string) error { + return fmt.Errorf( + "the '%s' lab has already been deployed in namespace '%s'. "+ + "Destroy the lab before deploying a lab with the same name, "+ + "or use '--reconfigure' to redeploy it", + name, + namespace, + ) +} + +func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { + if req.Name == "" { + return fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + log.Info("Deleting clabernetes topology", "name", req.Name, "namespace", namespace) + + err := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + log.Info("clabernetes topology not found", "name", req.Name, "namespace", namespace) + return nil + } + if err != nil { + return fmt.Errorf("failed to delete clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if !req.Wait { + return nil + } + + return r.waitDeleted(ctx, req.Name, namespace, req.Timeout) +} + +func (r *Runtime) Inspect( + ctx context.Context, + req clablabruntime.InspectRequest, +) (*clablabruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + obj, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to inspect clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + state := stateFromTopology(obj, namespace) + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes topology state", "error", err) + } + + return state, nil +} + +func (r *Runtime) List( + ctx context.Context, + req clablabruntime.ListRequest, +) ([]*clablabruntime.LabState, error) { + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + list, err := r.client.Resource(topologyGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) + } + + states := make([]*clablabruntime.LabState, 0, len(list.Items)) + for idx := range list.Items { + state := stateFromTopology(&list.Items[idx], namespace) + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes topology state", + "name", state.Name, + "namespace", state.Namespace, + "error", err, + ) + } + states = append(states, state) + } + + sort.Slice(states, func(i, j int) bool { + if states[i].Namespace == states[j].Namespace { + return states[i].Name < states[j].Name + } + return states[i].Namespace < states[j].Namespace + }) + + return states, nil +} + +func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + } + + state := stateFromTopology(obj, namespace) + if state.Ready { + return true, nil + } + if state.State == "deployfailed" { + return false, fmt.Errorf("clabernetes topology %s/%s reported deployfailed", + namespace, name) + } + + log.Debug("Waiting for clabernetes topology", + "name", name, + "namespace", namespace, + "state", state.State, + ) + + return false, nil + }) +} + +func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + _, err := resource.Get(ctx, name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + return true, nil + case err != nil: + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + default: + return false, nil + } + }) +} diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go new file mode 100644 index 0000000000..92b5f9be0c --- /dev/null +++ b/labruntime/clabernetes/nodes.go @@ -0,0 +1,293 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + "time" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (r *Runtime) Start(ctx context.Context, req clablabruntime.NodeRequest) error { + return r.setNodesReplicas(ctx, req, 1) +} + +func (r *Runtime) Stop(ctx context.Context, req clablabruntime.NodeRequest) error { + if err := r.setTopologyIgnoreReconcile(ctx, req.Name, req.Namespace, true); err != nil { + return err + } + + return r.setNodesReplicas(ctx, req, 0) +} + +func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + + now := time.Now().UTC().Format(time.RFC3339) + for _, nodeName := range targets { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + if deployment.Spec.Template.ObjectMeta.Annotations == nil { + deployment.Spec.Template.ObjectMeta.Annotations = map[string]string{} + } + deployment.Spec.Template.ObjectMeta.Annotations[restartedAtAnnotation] = now + + if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { + replicas := int32(1) + deployment.Spec.Replicas = &replicas + } + + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to restart clabernetes node %s/%s/%s: %w", + namespace, req.Name, nodeName, err) + } + + if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, 1, req.Timeout); err != nil { + return err + } + } + + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) +} + +func (r *Runtime) targetNodes( + ctx context.Context, + req clablabruntime.NodeRequest, +) ([]string, string, error) { + if req.Name == "" { + return nil, "", fmt.Errorf("topology name is required") + } + + namespace := r.namespaceFor(req.Namespace) + deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, "", err + } + + known := map[string]struct{}{} + for idx := range deployments.Items { + nodeName := deployments.Items[idx].Labels[labelTopologyNode] + if nodeName != "" { + known[nodeName] = struct{}{} + } + } + + if len(known) == 0 { + state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + if err != nil { + return nil, "", err + } + for _, node := range state.Nodes { + known[node.Name] = struct{}{} + } + } + + if len(known) == 0 { + return nil, "", fmt.Errorf("topology %s/%s has no nodes", namespace, req.Name) + } + + var targets []string + if len(req.Nodes) == 0 { + targets = make([]string, 0, len(known)) + for nodeName := range known { + targets = append(targets, nodeName) + } + sort.Strings(targets) + + return targets, namespace, nil + } + + for _, nodeName := range req.Nodes { + if _, ok := known[nodeName]; !ok { + return nil, "", fmt.Errorf("node %q was not found in topology %s/%s", + nodeName, namespace, req.Name) + } + targets = append(targets, nodeName) + } + + return targets, namespace, nil +} + +func (r *Runtime) setNodesReplicas( + ctx context.Context, + req clablabruntime.NodeRequest, + replicas int32, +) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + + for _, nodeName := range targets { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + deployment.Spec.Replicas = &replicas + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to set clabernetes node %s/%s/%s replicas to %d: %w", + namespace, req.Name, nodeName, replicas, err) + } + + if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, replicas, req.Timeout); err != nil { + return err + } + } + + if replicas > 0 { + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) + } + + return nil +} + +func (r *Runtime) clearIgnoreWhenAllStarted(ctx context.Context, name, namespace string) error { + deployments, err := r.deploymentsForTopology(ctx, name, namespace) + if err != nil { + return err + } + + for idx := range deployments.Items { + replicas := int32(1) + if deployments.Items[idx].Spec.Replicas != nil { + replicas = *deployments.Items[idx].Spec.Replicas + } + if replicas == 0 { + return nil + } + } + + return r.setTopologyIgnoreReconcile(ctx, name, namespace, false) +} + +func (r *Runtime) setTopologyIgnoreReconcile( + ctx context.Context, + name, + namespace string, + enabled bool, +) error { + if name == "" { + return fmt.Errorf("topology name is required") + } + + namespace = r.namespaceFor(namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + } + + labelsMap := obj.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + + if enabled { + labelsMap[labelIgnoreReconcile] = "true" + } else { + delete(labelsMap, labelIgnoreReconcile) + } + + obj.SetLabels(labelsMap) + + _, err = resource.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update clabernetes topology %s/%s labels: %w", + namespace, name, err) + } + + return nil +} + +func (r *Runtime) waitDeploymentReplicas( + ctx context.Context, + namespace, + name string, + replicas int32, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + deployment, err := r.kubeClient.AppsV1().Deployments(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("failed to get clabernetes deployment %s/%s: %w", + namespace, name, err) + } + + if replicas == 0 { + return deployment.Status.Replicas == 0 && + deployment.Status.AvailableReplicas == 0, nil + } + + return deployment.Status.ReadyReplicas >= replicas && + deployment.Status.AvailableReplicas >= replicas, nil + }) +} + +func (r *Runtime) deploymentsForTopology( + ctx context.Context, + name, + namespace string, +) (*appsv1.DeploymentList, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployments for topology %s/%s: %w", + namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) deploymentForNode( + ctx context.Context, + name, + namespace, + nodeName string, +) (*appsv1.Deployment, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: nodeName, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployment for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes deployment for node %s/%s/%s was not found", + namespace, name, nodeName) + } + + return &list.Items[0], nil +} diff --git a/labruntime/clabernetes/save.go b/labruntime/clabernetes/save.go new file mode 100644 index 0000000000..364da256ff --- /dev/null +++ b/labruntime/clabernetes/save.go @@ -0,0 +1,175 @@ +package clabernetes + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" +) + +func (r *Runtime) Save( + ctx context.Context, + req clablabruntime.SaveRequest, +) (*clablabruntime.SaveResult, error) { + targets, namespace, err := r.targetNodes(ctx, clablabruntime.NodeRequest{ + Name: req.Name, + Namespace: req.Namespace, + Nodes: req.Nodes, + }) + if err != nil { + return nil, err + } + + result := &clablabruntime.SaveResult{} + for _, nodeName := range targets { + pod, err := r.launcherPod(ctx, req.Name, namespace, nodeName) + if err != nil { + return nil, err + } + + copyDir := "" + command := []string{"containerlab", "save", "-t", "/clabernetes/topo.clab.yaml"} + if req.Copy { + copyDir = fmt.Sprintf("/tmp/clab-save-copy-%s-%s-%d", + req.Name, nodeName, time.Now().UnixNano()) + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", copyDir}) + command = append(command, "--copy", copyDir) + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, command) + if err != nil { + return nil, err + } + + if len(stdout) != 0 { + log.Info("clabernetes save output", "node", nodeName, "stdout", strings.TrimSpace(string(stdout))) + } + if len(stderr) != 0 { + log.Info("clabernetes save output", "node", nodeName, "stderr", strings.TrimSpace(string(stderr))) + } + if rc != 0 { + return nil, fmt.Errorf("save failed for clabernetes node %s/%s/%s: rc=%d", + namespace, req.Name, nodeName, rc) + } + + if req.Copy { + files, err := r.collectSavedFiles(ctx, pod, nodeName, copyDir) + if cleanupDir := copyDir; cleanupDir != "" { + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", cleanupDir}) + } + if err != nil { + return nil, err + } + result.Files = append(result.Files, files...) + } + } + + return result, nil +} + +func (r *Runtime) collectSavedFiles( + ctx context.Context, + pod *corev1.Pod, + nodeName, + copyDir string, +) ([]clablabruntime.SavedFile, error) { + if copyDir == "" { + return nil, nil + } + + nodeCopyDir := path.Join(copyDir, "clab-clabernetes-"+nodeName, nodeName) + _, _, rc, err := r.execInPod(ctx, pod, []string{"test", "-d", nodeCopyDir}) + if err != nil { + return nil, err + } + if rc != 0 { + log.Debug("no clabernetes saved config copy directory found", + "node", nodeName, + "path", nodeCopyDir, + ) + + return nil, nil + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"tar", "cf", "-", "-C", nodeCopyDir, "."}) + if err != nil { + return nil, err + } + if rc != 0 { + return nil, fmt.Errorf("failed to archive saved config copy for node %s: rc=%d stderr=%s", + nodeName, rc, strings.TrimSpace(string(stderr))) + } + + files, err := savedFilesFromTar(nodeName, stdout) + if err != nil { + return nil, fmt.Errorf("failed to read saved config archive for node %s: %w", + nodeName, err) + } + + return files, nil +} + +func savedFilesFromTar(nodeName string, data []byte) ([]clablabruntime.SavedFile, error) { + reader := tar.NewReader(bytes.NewReader(data)) + var files []clablabruntime.SavedFile + + for { + header, err := reader.Next() + switch { + case errors.Is(err, io.EOF): + return files, nil + case err != nil: + return nil, err + } + + name, ok := cleanTarPath(header.Name) + if !ok || name == "." { + continue + } + + switch header.Typeflag { + case tar.TypeReg, tar.TypeRegA: + content, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + files = append(files, clablabruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Data: content, + Mode: header.Mode, + }) + case tar.TypeSymlink: + files = append(files, clablabruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Mode: header.Mode, + LinkTarget: header.Linkname, + }) + } + } +} + +func cleanTarPath(name string) (string, bool) { + name = strings.TrimPrefix(name, "./") + cleaned := path.Clean(name) + if cleaned == "." || cleaned == "" { + return cleaned, true + } + if strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || cleaned == ".." { + return "", false + } + + return cleaned, true +} diff --git a/labruntime/clabernetes/state.go b/labruntime/clabernetes/state.go new file mode 100644 index 0000000000..3ca2c4e3ca --- /dev/null +++ b/labruntime/clabernetes/state.go @@ -0,0 +1,117 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + "strings" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabState) error { + if state == nil || state.Name == "" { + return nil + } + + deployments, err := r.deploymentsForTopology(ctx, state.Name, state.Namespace) + if err != nil { + return err + } + + pods, err := r.kubeClient.CoreV1().Pods(state.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: state.Name, + }.String(), + }) + if err != nil { + return fmt.Errorf("failed to list clabernetes pods for topology %s/%s: %w", + state.Namespace, state.Name, err) + } + + nodesByName := map[string]clablabruntime.NodeState{} + for _, node := range state.Nodes { + nodesByName[node.Name] = node + } + + podsByNode := map[string]*corev1.Pod{} + for idx := range pods.Items { + nodeName := pods.Items[idx].Labels[labelTopologyNode] + if nodeName == "" { + continue + } + if pods.Items[idx].Status.Phase == corev1.PodRunning { + podsByNode[nodeName] = &pods.Items[idx] + continue + } + if _, ok := podsByNode[nodeName]; !ok { + podsByNode[nodeName] = &pods.Items[idx] + } + } + + for idx := range deployments.Items { + deployment := &deployments.Items[idx] + nodeName := deployment.Labels[labelTopologyNode] + if nodeName == "" { + continue + } + + node := nodesByName[nodeName] + node.Name = nodeName + replicas := int32(1) + if deployment.Spec.Replicas != nil { + replicas = *deployment.Spec.Replicas + } + + switch { + case replicas == 0: + node.State = "stopped" + node.Ready = false + case deployment.Status.ReadyReplicas > 0: + node.State = "ready" + node.Ready = true + case podsByNode[nodeName] != nil && podsByNode[nodeName].Status.Phase != "": + node.State = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) + node.Ready = false + default: + node.State = "notready" + node.Ready = false + } + + nodesByName[nodeName] = node + } + + nodeNames := make([]string, 0, len(nodesByName)) + for nodeName := range nodesByName { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) + allReady := len(nodeNames) > 0 + allStopped := len(nodeNames) > 0 + for _, nodeName := range nodeNames { + node := nodesByName[nodeName] + state.Nodes = append(state.Nodes, node) + allReady = allReady && node.Ready + allStopped = allStopped && node.State == "stopped" + } + + switch { + case allReady: + state.State = "running" + state.Ready = true + case allStopped: + state.State = "stopped" + state.Ready = false + case len(nodeNames) != 0: + state.State = "partial" + state.Ready = false + } + + return nil +} diff --git a/labruntime/clabernetes/topology.go b/labruntime/clabernetes/topology.go new file mode 100644 index 0000000000..9013c10e16 --- /dev/null +++ b/labruntime/clabernetes/topology.go @@ -0,0 +1,180 @@ +package clabernetes + +import ( + "fmt" + "net" + "sort" + + "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + "gopkg.in/yaml.v2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation" +) + +func topologyObject(name, namespace, owner, definition string) *unstructured.Unstructured { + topologyLabels := map[string]any{ + "containerlab.dev/runtime": clablabruntime.ClabernetesRuntimeName, + } + topologyAnnotations := map[string]any{} + if owner != "" { + topologyAnnotations[clabconstants.Owner] = owner + if len(validation.IsValidLabelValue(owner)) == 0 { + topologyLabels[clabconstants.Owner] = owner + } + } + + metadata := map[string]any{ + "name": name, + "namespace": namespace, + "labels": topologyLabels, + } + if len(topologyAnnotations) != 0 { + metadata["annotations"] = topologyAnnotations + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "clabernetes.containerlab.dev/v1alpha1", + "kind": "Topology", + "metadata": metadata, + "spec": map[string]any{ + "definition": map[string]any{ + "containerlab": definition, + }, + }, + }, + } +} + +func stateFromTopology(obj *unstructured.Unstructured, namespace string) *clablabruntime.LabState { + if obj.GetNamespace() != "" { + namespace = obj.GetNamespace() + } + + ready, _, _ := unstructured.NestedBool(obj.Object, "status", "topologyReady") + state, _, _ := unstructured.NestedString(obj.Object, "status", "topologyState") + owner := obj.GetLabels()[clabconstants.Owner] + if owner == "" { + owner = obj.GetAnnotations()[clabconstants.Owner] + } + nodeReadiness, _, _ := unstructured.NestedStringMap( + obj.Object, + "status", + "nodeReadiness", + ) + exposedPorts, _, _ := unstructured.NestedMap(obj.Object, "status", "exposedPorts") + nodeSpecs := nodeSpecsFromTopology(obj) + + nodeNames := make([]string, 0, len(nodeSpecs)+len(nodeReadiness)) + seenNodes := map[string]struct{}{} + for nodeName := range nodeSpecs { + nodeNames = append(nodeNames, nodeName) + seenNodes[nodeName] = struct{}{} + } + for nodeName := range nodeReadiness { + if _, ok := seenNodes[nodeName]; ok { + continue + } + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + nodes := make([]clablabruntime.NodeState, 0, len(nodeNames)) + for _, nodeName := range nodeNames { + nodeState := nodeReadiness[nodeName] + spec := nodeSpecs[nodeName] + nodes = append(nodes, clablabruntime.NodeState{ + Name: nodeName, + Kind: spec.Kind, + Image: spec.Image, + State: nodeState, + Ready: nodeState == "ready", + LoadBalancerAddress: loadBalancerAddress(exposedPorts, nodeName), + }) + } + + return &clablabruntime.LabState{ + Name: obj.GetName(), + Namespace: namespace, + Owner: owner, + TopologyPath: fmt.Sprintf("k8s://%s/topologies/%s", namespace, obj.GetName()), + State: state, + Ready: ready, + Nodes: nodes, + } +} + +type nodeSpec struct { + Kind string `yaml:"kind"` + Image string `yaml:"image"` +} + +type containerlabDefinition struct { + Topology struct { + Nodes map[string]nodeSpec `yaml:"nodes"` + } `yaml:"topology"` +} + +func nodeSpecsFromTopology(obj *unstructured.Unstructured) map[string]nodeSpec { + specs := map[string]nodeSpec{} + + statusConfigs, _, _ := unstructured.NestedStringMap(obj.Object, "status", "configs") + for _, config := range statusConfigs { + mergeNodeSpecs(specs, config) + } + + if len(specs) != 0 { + return specs + } + + definition, _, _ := unstructured.NestedString( + obj.Object, + "spec", + "definition", + "containerlab", + ) + mergeNodeSpecs(specs, definition) + + return specs +} + +func mergeNodeSpecs(specs map[string]nodeSpec, definition string) { + if definition == "" { + return + } + + var parsed containerlabDefinition + if err := yaml.Unmarshal([]byte(definition), &parsed); err != nil { + log.Debug("failed to parse clabernetes topology definition", "error", err) + return + } + + for nodeName, spec := range parsed.Topology.Nodes { + specs[nodeName] = spec + } +} + +func loadBalancerAddress(exposedPorts map[string]any, nodeName string) string { + raw, ok := exposedPorts[nodeName] + if !ok { + return "" + } + + nodeExpose, ok := raw.(map[string]any) + if !ok { + return "" + } + + addr, ok := nodeExpose["loadBalancerAddress"].(string) + if !ok { + return "" + } + + if net.ParseIP(addr) == nil { + return "" + } + + return addr +} From c2781df3aa41f3e8ea04f110e928fce6b48603f8 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Fri, 5 Jun 2026 23:07:15 +0200 Subject: [PATCH 11/21] Support Docker-style labs in clabernetes runtime --- core/labruntime.go | 9 + labruntime/clabernetes/clabernetes_test.go | 281 +++++++ labruntime/clabernetes/files.go | 876 +++++++++++++++++++++ labruntime/clabernetes/lifecycle.go | 32 +- labruntime/clabernetes/topology.go | 33 +- labruntime/runtime.go | 2 + 6 files changed, 1225 insertions(+), 8 deletions(-) create mode 100644 labruntime/clabernetes/files.go diff --git a/core/labruntime.go b/core/labruntime.go index 469a294f1b..3ddfb24095 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -43,9 +43,18 @@ func (c *CLab) deployWithLabRuntime( } } + topologyFile := "" + topologyLabDir := "" + if c.TopoPaths != nil { + topologyFile = c.TopoPaths.TopologyFilenameAbsPath() + topologyLabDir = c.TopoPaths.TopologyLabDir() + } + state, err := c.LabRuntime.Deploy(ctx, clablabruntime.DeployRequest{ Name: c.Config.Name, Owner: c.labOwner(), + TopologyFile: topologyFile, + TopologyLabDir: topologyLabDir, TopologyDefinition: c.renderedTopology, Wait: true, Timeout: c.timeout, diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index b0b5259243..ae310f881b 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -4,12 +4,16 @@ import ( "archive/tar" "bytes" "context" + "os" + "path/filepath" + "slices" "strings" "testing" "time" clabconstants "github.com/srl-labs/containerlab/constants" clablabruntime "github.com/srl-labs/containerlab/labruntime" + "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -229,6 +233,189 @@ func TestDeployCreatesTopology(t *testing.T) { } } +func TestDeployStagesLocalFilesFromTopology(t *testing.T) { + t.Parallel() + + topologyDir := t.TempDir() + writeFile(t, filepath.Join(topologyDir, "configs", "client2", "iperf.sh"), "#!/bin/sh\n", 0o755) + writeFile(t, filepath.Join(topologyDir, "configs", "prometheus", "prometheus.yml"), "global: {}\n", 0o644) + writeFile(t, filepath.Join(topologyDir, "configs", "fabric", "leaf1.cfg"), "set / system name leaf1\n", 0o644) + + const definition = `name: lab1 +topology: + defaults: + kind: linux + nodes: + leaf1: + startup-config: configs/fabric/leaf1.cfg + client2: + kind: linux + binds: + - configs/client2:/config + prometheus: + kind: linux + binds: + - configs/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro +` + + topologyFile := filepath.Join(topologyDir, "lab.clab.yml") + writeFile(t, topologyFile, definition, 0o644) + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyFile: topologyFile, + TopologyLabDir: filepath.Join(topologyDir, "clab-lab1"), + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + clientConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-client2-files") + if got := string(clientConfigMap.BinaryData["configs-client2-iperf-sh"]); got != "#!/bin/sh\n" { + t.Fatalf("unexpected client2 staged file content: %q", got) + } + + prometheusConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-prometheus-files") + if got := string(prometheusConfigMap.BinaryData["configs-prometheus-prometheus-yml"]); got != "global: {}\n" { + t.Fatalf("unexpected prometheus staged file content: %q", got) + } + + startupConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-leaf1-startup-config") + if got := string(startupConfigMap.BinaryData["startup-config"]); got != "set / system name leaf1\n" { + t.Fatalf("unexpected startup config content: %q", got) + } + + obj := getTestTopology(t, r, "lab-ns", "lab1") + assertFileMount( + t, + obj, + "client2", + "configs/client2/iperf.sh", + "lab1-client2-files", + "configs-client2-iperf-sh", + "execute", + ) + assertFileMount( + t, + obj, + "prometheus", + "configs/prometheus/prometheus.yml", + "lab1-prometheus-files", + "configs-prometheus-prometheus-yml", + "read", + ) + assertFileMount( + t, + obj, + "leaf1", + "configs/fabric/leaf1.cfg", + "lab1-leaf1-startup-config", + "startup-config", + "read", + ) +} + +func TestDeployPreservesDockerCompatibleNamesForEmptyPrefixTopology(t *testing.T) { + t.Parallel() + + const definition = `name: st +prefix: "" +topology: + nodes: + leaf1: + kind: nokia_srlinux + image: ghcr.io/nokia/srlinux:24.10.1 + prometheus: + kind: linux + image: quay.io/prometheus/prometheus:v2.54.1 +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "st", + Namespace: "lab-ns", + TopologyDefinition: []byte(definition), + Wait: false, + }); err != nil { + t.Fatal(err) + } + + obj := getTestTopology(t, r, "lab-ns", "st") + if got := topologyNaming(t, obj); got != "non-prefixed" { + t.Fatalf("topology naming = %q, want %q", got, "non-prefixed") + } +} + +func TestDeployExposesGNMICMetricsPortForClabernetes(t *testing.T) { + t.Parallel() + + const definition = `name: st +prefix: "" +mgmt: + network: st + ipv4-subnet: 172.20.20.0/24 +topology: + nodes: + leaf1: + kind: nokia_srlinux + image: ghcr.io/nokia/srlinux:24.10.1 + gnmic: + kind: linux + image: ghcr.io/openconfig/gnmic:0.39.1 + cmd: --config /gnmic-config.yml --log subscribe + prometheus: + kind: linux + image: quay.io/prometheus/prometheus:v2.54.1 + ports: + - 9090:9090 + links: + - endpoints: ["leaf1:e1-1", "prometheus:eth1"] +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "st", + Namespace: "lab-ns", + TopologyDefinition: []byte(definition), + Wait: false, + }); err != nil { + t.Fatal(err) + } + + definitionAfterDeploy := topologyDefinition(t, getTestTopology(t, r, "lab-ns", "st")) + if !strings.Contains(definitionAfterDeploy, "mgmt:\n network: st\n ipv4-subnet: 172.20.20.0/24") { + t.Fatalf("topology definition did not preserve mgmt config:\n%s", definitionAfterDeploy) + } + if !strings.Contains(definitionAfterDeploy, "- leaf1:e1-1") || + !strings.Contains(definitionAfterDeploy, "- prometheus:eth1") { + t.Fatalf("topology definition did not preserve brief link endpoints:\n%s", definitionAfterDeploy) + } + if strings.Contains(definitionAfterDeploy, "node: leaf1") || + strings.Contains(definitionAfterDeploy, "interface: e1-1") { + t.Fatalf("topology definition rendered structured link endpoints:\n%s", definitionAfterDeploy) + } + + var config clabRuntimeConfig + if err := yaml.Unmarshal([]byte(definitionAfterDeploy), &config); err != nil { + t.Fatal(err) + } + + gnmic := config.Topology.Nodes["gnmic"] + if gnmic == nil { + t.Fatal("gnmic node was not found in topology definition") + } + if !slices.Contains(gnmic.Ports, "9273:9273/tcp") { + t.Fatalf("gnmic ports = %v, want 9273:9273/tcp", gnmic.Ports) + } +} + func TestDeployFailsWhenTopologyAlreadyExists(t *testing.T) { t.Parallel() @@ -427,6 +614,100 @@ func topologyDefinition(t *testing.T, obj *unstructured.Unstructured) string { return definition } +func topologyNaming(t *testing.T, obj *unstructured.Unstructured) string { + t.Helper() + + naming, found, err := unstructured.NestedString(obj.Object, "spec", "naming") + if err != nil { + t.Fatal(err) + } + if !found { + return "" + } + + return naming +} + +func getTestConfigMap( + t *testing.T, + r *Runtime, + namespace string, + name string, +) *corev1.ConfigMap { + t.Helper() + + configMap, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return configMap +} + +func assertFileMount( + t *testing.T, + obj *unstructured.Unstructured, + nodeName, + filePath, + configMapName, + configMapPath, + mode string, +) { + t.Helper() + + filesFromConfigMap, found, err := unstructured.NestedMap( + obj.Object, + "spec", + "deployment", + "filesFromConfigMap", + ) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("filesFromConfigMap was not found") + } + + rawMounts, ok := filesFromConfigMap[nodeName].([]any) + if !ok { + t.Fatalf("filesFromConfigMap[%s] has unexpected type %T", nodeName, filesFromConfigMap[nodeName]) + } + + for _, rawMount := range rawMounts { + mount, ok := rawMount.(map[string]any) + if !ok { + t.Fatalf("mount has unexpected type %T", rawMount) + } + if mount["filePath"] == filePath && + mount["configMapName"] == configMapName && + mount["configMapPath"] == configMapPath && + mount["mode"] == mode { + return + } + } + + t.Fatalf( + "mount %s/%s/%s/%s was not found in %+v", + filePath, + configMapName, + configMapPath, + mode, + rawMounts, + ) +} + +func writeFile(t *testing.T, path string, content string, mode os.FileMode) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + func writeTarEntry(t *testing.T, tw *tar.Writer, hdr *tar.Header, data []byte) { t.Helper() diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go new file mode 100644 index 0000000000..b347993c55 --- /dev/null +++ b/labruntime/clabernetes/files.go @@ -0,0 +1,876 @@ +package clabernetes + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + clablinks "github.com/srl-labs/containerlab/links" + clabtypes "github.com/srl-labs/containerlab/types" + clabutils "github.com/srl-labs/containerlab/utils" + "gopkg.in/yaml.v2" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + fileModeRead = "read" + fileModeExecute = "execute" + inlineStartupConfigMountPath = "/clabernetes/startup-config" + maxConfigMapFileBytes = 950_000 + kubernetesNameMaxLen = 63 + gnmicPrometheusPort = 9273 + clabernetesNamingNonPrefixed = "non-prefixed" + + clabDirVar = "__clabDir__" + clabLabNameVar = "__clabLabName__" + nodeDirVar = "__clabNodeDir__" + nodeNameVar = "__clabNodeName__" +) + +var ( + invalidDNSLabelChars = regexp.MustCompile(`[^a-z0-9\-]`) //nolint:gochecknoglobals + startsWithNonAlpha = regexp.MustCompile(`^[^a-z]`) //nolint:gochecknoglobals + endsWithNonAlpha = regexp.MustCompile(`[^a-z]$`) //nolint:gochecknoglobals +) + +type clabRuntimeConfig struct { + Name string `yaml:"name,omitempty"` + Prefix *string `yaml:"prefix,omitempty"` + Mgmt *clabtypes.MgmtNet `yaml:"mgmt,omitempty"` + Settings *clabtypes.Settings `yaml:"settings,omitempty"` + Topology *clabtypes.Topology `yaml:"topology,omitempty"` +} + +type clabernetesRenderConfig struct { + Name string `yaml:"name,omitempty"` + Prefix *string `yaml:"prefix,omitempty"` + Mgmt *clabtypes.MgmtNet `yaml:"mgmt,omitempty"` + Settings *clabtypes.Settings `yaml:"settings,omitempty"` + Topology *clabernetesRenderTopology `yaml:"topology,omitempty"` +} + +type clabernetesRenderTopology struct { + Defaults *clabtypes.NodeDefinition `yaml:"defaults,omitempty"` + Kinds map[string]*clabtypes.NodeDefinition `yaml:"kinds,omitempty"` + Nodes map[string]*clabtypes.NodeDefinition `yaml:"nodes,omitempty"` + Groups map[string]*clabtypes.NodeDefinition `yaml:"groups,omitempty"` + Links []*clablinks.LinkBriefRaw `yaml:"links,omitempty"` +} + +type stagedConfigMap struct { + name string + nodeName string + binaryData map[string][]byte + keyByFilePath map[string]string + mounts []stagedConfigMapMount +} + +type stagedConfigMapMount struct { + nodeName string + filePath string + configMapName string + configMapPath string + mode string +} + +type stagedLocalFile struct { + filePath string + resolvedPath string + mode string + content []byte +} + +func stageTopologyLocalFiles( + req clablabruntime.DeployRequest, +) ([]byte, []stagedConfigMap, string, error) { + config := &clabRuntimeConfig{} + if err := yaml.Unmarshal(req.TopologyDefinition, config); err != nil { + return nil, nil, "", fmt.Errorf( + "failed to parse rendered topology for clabernetes preparation: %w", + err, + ) + } + + naming := clabernetesNamingMode(config) + if config.Topology == nil || len(config.Topology.Nodes) == 0 { + return req.TopologyDefinition, nil, naming, nil + } + + extraConfigMaps := map[string]*stagedConfigMap{} + startupConfigMaps := map[string]*stagedConfigMap{} + definitionChanged := exposeClabernetesCompatibilityPorts(config) + + nodeNames := make([]string, 0, len(config.Topology.Nodes)) + for nodeName := range config.Topology.Nodes { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + if req.TopologyFile != "" { + topologyFileDir := filepath.Dir(req.TopologyFile) + topologyLabDir := req.TopologyLabDir + if topologyLabDir == "" && config.Name != "" { + topologyLabDir = filepath.Join(topologyFileDir, "clab-"+config.Name) + } + + for _, nodeName := range nodeNames { + if err := stageStartupConfig( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + startupConfigMaps, + &definitionChanged, + ); err != nil { + return nil, nil, "", err + } + + if err := stageLicenseFile( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } + + if err := stageBindFiles( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } + } + } + + topologyDefinition := req.TopologyDefinition + if definitionChanged { + updatedDefinition, err := renderClabernetesTopologyDefinition(config) + if err != nil { + return nil, nil, "", fmt.Errorf( + "failed to render updated clabernetes topology definition: %w", + err, + ) + } + topologyDefinition = updatedDefinition + } + + stagedConfigMaps := collectStagedConfigMaps(startupConfigMaps, extraConfigMaps) + + return topologyDefinition, stagedConfigMaps, naming, nil +} + +func renderClabernetesTopologyDefinition(config *clabRuntimeConfig) ([]byte, error) { + if config == nil { + return nil, fmt.Errorf("topology config is nil") + } + + rendered := &clabernetesRenderConfig{ + Name: config.Name, + Prefix: config.Prefix, + Mgmt: config.Mgmt, + Settings: config.Settings, + } + + if config.Topology != nil { + links, err := clabernetesBriefLinks(config.Topology.Links) + if err != nil { + return nil, err + } + + rendered.Topology = &clabernetesRenderTopology{ + Defaults: config.Topology.Defaults, + Kinds: config.Topology.Kinds, + Nodes: config.Topology.Nodes, + Groups: config.Topology.Groups, + Links: links, + } + } + + return yaml.Marshal(rendered) +} + +func clabernetesBriefLinks( + links []*clablinks.LinkDefinition, +) ([]*clablinks.LinkBriefRaw, error) { + if len(links) == 0 { + return nil, nil + } + + briefLinks := make([]*clablinks.LinkBriefRaw, 0, len(links)) + for _, link := range links { + if link == nil || link.Link == nil { + continue + } + + brief, err := clabernetesBriefLink(link) + if err != nil { + return nil, err + } + briefLinks = append(briefLinks, brief) + } + + return briefLinks, nil +} + +func clabernetesBriefLink( + link *clablinks.LinkDefinition, +) (*clablinks.LinkBriefRaw, error) { + switch raw := link.Link.(type) { + case *clablinks.LinkBriefRaw: + return raw, nil + case *clablinks.LinkVEthRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkHostRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkMgmtNetRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkMacVlanRaw: + return raw.ToLinkBriefRaw(), nil + default: + return nil, fmt.Errorf( + "failed to render clabernetes-compatible brief link for %s link", + link.Link.GetType(), + ) + } +} + +func clabernetesNamingMode(config *clabRuntimeConfig) string { + if config == nil || config.Prefix == nil || *config.Prefix != "" { + return "" + } + + return clabernetesNamingNonPrefixed +} + +func exposeClabernetesCompatibilityPorts(config *clabRuntimeConfig) bool { + if config == nil || config.Topology == nil { + return false + } + + definitionChanged := false + + for nodeName, nodeDefinition := range config.Topology.Nodes { + if nodeDefinition == nil || !isGNMICNode(nodeName, nodeDefinition) { + continue + } + + if hasDestinationPort(nodeDefinition.Ports, gnmicPrometheusPort, "tcp") { + continue + } + + nodeDefinition.Ports = append( + nodeDefinition.Ports, + fmt.Sprintf("%d:%d/tcp", gnmicPrometheusPort, gnmicPrometheusPort), + ) + definitionChanged = true + } + + return definitionChanged +} + +func isGNMICNode(nodeName string, nodeDefinition *clabtypes.NodeDefinition) bool { + nodeName = strings.ToLower(nodeName) + image := strings.ToLower(nodeDefinition.Image) + + return nodeName == "gnmic" || strings.Contains(image, "gnmic") +} + +func hasDestinationPort(portDefinitions []string, destinationPort int, protocol string) bool { + for _, portDefinition := range portDefinitions { + port, portProtocol := splitPortProtocol(portDefinition) + if portProtocol != "" && !strings.EqualFold(portProtocol, protocol) { + continue + } + + parts := strings.Split(port, ":") + if parts[len(parts)-1] == fmt.Sprint(destinationPort) { + return true + } + } + + return false +} + +func splitPortProtocol(portDefinition string) (port, protocol string) { + port, protocol, found := strings.Cut(portDefinition, "/") + if !found { + return portDefinition, "" + } + + return port, protocol +} + +func stageStartupConfig( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, + definitionChanged *bool, +) error { + startupConfig := config.Topology.GetNodeStartupConfig(nodeName) + if startupConfig == "" { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "startup-config"), + ) + + if strings.Contains(startupConfig, "\n") { + nodeDefinition := config.Topology.Nodes[nodeName] + if nodeDefinition == nil { + nodeDefinition = &clabtypes.NodeDefinition{} + config.Topology.Nodes[nodeName] = nodeDefinition + } + nodeDefinition.StartupConfig = inlineStartupConfigMountPath + *definitionChanged = true + + return addStagedConfigMapData( + configMap, + inlineStartupConfigMountPath, + "startup-config", + fileModeRead, + []byte(startupConfig), + ) + } + + files, err := resolveLocalFiles(startupConfig, nodeName, topologyFileDir, topologyLabDir) + if err != nil { + return fmt.Errorf("failed staging startup-config for node %q: %w", nodeName, err) + } + + for _, file := range files { + if err := addStagedConfigMapData( + configMap, + file.filePath, + "startup-config", + file.mode, + file.content, + ); err != nil { + return err + } + } + + return nil +} + +func stageLicenseFile( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + license := config.Topology.GetNodeLicense(nodeName) + if license == "" { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + return stageSourcePathIntoConfigMap(configMap, license, nodeName, topologyFileDir, topologyLabDir) +} + +func stageBindFiles( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + binds, err := config.Topology.GetNodeBinds(nodeName) + if err != nil { + return fmt.Errorf("failed parsing bind mounts for node %q: %w", nodeName, err) + } + if len(binds) == 0 { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + for _, bind := range binds { + parsedBind, err := clabtypes.NewBindFromString(bind) + if err != nil { + return fmt.Errorf("failed parsing bind %q for node %q: %w", bind, nodeName, err) + } + if parsedBind.Src() == "" { + continue + } + + if err := stageSourcePathIntoConfigMap( + configMap, + parsedBind.Src(), + nodeName, + topologyFileDir, + topologyLabDir, + ); err != nil { + return err + } + } + + return nil +} + +func stageSourcePathIntoConfigMap( + configMap *stagedConfigMap, + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir string, +) error { + files, err := resolveLocalFiles(sourcePath, nodeName, topologyFileDir, topologyLabDir) + if err != nil { + return fmt.Errorf("failed staging source path %q for node %q: %w", sourcePath, nodeName, err) + } + + for _, file := range files { + configMapKey := uniqueConfigMapKey(configMap, file.filePath) + if err := addStagedConfigMapData( + configMap, + file.filePath, + configMapKey, + file.mode, + file.content, + ); err != nil { + return err + } + } + + return nil +} + +func resolveLocalFiles( + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir string, +) ([]stagedLocalFile, error) { + displayPath := replaceClabPathVariables(sourcePath, nodeName, topologyLabDir) + resolvedPath := clabutils.ResolvePath(displayPath, topologyFileDir) + + fileInfo, err := os.Stat(resolvedPath) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + file, err := loadStagedLocalFile(displayPath, resolvedPath, fileInfo) + if err != nil { + return nil, err + } + + return []stagedLocalFile{file}, nil + } + + files := []stagedLocalFile{} + err = filepath.WalkDir(resolvedPath, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + + fileInfo, err := d.Info() + if err != nil { + return err + } + + relativePath, err := filepath.Rel(resolvedPath, path) + if err != nil { + return err + } + + file, err := loadStagedLocalFile( + filepath.ToSlash(filepath.Join(displayPath, relativePath)), + path, + fileInfo, + ) + if err != nil { + return err + } + + files = append(files, file) + + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(files, func(i, j int) bool { + return files[i].filePath < files[j].filePath + }) + + return files, nil +} + +func loadStagedLocalFile( + filePath, + resolvedPath string, + fileInfo os.FileInfo, +) (stagedLocalFile, error) { + content, err := os.ReadFile(resolvedPath) //nolint:gosec + if err != nil { + return stagedLocalFile{}, err + } + if len(content) > maxConfigMapFileBytes { + return stagedLocalFile{}, fmt.Errorf( + "file %q is %d bytes, larger than the supported ConfigMap file limit of %d bytes", + resolvedPath, + len(content), + maxConfigMapFileBytes, + ) + } + + mode := fileModeRead + if fileInfo.Mode()&0o111 != 0 { + mode = fileModeExecute + } + + return stagedLocalFile{ + filePath: filepath.ToSlash(filePath), + resolvedPath: resolvedPath, + mode: mode, + content: content, + }, nil +} + +func replaceClabPathVariables(sourcePath, nodeName, topologyLabDir string) string { + labName := filepath.Base(topologyLabDir) + nodeDir := "" + if topologyLabDir != "" && nodeName != "" { + nodeDir = filepath.Join(topologyLabDir, nodeName) + } + + replacer := strings.NewReplacer( + clabDirVar, topologyLabDir, + clabLabNameVar, labName, + nodeDirVar, nodeDir, + nodeNameVar, nodeName, + ) + + return replacer.Replace(sourcePath) +} + +func getOrCreateStagedConfigMap( + configMaps map[string]*stagedConfigMap, + nodeName, + name string, +) *stagedConfigMap { + configMap, ok := configMaps[nodeName] + if ok { + return configMap + } + + configMap = &stagedConfigMap{ + name: name, + nodeName: nodeName, + binaryData: map[string][]byte{}, + keyByFilePath: map[string]string{}, + } + configMaps[nodeName] = configMap + + return configMap +} + +func addStagedConfigMapData( + configMap *stagedConfigMap, + filePath, + configMapKey, + mode string, + content []byte, +) error { + if existingKey, ok := configMap.keyByFilePath[filePath]; ok { + if !bytes.Equal(configMap.binaryData[existingKey], content) { + return fmt.Errorf("staged file path %q has conflicting content", filePath) + } + + return nil + } + + configMap.binaryData[configMapKey] = content + configMap.keyByFilePath[filePath] = configMapKey + configMap.mounts = append(configMap.mounts, stagedConfigMapMount{ + nodeName: configMap.nodeName, + filePath: filePath, + configMapName: configMap.name, + configMapPath: configMapKey, + mode: mode, + }) + + return nil +} + +func uniqueConfigMapKey(configMap *stagedConfigMap, filePath string) string { + configMapKey := safeConfigMapKey(filePath) + if _, exists := configMap.binaryData[configMapKey]; !exists { + return configMapKey + } + + digest := sha256.Sum256([]byte(filePath)) + for idx := 0; ; idx++ { + candidate := safeKubernetesName( + configMapKey, + hex.EncodeToString(digest[:])[0:7], + fmt.Sprintf("%d", idx), + ) + if _, exists := configMap.binaryData[candidate]; !exists { + return candidate + } + } +} + +func collectStagedConfigMaps(configMapGroups ...map[string]*stagedConfigMap) []stagedConfigMap { + configMaps := []stagedConfigMap{} + + for _, configMapGroup := range configMapGroups { + nodeNames := make([]string, 0, len(configMapGroup)) + for nodeName := range configMapGroup { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + for _, nodeName := range nodeNames { + configMap := configMapGroup[nodeName] + sort.Slice(configMap.mounts, func(i, j int) bool { + return configMap.mounts[i].filePath < configMap.mounts[j].filePath + }) + configMaps = append(configMaps, *configMap) + } + } + + return configMaps +} + +func setTopologyFilesFromConfigMaps( + topology *unstructured.Unstructured, + configMaps []stagedConfigMap, +) error { + if len(configMaps) == 0 { + return nil + } + + filesFromConfigMap := map[string]any{} + for _, configMap := range configMaps { + for _, mount := range configMap.mounts { + nodeFiles, _ := filesFromConfigMap[mount.nodeName].([]any) + nodeFiles = append(nodeFiles, map[string]any{ + "filePath": mount.filePath, + "configMapName": mount.configMapName, + "configMapPath": mount.configMapPath, + "mode": mount.mode, + }) + filesFromConfigMap[mount.nodeName] = nodeFiles + } + } + + deployment, found, err := unstructured.NestedMap(topology.Object, "spec", "deployment") + if err != nil { + return err + } + if !found { + deployment = map[string]any{} + } + + deployment["filesFromConfigMap"] = filesFromConfigMap + + return unstructured.SetNestedMap(topology.Object, deployment, "spec", "deployment") +} + +func (r *Runtime) applyStagedConfigMaps( + ctx context.Context, + namespace string, + topologyName string, + configMaps []stagedConfigMap, +) error { + for _, staged := range configMaps { + configMap := stagedConfigMapObject(namespace, topologyName, staged, nil) + + created, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Create(ctx, configMap, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(ctx, staged.name, metav1.GetOptions{}) + if getErr != nil { + return fmt.Errorf("failed to get existing staged ConfigMap %s/%s: %w", + namespace, + staged.name, + getErr, + ) + } + + configMap.ResourceVersion = existing.ResourceVersion + created, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Update(ctx, configMap, metav1.UpdateOptions{}) + } + if err != nil { + return fmt.Errorf("failed to apply staged ConfigMap %s/%s: %w", + namespace, + staged.name, + err, + ) + } + + _ = created + } + + return nil +} + +func stagedConfigMapObject( + namespace string, + topologyName string, + staged stagedConfigMap, + ownerReferences []metav1.OwnerReference, +) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: staged.name, + Namespace: namespace, + OwnerReferences: ownerReferences, + Labels: map[string]string{ + labelApp: clabernetesAppValue, + labelTopologyOwner: topologyName, + labelTopologyNode: staged.nodeName, + }, + }, + BinaryData: staged.binaryData, + } +} + +func (r *Runtime) setStagedConfigMapOwnerReferences( + ctx context.Context, + namespace string, + configMaps []stagedConfigMap, + topology *unstructured.Unstructured, +) error { + if len(configMaps) == 0 { + return nil + } + + ownerReferences := []metav1.OwnerReference{ + { + APIVersion: "clabernetes.containerlab.dev/v1alpha1", + Kind: "Topology", + Name: topology.GetName(), + UID: topology.GetUID(), + }, + } + + for _, staged := range configMaps { + configMap, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(ctx, staged.name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + configMap = stagedConfigMapObject(namespace, topology.GetName(), staged, ownerReferences) + if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Create(ctx, configMap, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to recreate staged ConfigMap %s/%s with owner references: %w", + namespace, + staged.name, + err, + ) + } + + continue + } + if err != nil { + return fmt.Errorf("failed to get staged ConfigMap %s/%s for owner update: %w", + namespace, + staged.name, + err, + ) + } + + configMap.OwnerReferences = ownerReferences + + if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Update(ctx, configMap, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update staged ConfigMap %s/%s owner references: %w", + namespace, + staged.name, + err, + ) + } + } + + return nil +} + +func (r *Runtime) deleteStagedConfigMaps( + ctx context.Context, + namespace string, + configMaps []stagedConfigMap, +) { + for _, staged := range configMaps { + err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, staged.name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + log.Debug("failed to delete staged clabernetes ConfigMap", + "namespace", namespace, + "name", staged.name, + "error", err, + ) + } + } +} + +func safeConfigMapKey(filePath string) string { + parts := strings.FieldsFunc(filepath.ToSlash(filePath), func(r rune) bool { + return r == '/' || r == '\\' + }) + if len(parts) == 0 { + return "file" + } + + return safeKubernetesName(parts...) +} + +func safeKubernetesName(parts ...string) string { + name := strings.Join(parts, "-") + if len(name) > kubernetesNameMaxLen { + digest := sha256.Sum256([]byte(name)) + name = name[0:kubernetesNameMaxLen-8] + "-" + hex.EncodeToString(digest[:])[0:7] + } + + name = strings.ToLower(name) + name = invalidDNSLabelChars.ReplaceAllString(name, "-") + name = startsWithNonAlpha.ReplaceAllString(name, "z") + name = endsWithNonAlpha.ReplaceAllString(name, "z") + + return name +} diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index 4154eb956b..9c57f83f35 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -27,19 +27,47 @@ func (r *Runtime) Deploy( namespace := r.namespaceFor(req.Namespace) resource := r.client.Resource(topologyGVR).Namespace(namespace) - desired := topologyObject(req.Name, namespace, req.Owner, string(req.TopologyDefinition)) _, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) switch { case apierrors.IsNotFound(err): + topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) + if err != nil { + return nil, err + } + + desired := topologyObject( + req.Name, + namespace, + req.Owner, + string(topologyDefinition), + topologyWithNaming(naming), + ) + if err := setTopologyFilesFromConfigMaps(desired, stagedConfigMaps); err != nil { + return nil, err + } + + if err = r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) - if _, err = resource.Create(ctx, desired, metav1.CreateOptions{}); err != nil { + created, createErr := resource.Create(ctx, desired, metav1.CreateOptions{}) + if createErr != nil { + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + + err = createErr if apierrors.IsAlreadyExists(err) { return nil, duplicateTopologyError(req.Name, namespace) } + return nil, fmt.Errorf("failed to create clabernetes topology %s/%s: %w", namespace, req.Name, err) } + + if err = r.setStagedConfigMapOwnerReferences(ctx, namespace, stagedConfigMaps, created); err != nil { + return nil, err + } case err != nil: return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", namespace, req.Name, err) diff --git a/labruntime/clabernetes/topology.go b/labruntime/clabernetes/topology.go index 9013c10e16..26a34c6717 100644 --- a/labruntime/clabernetes/topology.go +++ b/labruntime/clabernetes/topology.go @@ -13,7 +13,23 @@ import ( "k8s.io/apimachinery/pkg/util/validation" ) -func topologyObject(name, namespace, owner, definition string) *unstructured.Unstructured { +type topologyObjectOption func(map[string]any) + +func topologyWithNaming(naming string) topologyObjectOption { + return func(spec map[string]any) { + if naming != "" { + spec["naming"] = naming + } + } +} + +func topologyObject( + name, + namespace, + owner, + definition string, + opts ...topologyObjectOption, +) *unstructured.Unstructured { topologyLabels := map[string]any{ "containerlab.dev/runtime": clablabruntime.ClabernetesRuntimeName, } @@ -34,16 +50,21 @@ func topologyObject(name, namespace, owner, definition string) *unstructured.Uns metadata["annotations"] = topologyAnnotations } + spec := map[string]any{ + "definition": map[string]any{ + "containerlab": definition, + }, + } + for _, opt := range opts { + opt(spec) + } + return &unstructured.Unstructured{ Object: map[string]any{ "apiVersion": "clabernetes.containerlab.dev/v1alpha1", "kind": "Topology", "metadata": metadata, - "spec": map[string]any{ - "definition": map[string]any{ - "containerlab": definition, - }, - }, + "spec": spec, }, } } diff --git a/labruntime/runtime.go b/labruntime/runtime.go index 6253b9e836..4ce3ac8a1d 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -21,6 +21,8 @@ type DeployRequest struct { Name string Namespace string Owner string + TopologyFile string + TopologyLabDir string TopologyDefinition []byte Wait bool Timeout time.Duration From 4cb6b7d4af6dcec155543860d5b3be8108c57c46 Mon Sep 17 00:00:00 2001 From: FloSch62 Date: Wed, 10 Jun 2026 18:41:53 +0200 Subject: [PATCH 12/21] Harden clabernetes runtime edge cases and cleaning --- cmd/root.go | 26 ++++++++++++ cmd/root_test.go | 59 ++++++++++++++++++++++++++- docs/manual/clabernetes/runtime.md | 4 +- labruntime/clabernetes/clabernetes.go | 15 ------- labruntime/clabernetes/exec.go | 28 ++++++++++++- labruntime/clabernetes/lifecycle.go | 12 ++++++ labruntime/clabernetes/nodes.go | 5 +++ labruntime/runtime.go | 14 ------- 8 files changed, 129 insertions(+), 34 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 8ca23378c9..6dc83d1c15 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -152,6 +152,10 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { log.SetTimeFormat(time.TimeOnly) + if err := checkLabRuntimeCommandSupport(cobraCmd, o.Global.Runtime); err != nil { + return err + } + err := clabutils.DropRootPrivs() if err != nil { return err @@ -176,6 +180,28 @@ func commandSkipsRoot(name string) bool { return clablabruntime.IsLabRuntimeName(name) } +// labRuntimeUnsupportedCommands operate on local containers or host networking +// and have no lab runtime equivalent. +var labRuntimeUnsupportedCommands = map[string]struct{}{ + "graph": {}, + "tools": {}, +} + +func checkLabRuntimeCommandSupport(cobraCmd *cobra.Command, runtimeName string) error { + if !clablabruntime.IsLabRuntimeName(runtimeName) { + return nil + } + + for cmd := cobraCmd; cmd != nil; cmd = cmd.Parent() { + if _, ok := labRuntimeUnsupportedCommands[cmd.Name()]; ok { + return fmt.Errorf("the %q command is not supported with lab runtime %q", + cmd.Name(), runtimeName) + } + } + + return nil +} + // getTopoFilePath finds *.clab.y*ml file in the current working directory // if the file was not specified. // If the topology file refers to a git repository, it will be cloned to the current directory. diff --git a/cmd/root_test.go b/cmd/root_test.go index f083ac4ed3..183406633e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,10 @@ package cmd -import "testing" +import ( + "testing" + + "github.com/spf13/cobra" +) func TestRootRequirementHelpers(t *testing.T) { tests := []struct { @@ -49,3 +53,56 @@ func TestRootRequirementHelpers(t *testing.T) { }) } } + +func TestCheckLabRuntimeCommandSupport(t *testing.T) { + root := &cobra.Command{Use: "containerlab"} + graph := &cobra.Command{Use: "graph"} + tools := &cobra.Command{Use: "tools"} + sshx := &cobra.Command{Use: "sshx"} + deploy := &cobra.Command{Use: "deploy"} + + root.AddCommand(graph, tools, deploy) + tools.AddCommand(sshx) + + tests := []struct { + name string + runtime string + cmd *cobra.Command + wantErr bool + }{ + { + name: "graph with docker runtime", + runtime: "docker", + cmd: graph, + wantErr: false, + }, + { + name: "deploy with clabernetes runtime", + runtime: "clabernetes", + cmd: deploy, + wantErr: false, + }, + { + name: "graph with clabernetes runtime", + runtime: "clabernetes", + cmd: graph, + wantErr: true, + }, + { + name: "tools subcommand with clabernetes runtime", + runtime: "clabernetes", + cmd: sshx, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkLabRuntimeCommandSupport(tt.cmd, tt.runtime) + if (err != nil) != tt.wantErr { + t.Fatalf("checkLabRuntimeCommandSupport(%q, %q) error = %v, wantErr %v", + tt.cmd.Name(), tt.runtime, err, tt.wantErr) + } + }) + } +} diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 40dd4e70a3..2a93a27212 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -576,8 +576,8 @@ Known differences: - Local network namespace features are not equivalent in c9s. - `inspect interfaces` and host-side `tc` or netem operations do not have the same local namespace access they have with Docker labs. -- Some `tools` commands create local helper containers and are not modeled as - Clabernetes `Topology` resources. +- `graph` and `tools` commands operate on local containers and host networking + and are rejected with an error when the `clabernetes` runtime is selected. - Per-node `runtime: docker` or `runtime: podman` is not the same as selecting the global `clabernetes` lab runtime. - Two c9s labs can have the same lab name in different namespaces. diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 151f896cad..dc10d549ee 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -73,18 +73,3 @@ func New(cfg clablabruntime.Config) (clablabruntime.LabRuntime, error) { timeout: cfg.Timeout, }, nil } - -func (r *Runtime) Capabilities() clablabruntime.RuntimeCapabilities { - return clablabruntime.RuntimeCapabilities{ - Deploy: true, - Destroy: true, - Inspect: true, - List: true, - Exec: true, - Start: true, - Stop: true, - Restart: true, - Save: true, - Events: true, - } -} diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go index 0bd9b29226..95450aa36a 100644 --- a/labruntime/clabernetes/exec.go +++ b/labruntime/clabernetes/exec.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" + "github.com/charmbracelet/log" clabexec "github.com/srl-labs/containerlab/exec" clablabruntime "github.com/srl-labs/containerlab/labruntime" corev1 "k8s.io/api/core/v1" @@ -74,13 +75,36 @@ func (r *Runtime) launcherPod( namespace, name, nodeName) } + candidates := make([]*corev1.Pod, 0, len(list.Items)) for idx := range list.Items { if list.Items[idx].Status.Phase == corev1.PodRunning { - return &list.Items[idx], nil + candidates = append(candidates, &list.Items[idx]) } } + if len(candidates) == 0 { + for idx := range list.Items { + candidates = append(candidates, &list.Items[idx]) + } + } + + // more than one pod can match during a rolling update; use the newest one + pod := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.CreationTimestamp.After(pod.CreationTimestamp.Time) { + pod = candidate + } + } + + if len(list.Items) > 1 { + log.Warn("multiple clabernetes launcher pods matched node, using newest", + "namespace", namespace, + "lab", name, + "node", nodeName, + "pod", pod.Name, + ) + } - return &list.Items[0], nil + return pod, nil } func (r *Runtime) execInPod( diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index 9c57f83f35..be7b07a553 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -66,6 +66,18 @@ func (r *Runtime) Deploy( } if err = r.setStagedConfigMapOwnerReferences(ctx, namespace, stagedConfigMaps, created); err != nil { + // without owner references the ConfigMaps would never be garbage + // collected, so roll back the partially deployed topology + if delErr := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}); delErr != nil && + !apierrors.IsNotFound(delErr) { + log.Debug("failed to roll back clabernetes topology", + "name", req.Name, + "namespace", namespace, + "error", delErr, + ) + } + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + return nil, err } case err != nil: diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go index 92b5f9be0c..6b6a7dff86 100644 --- a/labruntime/clabernetes/nodes.go +++ b/labruntime/clabernetes/nodes.go @@ -288,6 +288,11 @@ func (r *Runtime) deploymentForNode( return nil, fmt.Errorf("clabernetes deployment for node %s/%s/%s was not found", namespace, name, nodeName) } + if len(list.Items) > 1 { + return nil, fmt.Errorf( + "expected exactly one clabernetes deployment for node %s/%s/%s, found %d", + namespace, name, nodeName, len(list.Items)) + } return &list.Items[0], nil } diff --git a/labruntime/runtime.go b/labruntime/runtime.go index 4ce3ac8a1d..01fa59ac7f 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -86,19 +86,6 @@ type SaveResult struct { Files []SavedFile } -type RuntimeCapabilities struct { - Deploy bool - Destroy bool - Inspect bool - List bool - Exec bool - Start bool - Stop bool - Restart bool - Save bool - Events bool -} - type NodeState struct { Name string Kind string @@ -139,7 +126,6 @@ type LabRuntime interface { Restart(context.Context, NodeRequest) error Save(context.Context, SaveRequest) (*SaveResult, error) StreamEvents(context.Context, EventStreamRequest) (<-chan Event, <-chan error, error) - Capabilities() RuntimeCapabilities } type Initializer func(Config) (LabRuntime, error) From 2d4bf8dfbe14e4b881621c81f937810d03447acf Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Wed, 12 Aug 2026 09:34:53 +0200 Subject: [PATCH 13/21] Update c9s runtime for Node and Link APIs --- .github/workflows/clabernetes-tests.yml | 6 +- docs/manual/clabernetes/configuration.md | 6 +- docs/manual/clabernetes/index.md | 6 +- docs/manual/clabernetes/install.md | 14 +-- docs/manual/clabernetes/quickstart.md | 16 +-- docs/manual/clabernetes/runtime.md | 61 +++++++----- labruntime/clabernetes/clabernetes.go | 17 +++- labruntime/clabernetes/clabernetes_test.go | 84 +++++++++++++++- labruntime/clabernetes/exec.go | 8 +- labruntime/clabernetes/files.go | 2 +- labruntime/clabernetes/nodes.go | 110 ++++++++++++++++++--- labruntime/clabernetes/resources.go | 107 ++++++++++++++++++++ labruntime/clabernetes/state.go | 73 ++++++++++---- labruntime/clabernetes/topology.go | 2 +- 14 files changed, 422 insertions(+), 90 deletions(-) create mode 100644 labruntime/clabernetes/resources.go diff --git a/.github/workflows/clabernetes-tests.yml b/.github/workflows/clabernetes-tests.yml index 0b30ed00f0..c88c440936 100644 --- a/.github/workflows/clabernetes-tests.yml +++ b/.github/workflows/clabernetes-tests.yml @@ -45,7 +45,7 @@ jobs: - name: Install kind run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.22.0/kind-linux-amd64 + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind @@ -73,9 +73,9 @@ jobs: -v "${HOME}/.helm:/root/.helm" \ -v "${HOME}/.config/helm:/root/.config/helm" \ -v "${HOME}/.cache/helm:/root/.cache/helm" \ - alpine/helm:3.12.3 \ + alpine/helm:3.18.2 \ upgrade --install --create-namespace --namespace c9s \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes kubectl -n c9s rollout status deploy/clabernetes-manager --timeout=180s diff --git a/docs/manual/clabernetes/configuration.md b/docs/manual/clabernetes/configuration.md index 5463008067..b27eabdcd5 100644 --- a/docs/manual/clabernetes/configuration.md +++ b/docs/manual/clabernetes/configuration.md @@ -1,13 +1,13 @@ # Topology Configuration -The Topology custom resource (CR) is the primary way to deploy containerlab topologies in Kubernetes. This page covers all available configuration options. +The Topology custom resource (CR) is the high-level compatibility API for deploying a complete containerlab definition. c9s compiles it into the primary Node, Link, and LauncherProfile resources. This page covers the Topology configuration options. ## Definition The `definition` field contains the containerlab topology in YAML format: ```yaml -apiVersion: clabernetes.containerlab.dev/v1alpha1 +apiVersion: c9s.run/v1alpha1 kind: Topology metadata: name: my-lab @@ -244,7 +244,7 @@ spec: ## Complete Example ```yaml -apiVersion: clabernetes.containerlab.dev/v1alpha1 +apiVersion: c9s.run/v1alpha1 kind: Topology metadata: name: production-lab diff --git a/docs/manual/clabernetes/index.md b/docs/manual/clabernetes/index.md index 1b6647259b..7f8e7d4bdc 100644 --- a/docs/manual/clabernetes/index.md +++ b/docs/manual/clabernetes/index.md @@ -8,7 +8,7 @@ tags: pronounciation: *Kla-ber-net-ees* -Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Enter [**clabernetes**](https://github.com/srl-labs/clabernetes/) or simply **c9s**. +Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Enter [**clabernetes**](https://github.com/clabernetes/clabernetes/) or simply **c9s**. ![Clabernetes](https://gitlab.com/rdodin/pics/-/wikis/uploads/9d8c5abcb8db2c80811635d928aa98df/c9s_logo1_border_2.webp){ align=left width="300" } @@ -37,6 +37,6 @@ In the beta release we focus on the core topology constructs working our way tow ## Quick Links * [Helm chart on ArtifactHub](https://artifacthub.io/packages/helm/clabernetes/clabernetes) -* [CRD reference](https://crds.r8y.page/repo/github.com/srl-labs/clabernetes) +* [CRD reference](https://c9s.run/docs/crd) * [Native containerlab runtime](runtime.md) -* Source code on [GitHub](https://github.com/srl-labs/clabernetes) +* Source code on [GitHub](https://github.com/clabernetes/clabernetes) diff --git a/docs/manual/clabernetes/install.md b/docs/manual/clabernetes/install.md index bed05e3fd6..48e807d0e1 100644 --- a/docs/manual/clabernetes/install.md +++ b/docs/manual/clabernetes/install.md @@ -1,6 +1,6 @@ # Installation -Clabernetes runs on a Kubernetes cluster and hence requires one to be available before you start your Clabernetes journey. Although we don't have a strict requirement on the k8s version, we recommend using the version 1.21 or higher. +Clabernetes runs on a Kubernetes cluster and hence requires one to be available before you start your Clabernetes journey. c9s 0.7 and newer require Kubernetes 1.31 or higher for the field selectors used by the Link API. Clabernetes project consists of two main components: @@ -27,7 +27,7 @@ To install the latest Clabernetes release with Helm to an existing k8s cluster[^ ```bash helm upgrade --install --create-namespace --namespace c9s \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` @@ -38,7 +38,7 @@ To install a specific clabernetes version add `--version` flag like so: ```bash helm upgrade --version 0.0.25 --install \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` /// @@ -52,7 +52,7 @@ helm upgrade --install --version 0.0.0 --create-namespace --namespace c9s \ --set manager.imagePullPolicy=Always \ --set globalConfig.deployment.launcherImagePullPolicy=Always \ --set globalConfig.deployment.launcherLogLevel=debug \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` We also set the log level to `debug` for all the components to see more verbose logs. Trust us, you might need it :smile: @@ -77,7 +77,7 @@ Clabverter is versioned in the same way as Clabernetes, and the easiest way to u ```bash title="set up clabverter alias" alias clabverter='sudo docker run --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter' + ghcr.io/clabernetes/clabernetes/clabverter' ``` /// @@ -87,7 +87,7 @@ In case you need to install a specific version: ```bash alias clabverter='sudo docker run --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter:0.0.22' + ghcr.io/clabernetes/clabernetes/clabverter:0.7.0' ``` /// @@ -97,7 +97,7 @@ To use the latest development version of clabverter: ```bash alias clabverter='sudo docker run --pull always --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter:dev-latest' + ghcr.io/clabernetes/clabernetes/clabverter:dev-latest' ``` /// diff --git a/docs/manual/clabernetes/quickstart.md b/docs/manual/clabernetes/quickstart.md index cd3abcb57c..f8ae607e20 100644 --- a/docs/manual/clabernetes/quickstart.md +++ b/docs/manual/clabernetes/quickstart.md @@ -21,7 +21,7 @@ Let's see how it all works, buckle up! ## Creating a cluster -Clabernetes goal is to allow users to run networking labs with containerlab's simplicity and ease of use, but with the scaling powers of kubernetes. Surely, it is best to have a real deal available to you, but for demo purposes we'll use [`kind`](https://kind.sigs.k8s.io/) v0.22.0 to create a local multi-node kubernetes cluster. If you already have a k8s cluster, feel free to use it instead -- clabernetes can run in any kubernetes cluster[^1]! +Clabernetes goal is to allow users to run networking labs with containerlab's simplicity and ease of use, but with the scaling powers of kubernetes. Surely, it is best to have a real deal available to you, but for demo purposes we'll use [`kind`](https://kind.sigs.k8s.io/) v0.32.0 to create a local multi-node kubernetes cluster. c9s 0.7 requires Kubernetes 1.31 or newer. If you already have a compatible k8s cluster, feel free to use it instead[^1]! With the following command we instruct kind to set up a three node k8s cluster with two worker and one control plane nodes. @@ -47,9 +47,9 @@ Check that the cluster is ready and proceed with installing clabernetes. ```bash ❯ kubectl get nodes NAME STATUS ROLES AGE VERSION -c9s-control-plane Ready control-plane 5m6s v1.29.2 -c9s-worker Ready 4m46s v1.29.2 -c9s-worker2 Ready 4m42s v1.29.2 +c9s-control-plane Ready control-plane 5m6s v1.35.0 +c9s-worker Ready 4m46s v1.35.0 +c9s-worker2 Ready 4m42s v1.35.0 ``` ## Installing clabernetes @@ -66,7 +66,7 @@ alias helm='docker run --network host -ti --rm -v $(pwd):/apps -w /apps \ -v ~/.kube:/root/.kube -v ~/.helm:/root/.helm \ -v ~/.config/helm:/root/.config/helm \ -v ~/.cache/helm:/root/.cache/helm \ - alpine/helm:3.12.3' +alpine/helm:3.18.2' ``` /// @@ -79,7 +79,7 @@ alias helm='docker run --network host -ti --rm -v $(pwd):/apps -w /apps \ -v ~/.kube:/root/.kube -v ~/.helm:/root/.helm \ -v ~/.config/helm:/root/.config/helm \ -v ~/.cache/helm:/root/.cache/helm \ - alpine/helm:3.12.3' + alpine/helm:3.18.2' ``` /// @@ -231,7 +231,7 @@ As you can see, we have two namespaces: `c9s` and `c9s-vlan`. The `c9s` namespac ### Topology resource -The *main* clabernetes resource is called `Topology` and we should be able to find it in the `c9s-vlan` namespace where all lab resources are deployed: +The `Topology` compatibility resource holds the original containerlab definition. c9s compiles it into the primary `Node`, `Link`, and `LauncherProfile` resources in the `c9s-vlan` namespace: ``` {.bash .no-select} kubectl get --namespace c9s-vlan Topology @@ -244,7 +244,7 @@ vlan containerlab 14h ``` -Looking in the Topology CR we can see that the original containerlab topology definition can be found under the `spec.definition.containerlab` field of the custom resource. Clabernetes took the original topology and split it to sub-topologies that are outlined in the `status.configs` section of the resource: +Looking in the Topology CR we can see that the original containerlab topology definition can be found under the `spec.definition.containerlab` field. Topology status now contains bounded node/link counts and conditions; per-node readiness and exposed-port allocations live on the generated `Node` resources: ``` {.bash .no-select} kubectl get --namespace c9s-vlan Topology vlan -o yaml diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 2a93a27212..8770218ac7 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -29,7 +29,7 @@ Podman containers for the lab nodes. Instead, it renders the final topology and stores it in a Clabernetes `Topology` custom resource: ```yaml -apiVersion: clabernetes.containerlab.dev/v1alpha1 +apiVersion: c9s.run/v1alpha1 kind: Topology metadata: name: @@ -40,9 +40,10 @@ spec: ``` -The Clabernetes manager then reconciles this resource into kubernetes objects, -usually one launcher Deployment and Pod per topology node. Each launcher pod -runs containerlab inside the pod and starts the real node container there. +The Clabernetes manager compiles this compatibility resource into `Node`, +`Link`, and `LauncherProfile` resources. Each launcher pod runs containerlab +inside the pod and starts the real node container there. Nodes using +`network-mode: container:` share the primary node's launcher pod. /// note The node containers are nested inside the launcher pods. A `docker ps` on the @@ -69,6 +70,7 @@ The c9s runtime currently supports the main lab lifecycle and node operations: The c9s runtime expects: - a reachable kubernetes cluster +- kubernetes 1.31 or newer - Clabernetes CRDs installed in the cluster - the Clabernetes manager running and watching the lab namespace - a namespace that already exists for the lab @@ -127,6 +129,12 @@ CLAB_KUBE_NAMESPACE=lab-a \ creates the `Topology` resource in the `lab-a` namespace when a topology with the same name does not already exist there. +/// warning +In c9s 0.7 and newer, the namespace is also the node-name boundary. Two labs in +the same namespace cannot both contain a node with the same name. Use a +dedicated namespace when node names could overlap. +/// + Some commands intentionally look across namespaces: - `inspect --all` @@ -212,8 +220,8 @@ Useful kubernetes checks for the same state are: ```bash kubectl get topologies -A kubectl -n get topology -o yaml -kubectl -n get deploy,pod,svc,cm,pvc \ - -l clabernetes/topologyOwner= +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run,deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= ``` ## Exec @@ -247,18 +255,19 @@ containerlab --runtime clabernetes start -t topo.clab.yml containerlab --runtime clabernetes restart -t topo.clab.yml ``` -`stop` sets the Clabernetes ignore-reconcile label and scales the selected node -Deployments to `0`: +`stop` sets the Clabernetes ignore-reconcile label on the `Topology` and the +selected launcher `Node` resources, then scales their Deployments to `0`: ```text -clabernetes/ignoreReconcile=true +c9s.run/ignoreReconcile=true ``` The label prevents the Clabernetes manager from immediately reconciling the nodes back to the running state. -`start` scales the selected Deployments back to `1` and clears the -ignore-reconcile label when all nodes are running again. +`start` scales the selected Deployments back to `1`, clears the corresponding +Node labels, and clears the Topology label when all launchers are running +again. A grouped secondary shares lifecycle with its primary launcher node. `restart` patches each selected Deployment with a restart annotation and waits for it to become ready: @@ -323,7 +332,7 @@ For c9s, events do not come from Docker events on the outer host. Containerlab watches: - Clabernetes `Topology` resources -- Pods labeled with `clabernetes/topologyOwner` +- Pods labeled with `c9s.run/topologyOwner` With `--initial-state`, the stream starts with synthetic events for the current c9s node state and then continues with live watches. @@ -356,14 +365,14 @@ Related resources are selected with Clabernetes labels: ```bash kubectl -n get deploy,pod,svc,cm,pvc \ - -l clabernetes/topologyOwner= + -l c9s.run/topologyOwner= ``` To find one node launcher pod: ```bash kubectl -n get pod \ - -l clabernetes/topologyOwner=,clabernetes/topologyNode= + -l c9s.run/topologyOwner=,c9s.run/topologyNode= ``` Inside each launcher pod, Clabernetes uses: @@ -401,17 +410,21 @@ ls -la /clabernetes The kube identity used by the outer containerlab process must be able to: - create, get, list, watch, update, and delete Clabernetes `Topology` resources +- list, get, and update c9s `Node` resources - list and watch Pods - list, get, and update Deployments +- create, get, update, and delete ConfigMaps used for local files - exec into launcher Pods with `pods/exec` Useful checks: ```bash -kubectl auth can-i get topologies.clabernetes.containerlab.dev -n -kubectl auth can-i create topologies.clabernetes.containerlab.dev -n -kubectl auth can-i update topologies.clabernetes.containerlab.dev -n -kubectl auth can-i delete topologies.clabernetes.containerlab.dev -n +kubectl auth can-i get topologies.c9s.run -n +kubectl auth can-i create topologies.c9s.run -n +kubectl auth can-i update topologies.c9s.run -n +kubectl auth can-i delete topologies.c9s.run -n +kubectl auth can-i list nodes.c9s.run -n +kubectl auth can-i update nodes.c9s.run -n kubectl auth can-i list pods -n kubectl auth can-i watch pods -A kubectl auth can-i create pods/exec -n @@ -468,7 +481,7 @@ export CLAB_KUBE_NAMESPACE= The c9s runtime talks to: ```text -topologies.clabernetes.containerlab.dev +topologies.c9s.run ``` Typical symptoms: @@ -481,7 +494,7 @@ Check: ```bash kubectl api-resources | grep -i clabernetes -kubectl get crd topologies.clabernetes.containerlab.dev +kubectl get crd topologies.c9s.run ``` Install Clabernetes and its CRDs before using `--runtime clabernetes`. @@ -497,7 +510,7 @@ Check: kubectl get pods -A | grep -i clabernetes kubectl -n get topology -o yaml kubectl -n get deploy,pod,svc,cm,pvc \ - -l clabernetes/topologyOwner= + -l c9s.run/topologyOwner= ``` If deploy waits until timeout, check the Clabernetes manager logs and verify @@ -517,7 +530,7 @@ Check: kubectl -n get topology -o yaml kubectl -n describe topology kubectl -n get deploy,pod,svc,cm,pvc \ - -l clabernetes/topologyOwner= + -l c9s.run/topologyOwner= ``` Common causes include bad topology data, image pull failures, missing pull @@ -549,7 +562,7 @@ Check: ```bash kubectl -n get pod \ - -l clabernetes/topologyOwner=,clabernetes/topologyNode= \ + -l c9s.run/topologyOwner=,c9s.run/topologyNode= \ -o wide kubectl auth can-i create pods/exec -n kubectl -n exec -it -- sh @@ -588,6 +601,6 @@ Use kubernetes and launcher-pod state as the source of truth for c9s labs: ```bash kubectl get topologies -A kubectl -n get deploy,pod,svc,cm,pvc \ - -l clabernetes/topologyOwner= + -l c9s.run/topologyOwner= ``` /// diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index dc10d549ee..466d50a562 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -19,20 +19,27 @@ const ( envContext = "CLAB_KUBE_CONTEXT" envNamespace = "CLAB_KUBE_NAMESPACE" - labelApp = "clabernetes/app" - labelTopologyOwner = "clabernetes/topologyOwner" - labelTopologyNode = "clabernetes/topologyNode" - labelIgnoreReconcile = "clabernetes/ignoreReconcile" + c9sAPIVersion = "c9s.run/v1alpha1" + labelApp = "c9s.run/app" + labelTopologyOwner = "c9s.run/topologyOwner" + labelTopologyNode = "c9s.run/topologyNode" + labelIgnoreReconcile = "c9s.run/ignoreReconcile" clabernetesAppValue = "clabernetes" restartedAtAnnotation = "kubectl.kubernetes.io/restartedAt" ) var topologyGVR = schema.GroupVersionResource{ - Group: "clabernetes.containerlab.dev", + Group: "c9s.run", Version: "v1alpha1", Resource: "topologies", } +var nodeGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "nodes", +} + type Runtime struct { client dynamic.Interface kubeClient kubernetes.Interface diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index ae310f881b..1e815defec 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -18,6 +18,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/watch" dynamicfake "k8s.io/client-go/dynamic/fake" kubefake "k8s.io/client-go/kubernetes/fake" @@ -202,6 +203,80 @@ func TestStateFromTopology(t *testing.T) { } } +func TestTopologyObjectUsesCurrentC9sAPI(t *testing.T) { + t.Parallel() + + obj := topologyObject("lab1", "lab-ns", "", "topology: {}\n") + if got := obj.GetAPIVersion(); got != c9sAPIVersion { + t.Fatalf("apiVersion = %q, want %q", got, c9sAPIVersion) + } + if topologyGVR.Group != "c9s.run" || nodeGVR.Group != "c9s.run" { + t.Fatalf("unexpected c9s resource groups: topology=%q node=%q", + topologyGVR.Group, nodeGVR.Group) + } +} + +func TestEnrichStateUsesNodeResources(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "client", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + "spec": map[string]any{ + "kind": "linux", + "image": "client:latest", + }, + "status": map[string]any{ + "readiness": "ready", + "exposedPorts": map[string]any{ + "loadBalancerAddress": "192.0.2.10", + }, + }, + }} + + r := newTestRuntime(node) + state := &clablabruntime.LabState{Name: "lab1", Namespace: "lab-ns"} + if err := r.enrichState(context.Background(), state); err != nil { + t.Fatal(err) + } + + if len(state.Nodes) != 1 { + t.Fatalf("len(state.Nodes) = %d, want 1: %+v", len(state.Nodes), state.Nodes) + } + got := state.Nodes[0] + if got.Name != "client" || got.Kind != "linux" || got.Image != "client:latest" || + !got.Ready || got.State != "ready" || got.LoadBalancerAddress != "192.0.2.10" { + t.Fatalf("unexpected Node-derived state: %+v", got) + } + if !state.Ready || state.State != "running" { + t.Fatalf("unexpected aggregate state: %+v", state) + } +} + +func TestResolveLauncherNode(t *testing.T) { + t.Parallel() + + networkModes := map[string]string{ + "primary": "", + "secondary": "container:primary", + "nested": "container:secondary", + } + + if got := resolveLauncherNode("nested", networkModes); got != "primary" { + t.Fatalf("resolveLauncherNode(nested) = %q, want primary", got) + } + if got := resolveLauncherNode("standalone", networkModes); got != "standalone" { + t.Fatalf("resolveLauncherNode(standalone) = %q, want standalone", got) + } +} + func TestDeployCreatesTopology(t *testing.T) { t.Parallel() @@ -572,7 +647,14 @@ func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { } return &Runtime{ - client: dynamicfake.NewSimpleDynamicClient(k8sruntime.NewScheme(), runtimeObjects...), + client: dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + k8sruntime.NewScheme(), + map[schema.GroupVersionResource]string{ + topologyGVR: "TopologyList", + nodeGVR: "NodeList", + }, + runtimeObjects..., + ), kubeClient: kubefake.NewSimpleClientset(), namespace: defaultNamespace, } diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go index 95450aa36a..357828ce11 100644 --- a/labruntime/clabernetes/exec.go +++ b/labruntime/clabernetes/exec.go @@ -59,11 +59,17 @@ func (r *Runtime) launcherPod( nodeName string, ) (*corev1.Pod, error) { namespace = r.namespaceFor(namespace) + launchers, err := r.launcherNodeNames(ctx, name, namespace, []string{nodeName}) + if err != nil { + return nil, err + } + launcherNode := launchers[nodeName] + list, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: labels.Set{ labelApp: clabernetesAppValue, labelTopologyOwner: name, - labelTopologyNode: nodeName, + labelTopologyNode: launcherNode, }.String(), }) if err != nil { diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go index b347993c55..11a2eb2fa9 100644 --- a/labruntime/clabernetes/files.go +++ b/labruntime/clabernetes/files.go @@ -785,7 +785,7 @@ func (r *Runtime) setStagedConfigMapOwnerReferences( ownerReferences := []metav1.OwnerReference{ { - APIVersion: "clabernetes.containerlab.dev/v1alpha1", + APIVersion: c9sAPIVersion, Kind: "Topology", Name: topology.GetName(), UID: topology.GetUID(), diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go index 6b6a7dff86..6307cf8760 100644 --- a/labruntime/clabernetes/nodes.go +++ b/labruntime/clabernetes/nodes.go @@ -18,10 +18,6 @@ func (r *Runtime) Start(ctx context.Context, req clablabruntime.NodeRequest) err } func (r *Runtime) Stop(ctx context.Context, req clablabruntime.NodeRequest) error { - if err := r.setTopologyIgnoreReconcile(ctx, req.Name, req.Namespace, true); err != nil { - return err - } - return r.setNodesReplicas(ctx, req, 0) } @@ -30,9 +26,14 @@ func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) e if err != nil { return err } + launchers, err := r.launcherNodeNames(ctx, req.Name, namespace, targets) + if err != nil { + return err + } + launcherNodes := uniqueLauncherNodes(targets, launchers) now := time.Now().UTC().Format(time.RFC3339) - for _, nodeName := range targets { + for _, nodeName := range launcherNodes { deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) if err != nil { return err @@ -59,6 +60,11 @@ func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) e return err } } + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, false); err != nil { + return err + } + } return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) } @@ -72,26 +78,35 @@ func (r *Runtime) targetNodes( } namespace := r.namespaceFor(req.Namespace) - deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) + nodes, err := r.nodesForTopology(ctx, req.Name, namespace) if err != nil { return nil, "", err } known := map[string]struct{}{} - for idx := range deployments.Items { - nodeName := deployments.Items[idx].Labels[labelTopologyNode] - if nodeName != "" { - known[nodeName] = struct{}{} - } + for idx := range nodes.Items { + known[nodes.Items[idx].GetName()] = struct{}{} } if len(known) == 0 { - state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) if err != nil { return nil, "", err } - for _, node := range state.Nodes { - known[node.Name] = struct{}{} + for idx := range deployments.Items { + nodeName := deployments.Items[idx].Labels[labelTopologyNode] + if nodeName != "" { + known[nodeName] = struct{}{} + } + } + if len(known) == 0 { + state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + if err != nil { + return nil, "", err + } + for _, node := range state.Nodes { + known[node.Name] = struct{}{} + } } } @@ -130,8 +145,24 @@ func (r *Runtime) setNodesReplicas( if err != nil { return err } + launchers, err := r.launcherNodeNames(ctx, req.Name, namespace, targets) + if err != nil { + return err + } + launcherNodes := uniqueLauncherNodes(targets, launchers) + + if replicas == 0 { + if err := r.setTopologyIgnoreReconcile(ctx, req.Name, namespace, true); err != nil { + return err + } + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, true); err != nil { + return err + } + } + } - for _, nodeName := range targets { + for _, nodeName := range launcherNodes { deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) if err != nil { return err @@ -151,12 +182,53 @@ func (r *Runtime) setNodesReplicas( } if replicas > 0 { + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, false); err != nil { + return err + } + } + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) } return nil } +func (r *Runtime) setNodeIgnoreReconcile( + ctx context.Context, + topologyName, + namespace, + nodeName string, + enabled bool, +) error { + namespace = r.namespaceFor(namespace) + resource := r.client.Resource(nodeGVR).Namespace(namespace) + + node, err := resource.Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get c9s node %s/%s/%s: %w", + namespace, topologyName, nodeName, err) + } + + labelsMap := node.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + if enabled { + labelsMap[labelIgnoreReconcile] = "true" + } else { + delete(labelsMap, labelIgnoreReconcile) + } + node.SetLabels(labelsMap) + + if _, err = resource.Update(ctx, node, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update c9s node %s/%s/%s labels: %w", + namespace, topologyName, nodeName, err) + } + + return nil +} + func (r *Runtime) clearIgnoreWhenAllStarted(ctx context.Context, name, namespace string) error { deployments, err := r.deploymentsForTopology(ctx, name, namespace) if err != nil { @@ -273,11 +345,17 @@ func (r *Runtime) deploymentForNode( nodeName string, ) (*appsv1.Deployment, error) { namespace = r.namespaceFor(namespace) + launchers, err := r.launcherNodeNames(ctx, name, namespace, []string{nodeName}) + if err != nil { + return nil, err + } + launcherNode := launchers[nodeName] + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ LabelSelector: labels.Set{ labelApp: clabernetesAppValue, labelTopologyOwner: name, - labelTopologyNode: nodeName, + labelTopologyNode: launcherNode, }.String(), }) if err != nil { diff --git a/labruntime/clabernetes/resources.go b/labruntime/clabernetes/resources.go new file mode 100644 index 0000000000..f67d751e16 --- /dev/null +++ b/labruntime/clabernetes/resources.go @@ -0,0 +1,107 @@ +package clabernetes + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +func (r *Runtime) nodesForTopology( + ctx context.Context, + name, + namespace string, +) (*unstructured.UnstructuredList, error) { + namespace = r.namespaceFor(namespace) + + list, err := r.client.Resource(nodeGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: name}.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s nodes for topology %s/%s: %w", + namespace, name, err) + } + + return list, nil +} + +func nodeNetworkMode(node *unstructured.Unstructured) string { + if node == nil { + return "" + } + + networkMode, _, _ := unstructured.NestedString(node.Object, "spec", "network-mode") + + return networkMode +} + +func resolveLauncherNode(nodeName string, networkModes map[string]string) string { + current := nodeName + seen := map[string]struct{}{} + + for { + if _, ok := seen[current]; ok { + return current + } + seen[current] = struct{}{} + + primary, ok := strings.CutPrefix(networkModes[current], "container:") + if !ok || primary == "" { + return current + } + + current = primary + } +} + +func (r *Runtime) launcherNodeNames( + ctx context.Context, + topologyName, + namespace string, + nodeNames []string, +) (map[string]string, error) { + nodes, err := r.nodesForTopology(ctx, topologyName, namespace) + if err != nil { + return nil, err + } + + networkModes := make(map[string]string, len(nodes.Items)) + for idx := range nodes.Items { + node := &nodes.Items[idx] + networkModes[node.GetName()] = nodeNetworkMode(node) + } + + resolved := make(map[string]string, len(nodeNames)) + for _, nodeName := range nodeNames { + if _, ok := networkModes[nodeName]; !ok { + return nil, fmt.Errorf("node %q was not found in topology %s/%s", + nodeName, r.namespaceFor(namespace), topologyName) + } + resolved[nodeName] = resolveLauncherNode(nodeName, networkModes) + } + + return resolved, nil +} + +func uniqueLauncherNodes(nodeNames []string, launchers map[string]string) []string { + unique := make([]string, 0, len(nodeNames)) + seen := map[string]struct{}{} + + for _, nodeName := range nodeNames { + launcher := launchers[nodeName] + if launcher == "" { + launcher = nodeName + } + if _, ok := seen[launcher]; ok { + continue + } + + seen[launcher] = struct{}{} + unique = append(unique, launcher) + } + + return unique +} diff --git a/labruntime/clabernetes/state.go b/labruntime/clabernetes/state.go index 3ca2c4e3ca..236e771363 100644 --- a/labruntime/clabernetes/state.go +++ b/labruntime/clabernetes/state.go @@ -9,6 +9,7 @@ import ( clablabruntime "github.com/srl-labs/containerlab/labruntime" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" ) @@ -17,6 +18,36 @@ func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabStat return nil } + nodeResources, err := r.nodesForTopology(ctx, state.Name, state.Namespace) + if err != nil { + return err + } + + nodesByName := map[string]clablabruntime.NodeState{} + for _, node := range state.Nodes { + nodesByName[node.Name] = node + } + + networkModes := make(map[string]string, len(nodeResources.Items)) + for idx := range nodeResources.Items { + nodeResource := &nodeResources.Items[idx] + nodeName := nodeResource.GetName() + node := nodesByName[nodeName] + node.Name = nodeName + node.Kind, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "kind") + node.Image, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "image") + node.State, _, _ = unstructured.NestedString(nodeResource.Object, "status", "readiness") + node.Ready = node.State == "ready" + node.LoadBalancerAddress, _, _ = unstructured.NestedString( + nodeResource.Object, + "status", + "exposedPorts", + "loadBalancerAddress", + ) + nodesByName[nodeName] = node + networkModes[nodeName] = nodeNetworkMode(nodeResource) + } + deployments, err := r.deploymentsForTopology(ctx, state.Name, state.Namespace) if err != nil { return err @@ -33,11 +64,6 @@ func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabStat state.Namespace, state.Name, err) } - nodesByName := map[string]clablabruntime.NodeState{} - for _, node := range state.Nodes { - nodesByName[node.Name] = node - } - podsByNode := map[string]*corev1.Pod{} for idx := range pods.Items { nodeName := pods.Items[idx].Labels[labelTopologyNode] @@ -60,29 +86,42 @@ func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabStat continue } - node := nodesByName[nodeName] - node.Name = nodeName replicas := int32(1) if deployment.Spec.Replicas != nil { replicas = *deployment.Spec.Replicas } + deploymentState := "notready" + deploymentReady := false switch { case replicas == 0: - node.State = "stopped" - node.Ready = false + deploymentState = "stopped" case deployment.Status.ReadyReplicas > 0: - node.State = "ready" - node.Ready = true + deploymentState = "ready" + deploymentReady = true case podsByNode[nodeName] != nil && podsByNode[nodeName].Status.Phase != "": - node.State = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) - node.Ready = false - default: - node.State = "notready" - node.Ready = false + deploymentState = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) } - nodesByName[nodeName] = node + matched := false + for logicalNodeName, node := range nodesByName { + if resolveLauncherNode(logicalNodeName, networkModes) != nodeName { + continue + } + + node.State = deploymentState + node.Ready = deploymentReady + nodesByName[logicalNodeName] = node + matched = true + } + + if !matched { + node := nodesByName[nodeName] + node.Name = nodeName + node.State = deploymentState + node.Ready = deploymentReady + nodesByName[nodeName] = node + } } nodeNames := make([]string, 0, len(nodesByName)) diff --git a/labruntime/clabernetes/topology.go b/labruntime/clabernetes/topology.go index 26a34c6717..f6c82f13b3 100644 --- a/labruntime/clabernetes/topology.go +++ b/labruntime/clabernetes/topology.go @@ -61,7 +61,7 @@ func topologyObject( return &unstructured.Unstructured{ Object: map[string]any{ - "apiVersion": "clabernetes.containerlab.dev/v1alpha1", + "apiVersion": c9sAPIVersion, "kind": "Topology", "metadata": metadata, "spec": spec, From 21a064ca184d07ead8897ba08b0508c926422dc8 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Wed, 12 Aug 2026 13:10:20 +0200 Subject: [PATCH 14/21] Complete c9s Node and Link runtime support --- cmd/options.go | 1 + cmd/root.go | 23 +- cmd/root_test.go | 88 ++++ core/labruntime.go | 9 +- docs/manual/clabernetes/install.md | 11 +- docs/manual/clabernetes/runtime.md | 183 ++++--- go.mod | 60 ++- go.sum | 157 +++--- labruntime/clabernetes/clabernetes.go | 13 + labruntime/clabernetes/clabernetes_test.go | 447 +++++++++++++++--- labruntime/clabernetes/events.go | 99 +++- labruntime/clabernetes/exec.go | 8 +- labruntime/clabernetes/files.go | 52 +- labruntime/clabernetes/lifecycle.go | 360 +++++++++++--- labruntime/clabernetes/nodes.go | 42 +- labruntime/clabernetes/primitives.go | 185 ++++++++ labruntime/clabernetes/resources.go | 253 ++++++++++ labruntime/clabernetes/save.go | 16 +- labruntime/clabernetes/state.go | 48 +- tests/14-clabernetes/01-linux-lifecycle.robot | 20 + 20 files changed, 1732 insertions(+), 343 deletions(-) create mode 100644 labruntime/clabernetes/primitives.go diff --git a/cmd/options.go b/cmd/options.go index 01ce696dee..1f713b0c7d 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -15,6 +15,7 @@ import ( const ( multiToolImage = "ghcr.io/srl-labs/network-multitool" defaultTimeout = 120 * time.Second + defaultLabRuntimeTimeout = 10 * time.Minute defaultToolsServerPort = 8080 defaultToolsAPIServerPort = 8090 defaultToolsApiSSHBasePort = 2223 diff --git a/cmd/root.go b/cmd/root.go index 603b4a83df..d2a517fe73 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -87,7 +87,7 @@ func Entrypoint() (*cobra.Command, error) { "timeout", "", o.Global.Timeout, - "timeout for external API requests (e.g. container runtimes), e.g: 30s, 1m, 2m30s", + "timeout for external API requests (e.g. container runtimes); lab runtimes default to 10m", ) c.PersistentFlags().StringVarP( &o.Global.Runtime, @@ -131,6 +131,7 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { if v != nil { updateOptionsFromViper(cobraCmd, o) } + applyLabRuntimeDefaultTimeout(cobraCmd, o) // setting log level switch { @@ -172,6 +173,26 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { return getTopoFilePath(cobraCmd, o) } +// applyLabRuntimeDefaultTimeout gives remote, controller-driven lab runtimes enough time to load +// large node images. An explicit flag or environment value always wins. Local Docker and Podman +// retain their existing two-minute default. +func applyLabRuntimeDefaultTimeout(cobraCmd *cobra.Command, o *Options) { + if !clablabruntime.IsLabRuntimeName(o.Global.Runtime) { + return + } + + timeoutFlag := cobraCmd.Flag("timeout") + if timeoutFlag != nil && timeoutFlag.Changed { + return + } + + if value, ok := os.LookupEnv(envPrefix + "_TIMEOUT"); ok && strings.TrimSpace(value) != "" { + return + } + + o.Global.Timeout = defaultLabRuntimeTimeout +} + func globalRuntimeRequiresRoot(name string) bool { return name != "" && name != clabruntimedocker.RuntimeName && diff --git a/cmd/root_test.go b/cmd/root_test.go index 183406633e..8663b283a3 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,7 +1,9 @@ package cmd import ( + "os" "testing" + "time" "github.com/spf13/cobra" ) @@ -54,6 +56,92 @@ func TestRootRequirementHelpers(t *testing.T) { } } +func TestApplyLabRuntimeDefaultTimeout(t *testing.T) { + tests := []struct { + name string + runtime string + explicitFlag string + explicitEnv string + initialTimeout time.Duration + expectedTimeout time.Duration + }{ + { + name: "clabernetes default", + runtime: "clabernetes", + initialTimeout: defaultTimeout, + expectedTimeout: defaultLabRuntimeTimeout, + }, + { + name: "docker default unchanged", + runtime: "docker", + initialTimeout: defaultTimeout, + expectedTimeout: defaultTimeout, + }, + { + name: "explicit flag wins", + runtime: "clabernetes", + explicitFlag: "45s", + initialTimeout: defaultTimeout, + expectedTimeout: 45 * time.Second, + }, + { + name: "explicit environment wins", + runtime: "clabernetes", + explicitEnv: "3m", + initialTimeout: 3 * time.Minute, + expectedTimeout: 3 * time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalTimeout, timeoutWasSet := os.LookupEnv("CLAB_TIMEOUT") + if tt.explicitEnv == "" { + if err := os.Unsetenv("CLAB_TIMEOUT"); err != nil { + t.Fatal(err) + } + } else { + t.Setenv("CLAB_TIMEOUT", tt.explicitEnv) + } + t.Cleanup(func() { + if timeoutWasSet { + _ = os.Setenv("CLAB_TIMEOUT", originalTimeout) + } else { + _ = os.Unsetenv("CLAB_TIMEOUT") + } + }) + + root := &cobra.Command{Use: "containerlab"} + options := &Options{Global: &GlobalOptions{ + Runtime: tt.runtime, + Timeout: tt.initialTimeout, + }} + root.PersistentFlags().DurationVar( + &options.Global.Timeout, + "timeout", + options.Global.Timeout, + "timeout", + ) + deploy := &cobra.Command{Use: "deploy"} + root.AddCommand(deploy) + if tt.explicitFlag != "" { + if err := root.PersistentFlags().Set("timeout", tt.explicitFlag); err != nil { + t.Fatal(err) + } + } + + applyLabRuntimeDefaultTimeout(deploy, options) + if options.Global.Timeout != tt.expectedTimeout { + t.Fatalf( + "timeout = %s, want %s", + options.Global.Timeout, + tt.expectedTimeout, + ) + } + }) + } +} + func TestCheckLabRuntimeCommandSupport(t *testing.T) { root := &cobra.Command{Use: "containerlab"} graph := &cobra.Command{Use: "graph"} diff --git a/core/labruntime.go b/core/labruntime.go index 3ddfb24095..6f047b66c3 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -263,7 +263,9 @@ func (c *CLab) saveWithLabRuntime(ctx context.Context, opts *SaveOptions) error return c.copyLabRuntimeSavedFiles(result, opts.copyDst) } -func (c *CLab) containersFromLabState(state *clablabruntime.LabState) []clabruntime.GenericContainer { +func (c *CLab) containersFromLabState( + state *clablabruntime.LabState, +) []clabruntime.GenericContainer { if state == nil { return nil } @@ -474,7 +476,10 @@ func (c *CLab) copyLabRuntimeSavedFiles( nodeDstDir := filepath.Join(dstRoot, file.NodeName) dstPath := filepath.Join(nodeDstDir, relPath) - if err := os.MkdirAll(filepath.Dir(dstPath), clabconstants.PermissionsDirDefault); err != nil { + if err := os.MkdirAll( + filepath.Dir(dstPath), + clabconstants.PermissionsDirDefault, + ); err != nil { return fmt.Errorf("failed to create save dst directory %q: %w", filepath.Dir(dstPath), err) } diff --git a/docs/manual/clabernetes/install.md b/docs/manual/clabernetes/install.md index 48e807d0e1..7724d9e52b 100644 --- a/docs/manual/clabernetes/install.md +++ b/docs/manual/clabernetes/install.md @@ -4,14 +4,15 @@ Clabernetes runs on a Kubernetes cluster and hence requires one to be available Clabernetes project consists of two main components: -- Clabernetes manager (a.k.a. controller) - a k8s controller that watches for the Clabernetes topology resources and deploys them to the cluster. -- Clabverter - a CLI tool that converts containerlab topology files into Clabernetes topology resources. +- Clabernetes manager (a.k.a. controller) - k8s controllers that reconcile c9s `Node`, `Link`, `LauncherProfile`, and compatibility `Topology` resources. +- Clabverter - a CLI tool that converts containerlab topology files into Clabernetes resources. /// note | Using the containerlab runtime When you use [`containerlab --runtime clabernetes`](runtime.md), containerlab -renders the topology and creates the `Topology` custom resource directly. In -that workflow you still need the Clabernetes manager and CRDs installed in the -cluster, but you don't need to run `clabverter` for every deployment. +compiles the topology in memory and creates the primary `Node`, `Link`, and +`LauncherProfile` custom resources directly. In that workflow you still need +the Clabernetes manager and CRDs installed in the cluster, but you don't need +to run `clabverter` for every deployment. /// ## Clabernetes Manager diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 8770218ac7..95be3c42ee 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -25,25 +25,41 @@ manifests yourself. ## How it works When the c9s runtime is selected, containerlab does not create local Docker or -Podman containers for the lab nodes. Instead, it renders the final topology and -stores it in a Clabernetes `Topology` custom resource: +Podman containers for the lab nodes. It compiles the rendered topology in +memory, using the same compiler as Clabernetes, and creates the primary c9s +resources directly: ```yaml apiVersion: c9s.run/v1alpha1 -kind: Topology +kind: Node metadata: - name: + name: namespace: + labels: + c9s.run/topologyOwner: spec: - definition: - containerlab: | - + kind: + image: +--- +apiVersion: c9s.run/v1alpha1 +kind: Link +metadata: + name: + namespace: +spec: + endpointA: {nodeName: , interfaceName: } + endpointB: {nodeName: , interfaceName: } ``` -The Clabernetes manager compiles this compatibility resource into `Node`, -`Link`, and `LauncherProfile` resources. Each launcher pod runs containerlab -inside the pod and starts the real node container there. Nodes using -`network-mode: container:` share the primary node's launcher pod. +`LauncherProfile` resources carry reusable launcher policy. No `Topology` +object or complete topology definition is persisted, so every c9s object stays +bounded as the lab grows. Each launcher pod runs containerlab inside the pod +and starts the real node container there. Nodes using `network-mode: +container:` share the primary node's launcher pod. + +For backward compatibility, `inspect`, `destroy`, and the other lifecycle +commands can still manage older labs that have a `Topology` compatibility +resource. New runtime deployments are Node/Link-first. /// note The node containers are nested inside the launcher pods. A `docker ps` on the @@ -55,9 +71,9 @@ The c9s runtime currently supports the main lab lifecycle and node operations: | Command | c9s behavior | | ------- | ------------ | -| `deploy` | creates the Clabernetes `Topology` resource and waits for readiness | -| `destroy` | deletes the `Topology` resource | -| `inspect` | reads `Topology`, Deployment, Pod, and service status | +| `deploy` | creates `LauncherProfile`, `Node`, and `Link` resources and waits for readiness | +| `destroy` | deletes the lab's primitive resources and any compatibility `Topology` | +| `inspect` | reads Node, Deployment, Pod, and service status | | `exec` | execs through the launcher pod into the nested node container | | `start` | scales node Deployments to `1` | | `stop` | scales node Deployments to `0` and pauses reconciliation | @@ -126,8 +142,9 @@ CLAB_KUBE_NAMESPACE=lab-a \ containerlab --runtime clabernetes deploy -t topo.clab.yml ``` -creates the `Topology` resource in the `lab-a` namespace when a topology with -the same name does not already exist there. +creates the lab's `LauncherProfile`, `Node`, and `Link` resources in the +`lab-a` namespace when a lab with the same owner label does not already exist +there. /// warning In c9s 0.7 and newer, the namespace is also the node-name boundary. Two labs in @@ -155,6 +172,11 @@ For example: default/clos/srl1 ``` +Primitive-only labs created outside containerlab are also manageable when +their Nodes, Links, and LauncherProfiles carry the common +`c9s.run/topologyOwner=` label. Containerlab uses that label as the +lab boundary for list, inspect, lifecycle, events, and destroy operations. + ## Deploy Deploying with the c9s runtime looks like a regular containerlab deployment: @@ -166,23 +188,47 @@ containerlab --runtime clabernetes deploy -t topo.clab.yml The deploy flow is: 1. containerlab parses and checks the topology file. -2. It renders the final topology YAML. -3. It creates a Clabernetes `Topology` resource. -4. It waits until Clabernetes reports the topology as ready. -5. It inspects the resulting kubernetes state and prints the node table. +2. It stages local files into per-node ConfigMaps. +3. It compiles the final topology in memory into self-contained Nodes, Links, + and LauncherProfiles. +4. It creates profiles and Links first, then Nodes after the complete wiring + policy exists. +5. It waits until all Nodes report ready. +6. It inspects the resulting kubernetes state and prints the node table. + +The runtime enables c9s startup and readiness probes on the generated +`LauncherProfile`. c9s checks that each nested Docker container exists, is +running, and is not paused, restarting, or dead. When an image defines a Docker +healthcheck, that healthcheck must also be healthy. Containerlab does not guess +readiness ports or special-case kinds and images, so the same baseline works for +arbitrary containerlab nodes. + +For an image without a Docker healthcheck, this is a process-level signal: a +running network OS may still be booting services or converging protocols. Use an +image-defined healthcheck or an explicit c9s TCP/SSH probe when the lab requires +application-level readiness. + +The c9s runtime uses a ten-minute timeout by default because large NOS images +can take several minutes to load and boot. Override it when a lab needs a +different startup window, for example: + +```bash +containerlab --runtime clabernetes --timeout 10m deploy -t topo.clab.yml +``` -`deploy --reconfigure` first deletes the existing `Topology` resource and then -deploys it again. +`deploy --reconfigure` first deletes all resources in the existing lab and +then deploys them again. /// warning | Node filtering `deploy --node-filter` is not supported with the c9s runtime. Clabernetes owns -reconciliation of the complete topology stored in the `Topology` resource. -Deploy the full topology, then use node filtering with commands such as -`start`, `stop`, `restart`, `exec`, or `save` after the topology exists. +reconciliation of the complete set of Node and Link resources. Deploy the full +topology, then use node filtering with commands such as `start`, `stop`, +`restart`, `exec`, or `save` after the lab exists. /// -Deploy is create-only. If a `Topology` with the same name already exists in the -same namespace, containerlab fails the deployment: +Deploy is create-only. If primitive resources carrying the same +`c9s.run/topologyOwner` label, or a compatibility `Topology` with the same +name, already exist in the namespace, containerlab fails the deployment: ```text the '' lab has already been deployed in namespace ''. @@ -206,20 +252,19 @@ containerlab --runtime clabernetes inspect --all ``` For c9s labs, inspect reads kubernetes resources instead of local container -runtime state. It collects the topology name, namespace, topology state, node +runtime state. It collects the lab name, namespace, aggregate state, node readiness, node kind and image, and load-balancer management address when Clabernetes exposes one. /// note -`inspect --all` lists c9s topologies across all namespaces. A single-lab +`inspect --all` groups c9s Nodes by `c9s.run/topologyOwner` across all +namespaces. A single-lab inspect uses the selected namespace. /// Useful kubernetes checks for the same state are: ```bash -kubectl get topologies -A -kubectl -n get topology -o yaml kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run,deploy,pod,svc,cm,pvc \ -l c9s.run/topologyOwner= ``` @@ -255,8 +300,8 @@ containerlab --runtime clabernetes start -t topo.clab.yml containerlab --runtime clabernetes restart -t topo.clab.yml ``` -`stop` sets the Clabernetes ignore-reconcile label on the `Topology` and the -selected launcher `Node` resources, then scales their Deployments to `0`: +`stop` sets the Clabernetes ignore-reconcile label on the selected launcher +`Node` resources, then scales their Deployments to `0`: ```text c9s.run/ignoreReconcile=true @@ -265,9 +310,10 @@ c9s.run/ignoreReconcile=true The label prevents the Clabernetes manager from immediately reconciling the nodes back to the running state. -`start` scales the selected Deployments back to `1`, clears the corresponding -Node labels, and clears the Topology label when all launchers are running -again. A grouped secondary shares lifecycle with its primary launcher node. +`start` scales the selected Deployments back to `1` and clears the corresponding +Node labels. For an older compatibility lab, it also clears the Topology label +when all launchers are running again. A grouped secondary shares lifecycle +with its primary launcher node. `restart` patches each selected Deployment with a restart annotation and waits for it to become ready: @@ -320,7 +366,8 @@ files, the c9s runtime has nothing to copy for that node. ## Events -The c9s runtime can stream topology, pod, and interface-stat events: +The c9s runtime can stream Node, compatibility Topology, pod, and +interface-stat events: ```bash containerlab --runtime clabernetes events --format json @@ -331,7 +378,8 @@ containerlab --runtime clabernetes events --interface-stats --format json For c9s, events do not come from Docker events on the outer host. Containerlab watches: -- Clabernetes `Topology` resources +- c9s `Node` resources carrying `c9s.run/topologyOwner` +- compatibility `Topology` resources for older labs - Pods labeled with `c9s.run/topologyOwner` With `--initial-state`, the stream starts with synthetic events for the current @@ -355,10 +403,11 @@ samples can be missed. With c9s, the primary artifacts are kubernetes resources and files inside the launcher pods. -The main kubernetes resource is: +The primary kubernetes resources are: ```bash -kubectl -n get topology -o yaml +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run \ + -l c9s.run/topologyOwner= ``` Related resources are selected with Clabernetes labels: @@ -409,22 +458,26 @@ ls -la /clabernetes The kube identity used by the outer containerlab process must be able to: -- create, get, list, watch, update, and delete Clabernetes `Topology` resources -- list, get, and update c9s `Node` resources +- create, get, list, watch, update, and delete c9s `Node` resources +- create, get, list, watch, and delete c9s `Link` and `LauncherProfile` resources +- get, list, watch, and delete compatibility `Topology` resources when older + Topology-based labs must remain manageable - list and watch Pods - list, get, and update Deployments -- create, get, update, and delete ConfigMaps used for local files +- create, get, list, update, and delete ConfigMaps used for local files - exec into launcher Pods with `pods/exec` Useful checks: ```bash -kubectl auth can-i get topologies.c9s.run -n -kubectl auth can-i create topologies.c9s.run -n -kubectl auth can-i update topologies.c9s.run -n -kubectl auth can-i delete topologies.c9s.run -n +kubectl auth can-i create nodes.c9s.run -n kubectl auth can-i list nodes.c9s.run -n kubectl auth can-i update nodes.c9s.run -n +kubectl auth can-i delete nodes.c9s.run -n +kubectl auth can-i create links.c9s.run -n +kubectl auth can-i delete links.c9s.run -n +kubectl auth can-i create launcherprofiles.c9s.run -n +kubectl auth can-i delete launcherprofiles.c9s.run -n kubectl auth can-i list pods -n kubectl auth can-i watch pods -A kubectl auth can-i create pods/exec -n @@ -458,7 +511,7 @@ again. Typical symptoms: ```text -failed to create clabernetes topology /: namespaces "" not found +failed to create c9s /: namespaces "" not found ``` Check: @@ -481,7 +534,9 @@ export CLAB_KUBE_NAMESPACE= The c9s runtime talks to: ```text -topologies.c9s.run +nodes.c9s.run +links.c9s.run +launcherprofiles.c9s.run ``` Typical symptoms: @@ -494,41 +549,38 @@ Check: ```bash kubectl api-resources | grep -i clabernetes -kubectl get crd topologies.c9s.run +kubectl get crd nodes.c9s.run links.c9s.run launcherprofiles.c9s.run ``` Install Clabernetes and its CRDs before using `--runtime clabernetes`. ### Manager is not reconciling -The CRD may exist and the `Topology` resource may be created, but no node -Deployments or Pods appear. +The primitive resources may exist, but no node Deployments or Pods appear. Check: ```bash kubectl get pods -A | grep -i clabernetes -kubectl -n get topology -o yaml +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run \ + -l c9s.run/topologyOwner= kubectl -n get deploy,pod,svc,cm,pvc \ -l c9s.run/topologyOwner= ``` If deploy waits until timeout, check the Clabernetes manager logs and verify -that it watches the namespace where the `Topology` was created. +that it watches the namespace where the primitive resources were created. -### Topology reports deployfailed +### Nodes do not become ready -During deploy, containerlab fails immediately if Clabernetes reports: - -```text -status.topologyState=deployfailed -``` +Deploy waits for every Node to report `status.readiness=ready`. Check: ```bash -kubectl -n get topology -o yaml -kubectl -n describe topology +kubectl -n get node.c9s.run,link.c9s.run \ + -l c9s.run/topologyOwner= -o yaml +kubectl -n describe node.c9s.run kubectl -n get deploy,pod,svc,cm,pvc \ -l c9s.run/topologyOwner= ``` @@ -539,14 +591,15 @@ cannot run nested Docker. ### Inspect shows no containers -For c9s, `inspect` looks for Clabernetes topologies, not local Docker +For c9s, `inspect` looks for c9s Nodes grouped by +`c9s.run/topologyOwner` (and compatibility Topologies), not local Docker containers. Check: ```bash containerlab --runtime clabernetes inspect --all -kubectl get topologies -A +kubectl get nodes.c9s.run -A -l c9s.run/topologyOwner echo "$CLAB_KUBE_NAMESPACE" ``` @@ -599,7 +652,7 @@ Known differences: Use kubernetes and launcher-pod state as the source of truth for c9s labs: ```bash -kubectl get topologies -A +kubectl get nodes.c9s.run -A -l c9s.run/topologyOwner kubectl -n get deploy,pod,svc,cm,pvc \ -l c9s.run/topologyOwner= ``` diff --git a/go.mod b/go.mod index c394694aea..0d158ec841 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/x/term v0.2.2 + github.com/clabernetes/clabernetes v0.7.0 github.com/containernetworking/plugins v1.9.0 github.com/containers/podman/v5 v5.8.2 github.com/digitalocean/go-openvswitch v0.0.0-20250625173537-a00eb8d2cfce @@ -50,12 +51,12 @@ require ( go.podman.io/common v0.67.1 go.podman.io/image/v5 v5.39.2 go.uber.org/mock v0.6.0 - golang.org/x/crypto v0.47.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.39.0 + golang.org/x/crypto v0.50.0 + golang.org/x/sys v0.43.0 + golang.org/x/term v0.42.0 gopkg.in/yaml.v2 v2.4.0 - k8s.io/api v0.34.3 - k8s.io/client-go v0.34.3 + k8s.io/api v0.35.4 + k8s.io/client-go v0.35.4 sigs.k8s.io/kind v0.31.0 ) @@ -64,8 +65,10 @@ require ( charm.land/lipgloss/v2 v2.0.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298 // indirect + github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect @@ -85,17 +88,17 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -112,7 +115,7 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/mistifyio/go-zfs/v3 v3.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect @@ -133,6 +136,10 @@ require ( github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect @@ -152,23 +159,26 @@ require ( github.com/vbauerster/mpb/v8 v8.10.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect go.podman.io/storage v1.62.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect golang.org/x/oauth2 v0.32.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + k8s.io/apiextensions-apiserver v0.35.4 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) @@ -241,19 +251,19 @@ require ( github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netns v0.0.5 github.com/xanzy/ssh-agent v0.3.3 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/mod v0.37.0 - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 - golang.org/x/text v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/grpc v1.72.2 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apimachinery v0.34.3 + k8s.io/apimachinery v0.35.4 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 913ae01b61..937c596bf9 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,9 @@ github.com/bramvdbogaerde/go-scp v1.2.1 h1:BKTqrqXiQYovrDlfuVFaEGz0r4Ou6EED8L7jC github.com/bramvdbogaerde/go-scp v1.2.1/go.mod h1:s4ZldBoRAOgUg8IrRP2Urmq5qqd2yPXQTPshACY8vQ0= github.com/brunoga/deep v1.3.1 h1:bSrL6FhAZa6JlVv4vsi7Hg8SLwroDb1kgDERRVipBCo= github.com/brunoga/deep v1.3.1/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= -github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298 h1:Y8rTum6LZ8oP/2aC+OaaP76OCjHbunKMkim81mzNCH0= github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298/go.mod h1:+3MuSIeC3qmdSesR12cTLeb47R/Vvo+bHdB6hC5HShk= +github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae h1:h4sxL/AXg3FRPf+sT2Y4daEQQE/UAkNAM3U0t4Cgha8= +github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae/go.mod h1:+3MuSIeC3qmdSesR12cTLeb47R/Vvo+bHdB6hC5HShk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -89,6 +90,8 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= github.com/cilium/ebpf v0.17.3 h1:FnP4r16PWYSE4ux6zN+//jMcW4nMVRvuTLVTvCjyyjg= github.com/cilium/ebpf v0.17.3/go.mod h1:G5EDHij8yiLzaqn0WjyfJHvRa+3aDlReIaLVRMvOyJk= +github.com/clabernetes/clabernetes v0.7.0 h1:1uIMAAS1w2cxB7mAIq0Cm9Q8fbp+Bh6eAs1SoYmMKrg= +github.com/clabernetes/clabernetes v0.7.0/go.mod h1:yMMjzd5WgSLy7JhMIVIUOgz1+ZBtUMf9sx4snAge/wQ= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -164,8 +167,10 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= -github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-tc v0.4.7 h1:Ysai5TIx4PgOzqI/1cse/pquOFCEkWofKtc/EPumfrg= @@ -200,12 +205,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -218,12 +223,12 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9 h1:Kzr9J0S0V2PRxiX6B6xw1kWjzsIyjLO2Ibi4fNTaYBM= github.com/godbus/dbus/v5 v5.1.1-0.20241109141217-c266b19b28e9/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -243,6 +248,8 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2 github.com/google/go-intervals v0.0.2 h1:FGrVEiUnTRKR8yE04qzXYaJMtnIYqobR5QbblK3ixcM= github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= @@ -275,7 +282,6 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= @@ -305,8 +311,6 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ= github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -327,6 +331,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -376,8 +382,8 @@ github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1 github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -446,7 +452,6 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= @@ -458,14 +463,14 @@ github.com/pmorjan/kmod v1.1.1 h1:Vfw6bMaOg/sYSBCqJPT9TbqHHf5zK00GbaL5JQLO4r0= github.com/pmorjan/kmod v1.1.1/go.mod h1:jR4fVosEpQ6b5U0rpxaqoShTDPvCjLIP8vEESZyvnqQ= github.com/proglottis/gpgme v0.1.5 h1:KCGyOw8sQ+SI96j6G8D8YkOGn+1TwbQTT9/zQXoVlz0= github.com/proglottis/gpgme v0.1.5/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -522,16 +527,11 @@ github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h github.com/steiler/acls v0.1.5 h1:BjnpIqK3TIov+fq6fK80SXBrd/oDMSEgOpVLllAB78A= github.com/steiler/acls v0.1.5/go.mod h1:lFfnRSiSCWLgKiuxu7PFgaUPVanCn7o6m4dHe/cz128= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -568,27 +568,25 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.podman.io/common v0.67.1 h1:HddYLJfkfFUmFJ0V3PVoewguFM9eHkqk0g+fOc2B9R4= @@ -597,14 +595,19 @@ go.podman.io/image/v5 v5.39.2 h1:EJua/pRtvgLV/a5y8/RvA+ekKukZh0UuKMvLdTmEWFk= go.podman.io/image/v5 v5.39.2/go.mod h1:SlaR6Pra1ATIx4BcuZ16oafb3QcCHISaKcJbtlN/G/0= go.podman.io/storage v1.62.0 h1:0QjX1XlzVmbiaulb+aR/CG6p9+pzaqwIeZPe3tEjHbY= go.podman.io/storage v1.62.0/go.mod h1:A3UBK0XypjNZ6pghRhuxg62+2NIm5lcUGv/7XyMhMUI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -614,12 +617,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -633,9 +634,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -651,13 +650,11 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -665,8 +662,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -710,8 +707,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -721,8 +718,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -733,14 +730,12 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -748,25 +743,26 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= @@ -776,32 +772,35 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= -k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= +k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= +k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU= +k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c= +k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= +k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= +k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f h1:4Qiq0YAoQATdgmHALJWz9rJ4fj20pB3xebpB4CFNhYM= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc= diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 466d50a562..49368a5321 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -20,6 +20,7 @@ const ( envNamespace = "CLAB_KUBE_NAMESPACE" c9sAPIVersion = "c9s.run/v1alpha1" + labelRuntime = "containerlab.dev/runtime" labelApp = "c9s.run/app" labelTopologyOwner = "c9s.run/topologyOwner" labelTopologyNode = "c9s.run/topologyNode" @@ -40,6 +41,18 @@ var nodeGVR = schema.GroupVersionResource{ Resource: "nodes", } +var linkGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "links", +} + +var launcherProfileGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "launcherprofiles", +} + type Runtime struct { client dynamic.Interface kubeClient kubernetes.Interface diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index 1e815defec..beb8af52f6 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -11,10 +11,11 @@ import ( "testing" "time" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" clabconstants "github.com/srl-labs/containerlab/constants" clablabruntime "github.com/srl-labs/containerlab/labruntime" - "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8sruntime "k8s.io/apimachinery/pkg/runtime" @@ -68,7 +69,12 @@ func TestCleanTarPath(t *testing.T) { want string ok bool }{ - {name: "relative path", in: "./configs/startup.json", want: "configs/startup.json", ok: true}, + { + name: "relative path", + in: "./configs/startup.json", + want: "configs/startup.json", + ok: true, + }, {name: "current directory", in: ".", want: ".", ok: true}, {name: "parent path", in: "../secret", ok: false}, {name: "absolute path", in: "/etc/passwd", ok: false}, @@ -203,16 +209,109 @@ func TestStateFromTopology(t *testing.T) { } } -func TestTopologyObjectUsesCurrentC9sAPI(t *testing.T) { +func TestPrimitiveResourcesUseCurrentC9sAPI(t *testing.T) { + t.Parallel() + + for name, gvr := range map[string]schema.GroupVersionResource{ + "topology": topologyGVR, + "node": nodeGVR, + "link": linkGVR, + "launcher profile": launcherProfileGVR, + } { + if gvr.Group != "c9s.run" || gvr.Version != "v1alpha1" { + t.Fatalf("unexpected %s GVR: %+v", name, gvr) + } + } +} + +func TestPrimitiveResourceGroupsCreateLinksBeforeNodes(t *testing.T) { + t.Parallel() + + set := &primitiveResourceSet{} + groups := set.groups() + want := []string{"LauncherProfile", "Link", "Node"} + got := make([]string, 0, len(groups)) + for _, group := range groups { + got = append(got, group.kind) + } + + if !slices.Equal(got, want) { + t.Fatalf("primitive creation order = %v, want %v", got, want) + } +} + +func TestStageAndEnablePrimitiveNodeDeployments(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + "keep": "value", + }, + }, + }} + set := &primitiveResourceSet{nodes: []*unstructured.Unstructured{node}} + stagePrimitiveNodeDeployments(set) + + if node.GetLabels()[clabernetesconstants.LabelDisableDeployments] != "true" { + t.Fatalf("staged node labels = %v", node.GetLabels()) + } + + r := newTestRuntime() + created, err := r.client.Resource(nodeGVR).Namespace("lab-ns").Create( + context.Background(), + node, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + if err := r.enablePrimitiveNodeDeployments( + context.Background(), + "lab-ns", + map[string]*unstructured.Unstructured{"node1": created}, + ); err != nil { + t.Fatal(err) + } + + actual := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if _, exists := actual.GetLabels()[clabernetesconstants.LabelDisableDeployments]; exists { + t.Fatalf("enabled node retains staging label: %v", actual.GetLabels()) + } + if actual.GetLabels()["keep"] != "value" { + t.Fatalf("enable patch did not preserve labels: %v", actual.GetLabels()) + } +} + +func TestPrimitiveLinkPendingReason(t *testing.T) { t.Parallel() - obj := topologyObject("lab1", "lab-ns", "", "topology: {}\n") - if got := obj.GetAPIVersion(); got != c9sAPIVersion { - t.Fatalf("apiVersion = %q, want %q", got, c9sAPIVersion) + link := &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "resolvedEndpoints": map[string]any{ + "endpointA": map[string]any{"nodeName": "node1", "uid": "uid-1"}, + "endpointB": map[string]any{"nodeName": "host"}, + }, + }, + }} + if reason := primitiveLinkPendingReason(link); reason != "" { + t.Fatalf("resolved link reported pending: %q", reason) + } + + if err := unstructured.SetNestedField( + link.Object, + "endpoint node missing", + "status", + "error", + ); err != nil { + t.Fatal(err) } - if topologyGVR.Group != "c9s.run" || nodeGVR.Group != "c9s.run" { - t.Fatalf("unexpected c9s resource groups: topology=%q node=%q", - topologyGVR.Group, nodeGVR.Group) + if reason := primitiveLinkPendingReason(link); reason != "endpoint node missing" { + t.Fatalf("pending reason = %q, want endpoint status error", reason) } } @@ -260,6 +359,123 @@ func TestEnrichStateUsesNodeResources(t *testing.T) { } } +func TestWaitReadyTimeoutReportsPendingNodes(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "slow-node", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + "spec": map[string]any{ + "kind": "arbitrary-kind", + "image": "example.invalid/arbitrary-kind:latest", + }, + "status": map[string]any{"readiness": "notready"}, + }} + + r := newTestRuntime(node) + err := r.waitReady(context.Background(), "lab1", "lab-ns", 20*time.Millisecond) + if err == nil { + t.Fatal("expected readiness timeout") + } + if !strings.Contains(err.Error(), "timed out after 20ms") || + !strings.Contains(err.Error(), "pending nodes: slow-node (notready)") || + strings.Contains(err.Error(), "rate limiter") { + t.Fatalf("unexpected readiness timeout: %v", err) + } +} + +func TestManagePrimitiveOnlyLab(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + clabconstants.Owner: "alice", + }, + }, + "spec": map[string]any{"kind": "linux", "image": "alpine:3"}, + "status": map[string]any{ + "readiness": "ready", + }, + }} + link := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Link", + "metadata": map[string]any{ + "name": "node1-eth1-host-eth1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + }, + }, + }} + profile := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "LauncherProfile", + "metadata": map[string]any{ + "name": "primitive-lab", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + }, + }, + }} + + r := newTestRuntime(node, link, profile) + state, err := r.Inspect(context.Background(), clablabruntime.InspectRequest{ + Name: "primitive-lab", + Namespace: "lab-ns", + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "primitive-lab" || state.Owner != "alice" || !state.Ready || + len(state.Nodes) != 1 || state.Nodes[0].Name != "node1" { + t.Fatalf("unexpected primitive-only inspect state: %+v", state) + } + + states, err := r.List(context.Background(), clablabruntime.ListRequest{Namespace: "lab-ns"}) + if err != nil { + t.Fatal(err) + } + if len(states) != 1 || states[0].Name != "primitive-lab" { + t.Fatalf("unexpected primitive-only list state: %+v", states) + } + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "primitive-lab", + Namespace: "lab-ns", + }); err != nil { + t.Fatal(err) + } + for _, resource := range []struct { + gvr schema.GroupVersionResource + name string + }{ + {gvr: nodeGVR, name: "node1"}, + {gvr: linkGVR, name: "node1-eth1-host-eth1"}, + {gvr: launcherProfileGVR, name: "primitive-lab"}, + } { + _, err := r.client.Resource(resource.gvr).Namespace("lab-ns"). + Get(context.Background(), resource.name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected %s to be deleted, got %v", resource.name, err) + } + } +} + func TestResolveLauncherNode(t *testing.T) { t.Parallel() @@ -277,7 +493,7 @@ func TestResolveLauncherNode(t *testing.T) { } } -func TestDeployCreatesTopology(t *testing.T) { +func TestDeployCreatesPrimitiveResources(t *testing.T) { t.Parallel() const definition = `topology: @@ -285,6 +501,11 @@ func TestDeployCreatesTopology(t *testing.T) { node1: kind: linux image: alpine:latest + node2: + kind: linux + image: alpine:3 + links: + - endpoints: ["node1:eth1", "node2:eth1"] ` r := newTestRuntime() @@ -302,9 +523,45 @@ func TestDeployCreatesTopology(t *testing.T) { t.Fatalf("unexpected deploy state: %+v", state) } - obj := getTestTopology(t, r, "lab-ns", "lab1") - if got := topologyDefinition(t, obj); got != definition { - t.Fatalf("topology definition = %q, want %q", got, definition) + assertNoTestTopology(t, r, "lab-ns", "lab1") + node := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if node.GetLabels()[labelTopologyOwner] != "lab1" || + node.GetLabels()[labelRuntime] != clabernetesAppValue || + node.GetLabels()[clabconstants.Owner] != "alice" { + t.Fatalf("unexpected node labels: %v", node.GetLabels()) + } + if got, _, _ := unstructured.NestedString( + node.Object, + "spec", + "image", + ); got != "alpine:latest" { + t.Fatalf("node image = %q, want alpine:latest", got) + } + + profiles, err := r.client.Resource(launcherProfileGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(profiles.Items) != 1 || profiles.Items[0].GetName() != "lab1" { + t.Fatalf("unexpected launcher profiles: %+v", profiles.Items) + } + + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) + } + if got, _, _ := unstructured.NestedString( + links.Items[0].Object, + "spec", + "endpointA", + "nodeName", + ); got != "node1" { + t.Fatalf("link endpointA nodeName = %q, want node1", got) } } @@ -313,8 +570,18 @@ func TestDeployStagesLocalFilesFromTopology(t *testing.T) { topologyDir := t.TempDir() writeFile(t, filepath.Join(topologyDir, "configs", "client2", "iperf.sh"), "#!/bin/sh\n", 0o755) - writeFile(t, filepath.Join(topologyDir, "configs", "prometheus", "prometheus.yml"), "global: {}\n", 0o644) - writeFile(t, filepath.Join(topologyDir, "configs", "fabric", "leaf1.cfg"), "set / system name leaf1\n", 0o644) + writeFile( + t, + filepath.Join(topologyDir, "configs", "prometheus", "prometheus.yml"), + "global: {}\n", + 0o644, + ) + writeFile( + t, + filepath.Join(topologyDir, "configs", "fabric", "leaf1.cfg"), + "set / system name leaf1\n", + 0o644, + ) const definition = `name: lab1 topology: @@ -358,20 +625,22 @@ topology: } prometheusConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-prometheus-files") - if got := string(prometheusConfigMap.BinaryData["configs-prometheus-prometheus-yml"]); got != "global: {}\n" { + if got := string( + prometheusConfigMap.BinaryData["configs-prometheus-prometheus-yml"], + ); got != "global: {}\n" { t.Fatalf("unexpected prometheus staged file content: %q", got) } startupConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-leaf1-startup-config") - if got := string(startupConfigMap.BinaryData["startup-config"]); got != "set / system name leaf1\n" { + if got := string( + startupConfigMap.BinaryData["startup-config"], + ); got != "set / system name leaf1\n" { t.Fatalf("unexpected startup config content: %q", got) } - obj := getTestTopology(t, r, "lab-ns", "lab1") assertFileMount( t, - obj, - "client2", + getTestPrimitive(t, r, nodeGVR, "lab-ns", "client2"), "configs/client2/iperf.sh", "lab1-client2-files", "configs-client2-iperf-sh", @@ -379,8 +648,7 @@ topology: ) assertFileMount( t, - obj, - "prometheus", + getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus"), "configs/prometheus/prometheus.yml", "lab1-prometheus-files", "configs-prometheus-prometheus-yml", @@ -388,13 +656,23 @@ topology: ) assertFileMount( t, - obj, - "leaf1", + getTestPrimitive(t, r, nodeGVR, "lab-ns", "leaf1"), "configs/fabric/leaf1.cfg", "lab1-leaf1-startup-config", "startup-config", "read", ) + + for _, configMap := range []*corev1.ConfigMap{ + clientConfigMap, + prometheusConfigMap, + startupConfigMap, + } { + if len(configMap.OwnerReferences) != 1 || configMap.OwnerReferences[0].Kind != "Node" { + t.Fatalf("ConfigMap %s owner references = %+v, want one Node owner", + configMap.Name, configMap.OwnerReferences) + } + } } func TestDeployPreservesDockerCompatibleNamesForEmptyPrefixTopology(t *testing.T) { @@ -422,10 +700,9 @@ topology: t.Fatal(err) } - obj := getTestTopology(t, r, "lab-ns", "st") - if got := topologyNaming(t, obj); got != "non-prefixed" { - t.Fatalf("topology naming = %q, want %q", got, "non-prefixed") - } + assertNoTestTopology(t, r, "lab-ns", "st") + _ = getTestPrimitive(t, r, nodeGVR, "lab-ns", "leaf1") + _ = getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") } func TestDeployExposesGNMICMetricsPortForClabernetes(t *testing.T) { @@ -464,30 +741,52 @@ topology: t.Fatal(err) } - definitionAfterDeploy := topologyDefinition(t, getTestTopology(t, r, "lab-ns", "st")) - if !strings.Contains(definitionAfterDeploy, "mgmt:\n network: st\n ipv4-subnet: 172.20.20.0/24") { - t.Fatalf("topology definition did not preserve mgmt config:\n%s", definitionAfterDeploy) - } - if !strings.Contains(definitionAfterDeploy, "- leaf1:e1-1") || - !strings.Contains(definitionAfterDeploy, "- prometheus:eth1") { - t.Fatalf("topology definition did not preserve brief link endpoints:\n%s", definitionAfterDeploy) + assertNoTestTopology(t, r, "lab-ns", "st") + gnmic := getTestPrimitive(t, r, nodeGVR, "lab-ns", "gnmic") + ports, found, err := unstructured.NestedStringSlice(gnmic.Object, "spec", "ports") + if err != nil || !found { + t.Fatalf("failed to read gnmic ports: found=%t err=%v", found, err) } - if strings.Contains(definitionAfterDeploy, "node: leaf1") || - strings.Contains(definitionAfterDeploy, "interface: e1-1") { - t.Fatalf("topology definition rendered structured link endpoints:\n%s", definitionAfterDeploy) + if !slices.Contains(ports, "9273/tcp") { + t.Fatalf("gnmic ports = %v, want 9273/tcp", ports) } - var config clabRuntimeConfig - if err := yaml.Unmarshal([]byte(definitionAfterDeploy), &config); err != nil { - t.Fatal(err) + profile := getTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "st") + if enabled, found, err := unstructured.NestedBool( + profile.Object, + "spec", + "statusProbes", + "enabled", + ); err != nil || !found || !enabled { + t.Fatalf("status probes enabled = %t, found=%t, err=%v; want true", enabled, found, err) + } + statusProbes, found, err := unstructured.NestedMap(profile.Object, "spec", "statusProbes") + if err != nil || !found { + t.Fatalf("failed to read status probe configuration: found=%t err=%v", found, err) + } + if configurations, found := statusProbes["nodeProbeConfigurations"]; found && + configurations != nil { + t.Fatalf("containerlab must not infer node probe configurations: %v", statusProbes) + } + if excludedNodes, found := statusProbes["excludedNodes"]; found && excludedNodes != nil { + t.Fatalf("containerlab must not exclude kinds from generic readiness: %v", statusProbes) + } + if got, _, _ := unstructured.NestedString( + profile.Object, + "spec", + "mgmt", + "ipv4-subnet", + ); got != "172.20.20.0/24" { + t.Fatalf("launcher profile management subnet = %q, want 172.20.20.0/24", got) } - gnmic := config.Topology.Nodes["gnmic"] - if gnmic == nil { - t.Fatal("gnmic node was not found in topology definition") + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) } - if !slices.Contains(gnmic.Ports, "9273:9273/tcp") { - t.Fatalf("gnmic ports = %v, want 9273:9273/tcp", gnmic.Ports) + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) } } @@ -552,7 +851,8 @@ func TestDeployDuplicateCheckIsNamespaceScoped(t *testing.T) { } _ = getTestTopology(t, r, "lab-a", "lab1") - _ = getTestTopology(t, r, "lab-b", "lab1") + assertNoTestTopology(t, r, "lab-b", "lab1") + _ = getTestPrimitive(t, r, nodeGVR, "lab-b", "node1") } func TestForwardPodWatchReconnectsOnClosedChannel(t *testing.T) { @@ -650,8 +950,10 @@ func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { client: dynamicfake.NewSimpleDynamicClientWithCustomListKinds( k8sruntime.NewScheme(), map[schema.GroupVersionResource]string{ - topologyGVR: "TopologyList", - nodeGVR: "NodeList", + topologyGVR: "TopologyList", + nodeGVR: "NodeList", + linkGVR: "LinkList", + launcherProfileGVR: "LauncherProfileList", }, runtimeObjects..., ), @@ -677,6 +979,40 @@ func getTestTopology( return obj } +func assertNoTestTopology( + t *testing.T, + r *Runtime, + namespace string, + name string, +) { + t.Helper() + + _, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected compatibility Topology %s/%s not to exist, got %v", + namespace, name, err) + } +} + +func getTestPrimitive( + t *testing.T, + r *Runtime, + gvr schema.GroupVersionResource, + namespace string, + name string, +) *unstructured.Unstructured { + t.Helper() + + obj, err := r.client.Resource(gvr).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return obj +} + func topologyDefinition(t *testing.T, obj *unstructured.Unstructured) string { t.Helper() @@ -730,7 +1066,6 @@ func getTestConfigMap( func assertFileMount( t *testing.T, obj *unstructured.Unstructured, - nodeName, filePath, configMapName, configMapPath, @@ -738,10 +1073,9 @@ func assertFileMount( ) { t.Helper() - filesFromConfigMap, found, err := unstructured.NestedMap( + filesFromConfigMap, found, err := unstructured.NestedSlice( obj.Object, "spec", - "deployment", "filesFromConfigMap", ) if err != nil { @@ -751,12 +1085,7 @@ func assertFileMount( t.Fatal("filesFromConfigMap was not found") } - rawMounts, ok := filesFromConfigMap[nodeName].([]any) - if !ok { - t.Fatalf("filesFromConfigMap[%s] has unexpected type %T", nodeName, filesFromConfigMap[nodeName]) - } - - for _, rawMount := range rawMounts { + for _, rawMount := range filesFromConfigMap { mount, ok := rawMount.(map[string]any) if !ok { t.Fatalf("mount has unexpected type %T", rawMount) @@ -775,7 +1104,7 @@ func assertFileMount( configMapName, configMapPath, mode, - rawMounts, + filesFromConfigMap, ) } diff --git a/labruntime/clabernetes/events.go b/labruntime/clabernetes/events.go index b54f792a83..ee5c23bd35 100644 --- a/labruntime/clabernetes/events.go +++ b/labruntime/clabernetes/events.go @@ -19,7 +19,7 @@ func (r *Runtime) StreamEvents( req clablabruntime.EventStreamRequest, ) (<-chan clablabruntime.Event, <-chan error, error) { events := make(chan clablabruntime.Event, 128) - errs := make(chan error, 2) + errs := make(chan error, 3) namespace := r.namespaceFor(req.Namespace) if req.AllNamespaces { @@ -35,11 +35,90 @@ func (r *Runtime) StreamEvents( } go r.watchTopologies(ctx, namespace, events, errs) + go r.watchNodes(ctx, namespace, events, errs) go r.watchPods(ctx, namespace, events, errs) return events, errs, nil } +func (r *Runtime) watchNodes( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + resource := r.client.Resource(nodeGVR).Namespace(namespace) + + for { + watcher, err := resource.Watch(ctx, metav1.ListOptions{LabelSelector: labelTopologyOwner}) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch c9s nodes: %w", err)) + return + } + + if !r.forwardNodeWatch(ctx, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardNodeWatch( + ctx context.Context, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("c9s node watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("c9s node watch returned an error")) + return false + } + + node, ok := ev.Object.(*unstructured.Unstructured) + if !ok { + continue + } + + labName := node.GetLabels()[labelTopologyOwner] + if labName == "" { + continue + } + readiness, _, _ := unstructured.NestedString(node.Object, "status", "readiness") + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s/%s", node.GetNamespace(), labName, node.GetName()), + ActorName: fmt.Sprintf("%s-%s", labName, node.GetName()), + Attributes: map[string]string{ + "namespace": node.GetNamespace(), + "lab": labName, + "node": node.GetName(), + "state": readiness, + }, + }) + } + } +} + func (r *Runtime) emitInitialEvents( ctx context.Context, namespace string, @@ -96,7 +175,11 @@ func (r *Runtime) watchTopologies( return } - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes topologies: %w", err)) + sendEventError( + ctx, + errSink, + fmt.Errorf("failed to watch clabernetes topologies: %w", err), + ) return } @@ -129,7 +212,11 @@ func (r *Runtime) forwardTopologyWatch( return true } if ev.Type == watch.Error { - sendEventError(ctx, errSink, fmt.Errorf("clabernetes topology watch returned an error")) + sendEventError( + ctx, + errSink, + fmt.Errorf("clabernetes topology watch returned an error"), + ) return false } @@ -171,7 +258,8 @@ func (r *Runtime) watchPods( return } - sendEventError(ctx, errSink, fmt.Errorf("failed to watch clabernetes pods: %w", err)) + sendEventError(ctx, errSink, fmt.Errorf( + "failed to watch clabernetes pods: %w", err)) return } @@ -203,7 +291,8 @@ func (r *Runtime) forwardPodWatch( return true } if ev.Type == watch.Error { - sendEventError(ctx, errSink, fmt.Errorf("clabernetes pod watch returned an error")) + sendEventError(ctx, errSink, fmt.Errorf( + "clabernetes pod watch returned an error")) return false } diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go index 357828ce11..f3a6f52272 100644 --- a/labruntime/clabernetes/exec.go +++ b/labruntime/clabernetes/exec.go @@ -166,8 +166,12 @@ func (r *Runtime) execInPod( } } if err != nil { - return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf("failed to execute command in pod %s/%s: %w", - pod.Namespace, pod.Name, err) + return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf( + "failed to execute command in pod %s/%s: %w", + pod.Namespace, + pod.Name, + err, + ) } return stdout.Bytes(), stderr.Bytes(), rc, nil diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go index 11a2eb2fa9..f41e3de6d0 100644 --- a/labruntime/clabernetes/files.go +++ b/labruntime/clabernetes/files.go @@ -397,7 +397,13 @@ func stageLicenseFile( safeKubernetesName(topologyName, nodeName, "files"), ) - return stageSourcePathIntoConfigMap(configMap, license, nodeName, topologyFileDir, topologyLabDir) + return stageSourcePathIntoConfigMap( + configMap, + license, + nodeName, + topologyFileDir, + topologyLabDir, + ) } func stageBindFiles( @@ -454,7 +460,12 @@ func stageSourcePathIntoConfigMap( ) error { files, err := resolveLocalFiles(sourcePath, nodeName, topologyFileDir, topologyLabDir) if err != nil { - return fmt.Errorf("failed staging source path %q for node %q: %w", sourcePath, nodeName, err) + return fmt.Errorf( + "failed staging source path %q for node %q: %w", + sourcePath, + nodeName, + err, + ) } for _, file := range files { @@ -773,33 +784,44 @@ func stagedConfigMapObject( } } -func (r *Runtime) setStagedConfigMapOwnerReferences( +func (r *Runtime) setStagedConfigMapNodeOwnerReferences( ctx context.Context, namespace string, configMaps []stagedConfigMap, - topology *unstructured.Unstructured, + nodes map[string]*unstructured.Unstructured, ) error { if len(configMaps) == 0 { return nil } - ownerReferences := []metav1.OwnerReference{ - { - APIVersion: c9sAPIVersion, - Kind: "Topology", - Name: topology.GetName(), - UID: topology.GetUID(), - }, - } - for _, staged := range configMaps { + node := nodes[staged.nodeName] + if node == nil { + return fmt.Errorf("failed to find c9s node %s/%s for staged ConfigMap ownership", + namespace, staged.nodeName) + } + ownerReferences := []metav1.OwnerReference{ + { + APIVersion: c9sAPIVersion, + Kind: "Node", + Name: node.GetName(), + UID: node.GetUID(), + }, + } + configMap, err := r.kubeClient.CoreV1().ConfigMaps(namespace). Get(ctx, staged.name, metav1.GetOptions{}) if apierrors.IsNotFound(err) { - configMap = stagedConfigMapObject(namespace, topology.GetName(), staged, ownerReferences) + configMap = stagedConfigMapObject( + namespace, + node.GetLabels()[labelTopologyOwner], + staged, + ownerReferences, + ) if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). Create(ctx, configMap, metav1.CreateOptions{}); err != nil { - return fmt.Errorf("failed to recreate staged ConfigMap %s/%s with owner references: %w", + return fmt.Errorf( + "failed to recreate staged ConfigMap %s/%s with owner references: %w", namespace, staged.name, err, diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index be7b07a553..1ddaa46f9a 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -2,14 +2,19 @@ package clabernetes import ( "context" + "errors" "fmt" "sort" + "strings" "time" "github.com/charmbracelet/log" clablabruntime "github.com/srl-labs/containerlab/labruntime" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/wait" ) @@ -26,65 +31,94 @@ func (r *Runtime) Deploy( } namespace := r.namespaceFor(req.Namespace) - resource := r.client.Resource(topologyGVR).Namespace(namespace) + topologyResource := r.client.Resource(topologyGVR).Namespace(namespace) - _, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + _, err := topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) switch { case apierrors.IsNotFound(err): - topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) - if err != nil { - return nil, err - } + // Expected for the primary Node/Link path. + case err != nil: + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + default: + return nil, duplicateTopologyError(req.Name, namespace) + } - desired := topologyObject( - req.Name, - namespace, - req.Owner, - string(topologyDefinition), - topologyWithNaming(naming), - ) - if err := setTopologyFilesFromConfigMaps(desired, stagedConfigMaps); err != nil { - return nil, err - } + exists, err := r.primitiveLabExists(ctx, req.Name, namespace) + if err != nil { + return nil, err + } + if exists { + return nil, duplicateTopologyError(req.Name, namespace) + } - if err = r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { - return nil, err - } + topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) + if err != nil { + return nil, err + } - log.Info("Creating clabernetes topology", "name", req.Name, "namespace", namespace) - created, createErr := resource.Create(ctx, desired, metav1.CreateOptions{}) - if createErr != nil { - r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + desiredTopology := topologyObject( + req.Name, + namespace, + req.Owner, + string(topologyDefinition), + topologyWithNaming(naming), + ) + if err := setTopologyFilesFromConfigMaps(desiredTopology, stagedConfigMaps); err != nil { + return nil, err + } - err = createErr - if apierrors.IsAlreadyExists(err) { - return nil, duplicateTopologyError(req.Name, namespace) - } + primitives, err := compilePrimitiveResources(desiredTopology) + if err != nil { + return nil, err + } + if req.Wait { + stagePrimitiveNodeDeployments(primitives) + } + + if err = r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + log.Info("Creating clabernetes primitive resources", "name", req.Name, "namespace", namespace) + createdNodes, created, err := r.createPrimitiveResources(ctx, namespace, primitives) + if err != nil { + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) - return nil, fmt.Errorf("failed to create clabernetes topology %s/%s: %w", - namespace, req.Name, err) + return nil, err + } + + if err = r.setStagedConfigMapNodeOwnerReferences( + ctx, + namespace, + stagedConfigMaps, + createdNodes, + ); err != nil { + r.deleteCreatedPrimitiveResources(ctx, namespace, created) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + + return nil, err + } + + if req.Wait { + if err = r.waitPrimitiveLinksResolved( + ctx, + namespace, + primitives.links, + req.Timeout, + ); err != nil { + r.deleteCreatedPrimitiveResources(ctx, namespace, created) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + + return nil, err } - if err = r.setStagedConfigMapOwnerReferences(ctx, namespace, stagedConfigMaps, created); err != nil { - // without owner references the ConfigMaps would never be garbage - // collected, so roll back the partially deployed topology - if delErr := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}); delErr != nil && - !apierrors.IsNotFound(delErr) { - log.Debug("failed to roll back clabernetes topology", - "name", req.Name, - "namespace", namespace, - "error", delErr, - ) - } + if err = r.enablePrimitiveNodeDeployments(ctx, namespace, createdNodes); err != nil { + r.deleteCreatedPrimitiveResources(ctx, namespace, created) r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) return nil, err } - case err != nil: - return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", - namespace, req.Name, err) - default: - return nil, duplicateTopologyError(req.Name, namespace) } if !req.Wait { @@ -114,18 +148,67 @@ func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest } namespace := r.namespaceFor(req.Namespace) - resource := r.client.Resource(topologyGVR).Namespace(namespace) - - log.Info("Deleting clabernetes topology", "name", req.Name, "namespace", namespace) + selector := labels.Set{labelTopologyOwner: req.Name}.String() + + log.Info("Deleting clabernetes lab resources", "name", req.Name, "namespace", namespace) + + var deleteErrors []error + // Delete the compatibility owner first when present so it cannot recreate compiler output + // while the primitive resources are being removed. + err := r.client.Resource(topologyGVR).Namespace(namespace). + Delete(ctx, req.Name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete compatibility clabernetes topology %s/%s: %w", + namespace, req.Name, err)) + } - err := resource.Delete(ctx, req.Name, metav1.DeleteOptions{}) - if apierrors.IsNotFound(err) { - log.Info("clabernetes topology not found", "name", req.Name, "namespace", namespace) - return nil + for _, gvr := range []struct { + name string + gvr schema.GroupVersionResource + }{ + {name: "nodes", gvr: nodeGVR}, + {name: "links", gvr: linkGVR}, + {name: "launcher profiles", gvr: launcherProfileGVR}, + } { + list, listErr := r.primitiveResourcesForTopology(ctx, gvr.gvr, req.Name, namespace) + if listErr != nil { + deleteErrors = append(deleteErrors, listErr) + continue + } + for idx := range list.Items { + err = r.client.Resource(gvr.gvr).Namespace(namespace). + Delete(ctx, list.Items[idx].GetName(), metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete c9s %s %s/%s: %w", + gvr.name, namespace, list.Items[idx].GetName(), err)) + } + } } + + configMaps, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: selector}, + ) if err != nil { - return fmt.Errorf("failed to delete clabernetes topology %s/%s: %w", - namespace, req.Name, err) + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, req.Name, err)) + } else { + for idx := range configMaps.Items { + err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, configMaps.Items[idx].Name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete staged ConfigMap %s/%s: %w", + namespace, configMaps.Items[idx].Name, err)) + } + } + } + + if len(deleteErrors) != 0 { + return errors.Join(deleteErrors...) } if !req.Wait { @@ -146,14 +229,27 @@ func (r *Runtime) Inspect( namespace := r.namespaceFor(req.Namespace) obj, err := r.client.Resource(topologyGVR).Namespace(namespace). Get(ctx, req.Name, metav1.GetOptions{}) - if err != nil { + if err != nil && !apierrors.IsNotFound(err) { return nil, fmt.Errorf("failed to inspect clabernetes topology %s/%s: %w", namespace, req.Name, err) } - state := stateFromTopology(obj, namespace) + var state *clablabruntime.LabState + if err == nil { + state = stateFromTopology(obj, namespace) + } else { + nodes, listErr := r.nodesForTopology(ctx, req.Name, namespace) + if listErr != nil { + return nil, listErr + } + if len(nodes.Items) == 0 { + return nil, fmt.Errorf("c9s lab %s/%s was not found", namespace, req.Name) + } + state = stateFromNodeResources(req.Name, namespace, nodes.Items) + } + if err := r.enrichState(ctx, state); err != nil { - log.Debug("failed to enrich clabernetes topology state", "error", err) + log.Debug("failed to enrich clabernetes lab state", "error", err) } return state, nil @@ -168,17 +264,50 @@ func (r *Runtime) List( namespace = metav1.NamespaceAll } - list, err := r.client.Resource(topologyGVR).Namespace(namespace). + topologyList, err := r.client.Resource(topologyGVR).Namespace(namespace). List(ctx, metav1.ListOptions{}) if err != nil { return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) } - states := make([]*clablabruntime.LabState, 0, len(list.Items)) - for idx := range list.Items { - state := stateFromTopology(&list.Items[idx], namespace) + statesByLab := make(map[string]*clablabruntime.LabState, len(topologyList.Items)) + for idx := range topologyList.Items { + state := stateFromTopology(&topologyList.Items[idx], namespace) + statesByLab[state.Namespace+"/"+state.Name] = state + } + + nodeList, err := r.client.Resource(nodeGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s nodes: %w", err) + } + + nodesByLab := map[string][]unstructured.Unstructured{} + for idx := range nodeList.Items { + node := nodeList.Items[idx] + labName := node.GetLabels()[labelTopologyOwner] + if labName == "" { + continue + } + key := node.GetNamespace() + "/" + labName + nodesByLab[key] = append(nodesByLab[key], node) + } + for key, nodes := range nodesByLab { + if _, exists := statesByLab[key]; exists { + continue + } + statesByLab[key] = stateFromNodeResources( + nodes[0].GetLabels()[labelTopologyOwner], + nodes[0].GetNamespace(), + nodes, + ) + } + + states := make([]*clablabruntime.LabState, 0, len(statesByLab)) + for _, state := range statesByLab { if err := r.enrichState(ctx, state); err != nil { - log.Debug("failed to enrich clabernetes topology state", + log.Debug("failed to enrich clabernetes lab state", "name", state.Name, "namespace", state.Namespace, "error", err, @@ -197,21 +326,36 @@ func (r *Runtime) List( return states, nil } -func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout time.Duration) error { - waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) +func (r *Runtime) waitReady( + ctx context.Context, + name, namespace string, + timeout time.Duration, +) error { + effectiveTimeout := r.timeoutFor(timeout) + waitCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) defer cancel() - resource := r.client.Resource(topologyGVR).Namespace(namespace) + var lastState *clablabruntime.LabState - return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, func(ctx context.Context) (bool, error) { - obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + state, err := r.Inspect(ctx, clablabruntime.InspectRequest{ + Name: name, + Namespace: namespace, + }) if err != nil { - return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + // client-go's rate limiter can refuse a request shortly before the context + // expires with "would exceed context deadline". Let the poller return the + // authoritative timeout instead of masking it with that implementation detail. + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, fmt.Errorf("failed to inspect clabernetes lab %s/%s: %w", namespace, name, err) } + lastState = state - state := stateFromTopology(obj, namespace) if state.Ready { return true, nil } @@ -220,7 +364,7 @@ func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout namespace, name) } - log.Debug("Waiting for clabernetes topology", + log.Debug("Waiting for clabernetes lab", "name", name, "namespace", namespace, "state", state.State, @@ -228,25 +372,89 @@ func (r *Runtime) waitReady(ctx context.Context, name, namespace string, timeout return false, nil }) + if err == nil { + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + pendingNodes := make([]string, 0) + if lastState != nil { + for _, node := range lastState.Nodes { + if node.Ready { + continue + } + state := node.State + if state == "" { + state = "unknown" + } + pendingNodes = append(pendingNodes, fmt.Sprintf("%s (%s)", node.Name, state)) + } + } + + if len(pendingNodes) == 0 { + return fmt.Errorf( + "timed out after %s waiting for clabernetes lab %s/%s to become ready", + effectiveTimeout, + namespace, + name, + ) + } + + return fmt.Errorf( + "timed out after %s waiting for clabernetes lab %s/%s to become ready; "+ + "pending nodes: %s", + effectiveTimeout, + namespace, + name, + strings.Join(pendingNodes, ", "), + ) +} + +func contextDeadlineIsImminent(ctx context.Context) bool { + deadline, ok := ctx.Deadline() + + return ok && time.Until(deadline) <= pollInterval } -func (r *Runtime) waitDeleted(ctx context.Context, name, namespace string, timeout time.Duration) error { +func (r *Runtime) waitDeleted( + ctx context.Context, + name, namespace string, + timeout time.Duration, +) error { waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) defer cancel() - resource := r.client.Resource(topologyGVR).Namespace(namespace) - return wait.PollUntilContextCancel(waitCtx, pollInterval, true, func(ctx context.Context) (bool, error) { - _, err := resource.Get(ctx, name, metav1.GetOptions{}) + _, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, name, metav1.GetOptions{}) switch { - case apierrors.IsNotFound(err): - return true, nil case err != nil: + if apierrors.IsNotFound(err) { + break + } return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", namespace, name, err) default: return false, nil } + + for _, gvr := range []schema.GroupVersionResource{ + nodeGVR, + linkGVR, + launcherProfileGVR, + } { + list, err := r.primitiveResourcesForTopology(ctx, gvr, name, namespace) + if err != nil { + return false, err + } + if len(list.Items) != 0 { + return false, nil + } + } + + return true, nil }) } diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go index 6307cf8760..bcfe9e6327 100644 --- a/labruntime/clabernetes/nodes.go +++ b/labruntime/clabernetes/nodes.go @@ -8,6 +8,7 @@ import ( clablabruntime "github.com/srl-labs/containerlab/labruntime" appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/wait" @@ -56,7 +57,13 @@ func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) e namespace, req.Name, nodeName, err) } - if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, 1, req.Timeout); err != nil { + if err := r.waitDeploymentReplicas( + ctx, + namespace, + deployment.Name, + 1, + req.Timeout, + ); err != nil { return err } } @@ -100,7 +107,10 @@ func (r *Runtime) targetNodes( } } if len(known) == 0 { - state, err := r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + state, err := r.Inspect( + ctx, + clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}, + ) if err != nil { return nil, "", err } @@ -156,7 +166,13 @@ func (r *Runtime) setNodesReplicas( return err } for _, nodeName := range launcherNodes { - if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, true); err != nil { + if err := r.setNodeIgnoreReconcile( + ctx, + req.Name, + namespace, + nodeName, + true, + ); err != nil { return err } } @@ -176,14 +192,26 @@ func (r *Runtime) setNodesReplicas( namespace, req.Name, nodeName, replicas, err) } - if err := r.waitDeploymentReplicas(ctx, namespace, deployment.Name, replicas, req.Timeout); err != nil { + if err := r.waitDeploymentReplicas( + ctx, + namespace, + deployment.Name, + replicas, + req.Timeout, + ); err != nil { return err } } if replicas > 0 { for _, nodeName := range launcherNodes { - if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, false); err != nil { + if err := r.setNodeIgnoreReconcile( + ctx, + req.Name, + namespace, + nodeName, + false, + ); err != nil { return err } } @@ -262,6 +290,10 @@ func (r *Runtime) setTopologyIgnoreReconcile( resource := r.client.Resource(topologyGVR).Namespace(namespace) obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + // Primary Node/Link labs deliberately have no compatibility Topology object. + return nil + } if err != nil { return fmt.Errorf("failed to get clabernetes topology %s/%s: %w", namespace, name, err) diff --git a/labruntime/clabernetes/primitives.go b/labruntime/clabernetes/primitives.go new file mode 100644 index 0000000000..54369552f1 --- /dev/null +++ b/labruntime/clabernetes/primitives.go @@ -0,0 +1,185 @@ +package clabernetes + +import ( + "fmt" + + "github.com/charmbracelet/log" + clabernetesapisv1alpha1 "github.com/clabernetes/clabernetes/apis/v1alpha1" + clabernetesconfig "github.com/clabernetes/clabernetes/config" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + clabernetescontrollerstopology "github.com/clabernetes/clabernetes/controllers/topology" + clabconstants "github.com/srl-labs/containerlab/constants" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type primitiveResourceSet struct { + launcherProfiles []*unstructured.Unstructured + links []*unstructured.Unstructured + nodes []*unstructured.Unstructured +} + +type primitiveResourceGroup struct { + gvr schema.GroupVersionResource + kind string + objects []*unstructured.Unstructured +} + +func (s *primitiveResourceSet) groups() []primitiveResourceGroup { + return []primitiveResourceGroup{ + {gvr: launcherProfileGVR, kind: "LauncherProfile", objects: s.launcherProfiles}, + {gvr: linkGVR, kind: "Link", objects: s.links}, + // c9s lets never-bound Links wait for their endpoint Nodes. Creating Links first ensures + // the Node controller sees the complete wiring set before it creates launcher workloads, + // avoiding partial-topology launches and the resulting Pod rollouts. + {gvr: nodeGVR, kind: "Node", objects: s.nodes}, + } +} + +// stagePrimitiveNodeDeployments prevents the asynchronous Node controller from creating launcher +// workloads while the Link controller is still binding the complete, already-created wiring set. +func stagePrimitiveNodeDeployments(set *primitiveResourceSet) { + for _, node := range set.nodes { + labels := node.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[clabernetesconstants.LabelDisableDeployments] = "true" + node.SetLabels(labels) + } +} + +// compilePrimitiveResources runs the same compiler and renderer used by c9s' compatibility +// Topology controller, but keeps the Topology in memory. Only the resulting O(1)-per-object +// LauncherProfile, Link, and Node resources are sent to the Kubernetes API server. +func compilePrimitiveResources( + desiredTopology *unstructured.Unstructured, +) (*primitiveResourceSet, error) { + if desiredTopology == nil { + return nil, fmt.Errorf("clabernetes topology is nil") + } + + topology := &clabernetesapisv1alpha1.Topology{} + if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured( + desiredTopology.Object, + topology, + ); err != nil { + return nil, fmt.Errorf("failed to prepare c9s primitive resources: %w", err) + } + + // In-memory objects do not pass through API-server defaulting. + if topology.Spec.Connectivity == "" { + topology.Spec.Connectivity = string(clabernetesapisv1alpha1.LinkConnectivityVXLAN) + } + + compiled, err := clabernetescontrollerstopology.CompileTopology(c9sCompileLogger{}, topology) + if err != nil { + return nil, fmt.Errorf("failed to compile containerlab topology for c9s: %w", err) + } + + configurePrimitiveReadiness(topology) + + set := &primitiveResourceSet{} + for _, profile := range clabernetescontrollerstopology.RenderLauncherProfiles( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(profile, "LauncherProfile", desiredTopology) + if err != nil { + return nil, err + } + set.launcherProfiles = append(set.launcherProfiles, obj) + } + + for _, link := range clabernetescontrollerstopology.RenderLinks( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(link, "Link", desiredTopology) + if err != nil { + return nil, err + } + set.links = append(set.links, obj) + } + + for _, node := range clabernetescontrollerstopology.RenderNodes( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(node, "Node", desiredTopology) + if err != nil { + return nil, err + } + set.nodes = append(set.nodes, obj) + } + + return set, nil +} + +// In-memory compatibility Topologies do not pass through API-server defaulting. Enable c9s' +// generic nested-container readiness explicitly, without inferring behavior from kinds, images, +// ports, or credentials in containerlab. +func configurePrimitiveReadiness(topology *clabernetesapisv1alpha1.Topology) { + topology.Spec.StatusProbes = clabernetesapisv1alpha1.StatusProbes{Enabled: true} +} + +func primitiveObject( + typedObject any, + kind string, + desiredTopology *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + object, err := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(typedObject) + if err != nil { + return nil, fmt.Errorf("failed to render c9s %s: %w", kind, err) + } + + obj := &unstructured.Unstructured{Object: object} + obj.SetAPIVersion(c9sAPIVersion) + obj.SetKind(kind) + + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[labelRuntime] = clabernetesAppValue + if owner := desiredTopology.GetLabels()[clabconstants.Owner]; owner != "" { + labels[clabconstants.Owner] = owner + } + obj.SetLabels(labels) + + if owner := desiredTopology.GetAnnotations()[clabconstants.Owner]; owner != "" { + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[clabconstants.Owner] = owner + obj.SetAnnotations(annotations) + } + + return obj, nil +} + +// c9sCompileLogger adapts c9s compiler diagnostics to containerlab's logger. +type c9sCompileLogger struct{} + +func (c9sCompileLogger) Debug(message string) { log.Debug(message) } +func (c9sCompileLogger) Debugf(format string, args ...any) { log.Debugf(format, args...) } +func (c9sCompileLogger) Info(message string) { log.Info(message) } +func (c9sCompileLogger) Infof(format string, args ...any) { log.Infof(format, args...) } +func (c9sCompileLogger) Warn(message string) { log.Warn(message) } +func (c9sCompileLogger) Warnf(format string, args ...any) { log.Warnf(format, args...) } +func (c9sCompileLogger) Critical(message string) { log.Error(message) } +func (c9sCompileLogger) Criticalf(format string, args ...any) { log.Errorf(format, args...) } +func (c9sCompileLogger) Fatal(message string) { log.Error(message) } +func (c9sCompileLogger) Fatalf(format string, args ...any) { log.Errorf(format, args...) } +func (c9sCompileLogger) GetName() string { return "containerlab-c9s" } +func (c9sCompileLogger) GetLevel() string { return "info" } +func (c9sCompileLogger) Write(data []byte) (int, error) { + log.Info(string(data)) + + return len(data), nil +} diff --git a/labruntime/clabernetes/resources.go b/labruntime/clabernetes/resources.go index f67d751e16..44e1d7a4aa 100644 --- a/labruntime/clabernetes/resources.go +++ b/labruntime/clabernetes/resources.go @@ -2,12 +2,21 @@ package clabernetes import ( "context" + "errors" "fmt" + "sort" "strings" + "time" + clabernetesapisv1alpha1 "github.com/clabernetes/clabernetes/apis/v1alpha1" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" ) func (r *Runtime) nodesForTopology( @@ -28,6 +37,250 @@ func (r *Runtime) nodesForTopology( return list, nil } +func (r *Runtime) primitiveResourcesForTopology( + ctx context.Context, + gvr schema.GroupVersionResource, + name, + namespace string, +) (*unstructured.UnstructuredList, error) { + namespace = r.namespaceFor(namespace) + + list, err := r.client.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: name}.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s %s for lab %s/%s: %w", + gvr.Resource, namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) primitiveLabExists( + ctx context.Context, + name, + namespace string, +) (bool, error) { + for _, gvr := range []schema.GroupVersionResource{nodeGVR, linkGVR, launcherProfileGVR} { + list, err := r.primitiveResourcesForTopology(ctx, gvr, name, namespace) + if err != nil { + return false, err + } + if len(list.Items) != 0 { + return true, nil + } + } + + return false, nil +} + +type createdPrimitiveResource struct { + gvr schema.GroupVersionResource + name string +} + +func (r *Runtime) createPrimitiveResources( + ctx context.Context, + namespace string, + set *primitiveResourceSet, +) (map[string]*unstructured.Unstructured, []createdPrimitiveResource, error) { + createdNodes := map[string]*unstructured.Unstructured{} + created := []createdPrimitiveResource{} + + for _, group := range set.groups() { + resource := r.client.Resource(group.gvr).Namespace(namespace) + for _, desired := range group.objects { + actual, err := resource.Create(ctx, desired, metav1.CreateOptions{}) + if err != nil { + r.deleteCreatedPrimitiveResources(ctx, namespace, created) + if apierrors.IsAlreadyExists(err) { + return nil, nil, fmt.Errorf( + "c9s %s %s/%s already exists and belongs to another lab", + group.kind, + namespace, + desired.GetName(), + ) + } + + return nil, nil, fmt.Errorf("failed to create c9s %s %s/%s: %w", + group.kind, namespace, desired.GetName(), err) + } + + created = append( + created, + createdPrimitiveResource{gvr: group.gvr, name: actual.GetName()}, + ) + if group.gvr == nodeGVR { + createdNodes[actual.GetName()] = actual + } + } + } + + return createdNodes, created, nil +} + +func (r *Runtime) waitPrimitiveLinksResolved( + ctx context.Context, + namespace string, + desiredLinks []*unstructured.Unstructured, + timeout time.Duration, +) error { + if len(desiredLinks) == 0 { + return nil + } + + desiredNames := make(map[string]struct{}, len(desiredLinks)) + for _, link := range desiredLinks { + desiredNames[link.GetName()] = struct{}{} + } + + effectiveTimeout := r.timeoutFor(timeout) + waitCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + + var pending []string + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + links, err := r.client.Resource(linkGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, fmt.Errorf("failed to inspect c9s links in namespace %s: %w", + namespace, err) + } + + linksByName := make(map[string]*unstructured.Unstructured, len(links.Items)) + for idx := range links.Items { + linksByName[links.Items[idx].GetName()] = &links.Items[idx] + } + + pending = pending[:0] + for name := range desiredNames { + link, ok := linksByName[name] + if !ok { + pending = append(pending, name+" (not found)") + continue + } + + if reason := primitiveLinkPendingReason(link); reason != "" { + pending = append(pending, fmt.Sprintf("%s (%s)", name, reason)) + } + } + sort.Strings(pending) + + return len(pending) == 0, nil + }) + if err == nil { + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + return fmt.Errorf( + "timed out after %s waiting for c9s links in namespace %s to resolve; pending links: %s", + effectiveTimeout, + namespace, + strings.Join(pending, ", "), + ) +} + +func primitiveLinkPendingReason(link *unstructured.Unstructured) string { + if link == nil { + return "not found" + } + + if statusError, _, _ := unstructured.NestedString( + link.Object, + "status", + "error", + ); statusError != "" { + return statusError + } + + for _, endpointName := range []string{"endpointA", "endpointB"} { + nodeName, _, _ := unstructured.NestedString( + link.Object, + "status", + "resolvedEndpoints", + endpointName, + "nodeName", + ) + if nodeName == "" { + return "waiting for endpoint binding" + } + if nodeName == clabernetesapisv1alpha1.LinkHostNodeName { + continue + } + + uid, _, _ := unstructured.NestedString( + link.Object, + "status", + "resolvedEndpoints", + endpointName, + "uid", + ) + if uid == "" { + return "waiting for endpoint identity" + } + } + + return "" +} + +func (r *Runtime) enablePrimitiveNodeDeployments( + ctx context.Context, + namespace string, + nodes map[string]*unstructured.Unstructured, +) error { + patch := []byte(fmt.Sprintf( + `{"metadata":{"labels":{"%s":null}}}`, + clabernetesconstants.LabelDisableDeployments, + )) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + _, err := r.client.Resource(nodeGVR).Namespace(namespace).Patch( + ctx, + name, + types.MergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("failed to enable c9s Node deployment %s/%s: %w", + namespace, name, err) + } + } + + return nil +} + +func (r *Runtime) deleteCreatedPrimitiveResources( + ctx context.Context, + namespace string, + created []createdPrimitiveResource, +) { + for idx := len(created) - 1; idx >= 0; idx-- { + resource := created[idx] + err := r.client.Resource(resource.gvr).Namespace(namespace). + Delete(ctx, resource.name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + // This is rollback after a more useful create error; keep the original error as the one + // returned to the caller. + continue + } + } +} + func nodeNetworkMode(node *unstructured.Unstructured) string { if node == nil { return "" diff --git a/labruntime/clabernetes/save.go b/labruntime/clabernetes/save.go index 364da256ff..b2757f7f8e 100644 --- a/labruntime/clabernetes/save.go +++ b/labruntime/clabernetes/save.go @@ -51,10 +51,22 @@ func (r *Runtime) Save( } if len(stdout) != 0 { - log.Info("clabernetes save output", "node", nodeName, "stdout", strings.TrimSpace(string(stdout))) + log.Info( + "clabernetes save output", + "node", + nodeName, + "stdout", + strings.TrimSpace(string(stdout)), + ) } if len(stderr) != 0 { - log.Info("clabernetes save output", "node", nodeName, "stderr", strings.TrimSpace(string(stderr))) + log.Info( + "clabernetes save output", + "node", + nodeName, + "stderr", + strings.TrimSpace(string(stderr)), + ) } if rc != 0 { return nil, fmt.Errorf("save failed for clabernetes node %s/%s/%s: rc=%d", diff --git a/labruntime/clabernetes/state.go b/labruntime/clabernetes/state.go index 236e771363..57fd7adcd0 100644 --- a/labruntime/clabernetes/state.go +++ b/labruntime/clabernetes/state.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + clabconstants "github.com/srl-labs/containerlab/constants" clablabruntime "github.com/srl-labs/containerlab/labruntime" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -13,6 +14,43 @@ import ( "k8s.io/apimachinery/pkg/labels" ) +func stateFromNodeResources( + name, + namespace string, + nodeResources []unstructured.Unstructured, +) *clablabruntime.LabState { + state := &clablabruntime.LabState{ + Name: name, + Namespace: namespace, + TopologyPath: fmt.Sprintf("k8s://%s/labs/%s", namespace, name), + } + if len(nodeResources) != 0 { + state.Owner = nodeResources[0].GetLabels()[clabconstants.Owner] + if state.Owner == "" { + state.Owner = nodeResources[0].GetAnnotations()[clabconstants.Owner] + } + } + + state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeResources)) + for idx := range nodeResources { + nodeResource := &nodeResources[idx] + node := clablabruntime.NodeState{Name: nodeResource.GetName()} + node.Kind, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "kind") + node.Image, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "image") + node.State, _, _ = unstructured.NestedString(nodeResource.Object, "status", "readiness") + node.Ready = node.State == "ready" + node.LoadBalancerAddress, _, _ = unstructured.NestedString( + nodeResource.Object, + "status", + "exposedPorts", + "loadBalancerAddress", + ) + state.Nodes = append(state.Nodes, node) + } + + return state +} + func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabState) error { if state == nil || state.Name == "" { return nil @@ -109,8 +147,14 @@ func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabStat continue } - node.State = deploymentState - node.Ready = deploymentReady + // Node status is the authoritative signal that the nested containerlab node is ready. + // A launcher Deployment can be ready before containerlab has created the inner + // container. Deployment state remains the fallback for older resources without Node + // readiness, and replicas=0 is always an explicit stopped state. + if replicas == 0 || node.State == "" { + node.State = deploymentState + node.Ready = deploymentReady + } nodesByName[logicalNodeName] = node matched = true } diff --git a/tests/14-clabernetes/01-linux-lifecycle.robot b/tests/14-clabernetes/01-linux-lifecycle.robot index 75048e6a63..45a3af7250 100644 --- a/tests/14-clabernetes/01-linux-lifecycle.robot +++ b/tests/14-clabernetes/01-linux-lifecycle.robot @@ -26,6 +26,26 @@ Deploy c9s linux lab ${output} = Run Clab Command deploy -t ${topo} Should Be Equal As Integers ${output.rc} 0 +Deploy emits primary c9s resources without a Topology payload + ${nodes} = Process.Run Process + ... kubectl -n default get nodes.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... shell=True + Should Be Equal As Integers ${nodes.rc} 0 + Should Contain ${nodes.stdout} node.c9s.run/client + Should Contain ${nodes.stdout} node.c9s.run/server + + ${links} = Process.Run Process + ... kubectl -n default get links.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... shell=True + Should Be Equal As Integers ${links.rc} 0 + Should Not Be Empty ${links.stdout} + + ${topology} = Process.Run Process + ... kubectl -n default get topology.c9s.run ${lab-name} --ignore-not-found -o name + ... shell=True + Should Be Equal As Integers ${topology.rc} 0 + Should Be Empty ${topology.stdout} + Inspect c9s linux lab by topology and name ${topology_inspect} = Run Clab Command inspect -t ${topo} Should Be Equal As Integers ${topology_inspect.rc} 0 From d0fc5298dda2e47bde63bb09e7d7a91916ed5063 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Wed, 12 Aug 2026 14:28:27 +0200 Subject: [PATCH 15/21] Add per-lab namespaces to c9s runtime --- cmd/options.go | 2 + cmd/root.go | 6 + cmd/root_test.go | 24 ++ core/options_clab.go | 13 +- docs/cmd/deploy.md | 8 + docs/manual/clabernetes/runtime.md | 119 ++++++---- docs/manual/dev/test.md | 5 +- labruntime/clabernetes/clabernetes.go | 16 +- labruntime/clabernetes/clabernetes_test.go | 214 +++++++++++++++++- labruntime/clabernetes/config.go | 172 ++++++++++++++ labruntime/clabernetes/exec.go | 6 +- labruntime/clabernetes/lifecycle.go | 55 ++++- labruntime/clabernetes/nodes.go | 5 +- labruntime/runtime.go | 5 +- runtime/runtime.go | 1 + tests/14-clabernetes/01-linux-lifecycle.robot | 13 +- 16 files changed, 592 insertions(+), 72 deletions(-) diff --git a/cmd/options.go b/cmd/options.go index 1f713b0c7d..2255ef69df 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -242,6 +242,7 @@ type GlobalOptions struct { TopologyFile string VarsFiles []string TopologyName string + Namespace string Timeout time.Duration Runtime string GracefulShutdown bool @@ -266,6 +267,7 @@ func (o *GlobalOptions) toClabOptions() []clabcore.ClabOption { Debug: o.DebugCount > 0, Timeout: o.Timeout, GracefulShutdown: o.GracefulShutdown, + LabNamespace: o.Namespace, }, ), clabcore.WithDebug(o.DebugCount > 0), diff --git a/cmd/root.go b/cmd/root.go index d2a517fe73..d700110359 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -82,6 +82,12 @@ func Entrypoint() (*cobra.Command, error) { "", o.Global.TopologyName, "lab/topology name") + c.PersistentFlags().StringVar( + &o.Global.Namespace, + "namespace", + o.Global.Namespace, + "Kubernetes namespace override for the clabernetes runtime", + ) c.PersistentFlags().DurationVarP( &o.Global.Timeout, "timeout", diff --git a/cmd/root_test.go b/cmd/root_test.go index 8663b283a3..5ff4da723a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -56,6 +56,30 @@ func TestRootRequirementHelpers(t *testing.T) { } } +func TestNamespaceFlagHasNoShorthand(t *testing.T) { + optionsInstance = nil + t.Cleanup(func() { optionsInstance = nil }) + + root, err := Entrypoint() + if err != nil { + t.Fatal(err) + } + + flag := root.PersistentFlags().Lookup("namespace") + if flag == nil { + t.Fatal("--namespace flag was not registered") + } + if flag.Shorthand != "" { + t.Fatalf("--namespace shorthand = %q, want none", flag.Shorthand) + } + if err := root.PersistentFlags().Set("namespace", "default"); err != nil { + t.Fatal(err) + } + if got := GetOptions().Global.Namespace; got != "default" { + t.Fatalf("namespace option = %q, want default", got) + } +} + func TestApplyLabRuntimeDefaultTimeout(t *testing.T) { tests := []struct { name string diff --git a/core/options_clab.go b/core/options_clab.go index dddf44aee2..8509f640a0 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -139,8 +139,9 @@ func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { c.globalRuntimeName = name lr, err := clablabruntime.Init(name, clablabruntime.Config{ - Debug: rtconfig != nil && rtconfig.Debug, - Timeout: runtimeTimeout(rtconfig), + Debug: rtconfig != nil && rtconfig.Debug, + Timeout: runtimeTimeout(rtconfig), + Namespace: labRuntimeNamespace(rtconfig), }) if err != nil { return fmt.Errorf("failed to init the lab runtime: %w", err) @@ -177,6 +178,14 @@ func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { } } +func labRuntimeNamespace(config *clabruntime.RuntimeConfig) string { + if config == nil { + return "" + } + + return config.LabNamespace +} + func runtimeTimeout(rtconfig *clabruntime.RuntimeConfig) time.Duration { if rtconfig == nil { return 0 diff --git a/docs/cmd/deploy.md b/docs/cmd/deploy.md index f24077e66a..cddee13f63 100644 --- a/docs/cmd/deploy.md +++ b/docs/cmd/deploy.md @@ -257,6 +257,14 @@ A global runtime can be selected with the `--runtime | -r` flag. The possible va /// note `clabernetes` is a lab runtime, not a per-node container runtime. When it is selected, containerlab renders the topology and creates a Clabernetes `Topology` custom resource. See [Containerlab runtime](../manual/clabernetes/runtime.md) for details. + +By default, the Clabernetes runtime deploys each lab into a managed +`c9s-` namespace. Use the global `--namespace` option, which has no +short form, to target an existing namespace instead: + +```bash +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml +``` /// #### timeout diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 95be3c42ee..6d0d19895c 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -34,7 +34,7 @@ apiVersion: c9s.run/v1alpha1 kind: Node metadata: name: - namespace: + namespace: c9s- labels: c9s.run/topologyOwner: spec: @@ -45,7 +45,7 @@ apiVersion: c9s.run/v1alpha1 kind: Link metadata: name: - namespace: + namespace: c9s- spec: endpointA: {nodeName: , interfaceName: } endpointB: {nodeName: , interfaceName: } @@ -57,9 +57,9 @@ bounded as the lab grows. Each launcher pod runs containerlab inside the pod and starts the real node container there. Nodes using `network-mode: container:` share the primary node's launcher pod. -For backward compatibility, `inspect`, `destroy`, and the other lifecycle -commands can still manage older labs that have a `Topology` compatibility -resource. New runtime deployments are Node/Link-first. +For backward compatibility, all-namespace inspection and destruction still +discover older labs that have a `Topology` compatibility resource. New runtime +deployments are Node/Link-first. /// note The node containers are nested inside the launcher pods. A `docker ps` on the @@ -72,7 +72,7 @@ The c9s runtime currently supports the main lab lifecycle and node operations: | Command | c9s behavior | | ------- | ------------ | | `deploy` | creates `LauncherProfile`, `Node`, and `Link` resources and waits for readiness | -| `destroy` | deletes the lab's primitive resources and any compatibility `Topology` | +| `destroy` | deletes the lab's resources and its containerlab-managed namespace | | `inspect` | reads Node, Deployment, Pod, and service status | | `exec` | execs through the launcher pod into the nested node container | | `start` | scales node Deployments to `1` | @@ -88,13 +88,13 @@ The c9s runtime expects: - a reachable kubernetes cluster - kubernetes 1.31 or newer - Clabernetes CRDs installed in the cluster -- the Clabernetes manager running and watching the lab namespace -- a namespace that already exists for the lab +- the Clabernetes manager running and watching all lab namespaces - kubernetes RBAC allowing containerlab to manage the required resources -/// warning -The c9s runtime does not create namespaces for you. Create the target namespace -first, or select an existing namespace. +/// note +Containerlab creates a dedicated namespace for each c9s lab. The kube identity +therefore needs cluster-scoped permission to get, create, and delete namespaces +unless you select an existing namespace with `CLAB_KUBE_NAMESPACE`. /// ## Selecting the cluster @@ -111,45 +111,60 @@ You can override the kube context with: export CLAB_KUBE_CONTEXT= ``` -You can override the lab namespace with: +To deploy into an existing namespace instead of a per-lab namespace, use the +global `--namespace` option: ```bash -export CLAB_KUBE_NAMESPACE= +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml ``` -If no namespace is set with `CLAB_KUBE_NAMESPACE` or the selected kube context, -containerlab uses `default`. +For a persistent shell or automation setting, use: + +```bash +export CLAB_KUBE_NAMESPACE=default +``` + +An explicit namespace override must already exist. Containerlab places the lab +resources there but does not create, label, or delete the namespace itself. +`--namespace` takes precedence over `CLAB_KUBE_NAMESPACE`. /// tip -`CLAB_RUNTIME=clabernetes` and `CLAB_KUBE_NAMESPACE=` are often the -two variables worth exporting in shell profiles, CI jobs, or automation -environments that always target c9s. +`CLAB_RUNTIME=clabernetes` is worth exporting in shell profiles, CI jobs, or +automation environments that always target c9s. /// ## Namespace rules -For normal single-lab commands, containerlab operates in one namespace: +When `CLAB_KUBE_NAMESPACE` is unset, every lab deployed through the c9s runtime +gets a dedicated namespace named `c9s-`. For example, this command: + +```bash +containerlab --runtime clabernetes deploy -t clos.clab.yml +``` + +creates the `c9s-clos` namespace and places the lab's `LauncherProfile`, `Node`, +`Link`, ConfigMap, Deployment, Pod, Service, and PVC resources there. Inspect, +exec, start, stop, restart, save, and destroy derive the same namespace from the +lab name. -1. an internal request namespace, when the caller provides one -2. `CLAB_KUBE_NAMESPACE` -3. the namespace from the selected kube context -4. `default` +Containerlab labels namespaces it creates with the runtime and lab owner. A +normal destroy removes such a managed namespace after its lab resources are +gone. If `c9s-` existed before deploy and does not carry those +ownership labels, containerlab uses it but preserves it during destroy. -For example: +Set `--namespace` when a shared or externally managed namespace is required: ```bash -CLAB_KUBE_NAMESPACE=lab-a \ - containerlab --runtime clabernetes deploy -t topo.clab.yml +containerlab --runtime clabernetes --namespace default deploy -t clos.clab.yml ``` -creates the lab's `LauncherProfile`, `Node`, and `Link` resources in the -`lab-a` namespace when a lab with the same owner label does not already exist -there. +All single-lab lifecycle commands must use the same flag or environment +override. Because the namespace is then shared, node and other resource names +can conflict between labs. -/// warning -In c9s 0.7 and newer, the namespace is also the node-name boundary. Two labs in -the same namespace cannot both contain a node with the same name. Use a -dedicated namespace when node names could overlap. +/// warning | Lab names +`c9s-` must be a valid Kubernetes DNS label and cannot exceed 63 +characters. This leaves at most 59 characters for the lab name. /// Some commands intentionally look across namespaces: @@ -169,7 +184,7 @@ actions can still target the right lab: For example: ```text -default/clos/srl1 +c9s-clos/clos/srl1 ``` Primitive-only labs created outside containerlab are also manageable when @@ -258,8 +273,7 @@ Clabernetes exposes one. /// note `inspect --all` groups c9s Nodes by `c9s.run/topologyOwner` across all -namespaces. A single-lab -inspect uses the selected namespace. +namespaces. A single-lab inspect uses its canonical `c9s-` namespace. /// Useful kubernetes checks for the same state are: @@ -458,6 +472,7 @@ ls -la /clabernetes The kube identity used by the outer containerlab process must be able to: +- get, create, and delete namespaces when using automatic per-lab namespaces - create, get, list, watch, update, and delete c9s `Node` resources - create, get, list, watch, and delete c9s `Link` and `LauncherProfile` resources - get, list, watch, and delete compatibility `Topology` resources when older @@ -470,6 +485,9 @@ The kube identity used by the outer containerlab process must be able to: Useful checks: ```bash +kubectl auth can-i get namespaces +kubectl auth can-i create namespaces +kubectl auth can-i delete namespaces kubectl auth can-i create nodes.c9s.run -n kubectl auth can-i list nodes.c9s.run -n kubectl auth can-i update nodes.c9s.run -n @@ -506,27 +524,36 @@ echo "$CLAB_KUBE_CONTEXT" Fix the kubeconfig, context, or cluster access, then run the containerlab command again. -### Namespace does not exist +### Namespace creation fails Typical symptoms: ```text -failed to create c9s /: namespaces "" not found +failed to create c9s namespace "c9s-": ... ``` Check: ```bash -kubectl get namespace -echo "$CLAB_KUBE_NAMESPACE" -kubectl config view --minify --output 'jsonpath={..namespace}{"\n"}' +kubectl auth can-i get namespaces +kubectl auth can-i create namespaces +kubectl get namespace c9s- +``` + +Grant the selected kube identity permission to manage namespaces. You may also +pre-create the canonical namespace; containerlab will use it and will not +delete it unless it carries containerlab's runtime and lab-owner labels: + +```bash +kubectl create namespace c9s- ``` -Create the namespace or select an existing one: +With `--namespace` or `CLAB_KUBE_NAMESPACE` set, containerlab requires that +namespace to exist and never creates or deletes it: ```bash -kubectl create namespace -export CLAB_KUBE_NAMESPACE= +kubectl get namespace default +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml ``` ### CRDs are missing @@ -600,7 +627,6 @@ Check: ```bash containerlab --runtime clabernetes inspect --all kubectl get nodes.c9s.run -A -l c9s.run/topologyOwner -echo "$CLAB_KUBE_NAMESPACE" ``` If `docker ps` on the outer host is empty, that can be perfectly normal for c9s. @@ -646,7 +672,8 @@ Known differences: and are rejected with an error when the `clabernetes` runtime is selected. - Per-node `runtime: docker` or `runtime: podman` is not the same as selecting the global `clabernetes` lab runtime. -- Two c9s labs can have the same lab name in different namespaces. +- A lab name maps to one canonical `c9s-` namespace in the selected + cluster. /// note Use kubernetes and launcher-pod state as the source of truth for c9s labs: diff --git a/docs/manual/dev/test.md b/docs/manual/dev/test.md index 9d41ecc3c2..85b8f3be3e 100644 --- a/docs/manual/dev/test.md +++ b/docs/manual/dev/test.md @@ -73,8 +73,9 @@ Selecting a specific test case in a test suite is not supported, since test suit The c9s/Clabernetes Robot tests require the same Kubernetes prerequisites as the `containerlab --runtime clabernetes` command: a reachable cluster, -Clabernetes CRDs and manager installed, an existing target namespace, and RBAC -for the selected kubeconfig. +Clabernetes CRDs and manager installed, and RBAC (including namespace +management when automatic per-lab namespaces are used) for the selected +kubeconfig. To run all currently supported Clabernetes Robot tests: diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index 49368a5321..d3eaf93269 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -58,7 +58,10 @@ type Runtime struct { kubeClient kubernetes.Interface restConfig *rest.Config namespace string - timeout time.Duration + // labNamespaceOverride is set by --namespace or CLAB_KUBE_NAMESPACE. When + // empty, lab-scoped operations derive their namespace from the lab name. + labNamespaceOverride string + timeout time.Duration } func init() { @@ -86,10 +89,11 @@ func New(cfg clablabruntime.Config) (clablabruntime.LabRuntime, error) { } return &Runtime{ - client: client, - kubeClient: kubeClient, - restConfig: kubeConfig, - namespace: namespace, - timeout: cfg.Timeout, + client: client, + kubeClient: kubeClient, + restConfig: kubeConfig, + namespace: namespace, + labNamespaceOverride: configuredLabNamespace(cfg.Namespace), + timeout: cfg.Timeout, }, nil } diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index beb8af52f6..ecd31886b3 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -93,6 +93,211 @@ func TestCleanTarPath(t *testing.T) { } } +func TestNamespaceForLab(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labName string + explicit string + configuredOverride string + want string + wantError bool + }{ + { + name: "derived from lab name", + labName: "clos", + want: "c9s-clos", + }, + { + name: "configured namespace override", + labName: "clos", + configuredOverride: "default", + want: "default", + }, + { + name: "explicit namespace for discovered lab", + labName: "clos", + explicit: "legacy-namespace", + configuredOverride: "default", + want: "legacy-namespace", + }, + { + name: "invalid derived namespace", + labName: strings.Repeat("a", 60), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := &Runtime{labNamespaceOverride: tt.configuredOverride} + got, err := r.namespaceForLab(tt.labName, tt.explicit) + if tt.wantError { + if err == nil { + t.Fatalf("namespaceForLab(%q, %q) returned no error", tt.labName, tt.explicit) + } + + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("namespaceForLab(%q, %q) = %q, want %q", + tt.labName, tt.explicit, got, tt.want) + } + }) + } +} + +func TestConfiguredLabNamespacePrecedence(t *testing.T) { + t.Setenv(envNamespace, "from-environment") + + if got := configuredLabNamespace("from-flag"); got != "from-flag" { + t.Fatalf("configured namespace = %q, want flag value", got) + } + if got := configuredLabNamespace(""); got != "from-environment" { + t.Fatalf("configured namespace = %q, want environment value", got) + } + + t.Setenv(envNamespace, "") + if got := configuredLabNamespace(""); got != "" { + t.Fatalf("configured namespace = %q, want automatic namespace selection", got) + } +} + +func TestDeployUsesNamespaceOverrideWithoutManagingIt(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + r.labNamespaceOverride = defaultNamespace + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Namespace != defaultNamespace { + t.Fatalf("deployed namespace = %q, want %q", state.Namespace, defaultNamespace) + } + _ = getTestPrimitive(t, r, nodeGVR, defaultNamespace, "node1") + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + if _, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + defaultNamespace, + metav1.GetOptions{}, + ); err != nil { + t.Fatalf("namespace override was deleted: %v", err) + } +} + +func TestDeployCreatesAndDestroyRemovesDedicatedLabNamespace(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Namespace != "c9s-lab1" { + t.Fatalf("deployed namespace = %q, want c9s-lab1", state.Namespace) + } + + namespace, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ) + if err != nil { + t.Fatal(err) + } + if namespace.Labels[labelRuntime] != clabernetesAppValue || + namespace.Labels[labelTopologyOwner] != "lab1" { + t.Fatalf("unexpected namespace labels: %v", namespace.Labels) + } + _ = getTestPrimitive(t, r, nodeGVR, "c9s-lab1", "node1") + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + _, err = r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected managed lab namespace to be deleted, got %v", err) + } +} + +func TestDestroyPreservesPreexistingLabNamespace(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "c9s-lab1", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + }} + r := newTestRuntime(node) + _, err := r.kubeClient.CoreV1().Namespaces().Create( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "c9s-lab1"}}, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + if _, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ); err != nil { + t.Fatalf("pre-existing namespace was deleted: %v", err) + } +} + func TestSavedFilesFromTarSkipsUnsafeEntries(t *testing.T) { t.Parallel() @@ -957,8 +1162,13 @@ func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { }, runtimeObjects..., ), - kubeClient: kubefake.NewSimpleClientset(), - namespace: defaultNamespace, + kubeClient: kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: defaultNamespace}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-ns"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-a"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-b"}}, + ), + namespace: defaultNamespace, } } diff --git a/labruntime/clabernetes/config.go b/labruntime/clabernetes/config.go index 21ca0b27be..4e2fa37d38 100644 --- a/labruntime/clabernetes/config.go +++ b/labruntime/clabernetes/config.go @@ -1,14 +1,29 @@ package clabernetes import ( + "context" "fmt" "os" "time" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" ) +const labNamespacePrefix = "c9s-" + +func configuredLabNamespace(namespace string) string { + if namespace != "" { + return namespace + } + + return os.Getenv(envNamespace) +} + func (r *Runtime) namespaceFor(namespace string) string { if namespace != "" { return namespace @@ -19,6 +34,163 @@ func (r *Runtime) namespaceFor(namespace string) string { return defaultNamespace } +func canonicalNamespaceForLab(name string) (string, error) { + namespace := labNamespacePrefix + name + if errs := validation.IsDNS1123Label(namespace); len(errs) != 0 { + return "", fmt.Errorf( + "cannot derive Kubernetes namespace for c9s lab %q: %s", + name, + errs[0], + ) + } + + return namespace, nil +} + +func (r *Runtime) namespaceForLab(name, namespace string) (string, error) { + if namespace != "" { + return namespace, nil + } + if r.labNamespaceOverride != "" { + return r.labNamespaceOverride, nil + } + + return canonicalNamespaceForLab(name) +} + +func (r *Runtime) ensureLabNamespace( + ctx context.Context, + name, + namespace string, + managed bool, +) (bool, error) { + namespaces := r.kubeClient.CoreV1().Namespaces() + existing, err := namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if err == nil { + if owner := existing.Labels[labelTopologyOwner]; managed && owner != "" && owner != name { + return false, fmt.Errorf( + "c9s namespace %q belongs to lab %q, not %q", + namespace, + owner, + name, + ) + } + + return false, nil + } + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if !managed { + return false, fmt.Errorf( + "c9s namespace override %q does not exist; create it before deploying the lab", + namespace, + ) + } + + _, err = namespaces.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Labels: map[string]string{ + labelRuntime: clabernetesAppValue, + labelTopologyOwner: name, + }, + }, + }, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, err = namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf( + "failed to get concurrently created c9s namespace %q: %w", + namespace, + err, + ) + } + if owner := existing.Labels[labelTopologyOwner]; owner != "" && owner != name { + return false, fmt.Errorf( + "c9s namespace %q belongs to lab %q, not %q", + namespace, + owner, + name, + ) + } + + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to create c9s namespace %q: %w", namespace, err) + } + + return true, nil +} + +func (r *Runtime) deleteManagedLabNamespace( + ctx context.Context, + name, + namespace string, +) (bool, error) { + canonicalNamespace, err := canonicalNamespaceForLab(name) + if err != nil || namespace != canonicalNamespace { + return false, nil + } + + namespaces := r.kubeClient.CoreV1().Namespaces() + existing, err := namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if existing.Labels[labelRuntime] != clabernetesAppValue || + existing.Labels[labelTopologyOwner] != name { + return false, nil + } + + err = namespaces.Delete(ctx, namespace, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to delete c9s namespace %q: %w", namespace, err) + } + + return true, nil +} + +func (r *Runtime) waitNamespaceDeleted( + ctx context.Context, + namespace string, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + for { + _, err := r.kubeClient.CoreV1().Namespaces().Get( + waitCtx, + namespace, + metav1.GetOptions{}, + ) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + + select { + case <-waitCtx.Done(): + return fmt.Errorf( + "timed out after %s waiting for c9s namespace %q to be deleted", + r.timeoutFor(timeout), + namespace, + ) + case <-time.After(pollInterval): + } + } +} + func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { if timeout > 0 { return timeout diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go index f3a6f52272..ca6beb068a 100644 --- a/labruntime/clabernetes/exec.go +++ b/labruntime/clabernetes/exec.go @@ -58,7 +58,11 @@ func (r *Runtime) launcherPod( namespace, nodeName string, ) (*corev1.Pod, error) { - namespace = r.namespaceFor(namespace) + var err error + namespace, err = r.namespaceForLab(name, namespace) + if err != nil { + return nil, err + } launchers, err := r.launcherNodeNames(ctx, name, namespace, []string{nodeName}) if err != nil { return nil, err diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index 1ddaa46f9a..80e7a66d0d 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -30,10 +30,13 @@ func (r *Runtime) Deploy( return nil, fmt.Errorf("rendered containerlab topology is required") } - namespace := r.namespaceFor(req.Namespace) + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } topologyResource := r.client.Resource(topologyGVR).Namespace(namespace) - _, err := topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) + _, err = topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) switch { case apierrors.IsNotFound(err): // Expected for the primary Node/Link path. @@ -76,7 +79,25 @@ func (r *Runtime) Deploy( stagePrimitiveNodeDeployments(primitives) } + managedNamespace := req.Namespace == "" && r.labNamespaceOverride == "" + namespaceCreated, err := r.ensureLabNamespace( + ctx, + req.Name, + namespace, + managedNamespace, + ) + if err != nil { + return nil, err + } + cleanupNamespace := func() { + if namespaceCreated { + _, _ = r.deleteManagedLabNamespace(ctx, req.Name, namespace) + } + } + if err = r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + cleanupNamespace() + return nil, err } @@ -84,6 +105,7 @@ func (r *Runtime) Deploy( createdNodes, created, err := r.createPrimitiveResources(ctx, namespace, primitives) if err != nil { r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() return nil, err } @@ -96,6 +118,7 @@ func (r *Runtime) Deploy( ); err != nil { r.deleteCreatedPrimitiveResources(ctx, namespace, created) r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() return nil, err } @@ -109,6 +132,7 @@ func (r *Runtime) Deploy( ); err != nil { r.deleteCreatedPrimitiveResources(ctx, namespace, created) r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() return nil, err } @@ -116,6 +140,7 @@ func (r *Runtime) Deploy( if err = r.enablePrimitiveNodeDeployments(ctx, namespace, createdNodes); err != nil { r.deleteCreatedPrimitiveResources(ctx, namespace, created) r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() return nil, err } @@ -147,7 +172,10 @@ func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest return fmt.Errorf("topology name is required") } - namespace := r.namespaceFor(req.Namespace) + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return err + } selector := labels.Set{labelTopologyOwner: req.Name}.String() log.Info("Deleting clabernetes lab resources", "name", req.Name, "namespace", namespace) @@ -155,7 +183,7 @@ func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest var deleteErrors []error // Delete the compatibility owner first when present so it cannot recreate compiler output // while the primitive resources are being removed. - err := r.client.Resource(topologyGVR).Namespace(namespace). + err = r.client.Resource(topologyGVR).Namespace(namespace). Delete(ctx, req.Name, metav1.DeleteOptions{}) if err != nil && !apierrors.IsNotFound(err) { deleteErrors = append(deleteErrors, fmt.Errorf( @@ -211,11 +239,21 @@ func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest return errors.Join(deleteErrors...) } - if !req.Wait { + if req.Wait { + if err := r.waitDeleted(ctx, req.Name, namespace, req.Timeout); err != nil { + return err + } + } + + namespaceDeleted, err := r.deleteManagedLabNamespace(ctx, req.Name, namespace) + if err != nil { + return err + } + if !req.Wait || !namespaceDeleted { return nil } - return r.waitDeleted(ctx, req.Name, namespace, req.Timeout) + return r.waitNamespaceDeleted(ctx, namespace, req.Timeout) } func (r *Runtime) Inspect( @@ -226,7 +264,10 @@ func (r *Runtime) Inspect( return nil, fmt.Errorf("topology name is required") } - namespace := r.namespaceFor(req.Namespace) + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } obj, err := r.client.Resource(topologyGVR).Namespace(namespace). Get(ctx, req.Name, metav1.GetOptions{}) if err != nil && !apierrors.IsNotFound(err) { diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go index bcfe9e6327..d48531d0a7 100644 --- a/labruntime/clabernetes/nodes.go +++ b/labruntime/clabernetes/nodes.go @@ -84,7 +84,10 @@ func (r *Runtime) targetNodes( return nil, "", fmt.Errorf("topology name is required") } - namespace := r.namespaceFor(req.Namespace) + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, "", err + } nodes, err := r.nodesForTopology(ctx, req.Name, namespace) if err != nil { return nil, "", err diff --git a/labruntime/runtime.go b/labruntime/runtime.go index 01fa59ac7f..f99ed12d95 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -13,8 +13,9 @@ const ( ) type Config struct { - Debug bool - Timeout time.Duration + Debug bool + Timeout time.Duration + Namespace string } type DeployRequest struct { diff --git a/runtime/runtime.go b/runtime/runtime.go index 937893af51..3c2f6c91e3 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -155,6 +155,7 @@ type RuntimeConfig struct { GracefulShutdown bool Debug bool KeepMgmtNet bool + LabNamespace string VerifyLinkParams *clablinks.VerifyLinkParams } diff --git a/tests/14-clabernetes/01-linux-lifecycle.robot b/tests/14-clabernetes/01-linux-lifecycle.robot index 45a3af7250..95a264e67f 100644 --- a/tests/14-clabernetes/01-linux-lifecycle.robot +++ b/tests/14-clabernetes/01-linux-lifecycle.robot @@ -14,6 +14,7 @@ ${runtime} clabernetes ${lab-name} c9s-linux-lifecycle ${lab-file} 01-linux-lifecycle.clab.yml ${topo} ${CURDIR}/${lab-file} +${lab-namespace} c9s-${lab-name} ${client-label} clab-node-name\=client ${events-log} /tmp/clab-c9s-events.log ${events-err} /tmp/clab-c9s-events.err @@ -28,20 +29,20 @@ Deploy c9s linux lab Deploy emits primary c9s resources without a Topology payload ${nodes} = Process.Run Process - ... kubectl -n default get nodes.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... kubectl -n ${lab-namespace} get nodes.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name ... shell=True Should Be Equal As Integers ${nodes.rc} 0 Should Contain ${nodes.stdout} node.c9s.run/client Should Contain ${nodes.stdout} node.c9s.run/server ${links} = Process.Run Process - ... kubectl -n default get links.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... kubectl -n ${lab-namespace} get links.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name ... shell=True Should Be Equal As Integers ${links.rc} 0 Should Not Be Empty ${links.stdout} ${topology} = Process.Run Process - ... kubectl -n default get topology.c9s.run ${lab-name} --ignore-not-found -o name + ... kubectl -n ${lab-namespace} get topology.c9s.run ${lab-name} --ignore-not-found -o name ... shell=True Should Be Equal As Integers ${topology.rc} 0 Should Be Empty ${topology.stdout} @@ -112,6 +113,12 @@ Destroy c9s linux lab ${inspect_all} = Run Clab Command inspect --all Should Not Contain ${inspect_all.stdout} ${lab-name} + ${namespace} = Process.Run Process + ... kubectl get namespace ${lab-namespace} --ignore-not-found -o name + ... shell=True + Should Be Equal As Integers ${namespace.rc} 0 + Should Be Empty ${namespace.stdout} + *** Keywords *** Setup From dfca912cc31dbbd611e3ce23be47a1094f00fc60 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Wed, 12 Aug 2026 15:13:32 +0200 Subject: [PATCH 16/21] Reconcile existing c9s labs on deploy --- core/runtime_state.go | 14 + docs/manual/clabernetes/runtime.md | 18 +- labruntime/clabernetes/clabernetes_test.go | 228 +++++++++- labruntime/clabernetes/files.go | 59 ++- labruntime/clabernetes/lifecycle.go | 112 +++-- labruntime/clabernetes/primitives.go | 3 - labruntime/clabernetes/reconcile.go | 482 +++++++++++++++++++++ labruntime/clabernetes/resources.go | 40 -- labruntime/runtime.go | 7 + 9 files changed, 863 insertions(+), 100 deletions(-) create mode 100644 labruntime/clabernetes/reconcile.go diff --git a/core/runtime_state.go b/core/runtime_state.go index 07be4dd0f8..75c36060ff 100644 --- a/core/runtime_state.go +++ b/core/runtime_state.go @@ -7,6 +7,7 @@ import ( "strings" clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" ) @@ -97,6 +98,19 @@ func (c *CLab) runtimeNodeGroups( // NeedsInitialDeploy reports whether the lab has no runtime state yet, i.e. whether // Deploy would perform a fresh deployment instead of reconciling a running lab. func (c *CLab) NeedsInitialDeploy(ctx context.Context) (bool, error) { + if c.LabRuntime != nil { + checker, ok := c.LabRuntime.(clablabruntime.LabExistenceChecker) + if !ok { + return false, fmt.Errorf( + "lab runtime %q cannot determine whether the lab already exists", + c.globalRuntimeName, + ) + } + exists, err := checker.LabExists(ctx, clablabruntime.InspectRequest{Name: c.Config.Name}) + + return !exists, err + } + currentNodes, err := c.runtimeNodeGroups(ctx) if err != nil { return false, err diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 6d0d19895c..9753e5697c 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -241,16 +241,14 @@ topology, then use node filtering with commands such as `start`, `stop`, `restart`, `exec`, or `save` after the lab exists. /// -Deploy is create-only. If primitive resources carrying the same -`c9s.run/topologyOwner` label, or a compatibility `Topology` with the same -name, already exist in the namespace, containerlab fails the deployment: - -```text -the '' lab has already been deployed in namespace ''. -``` - -Use `deploy --reconfigure` to replace the existing lab, or use a different lab -name or namespace when you want a separate lab: +Deploy reconciles an existing lab in place. Containerlab compiles the requested topology and +creates, updates, or removes the corresponding c9s `Node`, `Link`, `LauncherProfile`, and staged +`ConfigMap` resources. New Nodes remain staged until the complete Link set is present. Labs +created through the older compatibility `Topology` API retain that controller ownership and +have their `Topology` definition updated in place. + +Use `deploy --reconfigure` when you explicitly want to delete and recreate every resource. Use a +different lab name or namespace when you want a separate lab: ```bash containerlab --runtime clabernetes --name deploy -t topo.clab.yml diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index ecd31886b3..0afdc0ca66 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -429,7 +429,7 @@ func TestPrimitiveResourcesUseCurrentC9sAPI(t *testing.T) { } } -func TestPrimitiveResourceGroupsCreateLinksBeforeNodes(t *testing.T) { +func TestPrimitiveResourceGroupOrder(t *testing.T) { t.Parallel() set := &primitiveResourceSet{} @@ -880,6 +880,63 @@ topology: } } +func TestDeployReconcileDeletesStaleStagedConfigMaps(t *testing.T) { + t.Parallel() + + topologyDir := t.TempDir() + writeFile(t, filepath.Join(topologyDir, "configs", "node1", "startup.sh"), "#!/bin/sh\n", 0o755) + + const initialDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest + binds: + - configs/node1:/config +` + const updatedDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + topologyFile := filepath.Join(topologyDir, "lab.clab.yml") + writeFile(t, topologyFile, initialDefinition, 0o644) + + r := newTestRuntime() + req := clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyFile: topologyFile, + TopologyDefinition: []byte(initialDefinition), + Wait: false, + } + if _, err := r.Deploy(context.Background(), req); err != nil { + t.Fatal(err) + } + _ = getTestConfigMap(t, r, "lab-ns", "lab1-node1-files") + + req.TopologyDefinition = []byte(updatedDefinition) + if _, err := r.Deploy(context.Background(), req); err != nil { + t.Fatal(err) + } + _, err := r.kubeClient.CoreV1().ConfigMaps("lab-ns").Get( + context.Background(), + "lab1-node1-files", + metav1.GetOptions{}, + ) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected stale ConfigMap to be deleted, got %v", err) + } + if files, found, err := unstructured.NestedSlice( + getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1").Object, + "spec", + "filesFromConfigMap", + ); err != nil || found || len(files) != 0 { + t.Fatalf("stale node file mounts remain: found=%t files=%v err=%v", found, files, err) + } +} + func TestDeployPreservesDockerCompatibleNamesForEmptyPrefixTopology(t *testing.T) { t.Parallel() @@ -995,7 +1052,7 @@ topology: } } -func TestDeployFailsWhenTopologyAlreadyExists(t *testing.T) { +func TestDeployReconcilesCompatibilityTopology(t *testing.T) { t.Parallel() const existingDefinition = `topology: @@ -1012,22 +1069,158 @@ func TestDeployFailsWhenTopologyAlreadyExists(t *testing.T) { ` r := newTestRuntime(topologyObject("lab1", "lab-ns", "", existingDefinition)) - _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ Name: "lab1", Namespace: "lab-ns", TopologyDefinition: []byte(newDefinition), Wait: false, }) - if err == nil { - t.Fatal("expected duplicate topology deploy to fail") + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), "already been deployed in namespace 'lab-ns'") { - t.Fatalf("unexpected error: %v", err) + if state.Name != "lab1" || state.Namespace != "lab-ns" { + t.Fatalf("unexpected reconciled state: %+v", state) } obj := getTestTopology(t, r, "lab-ns", "lab1") - if got := topologyDefinition(t, obj); got != existingDefinition { - t.Fatalf("topology definition was updated to %q, want %q", got, existingDefinition) + if got := topologyDefinition(t, obj); got != newDefinition { + t.Fatalf("topology definition was updated to %q, want %q", got, newDefinition) + } +} + +func TestDeployReconcilesPrimitiveResources(t *testing.T) { + t.Parallel() + + const initialDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.20 + old-node: + kind: linux + image: alpine:3.20 + links: + - endpoints: ["node1:eth1", "old-node:eth1"] +` + const updatedDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.21 + node2: + kind: linux + image: alpine:3.21 + links: + - endpoints: ["node1:eth2", "node2:eth1"] +` + + r := newTestRuntime() + request := clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(initialDefinition), + Wait: false, + } + if _, err := r.Deploy(context.Background(), request); err != nil { + t.Fatal(err) + } + + node1 := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + node1.SetLabels(mergeDesiredMetadata(node1.GetLabels(), map[string]string{ + "example.com/preserved": "true", + labelIgnoreReconcile: "true", + })) + if err := unstructured.SetNestedField(node1.Object, "ready", "status", "readiness"); err != nil { + t.Fatal(err) + } + if _, err := r.client.Resource(nodeGVR).Namespace("lab-ns").Update( + context.Background(), + node1, + metav1.UpdateOptions{}, + ); err != nil { + t.Fatal(err) + } + + request.TopologyDefinition = []byte(updatedDefinition) + state, err := r.Deploy(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" || len(state.Nodes) != 2 { + t.Fatalf("unexpected reconciled state: %+v", state) + } + + node1 = getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if got, _, _ := unstructured.NestedString(node1.Object, "spec", "image"); got != "alpine:3.21" { + t.Fatalf("node1 image = %q, want alpine:3.21", got) + } + if got, _, _ := unstructured.NestedString(node1.Object, "status", "readiness"); got != "ready" { + t.Fatalf("node1 status readiness = %q, want preserved ready", got) + } + if node1.GetLabels()["example.com/preserved"] != "true" { + t.Fatalf("node1 extra labels were not preserved: %v", node1.GetLabels()) + } + if _, exists := node1.GetLabels()[labelIgnoreReconcile]; exists { + t.Fatalf("node1 retained stop lifecycle label: %v", node1.GetLabels()) + } + + node2 := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node2") + if _, exists := node2.GetLabels()[clabernetesconstants.LabelDisableDeployments]; exists { + t.Fatalf("new node retained deployment staging label: %v", node2.GetLabels()) + } + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "old-node") + + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) + } + if got, _, _ := unstructured.NestedString( + links.Items[0].Object, + "spec", + "endpointB", + "nodeName", + ); got != "node2" { + t.Fatalf("reconciled link endpointB = %q, want node2", got) + } +} + +func TestDeployRejectsPrimitiveResourceOwnedByAnotherLab(t *testing.T) { + t.Parallel() + + foreignNode := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "other-lab", + }, + }, + }} + r := newTestRuntime(foreignNode) + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + node1: + kind: linux + image: alpine:latest +`), + Wait: false, + }) + if err == nil || !strings.Contains(err.Error(), "belongs to another lab") { + t.Fatalf("unexpected ownership collision error: %v", err) + } + + actual := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if actual.GetLabels()[labelTopologyOwner] != "other-lab" { + t.Fatalf("foreign node ownership changed: %v", actual.GetLabels()) } } @@ -1223,6 +1416,23 @@ func getTestPrimitive( return obj } +func assertNoTestPrimitive( + t *testing.T, + r *Runtime, + gvr schema.GroupVersionResource, + namespace, + name string, +) { + t.Helper() + + _, err := r.client.Resource(gvr).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected c9s %s %s/%s not to exist, got %v", + gvr.Resource, namespace, name, err) + } +} + func topologyDefinition(t *testing.T, obj *unstructured.Unstructured) string { t.Helper() diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go index f41e3de6d0..9213f3ae0c 100644 --- a/labruntime/clabernetes/files.go +++ b/labruntime/clabernetes/files.go @@ -19,6 +19,7 @@ import ( clabutils "github.com/srl-labs/containerlab/utils" "gopkg.in/yaml.v2" corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -745,9 +746,17 @@ func (r *Runtime) applyStagedConfigMaps( ) } - configMap.ResourceVersion = existing.ResourceVersion - created, err = r.kubeClient.CoreV1().ConfigMaps(namespace). - Update(ctx, configMap, metav1.UpdateOptions{}) + updated := existing.DeepCopy() + updated.Labels = mergeDesiredMetadata(existing.Labels, configMap.Labels) + updated.Data = configMap.Data + updated.BinaryData = configMap.BinaryData + if stagedConfigMapsConform(existing, updated) { + created = existing + err = nil + } else { + created, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Update(ctx, updated, metav1.UpdateOptions{}) + } } if err != nil { return fmt.Errorf("failed to apply staged ConfigMap %s/%s: %w", @@ -763,6 +772,12 @@ func (r *Runtime) applyStagedConfigMaps( return nil } +func stagedConfigMapsConform(existing, desired *corev1.ConfigMap) bool { + return apiequality.Semantic.DeepEqual(existing.Labels, desired.Labels) && + apiequality.Semantic.DeepEqual(existing.Data, desired.Data) && + apiequality.Semantic.DeepEqual(existing.BinaryData, desired.BinaryData) +} + func stagedConfigMapObject( namespace string, topologyName string, @@ -838,6 +853,9 @@ func (r *Runtime) setStagedConfigMapNodeOwnerReferences( ) } + if apiequality.Semantic.DeepEqual(configMap.OwnerReferences, ownerReferences) { + continue + } configMap.OwnerReferences = ownerReferences if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). @@ -871,6 +889,41 @@ func (r *Runtime) deleteStagedConfigMaps( } } +func (r *Runtime) deleteStaleConfigMaps( + ctx context.Context, + namespace, + topologyName string, + desired []stagedConfigMap, +) error { + desiredNames := make(map[string]struct{}, len(desired)) + for _, staged := range desired { + desiredNames[staged.name] = struct{}{} + } + + configMaps, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: labelTopologyOwner + "=" + topologyName}, + ) + if err != nil { + return fmt.Errorf("failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, topologyName, err) + } + + for idx := range configMaps.Items { + name := configMaps.Items[idx].Name + if _, keep := desiredNames[name]; keep { + continue + } + if err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete stale staged ConfigMap %s/%s: %w", + namespace, name, err) + } + } + + return nil +} + func safeConfigMapKey(filePath string) string { parts := strings.FieldsFunc(filepath.ToSlash(filePath), func(r rune) bool { return r == '/' || r == '\\' diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index 80e7a66d0d..2e5de1a435 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -36,24 +36,20 @@ func (r *Runtime) Deploy( } topologyResource := r.client.Resource(topologyGVR).Namespace(namespace) - _, err = topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) + existingTopology, err := topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) switch { case apierrors.IsNotFound(err): + existingTopology = nil // Expected for the primary Node/Link path. case err != nil: return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", namespace, req.Name, err) - default: - return nil, duplicateTopologyError(req.Name, namespace) } - exists, err := r.primitiveLabExists(ctx, req.Name, namespace) + primitiveExists, err := r.primitiveLabExists(ctx, req.Name, namespace) if err != nil { return nil, err } - if exists { - return nil, duplicateTopologyError(req.Name, namespace) - } topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) if err != nil { @@ -70,13 +66,23 @@ func (r *Runtime) Deploy( if err := setTopologyFilesFromConfigMaps(desiredTopology, stagedConfigMaps); err != nil { return nil, err } - primitives, err := compilePrimitiveResources(desiredTopology) if err != nil { return nil, err } - if req.Wait { - stagePrimitiveNodeDeployments(primitives) + + // Compatibility Topologies are still supported for labs created by older versions. Keep + // their controller ownership intact and reconcile the definition in place. New labs and + // current primitive labs are reconciled directly through Node, Link, and LauncherProfile. + if existingTopology != nil { + return r.reconcileCompatibilityTopology( + ctx, + req, + namespace, + desiredTopology, + primitives, + stagedConfigMaps, + ) } managedNamespace := req.Namespace == "" && r.labNamespaceOverride == "" @@ -101,11 +107,23 @@ func (r *Runtime) Deploy( return nil, err } - log.Info("Creating clabernetes primitive resources", "name", req.Name, "namespace", namespace) - createdNodes, created, err := r.createPrimitiveResources(ctx, namespace, primitives) + operation := "Creating" + if primitiveExists { + operation = "Reconciling" + } + log.Info(operation+" clabernetes primitive resources", "name", req.Name, "namespace", namespace) + appliedNodes, createdNodes, createdResources, err := r.reconcilePrimitiveResources( + ctx, + namespace, + req.Name, + primitives, + ) if err != nil { - r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) - cleanupNamespace() + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } return nil, err } @@ -114,14 +132,19 @@ func (r *Runtime) Deploy( ctx, namespace, stagedConfigMaps, - createdNodes, + appliedNodes, ); err != nil { - r.deleteCreatedPrimitiveResources(ctx, namespace, created) - r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) - cleanupNamespace() + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } return nil, err } + if err = r.deleteStaleConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } if req.Wait { if err = r.waitPrimitiveLinksResolved( @@ -130,20 +153,24 @@ func (r *Runtime) Deploy( primitives.links, req.Timeout, ); err != nil { - r.deleteCreatedPrimitiveResources(ctx, namespace, created) - r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) - cleanupNamespace() + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } return nil, err } + } - if err = r.enablePrimitiveNodeDeployments(ctx, namespace, createdNodes); err != nil { - r.deleteCreatedPrimitiveResources(ctx, namespace, created) + if err = r.enablePrimitiveNodeDeployments(ctx, namespace, createdNodes); err != nil { + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) cleanupNamespace() - - return nil, err } + + return nil, err } if !req.Wait { @@ -157,16 +184,6 @@ func (r *Runtime) Deploy( return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) } -func duplicateTopologyError(name, namespace string) error { - return fmt.Errorf( - "the '%s' lab has already been deployed in namespace '%s'. "+ - "Destroy the lab before deploying a lab with the same name, "+ - "or use '--reconfigure' to redeploy it", - name, - namespace, - ) -} - func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { if req.Name == "" { return fmt.Errorf("topology name is required") @@ -296,6 +313,31 @@ func (r *Runtime) Inspect( return state, nil } +func (r *Runtime) LabExists( + ctx context.Context, + req clablabruntime.InspectRequest, +) (bool, error) { + if req.Name == "" { + return false, fmt.Errorf("topology name is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return false, err + } + _, err = r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + switch { + case err == nil: + return true, nil + case !apierrors.IsNotFound(err): + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + return r.primitiveLabExists(ctx, req.Name, namespace) +} + func (r *Runtime) List( ctx context.Context, req clablabruntime.ListRequest, diff --git a/labruntime/clabernetes/primitives.go b/labruntime/clabernetes/primitives.go index 54369552f1..d80d2727e6 100644 --- a/labruntime/clabernetes/primitives.go +++ b/labruntime/clabernetes/primitives.go @@ -30,9 +30,6 @@ func (s *primitiveResourceSet) groups() []primitiveResourceGroup { return []primitiveResourceGroup{ {gvr: launcherProfileGVR, kind: "LauncherProfile", objects: s.launcherProfiles}, {gvr: linkGVR, kind: "Link", objects: s.links}, - // c9s lets never-bound Links wait for their endpoint Nodes. Creating Links first ensures - // the Node controller sees the complete wiring set before it creates launcher workloads, - // avoiding partial-topology launches and the resulting Pod rollouts. {gvr: nodeGVR, kind: "Node", objects: s.nodes}, } } diff --git a/labruntime/clabernetes/reconcile.go b/labruntime/clabernetes/reconcile.go new file mode 100644 index 0000000000..64651f9ffd --- /dev/null +++ b/labruntime/clabernetes/reconcile.go @@ -0,0 +1,482 @@ +package clabernetes + +import ( + "context" + "errors" + "fmt" + "time" + + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/util/retry" +) + +// reconcilePrimitiveResources converges the directly managed c9s resources on the compiled +// topology. New Nodes are staged until the complete Link set is present, preventing their +// launchers from starting against a partially reconciled wiring set. +func (r *Runtime) reconcilePrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, +) ( + map[string]*unstructured.Unstructured, + map[string]*unstructured.Unstructured, + []createdPrimitiveResource, + error, +) { + existing, err := r.primitiveResourceInventory(ctx, namespace) + if err != nil { + return nil, nil, nil, err + } + if err := validatePrimitiveResourceOwnership(existing, desired, topologyName, namespace); err != nil { + return nil, nil, nil, err + } + + appliedNodes := map[string]*unstructured.Unstructured{} + createdNodes := map[string]*unstructured.Unstructured{} + created := []createdPrimitiveResource{} + + // Profiles must exist before Nodes can resolve them. + for _, profile := range desired.launcherProfiles { + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + launcherProfileGVR, + "LauncherProfile", + profile, + false, + ) + if err != nil { + return nil, nil, created, err + } + if wasCreated { + created = append(created, createdPrimitiveResource{ + gvr: launcherProfileGVR, name: actual.GetName(), + }) + } + } + + // Materialize new Node identities first, but keep their launcher deployments disabled until + // every desired Link has been created or updated. + for _, node := range desired.nodes { + if existing[nodeGVR][node.GetName()] != nil { + continue + } + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + nodeGVR, + "Node", + node, + true, + ) + if err != nil { + return nil, nil, created, err + } + appliedNodes[actual.GetName()] = actual + if wasCreated { + createdNodes[actual.GetName()] = actual + created = append(created, createdPrimitiveResource{gvr: nodeGVR, name: actual.GetName()}) + } + } + + for _, link := range desired.links { + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + linkGVR, + "Link", + link, + false, + ) + if err != nil { + return nil, nil, created, err + } + if wasCreated { + created = append(created, createdPrimitiveResource{gvr: linkGVR, name: actual.GetName()}) + } + } + + // Existing Nodes are updated only after the full desired Link set is present. This also + // clears lifecycle staging/stop labels so deploy converges stopped or interrupted labs. + for _, node := range desired.nodes { + if existing[nodeGVR][node.GetName()] == nil { + continue + } + actual, _, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + nodeGVR, + "Node", + node, + false, + ) + if err != nil { + return nil, nil, created, err + } + appliedNodes[actual.GetName()] = actual + } + + if err := r.deleteStalePrimitiveResources(ctx, namespace, topologyName, desired); err != nil { + return nil, nil, created, err + } + + return appliedNodes, createdNodes, created, nil +} + +func (r *Runtime) primitiveResourceInventory( + ctx context.Context, + namespace string, +) (map[schema.GroupVersionResource]map[string]*unstructured.Unstructured, error) { + result := map[schema.GroupVersionResource]map[string]*unstructured.Unstructured{} + for _, gvr := range []schema.GroupVersionResource{launcherProfileGVR, linkGVR, nodeGVR} { + list, err := r.client.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list c9s %s in namespace %s: %w", + gvr.Resource, namespace, err) + } + + result[gvr] = make(map[string]*unstructured.Unstructured, len(list.Items)) + for idx := range list.Items { + obj := &list.Items[idx] + // Include resources from other labs so ownership validation can reject same-named + // collisions instead of overwriting them. + result[gvr][obj.GetName()] = obj + } + } + + return result, nil +} + +func validatePrimitiveResourceOwnership( + existing map[schema.GroupVersionResource]map[string]*unstructured.Unstructured, + desired *primitiveResourceSet, + topologyName, + namespace string, +) error { + for _, group := range desired.groups() { + for _, obj := range group.objects { + actual := existing[group.gvr][obj.GetName()] + if actual == nil || actual.GetLabels()[labelTopologyOwner] == topologyName { + continue + } + + return fmt.Errorf( + "c9s %s %s/%s already exists and belongs to another lab", + group.kind, + namespace, + obj.GetName(), + ) + } + } + + return nil +} + +func (r *Runtime) reconcilePrimitiveResource( + ctx context.Context, + namespace, + topologyName string, + gvr schema.GroupVersionResource, + kind string, + desired *unstructured.Unstructured, + stageNewNode bool, +) (*unstructured.Unstructured, bool, error) { + resource := r.client.Resource(gvr).Namespace(namespace) + var actual *unstructured.Unstructured + created := false + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, err := resource.Get(ctx, desired.GetName(), metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + toCreate := desired.DeepCopy() + if stageNewNode { + setPrimitiveNodeDeploymentStaged(toCreate, true) + } + actual, err = resource.Create(ctx, toCreate, metav1.CreateOptions{}) + if err == nil { + created = true + return nil + } + if apierrors.IsAlreadyExists(err) { + return apierrors.NewConflict(gvr.GroupResource(), desired.GetName(), err) + } + + return err + } + if err != nil { + return err + } + if existing.GetLabels()[labelTopologyOwner] != topologyName { + return fmt.Errorf("c9s %s %s/%s already exists and belongs to another lab", + kind, namespace, desired.GetName()) + } + + updated := reconciledPrimitiveObject(existing, desired, stageNewNode) + if primitiveObjectsConform(existing, updated) { + actual = existing + return nil + } + + actual, err = resource.Update(ctx, updated, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, false, fmt.Errorf("failed to reconcile c9s %s %s/%s: %w", + kind, namespace, desired.GetName(), err) + } + + return actual, created, nil +} + +func reconciledPrimitiveObject( + existing, + desired *unstructured.Unstructured, + stageNode bool, +) *unstructured.Unstructured { + updated := existing.DeepCopy() + if desiredSpec, ok := desired.Object["spec"]; ok { + updated.Object["spec"] = k8sruntime.DeepCopyJSONValue(desiredSpec) + } else { + delete(updated.Object, "spec") + } + + updated.SetLabels(mergeDesiredMetadata(existing.GetLabels(), desired.GetLabels())) + updated.SetAnnotations(mergeDesiredMetadata(existing.GetAnnotations(), desired.GetAnnotations())) + deleteLifecycleLabels(updated) + if stageNode { + setPrimitiveNodeDeploymentStaged(updated, true) + } + + return updated +} + +func mergeDesiredMetadata(existing, desired map[string]string) map[string]string { + result := make(map[string]string, len(existing)+len(desired)) + for key, value := range existing { + result[key] = value + } + for key, value := range desired { + result[key] = value + } + + return result +} + +func deleteLifecycleLabels(obj *unstructured.Unstructured) { + labelsMap := obj.GetLabels() + delete(labelsMap, labelIgnoreReconcile) + delete(labelsMap, clabernetesconstants.LabelDisableDeployments) + obj.SetLabels(labelsMap) +} + +func setPrimitiveNodeDeploymentStaged(obj *unstructured.Unstructured, staged bool) { + labelsMap := obj.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + if staged { + labelsMap[clabernetesconstants.LabelDisableDeployments] = "true" + } else { + delete(labelsMap, clabernetesconstants.LabelDisableDeployments) + } + obj.SetLabels(labelsMap) +} + +func primitiveObjectsConform(existing, desired *unstructured.Unstructured) bool { + return apiequality.Semantic.DeepEqual(existing.Object["spec"], desired.Object["spec"]) && + apiequality.Semantic.DeepEqual(existing.GetLabels(), desired.GetLabels()) && + apiequality.Semantic.DeepEqual(existing.GetAnnotations(), desired.GetAnnotations()) +} + +func (r *Runtime) deleteStalePrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, +) error { + desiredNames := map[schema.GroupVersionResource]map[string]struct{}{} + for _, group := range desired.groups() { + desiredNames[group.gvr] = make(map[string]struct{}, len(group.objects)) + for _, obj := range group.objects { + desiredNames[group.gvr][obj.GetName()] = struct{}{} + } + } + + var deleteErrors []error + // Remove obsolete wiring before obsolete Nodes, then remove profiles after no Node can refer + // to them. + for _, group := range []primitiveResourceGroup{ + {gvr: linkGVR, kind: "Link"}, + {gvr: nodeGVR, kind: "Node"}, + {gvr: launcherProfileGVR, kind: "LauncherProfile"}, + } { + resource := r.client.Resource(group.gvr).Namespace(namespace) + list, err := resource.List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: topologyName}.String(), + }) + if err != nil { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to list c9s %s resources for lab %s/%s: %w", + group.kind, namespace, topologyName, err)) + continue + } + + for idx := range list.Items { + name := list.Items[idx].GetName() + if _, keep := desiredNames[group.gvr][name]; keep { + continue + } + if err := resource.Delete(ctx, name, metav1.DeleteOptions{}); err != nil && + !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete stale c9s %s %s/%s: %w", + group.kind, namespace, name, err)) + } + } + } + + return errors.Join(deleteErrors...) +} + +func (r *Runtime) reconcileCompatibilityTopology( + ctx context.Context, + req clablabruntime.DeployRequest, + namespace string, + desired *unstructured.Unstructured, + desiredPrimitives *primitiveResourceSet, + stagedConfigMaps []stagedConfigMap, +) (*clablabruntime.LabState, error) { + if err := r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + updated := reconciledPrimitiveObject(latest, desired, false) + if primitiveObjectsConform(latest, updated) { + return nil + } + + _, err = resource.Update(ctx, updated, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to reconcile compatibility clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if !req.Wait { + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + } + if err := r.waitCompatibilityPrimitivesReconciled( + ctx, + namespace, + req.Name, + desiredPrimitives, + req.Timeout, + ); err != nil { + return nil, err + } + if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + return nil, err + } + + nodes, err := r.nodesForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, err + } + nodesByName := make(map[string]*unstructured.Unstructured, len(nodes.Items)) + for idx := range nodes.Items { + nodesByName[nodes.Items[idx].GetName()] = &nodes.Items[idx] + } + if err := r.setStagedConfigMapNodeOwnerReferences( + ctx, + namespace, + stagedConfigMaps, + nodesByName, + ); err != nil { + return nil, err + } + if err := r.deleteStaleConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) +} + +func (r *Runtime) waitCompatibilityPrimitivesReconciled( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + for _, group := range desired.groups() { + list, err := r.primitiveResourcesForTopology( + ctx, + group.gvr, + topologyName, + namespace, + ) + if err != nil { + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, err + } + if len(list.Items) != len(group.objects) { + return false, nil + } + + actualByName := make(map[string]*unstructured.Unstructured, len(list.Items)) + for idx := range list.Items { + actualByName[list.Items[idx].GetName()] = &list.Items[idx] + } + for _, expected := range group.objects { + actual := actualByName[expected.GetName()] + if actual == nil || !apiequality.Semantic.DeepEqual( + actual.Object["spec"], + expected.Object["spec"], + ) { + return false, nil + } + } + } + + return true, nil + }) + if err == nil { + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + return fmt.Errorf("timed out after %s waiting for compatibility clabernetes topology %s/%s "+ + "resources to reconcile", r.timeoutFor(timeout), namespace, topologyName) +} diff --git a/labruntime/clabernetes/resources.go b/labruntime/clabernetes/resources.go index 44e1d7a4aa..1fa2a23143 100644 --- a/labruntime/clabernetes/resources.go +++ b/labruntime/clabernetes/resources.go @@ -79,46 +79,6 @@ type createdPrimitiveResource struct { name string } -func (r *Runtime) createPrimitiveResources( - ctx context.Context, - namespace string, - set *primitiveResourceSet, -) (map[string]*unstructured.Unstructured, []createdPrimitiveResource, error) { - createdNodes := map[string]*unstructured.Unstructured{} - created := []createdPrimitiveResource{} - - for _, group := range set.groups() { - resource := r.client.Resource(group.gvr).Namespace(namespace) - for _, desired := range group.objects { - actual, err := resource.Create(ctx, desired, metav1.CreateOptions{}) - if err != nil { - r.deleteCreatedPrimitiveResources(ctx, namespace, created) - if apierrors.IsAlreadyExists(err) { - return nil, nil, fmt.Errorf( - "c9s %s %s/%s already exists and belongs to another lab", - group.kind, - namespace, - desired.GetName(), - ) - } - - return nil, nil, fmt.Errorf("failed to create c9s %s %s/%s: %w", - group.kind, namespace, desired.GetName(), err) - } - - created = append( - created, - createdPrimitiveResource{gvr: group.gvr, name: actual.GetName()}, - ) - if group.gvr == nodeGVR { - createdNodes[actual.GetName()] = actual - } - } - } - - return createdNodes, created, nil -} - func (r *Runtime) waitPrimitiveLinksResolved( ctx context.Context, namespace string, diff --git a/labruntime/runtime.go b/labruntime/runtime.go index f99ed12d95..991ab1a0fb 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -129,6 +129,13 @@ type LabRuntime interface { StreamEvents(context.Context, EventStreamRequest) (<-chan Event, <-chan error, error) } +// LabExistenceChecker lets controller-driven runtimes report whether a lab has remote state. +// Core uses it to distinguish a fresh deployment from reconciliation without consulting the +// local container runtime. +type LabExistenceChecker interface { + LabExists(context.Context, InspectRequest) (bool, error) +} + type Initializer func(Config) (LabRuntime, error) var LabRuntimes = map[string]Initializer{} From b79d4fef852829755b9a7a81df288bb8da2e2451 Mon Sep 17 00:00:00 2001 From: Flosch62 Date: Wed, 12 Aug 2026 17:39:19 +0200 Subject: [PATCH 17/21] Harden clabernetes runtime compatibility --- cmd/deploy.go | 44 ++- cmd/exec.go | 24 +- cmd/root.go | 62 ++++ cmd/root_test.go | 55 ++++ cmd/validate.go | 24 ++ core/deploy.go | 22 ++ core/labruntime.go | 64 ++++- core/labruntime_test.go | 74 +++++ docs/manual/clabernetes/runtime.md | 79 +++++- labruntime/clabernetes/clabernetes_test.go | 280 ++++++++++++++++++- labruntime/clabernetes/files.go | 80 +++++- labruntime/clabernetes/lifecycle.go | 29 +- labruntime/clabernetes/plan.go | 311 +++++++++++++++++++++ labruntime/clabernetes/primitives.go | 8 +- labruntime/clabernetes/reconcile.go | 60 +++- labruntime/runtime.go | 32 +++ 16 files changed, 1187 insertions(+), 61 deletions(-) create mode 100644 core/labruntime_test.go create mode 100644 labruntime/clabernetes/plan.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 769adff77a..19fbb695d9 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -231,7 +232,7 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { } if o.Deploy.DryRun { - return printDryRunResult(result.Apply, o) + return printDryRunResult(result, o) } // keep stdout machine-readable for non-table formats: the reconciliation summary @@ -258,9 +259,14 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { // printDryRunResult prints the planned changes of a dry run, as JSON when requested via // the --format flag and as a table otherwise. -func printDryRunResult(result *clabcore.ApplyResult, o *Options) error { +func printDryRunResult(result *clabcore.DeployResult, o *Options) error { if o.Inspect.Format == clabconstants.FormatJSON { - b, err := json.MarshalIndent(result, "", " ") + value := any(result.Apply) + if result.RuntimePlan != nil { + value = result.RuntimePlan + } + + b, err := json.MarshalIndent(value, "", " ") if err != nil { return err } @@ -270,11 +276,41 @@ func printDryRunResult(result *clabcore.ApplyResult, o *Options) error { return nil } - printApplyResult(result) + if result.RuntimePlan != nil { + printLabRuntimePlan(result.RuntimePlan) + + return nil + } + + printApplyResult(result.Apply) return nil } +func printLabRuntimePlan(plan *clablabruntime.DeployPlan) { + log.Info("Lab runtime plan", "name", plan.LabName, "namespace", plan.Namespace) + + table := tableWriter.NewWriter() + table.SetOutputMirror(os.Stdout) + table.SetStyle(tableWriter.StyleRounded) + table.Style().Format.Header = text.FormatTitle + table.Style().Format.HeaderAlign = text.AlignCenter + table.AppendHeader(tableWriter.Row{"Action", "Kind", "Resource"}) + + for _, change := range plan.Changes { + resourceName := change.Name + if change.Namespace != "" { + resourceName = change.Namespace + "/" + resourceName + } + table.AppendRow(tableWriter.Row{change.Action, change.Kind, resourceName}) + } + if len(plan.Changes) == 0 { + table.AppendRow(tableWriter.Row{"no changes", "-", "-"}) + } + + table.Render() +} + func printApplyResult(result *clabcore.ApplyResult) { title := "Apply summary" if result.DryRun { diff --git a/cmd/exec.go b/cmd/exec.go index a836ac381c..498df84ff9 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -84,20 +84,18 @@ func execFn(_ *cobra.Command, o *Options) error { } resultCollection, err := c.Exec(ctx, o.Exec.Commands, listOptions...) - if err != nil { - return err - } - - switch outputFormat { - case clabconstants.FormatPlain: - resultCollection.Log() - case clabconstants.FormatJSON: - out, err := resultCollection.Dump(outputFormat) - if err != nil { - return fmt.Errorf("failed to print the results collection: %v", err) + if resultCollection != nil { + switch outputFormat { + case clabconstants.FormatPlain: + resultCollection.Log() + case clabconstants.FormatJSON: + out, dumpErr := resultCollection.Dump(outputFormat) + if dumpErr != nil { + return fmt.Errorf("failed to print the results collection: %v", dumpErr) + } + + fmt.Println(out) } - - fmt.Println(out) } return err diff --git a/cmd/root.go b/cmd/root.go index d700110359..df4b312bd4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -216,6 +217,23 @@ var labRuntimeUnsupportedCommands = map[string]struct{}{ "tools": {}, } +// labRuntimeUnsupportedFlags is the explicit CLI compatibility contract for controller-driven +// runtimes. A flag listed here must fail before topology parsing or remote mutation instead of +// being accepted and silently ignored by the adapter. +var labRuntimeUnsupportedFlags = map[string][]string{ //nolint:gochecknoglobals + "deploy": { + "graph", "ipv4-subnet", "ipv6-subnet", "max-workers", "network", "node-filter", + "restore", "restore-all", "skip-labdir-acl", "skip-post-deploy", "export-template", + }, + "destroy": { + "cleanup", "graceful", "keep-mgmt-net", "max-workers", "node-filter", + }, + "redeploy": { + "cleanup", "graceful", "graph", "ipv4-subnet", "ipv6-subnet", "keep-mgmt-net", + "max-workers", "network", "skip-labdir-acl", "skip-post-deploy", "export-template", + }, +} + func checkLabRuntimeCommandSupport(cobraCmd *cobra.Command, runtimeName string) error { if !clablabruntime.IsLabRuntimeName(runtimeName) { return nil @@ -228,9 +246,53 @@ func checkLabRuntimeCommandSupport(cobraCmd *cobra.Command, runtimeName string) } } + if getCommandPath(cobraCmd) == "inspect.interfaces" { + return fmt.Errorf("the %q command is not supported with lab runtime %q", + "inspect interfaces", runtimeName) + } + + var unsupported []string + for _, flagName := range labRuntimeUnsupportedFlags[getCommandPath(cobraCmd)] { + if labRuntimeFlagWasSet(cobraCmd, flagName) { + unsupported = append(unsupported, "--"+flagName) + } + } + if len(unsupported) != 0 { + sort.Strings(unsupported) + + return fmt.Errorf( + "flag(s) %s are not supported with the %q command and lab runtime %q", + strings.Join(unsupported, ", "), + cobraCmd.Name(), + runtimeName, + ) + } + return nil } +func labRuntimeFlagWasSet(cobraCmd *cobra.Command, flagName string) bool { + flag := cobraCmd.Flag(flagName) + if flag == nil { + return false + } + + if flag.Changed { + return true + } + + if v == nil { + return false + } + + commandKey := getCommandPath(cobraCmd) + "." + flagName + if v.IsSet(commandKey) { + return true + } + + return v.IsSet(flagName) +} + // getTopoFilePath finds *.clab.y*ml file in the current working directory // if the file was not specified. // If the topology file refers to a git repository, it will be cloned to the current directory. diff --git a/cmd/root_test.go b/cmd/root_test.go index 5ff4da723a..0966ed61ce 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -218,3 +218,58 @@ func TestCheckLabRuntimeCommandSupport(t *testing.T) { }) } } + +func TestCheckLabRuntimeFlagAndNestedCommandSupport(t *testing.T) { + tests := []struct { + name string + command string + flag string + flagValue string + wantErr bool + }{ + {name: "deploy dry-run supported", command: "deploy", flag: "dry-run", flagValue: "true"}, + {name: "deploy worker controls rejected", command: "deploy", flag: "max-workers", flagValue: "4", wantErr: true}, + {name: "deploy node filter rejected", command: "deploy", flag: "node-filter", flagValue: "n1", wantErr: true}, + {name: "destroy graceful rejected", command: "destroy", flag: "graceful", flagValue: "true", wantErr: true}, + {name: "destroy node filter rejected", command: "destroy", flag: "node-filter", flagValue: "n1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v = nil + t.Cleanup(func() { v = nil }) + + root := &cobra.Command{Use: "containerlab"} + command := &cobra.Command{Use: tt.command} + root.AddCommand(command) + switch tt.flag { + case "dry-run", "graceful": + command.Flags().Bool(tt.flag, false, "") + case "max-workers": + command.Flags().Uint(tt.flag, 0, "") + case "node-filter": + command.Flags().StringSlice(tt.flag, nil, "") + } + if err := command.Flags().Set(tt.flag, tt.flagValue); err != nil { + t.Fatal(err) + } + + err := checkLabRuntimeCommandSupport(command, "clabernetes") + if (err != nil) != tt.wantErr { + t.Fatalf("checkLabRuntimeCommandSupport() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + t.Run("inspect interfaces rejected", func(t *testing.T) { + root := &cobra.Command{Use: "containerlab"} + inspect := &cobra.Command{Use: "inspect"} + interfaces := &cobra.Command{Use: "interfaces"} + root.AddCommand(inspect) + inspect.AddCommand(interfaces) + + if err := checkLabRuntimeCommandSupport(interfaces, "clabernetes"); err == nil { + t.Fatal("inspect interfaces was accepted for clabernetes") + } + }) +} diff --git a/cmd/validate.go b/cmd/validate.go index 13001bca70..b99ede1987 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "github.com/charmbracelet/log" @@ -33,12 +34,35 @@ func validateFn(o *Options) error { } if c.Config.Name == "" || len(c.Nodes) == 0 { + if c.LabRuntime != nil && c.Config.Name != "" { + // Lab runtimes intentionally do not instantiate native node implementations. + if err := c.ValidateLabRuntimeTopology(context.Background()); err != nil { + return err + } + + log.Info("Topology is valid for lab runtime", "name", c.Config.Name, + "runtime", o.Global.Runtime) + + return nil + } + return fmt.Errorf( "topology file %q defines no name or nodes. likely an empty file", c.TopoPaths.TopologyFilenameBase(), ) } + if c.LabRuntime != nil { + if err := c.ValidateLabRuntimeTopology(context.Background()); err != nil { + return err + } + + log.Info("Topology is valid for lab runtime", "name", c.Config.Name, + "runtime", o.Global.Runtime) + + return nil + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/deploy.go b/core/deploy.go index 81639375ee..c114adc729 100644 --- a/core/deploy.go +++ b/core/deploy.go @@ -14,6 +14,7 @@ import ( clabcert "github.com/srl-labs/containerlab/cert" clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" @@ -26,6 +27,8 @@ type DeployResult struct { // Apply summarizes the reconciliation of an already deployed lab or the dry-run plan; // it is nil when Deploy performed a fresh full deployment. Apply *ApplyResult + // RuntimePlan is the remote resource plan produced by a controller-driven runtime dry-run. + RuntimePlan *clablabruntime.DeployPlan } // Deploy converges the lab to the requested topology. A lab without runtime state is @@ -58,6 +61,25 @@ func (c *CLab) Deploy( return &DeployResult{Containers: containers}, nil } + if c.LabRuntime != nil && options.dryRun { + planner, ok := c.LabRuntime.(clablabruntime.TopologyPlanner) + if !ok { + return nil, fmt.Errorf("lab runtime %q does not support dry-run", c.globalRuntimeName) + } + + req, err := c.labRuntimeDeployRequest() + if err != nil { + return nil, err + } + + plan, err := planner.Plan(ctx, req) + if err != nil { + return nil, err + } + + return &DeployResult{RuntimePlan: plan}, nil + } + currentNodes, err := c.runtimeNodeGroups(ctx) if err != nil { return nil, err diff --git a/core/labruntime.go b/core/labruntime.go index 6f047b66c3..e1f17fdf64 100644 --- a/core/labruntime.go +++ b/core/labruntime.go @@ -43,6 +43,28 @@ func (c *CLab) deployWithLabRuntime( } } + req, err := c.labRuntimeDeployRequest() + if err != nil { + return nil, err + } + req.Wait = true + + state, err := c.LabRuntime.Deploy(ctx, req) + if err != nil { + return nil, err + } + + return c.containersFromLabState(state), nil +} + +func (c *CLab) labRuntimeDeployRequest() (clablabruntime.DeployRequest, error) { + if c.Config.Name == "" { + return clablabruntime.DeployRequest{}, fmt.Errorf("topology name is required") + } + if len(c.renderedTopology) == 0 { + return clablabruntime.DeployRequest{}, fmt.Errorf("rendered topology is empty") + } + topologyFile := "" topologyLabDir := "" if c.TopoPaths != nil { @@ -50,23 +72,41 @@ func (c *CLab) deployWithLabRuntime( topologyLabDir = c.TopoPaths.TopologyLabDir() } - state, err := c.LabRuntime.Deploy(ctx, clablabruntime.DeployRequest{ + return clablabruntime.DeployRequest{ Name: c.Config.Name, Owner: c.labOwner(), TopologyFile: topologyFile, TopologyLabDir: topologyLabDir, TopologyDefinition: c.renderedTopology, - Wait: true, Timeout: c.timeout, - }) + }, nil +} + +// ValidateLabRuntimeTopology executes the selected runtime's compiler without mutation. +func (c *CLab) ValidateLabRuntimeTopology(ctx context.Context) error { + if c.LabRuntime == nil { + return fmt.Errorf("no lab runtime selected") + } + + validator, ok := c.LabRuntime.(clablabruntime.TopologyValidator) + if !ok { + return fmt.Errorf("lab runtime %q does not support topology validation", c.globalRuntimeName) + } + + req, err := c.labRuntimeDeployRequest() if err != nil { - return nil, err + return err } - return c.containersFromLabState(state), nil + return validator.Validate(ctx, req) } func (c *CLab) destroyWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { + if len(opts.nodeFilter) != 0 { + return fmt.Errorf("node-filter is not supported for lab runtime %q; no resources were deleted", + c.globalRuntimeName) + } + if opts.all { return c.destroyAllWithLabRuntime(ctx, opts) } @@ -188,10 +228,13 @@ func (c *CLab) execWithLabRuntime( } resultCollection := clabexec.NewExecCollection() + var execErrors []error for idx := range containers { namespace, labName, nodeName, err := labRuntimeContainerParts(containers[idx]) if err != nil { log.Warnf("exec target %s is invalid: %v", containers[idx].Names[0], err) + execErrors = append(execErrors, fmt.Errorf( + "exec target %s is invalid: %w", containers[idx].Names[0], err)) continue } @@ -204,14 +247,23 @@ func (c *CLab) execWithLabRuntime( }) if err != nil { log.Warnf("exec on %s failed: %v", containers[idx].Names[0], err) + execErrors = append(execErrors, fmt.Errorf( + "exec on %s failed: %w", containers[idx].Names[0], err)) continue } resultCollection.Add(containers[idx].Names[0], result) + if result.GetReturnCode() != 0 { + execErrors = append(execErrors, fmt.Errorf( + "exec on %s returned exit code %d", + containers[idx].Names[0], + result.GetReturnCode(), + )) + } } } - return resultCollection, nil + return resultCollection, errors.Join(execErrors...) } func (c *CLab) startNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { diff --git a/core/labruntime_test.go b/core/labruntime_test.go new file mode 100644 index 0000000000..cbb364e170 --- /dev/null +++ b/core/labruntime_test.go @@ -0,0 +1,74 @@ +package core + +import ( + "context" + "strings" + "testing" + + clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + clabtypes "github.com/srl-labs/containerlab/types" +) + +type failingExecLabRuntime struct { + clablabruntime.LabRuntime + returnCode int +} + +func (r *failingExecLabRuntime) Inspect( + context.Context, + clablabruntime.InspectRequest, +) (*clablabruntime.LabState, error) { + return &clablabruntime.LabState{ + Name: "lab1", Namespace: "lab-ns", Nodes: []clablabruntime.NodeState{{Name: "node1"}}, + }, nil +} + +func (r *failingExecLabRuntime) Exec( + _ context.Context, + req clablabruntime.ExecRequest, +) (*clabexec.ExecResult, error) { + return &clabexec.ExecResult{Cmd: req.Command, ReturnCode: r.returnCode, Stderr: "failed"}, nil +} + +func TestExecWithLabRuntimeReturnsNestedFailureAndPreservesResult(t *testing.T) { + t.Parallel() + + runtime := &failingExecLabRuntime{returnCode: 7} + c := &CLab{ + Config: &Config{Name: "lab1", Topology: clabtypes.NewTopology()}, + TopoPaths: &clabtypes.TopoPaths{}, + LabRuntime: runtime, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, + } + + results, err := c.execWithLabRuntime(context.Background(), []string{"false"}) + if err == nil || !strings.Contains(err.Error(), "exit code 7") { + t.Fatalf("execWithLabRuntime() error = %v, want exit code 7", err) + } + if results == nil { + t.Fatal("execWithLabRuntime() discarded the failed command result") + } + dumped, dumpErr := results.Dump("json") + if dumpErr != nil { + t.Fatal(dumpErr) + } + if !strings.Contains(dumped, `"return-code": 7`) { + t.Fatalf("failed result was not retained: %s", dumped) + } +} + +func TestDestroyWithLabRuntimeRejectsNodeFilterBeforeDeletion(t *testing.T) { + t.Parallel() + + c := &CLab{ + Config: &Config{Name: "lab1"}, + LabRuntime: &failingExecLabRuntime{}, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, + } + + err := c.destroyWithLabRuntime(context.Background(), &DestroyOptions{nodeFilter: []string{"node1"}}) + if err == nil || !strings.Contains(err.Error(), "no resources were deleted") { + t.Fatalf("destroyWithLabRuntime() error = %v, want safe node-filter rejection", err) + } +} diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 9753e5697c..1f8fd4529c 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -80,6 +80,8 @@ The c9s runtime currently supports the main lab lifecycle and node operations: | `restart` | restarts node Deployments | | `save` | runs `containerlab save` inside launcher pods | | `events` | watches Clabernetes resources and pods | +| `validate` | runs the strict c9s compiler without creating Kubernetes resources | +| `deploy --dry-run` | compiles and diffs Namespace, ConfigMap, LauncherProfile, Link, and Node resources without changing them | ## Requirements @@ -218,6 +220,12 @@ healthcheck, that healthcheck must also be healthy. Containerlab does not guess readiness ports or special-case kinds and images, so the same baseline works for arbitrary containerlab nodes. +Readiness is atomic for a `network-mode: container:` group. The one +launcher Pod is ready only while every nested group member satisfies the generic +readiness contract. Because every Node in the group inherits that Deployment's +readiness, a restarting secondary makes both the primary and secondary Nodes +not ready. + For an image without a Docker healthcheck, this is a process-level signal: a running network OS may still be booting services or converging protocols. Use an image-defined healthcheck or an explicit c9s TCP/SSH probe when the lab requires @@ -254,6 +262,28 @@ different lab name or namespace when you want a separate lab: containerlab --runtime clabernetes --name deploy -t topo.clab.yml ``` +A failure while waiting for a newly created lab to become ready rolls back the +resources and managed namespace created by that deployment. A timeout while +reconciling a lab that already existed retains the lab for diagnosis and a +later corrective reconciliation. + +### Validation and dry-run + +Both commands use the same strict c9s preparation path as deploy, including +extended-link normalization and local-file staging checks: + +```bash +containerlab --runtime clabernetes validate -t topo.clab.yml +containerlab --runtime clabernetes deploy --dry-run -t topo.clab.yml +containerlab --runtime clabernetes deploy --dry-run --format json -t topo.clab.yml +``` + +`validate` reports whether the topology fits the c9s runtime subset without +reading or changing lab resources. `deploy --dry-run` additionally reads the +selected cluster and reports the exact create, update, and delete plan for +Namespace, ConfigMap, LauncherProfile, Link, Node, or an older compatibility +Topology. An empty `changes` list means the deployed resources already conform. + ## Inspect Inspect works with a topology file, a lab name, or all known c9s labs: @@ -301,6 +331,11 @@ The command executes in the node container, not in the launcher pod shell. RBAC must allow `pods/exec`, and the launcher pod must be ready. /// +If any selected nested command returns nonzero, or pod exec itself fails, the +outer `containerlab exec` also returns nonzero. Successful and failed results +that were received are still printed, so automation can use both the output and +the process exit status. + ## Start, stop, and restart Node lifecycle commands operate on the kubernetes Deployments created by @@ -454,6 +489,13 @@ Per-node containerlab artifacts commonly live under: /clabernetes/clab-clabernetes-// ``` +Local startup configurations, licenses, bind sources, `env-files`, +`extras.srl-agents`, and `extras.ceos-copy-to-flash` paths are copied into +per-node ConfigMaps and projected at the paths the inner containerlab process +expects. Each staged file is currently limited to 950 KB. These projections +are snapshots taken at deploy time, not mutable host bind mounts; run deploy +again after changing a source file. + /// tip When debugging from inside a launcher pod, the usual containerlab and Docker commands are useful again: @@ -659,13 +701,40 @@ The c9s runtime is not a complete drop-in replacement for the local Docker or Podman runtime. Several containerlab features still assume local containers, local network namespaces, or direct access to the host container runtime. -Known differences: - -- `deploy --node-filter` is not supported. +The runtime divides compatibility into three categories: + +- Native-equivalent: normal point-to-point links and MTU, lifecycle operations, + node configuration, staged startup files, exec, inspect, and group-atomic + readiness. +- Documented c9s semantics: Kubernetes Service/LoadBalancer management access, + `host:` endpoints in the launcher Pod network namespace, c9s internal + cross-Pod link transport, and ConfigMap-backed local files. +- Rejected: external bridge/host pseudo-nodes, macvlan and `mgmt-net:` links, + explicit native VXLAN/stitch/dummy link types, link labels or vars that would + be discarded, native shared-management-network settings, and commands or + flags with no c9s implementation. + +Management access is not a shared management network. Each launcher has its +own nested Docker management network, so static `mgmt-ipv4`/`mgmt-ipv6` +addresses are launcher-local and are not cluster-routable between Pods. +Kubernetes Services, LoadBalancers, and DNS are the supported management access +path. Explicit topology `mgmt` settings and the `--network`, `--ipv4-subnet`, +and `--ipv6-subnet` flags are rejected to avoid implying native Docker-network +semantics. + +Known command differences: + +- `deploy --node-filter` and `destroy --node-filter` are rejected; a filtered + destroy never falls through to whole-lab deletion. +- Deploy flags `--graph`, `--max-workers`, `--skip-post-deploy`, + `--skip-labdir-acl`, `--export-template`, `--restore`, and `--restore-all` + are rejected. +- Destroy flags `--graceful`, `--cleanup`, `--keep-mgmt-net`, and + `--max-workers` are rejected. - Local Docker commands on the outer host are not authoritative for c9s labs. - Local network namespace features are not equivalent in c9s. -- `inspect interfaces` and host-side `tc` or netem operations do not have the - same local namespace access they have with Docker labs. +- `inspect interfaces` is rejected. Host-side `tc` or netem operations do not + have the same local namespace access they have with Docker labs. - `graph` and `tools` commands operate on local containers and host networking and are rejected with an error when the `clabernetes` runtime is selected. - Per-node `runtime: docker` or `runtime: podman` is not the same as selecting diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index 0afdc0ca66..aa108fb397 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -787,6 +787,9 @@ func TestDeployStagesLocalFilesFromTopology(t *testing.T) { "set / system name leaf1\n", 0o644, ) + writeFile(t, filepath.Join(topologyDir, "configs", "client2.env"), "MODE=test\n", 0o644) + writeFile(t, filepath.Join(topologyDir, "configs", "agent.yml"), "name: agent\n", 0o644) + writeFile(t, filepath.Join(topologyDir, "configs", "flash.cfg"), "hostname ceos\n", 0o644) const definition = `name: lab1 topology: @@ -797,6 +800,13 @@ topology: startup-config: configs/fabric/leaf1.cfg client2: kind: linux + env-files: + - configs/client2.env + extras: + srl-agents: + - configs/agent.yml + ceos-copy-to-flash: + - configs/flash.cfg binds: - configs/client2:/config prometheus: @@ -828,6 +838,15 @@ topology: if got := string(clientConfigMap.BinaryData["configs-client2-iperf-sh"]); got != "#!/bin/sh\n" { t.Fatalf("unexpected client2 staged file content: %q", got) } + for key, want := range map[string]string{ + "configs-client2-env": "MODE=test\n", + "configs-agent-yml": "name: agent\n", + "configs-flash-cfg": "hostname ceos\n", + } { + if got := string(clientConfigMap.BinaryData[key]); got != want { + t.Fatalf("staged %s content = %q, want %q", key, got, want) + } + } prometheusConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-prometheus-files") if got := string( @@ -851,6 +870,20 @@ topology: "configs-client2-iperf-sh", "execute", ) + for _, filePath := range []string{ + "configs/client2.env", + "configs/agent.yml", + "configs/flash.cfg", + } { + assertFileMount( + t, + getTestPrimitive(t, r, nodeGVR, "lab-ns", "client2"), + filePath, + "lab1-client2-files", + safeConfigMapKey(filePath), + "read", + ) + } assertFileMount( t, getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus"), @@ -880,6 +913,239 @@ topology: } } +func TestValidateStrictlyRejectsUnsupportedContainerlabSemantics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + definition string + want string + }{ + { + name: "management network", + definition: `mgmt: + network: shared +topology: + nodes: + node1: + kind: linux + image: alpine +`, + want: "management network", + }, + { + name: "external bridge", + definition: `topology: + nodes: + br0: + kind: bridge +`, + want: "pseudo-node", + }, + { + name: "lossy link metadata", + definition: `topology: + nodes: + node1: + kind: linux + image: alpine + node2: + kind: linux + image: alpine + links: + - endpoints: ["node1:eth1", "node2:eth1"] + labels: + purpose: test +`, + want: "link labels", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + err := r.Validate(context.Background(), clablabruntime.DeployRequest{ + Name: "strict", + Namespace: "lab-ns", + TopologyDefinition: []byte(tt.definition), + }) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Validate() error = %v, want substring %q", err, tt.want) + } + }) + } +} + +func TestPlanReportsPrimitiveDiffWithoutMutation(t *testing.T) { + t.Parallel() + + const initial = `topology: + nodes: + node1: + kind: linux + image: alpine:3 + node2: + kind: linux + image: alpine:3 + links: + - endpoints: ["node1:eth1", "node2:eth1"] +` + const changed = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + freshPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }) + if err != nil { + t.Fatal(err) + } + if len(freshPlan.Changes) != 4 { + t.Fatalf("fresh plan changes = %+v, want profile, two nodes, and link creates", freshPlan.Changes) + } + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), Wait: false, + }); err != nil { + t.Fatal(err) + } + + noOpPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }) + if err != nil { + t.Fatal(err) + } + if len(noOpPlan.Changes) != 0 { + t.Fatalf("no-op plan changes = %+v, want none", noOpPlan.Changes) + } + + changedPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(changed), + }) + if err != nil { + t.Fatal(err) + } + wantChanges := map[string]bool{ + "update/Node/node1": false, + "delete/Node/node2": false, + "delete/Link/node1-eth1-node2-eth1": false, + } + for _, change := range changedPlan.Changes { + key := string(change.Action) + "/" + change.Kind + "/" + change.Name + if _, ok := wantChanges[key]; ok { + wantChanges[key] = true + } + } + for change, found := range wantChanges { + if !found { + t.Fatalf("changed plan = %+v, missing %s", changedPlan.Changes, change) + } + } +} + +func TestPrimitiveObjectsConformIgnoresMaterializedZeroDefaults(t *testing.T) { + t.Parallel() + + desired := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{"statusProbes": map[string]any{"enabled": true}}, + }} + existing := desired.DeepCopy() + existing.Object["spec"] = map[string]any{ + "statusProbes": map[string]any{ + "enabled": true, + "probeConfiguration": map[string]any{"startupSeconds": int64(0)}, + }, + "expose": map[string]any{ + "disableAutoExpose": false, + "disableExpose": false, + "useNodeMgmtIpv4Address": false, + }, + } + + if !primitiveObjectsConform(existing, desired) { + t.Fatal("API-materialized zero/default fields were reported as drift") + } + + existing.Object["spec"].(map[string]any)["expose"].(map[string]any)["disableExpose"] = true + if primitiveObjectsConform(existing, desired) { + t.Fatal("nonzero API field was incorrectly ignored") + } +} + +func TestFreshDeployReadinessTimeoutRollsBackCreatedPrimitives(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "timeout-lab", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + node1: + kind: linux + image: alpine:3 +`), + Wait: true, + Timeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected readiness timeout") + } + + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + assertNoTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "timeout-lab") +} + +func TestReconcileReadinessTimeoutRetainsExistingLab(t *testing.T) { + t.Parallel() + + const initial = `topology: + nodes: + node1: + kind: linux + image: alpine:3 +` + const changed = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "retained-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }); err != nil { + t.Fatal(err) + } + + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "retained-lab", Namespace: "lab-ns", TopologyDefinition: []byte(changed), + Wait: true, Timeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected reconcile readiness timeout") + } + + node := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + image, _, nestedErr := unstructured.NestedString(node.Object, "spec", "image") + if nestedErr != nil { + t.Fatal(nestedErr) + } + if image != "alpine:latest" { + t.Fatalf("retained node image = %q, want reconciled image", image) + } + _ = getTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "retained-lab") +} + func TestDeployReconcileDeletesStaleStagedConfigMaps(t *testing.T) { t.Parallel() @@ -972,9 +1238,6 @@ func TestDeployExposesGNMICMetricsPortForClabernetes(t *testing.T) { const definition = `name: st prefix: "" -mgmt: - network: st - ipv4-subnet: 172.20.20.0/24 topology: nodes: leaf1: @@ -988,7 +1251,7 @@ topology: kind: linux image: quay.io/prometheus/prometheus:v2.54.1 ports: - - 9090:9090 + - 9090/tcp links: - endpoints: ["leaf1:e1-1", "prometheus:eth1"] ` @@ -1033,15 +1296,6 @@ topology: if excludedNodes, found := statusProbes["excludedNodes"]; found && excludedNodes != nil { t.Fatalf("containerlab must not exclude kinds from generic readiness: %v", statusProbes) } - if got, _, _ := unstructured.NestedString( - profile.Object, - "spec", - "mgmt", - "ipv4-subnet", - ); got != "172.20.20.0/24" { - t.Fatalf("launcher profile management subnet = %q, want 172.20.20.0/24", got) - } - links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). List(context.Background(), metav1.ListOptions{}) if err != nil { diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go index 9213f3ae0c..e7edd52e2c 100644 --- a/labruntime/clabernetes/files.go +++ b/labruntime/clabernetes/files.go @@ -111,7 +111,13 @@ func stageTopologyLocalFiles( extraConfigMaps := map[string]*stagedConfigMap{} startupConfigMaps := map[string]*stagedConfigMap{} - definitionChanged := exposeClabernetesCompatibilityPorts(config) + // Always render parsed links through the c9s brief-link boundary. Previously this happened + // only as a side effect of staging an unrelated file or compatibility port, which made an + // extended link's behavior depend on whether some other field changed the definition. + definitionChanged := len(config.Topology.Links) > 0 + if exposeClabernetesCompatibilityPorts(config) { + definitionChanged = true + } nodeNames := make([]string, 0, len(config.Topology.Nodes)) for nodeName := range config.Topology.Nodes { @@ -160,6 +166,17 @@ func stageTopologyLocalFiles( ); err != nil { return nil, nil, "", err } + + if err := stageAdditionalNodeFiles( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } } } @@ -281,7 +298,7 @@ func exposeClabernetesCompatibilityPorts(config *clabRuntimeConfig) bool { nodeDefinition.Ports = append( nodeDefinition.Ports, - fmt.Sprintf("%d:%d/tcp", gnmicPrometheusPort, gnmicPrometheusPort), + fmt.Sprintf("%d/tcp", gnmicPrometheusPort), ) definitionChanged = true } @@ -452,6 +469,65 @@ func stageBindFiles( return nil } +// stageAdditionalNodeFiles covers path-bearing fields that containerlab normally resolves on +// the machine running the CLI. A remote launcher cannot see those paths unless the adapter copies +// them into the node's ConfigMap-backed file projection first. +func stageAdditionalNodeFiles( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + pathsByField := map[string][]string{ + "env-files": config.Topology.GetNodeEnvFiles(nodeName), + } + + if extras := config.Topology.GetNodeExtras(nodeName); extras != nil { + pathsByField["extras.srl-agents"] = extras.SRLAgents + pathsByField["extras.ceos-copy-to-flash"] = extras.CeosCopyToFlash + } + + fieldNames := make([]string, 0, len(pathsByField)) + for fieldName := range pathsByField { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + + for _, fieldName := range fieldNames { + for _, sourcePath := range pathsByField[fieldName] { + if strings.TrimSpace(sourcePath) == "" { + continue + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + if err := stageSourcePathIntoConfigMap( + configMap, + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir, + ); err != nil { + return fmt.Errorf( + "failed staging %s path %q for node %q: %w", + fieldName, + sourcePath, + nodeName, + err, + ) + } + } + } + + return nil +} + func stageSourcePathIntoConfigMap( configMap *stagedConfigMap, sourcePath, diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index 2e5de1a435..e012c6855a 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -51,25 +51,13 @@ func (r *Runtime) Deploy( return nil, err } - topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) - if err != nil { - return nil, err - } - - desiredTopology := topologyObject( - req.Name, - namespace, - req.Owner, - string(topologyDefinition), - topologyWithNaming(naming), - ) - if err := setTopologyFilesFromConfigMaps(desiredTopology, stagedConfigMaps); err != nil { - return nil, err - } - primitives, err := compilePrimitiveResources(desiredTopology) + prepared, err := prepareDesiredDeployment(req, namespace) if err != nil { return nil, err } + desiredTopology := prepared.topology + stagedConfigMaps := prepared.configMaps + primitives := prepared.primitives // Compatibility Topologies are still supported for labs created by older versions. Keep // their controller ownership intact and reconcile the definition in place. New labs and @@ -178,6 +166,15 @@ func (r *Runtime) Deploy( } if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + // A fresh deployment is transactional: timeout/failure removes only resources this + // operation created. A failed reconciliation retains the existing lab for diagnosis and + // recovery because rolling it back would destroy prior working state. + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + return nil, err } diff --git a/labruntime/clabernetes/plan.go b/labruntime/clabernetes/plan.go new file mode 100644 index 0000000000..6b86f10385 --- /dev/null +++ b/labruntime/clabernetes/plan.go @@ -0,0 +1,311 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type preparedDeployment struct { + topology *unstructured.Unstructured + configMaps []stagedConfigMap + primitives *primitiveResourceSet +} + +func prepareDesiredDeployment( + req clablabruntime.DeployRequest, + namespace string, +) (*preparedDeployment, error) { + topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) + if err != nil { + return nil, err + } + + desiredTopology := topologyObject( + req.Name, + namespace, + req.Owner, + string(topologyDefinition), + topologyWithNaming(naming), + ) + if err := setTopologyFilesFromConfigMaps(desiredTopology, stagedConfigMaps); err != nil { + return nil, err + } + + primitives, err := compilePrimitiveResources(desiredTopology) + if err != nil { + return nil, err + } + + return &preparedDeployment{ + topology: desiredTopology, + configMaps: stagedConfigMaps, + primitives: primitives, + }, nil +} + +// Validate compiles the strict containerlab/c9s subset and stages all local-path inputs in +// memory. It deliberately performs no Kubernetes reads or writes. +func (r *Runtime) Validate(_ context.Context, req clablabruntime.DeployRequest) error { + if req.Name == "" { + return fmt.Errorf("topology name is required") + } + if len(req.TopologyDefinition) == 0 { + return fmt.Errorf("rendered containerlab topology is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return err + } + + _, err = prepareDesiredDeployment(req, namespace) + + return err +} + +// Plan compiles the strict c9s topology and returns the complete primitive/configuration diff +// against the running cluster without changing any resource. +func (r *Runtime) Plan( + ctx context.Context, + req clablabruntime.DeployRequest, +) (*clablabruntime.DeployPlan, error) { + if err := r.Validate(ctx, req); err != nil { + return nil, err + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } + prepared, err := prepareDesiredDeployment(req, namespace) + if err != nil { + return nil, err + } + + plan := &clablabruntime.DeployPlan{ + LabName: req.Name, Namespace: namespace, Changes: []clablabruntime.ResourceChange{}, + } + namespaceExists, err := r.planNamespace(ctx, req, namespace, plan) + if err != nil { + return nil, err + } + if !namespaceExists { + appendAllDesiredCreates(plan, namespace, prepared) + sortDeployPlan(plan) + + return plan, nil + } + + existingTopology, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + if err == nil { + updated := reconciledPrimitiveObject(existingTopology, prepared.topology, false) + if !primitiveObjectsConform(existingTopology, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, "Topology", namespace, req.Name) + } + if err := r.planConfigMaps(ctx, namespace, req.Name, prepared.configMaps, plan); err != nil { + return nil, err + } + sortDeployPlan(plan) + + return plan, nil + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if err := r.planPrimitiveResources(ctx, namespace, req.Name, prepared.primitives, plan); err != nil { + return nil, err + } + if err := r.planConfigMaps(ctx, namespace, req.Name, prepared.configMaps, plan); err != nil { + return nil, err + } + + sortDeployPlan(plan) + + return plan, nil +} + +func (r *Runtime) planNamespace( + ctx context.Context, + req clablabruntime.DeployRequest, + namespace string, + plan *clablabruntime.DeployPlan, +) (bool, error) { + existing, err := r.kubeClient.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + managed := req.Namespace == "" && r.labNamespaceOverride == "" + if err == nil { + if owner := existing.Labels[labelTopologyOwner]; managed && owner != "" && owner != req.Name { + return false, fmt.Errorf("c9s namespace %q belongs to lab %q, not %q", + namespace, owner, req.Name) + } + + return true, nil + } + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if !managed { + return false, fmt.Errorf( + "c9s namespace override %q does not exist; create it before deploying the lab", + namespace, + ) + } + + appendPlanChange(plan, clablabruntime.ChangeCreate, "Namespace", "", namespace) + + return false, nil +} + +func appendAllDesiredCreates( + plan *clablabruntime.DeployPlan, + namespace string, + prepared *preparedDeployment, +) { + for _, group := range prepared.primitives.groups() { + for _, obj := range group.objects { + appendPlanChange(plan, clablabruntime.ChangeCreate, group.kind, namespace, obj.GetName()) + } + } + for _, configMap := range prepared.configMaps { + appendPlanChange(plan, clablabruntime.ChangeCreate, "ConfigMap", namespace, configMap.name) + } +} + +func (r *Runtime) planPrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, + plan *clablabruntime.DeployPlan, +) error { + existing, err := r.primitiveResourceInventory(ctx, namespace) + if err != nil { + return err + } + if err := validatePrimitiveResourceOwnership(existing, desired, topologyName, namespace); err != nil { + return err + } + + desiredNames := map[schema.GroupVersionResource]map[string]struct{}{} + for _, group := range desired.groups() { + desiredNames[group.gvr] = map[string]struct{}{} + for _, obj := range group.objects { + desiredNames[group.gvr][obj.GetName()] = struct{}{} + actual := existing[group.gvr][obj.GetName()] + if actual == nil { + appendPlanChange(plan, clablabruntime.ChangeCreate, group.kind, namespace, obj.GetName()) + continue + } + + updated := reconciledPrimitiveObject(actual, obj, false) + if !primitiveObjectsConform(actual, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, group.kind, namespace, obj.GetName()) + } + } + } + + for _, group := range []primitiveResourceGroup{ + {gvr: linkGVR, kind: "Link"}, + {gvr: nodeGVR, kind: "Node"}, + {gvr: launcherProfileGVR, kind: "LauncherProfile"}, + } { + for name, actual := range existing[group.gvr] { + if actual.GetLabels()[labelTopologyOwner] != topologyName { + continue + } + if _, keep := desiredNames[group.gvr][name]; !keep { + appendPlanChange(plan, clablabruntime.ChangeDelete, group.kind, namespace, name) + } + } + } + + return nil +} + +func (r *Runtime) planConfigMaps( + ctx context.Context, + namespace, + topologyName string, + desired []stagedConfigMap, + plan *clablabruntime.DeployPlan, +) error { + existingList, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: labels.Set{labelTopologyOwner: topologyName}.String()}, + ) + if err != nil { + return fmt.Errorf("failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, topologyName, err) + } + existing := make(map[string]*corev1.ConfigMap, len(existingList.Items)) + for idx := range existingList.Items { + existing[existingList.Items[idx].Name] = &existingList.Items[idx] + } + + desiredNames := make(map[string]struct{}, len(desired)) + for _, staged := range desired { + desiredNames[staged.name] = struct{}{} + wanted := stagedConfigMapObject(namespace, topologyName, staged, nil) + actual := existing[staged.name] + if actual == nil { + appendPlanChange(plan, clablabruntime.ChangeCreate, "ConfigMap", namespace, staged.name) + continue + } + + updated := actual.DeepCopy() + updated.Labels = mergeDesiredMetadata(actual.Labels, wanted.Labels) + updated.Data = wanted.Data + updated.BinaryData = wanted.BinaryData + if !stagedConfigMapsConform(actual, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, "ConfigMap", namespace, staged.name) + } + } + + for name := range existing { + if _, keep := desiredNames[name]; !keep { + appendPlanChange(plan, clablabruntime.ChangeDelete, "ConfigMap", namespace, name) + } + } + + return nil +} + +func appendPlanChange( + plan *clablabruntime.DeployPlan, + action clablabruntime.ChangeAction, + kind, + namespace, + name string, +) { + plan.Changes = append(plan.Changes, clablabruntime.ResourceChange{ + Action: action, Kind: kind, Namespace: namespace, Name: name, + }) +} + +func sortDeployPlan(plan *clablabruntime.DeployPlan) { + sort.Slice(plan.Changes, func(i, j int) bool { + left, right := plan.Changes[i], plan.Changes[j] + if left.Action != right.Action { + return left.Action < right.Action + } + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + if left.Namespace != right.Namespace { + return left.Namespace < right.Namespace + } + + return left.Name < right.Name + }) +} diff --git a/labruntime/clabernetes/primitives.go b/labruntime/clabernetes/primitives.go index d80d2727e6..54fbd984d1 100644 --- a/labruntime/clabernetes/primitives.go +++ b/labruntime/clabernetes/primitives.go @@ -70,7 +70,13 @@ func compilePrimitiveResources( topology.Spec.Connectivity = string(clabernetesapisv1alpha1.LinkConnectivityVXLAN) } - compiled, err := clabernetescontrollerstopology.CompileTopology(c9sCompileLogger{}, topology) + compiled, err := clabernetescontrollerstopology.CompileTopologyWithOptions( + c9sCompileLogger{}, + topology, + clabernetescontrollerstopology.CompileOptions{ + UnsupportedFieldPolicy: clabernetescontrollerstopology.UnsupportedFieldPolicyError, + }, + ) if err != nil { return nil, fmt.Errorf("failed to compile containerlab topology for c9s: %w", err) } diff --git a/labruntime/clabernetes/reconcile.go b/labruntime/clabernetes/reconcile.go index 64651f9ffd..d3e16bda9a 100644 --- a/labruntime/clabernetes/reconcile.go +++ b/labruntime/clabernetes/reconcile.go @@ -297,11 +297,69 @@ func setPrimitiveNodeDeploymentStaged(obj *unstructured.Unstructured, staged boo } func primitiveObjectsConform(existing, desired *unstructured.Unstructured) bool { - return apiequality.Semantic.DeepEqual(existing.Object["spec"], desired.Object["spec"]) && + return apiequality.Semantic.DeepEqual( + normalizeAPIDefaultedJSON(existing.Object["spec"]), + normalizeAPIDefaultedJSON(desired.Object["spec"]), + ) && apiequality.Semantic.DeepEqual(existing.GetLabels(), desired.GetLabels()) && apiequality.Semantic.DeepEqual(existing.GetAnnotations(), desired.GetAnnotations()) } +// normalizeAPIDefaultedJSON removes recursively empty/zero JSON values. Kubernetes CRD +// defaulting materializes fields such as false booleans and zero-second probe configuration in +// stored objects even when the renderer omitted them. Those values are declaratively equivalent; +// treating them as drift would make every plan and reconciliation report an update forever. +func normalizeAPIDefaultedJSON(value any) any { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for key, child := range typed { + normalized := normalizeAPIDefaultedJSON(child) + if !isZeroJSONValue(normalized) { + result[key] = normalized + } + } + + return result + case []any: + result := make([]any, len(typed)) + for idx := range typed { + result[idx] = normalizeAPIDefaultedJSON(typed[idx]) + } + + return result + default: + return value + } +} + +func isZeroJSONValue(value any) bool { + switch typed := value.(type) { + case nil: + return true + case bool: + return !typed + case string: + return typed == "" + case int: + return typed == 0 + case int32: + return typed == 0 + case int64: + return typed == 0 + case float32: + return typed == 0 + case float64: + return typed == 0 + case map[string]any: + return len(typed) == 0 + case []any: + return len(typed) == 0 + default: + return false + } +} + func (r *Runtime) deleteStalePrimitiveResources( ctx context.Context, namespace, diff --git a/labruntime/runtime.go b/labruntime/runtime.go index 991ab1a0fb..bf68075aae 100644 --- a/labruntime/runtime.go +++ b/labruntime/runtime.go @@ -106,6 +106,28 @@ type LabState struct { Nodes []NodeState } +type ChangeAction string + +const ( + ChangeCreate ChangeAction = "create" + ChangeUpdate ChangeAction = "update" + ChangeDelete ChangeAction = "delete" +) + +type ResourceChange struct { + Action ChangeAction `json:"action"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` +} + +// DeployPlan describes the remote resources a lab runtime would change without mutating them. +type DeployPlan struct { + LabName string `json:"lab-name"` + Namespace string `json:"namespace"` + Changes []ResourceChange `json:"changes"` +} + type Event struct { Timestamp time.Time Type string @@ -136,6 +158,16 @@ type LabExistenceChecker interface { LabExists(context.Context, InspectRequest) (bool, error) } +// TopologyValidator validates the runtime-specific topology subset without changing remote state. +type TopologyValidator interface { + Validate(context.Context, DeployRequest) error +} + +// TopologyPlanner compiles and diffs a desired topology without changing remote state. +type TopologyPlanner interface { + Plan(context.Context, DeployRequest) (*DeployPlan, error) +} + type Initializer func(Config) (LabRuntime, error) var LabRuntimes = map[string]Initializer{} From a98a40157b6983111bd34d569bb4e9076671dec1 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Thu, 13 Aug 2026 10:11:39 +0200 Subject: [PATCH 18/21] fix: warn for lossy c9s topology fields --- docs/manual/clabernetes/runtime.md | 32 ++++---- go.mod | 43 ++++++---- go.sum | 96 ++++++++++++++-------- labruntime/clabernetes/clabernetes_test.go | 61 ++++++++++---- labruntime/clabernetes/plan.go | 9 +- labruntime/clabernetes/primitives.go | 2 +- 6 files changed, 157 insertions(+), 86 deletions(-) diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md index 1f8fd4529c..7cdcc10bd6 100644 --- a/docs/manual/clabernetes/runtime.md +++ b/docs/manual/clabernetes/runtime.md @@ -80,7 +80,7 @@ The c9s runtime currently supports the main lab lifecycle and node operations: | `restart` | restarts node Deployments | | `save` | runs `containerlab save` inside launcher pods | | `events` | watches Clabernetes resources and pods | -| `validate` | runs the strict c9s compiler without creating Kubernetes resources | +| `validate` | checks c9s compatibility without creating Kubernetes resources | | `deploy --dry-run` | compiles and diffs Namespace, ConfigMap, LauncherProfile, Link, and Node resources without changing them | ## Requirements @@ -269,8 +269,8 @@ later corrective reconciliation. ### Validation and dry-run -Both commands use the same strict c9s preparation path as deploy, including -extended-link normalization and local-file staging checks: +Both commands use the same c9s preparation path as deploy, including extended-link +normalization, compatibility warnings, and local-file staging checks: ```bash containerlab --runtime clabernetes validate -t topo.clab.yml @@ -278,10 +278,11 @@ containerlab --runtime clabernetes deploy --dry-run -t topo.clab.yml containerlab --runtime clabernetes deploy --dry-run --format json -t topo.clab.yml ``` -`validate` reports whether the topology fits the c9s runtime subset without -reading or changing lab resources. `deploy --dry-run` additionally reads the -selected cluster and reports the exact create, update, and delete plan for -Namespace, ConfigMap, LauncherProfile, Link, Node, or an older compatibility +`validate` reports whether the topology can be represented by the c9s runtime without reading +or changing lab resources. Fields whose semantics cannot be preserved exactly are reported as +warnings and normalized or omitted. Structurally impossible constructs remain errors. +`deploy --dry-run` additionally reads the selected cluster and reports the exact create, update, +and delete plan for Namespace, ConfigMap, LauncherProfile, Link, Node, or an older compatibility Topology. An empty `changes` list means the deployed resources already conform. ## Inspect @@ -709,18 +710,21 @@ The runtime divides compatibility into three categories: - Documented c9s semantics: Kubernetes Service/LoadBalancer management access, `host:` endpoints in the launcher Pod network namespace, c9s internal cross-Pod link transport, and ConfigMap-backed local files. +- Accepted with warnings: topology fields that c9s cannot preserve exactly, including + shared-management-network settings, pinned host-side ports, node groups, link labels or vars, + and other unsupported vocabulary. These fields are normalized or omitted before resources are + created. - Rejected: external bridge/host pseudo-nodes, macvlan and `mgmt-net:` links, - explicit native VXLAN/stitch/dummy link types, link labels or vars that would - be discarded, native shared-management-network settings, and commands or - flags with no c9s implementation. + explicit native VXLAN/stitch/dummy link types, invalid launcher grouping, and commands or flags + with no c9s implementation. Management access is not a shared management network. Each launcher has its own nested Docker management network, so static `mgmt-ipv4`/`mgmt-ipv6` addresses are launcher-local and are not cluster-routable between Pods. -Kubernetes Services, LoadBalancers, and DNS are the supported management access -path. Explicit topology `mgmt` settings and the `--network`, `--ipv4-subnet`, -and `--ipv6-subnet` flags are rejected to avoid implying native Docker-network -semantics. +Kubernetes Services, LoadBalancers, and DNS are the supported management access path. Explicit +topology `mgmt` settings produce a warning and apply only inside each launcher. The `--network`, +`--ipv4-subnet`, and `--ipv6-subnet` flags remain rejected because they imply native +Docker-network semantics. Known command differences: diff --git a/go.mod b/go.mod index 0d158ec841..4489e877df 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/clabernetes/clabernetes v0.7.0 + github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81 github.com/containernetworking/plugins v1.9.0 github.com/containers/podman/v5 v5.8.2 github.com/digitalocean/go-openvswitch v0.0.0-20250625173537-a00eb8d2cfce @@ -51,9 +51,9 @@ require ( go.podman.io/common v0.67.1 go.podman.io/image/v5 v5.39.2 go.uber.org/mock v0.6.0 - golang.org/x/crypto v0.50.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 + golang.org/x/crypto v0.55.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.35.4 k8s.io/client-go v0.35.4 @@ -86,7 +86,7 @@ require ( github.com/containerd/platforms v1.0.0-rc.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -94,20 +94,29 @@ require ( github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mdlayher/socket v0.5.1 // indirect @@ -174,11 +183,11 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/apiextensions-apiserver v0.35.4 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/controller-runtime v0.23.3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.1 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) @@ -253,10 +262,10 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect - golang.org/x/mod v0.37.0 - golang.org/x/net v0.52.0 // indirect - golang.org/x/sync v0.20.0 - golang.org/x/text v0.36.0 // indirect + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/grpc v1.72.2 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 937c596bf9..63c68b85f4 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= github.com/cilium/ebpf v0.17.3 h1:FnP4r16PWYSE4ux6zN+//jMcW4nMVRvuTLVTvCjyyjg= github.com/cilium/ebpf v0.17.3/go.mod h1:G5EDHij8yiLzaqn0WjyfJHvRa+3aDlReIaLVRMvOyJk= -github.com/clabernetes/clabernetes v0.7.0 h1:1uIMAAS1w2cxB7mAIq0Cm9Q8fbp+Bh6eAs1SoYmMKrg= -github.com/clabernetes/clabernetes v0.7.0/go.mod h1:yMMjzd5WgSLy7JhMIVIUOgz1+ZBtUMf9sx4snAge/wQ= +github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81 h1:MAPKcVwQcmJvxg/LG8D2GY5L1uHpY4RGbZDniuu/pv4= +github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81/go.mod h1:uJgJfgl+nHOS0ELOWNRd0gtPymplP+KOFKPSY9L77h4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -161,8 +161,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -207,12 +207,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -290,8 +318,6 @@ github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= @@ -339,8 +365,6 @@ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr7f2i0= github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -617,8 +641,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -626,8 +650,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -650,8 +674,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -662,8 +686,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -707,8 +731,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -718,8 +742,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -730,8 +754,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -740,8 +764,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -787,10 +811,10 @@ k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f h1:4Qiq0YAoQATdgmHALJWz9rJ4fj20pB3xebpB4CFNhYM= -k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= @@ -799,8 +823,8 @@ sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1 h1:AkER7js0XVWi/F/V2Iwl5N7O/B9VP2JyrOMmHPdco+g= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc= diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index aa108fb397..503a7b3877 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -913,13 +913,12 @@ topology: } } -func TestValidateStrictlyRejectsUnsupportedContainerlabSemantics(t *testing.T) { +func TestValidateAcceptsLossyContainerlabSemantics(t *testing.T) { t.Parallel() tests := []struct { name string definition string - want string }{ { name: "management network", @@ -931,16 +930,18 @@ topology: kind: linux image: alpine `, - want: "management network", }, { - name: "external bridge", + name: "group and pinned host port", definition: `topology: nodes: - br0: - kind: bridge + node1: + kind: linux + image: alpine + group: clients + ports: + - 8080:80 `, - want: "pseudo-node", }, { name: "lossy link metadata", @@ -957,7 +958,6 @@ topology: labels: purpose: test `, - want: "link labels", }, } @@ -966,18 +966,35 @@ topology: t.Parallel() r := newTestRuntime() - err := r.Validate(context.Background(), clablabruntime.DeployRequest{ - Name: "strict", + if err := r.Validate(context.Background(), clablabruntime.DeployRequest{ + Name: "lossy", Namespace: "lab-ns", TopologyDefinition: []byte(tt.definition), - }) - if err == nil || !strings.Contains(err.Error(), tt.want) { - t.Fatalf("Validate() error = %v, want substring %q", err, tt.want) + }); err != nil { + t.Fatalf("Validate() error = %v, want lossy topology to be accepted", err) } }) } } +func TestValidateRejectsStructurallyUnsupportedContainerlabSemantics(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + err := r.Validate(context.Background(), clablabruntime.DeployRequest{ + Name: "structurally-unsupported", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + br0: + kind: bridge +`), + }) + if err == nil || !strings.Contains(err.Error(), "pseudo-node") { + t.Fatalf("Validate() error = %v, want structurally unsupported pseudo-node error", err) + } +} + func TestPlanReportsPrimitiveDiffWithoutMutation(t *testing.T) { t.Parallel() @@ -1238,6 +1255,9 @@ func TestDeployExposesGNMICMetricsPortForClabernetes(t *testing.T) { const definition = `name: st prefix: "" +mgmt: + network: st + ipv4-subnet: 172.20.20.0/24 topology: nodes: leaf1: @@ -1251,7 +1271,7 @@ topology: kind: linux image: quay.io/prometheus/prometheus:v2.54.1 ports: - - 9090/tcp + - 9090:9090 links: - endpoints: ["leaf1:e1-1", "prometheus:eth1"] ` @@ -1275,6 +1295,11 @@ topology: if !slices.Contains(ports, "9273/tcp") { t.Fatalf("gnmic ports = %v, want 9273/tcp", ports) } + prometheus := getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") + ports, found, err = unstructured.NestedStringSlice(prometheus.Object, "spec", "ports") + if err != nil || !found || !slices.Contains(ports, "9090") { + t.Fatalf("prometheus normalized ports = %v, found=%t, err=%v; want 9090", ports, found, err) + } profile := getTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "st") if enabled, found, err := unstructured.NestedBool( @@ -1296,6 +1321,14 @@ topology: if excludedNodes, found := statusProbes["excludedNodes"]; found && excludedNodes != nil { t.Fatalf("containerlab must not exclude kinds from generic readiness: %v", statusProbes) } + if got, _, _ := unstructured.NestedString( + profile.Object, + "spec", + "mgmt", + "ipv4-subnet", + ); got != "172.20.20.0/24" { + t.Fatalf("launcher profile management subnet = %q, want 172.20.20.0/24", got) + } links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). List(context.Background(), metav1.ListOptions{}) if err != nil { diff --git a/labruntime/clabernetes/plan.go b/labruntime/clabernetes/plan.go index 6b86f10385..61b84b6df6 100644 --- a/labruntime/clabernetes/plan.go +++ b/labruntime/clabernetes/plan.go @@ -52,8 +52,9 @@ func prepareDesiredDeployment( }, nil } -// Validate compiles the strict containerlab/c9s subset and stages all local-path inputs in -// memory. It deliberately performs no Kubernetes reads or writes. +// Validate compiles the containerlab/c9s subset and stages all local-path inputs in memory. +// Lossy-but-deployable fields produce warnings; structurally impossible constructs remain +// errors. Validation deliberately performs no Kubernetes reads or writes. func (r *Runtime) Validate(_ context.Context, req clablabruntime.DeployRequest) error { if req.Name == "" { return fmt.Errorf("topology name is required") @@ -72,8 +73,8 @@ func (r *Runtime) Validate(_ context.Context, req clablabruntime.DeployRequest) return err } -// Plan compiles the strict c9s topology and returns the complete primitive/configuration diff -// against the running cluster without changing any resource. +// Plan compiles the c9s topology and returns the complete primitive/configuration diff against +// the running cluster without changing any resource. func (r *Runtime) Plan( ctx context.Context, req clablabruntime.DeployRequest, diff --git a/labruntime/clabernetes/primitives.go b/labruntime/clabernetes/primitives.go index 54fbd984d1..b54e6a74e5 100644 --- a/labruntime/clabernetes/primitives.go +++ b/labruntime/clabernetes/primitives.go @@ -74,7 +74,7 @@ func compilePrimitiveResources( c9sCompileLogger{}, topology, clabernetescontrollerstopology.CompileOptions{ - UnsupportedFieldPolicy: clabernetescontrollerstopology.UnsupportedFieldPolicyError, + UnsupportedFieldPolicy: clabernetescontrollerstopology.UnsupportedFieldPolicyWarn, }, ) if err != nil { From 8564f465106805d465fd49f11d81abf1e57dfcd5 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Thu, 13 Aug 2026 10:13:31 +0200 Subject: [PATCH 19/21] fix: remove inferred gNMIc metrics port --- labruntime/clabernetes/clabernetes_test.go | 10 ++-- labruntime/clabernetes/files.go | 67 +--------------------- 2 files changed, 7 insertions(+), 70 deletions(-) diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index 503a7b3877..bc793d86fb 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -1250,7 +1250,7 @@ topology: _ = getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") } -func TestDeployExposesGNMICMetricsPortForClabernetes(t *testing.T) { +func TestDeployDoesNotInferApplicationPorts(t *testing.T) { t.Parallel() const definition = `name: st @@ -1289,11 +1289,11 @@ topology: assertNoTestTopology(t, r, "lab-ns", "st") gnmic := getTestPrimitive(t, r, nodeGVR, "lab-ns", "gnmic") ports, found, err := unstructured.NestedStringSlice(gnmic.Object, "spec", "ports") - if err != nil || !found { - t.Fatalf("failed to read gnmic ports: found=%t err=%v", found, err) + if err != nil { + t.Fatalf("failed to read gnmic ports: %v", err) } - if !slices.Contains(ports, "9273/tcp") { - t.Fatalf("gnmic ports = %v, want 9273/tcp", ports) + if found || len(ports) != 0 { + t.Fatalf("gnmic ports = %v, found=%t; want no inferred application ports", ports, found) } prometheus := getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") ports, found, err = unstructured.NestedStringSlice(prometheus.Object, "spec", "ports") diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go index e7edd52e2c..078a52f803 100644 --- a/labruntime/clabernetes/files.go +++ b/labruntime/clabernetes/files.go @@ -31,7 +31,6 @@ const ( inlineStartupConfigMountPath = "/clabernetes/startup-config" maxConfigMapFileBytes = 950_000 kubernetesNameMaxLen = 63 - gnmicPrometheusPort = 9273 clabernetesNamingNonPrefixed = "non-prefixed" clabDirVar = "__clabDir__" @@ -111,13 +110,9 @@ func stageTopologyLocalFiles( extraConfigMaps := map[string]*stagedConfigMap{} startupConfigMaps := map[string]*stagedConfigMap{} - // Always render parsed links through the c9s brief-link boundary. Previously this happened - // only as a side effect of staging an unrelated file or compatibility port, which made an - // extended link's behavior depend on whether some other field changed the definition. + // Always render parsed links through the c9s brief-link boundary so an extended link's + // behavior does not depend on whether some unrelated field changed the definition. definitionChanged := len(config.Topology.Links) > 0 - if exposeClabernetesCompatibilityPorts(config) { - definitionChanged = true - } nodeNames := make([]string, 0, len(config.Topology.Nodes)) for nodeName := range config.Topology.Nodes { @@ -280,64 +275,6 @@ func clabernetesNamingMode(config *clabRuntimeConfig) string { return clabernetesNamingNonPrefixed } -func exposeClabernetesCompatibilityPorts(config *clabRuntimeConfig) bool { - if config == nil || config.Topology == nil { - return false - } - - definitionChanged := false - - for nodeName, nodeDefinition := range config.Topology.Nodes { - if nodeDefinition == nil || !isGNMICNode(nodeName, nodeDefinition) { - continue - } - - if hasDestinationPort(nodeDefinition.Ports, gnmicPrometheusPort, "tcp") { - continue - } - - nodeDefinition.Ports = append( - nodeDefinition.Ports, - fmt.Sprintf("%d/tcp", gnmicPrometheusPort), - ) - definitionChanged = true - } - - return definitionChanged -} - -func isGNMICNode(nodeName string, nodeDefinition *clabtypes.NodeDefinition) bool { - nodeName = strings.ToLower(nodeName) - image := strings.ToLower(nodeDefinition.Image) - - return nodeName == "gnmic" || strings.Contains(image, "gnmic") -} - -func hasDestinationPort(portDefinitions []string, destinationPort int, protocol string) bool { - for _, portDefinition := range portDefinitions { - port, portProtocol := splitPortProtocol(portDefinition) - if portProtocol != "" && !strings.EqualFold(portProtocol, protocol) { - continue - } - - parts := strings.Split(port, ":") - if parts[len(parts)-1] == fmt.Sprint(destinationPort) { - return true - } - } - - return false -} - -func splitPortProtocol(portDefinition string) (port, protocol string) { - port, protocol, found := strings.Cut(portDefinition, "/") - if !found { - return portDefinition, "" - } - - return port, protocol -} - func stageStartupConfig( config *clabRuntimeConfig, topologyName, From b8f0ad15e13784b17b05f6c53655682207aa1657 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Thu, 13 Aug 2026 13:29:24 +0200 Subject: [PATCH 20/21] fix: resolve clabernetes component containers --- labruntime/clabernetes/clabernetes_test.go | 78 +++++++++++++++ labruntime/clabernetes/exec.go | 105 ++++++++++++++++++++- labruntime/clabernetes/iface_stats.go | 14 ++- 3 files changed, 195 insertions(+), 2 deletions(-) diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index bc793d86fb..f7268fd248 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -60,6 +60,84 @@ func TestParseProcNetDevRejectsMalformedLine(t *testing.T) { } } +func TestPreferredNestedContainerName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + nodeName string + exactNames []string + componentNames []string + want string + wantError string + }{ + { + name: "regular-node", + nodeName: "router", + exactNames: []string{"router"}, + want: "router", + }, + { + name: "components-prefer-cpm-a", + nodeName: "srsim", + componentNames: []string{"srsim-2", "srsim-b", "srsim-1", "srsim-a"}, + want: "srsim-a", + }, + { + name: "components-fall-back-to-cpm-b", + nodeName: "srsim", + componentNames: []string{"srsim-1", "srsim-b"}, + want: "srsim-b", + }, + { + name: "missing-node", + nodeName: "missing", + wantError: "was not found", + }, + { + name: "missing-cpm", + nodeName: "srsim", + componentNames: []string{"srsim-1", "srsim-2"}, + wantError: "CPM component container", + }, + { + name: "ambiguous-exact-node", + nodeName: "router", + exactNames: []string{"router-duplicate", "router"}, + wantError: "multiple nested containers", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := preferredNestedContainerName( + tt.nodeName, + tt.exactNames, + tt.componentNames, + ) + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf( + "preferredNestedContainerName() error = %v, want %q", + err, + tt.wantError, + ) + } + + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("preferredNestedContainerName() = %q, want %q", got, tt.want) + } + }) + } +} + func TestCleanTarPath(t *testing.T) { t.Parallel() diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go index ca6beb068a..8c39459902 100644 --- a/labruntime/clabernetes/exec.go +++ b/labruntime/clabernetes/exec.go @@ -5,8 +5,11 @@ import ( "context" "errors" "fmt" + "slices" + "strings" "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" clablabruntime "github.com/srl-labs/containerlab/labruntime" corev1 "k8s.io/api/core/v1" @@ -36,9 +39,14 @@ func (r *Runtime) Exec( return nil, err } + containerName, err := r.nestedContainerName(ctx, pod, req.NodeName) + if err != nil { + return nil, err + } + execCmd := clabexec.NewExecCmdFromSlice(req.Command) result := clabexec.NewExecResult(execCmd) - cmd := append([]string{"docker", "exec", req.NodeName}, req.Command...) + cmd := append([]string{"docker", "exec", containerName}, req.Command...) stdout, stderr, rc, err := r.execInPod(ctx, pod, cmd) if err != nil { @@ -52,6 +60,101 @@ func (r *Runtime) Exec( return result, nil } +func (r *Runtime) nestedContainerName( + ctx context.Context, + pod *corev1.Pod, + nodeName string, +) (string, error) { + exactNames, err := r.nestedContainerNamesByLabel( + ctx, + pod, + clabconstants.NodeName, + nodeName, + ) + if err != nil { + return "", err + } + + componentNames := []string(nil) + if len(exactNames) == 0 { + componentNames, err = r.nestedContainerNamesByLabel( + ctx, + pod, + clabconstants.RootNodeName, + nodeName, + ) + if err != nil { + return "", err + } + } + + return preferredNestedContainerName(nodeName, exactNames, componentNames) +} + +func (r *Runtime) nestedContainerNamesByLabel( + ctx context.Context, + pod *corev1.Pod, + label, + value string, +) ([]string, error) { + stdout, stderr, rc, err := r.execInPod(ctx, pod, []string{ + "docker", + "ps", + "--all", + "--filter", + fmt.Sprintf("label=%s=%s", label, value), + "--format", + "{{.Names}}", + }) + if err != nil { + return nil, err + } + if rc != 0 { + return nil, fmt.Errorf( + "failed listing nested containers for node %q: rc=%d stderr=%s", + value, + rc, + strings.TrimSpace(string(stderr)), + ) + } + + return strings.Fields(string(stdout)), nil +} + +func preferredNestedContainerName( + nodeName string, + exactNames, + componentNames []string, +) (string, error) { + switch len(exactNames) { + case 1: + return exactNames[0], nil + case 0: + default: + return "", fmt.Errorf("multiple nested containers matched node %q: %v", nodeName, exactNames) + } + + slices.Sort(componentNames) + + for _, cpmSuffix := range []string{"-a", "-b"} { + for _, componentName := range componentNames { + if strings.EqualFold(componentName, nodeName+cpmSuffix) { + return componentName, nil + } + } + } + + if len(componentNames) == 0 { + return "", fmt.Errorf("nested container for node %q was not found", nodeName) + } + + return "", fmt.Errorf( + "CPM component container for node %q was not found among %v", + nodeName, + componentNames, + ) +} + func (r *Runtime) launcherPod( ctx context.Context, name, diff --git a/labruntime/clabernetes/iface_stats.go b/labruntime/clabernetes/iface_stats.go index e7fbf82201..25e06e4967 100644 --- a/labruntime/clabernetes/iface_stats.go +++ b/labruntime/clabernetes/iface_stats.go @@ -53,8 +53,20 @@ func (r *Runtime) pollInterfaceStats( continue } + containerName, err := r.nestedContainerName(ctx, pod, node.Name) + if err != nil { + log.Debug("failed to resolve nested container for interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + + continue + } + stdout, stderr, rc, err := r.execInPod(ctx, pod, - []string{"docker", "exec", node.Name, "cat", "/proc/net/dev"}) + []string{"docker", "exec", containerName, "cat", "/proc/net/dev"}) if err != nil { log.Debug("failed to collect clabernetes interface stats", "namespace", state.Namespace, From 74d43832f87b2ebb4a8b6dfc9c7ace69c92349b2 Mon Sep 17 00:00:00 2001 From: flosch62 Date: Mon, 17 Aug 2026 15:26:19 +0200 Subject: [PATCH 21/21] feat: improve clabernetes deploy progress --- cmd/deploy.go | 25 +- cmd/deploy_test.go | 20 ++ labruntime/clabernetes/clabernetes.go | 6 + labruntime/clabernetes/clabernetes_test.go | 185 +++++++++++++++ labruntime/clabernetes/image_progress.go | 264 +++++++++++++++++++++ labruntime/clabernetes/lifecycle.go | 90 ++++++- labruntime/clabernetes/resources.go | 12 + 7 files changed, 591 insertions(+), 11 deletions(-) create mode 100644 labruntime/clabernetes/image_progress.go diff --git a/cmd/deploy.go b/cmd/deploy.go index 19fbb695d9..d8abc6b297 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -241,22 +241,27 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { printApplyResult(result.Apply) } - // historically i think this was 5s, but we will already have had at least some time for - // the manager to have gone off and fetched the version, so 3s max to wrap that up and print - // seems reasonable - versionCheckContext, cancel := context.WithTimeout( - cobraCmd.Context(), - postDeployVersionCheckTimeout, - ) - defer cancel() + if shouldDisplayPostDeployVersion(o.Global.Runtime) { + // The manager has fetched in the background during deploy, so allow at most three more + // seconds to finish and print the available containerlab release. + versionCheckContext, cancel := context.WithTimeout( + cobraCmd.Context(), + postDeployVersionCheckTimeout, + ) + defer cancel() - m := getVersionManager() - m.DisplayNewVersionAvailable(versionCheckContext, false) + m := getVersionManager() + m.DisplayNewVersionAvailable(versionCheckContext, false) + } // print table summary return PrintContainerInspect(result.Containers, o) } +func shouldDisplayPostDeployVersion(runtimeName string) bool { + return !clablabruntime.IsLabRuntimeName(runtimeName) +} + // printDryRunResult prints the planned changes of a dry run, as JSON when requested via // the --format flag and as a table otherwise. func printDryRunResult(result *clabcore.DeployResult, o *Options) error { diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index f0f2dc6e5e..f747450401 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -39,6 +39,26 @@ func TestApplyIsDeployAlias(t *testing.T) { } } +func TestPostDeployVersionDisplaySkipsLabRuntimes(t *testing.T) { + tests := []struct { + name string + runtime string + want bool + }{ + {name: "default runtime", want: true}, + {name: "docker runtime", runtime: "docker", want: true}, + {name: "clabernetes runtime", runtime: "clabernetes", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldDisplayPostDeployVersion(tt.runtime); got != tt.want { + t.Fatalf("shouldDisplayPostDeployVersion(%q) = %t, want %t", tt.runtime, got, tt.want) + } + }) + } +} + func TestPrintApplyResultUsesInfoAndItemRows(t *testing.T) { output := captureApplyOutput(t, func() { printApplyResult(&clabcore.ApplyResult{ diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go index d3eaf93269..9be3b78127 100644 --- a/labruntime/clabernetes/clabernetes.go +++ b/labruntime/clabernetes/clabernetes.go @@ -53,6 +53,12 @@ var launcherProfileGVR = schema.GroupVersionResource{ Resource: "launcherprofiles", } +var imageRequestGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "imagerequests", +} + type Runtime struct { client dynamic.Interface kubeClient kubernetes.Interface diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go index f7268fd248..cc6c50a2e6 100644 --- a/labruntime/clabernetes/clabernetes_test.go +++ b/labruntime/clabernetes/clabernetes_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/charmbracelet/log" clabernetesconstants "github.com/clabernetes/clabernetes/constants" clabconstants "github.com/srl-labs/containerlab/constants" clablabruntime "github.com/srl-labs/containerlab/labruntime" @@ -674,6 +675,189 @@ func TestWaitReadyTimeoutReportsPendingNodes(t *testing.T) { } } +func TestNodeReadinessProgressReportsTransitions(t *testing.T) { + var output bytes.Buffer + oldLevel := log.GetLevel() + log.SetLevel(log.InfoLevel) + log.SetOutput(&output) + defer func() { + log.SetLevel(oldLevel) + log.SetOutput(os.Stderr) + }() + + progress := nodeReadinessProgress{} + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "notready"}, + {Name: "node2", State: "ready", Ready: true}, + }}) + + got := output.String() + if strings.Contains(got, "node=node1") { + t.Fatalf("initial non-ready node should not produce a log line:\n%s", got) + } + if !strings.Contains(got, "Clabernetes node is ready") || + !strings.Contains(got, "node=node2") || + !strings.Contains(got, "ready=1") || + !strings.Contains(got, "total=2") { + t.Fatalf("initial ready node progress was not reported:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "ready", Ready: true}, + }}) + got = output.String() + if strings.Count(got, "Clabernetes node is ready") != 1 || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "ready=2") { + t.Fatalf("newly ready node progress was not reported exactly once:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "notready"}, + }}) + got = output.String() + if !strings.Contains(got, "Clabernetes node is not ready") || + !strings.Contains(got, "node=node2") || + !strings.Contains(got, "state=notready") || + !strings.Contains(got, "ready=1") { + t.Fatalf("readiness regression was not reported:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "notready"}, + }}) + if output.Len() != 0 { + t.Fatalf("unchanged readiness should not produce repeated log lines:\n%s", output.String()) + } +} + +func TestImagePullRequestsFilterAndProgress(t *testing.T) { + request := func(name, node, image, kubernetesNode string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "ImageRequest", + "metadata": map[string]any{ + "name": name, + "namespace": "lab-ns", + }, + "spec": map[string]any{ + "topologyNodeName": node, + "requestedImage": image, + "kubernetesNode": kubernetesNode, + }, + }} + } + + r := newTestRuntime( + request("node1-image", "node1", "example/node1:latest", "worker-a"), + request("node1-other-image", "node1", "example/other:latest", "worker-a"), + request("foreign-image", "foreign", "example/foreign:latest", "worker-b"), + ) + requests, err := r.imagePullRequests( + context.Background(), + "lab-ns", + &clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", Image: "example/node1:latest"}, + }}, + ) + if err != nil { + t.Fatal(err) + } + if len(requests) != 1 || requests[0].name != "node1-image" { + t.Fatalf("image pull requests = %+v, want only node1-image", requests) + } + + var output bytes.Buffer + oldLevel := log.GetLevel() + log.SetLevel(log.InfoLevel) + log.SetOutput(&output) + defer func() { + log.SetLevel(oldLevel) + log.SetOutput(os.Stderr) + }() + + progress := imagePullProgress{} + progress.report(requests) + got := output.String() + if !strings.Contains(got, "Pulling clabernetes node image") || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "image=example/node1:latest") || + !strings.Contains(got, "kubernetes-node=worker-a") { + t.Fatalf("image pull start was not reported:\n%s", got) + } + + output.Reset() + progress.report(requests) + if output.Len() != 0 { + t.Fatalf("unchanged image pull should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.report(nil) + got = output.String() + if !strings.Contains(got, "Clabernetes node image pull completed") || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "image=example/node1:latest") { + t.Fatalf("image pull completion was not reported:\n%s", got) + } + + output.Reset() + progress.report(nil) + if output.Len() != 0 { + t.Fatalf("completed image pull should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod1", + node: "node1", + image: "example/node1:latest", + kubernetesNode: "worker-a", + content: "image \"example/node1:latest\" is present, begin copy to docker daemon...\n" + + "Loaded image: example/node1:latest\n", + }) + got = output.String() + if !strings.Contains(got, "already present on Kubernetes node") || + strings.Contains(got, "copied to launcher Docker daemon") || + strings.Count(got, "node=node1") != 1 { + t.Fatalf("cached image copy lifecycle was not reported:\n%s", got) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod1", + node: "node1", + image: "example/node1:latest", + kubernetesNode: "worker-a", + content: "Loaded image: example/node1:latest\n", + }) + if output.Len() != 0 { + t.Fatalf("completed image copy should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod2", + node: "node2", + image: "example/node2:latest", + kubernetesNode: "worker-b", + content: "image \"example/node2:latest\" is now available on node, continuing...\n" + + "Loaded image: example/node2:latest\n", + }) + got = output.String() + if !strings.Contains(got, "Copying clabernetes node image") || + strings.Contains(got, "already present") || + strings.Contains(got, "copied to launcher Docker daemon") { + t.Fatalf("pulled image copy lifecycle was not reported:\n%s", got) + } +} + func TestManagePrimitiveOnlyLab(t *testing.T) { t.Parallel() @@ -1717,6 +1901,7 @@ func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { nodeGVR: "NodeList", linkGVR: "LinkList", launcherProfileGVR: "LauncherProfileList", + imageRequestGVR: "ImageRequestList", }, runtimeObjects..., ), diff --git a/labruntime/clabernetes/image_progress.go b/labruntime/clabernetes/image_progress.go new file mode 100644 index 0000000000..65fb25fece --- /dev/null +++ b/labruntime/clabernetes/image_progress.go @@ -0,0 +1,264 @@ +package clabernetes + +import ( + "context" + "sort" + "strings" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +const launcherLogTailLines int64 = 200 + +type imagePullRequest struct { + name string + node string + image string + kubernetesNode string + complete bool +} + +type imagePullProgress struct { + active map[string]imagePullRequest + listErrorReported bool + launcherListErrorReported bool + launcherLogErrors map[string]struct{} + launcherCopies map[string]launcherImageCopyProgress +} + +type launcherImageLog struct { + podName string + node string + image string + kubernetesNode string + content string +} + +type launcherImageCopyProgress struct { + copying bool + complete bool +} + +func (r *Runtime) imagePullRequests( + ctx context.Context, + namespace string, + state *clablabruntime.LabState, +) ([]imagePullRequest, error) { + labNodes := make(map[string]string, len(state.Nodes)) + for _, node := range state.Nodes { + labNodes[node.Name] = node.Image + } + + list, err := r.client.Resource(imageRequestGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + requests := make([]imagePullRequest, 0, len(list.Items)) + for idx := range list.Items { + request := imagePullRequestFromResource(&list.Items[idx]) + expectedImage, belongsToLab := labNodes[request.node] + if !belongsToLab || request.image != expectedImage { + continue + } + requests = append(requests, request) + } + sort.Slice(requests, func(i, j int) bool { + return requests[i].name < requests[j].name + }) + + return requests, nil +} + +func imagePullRequestFromResource(resource *unstructured.Unstructured) imagePullRequest { + request := imagePullRequest{name: resource.GetName()} + request.node, _, _ = unstructured.NestedString( + resource.Object, "spec", "topologyNodeName", + ) + request.image, _, _ = unstructured.NestedString( + resource.Object, "spec", "requestedImage", + ) + request.kubernetesNode, _, _ = unstructured.NestedString( + resource.Object, "spec", "kubernetesNode", + ) + request.complete, _, _ = unstructured.NestedBool( + resource.Object, "status", "complete", + ) + + return request +} + +func (p *imagePullProgress) report(requests []imagePullRequest) { + p.listErrorReported = false + next := make(map[string]imagePullRequest, len(requests)) + for _, request := range requests { + previous, seen := p.active[request.name] + next[request.name] = request + if !seen { + logImagePullProgress("Pulling clabernetes node image", request) + } + if request.complete && (!seen || !previous.complete) { + logImagePullProgress("Clabernetes node image pull completed", request) + } + } + + for name, request := range p.active { + if _, stillActive := next[name]; stillActive || request.complete { + continue + } + // The controller deletes an ImageRequest after its puller leaves Pending. Usually that + // deletion is observed between polls, before the brief complete status can be listed. + logImagePullProgress("Clabernetes node image pull completed", request) + } + + p.active = next +} + +func (p *imagePullProgress) reportListError(err error) { + if p.listErrorReported { + return + } + log.Debug("Unable to inspect clabernetes image pulls", "error", err) + p.listErrorReported = true +} + +func (p *imagePullProgress) inspectLauncherCopies( + ctx context.Context, + r *Runtime, + namespace string, + state *clablabruntime.LabState, +) { + labNodes := make(map[string]string, len(state.Nodes)) + for _, node := range state.Nodes { + labNodes[node.Name] = node.Image + } + + pods, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: state.Name, + }.String(), + }) + if err != nil { + if !p.launcherListErrorReported { + log.Debug("Unable to inspect clabernetes launcher pods", "error", err) + p.launcherListErrorReported = true + } + + return + } + p.launcherListErrorReported = false + sort.Slice(pods.Items, func(i, j int) bool { + return pods.Items[i].Name < pods.Items[j].Name + }) + + for idx := range pods.Items { + pod := &pods.Items[idx] + nodeName := pod.Labels[labelTopologyNode] + image, belongsToLab := labNodes[nodeName] + if !belongsToLab || image == "" || pod.Status.Phase != corev1.PodRunning { + continue + } + + key := string(pod.UID) + if key == "" { + key = pod.Name + } + if p.launcherCopies[key].complete { + continue + } + + content, err := r.kubeClient.CoreV1().Pods(namespace).GetLogs( + pod.Name, + &corev1.PodLogOptions{TailLines: pointerTo(launcherLogTailLines)}, + ).DoRaw(ctx) + if err != nil { + p.reportLauncherLogError(key, pod.Name, err) + continue + } + delete(p.launcherLogErrors, key) + p.reportLauncherLog(launcherImageLog{ + podName: key, + node: nodeName, + image: image, + kubernetesNode: pod.Spec.NodeName, + content: string(content), + }) + } +} + +func (p *imagePullProgress) reportLauncherLog(observation launcherImageLog) { + progress := p.launcherCopies[observation.podName] + fields := imagePullRequest{ + node: observation.node, + image: observation.image, + kubernetesNode: observation.kubernetesNode, + } + + imageQuoted := `image "` + observation.image + `"` + if !progress.copying && strings.Contains( + observation.content, + imageQuoted+" is present, begin copy to docker daemon", + ) { + logImagePullProgress( + "Clabernetes node image already present on Kubernetes node; "+ + "copying to launcher Docker daemon", + fields, + ) + progress.copying = true + } + + if !progress.copying && strings.Contains( + observation.content, + imageQuoted+" is now available on node, continuing", + ) { + logImagePullProgress( + "Copying clabernetes node image to launcher Docker daemon", + fields, + ) + progress.copying = true + } + + if !progress.complete && strings.Contains( + observation.content, + "Loaded image: "+observation.image, + ) { + progress.copying = true + progress.complete = true + } + + if p.launcherCopies == nil { + p.launcherCopies = map[string]launcherImageCopyProgress{} + } + p.launcherCopies[observation.podName] = progress +} + +func (p *imagePullProgress) reportLauncherLogError(key, podName string, err error) { + if _, reported := p.launcherLogErrors[key]; reported { + return + } + log.Debug("Unable to inspect clabernetes launcher logs", "pod", podName, "error", err) + if p.launcherLogErrors == nil { + p.launcherLogErrors = map[string]struct{}{} + } + p.launcherLogErrors[key] = struct{}{} +} + +func pointerTo[T any](value T) *T { + return &value +} + +func logImagePullProgress(message string, request imagePullRequest) { + log.Info( + message, + "node", request.node, + "image", request.image, + "kubernetes-node", request.kubernetesNode, + ) +} diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go index e012c6855a..e3bc77d3fe 100644 --- a/labruntime/clabernetes/lifecycle.go +++ b/labruntime/clabernetes/lifecycle.go @@ -34,6 +34,7 @@ func (r *Runtime) Deploy( if err != nil { return nil, err } + log.Info("Preparing clabernetes lab", "name", req.Name, "namespace", namespace) topologyResource := r.client.Resource(topologyGVR).Namespace(namespace) existingTopology, err := topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) @@ -58,6 +59,12 @@ func (r *Runtime) Deploy( desiredTopology := prepared.topology stagedConfigMaps := prepared.configMaps primitives := prepared.primitives + log.Info( + "Staging clabernetes lab artifacts", + "name", req.Name, + "namespace", namespace, + "config-maps", len(stagedConfigMaps), + ) // Compatibility Topologies are still supported for labs created by older versions. Keep // their controller ownership intact and reconcile the definition in place. New labs and @@ -99,7 +106,13 @@ func (r *Runtime) Deploy( if primitiveExists { operation = "Reconciling" } - log.Info(operation+" clabernetes primitive resources", "name", req.Name, "namespace", namespace) + log.Info( + operation+" clabernetes lab resources", + "name", req.Name, + "namespace", namespace, + "nodes", len(primitives.nodes), + "links", len(primitives.links), + ) appliedNodes, createdNodes, createdResources, err := r.reconcilePrimitiveResources( ctx, namespace, @@ -416,6 +429,9 @@ func (r *Runtime) waitReady( defer cancel() var lastState *clablabruntime.LabState + readinessProgress := nodeReadinessProgress{} + imageProgress := imagePullProgress{} + log.Info("Waiting for clabernetes lab to become ready", "name", name, "namespace", namespace) err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, func(ctx context.Context) (bool, error) { @@ -435,6 +451,14 @@ func (r *Runtime) waitReady( namespace, name, err) } lastState = state + imageRequests, imageErr := r.imagePullRequests(ctx, namespace, state) + if imageErr != nil { + imageProgress.reportListError(imageErr) + } else { + imageProgress.report(imageRequests) + } + imageProgress.inspectLauncherCopies(ctx, r, namespace, state) + readinessProgress.report(state) if state.Ready { return true, nil @@ -453,6 +477,8 @@ func (r *Runtime) waitReady( return false, nil }) if err == nil { + log.Info("Clabernetes lab is ready", "name", name, "namespace", namespace) + return nil } if !errors.Is(err, context.DeadlineExceeded) { @@ -492,6 +518,68 @@ func (r *Runtime) waitReady( ) } +type trackedNodeReadiness struct { + state string + ready bool +} + +type nodeReadinessProgress struct { + nodes map[string]trackedNodeReadiness +} + +func (p *nodeReadinessProgress) report(state *clablabruntime.LabState) { + if state == nil { + return + } + + readyCount := 0 + for _, node := range state.Nodes { + if node.Ready { + readyCount++ + } + } + + next := make(map[string]trackedNodeReadiness, len(state.Nodes)) + for _, node := range state.Nodes { + current := trackedNodeReadiness{state: node.State, ready: node.Ready} + previous, seen := p.nodes[node.Name] + next[node.Name] = current + + // The initial non-ready snapshot is summarized by the wait message. Report nodes that + // are already ready on the first poll, and every meaningful transition after that. + if !node.Ready && (!seen || previous == current) { + continue + } + if seen && previous == current { + continue + } + + if node.Ready { + log.Info( + "Clabernetes node is ready", + "node", node.Name, + "ready", readyCount, + "total", len(state.Nodes), + ) + continue + } + + nodeState := node.State + if nodeState == "" { + nodeState = "unknown" + } + log.Info( + "Clabernetes node is not ready", + "node", node.Name, + "state", nodeState, + "ready", readyCount, + "total", len(state.Nodes), + ) + } + + p.nodes = next +} + func contextDeadlineIsImminent(ctx context.Context) bool { deadline, ok := ctx.Deadline() diff --git a/labruntime/clabernetes/resources.go b/labruntime/clabernetes/resources.go index 1fa2a23143..93ac9d19a7 100644 --- a/labruntime/clabernetes/resources.go +++ b/labruntime/clabernetes/resources.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/charmbracelet/log" clabernetesapisv1alpha1 "github.com/clabernetes/clabernetes/apis/v1alpha1" clabernetesconstants "github.com/clabernetes/clabernetes/constants" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -88,6 +89,11 @@ func (r *Runtime) waitPrimitiveLinksResolved( if len(desiredLinks) == 0 { return nil } + log.Info( + "Waiting for clabernetes links to resolve", + "namespace", namespace, + "links", len(desiredLinks), + ) desiredNames := make(map[string]struct{}, len(desiredLinks)) for _, link := range desiredLinks { @@ -134,6 +140,12 @@ func (r *Runtime) waitPrimitiveLinksResolved( return len(pending) == 0, nil }) if err == nil { + log.Info( + "Clabernetes links are resolved", + "namespace", namespace, + "links", len(desiredLinks), + ) + return nil } if !errors.Is(err, context.DeadlineExceeded) {