Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 151 additions & 102 deletions docs/docs.go

Large diffs are not rendered by default.

253 changes: 151 additions & 102 deletions docs/swagger.json

Large diffs are not rendered by default.

302 changes: 179 additions & 123 deletions docs/swagger.yaml

Large diffs are not rendered by default.

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions internal/api/auth_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import (
)

// LoginHandler - Handles user authentication
// @Summary Login
// @Description Authenticate user and return JWT token
// @Summary Log in
// @Description Authenticates a user and returns a JWT token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param credentials body models.LoginRequest true "User Credentials"
// @Success 200 {object} models.LoginResponse
// @Success 200 {object} models.LoginResponse "JWT token"
// @Failure 400 {object} models.ErrorResponse "Invalid input"
// @Failure 401 {object} models.ErrorResponse "Invalid credentials"
// @Failure 500 {object} models.ErrorResponse "Internal server error (PAM config?)"
Expand Down
73 changes: 19 additions & 54 deletions internal/api/events_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,33 +37,31 @@ type clabEventJSON struct {
Attributes map[string]string `json:"attributes"`
}

// @Summary Stream Containerlab Events
// @Description Streams containerlab events in real-time. The response stays open until the client disconnects.
// @Summary Stream containerlab events
// @Description Streams containerlab events in real time as NDJSON (one JSON object per line).
// @Description
// @Description **JSON format example** (default, returns NDJSON - one JSON object per line):
// @Description **Notes**
// @Description - The response stays open until the client disconnects.
// @Description
// @Description **Examples**
// @Description NDJSON (one JSON object per line):
// @Description ```json
// @Description {"time":1706918400,"type":"container","action":"start","attributes":{"name":"clab-mylab-srl1","lab":"mylab","clab-node-name":"srl1","clab-node-kind":"nokia_srlinux"}}
// @Description {"time":1706918405,"type":"container","action":"start","attributes":{"name":"clab-mylab-srl2","lab":"mylab","clab-node-name":"srl2","clab-node-kind":"nokia_srlinux"}}
// @Description ```
// @Description
// @Description **Interface stats example** (interfaceStats=true):
// @Description
// @Description Interface stats (interfaceStats=true):
// @Description ```json
// @Description {"time":1706918410,"type":"interface-stats","action":"stats","attributes":{"name":"clab-mylab-srl1","lab":"mylab","interface":"e1-1","rx_bytes":123456,"tx_bytes":654321}}
// @Description ```
// @Description
// @Description **Plain format example** (format=plain):
// @Description ```
// @Description 2024-02-03T10:30:00Z container start (name=clab-mylab-srl1, lab=mylab, kind=nokia_srlinux)
// @Description 2024-02-03T10:30:05Z container start (name=clab-mylab-srl2, lab=mylab, kind=nokia_srlinux)
// @Description ```
// @Tags Events
// @Security BearerAuth
// @Produce json
// @Param format query string false "Output format ('json' or 'plain'). Default is 'json'." Enums(json, plain) default(json)
// @Produce application/x-ndjson
// @Param initialState query boolean false "Include initial snapshot events when the stream starts." default(false)
// @Param interfaceStats query boolean false "Include interface stats events." default(false)
// @Param interfaceStatsInterval query string false "Interval for interface stats collection (e.g., 10s). Requires interfaceStats=true." default(10s)
// @Success 200 {object} models.EventResponse "Event stream - returns newline-delimited events (plain text or NDJSON)"
// @Success 200 {object} models.EventResponse "Event stream - NDJSON (one JSON object per line)"
// @Failure 400 {object} models.ErrorResponse "Invalid input"
// @Failure 401 {object} models.ErrorResponse "Unauthorized"
// @Failure 500 {object} models.ErrorResponse "Internal server error"
Expand All @@ -72,12 +70,6 @@ func StreamEventsHandler(c *gin.Context) {
username := c.GetString("username")
isSuperuserUser := isSuperuser(username)

format := strings.ToLower(c.DefaultQuery("format", "json"))
if format != "plain" && format != "json" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid format parameter. Use 'plain' or 'json'."})
return
}

