diff --git a/cmd/options.go b/cmd/options.go index 57113b0a26..3d6996ce4d 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -102,6 +102,12 @@ func GetOptions() *Options { SrcPort: 0, DeletionPrefix: "vx-", }, + ToolsCodeServer: &ToolsCodeServerOptions{ + Image: "ghcr.io/kaelemc/clab-code-server:main", + Name: "clab-code-server", + LogLevel: "debug", + OutputFormat: "table", + }, Version: &VersionOptions{ Short: false, JSON: false, @@ -113,23 +119,24 @@ func GetOptions() *Options { } type Options struct { - Global *GlobalOptions - Filter *FilterOptions - Deploy *DeployOptions - Destroy *DestroyOptions - Config *ConfigOptions - Exec *ExecOptions - Inspect *InspectOptions - Graph *GraphOptions - ToolsAPI *ToolsApiOptions - ToolsCert *ToolsCertOptions - ToolsTxOffload *ToolsDisableTxOffloadOptions - ToolsGoTTY *ToolsGoTTYOptions - ToolsNetem *ToolsNetemOptions - ToolsSSHX *ToolsSSHXOptions - ToolsVeth *ToolsVethOptions - ToolsVxlan *ToolsVxlanOptions - Version *VersionOptions + Global *GlobalOptions + Filter *FilterOptions + Deploy *DeployOptions + Destroy *DestroyOptions + Config *ConfigOptions + Exec *ExecOptions + Inspect *InspectOptions + Graph *GraphOptions + ToolsAPI *ToolsApiOptions + ToolsCert *ToolsCertOptions + ToolsTxOffload *ToolsDisableTxOffloadOptions + ToolsGoTTY *ToolsGoTTYOptions + ToolsNetem *ToolsNetemOptions + ToolsSSHX *ToolsSSHXOptions + ToolsVeth *ToolsVethOptions + ToolsVxlan *ToolsVxlanOptions + ToolsCodeServer *ToolsCodeServerOptions + Version *VersionOptions } func (o *Options) ToClabOptions() []clabcore.ClabOption { @@ -435,6 +442,16 @@ type ToolsVxlanOptions struct { DeletionPrefix string } +type ToolsCodeServerOptions struct { + Image string + Name string + Port uint + LogLevel string + OutputFormat string + LabsDirectory string + Owner string +} + type VersionOptions struct { Short bool JSON bool diff --git a/cmd/tools.go b/cmd/tools.go index c52a53856e..7df12505a6 100644 --- a/cmd/tools.go +++ b/cmd/tools.go @@ -5,6 +5,7 @@ package cmd import ( + "os" "path/filepath" "strings" @@ -22,6 +23,7 @@ func toolsSubcommandRegisterFuncs() []func(*Options) (*cobra.Command, error) { sshxCmd, vethCmd, vxlanCmd, + codeServerCmd, } } @@ -81,3 +83,18 @@ func createLabelsMap(topo, labName, containerName, owner, toolType string) map[s return labels } + +// getclabBinaryPath determine the binary path of the running executable. +func getclabBinaryPath() (string, error) { + exePath, err := os.Executable() + if err != nil { + return "", err + } + + absPath, err := filepath.EvalSymlinks(exePath) + if err != nil { + return "", err + } + + return absPath, nil +} diff --git a/cmd/tools_api_start.go b/cmd/tools_api_start.go index f50f1abb5a..99a274e209 100644 --- a/cmd/tools_api_start.go +++ b/cmd/tools_api_start.go @@ -7,7 +7,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "github.com/charmbracelet/log" "github.com/spf13/cobra" @@ -93,16 +92,6 @@ func (*APIServerNode) GetEndpoints() []clablinks.Endpoint { return nil } -// getclabBinaryPath determine the binary path of the running executable. -func getclabBinaryPath() (string, error) { - exePath, err := os.Executable() - if err != nil { - return "", err - } - - return filepath.EvalSymlinks(exePath) -} - // createLabels creates container labels. func createAPIServerLabels( containerName, @@ -239,7 +228,7 @@ func apiServerStart(cobraCmd *cobra.Command, o *Options) error { //nolint: funle // Create container labels if o.ToolsAPI.LabsDirectory == "" { - o.ToolsAPI.LabsDirectory = "~/.clab" + o.ToolsAPI.LabsDirectory = defaultLabsDir } owner := getOwnerName(o) diff --git a/cmd/tools_api_status.go b/cmd/tools_api_status.go index a1152d2225..3bbb70d14c 100644 --- a/cmd/tools_api_status.go +++ b/cmd/tools_api_status.go @@ -79,7 +79,7 @@ func apiServerStatus(cobraCmd *cobra.Command, o *Options) error { } // Get labs dir from labels or use default - labsDir := "~/.clab" // default + labsDir := defaultLabsDir // default if dirsVal, ok := containers[idx].Labels["clab-labs-dir"]; ok { labsDir = dirsVal } diff --git a/cmd/tools_code_server.go b/cmd/tools_code_server.go new file mode 100644 index 0000000000..fd131ed307 --- /dev/null +++ b/cmd/tools_code_server.go @@ -0,0 +1,601 @@ +// Copyright 2025 +// Licensed under the BSD 3-Clause License. +// SPDX-License-Identifier: BSD-3-Clause + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/log" + "github.com/docker/go-connections/nat" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" + clabconstants "github.com/srl-labs/containerlab/constants" + clabcore "github.com/srl-labs/containerlab/core" + clablinks "github.com/srl-labs/containerlab/links" + clabruntime "github.com/srl-labs/containerlab/runtime" + clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" + clabtypes "github.com/srl-labs/containerlab/types" + clabutils "github.com/srl-labs/containerlab/utils" +) + +const ( + codeServerPort = 8080 + codeServerDirPerm = 0o755 + codeServerConfigPerm = 0o644 + codeServerMarkerName = ".initialized" + defaultLabsDir = "~/.clab" +) + +type codeServerPaths struct { + dataDir string + configDir string + extensionsDir string + userDataDir string + markerFile string + configFile string +} + +func newCodeServerPaths(homeDir, name string) codeServerPaths { + basePath := fmt.Sprintf("%s/.clab/code-server/%s", homeDir, name) + + return codeServerPaths{ + dataDir: fmt.Sprintf("%s/data", basePath), + configDir: fmt.Sprintf("%s/config", basePath), + extensionsDir: fmt.Sprintf("%s/extensions", basePath), + userDataDir: fmt.Sprintf("%s/user-data", basePath), + markerFile: fmt.Sprintf("%s/extensions/%s", basePath, codeServerMarkerName), + configFile: fmt.Sprintf("%s/config/config.yaml", basePath), + } +} + +func prepareCodeServerPersistence(paths *codeServerPaths) (bool, error) { + directories := []string{ + paths.dataDir, + paths.configDir, + paths.extensionsDir, + paths.userDataDir, + } + + for _, dir := range directories { + if err := os.MkdirAll(dir, codeServerDirPerm); err != nil { + return false, fmt.Errorf("failed to create %s directory: %w", dir, err) + } + } + + isFirstRun, err := ensureExtensionsInitialized(paths.markerFile) + if err != nil { + return false, err + } + + if err := writeCodeServerConfig(paths.configFile); err != nil { + return false, err + } + + return isFirstRun, nil +} + +func ensureExtensionsInitialized(markerFile string) (bool, error) { + if _, err := os.Stat(markerFile); err == nil { + return false, nil + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("failed to check code-server marker file: %w", err) + } + + if err := os.WriteFile(markerFile, []byte("initialized"), codeServerConfigPerm); err != nil { + return false, fmt.Errorf("failed to create code-server marker file: %w", err) + } + + return true, nil +} + +func writeCodeServerConfig(configFile string) error { + const configContent = `bind-addr: 0.0.0.0:8080 +auth: password +password: clab +cert: false +` + + if err := os.WriteFile(configFile, []byte(configContent), codeServerConfigPerm); err != nil { + return fmt.Errorf("failed to create code-server config file: %w", err) + } + + return nil +} + +func buildCodeServerBinds( + homeDir string, + runtime clabruntime.ContainerRuntime, + paths *codeServerPaths, +) (clabtypes.Binds, error) { + binds := clabtypes.Binds{ + clabtypes.NewBind(homeDir, "/labs", ""), + clabtypes.NewBind("/home", "/home", ""), + clabtypes.NewBind(paths.dataDir, "/root/.local/share/code-server", ""), + clabtypes.NewBind(paths.configDir, "/root/.config/code-server", ""), + clabtypes.NewBind(paths.extensionsDir, "/persistent-extensions", ""), + clabtypes.NewBind(paths.userDataDir, "/persistent-user-data", ""), + } + + rtSocket, err := runtime.GetRuntimeSocket() + if err != nil { + return nil, err + } + + binds = append(binds, clabtypes.NewBind(rtSocket, rtSocket, "")) + binds = append(binds, runtime.GetCooCBindMounts()...) + + rtBinPath, err := runtime.GetRuntimeBinary() + if err != nil { + return nil, fmt.Errorf("could not find docker binary: %v. "+ + "code-server might not function correctly if docker is not available", err) + } + + binds = append(binds, clabtypes.NewBind(rtBinPath, "/usr/bin/docker", "ro")) + + clabPath, err := getclabBinaryPath() + if err != nil { + return nil, fmt.Errorf("could not find containerlab binary: %v. "+ + "code-server might not function correctly if containerlab is not in its PATH", err) + } + + binds = append(binds, clabtypes.NewBind(clabPath, "/usr/bin/containerlab", "ro")) + binds = append(binds, clabtypes.NewBind(clabPath, "/usr/bin/clab", "ro")) + + return binds, nil +} + +func buildCodeServerCommand(isFirstRun bool, defaultDir string) string { + baseCommand := strings.Join([]string{ + "code-server --config /root/.config/code-server/config.yaml", + "--extensions-dir /persistent-extensions", + "--user-data-dir /persistent-user-data ", defaultDir, + }, " ") + + if !isFirstRun { + return fmt.Sprintf("-c %q", baseCommand) + } + + copyExtensionsCommand := "cp -r /extensions/* /persistent-extensions/" + + " 2>/dev/null || true" + + firstRunCommand := copyExtensionsCommand + "; " + baseCommand + + return fmt.Sprintf("-c %q", firstRunCommand) +} + +// codeServerNode implements runtime.Node interface for code-server containers. +type codeServerNode struct { + config *clabtypes.NodeConfig +} + +func codeServerCmd(o *Options) (*cobra.Command, error) { + c := &cobra.Command{ + Use: "code-server", + Short: "Containerlab code-server server operations", + Long: "Start, stop, and manage Containerlab code-server containers", + } + + codeServerStartCmd := &cobra.Command{ + Use: "start", + Short: "start Containerlab code-server container", + PreRunE: func(_ *cobra.Command, _ []string) error { + return clabutils.CheckAndGetRootPrivs() + }, + RunE: func(cobraCmd *cobra.Command, _ []string) error { + return codeServerStart(cobraCmd, o) + }, + } + + c.AddCommand(codeServerStartCmd) + codeServerStartCmd.Flags().StringVarP(&o.ToolsCodeServer.Image, "image", "i", + o.ToolsCodeServer.Image, + "container image to use for code-server") + codeServerStartCmd.Flags().StringVarP(&o.ToolsCodeServer.Name, "name", "n", + o.ToolsCodeServer.Name, + "name of the code-server container") + codeServerStartCmd.Flags().StringVarP(&o.ToolsCodeServer.LabsDirectory, "labs-dir", "l", + o.ToolsCodeServer.LabsDirectory, + "directory to mount as shared labs directory") + codeServerStartCmd.Flags().UintVarP(&o.ToolsCodeServer.Port, "port", "p", + o.ToolsCodeServer.Port, + "port to expose the code-server on") + codeServerStartCmd.Flags().StringVarP(&o.ToolsCodeServer.Owner, "owner", "o", + o.ToolsCodeServer.Owner, + "owner name for the code-server container") + + codeServerStatusCmd := &cobra.Command{ + Use: "status", + Short: "show status of active Containerlab code-server containers", + PreRunE: func(_ *cobra.Command, _ []string) error { + return clabutils.CheckAndGetRootPrivs() + }, + RunE: func(cobraCmd *cobra.Command, _ []string) error { + return codeServerStatus(cobraCmd, o) + }, + } + c.AddCommand(codeServerStatusCmd) + codeServerStatusCmd.Flags().StringVarP(&o.ToolsCodeServer.OutputFormat, "format", "f", + o.ToolsCodeServer.OutputFormat, + "output format for 'status' command (table, json)") + + codeServerStopCmd := &cobra.Command{ + Use: "stop", + Short: "stop Containerlab code-server container", + PreRunE: func(_ *cobra.Command, _ []string) error { + return clabutils.CheckAndGetRootPrivs() + }, + RunE: func(cobraCmd *cobra.Command, _ []string) error { + return codeServerStop(cobraCmd, o) + }, + } + c.AddCommand(codeServerStopCmd) + codeServerStopCmd.Flags().StringVarP(&o.ToolsCodeServer.Name, + "name", "n", o.ToolsCodeServer.Name, + "name of the code-server container to stop") + + return c, nil +} + +func NewCodeServerNode(name, image, labsDir string, + port uint, + runtime clabruntime.ContainerRuntime, + labels map[string]string, +) (*codeServerNode, error) { + log.With("name", name, + "image", image, + "labsDir", labsDir, + "runtime", runtime).Debug("Creating new code-server node.") + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get user home directory: %w", err) + } + + paths := newCodeServerPaths(homeDir, name) + + isFirstRun, err := prepareCodeServerPersistence(&paths) + if err != nil { + return nil, err + } + + binds, err := buildCodeServerBinds(homeDir, runtime, &paths) + if err != nil { + return nil, err + } + + exposedPorts := make(nat.PortSet) + portBindings := make(nat.PortMap) + + containerPort, err := nat.NewPort("tcp", fmt.Sprintf("%d", codeServerPort)) + if err != nil { + return nil, fmt.Errorf("failed to create container port: %w", err) + } + + exposedPorts[containerPort] = struct{}{} + + var hostPort uint = 0 + if port != 0 { + hostPort = port + } + + portBindings[containerPort] = []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: fmt.Sprintf("%d", hostPort), + }, + } + + cmd := buildCodeServerCommand(isFirstRun, homeDir) + + nodeConfig := &clabtypes.NodeConfig{ + LongName: name, + ShortName: name, + Image: image, + Binds: binds.ToStringSlice(), + Labels: labels, + PortSet: exposedPorts, + PortBindings: portBindings, + NetworkMode: "bridge", + User: "0", + Entrypoint: "/bin/sh", + Cmd: cmd, + } + + return &codeServerNode{ + config: nodeConfig, + }, nil +} + +func (n *codeServerNode) Config() *clabtypes.NodeConfig { + return n.config +} + +// GetEndpoints implementation for the Node interface. +func (*codeServerNode) GetEndpoints() []clablinks.Endpoint { + return nil +} + +// createLabels creates container labels. +func createCodeServerLabels(containerName, owner, labsDir string) map[string]string { + labels := map[string]string{ + clabconstants.NodeName: containerName, + clabconstants.NodeKind: "linux", + clabconstants.NodeType: "tool", + clabconstants.ToolType: "code-server", + "clab-labs-dir": labsDir, + } + + // Add owner label if available + if owner != "" { + labels[clabconstants.Owner] = owner + } + + return labels +} + +func codeServerStart(cobraCmd *cobra.Command, o *Options) error { + ctx := cobraCmd.Context() + + log.With( + "name", o.ToolsCodeServer.Name, + "image", o.ToolsCodeServer.Image, + "labsDir", o.ToolsCodeServer.LabsDirectory, + "port", o.ToolsCodeServer.Port).Debug("code-server start called.") + + runtimeName := o.Global.Runtime + if runtimeName == "" { + runtimeName = clabruntimedocker.RuntimeName + } + + // Initialize runtime + _, rinit, err := clabcore.RuntimeInitializer(runtimeName) + if err != nil { + return fmt.Errorf("failed to get runtime initializer for '%s': %w", runtimeName, err) + } + + rt := rinit() + + err = rt.Init(clabruntime.WithConfig(&clabruntime.RuntimeConfig{Timeout: o.Global.Timeout})) + if err != nil { + return fmt.Errorf("failed to initialize runtime: %w", err) + } + + // Set management network to bridge for default Docker networking + rt.WithMgmtNet(&clabtypes.MgmtNet{Network: "bridge"}) + + // Check if container already exists + filter := []*clabtypes.GenericFilter{{FilterType: "name", Match: o.ToolsCodeServer.Name}} + + containers, err := rt.ListContainers(ctx, filter) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + if len(containers) > 0 { + return fmt.Errorf("container %s already exists", o.ToolsCodeServer.Name) + } + + // Pull the container image + log.Infof("Pulling image %s...", o.ToolsCodeServer.Image) + + if err := rt.PullImage(ctx, o.ToolsCodeServer.Image, clabtypes.PullPolicyAlways); err != nil { + return fmt.Errorf("failed to pull image %s: %w", o.ToolsCodeServer.Image, err) + } + + // Create container labels + if o.ToolsCodeServer.LabsDirectory == "" { + o.ToolsCodeServer.LabsDirectory = defaultLabsDir + } + + owner := getOwnerName(o) + labels := createCodeServerLabels(o.ToolsCodeServer.Name, owner, + o.ToolsCodeServer.LabsDirectory) + + // Create and start code server container + log.Info("Creating code server container", "name", o.ToolsCodeServer.Name) + + codeServerNode, err := NewCodeServerNode(o.ToolsCodeServer.Name, o.ToolsCodeServer.Image, + o.ToolsCodeServer.LabsDirectory, o.ToolsCodeServer.Port, rt, labels) + if err != nil { + return err + } + + id, err := rt.CreateContainer(ctx, codeServerNode.Config()) + if err != nil { + return fmt.Errorf("failed to create code-server container: %w", err) + } + + if _, err := rt.StartContainer(ctx, id, codeServerNode); err != nil { + // Clean up on failure + rt.DeleteContainer(ctx, o.ToolsCodeServer.Name) + return fmt.Errorf("failed to start code-server container: %w", err) + } + + log.Infof("code-server container %s started successfully.", o.ToolsCodeServer.Name) + + // Get the actual assigned port from the container if using random port + if o.ToolsCodeServer.Port == 0 { + // Get container info to find the assigned port + containers, err := rt.ListContainers(ctx, []*clabtypes.GenericFilter{{ + FilterType: "name", Match: o.ToolsCodeServer.Name, + }}) + if err == nil && len(containers) > 0 && len(containers[0].Ports) > 0 { + for _, portMapping := range containers[0].Ports { + if portMapping.ContainerPort == codeServerPort { + // log the HOST PORT + log.Infof("code-server available at: http://0.0.0.0:%d", portMapping.HostPort) + break + } + } + } else { + log.Infof("code-server container started. Check 'docker ps' for the assigned port.") + } + } else { + log.Infof("code-server available at: http://0.0.0.0:%d", o.ToolsCodeServer.Port) + } + + return nil +} + +// codeServerListItem defines the structure for API server container info in JSON output. +type codeServerListItem struct { + Name string `json:"name"` + State string `json:"state"` + Host string `json:"host"` + Port int `json:"port"` + LabsDir string `json:"labs_dir"` + Owner string `json:"owner"` +} + +func codeServerStatus(cobraCmd *cobra.Command, o *Options) error { + ctx := cobraCmd.Context() + + // Use common.Runtime for consistency with other commands + runtimeName := o.Global.Runtime + if runtimeName == "" { + runtimeName = clabruntimedocker.RuntimeName + } + + // Initialize containerlab with runtime using the same approach as inspect command + opts := []clabcore.ClabOption{ + clabcore.WithTimeout(o.Global.Timeout), + clabcore.WithRuntime(runtimeName, + &clabruntime.RuntimeConfig{ + Debug: o.Global.DebugCount > 0, + Timeout: o.Global.Timeout, + GracefulShutdown: o.Global.GracefulShutdown, + }, + ), + clabcore.WithDebug(o.Global.DebugCount > 0), + } + + c, err := clabcore.NewContainerLab(opts...) + if err != nil { + return err + } + + // Check connectivity like inspect does + err = c.CheckConnectivity(ctx) + if err != nil { + return err + } + + containers, err := c.ListContainers(ctx, clabcore.WithListToolType("code-server")) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + if len(containers) == 0 { + if o.ToolsCodeServer.OutputFormat == "json" { + fmt.Println("[]") + } else { + fmt.Println("No active code-server containers found") + } + + return nil + } + + // Process containers and format output + listItems := make([]codeServerListItem, 0, len(containers)) + for idx := range containers { + name := strings.TrimPrefix(containers[idx].Names[0], "/") + + // Get port from labels or use default + port := containers[idx].Ports[0].HostPort + + // Get labs dir from labels or use default + labsDir := defaultLabsDir // default + if dirsVal, ok := containers[idx].Labels["clab-labs-dir"]; ok { + labsDir = dirsVal + } + + // Get owner from container labels + owner := "N/A" + if ownerVal, exists := containers[idx].Labels[clabconstants.Owner]; exists && + ownerVal != "" { + owner = ownerVal + } + + listItems = append(listItems, codeServerListItem{ + Name: name, + State: containers[idx].State, + Port: port, + LabsDir: labsDir, + Owner: owner, + }) + } + + if o.ToolsCodeServer.OutputFormat == "json" { + b, err := json.MarshalIndent(listItems, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal to JSON: %w", err) + } + + fmt.Println(string(b)) + } else { + // Use go-pretty table + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.SetStyle(table.StyleRounded) + t.Style().Format.Header = text.FormatTitle + t.Style().Options.SeparateRows = true + + t.AppendHeader(table.Row{"NAME", "STATUS", "PORT", "LABS DIR", "OWNER"}) + + for _, item := range listItems { + t.AppendRow(table.Row{ + item.Name, + item.State, + item.Port, + item.LabsDir, + item.Owner, + }) + } + + t.Render() + } + + return nil +} + +func codeServerStop(cobraCmd *cobra.Command, o *Options) error { + ctx := cobraCmd.Context() + + log.Debugf("Container name for deletion: %s", o.ToolsCodeServer.Name) + + // Use common.Runtime if available, otherwise use the api-server flag + runtimeName := o.Global.Runtime + + if runtimeName == "" { + runtimeName = clabruntimedocker.RuntimeName + } + + // Initialize runtime + _, rinit, err := clabcore.RuntimeInitializer(runtimeName) + if err != nil { + return fmt.Errorf("failed to get runtime initializer: %w", err) + } + + rt := rinit() + + err = rt.Init(clabruntime.WithConfig(&clabruntime.RuntimeConfig{Timeout: o.Global.Timeout})) + if err != nil { + return fmt.Errorf("failed to initialize runtime: %w", err) + } + + log.Info("Removing code-server container", "name", o.ToolsCodeServer.Name) + + if err := rt.DeleteContainer(ctx, o.ToolsCodeServer.Name); err != nil { + return fmt.Errorf("failed to remove code-server container: %w", err) + } + + log.Info("code server container removed", "name", o.ToolsCodeServer.Name) + + return nil +} diff --git a/docs/cmd/tools/code-server/start.md b/docs/cmd/tools/code-server/start.md new file mode 100644 index 0000000000..b2291f8e7b --- /dev/null +++ b/docs/cmd/tools/code-server/start.md @@ -0,0 +1,64 @@ +# code-server start + +## Description + +The `start` sub-command under the `tools code-server` command launches a dedicated [code-server](https://github.com/coder/code-server) container that is pre-configured with the VSCode Containerlab extension. The container exposes a VS Code compatible web UI in your browser and mounts both the host lab directory and user home so you can browse, edit, and run labs. + +On first start the command creates persistent directories under `~/.clab/code-server//` for configuration, extensions, and user data. It also seeds the extensions directory with the pre-baked extensions that ship in the container image and writes a default configuration (`password: clab`). + +## Usage + +``` +containerlab tools code-server start [flags] +``` + +## Flags + +### --image | -i + +Container image to use for the code-server instance. Defaults to `ghcr.io/kaelemc/clab-code-server:main`. + +### --name | -n + +Container name to create. Defaults to `clab-code-server`. + +### --labs-dir | -l + +Host directory that will be mounted inside the container at `/labs`. Defaults to `~/.clab` when not provided. + +### --port | -p + +Host TCP port that will be forwarded to the container's port `8080`. Defaults to `0`, which lets the container runtime pick a random available port. + +### --owner | -o + +Label value stored on the container to record the creator/owner. If omitted, Containerlab derives the value from `SUDO_USER` or `USER`. + +## Examples + +Start with default settings and let Docker assign a random host port: + +```bash +❯ containerlab tools code-server start +16:41:50 INFO Pulling image ghcr.io/kaelemc/clab-code-server:main... +16:41:50 INFO Pulling image image=ghcr.io/kaelemc/clab-code-server:main +main: Pulling from kaelemc/clab-code-server +Digest: sha256:5d3b80127db6f74b556f1df1ad8c339f8bbd9694616e8325ea7e9b9fe6065fe9 +Status: Image is up to date for ghcr.io/kaelemc/clab-code-server:main +16:41:50 INFO Done pulling image image=ghcr.io/kaelemc/clab-code-server:main +16:41:50 INFO Creating code server container name=clab-code-server +16:41:50 INFO Creating container name=clab-code-server +16:41:50 INFO code-server container clab-code-server started successfully. +16:41:50 INFO code-server available at: http://0.0.0.0:32779 +``` + +Expose the service on a specific host port with a custom labs directory: + +```bash +❯ containerlab tools code-server start --port 10080 --labs-dir /srv/containerlab/labs +...[snip]... +INFO code-server container clab-code-server started successfully. +INFO code-server available at: http://0.0.0.0:10080 +``` + +After the container starts you can browse to the reported URL and log in with username `clab` / password `clab`. diff --git a/docs/cmd/tools/code-server/status.md b/docs/cmd/tools/code-server/status.md new file mode 100644 index 0000000000..b4bd8ec3ff --- /dev/null +++ b/docs/cmd/tools/code-server/status.md @@ -0,0 +1,48 @@ +# code-server status + +## Description + +The `status` sub-command under the `tools code-server` command inspects the active code-server containers that were launched via `containerlab`. It reports each container's runtime state, exposed port, mounted labs directory, and owner label so that you can quickly find connection details or verify cleanup. + +## Usage + +``` +containerlab tools code-server status [flags] +``` + +## Flags + +### --format | -f + +Output format for the listing. Accepts `table` (default) or `json`. + +## Examples + +List all running code-server containers in table form: + +```bash +❯ containerlab tools code-server status +╭──────────────────┬─────────┬───────┬──────────┬───────╮ +│ NAME │ STATUS │ PORT │ LABS DIR │ OWNER │ +├──────────────────┼─────────┼───────┼──────────┼───────┤ +│ clab-code-server │ running │ 32779 │ ~/.clab │ clab │ +╰──────────────────┴─────────┴───────┴──────────┴───────╯ +``` + +Show the same information in JSON format (useful for scripting): + +```bash +❯ containerlab tools code-server status --format json +[ + { + "name": "clab-code-server", + "state": "running", + "host": "", + "port": 32779, + "labs_dir": "~/.clab", + "owner": "clab" + } +] +``` + +When no containers are active the command prints `No active code-server containers found` (or an empty JSON array when `--format json` is used). diff --git a/docs/cmd/tools/code-server/stop.md b/docs/cmd/tools/code-server/stop.md new file mode 100644 index 0000000000..a28a33fab8 --- /dev/null +++ b/docs/cmd/tools/code-server/stop.md @@ -0,0 +1,39 @@ +# code-server stop + +## Description + +The `stop` sub-command under the `tools code-server` command removes a running code-server container. Use it to tear down the VS Code web terminal once you are done editing lab files. The command deletes the container but leaves the persistent configuration, extension, and user-data directories on disk so that the next start is nearly instant. + +## Usage + +``` +containerlab tools code-server stop [flags] +``` + +## Flags + +### --name | -n + +Name of the code-server container to remove. Defaults to `clab-code-server`. + +## Examples + +Stop the default helper container: + +```bash +❯ containerlab tools code-server stop +16:42:13 INFO Removing code-server container name=clab-code-server +16:42:13 INFO Removed container name=clab-code-server +16:42:13 INFO code server container removed name=clab-code-server +``` + +Target a specific container name: + +```bash +❯ containerlab tools code-server stop --name dev-code-server +16:45:01 INFO Removing code-server container name=dev-code-server +16:45:01 INFO Removed container name=dev-code-server +16:45:01 INFO code server container removed name=dev-code-server +``` + +If the named container is not found the command returns an error from the underlying runtime (for example `container "dev-code-server" not found`). diff --git a/mkdocs.yml b/mkdocs.yml index 2f812cd430..24fb13b6af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,10 @@ nav: - start: cmd/tools/api-server/start.md - stop: cmd/tools/api-server/stop.md - status: cmd/tools/api-server/status.md + - code-server: + - start: cmd/tools/code-server/start.md + - stop: cmd/tools/code-server/stop.md + - status: cmd/tools/code-server/status.md - sshx: - attach: cmd/tools/sshx/attach.md - detach: cmd/tools/sshx/detach.md diff --git a/mocks/mockruntime/runtime.go b/mocks/mockruntime/runtime.go index bfae029b13..c1b90c9186 100644 --- a/mocks/mockruntime/runtime.go +++ b/mocks/mockruntime/runtime.go @@ -230,6 +230,21 @@ func (mr *MockContainerRuntimeMockRecorder) GetName() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockContainerRuntime)(nil).GetName)) } +// GetRuntimeBinary mocks base method. +func (m *MockContainerRuntime) GetRuntimeBinary() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRuntimeBinary") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRuntimeBinary indicates an expected call of GetRuntimeBinary. +func (mr *MockContainerRuntimeMockRecorder) GetRuntimeBinary() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRuntimeBinary", reflect.TypeOf((*MockContainerRuntime)(nil).GetRuntimeBinary)) +} + // GetRuntimeSocket mocks base method. func (m *MockContainerRuntime) GetRuntimeSocket() (string, error) { m.ctrl.T.Helper() diff --git a/runtime/docker/docker.go b/runtime/docker/docker.go index 3f97ec01a1..a007492058 100644 --- a/runtime/docker/docker.go +++ b/runtime/docker/docker.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path" "strconv" "strings" @@ -1330,3 +1331,11 @@ func (*DockerRuntime) GetCooCBindMounts() clabtypes.Binds { clabtypes.NewBind("/run/netns", "/run/netns", ""), } } + +func (*DockerRuntime) GetRuntimeBinary() (string, error) { + runtimePath, err := exec.LookPath("docker") + if err != nil { + return "", fmt.Errorf("failed to get docker runtime binary path: %w", err) + } + return runtimePath, nil +} diff --git a/runtime/ignite/ignite.go b/runtime/ignite/ignite.go index f3e8f394d5..c163f35ff1 100644 --- a/runtime/ignite/ignite.go +++ b/runtime/ignite/ignite.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "os/exec" "strings" "time" @@ -518,3 +519,11 @@ func (*IgniteRuntime) GetRuntimeSocket() (string, error) { func (*IgniteRuntime) GetCooCBindMounts() clabtypes.Binds { return nil } + +func (*IgniteRuntime) GetRuntimeBinary() (string, error) { + path, err := exec.LookPath("ignite") + if err != nil { + return "", fmt.Errorf("failed to get ignite runtime binary path: %w", err) + } + return path, nil +} diff --git a/runtime/podman/podman.go b/runtime/podman/podman.go index 7ceff7c15d..4a2e7408d9 100644 --- a/runtime/podman/podman.go +++ b/runtime/podman/podman.go @@ -489,3 +489,7 @@ func (r *PodmanRuntime) GetRuntimeSocket() (string, error) { } return socket, nil } + +func (*PodmanRuntime) GetRuntimeBinary() (string, error) { + return "", fmt.Errorf("Podman runtime is currently unsupported") +} diff --git a/runtime/runtime.go b/runtime/runtime.go index 38eddac3ac..7c6a2690b8 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -74,6 +74,8 @@ type ContainerRuntime interface { // Container-outside-of-Container (CooC - General case – container uses host container // runtime) does need to function properly GetCooCBindMounts() clabtypes.Binds + // GetRuntimeBinary returns the path to the binary of the runtime + GetRuntimeBinary() (string, error) } type ContainerStatus string diff --git a/tests/01-smoke/25-tools-code-server.robot b/tests/01-smoke/25-tools-code-server.robot new file mode 100644 index 0000000000..38c2f10645 --- /dev/null +++ b/tests/01-smoke/25-tools-code-server.robot @@ -0,0 +1,117 @@ +*** Comments *** +This test suite verifies the functionality of the Containerlab code-server tool operations: +- Starting a code-server container with default settings +- Checking code-server status in table and JSON formats +- Stopping a code-server container +- Starting a code-server container with a custom port +- Verifying cleanup behaviour when no code-server containers are running + +*** Settings *** +Library OperatingSystem +Library String +Resource ../common.robot + +Suite Teardown Run Keyword Cleanup Code Server Containers + +*** Variables *** +${runtime} docker +${code_server_name} clab-code-server +${code_server_image} ghcr.io/kaelemc/clab-code-server:main +${custom_port} 10080 + +*** Test Cases *** +Start Code Server With Default Settings + [Documentation] Test starting code-server with default parameters + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server start + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} code-server container ${code_server_name} started successfully + Should Contain ${output} code-server available at: http://0.0.0.0: + +Check Code Server Status + [Documentation] Verify code-server status is reported in table format + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server status + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} ${code_server_name} + Should Contain ${output} running + Should Contain ${output} ~/.clab + +Check Code Server Status JSON Format + [Documentation] Verify code-server status is reported in JSON format + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server status --format json + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} "${code_server_name}" + Should Contain ${output} "running" + Should Contain ${output} "labs_dir": "~/.clab" + +Stop Code Server + [Documentation] Test stopping the default code-server container + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server stop + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Removing code-server container + Should Contain ${output} name=${code_server_name} + Should Contain ${output} code server container removed + + # Verify container is removed + ${rc} ${output}= Run And Return Rc And Output + ... ${runtime} ps -a | grep ${code_server_name} || true + Log ${output} + Should Not Contain ${output} ${code_server_name} + +Start Code Server With Custom Port + [Documentation] Test starting code-server with a custom host port + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server start --port ${custom_port} + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} code-server container ${code_server_name} started successfully + Should Contain ${output} code-server available at: http://0.0.0.0:${custom_port} + +Verify Code Server Status With Custom Port + [Documentation] Verify code-server status reflects the custom port value + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server status + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} ${code_server_name} + Should Contain ${output} running + Should Contain ${output} ${custom_port} + +Stop Code Server Custom Port + [Documentation] Stop the code-server container started with custom port + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server stop + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} Removing code-server container + Should Contain ${output} name=${code_server_name} + Should Contain ${output} code server container removed + +Verify Empty Code Server List + [Documentation] Verify status command reports no code-server containers running + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server status + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} No active code-server containers found + +Verify Empty Code Server List JSON Format + [Documentation] Verify JSON status is empty when no code-server containers exist + ${rc} ${output}= Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} tools code-server status --format json + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Be Equal ${output} [] + +*** Keywords *** +Cleanup Code Server Containers + [Documentation] Cleanup all code-server containers + Run Keyword And Ignore Error Run ${CLAB_BIN} --runtime ${runtime} tools code-server stop --name ${code_server_name} + Run Keyword And Ignore Error Run ${runtime} rm -f ${code_server_name}