A comprehensive Go client library for Cisco Modeling Labs (CML) 2.x, providing modern service-based APIs.
- Features
- Installation
- Quick Start
- Configuration
- API Reference
- Error Handling
- Advanced Usage
- Contributing
- License
- 🚀 Modern Service Architecture: Clean, modular design with dedicated services for each resource type
- 🔄 Focused API: Modern, service-based client (not a drop-in replacement for older gocmlclient versions)
- 🔐 Flexible Authentication: Support for username/password, tokens, and custom providers
- 🛡️ Production Ready: Comprehensive error handling, retries, and connection management
- 📊 Built-in Monitoring: Request/response statistics and health checks
- 🧪 Well Tested: High test coverage with race detection and integration tests
- 📚 Rich Documentation: Comprehensive examples and API documentation
go get github.com/rschmied/gocmlclientRequirements:
- Go 1.25 or later
- Access to a CML 2.x controller (version 2.9.0+ recommended; 2.9/2.10 tested)
More examples:
- Runnable programs:
examples/ - API docs and examples: pkg.go.dev (click the Go Reference badge)
package main
import (
"context"
"log"
"github.com/rschmied/gocmlclient"
)
func main() {
// Create client with authentication
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithUsernamePassword("admin", "password"))
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// List lab IDs (use show_all=true)
labs, err := client.Lab.Labs(ctx, true)
if err != nil {
log.Fatal(err)
}
log.Printf("Found %d labs", len(labs))
}By default, the client automatically performs a system readiness check during initialization to ensure the CML server is compatible and ready. This check:
- Verifies the server is running and accessible
- Validates version compatibility (>=2.9.0, <3.0.0)
- Caches version information for subsequent operations
- Checks for named configuration support (>=2.7.0)
If you need to skip this check (e.g., for testing or when working with servers that don't support the system_information endpoint) or when working with older versions (might or might not work, untested):
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.SkipReadyCheck())The client supports multiple authentication methods:
// Using username/password
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithUsernamePassword("username", "password"))
// Using token
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithToken("your-token"))
// Skip TLS verification (for development)
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithInsecureTLS())
// Combine options
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithUsernamePassword("username", "password"),
gocmlclient.WithTokenStorageFile("/tmp/cml_tokens.json"),
gocmlclient.WithInsecureTLS(),
gocmlclient.SkipReadyCheck())
// Add a static proxy/auth header to every outbound request
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithStaticToken("your-token"),
gocmlclient.WithRequestHeader("X-Proxy-Token", os.Getenv("CML_PROXY_TOKEN")))
// Add a Bearer token in Proxy-Authorization, similar to IAP-style proxy auth
proxyToken := os.Getenv("CML_PROXY_TOKEN")
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithStaticToken("your-token"),
gocmlclient.WithRequestHeader("Proxy-Authorization", "Bearer "+proxyToken))WithRequestHeader and WithRequestHeaders apply to all outbound HTTP calls,
including authentication bootstrap requests such as /api/v0/auth_extended.
This makes them suitable for proxies or gateways that require additional static
headers. Empty header values are ignored.
If your proxy expects an IAP-style bearer token in Proxy-Authorization, load
the token in caller code and pass "Bearer "+token as the header value. See
examples/auth-proxy-header/main.go for a runnable example.
By default, tokens are cached in memory for the lifetime of the client. To reuse tokens across process restarts (e.g., Terraform runs), configure file-based token storage:
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithUsernamePassword("username", "password"),
gocmlclient.WithTokenStorageFile("/tmp/cml_tokens.json"))Note: the token file can contain a valid bearer token; secure and clean it up per your environment.
Note: In the examples below, UUIDs are represented as short strings like models.UUID("lab-uuid") for brevity. In production, these would be actual UUIDs (e.g., models.UUID("123e4567-e89b-12d3-a456-426614174000")).
Manage CML labs including creation, configuration, and lifecycle operations.
// Get all labs
labs, err := client.Lab.Labs(ctx, true) // true sets show_all=true
// Get labs with topology tile data (fast endpoint)
tiles, err := client.Lab.LabsWithData(ctx) // GET /populate_lab_tiles
// Get lab by ID
lab, err := client.Lab.GetByID(ctx, models.UUID("lab-uuid"), true)
// Get lab by title
lab, err := client.Lab.GetByTitle(ctx, "My Lab", true)
// Create a new lab
newLab := models.LabCreateRequest{
Title: "New Lab",
Description: "A new CML lab",
Notes: "Created via API",
}
createdLab, err := client.Lab.Create(ctx, newLab)
// Update lab metadata
updateReq := models.LabUpdateRequest{
Title: "Updated Title",
Description: "Updated description",
}
updatedLab, err := client.Lab.Update(ctx, models.UUID("lab-uuid"), updateReq)
// Compatibility-only on older backends: newer schemas no longer document
// lab groups on /labs payloads, but the client still supports them.
updateReq.Groups = []models.LabGroup{{
ID: models.UUID("group-uuid"),
Permission: models.OldPermissionReadOnly,
}}
// Node staging (CML 2.10+; same request shape as used by the UI)
_, err = client.Lab.Update(ctx, models.UUID("lab-uuid"), models.LabUpdateRequest{
NodeStaging: &models.NodeStaging{Enabled: false, StartRemaining: true, AbortOnFailure: false},
})
// Control lab lifecycle
err = client.Lab.Start(ctx, models.UUID("lab-uuid"))
err = client.Lab.Stop(ctx, models.UUID("lab-uuid"))
err = client.Lab.Wipe(ctx, models.UUID("lab-uuid"))
err = client.Lab.Delete(ctx, models.UUID("lab-uuid"))
// Import lab from topology
lab, err := client.Lab.Import(ctx, topologyYAML)
// Check convergence
converged, err := client.Lab.HasConverged(ctx, models.UUID("lab-uuid"))Manage individual nodes within labs.
// Get nodes for a lab
nodes, err := client.Node.GetNodesForLab(ctx, models.UUID("lab-uuid"))
// Get specific node
node, err := client.Node.GetByID(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))
// Create a new node
ram := 512
img := "vios-adventerprisek9-m"
newNode := models.Node{
LabID: models.UUID("lab-uuid"),
Label: "Router1",
NodeDefinition: "iosv",
ImageDefinition: &img,
CPUs: 1,
RAM: &ram,
X: 100,
Y: 100,
}
createdNode, err := client.Node.Create(ctx, newNode)
// Update node configuration
updatedNode, err := client.Node.Update(ctx, existingNode)
// Set node configuration
err = client.Node.SetConfig(ctx, &node, "interface GigabitEthernet0/0\n ip address 192.168.1.1 255.255.255.0")
// Set named configurations
configs := []models.NodeConfig{
{Name: "startup", Content: "hostname R1\ninterface GigabitEthernet0/0\n ip address 192.168.1.1 255.255.255.0"},
}
err = client.Node.SetNamedConfigs(ctx, &node, configs)
// Control node lifecycle
err = client.Node.Start(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))
err = client.Node.Stop(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))
err = client.Node.Wipe(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))
err = client.Node.Delete(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))Manage CML user accounts and authentication.
// Get all users
users, err := client.User.Users(ctx)
// Get user by ID
user, err := client.User.GetByID(ctx, models.UUID("user-uuid"))
// Get user by name
user, err := client.User.GetByName(ctx, "username")
// Create a new user
newUser := models.UserCreateRequest{
UserBase: models.UserBase{
Username: "newuser",
Fullname: "New User",
Email: "user@example.com",
IsAdmin: false,
},
Password: "securepassword",
}
createdUser, err := client.User.Create(ctx, newUser)
// Update user
updateReq := models.UserUpdateRequest{
UserBase: models.UserBase{
Username: "updateduser",
Fullname: "Updated Name",
Email: "updated@example.com",
},
}
updatedUser, err := client.User.Update(ctx, models.UUID("user-uuid"), updateReq)
// Delete user
err = client.User.Delete(ctx, models.UUID("user-uuid"))
// Compatibility-only on older backends: newer schemas no longer document this
// endpoint, so it may return 404 on newer controllers.
groups, err := client.User.Groups(ctx, models.UUID("user-uuid"))Manage user groups and permissions.
// Get all groups
groups, err := client.Group.Groups(ctx)
// Get group by ID
group, err := client.Group.GetByID(ctx, models.UUID("group-uuid"))
// Get group by name
group, err := client.Group.ByName(ctx, "groupname")
// Create a new group
newGroup := models.Group{
Name: "Students",
Description: "Student group",
Members: []string{"user1-uuid", "user2-uuid"},
}
createdGroup, err := client.Group.Create(ctx, newGroup)
// Update group
updatedGroup, err := client.Group.Update(ctx, existingGroup)
// Delete group
err = client.Group.Delete(ctx, models.UUID("group-uuid"))Manage network links between nodes.
// Get links for a lab
links, err := client.Link.GetLinksForLab(ctx, models.UUID("lab-uuid"))
// Get specific link
link, err := client.Link.GetByID(ctx, models.UUID("lab-uuid"), models.UUID("link-uuid"))
// Create a new link
newLink := models.Link{
LabID: models.UUID("lab-uuid"),
SrcNode: models.UUID("node1-uuid"),
DstNode: models.UUID("node2-uuid"),
SrcSlot: 0,
DstSlot: 1,
}
createdLink, err := client.Link.Create(ctx, newLink)
// Delete link
err = client.Link.Delete(ctx, models.UUID("lab-uuid"), models.UUID("link-uuid"))
// Link conditions (if supported)
condition, err := client.Link.GetCondition(ctx, models.UUID("lab-uuid"), models.UUID("link-uuid"))
err = client.Link.SetCondition(ctx, models.UUID("lab-uuid"), models.UUID("link-uuid"), conditionConfig)
err = client.Link.DeleteCondition(ctx, models.UUID("lab-uuid"), models.UUID("link-uuid"))Manage network interfaces on nodes.
// Get interfaces for a node
interfaces, err := client.Interface.GetInterfacesForNode(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"))
// Get specific interface
iface, err := client.Interface.GetByID(ctx, models.UUID("lab-uuid"), models.UUID("interface-uuid"))
// Create a new interface
newInterface, err := client.Interface.Create(ctx, models.UUID("lab-uuid"), models.UUID("node-uuid"), 0) // slot 0Manage classic annotations (text/rectangle/ellipse/line) and smart annotations.
// Create a text annotation
create := models.AnnotationCreate{
Type: models.AnnotationTypeText,
Text: &models.TextAnnotation{
Type: models.AnnotationTypeText,
BorderColor: "#000000",
BorderStyle: "",
Color: "#ffffff",
Thickness: 1,
X1: 10,
Y1: 10,
ZIndex: 0,
Rotation: 0,
TextBold: false,
TextContent: "hello",
TextFont: "sans",
TextItalic: false,
TextSize: 12,
TextUnit: "px",
},
}
ann, err := client.Annotation.Create(ctx, models.UUID("lab-uuid"), create)
// List annotations
anns, err := client.Annotation.List(ctx, models.UUID("lab-uuid"))
// Patch an annotation (OpenAPI requires `type`)
updated := "hello-updated"
upd := models.AnnotationUpdate{Type: models.AnnotationTypeText, Text: &models.TextAnnotationPartial{Type: models.AnnotationTypeText, TextContent: &updated}}
ann, err = client.Annotation.Update(ctx, models.UUID("lab-uuid"), ann.Text.ID, upd)
// Line annotations: line_start/line_end are required but may be null.
// On PATCH, gocmlclient always includes these keys so callers can send explicit nulls.
arrow := models.LineStyleArrow
lineCreate := models.AnnotationCreate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotation{Type: models.AnnotationTypeLine, BorderColor: "#000000", BorderStyle: "", Color: "#ffffff", Thickness: 1, X1: 10, Y1: 10, X2: 100, Y2: 10, ZIndex: 0, LineStart: &arrow, LineEnd: &arrow}}
line, err := client.Annotation.Create(ctx, models.UUID("lab-uuid"), lineCreate)
if line.Line != nil {
// Clear both line ends (explicit JSON null)
_, err = client.Annotation.Update(ctx, models.UUID("lab-uuid"), line.Line.ID, models.AnnotationUpdate{Type: models.AnnotationTypeLine, Line: &models.LineAnnotationPartial{Type: models.AnnotationTypeLine, LineStart: nil, LineEnd: nil}})
}
// Delete
err = client.Annotation.Delete(ctx, models.UUID("lab-uuid"), ann.Text.ID)
// Smart annotations
smart, err := client.SmartAnnotation.List(ctx, models.UUID("lab-uuid"))
if len(smart) > 0 {
_, _ = client.SmartAnnotation.Get(ctx, models.UUID("lab-uuid"), smart[0].ID)
}Access system-level information and configuration.
// Get system version
version := client.System.Version()
// Check version compatibility
compatible, err := client.System.VersionCheck(ctx, ">=2.9.0")
// Check system readiness
err = client.System.Ready(ctx)
// Enable named configurations (if supported)
client.System.UseNamedConfigs()Retrieve image definitions available on the controller.
images, err := client.ImageDefinition.ImageDefinitions(ctx) // GET /image_definitionsRetrieve simplified node definitions available on the controller.
defs, err := client.NodeDefinition.NodeDefinitions(ctx) // GET /simplified_node_definitionsList or fetch external connectors configured on the system.
exts, err := client.ExtConn.List(ctx) // GET /system/external_connectors
ext, err := client.ExtConn.Get(ctx, models.UUID("extconn-uuid")) // GET /system/external_connectors/{id}The gocmlclient provides comprehensive error handling with specific error types and detailed error messages.
import (
"errors"
cmlerror "github.com/rschmied/gocmlclient/pkg/errors"
"github.com/rschmied/gocmlclient/pkg/models"
)
lab, err := client.Lab.GetByID(ctx, models.UUID("nonexistent-id"), false)
if err != nil {
if errors.Is(err, cmlerror.ErrElementNotFound) {
log.Println("Lab not found")
} else if errors.Is(err, cmlerror.ErrSystemNotReady) {
log.Println("CML system is not ready")
} else {
log.Printf("Unexpected error: %v", err)
}
}ErrElementNotFound: Resource not foundErrSystemNotReady: CML system is not accessible or readyErrAuthenticationFailed: Authentication failedErrPermissionDenied: Insufficient permissionsErrInvalidRequest: Invalid request parameters
import (
"net/http"
"time"
"github.com/rschmied/gocmlclient"
)
customClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
client, err := gocmlclient.New("https://cml-controller.example.com",
gocmlclient.WithHTTPClient(customClient),
gocmlclient.WithUsernamePassword("admin", "password"))// Get request statistics
stats := client.Stats()
log.Printf("Total requests: %d", stats.TotalRequests)
log.Printf("Failed requests: %d", stats.FailedRequests)
log.Printf("Average response time: %v", stats.AverageResponseTime)import (
"sync"
"golang.org/x/sync/errgroup"
)
func processLabsConcurrently(ctx context.Context, client *gocmlclient.Client, labIDs []string) error {
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(10) // Limit concurrent operations
for _, labID := range labIDs {
labID := labID // Capture loop variable
g.Go(func() error {
lab, err := client.Lab.GetByID(gctx, labID, false)
if err != nil {
return err
}
log.Printf("Processed lab: %s", lab.Title)
return nil
})
}
return g.Wait()
}We welcome contributions! Please see our Contributing Guide for details.
# Clone the repository
git clone https://github.com/rschmied/gocmlclient.git
cd gocmlclient
# Install dependencies
go mod download
# Run tests
go test ./...
# Run with race detection
go test -race ./...
# Run linting
make lint
# Or, without make:
go vet ./...
golangci-lint run- Follow standard Go conventions
- Use
gofmtfor formatting - Add tests for new functionality
- Update documentation for API changes
Copyright (c) Ralph Schmieder 2022-2026
Licensed under the MIT License. See LICENSE for details.