initialState, err := parseBoolQuery(c, "initialState", false)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid initialState parameter. Use true or false."})
Expand Down Expand Up @@ -114,14 +106,10 @@ func StreamEventsHandler(c *gin.Context) {
runtime = "docker"
}

log.Infof("StreamEvents user '%s': Starting containerlab events stream (format=%s, initialState=%v, interfaceStats=%v, interval=%s)",
username, format, initialState, interfaceStats, interfaceStatsInterval)
log.Infof("StreamEvents user '%s': Starting containerlab events stream (initialState=%v, interfaceStats=%v, interval=%s)",
username, initialState, interfaceStats, interfaceStatsInterval)

if format == "json" {
c.Writer.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
} else {
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
c.Writer.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
c.Writer.Header().Set("X-Content-Type-Options", "nosniff")
c.Writer.Header().Set("Cache-Control", "no-cache")

Expand All @@ -137,7 +125,7 @@ func StreamEventsHandler(c *gin.Context) {

streamReader, streamWriter := io.Pipe()
eventsOpts := clabevents.Options{
Format: format,
Format: "json",
Runtime: runtime,
IncludeInitialState: initialState,
IncludeInterfaceStats: interfaceStats,
Expand Down Expand Up @@ -208,7 +196,7 @@ func StreamEventsHandler(c *gin.Context) {
for scanner.Scan() {
line := scanner.Text()
if !isSuperuserUser {
labName, ok := extractLabFromEventLine(line, format)
labName, ok := extractLabFromEventLine(line)
if !ok || !allowedLab(labName) {
continue
}
Expand Down Expand Up @@ -238,11 +226,8 @@ func parseBoolQuery(c *gin.Context, name string, defaultValue bool) (bool, error
return strconv.ParseBool(raw)
}

func extractLabFromEventLine(line, format string) (string, bool) {
if format == "json" {
return extractLabFromJSONLine(line)
}
return extractLabFromPlainLine(line)
func extractLabFromEventLine(line string) (string, bool) {
return extractLabFromJSONLine(line)
}

func extractLabFromJSONLine(line string) (string, bool) {
Expand All @@ -262,24 +247,4 @@ func extractLabFromJSONLine(line string) (string, bool) {
return "", false
}

func extractLabFromPlainLine(line string) (string, bool) {
start := strings.LastIndex(line, "(")
end := strings.LastIndex(line, ")")
if start == -1 || end == -1 || end <= start {
return "", false
}
attrs := line[start+1 : end]
parts := strings.Split(attrs, ", ")
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key := kv[0]
value := kv[1]
if key == "lab" || key == "containerlab" {
return value, true
}
}
return "", false
}
// Plain-text event parsing removed; NDJSON-only output is supported.
8 changes: 4 additions & 4 deletions internal/api/health_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ func InitHealth(version string) {
apiServerVersion = version
}

// @Summary Get API Server Basic Health
// @Description Returns basic health status of the API server.
// @Summary Get API server health
// @Description Returns basic health status for the API server.
// @Tags Health
// @Produce json
// @Success 200 {object} models.HealthResponse "Basic health information"
Expand All @@ -52,8 +52,8 @@ func HealthCheckHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}

// @Summary Get Detailed System Metrics
// @Description Returns detailed CPU, memory, and disk metrics for the API server. Requires SUPERUSER privileges.
// @Summary Get system metrics
// @Description Returns detailed CPU, memory, and disk metrics for the API server. Requires superuser privileges.
// @Tags Health
// @Security BearerAuth
// @Produce json
Expand Down
9 changes: 5 additions & 4 deletions internal/api/info_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
"github.com/srl-labs/clab-api-server/internal/models"
)

// @Summary Get Containerlab Version
// @Description Retrieves version information about the containerlab library in use.
// @Summary Get containerlab version
// @Description Returns version information for the containerlab library in use.
// @Tags Version
// @Security BearerAuth
// @Produce json
Expand All @@ -31,8 +31,9 @@ func GetVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, models.VersionResponse{VersionInfo: versionInfo})
}

// @Summary Check for Containerlab Updates
// @Description This endpoint has been deprecated. Version checks are no longer supported when using containerlab as a library.
// @Summary Check containerlab updates
// @Description **Deprecated**
// @Description Version checks are not supported when containerlab runs as a library.
// @Tags Version
// @Security BearerAuth
// @Produce json
Expand Down
59 changes: 39 additions & 20 deletions internal/api/lab_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,17 @@ import (
"github.com/srl-labs/clab-api-server/internal/models"
)

// @Summary Deploy Lab
// @Description Deploys a containerlab topology. Requires EITHER 'topologyContent' OR 'topologySourceUrl' in the request body, but not both.
// @Summary Deploy lab
// @Description Deploys a containerlab topology.
// @Description
// @Description **Notes**
// @Description - The request body must include either `topologyContent` or `topologySourceUrl` (not both).
// @Tags Labs
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param deploy_request body models.DeployRequest true "Deployment Source"
// @Param labNameOverride query string false "Override lab name when deploying from a URL (optional)"
// @Param reconfigure query boolean false "Allow overwriting an existing lab IF owned by the user"
// @Param maxWorkers query int false "Limit concurrent workers"
// @Param exportTemplate query string false "Custom Go template file for topology data export"
Expand Down Expand Up @@ -245,7 +249,7 @@ func DeployLabHandler(c *gin.Context) {
c.JSON(http.StatusOK, result)
}

// @Summary Deploy Lab from Archive
// @Summary Deploy lab from archive
// @Description Deploys a containerlab topology provided as a .zip or .tar.gz archive.
// @Tags Labs
// @Security BearerAuth
Expand All @@ -254,6 +258,11 @@ func DeployLabHandler(c *gin.Context) {
// @Param labArchive formData file true "Lab archive (.zip or .tar.gz)"
// @Param labName query string true "Name for the lab"
// @Param reconfigure query boolean false "Allow overwriting an existing lab"
// @Param maxWorkers query int false "Limit concurrent workers"
// @Param exportTemplate query string false "Custom Go template file for topology data export"
// @Param nodeFilter query string false "Comma-separated list of node names to deploy"
// @Param skipPostDeploy query boolean false "Skip post-deploy actions"
// @Param skipLabdirAcl query boolean false "Skip setting extended ACLs on lab directory"
// @Success 200 {object} models.ClabInspectOutput "Deployed lab details"
// @Failure 400 {object} models.ErrorResponse "Invalid input"
// @Failure 401 {object} models.ErrorResponse "Unauthorized"
Expand Down Expand Up @@ -431,8 +440,8 @@ func DeployLabArchiveHandler(c *gin.Context) {
c.JSON(http.StatusOK, result)
}

// @Summary Destroy Lab
// @Description Destroys a lab by name, checking ownership.
// @Summary Destroy lab
// @Description Destroys a lab by name after verifying ownership.
// @Tags Labs
// @Security BearerAuth
// @Produce json
Expand All @@ -441,7 +450,7 @@ func DeployLabArchiveHandler(c *gin.Context) {
// @Param graceful query boolean false "Attempt graceful shutdown"
// @Param keepMgmtNet query boolean false "Keep the management network"
// @Param nodeFilter query string false "Destroy only specific nodes"
// @Success 200 {object} models.GenericSuccessResponse
// @Success 200 {object} models.GenericSuccessResponse "Lab destroyed successfully"
// @Failure 400 {object} models.ErrorResponse "Invalid lab name"
// @Failure 401 {object} models.ErrorResponse "Unauthorized"
// @Failure 404 {object} models.ErrorResponse "Lab not found"
Expand Down Expand Up @@ -530,12 +539,22 @@ func DestroyLabHandler(c *gin.Context) {
c.JSON(http.StatusOK, models.GenericSuccessResponse{Message: fmt.Sprintf("Lab '%s' destroyed successfully", labName)})
}

// @Summary Redeploy Lab
// @Description Redeploys a lab by name (destroy + deploy).
// @Summary Redeploy lab
// @Description Redeploys a lab by name.
// @Description
// @Description **Notes**
// @Description - This operation destroys the lab and then deploys it again.
// @Tags Labs
// @Security BearerAuth
// @Produce json
// @Param labName path string true "Name of the lab to redeploy"
// @Param cleanup query boolean false "Remove lab directory after destroy"
// @Param graceful query boolean false "Attempt graceful shutdown"
// @Param keepMgmtNet query boolean false "Keep the management network"
// @Param maxWorkers query int false "Limit concurrent workers"
// @Param exportTemplate query string false "Custom Go template file for topology data export"
// @Param skipPostDeploy query boolean false "Skip post-deploy actions"
// @Param skipLabdirAcl query boolean false "Skip setting extended ACLs on lab directory"
// @Success 200 {object} models.ClabInspectOutput "Redeployed lab details"
// @Failure 400 {object} models.ErrorResponse "Invalid lab name"
// @Failure 401 {object} models.ErrorResponse "Unauthorized"
Expand Down Expand Up @@ -632,13 +651,12 @@ func RedeployLabHandler(c *gin.Context) {
c.JSON(http.StatusOK, result)
}

// @Summary Inspect Lab
// @Description Get details about a specific running lab.
// @Summary Inspect lab
// @Description Returns details for a specific running lab.
// @Tags Labs
// @Security BearerAuth
// @Produce json
// @Param labName path string true "Name of the lab to inspect"
// @Param details query boolean false "Include full container details"
// @Success 200 {object} []models.ClabContainerInfo "Lab containers"
// @Failure 400 {object} models.ErrorResponse "Invalid lab name"
// @Failure 401 {object} models.ErrorResponse "Unauthorized"
Expand Down Expand Up @@ -691,8 +709,8 @@ func InspectLabHandler(c *gin.Context) {
c.JSON(http.StatusOK, labContainers)
}

// @Summary List Lab Interfaces
// @Description Get network interface details for nodes in a specific lab.
// @Summary List lab interfaces
// @Description Returns interface details for nodes in a lab.
// @Tags Labs
// @Security BearerAuth
// @Produce json
Expand Down Expand Up @@ -767,8 +785,11 @@ func InspectInterfacesHandler(c *gin.Context) {
c.JSON(http.StatusOK, result)
}

// @Summary List All Labs
// @Description Get details about all running labs (filtered by owner unless superuser).
// @Summary List labs
// @Description Returns details for all running labs.
// @Description
// @Description **Notes**
// @Description - Results are filtered by owner unless the caller is a superuser.
// @Tags Labs
// @Security BearerAuth
// @Produce json
Expand Down Expand Up @@ -824,7 +845,7 @@ func ListLabsHandler(c *gin.Context) {
c.JSON(http.StatusOK, finalResult)
}

// @Summary Save Lab Configuration
// @Summary Save lab configuration
// @Description Saves the running configuration for nodes in a lab.
// @Tags Labs
// @Security BearerAuth
Expand Down Expand Up @@ -893,15 +914,14 @@ func SaveLabConfigHandler(c *gin.Context) {
})
}

// @Summary Execute Command in Lab
// @Description Executes a command on nodes within a specific lab.
// @Summary Execute command in lab
// @Description Executes a command on nodes within a lab.
// @Tags Labs
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param labName path string true "Name of the lab"
// @Param nodeFilter query string false "Execute only on this specific node"
// @Param format query string false "Output format ('plain' or 'json')"
// @Param exec_request body models.ExecRequest true "Command to execute"
// @Success 200 {object} models.ExecResponse "Execution result"
// @Failure 400 {object} models.ErrorResponse "Invalid input"
Expand All @@ -922,7 +942,6 @@ func ExecCommandHandler(c *gin.Context) {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid characters in nodeFilter."})
return
}

var req models.ExecRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: "Invalid request body: " + err.Error()})
Expand Down
Loading