diff --git a/.gitignore b/.gitignore index 5e62f984a..9a180986e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ bin/* rpm rpm/* dist +private # Test binary, built with `go test -c` *.test @@ -16,6 +17,9 @@ dist # Output of the go coverage tool, specifically when used with LiteIDE *.out +# clab directories +lab-examples/**/clab-* + # ignore the following files/directories graph/* license.key @@ -24,4 +28,5 @@ containerlab .vscode/ .DS_Store __rd* -tests/out \ No newline at end of file +tests/out + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..dbb6547be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/codespell-project/codespell + rev: v2.0.0 + hooks: + - id: codespell + - repo: https://github.com/syntaqx/git-hooks + rev: v0.0.17 + hooks: + - id: forbid-binary + - id: go-fmt + - id: go-test + - id: go-mod-tidy + - id: shfmt diff --git a/clab/config.go b/clab/config.go index 643062655..383f39d3e 100644 --- a/clab/config.go +++ b/clab/config.go @@ -166,7 +166,7 @@ func (c *CLab) createNodeCfg(nodeName string, nodeDef *types.NodeDefinition, idx log.Debugf("node config: %+v", nodeCfg) var err error // initialize config - nodeCfg.StartupConfig, err = c.Config.Topology.GetNodeConfig(nodeCfg.ShortName) + nodeCfg.StartupConfig, err = c.Config.Topology.GetNodeStartupConfig(nodeCfg.ShortName) if err != nil { return nil, err } @@ -188,6 +188,8 @@ func (c *CLab) createNodeCfg(nodeName string, nodeDef *types.NodeDefinition, idx } nodeCfg.Labels = c.Config.Topology.GetNodeLabels(nodeCfg.ShortName) + nodeCfg.Config = c.Config.Topology.GetNodeConfigDispatcher(nodeCfg.ShortName) + return nodeCfg, nil } @@ -196,11 +198,13 @@ func (c *CLab) NewLink(l *types.LinkConfig) *types.Link { if len(l.Endpoints) != 2 { log.Fatalf("endpoint %q has wrong syntax, unexpected number of items", l.Endpoints) } + return &types.Link{ A: c.NewEndpoint(l.Endpoints[0]), B: c.NewEndpoint(l.Endpoints[1]), MTU: defaultVethLinkMTU, Labels: l.Labels, + Vars: l.Vars, } } @@ -221,7 +225,7 @@ func (c *CLab) NewEndpoint(e string) *types.Endpoint { if len(endpoint.EndpointName) > 15 { log.Fatalf("interface '%s' name exceeds maximum length of 15 characters", endpoint.EndpointName) } - // generate unqiue MAC + // generate unique MAC endpoint.MAC = utils.GenMac(clabOUI) // search the node pointer for a node name referenced in endpoint section @@ -416,7 +420,7 @@ func (c *CLab) verifyRootNetnsInterfaceUniqueness() error { for _, e := range endpoints { if e.Node.Kind == nodes.NodeKindBridge || e.Node.Kind == nodes.NodeKindOVS || e.Node.Kind == nodes.NodeKindHOST { if _, ok := rootNsIfaces[e.EndpointName]; ok { - return fmt.Errorf(`interface %s defined for node %s has already been used in other bridges, ovs-bridges or host interfaces. + return fmt.Errorf(`interface %s defined for node %s has already been used in other bridges, ovs-bridges or host interfaces. Make sure that nodes of these kinds use unique interface names`, e.EndpointName, e.Node.ShortName) } rootNsIfaces[e.EndpointName] = struct{}{} diff --git a/clab/config/helpers.go b/clab/config/helpers.go new file mode 100644 index 000000000..df3347378 --- /dev/null +++ b/clab/config/helpers.go @@ -0,0 +1,41 @@ +package config + +import ( + "strings" +) + +// Split a string on commas and trim each line +func SplitTrim(s string) []string { + res := strings.Split(s, ",") + for i, v := range res { + res[i] = strings.Trim(v, " \n\t") + } + return res +} + +// The new agreed node config +type NodeSettings struct { + Vars map[string]string + Transport string + Templates []string +} + +// Temporary function to extract NodeSettings from the Labels +// In the next phase node settings will be added to the clab file +func GetNodeConfig(labels map[string]string) NodeSettings { + nc := NodeSettings{ + Vars: labels, + Transport: "ssh", + } + if len(TemplateNames) > 0 { + nc.Templates = TemplateNames + } else if t, ok := labels["templates"]; ok { + nc.Templates = SplitTrim(t) + } else { + nc.Templates = []string{"base"} + } + if t, ok := labels["transport"]; ok { + nc.Transport = t + } + return nc +} diff --git a/clab/config/template.go b/clab/config/template.go new file mode 100644 index 000000000..ecf3cfd5c --- /dev/null +++ b/clab/config/template.go @@ -0,0 +1,110 @@ +package config + +import ( + "encoding/json" + "fmt" + "strings" + + jT "github.com/kellerza/template" + + log "github.com/sirupsen/logrus" + "github.com/srl-labs/containerlab/nodes" + "github.com/srl-labs/containerlab/types" +) + +// templates to execute +var TemplateNames []string + +// path to additional templates +var TemplatePaths []string + +type NodeConfig struct { + TargetNode *types.NodeConfig + // All the variables used to render the template + Vars map[string]interface{} + // the Rendered templates + Data []string + Info []string +} + +func RenderAll(nodes map[string]nodes.Node, links map[int]*types.Link) (map[string]*NodeConfig, error) { + res := make(map[string]*NodeConfig) + + if len(TemplatePaths) == 0 { + return nil, fmt.Errorf("please specify one of more paths with --template-path") + } + + if len(TemplateNames) == 0 { + var err error + TemplateNames, err = GetTemplateNamesInDirs(TemplatePaths) + if err != nil { + return nil, err + } + if len(TemplateNames) == 0 { + return nil, fmt.Errorf("no templates files were found by %s path", TemplatePaths) + } + } + + tmpl, err := jT.New("", jT.SearchPath(TemplatePaths...)) + if err != nil { + return nil, err + } + + for nodeName, vars := range PrepareVars(nodes, links) { + res[nodeName] = &NodeConfig{ + TargetNode: nodes[nodeName].Config(), + Vars: vars, + } + + for _, baseN := range TemplateNames { + tmplN := fmt.Sprintf("%s__%s.tmpl", baseN, vars["role"]) + data1, err := tmpl.ExecuteTemplate(tmplN, vars) + if err != nil { + return nil, err + } + data1 = strings.ReplaceAll(strings.Trim(data1, "\n \t"), "\n\n\n", "\n\n") + res[nodeName].Data = append(res[nodeName].Data, data1) + res[nodeName].Info = append(res[nodeName].Info, tmplN) + } + } + return res, nil +} + +// Implement stringer for conf snippet +func (c *NodeConfig) String() string { + + s := fmt.Sprintf("%s: %v", c.TargetNode.ShortName, c.Info) + return s +} + +// Print the config +func (c *NodeConfig) Print(printLines int) { + var s strings.Builder + + s.WriteString(c.TargetNode.ShortName) + + if log.IsLevelEnabled(log.DebugLevel) { + s.WriteString(" vars = ") + vars, _ := json.MarshalIndent(c.Vars, "", " ") + s.Write(vars[0 : len(vars)-1]) + s.WriteString(" }") + } + + if printLines > 0 { + for idx, conf := range c.Data { + fmt.Fprintf(&s, "\n Template %s for %s = [[", c.Info[idx], c.TargetNode.ShortName) + + cl := strings.SplitN(conf, "\n", printLines+1) + if len(cl) > printLines { + cl[printLines] = "..." + } + for _, l := range cl { + s.WriteString("\n ") + s.WriteString(l) + } + s.WriteString("\n ]]") + } + } + + log.Infoln(s.String()) +} diff --git a/clab/config/transport/ssh.go b/clab/config/transport/ssh.go new file mode 100644 index 000000000..f6b102a80 --- /dev/null +++ b/clab/config/transport/ssh.go @@ -0,0 +1,418 @@ +package transport + +import ( + "fmt" + "io" + "net" + "runtime" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "github.com/srl-labs/containerlab/types" + "golang.org/x/crypto/ssh" +) + +type SSHSession struct { + In io.Reader + Out io.WriteCloser + Session *ssh.Session +} + +type SSHTransportOption func(*SSHTransport) error + +// The SSH reply, executed command and the prompt +type SSHReply struct{ result, prompt, command string } + +// SSHTransport setting needs to be set before calling Connect() +// SSHTransport implements the Transport interface +type SSHTransport struct { + // Channel used to read. Can use Expect to Write & read with timeout + in chan SSHReply + // SSH Session + ses *SSHSession + // Contains the first read after connecting + LoginMessage *SSHReply + // SSH parameters used in connect + // default: 22 + Port int + + // Keep the target for logging + Target string + + // SSH Options + // required! + SSHConfig *ssh.ClientConfig + + // Character to split the incoming stream (#/$/>) + // default: # + PromptChar string + + // Kind specific transactions & prompt checking function + K SSHKind +} + +// Add username & password authentication +func WithUserNamePassword(username, password string) SSHTransportOption { + return func(tx *SSHTransport) error { + tx.SSHConfig.User = username + if tx.SSHConfig.Auth == nil { + tx.SSHConfig.Auth = []ssh.AuthMethod{} + } + tx.SSHConfig.Auth = append(tx.SSHConfig.Auth, ssh.Password(password)) + return nil + } +} + +// Add a basic username & password to a config +// Will initialize the config if required +func HostKeyCallback(callback ...ssh.HostKeyCallback) SSHTransportOption { + return func(tx *SSHTransport) error { + tx.SSHConfig.HostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if len(callback) == 0 { + log.Warnf("Skipping host key verification for %s", hostname) + return nil + } + for _, hkc := range callback { + if hkc(hostname, remote, key) == nil { + return nil + } + } + return fmt.Errorf("invalid host key %s: %s", hostname, key) + } + return nil + } +} + +func NewSSHTransport(node *types.NodeConfig, options ...SSHTransportOption) (*SSHTransport, error) { + switch node.Kind { + case "vr-sros", "srl": + c := &SSHTransport{} + c.SSHConfig = &ssh.ClientConfig{} + + // apply options + for _, opt := range options { + err := opt(c) + if err != nil { + return nil, err + } + } + + switch node.Kind { + case "vr-sros": + c.K = &VrSrosSSHKind{} + case "srl": + c.K = &SrlSSHKind{} + } + return c, nil + } + return nil, fmt.Errorf("no transport implemented for kind: %s", node.Kind) +} + +// Creates the channel reading the SSH connection +// +// The first prompt is saved in LoginMessages +// +// - The channel read the SSH session, splits on PromptChar +// - Uses SSHKind's PromptParse to split the received data in *result* and *prompt* parts +// (if no valid prompt was found, prompt will simply be empty and result contain all the data) +// - Emit data +func (t *SSHTransport) InChannel() { + // Ensure we have a working channel + t.in = make(chan SSHReply) + + // setup a buffered string channel + go func() { + buf := make([]byte, 1024) + tmpS := "" + n, err := t.ses.In.Read(buf) //this reads the ssh terminal + if err == nil { + tmpS = string(buf[:n]) + } + for err == nil { + + if strings.Contains(tmpS, "#") { + parts := strings.Split(tmpS, "#") + li := len(parts) - 1 + for i := 0; i < li; i++ { + r := t.K.PromptParse(t, &parts[i]) + if r == nil { + r = &SSHReply{ + result: parts[i], + } + } + t.in <- *r + } + tmpS = parts[li] + } + n, err = t.ses.In.Read(buf) + tmpS += string(buf[:n]) + } + log.Debugf("In Channel closing: %v", err) + t.in <- SSHReply{ + result: tmpS, + prompt: "", + } + }() + + // Save first prompt + t.LoginMessage = t.Run("", 15) + if DebugCount > 1 { + t.LoginMessage.Info(t.Target) + } +} + +// Run a single command and wait for the reply +func (t *SSHTransport) Run(command string, timeout int) *SSHReply { + if command != "" { + t.ses.Writeln(command) + log.Debugf("--> %s\n", command) + } + + sHistory := "" + + for { + // Read from the channel with a timeout + var rr string + + select { + case <-time.After(time.Duration(timeout) * time.Second): + log.Warnf("timeout waiting for prompt: %s", command) + return &SSHReply{ + result: sHistory, + command: command, + } + case ret := <-t.in: + if DebugCount > 1 { + ret.Debug(t.Target, command+"<--InChannel--") + } + + if ret.result == "" && ret.prompt == "" { + log.Fatalf("received zero?") + continue + } + + if ret.prompt == "" && ret.result != "" { + // we should continue reading... + sHistory += ret.result + if DebugCount > 1 { + log.Debugf("+") + } + timeout = 2 // reduce timeout, node is already sending data + continue + } + + if sHistory == "" { + rr = ret.result + } else { + rr = sHistory + "#" + ret.result + sHistory = "" + } + rr = strings.Trim(rr, " \n\r\t") + + if strings.HasPrefix(rr, command) { + rr = strings.Trim(rr[len(command):], " \n\r\t") + } else if !strings.Contains(rr, command) { + log.Debugf("read more %s:%s", command, rr) + sHistory = rr + continue + } + res := &SSHReply{ + result: rr, + prompt: ret.prompt, + command: command, + } + res.Debug(t.Target, command+"<--RUN--") + return res + } + } +} + +// Write a config snippet (a set of commands) +// Session NEEDS to be configurable for other kinds +// Part of the Transport interface +func (t *SSHTransport) Write(data, info *string) error { + if *data == "" { + return nil + } + + transaction := !strings.HasPrefix(*info, "show-") + + err := t.K.ConfigStart(t, transaction) + if err != nil { + return err + } + + c := 0 + + for _, l := range strings.Split(*data, "\n") { + l = strings.TrimSpace(l) + if l == "" || strings.HasPrefix(l, "#") { + continue + } + c += 1 + t.Run(l, 5).Info(t.Target) + } + + if transaction { + commit, err := t.K.ConfigCommit(t) + msg := fmt.Sprintf("%s COMMIT - %d lines", *info, c) + if commit.result != "" { + msg += commit.LogString(t.Target, true, false) + } + if err != nil { + log.Error(msg) + return err + } + log.Info(msg) + } + + return nil +} + +// Connect to a host +// Part of the Transport interface +func (t *SSHTransport) Connect(host string, options ...TransportOption) error { + // Assign Default Values + if t.PromptChar == "" { + t.PromptChar = "#" + } + if t.Port == 0 { + t.Port = 22 + } + if t.SSHConfig == nil { + return fmt.Errorf("require auth credentials in SSHConfig") + } + + // Start some client config + host = fmt.Sprintf("%s:%d", host, t.Port) + //sshConfig := &ssh.ClientConfig{} + //SSHConfigWithUserNamePassword(sshConfig, "admin", "admin") + + t.Target = strings.Split(strings.Split(host, ":")[0], "-")[2] + + ses_, err := NewSSHSession(host, t.SSHConfig) + if err != nil || ses_ == nil { + return fmt.Errorf("cannot connect to %s: %s", host, err) + } + t.ses = ses_ + + log.Infof("Connected to %s\n", host) + t.InChannel() + //Read to first prompt + return nil +} + +// Close the Session and channels +// Part of the Transport interface +func (t *SSHTransport) Close() { + if t.in != nil { + close(t.in) + t.in = nil + } + t.ses.Close() +} + +// Create a new SSH session (Dial, open in/out pipes and start the shell) +// pass the authntication details in sshConfig +func NewSSHSession(host string, sshConfig *ssh.ClientConfig) (*SSHSession, error) { + if !strings.Contains(host, ":") { + return nil, fmt.Errorf("include the port in the host: %s", host) + } + + connection, err := ssh.Dial("tcp", host, sshConfig) + if err != nil { + return nil, fmt.Errorf("failed to connect: %s", err) + } + session, err := connection.NewSession() + if err != nil { + return nil, err + } + sshIn, err := session.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("session stdout: %s", err) + } + sshOut, err := session.StdinPipe() + if err != nil { + return nil, fmt.Errorf("session stdin: %s", err) + } + // sshIn2, err := session.StderrPipe() + // if err != nil { + // return nil, fmt.Errorf("session stderr: %s", err) + // } + // Request PTY (required for srl) + modes := ssh.TerminalModes{ + ssh.ECHO: 1, // disable echo + } + err = session.RequestPty("dumb", 24, 100, modes) + if err != nil { + session.Close() + return nil, fmt.Errorf("pty request failed: %s", err) + } + + if err := session.Shell(); err != nil { + session.Close() + return nil, fmt.Errorf("session shell: %s", err) + } + + return &SSHSession{ + Session: session, + In: sshIn, + Out: sshOut, + }, nil +} + +func (ses *SSHSession) Writeln(command string) (int, error) { + return ses.Out.Write([]byte(command + "\r")) +} + +func (ses *SSHSession) Close() { + log.Debugf("Closing session") + ses.Session.Close() +} + +// The LogString will include the entire SSHReply +// Each field will be prefixed by a character. +// # - command sent +// | - result received +// ? - prompt part of the result +func (r *SSHReply) LogString(node string, linefeed, debug bool) string { + ind := 12 + len(node) + prefix := "\n" + strings.Repeat(" ", ind) + s := "" + if linefeed { + s = "\n" + strings.Repeat(" ", 11) + } + s += node + " # " + r.command + s += prefix + "| " + s += strings.Join(strings.Split(r.result, "\n"), prefix+"| ") + if debug { // Add the prompt & more + s = "" + strings.Repeat(" ", ind) + s + s += prefix + "? " + s += strings.Join(strings.Split(r.prompt, "\n"), prefix+"? ") + if DebugCount > 3 { // add bytestring + s += fmt.Sprintf("%s| %v%s ? %v", prefix, []byte(r.result), prefix, []byte(r.prompt)) + } + + } + return s +} + +func (r *SSHReply) Info(node string) *SSHReply { + if r.result == "" { + return r + } + log.Info(r.LogString(node, false, false)) + return r +} + +func (r *SSHReply) Debug(node, message string, t ...interface{}) { + msg := message + if len(t) > 0 { + msg = t[0].(string) + } + _, fn, line, _ := runtime.Caller(1) + msg += fmt.Sprintf("(%s line %d)", fn, line) + msg += r.LogString(node, true, true) + log.Debugf(msg) +} diff --git a/clab/config/transport/sshkind.go b/clab/config/transport/sshkind.go new file mode 100644 index 000000000..2e65090b3 --- /dev/null +++ b/clab/config/transport/sshkind.go @@ -0,0 +1,114 @@ +package transport + +import ( + "fmt" + "strings" + + log "github.com/sirupsen/logrus" +) + +// An interface to implement kind specific methods for transactions and prompt checking +type SSHKind interface { + // Start a config transaction + ConfigStart(s *SSHTransport, transaction bool) error + // Commit a config transaction + ConfigCommit(s *SSHTransport) (*SSHReply, error) + // Prompt parsing function + // + // This function receives string, split by the delimiter and should ensure this is a valid prompt + // Valid prompt, strip the prompt from the result and add it to the prompt in SSHReply + // + // A default implementation is promptParseNoSpaces, which simply ensures there are + // no spaces between the start of the line and the # + PromptParse(s *SSHTransport, in *string) *SSHReply +} + +// implements SShKind +type VrSrosSSHKind struct{} + +func (sk *VrSrosSSHKind) ConfigStart(s *SSHTransport, transaction bool) error { + s.PromptChar = "#" // ensure it's '#' + //s.debug = true + r := s.Run("/environment more false", 5) + if r.result != "" { + log.Warnf("%s Are you in MD-Mode?%s", s.Target, r.LogString(s.Target, true, false)) + } + + if transaction { + s.Run("/configure global", 5).Info(s.Target) + s.Run("discard", 1).Info(s.Target) + } + return nil +} +func (sk *VrSrosSSHKind) ConfigCommit(s *SSHTransport) (*SSHReply, error) { + res := s.Run("commit", 10) + if res.result != "" { + return res, fmt.Errorf("could not commit %s", res.result) + } + return res, nil +} + +func (sk *VrSrosSSHKind) PromptParse(s *SSHTransport, in *string) *SSHReply { + // SROS MD-CLI \r...prompt + r := strings.LastIndex(*in, "\r\n\r\n") + if r > 0 { + return &SSHReply{ + result: (*in)[:r], + prompt: (*in)[r+4:] + s.PromptChar, + } + } + return nil +} + +// implements SShKind +type SrlSSHKind struct{} + +func (sk *SrlSSHKind) ConfigStart(s *SSHTransport, transaction bool) error { + s.PromptChar = "#" // ensure it's '#' + if transaction { + r0 := s.Run("enter candidate private", 5) + r1 := s.Run("discard stay", 2) + if !strings.Contains(r1.result, "Nothing to discard") { + r0.result += "; " + r1.result + r0.command += "; " + r1.command + } + r0.Info(s.Target) + } + return nil +} +func (sk *SrlSSHKind) ConfigCommit(s *SSHTransport) (*SSHReply, error) { + r := s.Run("commit now", 10) + if strings.Contains(r.result, "All changes have been committed") { + r.result = "" + } else { + return r, fmt.Errorf("could not commit %s", r.result) + } + return r, nil +} +func (sk *SrlSSHKind) PromptParse(s *SSHTransport, in *string) *SSHReply { + return promptParseNoSpaces(in, s.PromptChar, 2) +} + +// This is a helper function to parse the prompt, and can be used by SSHKind's ParsePrompt +// Used in SRL today +func promptParseNoSpaces(in *string, promptChar string, lines int) *SSHReply { + n := strings.LastIndex(*in, "\n") + if n < 0 { + return nil + } + if strings.Contains((*in)[n:], " ") { + return nil + } + if lines > 1 { + // Add another line to the prompt + res := (*in)[:n] + n = strings.LastIndex(res, "\n") + } + if n < 0 { + n = 0 + } + return &SSHReply{ + result: (*in)[:n], + prompt: (*in)[n:] + promptChar, + } +} diff --git a/clab/config/transport/transport.go b/clab/config/transport/transport.go new file mode 100644 index 000000000..ad3f83db9 --- /dev/null +++ b/clab/config/transport/transport.go @@ -0,0 +1,39 @@ +package transport + +import ( + "fmt" +) + +// Debug count +var DebugCount int + +type TransportOption func(*Transport) + +type Transport interface { + // Connect to the target host + Connect(host string, options ...TransportOption) error + // Execute some config + Write(data *string, info *string) error + Close() +} + +// Write config to a node +func Write(tx Transport, host string, data, info []string, options ...TransportOption) error { + // the Kind should configure the transport parameters before + + err := tx.Connect(host, options...) + if err != nil { + return fmt.Errorf("%s: %s", host, err) + } + + defer tx.Close() + + for i1, d1 := range data { + err := tx.Write(&d1, &info[i1]) + if err != nil { + return fmt.Errorf("could not write config %s: %s", d1, err) + } + } + + return nil +} diff --git a/clab/config/utils.go b/clab/config/utils.go new file mode 100644 index 000000000..bacfeb5a1 --- /dev/null +++ b/clab/config/utils.go @@ -0,0 +1,202 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + log "github.com/sirupsen/logrus" + "github.com/srl-labs/containerlab/nodes" + "github.com/srl-labs/containerlab/types" + "inet.af/netaddr" +) + +const ( + systemIP = "systemip" +) + +type Dict map[string]interface{} + +// Prepare variables for all nodes. This will also prepare all variables for the links +func PrepareVars(nodes map[string]nodes.Node, links map[int]*types.Link) map[string]Dict { + + res := make(map[string]Dict) + + // preparing all nodes vars + for _, node := range nodes { + nodeCfg := node.Config() + name := nodeCfg.ShortName + // Init array for this node + res[name] = make(map[string]interface{}) + nc := GetNodeConfig(nodeCfg.Config.Vars) + for key := range nc.Vars { + res[name][key] = nc.Vars[key] + } + // Create link array + res[name]["links"] = []interface{}{} + // Ensure role or Kind + if _, ok := res[name]["role"]; !ok { + res[name]["role"] = nodeCfg.Kind + } + } + + // prepare all links + for lIdx, link := range links { + varsA := make(map[string]interface{}) + varsB := make(map[string]interface{}) + err := prepareLinkVars(lIdx, link, varsA, varsB) + if err != nil { + log.Errorf("cannot prepare link vars for %d. %s: %s", lIdx, link.String(), err) + } + res[link.A.Node.ShortName]["links"] = append(res[link.A.Node.ShortName]["links"].([]interface{}), varsA) + res[link.B.Node.ShortName]["links"] = append(res[link.B.Node.ShortName]["links"].([]interface{}), varsB) + } + return res +} + +// Prepare variables for a specific link +func prepareLinkVars(lIdx int, link *types.Link, varsA, varsB map[string]interface{}) error { + ncA := GetNodeConfig(link.A.Node.Config.Vars) + ncB := GetNodeConfig(link.B.Node.Config.Vars) + + addV := func(key string, v1 interface{}, v2 ...interface{}) { + varsA[key] = v1 + if len(v2) == 0 { + varsB[key] = v1 + } else { + varsA[key+"_far"] = v2[0] + varsB[key] = v2[0] + varsB[key+"_far"] = v1 + } + } + + // Link IPs + ipA, ipB, err := linkIPfromSystemIP(link) + if err != nil { + return fmt.Errorf("%s: %s", link, err) + } + + addV("ip", ipA.String(), ipB.String()) + addV(systemIP, ncA.Vars[systemIP], ncB.Vars[systemIP]) + + // Split all fields with a comma... + for k, v := range link.Vars { + r := SplitTrim(v) + switch len(r) { + case 1: + addV(k, r[0]) + case 2: + addV(k, r[0], r[1]) + default: + log.Warnf("%s: %s contains %d elements, should be 1 or 2: %s", link.String(), k, len(r), v) + } + } + + if _, ok := varsA["name"]; !ok { + var linkNr string + if v, ok := varsA["linkNr"]; ok { + linkNr = fmt.Sprintf("_%v", v) + } + addV("name", fmt.Sprintf("to_%s%s", link.B.Node.ShortName, linkNr), + fmt.Sprintf("to_%s%s", link.A.Node.ShortName, linkNr)) + } + + return nil +} + +func linkIPfromSystemIP(link *types.Link) (netaddr.IPPrefix, netaddr.IPPrefix, error) { + var ipA netaddr.IPPrefix + var err error + if linkIp, ok := link.Vars["ip"]; ok { + // calc far end IP + ipA, err = netaddr.ParseIPPrefix(linkIp) + if err != nil { + return ipA, ipA, fmt.Errorf("invalid ip %s", link.A.EndpointName) + } + } else { + // Calculate link IP from the system IPs + sysA, err := netaddr.ParseIPPrefix(link.A.Node.Config.Vars[systemIP]) + if err != nil { + return ipA, ipA, fmt.Errorf("no 'ip' on link & the '%s' of %s: %s", systemIP, link.A.Node.ShortName, err) + } + sysB, err := netaddr.ParseIPPrefix(link.B.Node.Config.Vars[systemIP]) + if err != nil { + return ipA, ipA, fmt.Errorf("no 'ip' on link & the '%s' of %s: %s", systemIP, link.B.Node.ShortName, err) + } + o2, o3, o4 := ipLastOctet(sysA.IP()), ipLastOctet(sysB.IP()), 0 + if o3 < o2 { + o2, o3, o4 = o3, o2, o4+1 + } + ipA, err = netaddr.ParseIPPrefix(fmt.Sprintf("1.%d.%d.%d/31", o2, o3, o4)) + if err != nil { + log.Errorf("could not create link IP from system-ip: %s", err) + } + } + return ipA, ipFarEnd(ipA), nil +} + +func ipLastOctet(in netaddr.IP) int { + s := in.String() + i := strings.LastIndexAny(s, ".") + if i < 0 { + i = strings.LastIndexAny(s, ":") + } + res, err := strconv.Atoi(s[i+1:]) + if err != nil { + log.Errorf("last octet %s from IP %s not a string", s[i+1:], s) + } + return res +} + +func ipFarEnd(in netaddr.IPPrefix) netaddr.IPPrefix { + if in.IP().Is4() && in.Bits() == 32 { + return netaddr.IPPrefix{} + } + + n := in.IP().Next() + + if in.IP().Is4() && in.Bits() <= 30 { + if !in.Contains(n) || !in.Contains(in.IP().Prior()) { + return netaddr.IPPrefix{} + } + if !in.Contains(n.Next()) { + n = in.IP().Prior() + } + } + if !in.Contains(n) { + n = in.IP().Prior() + } + if !in.Contains(n) { + return netaddr.IPPrefix{} + } + return netaddr.IPPrefixFrom(n, in.Bits()) +} + +// GetTemplateNamesInDirs returns a list of template file names found in a dir p +// without traversing nested dirs +// template names are following the pattern __.tmpl +func GetTemplateNamesInDirs(paths []string) ([]string, error) { + var tnames []string + for _, p := range paths { + files, err := os.ReadDir(p) + if err != nil { + return nil, err + } + + for _, file := range files { + if file.IsDir() { + continue + } + var tn string + fn := file.Name() + if strings.Contains(fn, "__") { + tn = strings.Split(fn, "__")[0] + } + + tnames = append(tnames, tn) + } + } + + return tnames, nil +} diff --git a/clab/config/utils_test.go b/clab/config/utils_test.go new file mode 100644 index 000000000..bfd275c31 --- /dev/null +++ b/clab/config/utils_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "testing" + + "inet.af/netaddr" +) + +func TestFarEndIP(t *testing.T) { + + lst := map[string]string{ + "10.0.0.1/32": "", + + "10.0.0.0/31": "10.0.0.1/31", + "10.0.0.1/31": "10.0.0.0/31", + "10.0.0.2/31": "10.0.0.3/31", + "10.0.0.3/31": "10.0.0.2/31", + + "10.0.0.1/30": "10.0.0.2/30", + "10.0.0.2/30": "10.0.0.1/30", + "10.0.0.0/30": "", + "10.0.0.3/30": "", + "10.0.0.4/30": "", + "10.0.0.5/30": "10.0.0.6/30", + "10.0.0.6/30": "10.0.0.5/30", + } + + for k, v := range lst { + n := ipFarEnd(netaddr.MustParseIPPrefix(k)) + if n.IsZero() && v == "" { + continue + } + if v != n.String() { + t.Errorf("far end of %s, got %s, expected %s", k, n, v) + } + } + +} + +func TestIPLastOctect(t *testing.T) { + + lst := map[string]int{ + "10.0.0.1/32": 1, + "::1/32": 1, + } + for k, v := range lst { + n := netaddr.MustParseIPPrefix(k) + lo := ipLastOctet(n.IP()) + if v != lo { + t.Errorf("far end of %s, got %d, expected %d", k, lo, v) + } + } + +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 000000000..af2cb1fd0 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "sync" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/srl-labs/containerlab/clab" + "github.com/srl-labs/containerlab/clab/config" + "github.com/srl-labs/containerlab/clab/config/transport" + "github.com/srl-labs/containerlab/nodes" +) + +// Only print config locally, dont send to the node +var printLines int + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "configure a lab", + Long: "configure a lab based using templates and variables from the topology definition file\nreference: https://containerlab.srlinux.dev/cmd/config/", + Aliases: []string{"conf"}, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + + transport.DebugCount = debugCount + + c := clab.NewContainerLab( + clab.WithDebug(debug), + clab.WithTimeout(timeout), + clab.WithTopoFile(topo), + ) + + // setFlags(c.Config) + log.Debugf("Topology definition: %+v", c.Config) + // Parse topology information + if err = c.ParseTopology(); err != nil { + return err + } + + // Config map per node. Each node gets a config.NodeConfig + allConfig, err := config.RenderAll(c.Nodes, c.Links) + if err != nil { + return err + } + + if printLines > 0 { + for _, c := range allConfig { + c.Print(printLines) + } + return nil + } + + var wg sync.WaitGroup + wg.Add(len(allConfig)) + for _, cs_ := range allConfig { + deploy1 := func(cs *config.NodeConfig) { + defer wg.Done() + + var tx transport.Transport + var err error + + ct, ok := cs.TargetNode.Labels["config.transport"] + if !ok { + ct = "ssh" + } + + if ct == "ssh" { + tx, err = transport.NewSSHTransport( + cs.TargetNode, + transport.WithUserNamePassword( + nodes.DefaultCredentials[cs.TargetNode.Kind][0], + nodes.DefaultCredentials[cs.TargetNode.Kind][1]), + transport.HostKeyCallback(), + ) + if err != nil { + log.Errorf("%s: %s", kind, err) + } + } else if ct == "grpc" { + // NewGRPCTransport + } else { + log.Errorf("Unknown transport: %s", ct) + return + } + + err = transport.Write(tx, cs.TargetNode.LongName, cs.Data, cs.Info) + if err != nil { + log.Errorf("%s\n", err) + } + } + + // On debug this will not be executed concurrently + if log.IsLevelEnabled(log.DebugLevel) { + deploy1(cs_) + } else { + go deploy1(cs_) + } + } + wg.Wait() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(configCmd) + configCmd.Flags().StringSliceVarP(&config.TemplatePaths, "template-path", "p", []string{}, "comma separated list of paths to search for templates") + configCmd.MarkFlagDirname("template-path") + configCmd.Flags().StringSliceVarP(&config.TemplateNames, "template-list", "l", []string{}, "comma separated list of template names to render") + configCmd.Flags().IntVarP(&printLines, "check", "c", 0, "render dry-run & print n lines of config") +} diff --git a/cmd/root.go b/cmd/root.go index dd32619b9..9ea840249 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" ) +var debugCount int var debug bool var timeout time.Duration @@ -29,6 +30,7 @@ var rootCmd = &cobra.Command{ Use: "containerlab", Short: "deploy container based lab environments with a user-defined interconnections", PersistentPreRun: func(cmd *cobra.Command, args []string) { + debug = debugCount > 0 if debug { log.SetLevel(log.DebugLevel) } @@ -46,7 +48,7 @@ func Execute() { func init() { rootCmd.SilenceUsage = true - rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "enable debug mode") + rootCmd.PersistentFlags().CountVarP(&debugCount, "debug", "d", "enable debug mode") rootCmd.PersistentFlags().StringVarP(&topo, "topo", "t", "", "path to the file with topology information") _ = rootCmd.MarkPersistentFlagFilename("topo", "*.yaml", "*.yml") rootCmd.PersistentFlags().StringVarP(&name, "name", "n", "", "lab name") diff --git a/go.mod b/go.mod index 12f842a58..1c439e04c 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/google/uuid v1.2.0 github.com/hashicorp/go-version v1.2.1 github.com/jsimonetti/rtnetlink v0.0.0-20210226120601-1b79e63a70a0 + github.com/kellerza/template v0.0.4 github.com/mitchellh/go-homedir v1.1.0 github.com/morikuni/aec v1.0.0 // indirect github.com/olekukonko/tablewriter v0.0.5-0.20201029120751-42e21c7531a3 @@ -26,6 +27,8 @@ require ( github.com/sirupsen/logrus v1.8.1 github.com/spf13/cobra v1.0.0 github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b golang.org/x/term v0.0.0-20210503060354-a79de5458b56 gopkg.in/yaml.v2 v2.4.0 + inet.af/netaddr v0.0.0-20210521171555-9ee55bc0c50b ) diff --git a/go.sum b/go.sum index 9d80bb156..d22e8fb43 100644 --- a/go.sum +++ b/go.sum @@ -253,6 +253,7 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= @@ -420,6 +421,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kellerza/template v0.0.4 h1:z/qhD9D50bEhbTu/1JN6ANiTECws8rKQBKmLDdwK6hs= +github.com/kellerza/template v0.0.4/go.mod h1:Og3Jdssypk1J/bNpWIrmymW5FOWI88vsY5Qx/e57V7M= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -697,6 +700,11 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go4.org/intern v0.0.0-20210108033219-3eb7198706b2 h1:VFTf+jjIgsldaz/Mr00VaCSswHJrI2hIjQygE/W4IMg= +go4.org/intern v0.0.0-20210108033219-3eb7198706b2/go.mod h1:vLqJ+12kCw61iCWsPto0EOHhBS+o4rO5VIucbc9g2Cc= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222175341-b30ae309168e/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063 h1:1tk03FUNpulq2cuWpXZWj649rwJpk0d20rxWiopKRmc= +go4.org/unsafe/assume-no-moving-gc v0.0.0-20201222180813-1025295fd063/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -859,11 +867,13 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210216163648-f7da38b97c65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= @@ -917,6 +927,7 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 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.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 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= @@ -1029,6 +1040,8 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +inet.af/netaddr v0.0.0-20210521171555-9ee55bc0c50b h1:GoOAHwQKK/dkkO/DgT/NQCSNZuXS6tlgLsHiXRakEiY= +inet.af/netaddr v0.0.0-20210521171555-9ee55bc0c50b/go.mod h1:z0nx+Dh+7N7CC8V5ayHtHGpZpxLQZZxkIaaz6HN65Ls= k8s.io/api v0.20.1/go.mod h1:KqwcCVogGxQY3nBlRpwt+wpAMF/KjaCc7RpywacvqUo= k8s.io/api v0.20.4/go.mod h1:++lNL1AJMkDymriNniQsWRkMDzRaX2Y/POTUi8yvqYQ= k8s.io/api v0.20.6/go.mod h1:X9e8Qag6JV/bL5G6bU8sdVRltWKmdHsFUGS3eVndqE8= diff --git a/lab-examples/vr05/sros4.clab.yml b/lab-examples/vr05/sros4.clab.yml new file mode 100644 index 000000000..767fa791c --- /dev/null +++ b/lab-examples/vr05/sros4.clab.yml @@ -0,0 +1,50 @@ +name: conf1 + +topology: + defaults: + kind: vr-sros + image: registry.srlinux.dev/pub/vr-sros:21.2.R1 + license: license-sros21.txt + labels: + isis_iid: 0 + nodes: + sr1: + config: + vars: + systemip: 10.0.50.31/32 + sid_idx: 1 + sr2: + config: + vars: + systemip: 10.0.50.32/32 + sid_idx: 2 + sr3: + config: + vars: + systemip: 10.0.50.33/32 + sid_idx: 3 + sr4: + config: + vars: + systemip: 10.0.50.34/32 + sid_idx: 4 + links: + - endpoints: [sr1:eth1, sr2:eth2] + vars: + port: 1/1/c1/1, 1/1/c2/1 + ip: 1.1.1.2/30 + vlan: "99,99" + isis_iid: 0 + - endpoints: [sr2:eth1, sr3:eth2] + vars: + port: 1/1/c1/1, 1/1/c2/1 + vlan: 98 + isis_iid: 0 + - endpoints: [sr3:eth1, sr4:eth2] + vars: + port: 1/1/c1/1, 1/1/c2/1 + isis_iid: 0 + - endpoints: [sr4:eth1, sr1:eth2] + vars: + port: 1/1/c1/1, 1/1/c2/1 + isis_iid: 0 diff --git a/lab-examples/vr05/vr01.clab.yml b/lab-examples/vr05/vr01.clab.yml new file mode 100644 index 000000000..0da9da43c --- /dev/null +++ b/lab-examples/vr05/vr01.clab.yml @@ -0,0 +1,30 @@ +name: vr01 + +topology: + nodes: + srl: + kind: srl + image: registry.srlinux.dev/pub/srlinux:21.3.1-410 + license: license.key + config: + vars: + systemip: 10.0.50.50/32 + isis_iid: 0 + sid_idx: 11 + sros: + kind: vr-sros + image: registry.srlinux.dev/pub/vr-sros:21.2.R1 + type: sr-1 + license: license-sros21.txt + config: + vars: + systemip: 10.0.50.51/32 + sid_idx: 10 + isis_iid: 0 + + links: + - endpoints: ["srl:e1-1", "sros:eth1"] + vars: + port: ethernet-1/1, 1/1/c1/1 + vlan: 10 + isis_iid: 0 diff --git a/nodes/node.go b/nodes/node.go index d95cf8681..4071e2ae2 100644 --- a/nodes/node.go +++ b/nodes/node.go @@ -74,6 +74,7 @@ var DefaultConfigTemplates = map[string]string{ // DefaultCredentials holds default username and password per each kind var DefaultCredentials = map[string][]string{ + "srl": {"admin", "admin"}, "vr-sros": {"admin", "admin"}, "vr-vmx": {"admin", "admin@123"}, "vr-xrv9k": {"clab", "clab@123"}, diff --git a/templates/base__srl.tmpl b/templates/base__srl.tmpl new file mode 100644 index 000000000..a54eeab5b --- /dev/null +++ b/templates/base__srl.tmpl @@ -0,0 +1,75 @@ +{{ expect .systemip "ip" }} +{{ optional .isis_iid "0-31" }} +{{ range .links }} + {{ expect .port "^(ethernet-\\d+/|e\\d+-)\\d+$" }} + {{ expect .name "string" }} + {{ expect .ip "ip" }} + {{ optional .vlan "0-4095" }} + {{ optional .metric "1-10000" }} +{{ end }} + +/interface lo0 { + admin-state enable + subinterface 0 { + ipv4 { + address {{ .systemip }} { + } + } + ipv6 { + address ::ffff:{{ ip .systemip }}/128 { + } + } + } +} + +/network-instance default { + router-id {{ ip .systemip }} + interface lo0.0 { + } + protocols { + isis { + instance default { + admin-state enable + level-capability L2 + set level 2 metric-style wide + # net should not be multiline (net [), becasue of the SRL ... prompt + net [ 49.0000.0000.0000.0{{ default 0 .isis_iid }} ] + interface lo0.0 { + } + } + } + } +} + + /system lldp admin-state enable + + +{{ range .links }} +/interface {{ .port }} { + admin-state enable + vlan-tagging true + subinterface {{ default 10 .vlan }} { + set vlan encap single-tagged vlan-id {{ default 10 .vlan }} + set ipv4 address {{ .ip }} + set ipv6 address ::FFFF:{{ ip .ip }}/127 + } +} + +/network-instance default { + interface {{ .port }}.{{ default 10 .vlan }} { + } + protocols { + isis { + instance default { + interface {{ .port }}.{{ default 10 .vlan }} { + circuit-type point-to-point + level 2 { + metric {{ default 10 .metric }} + } + } + } + } + } +} + +{{ end }} \ No newline at end of file diff --git a/templates/base__vr-sros.tmpl b/templates/base__vr-sros.tmpl new file mode 100644 index 000000000..e69de29bb diff --git a/templates/srl-ifaces__srl.tmpl b/templates/srl-ifaces__srl.tmpl new file mode 100644 index 000000000..88188c967 --- /dev/null +++ b/templates/srl-ifaces__srl.tmpl @@ -0,0 +1,32 @@ +{{ expect .systemip "ip" }} +{{ range .links }} + {{ expect .port "^(ethernet-\\d+/|e\\d+-)\\d+$" }} + {{ expect .name "string" }} +{{ end }} +/interface lo0 { + admin-state enable + subinterface 0 { + ipv4 { + address {{ .systemip }} { + } + } + ipv6 { + address ::c1ab:{{ ip .systemip }}/128 { + } + } + } +} +/network-instance default { + router-id {{ ip .systemip }} + interface lo0.0 { + } +} +/system lldp admin-state enable +/ network-instance mgmt { + description "set from clab" +} +{{ range .links }} +/interface {{ .port }} { + admin-state enable +} +{{ end }} \ No newline at end of file diff --git a/types/node_definition.go b/types/node_definition.go index 5bbdf1453..feb1431db 100644 --- a/types/node_definition.go +++ b/types/node_definition.go @@ -2,14 +2,15 @@ package types // NodeDefinition represents a configuration a given node can have in the lab definition file type NodeDefinition struct { - Kind string `yaml:"kind,omitempty"` - Group string `yaml:"group,omitempty"` - Type string `yaml:"type,omitempty"` - StartupConfig string `yaml:"startup-config,omitempty"` - Image string `yaml:"image,omitempty"` - License string `yaml:"license,omitempty"` - Position string `yaml:"position,omitempty"` - Cmd string `yaml:"cmd,omitempty"` + Kind string `yaml:"kind,omitempty"` + Group string `yaml:"group,omitempty"` + Type string `yaml:"type,omitempty"` + StartupConfig string `yaml:"startup-config,omitempty"` + Config *ConfigDispatcher `yaml:"config,omitempty"` + Image string `yaml:"image,omitempty"` + License string `yaml:"license,omitempty"` + Position string `yaml:"position,omitempty"` + Cmd string `yaml:"cmd,omitempty"` // list of bind mount compatible strings Binds []string `yaml:"binds,omitempty"` // list of port bindings @@ -58,6 +59,16 @@ func (n *NodeDefinition) GetStartupConfig() string { return n.StartupConfig } +func (n *NodeDefinition) GetConfigDispatcher() *ConfigDispatcher { + if n == nil { + return nil + } + if n.Config == nil { + return &ConfigDispatcher{} + } + return n.Config +} + func (n *NodeDefinition) GetImage() string { if n == nil { return "" diff --git a/types/topology.go b/types/topology.go index 90d339022..1176817a4 100644 --- a/types/topology.go +++ b/types/topology.go @@ -29,6 +29,7 @@ func NewTopology() *Topology { type LinkConfig struct { Endpoints []string Labels map[string]string `yaml:"labels,omitempty"` + Vars map[string]string `yaml:"vars,omitempty"` } func (t *Topology) GetDefaults() *NodeDefinition { @@ -134,7 +135,22 @@ func (t *Topology) GetNodeLabels(name string) map[string]string { return nil } -func (t *Topology) GetNodeConfig(name string) (string, error) { +func (t *Topology) GetNodeConfigDispatcher(name string) *ConfigDispatcher { + if ndef, ok := t.Nodes[name]; ok { + vars := utils.MergeStringMaps( + utils.MergeStringMaps(t.Defaults.GetConfigDispatcher().Vars, + t.GetKind(t.GetNodeKind(name)).GetConfigDispatcher().Vars), + ndef.GetConfigDispatcher().Vars) + + return &ConfigDispatcher{ + Vars: vars, + } + } + + return nil +} + +func (t *Topology) GetNodeStartupConfig(name string) (string, error) { var cfg string if ndef, ok := t.Nodes[name]; ok { var err error diff --git a/types/topology_test.go b/types/topology_test.go index 8be536be9..8da6bde32 100644 --- a/types/topology_test.go +++ b/types/topology_test.go @@ -264,7 +264,7 @@ func TestGetNodeType(t *testing.T) { func TestGetNodeConfig(t *testing.T) { for name, item := range topologyTestSet { t.Logf("%q test item", name) - config, err := item.input.GetNodeConfig("node1") + config, err := item.input.GetNodeStartupConfig("node1") if err != nil { t.Fatal(err) } diff --git a/types/types.go b/types/types.go index e1c494e56..48b3381fc 100644 --- a/types/types.go +++ b/types/types.go @@ -6,6 +6,7 @@ package types import ( "bytes" + "fmt" "os" "path/filepath" "strings" @@ -23,6 +24,12 @@ type Link struct { B *Endpoint MTU int Labels map[string]string + Vars map[string]string +} + +func (link *Link) String() string { + return fmt.Sprintf("link [%s:%s, %s:%s]", link.A.Node.ShortName, + link.A.EndpointName, link.B.Node.ShortName, link.B.EndpointName) } // Endpoint is a struct that contains information of a link endpoint @@ -55,6 +62,7 @@ type NodeConfig struct { Kind string StartupConfig string // path to config template file that is used for startup config generation ResStartupConfig string // path to config file that is actually mounted to the container and is a result of templation + Config *ConfigDispatcher NodeType string Position string License string @@ -191,3 +199,10 @@ func FilterFromLabelStrings(labels []string) []*GenericFilter { } return gfl } + +// ConfigDispatcher represents the config of a configuration machine +// that is responsible to execute configuration commands on the nodes +// after they started +type ConfigDispatcher struct { + Vars map[string]string `yaml:"vars,omitempty"` +}