diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a410e08ee5..7e5e2f6772 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -377,6 +377,15 @@ jobs: uv_ver: ${{ needs.process-gitref.outputs.uv_ver }} runtime: ${{ needs.process-gitref.outputs.runtime }} + clabernetes-tests: + uses: ./.github/workflows/clabernetes-tests.yml + needs: + - file-changes + - build-containerlab + - process-gitref + with: + py_ver: ${{ needs.process-gitref.outputs.py_ver }} + # sros-tests: # uses: ./.github/workflows/sros-tests.yml # needs: @@ -464,6 +473,7 @@ jobs: - ixiac-one-basic-tests - vxlan-tests - kind-tests + - clabernetes-tests - srsim-tests - fortigate-tests - cisco_iol-tests @@ -562,6 +572,7 @@ jobs: - ext-container-tests - vxlan-tests - kind-tests + - clabernetes-tests - srsim-tests - cisco_iol-tests - fortigate-tests diff --git a/.github/workflows/clabernetes-tests.yml b/.github/workflows/clabernetes-tests.yml new file mode 100644 index 0000000000..c88c440936 --- /dev/null +++ b/.github/workflows/clabernetes-tests.yml @@ -0,0 +1,99 @@ +name: clabernetes-tests + +"on": + workflow_call: + inputs: + py_ver: + required: true + type: string + +jobs: + clabernetes-tests: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v7 + with: + name: containerlab + + - name: Move containerlab to usr/bin + run: sudo mv ./containerlab /usr/bin/containerlab && sudo chown root:root /usr/bin/containerlab && sudo chmod 4755 /usr/bin/containerlab + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.5.28" + enable-cache: true + + - uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Install kubectl + run: | + curl -L -o ./kubectl "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x ./kubectl + sudo mv ./kubectl /usr/local/bin/kubectl + + - name: Install kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64 + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Create kind cluster + run: | + cat > /tmp/c9s-kind-config.yaml <<'EOF' + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + nodes: + - role: control-plane + - role: worker + containerdConfigPatches: + - |- + [plugins."io.containerd.grpc.v1.cri".containerd] + discard_unpacked_layers = false + EOF + + kind create cluster --name c9s --config /tmp/c9s-kind-config.yaml + kubectl wait --for=condition=Ready nodes --all --timeout=120s + + - name: Install Clabernetes + run: | + docker run --network host --rm \ + -v "${HOME}/.kube:/root/.kube" \ + -v "${HOME}/.helm:/root/.helm" \ + -v "${HOME}/.config/helm:/root/.config/helm" \ + -v "${HOME}/.cache/helm:/root/.cache/helm" \ + alpine/helm:3.18.2 \ + upgrade --install --create-namespace --namespace c9s \ + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes + + kubectl -n c9s rollout status deploy/clabernetes-manager --timeout=180s + + - name: Run Clabernetes Robot tests + run: | + bash ./tests/rf-run.sh clabernetes ./tests/14-clabernetes + + - name: Upload test report + uses: actions/upload-artifact@v6 + if: always() + with: + name: 14-clabernetes-log + path: ./tests/out/*.html + + - name: Upload coverage + uses: actions/upload-artifact@v6 + if: always() + with: + name: coverage-clabernetes-tests + path: /tmp/clab-tests/coverage/* + retention-days: 7 diff --git a/Makefile b/Makefile index f69fd72f2a..c775e771c2 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,7 @@ endif ifndef suite override suite = . endif +.PHONY: robot-test robot-test: build-with-podman-debug sudo chown root:root $(BINARY) && sudo chmod 4755 $(BINARY) CLAB_BIN=$(BINARY) $$PWD/tests/rf-run.sh $(runtime) $$PWD/tests/$(suite) diff --git a/cmd/deploy.go b/cmd/deploy.go index e38d6e4e23..d8abc6b297 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -34,6 +35,10 @@ func deployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen Aliases: []string{"dep", "apply"}, SilenceUsage: true, PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cobraCmd *cobra.Command, _ []string) error { @@ -227,7 +232,7 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { } if o.Deploy.DryRun { - return printDryRunResult(result.Apply, o) + return printDryRunResult(result, o) } // keep stdout machine-readable for non-table formats: the reconciliation summary @@ -236,27 +241,37 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { printApplyResult(result.Apply) } - // historically i think this was 5s, but we will already have had at least some time for - // the manager to have gone off and fetched the version, so 3s max to wrap that up and print - // seems reasonable - versionCheckContext, cancel := context.WithTimeout( - cobraCmd.Context(), - postDeployVersionCheckTimeout, - ) - defer cancel() + if shouldDisplayPostDeployVersion(o.Global.Runtime) { + // The manager has fetched in the background during deploy, so allow at most three more + // seconds to finish and print the available containerlab release. + versionCheckContext, cancel := context.WithTimeout( + cobraCmd.Context(), + postDeployVersionCheckTimeout, + ) + defer cancel() - m := getVersionManager() - m.DisplayNewVersionAvailable(versionCheckContext, false) + m := getVersionManager() + m.DisplayNewVersionAvailable(versionCheckContext, false) + } // print table summary return PrintContainerInspect(result.Containers, o) } +func shouldDisplayPostDeployVersion(runtimeName string) bool { + return !clablabruntime.IsLabRuntimeName(runtimeName) +} + // printDryRunResult prints the planned changes of a dry run, as JSON when requested via // the --format flag and as a table otherwise. -func printDryRunResult(result *clabcore.ApplyResult, o *Options) error { +func printDryRunResult(result *clabcore.DeployResult, o *Options) error { if o.Inspect.Format == clabconstants.FormatJSON { - b, err := json.MarshalIndent(result, "", " ") + value := any(result.Apply) + if result.RuntimePlan != nil { + value = result.RuntimePlan + } + + b, err := json.MarshalIndent(value, "", " ") if err != nil { return err } @@ -266,11 +281,41 @@ func printDryRunResult(result *clabcore.ApplyResult, o *Options) error { return nil } - printApplyResult(result) + if result.RuntimePlan != nil { + printLabRuntimePlan(result.RuntimePlan) + + return nil + } + + printApplyResult(result.Apply) return nil } +func printLabRuntimePlan(plan *clablabruntime.DeployPlan) { + log.Info("Lab runtime plan", "name", plan.LabName, "namespace", plan.Namespace) + + table := tableWriter.NewWriter() + table.SetOutputMirror(os.Stdout) + table.SetStyle(tableWriter.StyleRounded) + table.Style().Format.Header = text.FormatTitle + table.Style().Format.HeaderAlign = text.AlignCenter + table.AppendHeader(tableWriter.Row{"Action", "Kind", "Resource"}) + + for _, change := range plan.Changes { + resourceName := change.Name + if change.Namespace != "" { + resourceName = change.Namespace + "/" + resourceName + } + table.AppendRow(tableWriter.Row{change.Action, change.Kind, resourceName}) + } + if len(plan.Changes) == 0 { + table.AppendRow(tableWriter.Row{"no changes", "-", "-"}) + } + + table.Render() +} + func printApplyResult(result *clabcore.ApplyResult) { title := "Apply summary" if result.DryRun { diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index f0f2dc6e5e..f747450401 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -39,6 +39,26 @@ func TestApplyIsDeployAlias(t *testing.T) { } } +func TestPostDeployVersionDisplaySkipsLabRuntimes(t *testing.T) { + tests := []struct { + name string + runtime string + want bool + }{ + {name: "default runtime", want: true}, + {name: "docker runtime", runtime: "docker", want: true}, + {name: "clabernetes runtime", runtime: "clabernetes", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := shouldDisplayPostDeployVersion(tt.runtime); got != tt.want { + t.Fatalf("shouldDisplayPostDeployVersion(%q) = %t, want %t", tt.runtime, got, tt.want) + } + }) + } +} + func TestPrintApplyResultUsesInfoAndItemRows(t *testing.T) { output := captureApplyOutput(t, func() { printApplyResult(&clabcore.ApplyResult{ diff --git a/cmd/destroy.go b/cmd/destroy.go index 221d7ee69a..10d07f8fac 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -20,6 +20,10 @@ func destroyCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/destroy/", Aliases: []string{"des"}, PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cobraCmd *cobra.Command, _ []string) error { diff --git a/cmd/events.go b/cmd/events.go index 5114558118..ae79894d16 100644 --- a/cmd/events.go +++ b/cmd/events.go @@ -14,6 +14,10 @@ func eventsCmd(o *Options) (*cobra.Command, error) { "reference: https://containerlab.dev/cmd/events/", Aliases: []string{"ev"}, PreRunE: func(*cobra.Command, []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmd/exec.go b/cmd/exec.go index a836ac381c..498df84ff9 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -84,20 +84,18 @@ func execFn(_ *cobra.Command, o *Options) error { } resultCollection, err := c.Exec(ctx, o.Exec.Commands, listOptions...) - if err != nil { - return err - } - - switch outputFormat { - case clabconstants.FormatPlain: - resultCollection.Log() - case clabconstants.FormatJSON: - out, err := resultCollection.Dump(outputFormat) - if err != nil { - return fmt.Errorf("failed to print the results collection: %v", err) + if resultCollection != nil { + switch outputFormat { + case clabconstants.FormatPlain: + resultCollection.Log() + case clabconstants.FormatJSON: + out, dumpErr := resultCollection.Dump(outputFormat) + if dumpErr != nil { + return fmt.Errorf("failed to print the results collection: %v", dumpErr) + } + + fmt.Println(out) } - - fmt.Println(out) } return err diff --git a/cmd/inspect.go b/cmd/inspect.go index dad002626d..5730120cc8 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -165,6 +165,10 @@ func listContainers( c *clabcore.CLab, o *Options, ) ([]clabruntime.GenericContainer, error) { + if c.LabRuntime != nil { + return c.ListLabRuntimeContainers(ctx, o.Destroy.All) + } + var containers []clabruntime.GenericContainer var err error @@ -248,6 +252,9 @@ func getShortestTopologyPath(p string) (string, error) { if p == "" { return "", nil } + if strings.Contains(p, "://") { + return p, nil + } // get topo file path relative of the cwd cwd, err := os.Getwd() diff --git a/cmd/options.go b/cmd/options.go index 2232946553..2255ef69df 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -7,6 +7,7 @@ import ( clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" ) @@ -14,6 +15,7 @@ import ( const ( multiToolImage = "ghcr.io/srl-labs/network-multitool" defaultTimeout = 120 * time.Second + defaultLabRuntimeTimeout = 10 * time.Minute defaultToolsServerPort = 8080 defaultToolsAPIServerPort = 8090 defaultToolsApiSSHBasePort = 2223 @@ -240,6 +242,7 @@ type GlobalOptions struct { TopologyFile string VarsFiles []string TopologyName string + Namespace string Timeout time.Duration Runtime string GracefulShutdown bool @@ -264,6 +267,7 @@ func (o *GlobalOptions) toClabOptions() []clabcore.ClabOption { Debug: o.DebugCount > 0, Timeout: o.Timeout, GracefulShutdown: o.GracefulShutdown, + LabNamespace: o.Namespace, }, ), clabcore.WithDebug(o.DebugCount > 0), @@ -279,7 +283,8 @@ func (o *GlobalOptions) toClabOptions() []clabcore.ClabOption { options = append(options, clabcore.WithTopologyName(o.TopologyName)) } - if o.TopologyFile == "" && o.TopologyName != "" { + if o.TopologyFile == "" && o.TopologyName != "" && + !clablabruntime.IsLabRuntimeName(o.Runtime) { options = append(options, clabcore.WithTopologyFromLab(o.TopologyName, o.VarsFiles)) } diff --git a/cmd/redeploy.go b/cmd/redeploy.go index 3223cdf318..ff9ab47a03 100644 --- a/cmd/redeploy.go +++ b/cmd/redeploy.go @@ -13,6 +13,10 @@ func redeployCmd(o *Options) (*cobra.Command, error) { //nolint: funlen "reference: https://containerlab.dev/cmd/redeploy/", Aliases: []string{"rdep"}, PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/restart.go b/cmd/restart.go index 643331359c..54291b0da6 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -13,6 +13,10 @@ func restartCmd(o *Options) (*cobra.Command, error) { Use: "restart", Short: "Restart one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/root.go b/cmd/root.go index bdd7b5db32..df4b312bd4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/charmbracelet/x/term" "github.com/spf13/cobra" clabgit "github.com/srl-labs/containerlab/git" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntimedocker "github.com/srl-labs/containerlab/runtime/docker" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -81,12 +83,18 @@ func Entrypoint() (*cobra.Command, error) { "", o.Global.TopologyName, "lab/topology name") + c.PersistentFlags().StringVar( + &o.Global.Namespace, + "namespace", + o.Global.Namespace, + "Kubernetes namespace override for the clabernetes runtime", + ) c.PersistentFlags().DurationVarP( &o.Global.Timeout, "timeout", "", o.Global.Timeout, - "timeout for external API requests (e.g. container runtimes), e.g: 30s, 1m, 2m30s", + "timeout for external API requests (e.g. container runtimes); lab runtimes default to 10m", ) c.PersistentFlags().StringVarP( &o.Global.Runtime, @@ -130,6 +138,7 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { if v != nil { updateOptionsFromViper(cobraCmd, o) } + applyLabRuntimeDefaultTimeout(cobraCmd, o) // setting log level switch { @@ -153,12 +162,15 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { log.SetTimeFormat(time.TimeOnly) + if err := checkLabRuntimeCommandSupport(cobraCmd, o.Global.Runtime); err != nil { + return err + } + err := clabutils.DropRootPrivs() if err != nil { return err } - // Rootless operations only supported for Docker runtime - if o.Global.Runtime != "" && o.Global.Runtime != clabruntimedocker.RuntimeName { + if globalRuntimeRequiresRoot(o.Global.Runtime) { err := clabutils.CheckAndGetRootPrivs() if err != nil { return err @@ -168,6 +180,119 @@ func preRunFn(cobraCmd *cobra.Command, o *Options) error { return getTopoFilePath(cobraCmd, o) } +// applyLabRuntimeDefaultTimeout gives remote, controller-driven lab runtimes enough time to load +// large node images. An explicit flag or environment value always wins. Local Docker and Podman +// retain their existing two-minute default. +func applyLabRuntimeDefaultTimeout(cobraCmd *cobra.Command, o *Options) { + if !clablabruntime.IsLabRuntimeName(o.Global.Runtime) { + return + } + + timeoutFlag := cobraCmd.Flag("timeout") + if timeoutFlag != nil && timeoutFlag.Changed { + return + } + + if value, ok := os.LookupEnv(envPrefix + "_TIMEOUT"); ok && strings.TrimSpace(value) != "" { + return + } + + o.Global.Timeout = defaultLabRuntimeTimeout +} + +func globalRuntimeRequiresRoot(name string) bool { + return name != "" && + name != clabruntimedocker.RuntimeName && + !commandSkipsRoot(name) +} + +func commandSkipsRoot(name string) bool { + return clablabruntime.IsLabRuntimeName(name) +} + +// labRuntimeUnsupportedCommands operate on local containers or host networking +// and have no lab runtime equivalent. +var labRuntimeUnsupportedCommands = map[string]struct{}{ + "graph": {}, + "tools": {}, +} + +// labRuntimeUnsupportedFlags is the explicit CLI compatibility contract for controller-driven +// runtimes. A flag listed here must fail before topology parsing or remote mutation instead of +// being accepted and silently ignored by the adapter. +var labRuntimeUnsupportedFlags = map[string][]string{ //nolint:gochecknoglobals + "deploy": { + "graph", "ipv4-subnet", "ipv6-subnet", "max-workers", "network", "node-filter", + "restore", "restore-all", "skip-labdir-acl", "skip-post-deploy", "export-template", + }, + "destroy": { + "cleanup", "graceful", "keep-mgmt-net", "max-workers", "node-filter", + }, + "redeploy": { + "cleanup", "graceful", "graph", "ipv4-subnet", "ipv6-subnet", "keep-mgmt-net", + "max-workers", "network", "skip-labdir-acl", "skip-post-deploy", "export-template", + }, +} + +func checkLabRuntimeCommandSupport(cobraCmd *cobra.Command, runtimeName string) error { + if !clablabruntime.IsLabRuntimeName(runtimeName) { + return nil + } + + for cmd := cobraCmd; cmd != nil; cmd = cmd.Parent() { + if _, ok := labRuntimeUnsupportedCommands[cmd.Name()]; ok { + return fmt.Errorf("the %q command is not supported with lab runtime %q", + cmd.Name(), runtimeName) + } + } + + if getCommandPath(cobraCmd) == "inspect.interfaces" { + return fmt.Errorf("the %q command is not supported with lab runtime %q", + "inspect interfaces", runtimeName) + } + + var unsupported []string + for _, flagName := range labRuntimeUnsupportedFlags[getCommandPath(cobraCmd)] { + if labRuntimeFlagWasSet(cobraCmd, flagName) { + unsupported = append(unsupported, "--"+flagName) + } + } + if len(unsupported) != 0 { + sort.Strings(unsupported) + + return fmt.Errorf( + "flag(s) %s are not supported with the %q command and lab runtime %q", + strings.Join(unsupported, ", "), + cobraCmd.Name(), + runtimeName, + ) + } + + return nil +} + +func labRuntimeFlagWasSet(cobraCmd *cobra.Command, flagName string) bool { + flag := cobraCmd.Flag(flagName) + if flag == nil { + return false + } + + if flag.Changed { + return true + } + + if v == nil { + return false + } + + commandKey := getCommandPath(cobraCmd) + "." + flagName + if v.IsSet(commandKey) { + return true + } + + return v.IsSet(flagName) +} + // getTopoFilePath finds *.clab.y*ml file in the current working directory // if the file was not specified. // If the topology file refers to a git repository, it will be cloned to the current directory. diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000..0966ed61ce --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "os" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestRootRequirementHelpers(t *testing.T) { + tests := []struct { + name string + runtime string + wantGlobalRoot bool + wantCommandSkipped bool + }{ + { + name: "default runtime", + runtime: "", + wantGlobalRoot: false, + wantCommandSkipped: false, + }, + { + name: "docker runtime", + runtime: "docker", + wantGlobalRoot: false, + wantCommandSkipped: false, + }, + { + name: "podman runtime", + runtime: "podman", + wantGlobalRoot: true, + wantCommandSkipped: false, + }, + { + name: "clabernetes runtime", + runtime: "clabernetes", + wantGlobalRoot: false, + wantCommandSkipped: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := globalRuntimeRequiresRoot(tt.runtime); got != tt.wantGlobalRoot { + t.Fatalf("globalRuntimeRequiresRoot(%q) = %v, want %v", + tt.runtime, got, tt.wantGlobalRoot) + } + + if got := commandSkipsRoot(tt.runtime); got != tt.wantCommandSkipped { + t.Fatalf("commandSkipsRoot(%q) = %v, want %v", + tt.runtime, got, tt.wantCommandSkipped) + } + }) + } +} + +func TestNamespaceFlagHasNoShorthand(t *testing.T) { + optionsInstance = nil + t.Cleanup(func() { optionsInstance = nil }) + + root, err := Entrypoint() + if err != nil { + t.Fatal(err) + } + + flag := root.PersistentFlags().Lookup("namespace") + if flag == nil { + t.Fatal("--namespace flag was not registered") + } + if flag.Shorthand != "" { + t.Fatalf("--namespace shorthand = %q, want none", flag.Shorthand) + } + if err := root.PersistentFlags().Set("namespace", "default"); err != nil { + t.Fatal(err) + } + if got := GetOptions().Global.Namespace; got != "default" { + t.Fatalf("namespace option = %q, want default", got) + } +} + +func TestApplyLabRuntimeDefaultTimeout(t *testing.T) { + tests := []struct { + name string + runtime string + explicitFlag string + explicitEnv string + initialTimeout time.Duration + expectedTimeout time.Duration + }{ + { + name: "clabernetes default", + runtime: "clabernetes", + initialTimeout: defaultTimeout, + expectedTimeout: defaultLabRuntimeTimeout, + }, + { + name: "docker default unchanged", + runtime: "docker", + initialTimeout: defaultTimeout, + expectedTimeout: defaultTimeout, + }, + { + name: "explicit flag wins", + runtime: "clabernetes", + explicitFlag: "45s", + initialTimeout: defaultTimeout, + expectedTimeout: 45 * time.Second, + }, + { + name: "explicit environment wins", + runtime: "clabernetes", + explicitEnv: "3m", + initialTimeout: 3 * time.Minute, + expectedTimeout: 3 * time.Minute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalTimeout, timeoutWasSet := os.LookupEnv("CLAB_TIMEOUT") + if tt.explicitEnv == "" { + if err := os.Unsetenv("CLAB_TIMEOUT"); err != nil { + t.Fatal(err) + } + } else { + t.Setenv("CLAB_TIMEOUT", tt.explicitEnv) + } + t.Cleanup(func() { + if timeoutWasSet { + _ = os.Setenv("CLAB_TIMEOUT", originalTimeout) + } else { + _ = os.Unsetenv("CLAB_TIMEOUT") + } + }) + + root := &cobra.Command{Use: "containerlab"} + options := &Options{Global: &GlobalOptions{ + Runtime: tt.runtime, + Timeout: tt.initialTimeout, + }} + root.PersistentFlags().DurationVar( + &options.Global.Timeout, + "timeout", + options.Global.Timeout, + "timeout", + ) + deploy := &cobra.Command{Use: "deploy"} + root.AddCommand(deploy) + if tt.explicitFlag != "" { + if err := root.PersistentFlags().Set("timeout", tt.explicitFlag); err != nil { + t.Fatal(err) + } + } + + applyLabRuntimeDefaultTimeout(deploy, options) + if options.Global.Timeout != tt.expectedTimeout { + t.Fatalf( + "timeout = %s, want %s", + options.Global.Timeout, + tt.expectedTimeout, + ) + } + }) + } +} + +func TestCheckLabRuntimeCommandSupport(t *testing.T) { + root := &cobra.Command{Use: "containerlab"} + graph := &cobra.Command{Use: "graph"} + tools := &cobra.Command{Use: "tools"} + sshx := &cobra.Command{Use: "sshx"} + deploy := &cobra.Command{Use: "deploy"} + + root.AddCommand(graph, tools, deploy) + tools.AddCommand(sshx) + + tests := []struct { + name string + runtime string + cmd *cobra.Command + wantErr bool + }{ + { + name: "graph with docker runtime", + runtime: "docker", + cmd: graph, + wantErr: false, + }, + { + name: "deploy with clabernetes runtime", + runtime: "clabernetes", + cmd: deploy, + wantErr: false, + }, + { + name: "graph with clabernetes runtime", + runtime: "clabernetes", + cmd: graph, + wantErr: true, + }, + { + name: "tools subcommand with clabernetes runtime", + runtime: "clabernetes", + cmd: sshx, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkLabRuntimeCommandSupport(tt.cmd, tt.runtime) + if (err != nil) != tt.wantErr { + t.Fatalf("checkLabRuntimeCommandSupport(%q, %q) error = %v, wantErr %v", + tt.cmd.Name(), tt.runtime, err, tt.wantErr) + } + }) + } +} + +func TestCheckLabRuntimeFlagAndNestedCommandSupport(t *testing.T) { + tests := []struct { + name string + command string + flag string + flagValue string + wantErr bool + }{ + {name: "deploy dry-run supported", command: "deploy", flag: "dry-run", flagValue: "true"}, + {name: "deploy worker controls rejected", command: "deploy", flag: "max-workers", flagValue: "4", wantErr: true}, + {name: "deploy node filter rejected", command: "deploy", flag: "node-filter", flagValue: "n1", wantErr: true}, + {name: "destroy graceful rejected", command: "destroy", flag: "graceful", flagValue: "true", wantErr: true}, + {name: "destroy node filter rejected", command: "destroy", flag: "node-filter", flagValue: "n1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v = nil + t.Cleanup(func() { v = nil }) + + root := &cobra.Command{Use: "containerlab"} + command := &cobra.Command{Use: tt.command} + root.AddCommand(command) + switch tt.flag { + case "dry-run", "graceful": + command.Flags().Bool(tt.flag, false, "") + case "max-workers": + command.Flags().Uint(tt.flag, 0, "") + case "node-filter": + command.Flags().StringSlice(tt.flag, nil, "") + } + if err := command.Flags().Set(tt.flag, tt.flagValue); err != nil { + t.Fatal(err) + } + + err := checkLabRuntimeCommandSupport(command, "clabernetes") + if (err != nil) != tt.wantErr { + t.Fatalf("checkLabRuntimeCommandSupport() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } + + t.Run("inspect interfaces rejected", func(t *testing.T) { + root := &cobra.Command{Use: "containerlab"} + inspect := &cobra.Command{Use: "inspect"} + interfaces := &cobra.Command{Use: "interfaces"} + root.AddCommand(inspect) + inspect.AddCommand(interfaces) + + if err := checkLabRuntimeCommandSupport(interfaces, "clabernetes"); err == nil { + t.Fatal("inspect interfaces was accepted for clabernetes") + } + }) +} diff --git a/cmd/start.go b/cmd/start.go index d140f7b974..f1f3afaba4 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,6 +13,10 @@ func startCmd(o *Options) (*cobra.Command, error) { Use: "start", Short: "Start one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/stop.go b/cmd/stop.go index b3d5ad7d38..ef31f93ab1 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -13,6 +13,10 @@ func stopCmd(o *Options) (*cobra.Command, error) { Use: "stop", Short: "Stop one or more nodes in a deployed lab (seamless dataplane)", PreRunE: func(_ *cobra.Command, _ []string) error { + if commandSkipsRoot(o.Global.Runtime) { + return nil + } + return clabutils.CheckAndGetRootPrivs() }, SilenceUsage: true, diff --git a/cmd/validate.go b/cmd/validate.go index 13001bca70..b99ede1987 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "github.com/charmbracelet/log" @@ -33,12 +34,35 @@ func validateFn(o *Options) error { } if c.Config.Name == "" || len(c.Nodes) == 0 { + if c.LabRuntime != nil && c.Config.Name != "" { + // Lab runtimes intentionally do not instantiate native node implementations. + if err := c.ValidateLabRuntimeTopology(context.Background()); err != nil { + return err + } + + log.Info("Topology is valid for lab runtime", "name", c.Config.Name, + "runtime", o.Global.Runtime) + + return nil + } + return fmt.Errorf( "topology file %q defines no name or nodes. likely an empty file", c.TopoPaths.TopologyFilenameBase(), ) } + if c.LabRuntime != nil { + if err := c.ValidateLabRuntimeTopology(context.Background()); err != nil { + return err + } + + log.Info("Topology is valid for lab runtime", "name", c.Config.Name, + "runtime", o.Global.Runtime) + + return nil + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/clab.go b/core/clab.go index 4315c427a7..45a355bf0c 100644 --- a/core/clab.go +++ b/core/clab.go @@ -20,6 +20,8 @@ import ( clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" claberrors "github.com/srl-labs/containerlab/errors" clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + _ "github.com/srl-labs/containerlab/labruntime/all" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" @@ -33,12 +35,13 @@ import ( var ErrNodeNotFound = errors.New("node not found") type CLab struct { - Config *Config `json:"config,omitempty"` - TopoPaths *clabtypes.TopoPaths - Nodes map[string]clabnodes.Node `json:"nodes,omitempty"` - Links map[int]clablinks.Link `json:"links,omitempty"` - Endpoints []clablinks.Endpoint - Runtimes map[string]clabruntime.ContainerRuntime `json:"runtimes,omitempty"` + Config *Config `json:"config,omitempty"` + TopoPaths *clabtypes.TopoPaths + Nodes map[string]clabnodes.Node `json:"nodes,omitempty"` + Links map[int]clablinks.Link `json:"links,omitempty"` + Endpoints []clablinks.Endpoint + Runtimes map[string]clabruntime.ContainerRuntime `json:"runtimes,omitempty"` + LabRuntime clablabruntime.LabRuntime `json:"-"` // reg is a registry of node kinds Reg *clabnodes.NodeRegistry Cert *clabcert.Cert @@ -66,6 +69,8 @@ type CLab struct { // to avoid repeated repository opens. Empty strings indicate not yet cached. gitBranch string gitHash string + + renderedTopology []byte } // NewContainerLab function defines a new container lab. @@ -97,7 +102,11 @@ func NewContainerLab(opts ...ClabOption) (*CLab, error) { var err error if c.TopoPaths.TopologyFileIsSet() { - err = c.parseTopology() + if c.LabRuntime != nil { + err = c.prepareLabRuntimeTopology() + } else { + err = c.parseTopology() + } } // Extract the host systems DNS servers and populate the @@ -113,23 +122,55 @@ func NewContainerLab(opts ...ClabOption) (*CLab, error) { // RuntimeInitializer returns a runtime initializer function for a provided runtime name. // Order of preference: cli flag -> env var -> default value of docker. func RuntimeInitializer(name string) (string, clabruntime.Initializer, error) { + name = resolveRuntimeName(name) + + runtimeInitializer, ok := clabruntime.ContainerRuntimes[name] + if !ok { + return name, nil, fmt.Errorf("unknown container runtime %q", name) + } + + return name, runtimeInitializer, nil +} + +func resolveRuntimeName(name string) string { envN := os.Getenv("CLAB_RUNTIME") log.Debugf("env runtime var value is %v", envN) switch { case name != "": + return name case envN != "": - name = envN + return envN default: - name = clabruntimedocker.RuntimeName + return clabruntimedocker.RuntimeName } +} - runtimeInitializer, ok := clabruntime.ContainerRuntimes[name] - if !ok { - return name, nil, fmt.Errorf("unknown container runtime %q", name) +func (c *CLab) prepareLabRuntimeTopology() error { + log.Info("Parsing & checking topology", "file", c.TopoPaths.TopologyFilenameBase()) + + if strings.Contains(c.Config.Name, gitBranchVar) || + strings.Contains(c.Config.Name, gitHashVar) { + r := c.magicTopoNameReplacer() + oldName := c.Config.Name + c.Config.Name = r.Replace(c.Config.Name) + log.Debugf( + "Topology name contains Git variables, substituted topology name: %q -> %q", + oldName, + c.Config.Name, + ) } - return name, runtimeInitializer, nil + if err := c.TopoPaths.SetLabDirByPrefix(c.Config.Name); err != nil { + return err + } + + if c.Config.Prefix == nil { + c.Config.Prefix = new(string) + *c.Config.Prefix = defaultPrefix + } + + return nil } // ProcessTopoPath takes a topology path, which might be the path to a directory or a file @@ -187,6 +228,11 @@ func (c *CLab) filterClabNodes(nodeFilter []string) error { c.nodeFilter = nodeFilter + if c.LabRuntime != nil { + log.Infof("Applying node filter: %q", nodeFilter) + return nil + } + // ensure that the node filter is a subset of the nodes in the topology for _, n := range nodeFilter { if _, ok := c.Config.Topology.Nodes[n]; !ok { diff --git a/core/config.go b/core/config.go index 660156bfea..688184a929 100644 --- a/core/config.go +++ b/core/config.go @@ -751,9 +751,6 @@ func (c *CLab) HasKind(k string) bool { return false } -// addDefaultLabels adds default labels to node's config struct. -// Update the addDefaultLabels function in clab/config.go -// addDefaultLabels adds default labels to node's config struct. func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { if cfg.Labels == nil { cfg.Labels = map[string]string{} @@ -768,16 +765,7 @@ func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { cfg.Labels[clabconstants.NodeLabDir] = cfg.LabDir cfg.Labels[clabconstants.TopoFile] = c.TopoPaths.TopologyFilenameAbsPath() - // Use custom owner if set, otherwise use current user - owner := c.customOwner - if owner == "" { - owner = os.Getenv("SUDO_USER") - if owner == "" { - owner = os.Getenv("USER") - } - } - - cfg.Labels[clabconstants.Owner] = owner + cfg.Labels[clabconstants.Owner] = c.labOwner() gitBranch, gitHash := c.getGitInfo() @@ -789,6 +777,18 @@ func (c *CLab) addDefaultLabels(cfg *clabtypes.NodeConfig) { } } +func (c *CLab) labOwner() string { + if c.customOwner != "" { + return c.customOwner + } + + if owner := os.Getenv("SUDO_USER"); owner != "" { + return owner + } + + return os.Getenv("USER") +} + // labelsToEnvVars adds labels to env vars with CLAB_LABEL_ prefix added // and labels value sanitized. func labelsToEnvVars(n *clabtypes.NodeConfig) { diff --git a/core/deploy.go b/core/deploy.go index 652845e38c..c114adc729 100644 --- a/core/deploy.go +++ b/core/deploy.go @@ -14,6 +14,7 @@ import ( clabcert "github.com/srl-labs/containerlab/cert" clabconstants "github.com/srl-labs/containerlab/constants" clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" @@ -26,6 +27,8 @@ type DeployResult struct { // Apply summarizes the reconciliation of an already deployed lab or the dry-run plan; // it is nil when Deploy performed a fresh full deployment. Apply *ApplyResult + // RuntimePlan is the remote resource plan produced by a controller-driven runtime dry-run. + RuntimePlan *clablabruntime.DeployPlan } // Deploy converges the lab to the requested topology. A lab without runtime state is @@ -58,6 +61,25 @@ func (c *CLab) Deploy( return &DeployResult{Containers: containers}, nil } + if c.LabRuntime != nil && options.dryRun { + planner, ok := c.LabRuntime.(clablabruntime.TopologyPlanner) + if !ok { + return nil, fmt.Errorf("lab runtime %q does not support dry-run", c.globalRuntimeName) + } + + req, err := c.labRuntimeDeployRequest() + if err != nil { + return nil, err + } + + plan, err := planner.Plan(ctx, req) + if err != nil { + return nil, err + } + + return &DeployResult{RuntimePlan: plan}, nil + } + currentNodes, err := c.runtimeNodeGroups(ctx) if err != nil { return nil, err @@ -152,6 +174,10 @@ func (c *CLab) deploy( //nolint: funlen ctx context.Context, options *DeployOptions, ) ([]clabruntime.GenericContainer, error) { + if c.LabRuntime != nil { + return c.deployWithLabRuntime(ctx, options) + } + var err error err = c.ResolveLinks() diff --git a/core/destroy.go b/core/destroy.go index 058f274a22..d02b0d6133 100644 --- a/core/destroy.go +++ b/core/destroy.go @@ -29,6 +29,10 @@ func (c *CLab) Destroy(ctx context.Context, options ...DestroyOption) (err error opt(opts) } + if c.LabRuntime != nil { + return c.destroyWithLabRuntime(ctx, opts) + } + var containers []clabruntime.GenericContainer switch { diff --git a/core/destroy_test.go b/core/destroy_test.go index e1b4cb714e..589deda1ae 100644 --- a/core/destroy_test.go +++ b/core/destroy_test.go @@ -10,6 +10,7 @@ import ( "testing" claberrors "github.com/srl-labs/containerlab/errors" + clablabruntime "github.com/srl-labs/containerlab/labruntime" ) // makeCopyForDestroy must apply WithTopoPath (or WithLabNameOnly) before WithNodeFilter so that @@ -72,3 +73,20 @@ func TestWithLabNameOnly_setsNameWithoutTopologyFile(t *testing.T) { t.Fatal("topology file should not be set for lab-name-only init") } } + +type noopLabRuntime struct { + clablabruntime.LabRuntime +} + +func TestWithKeepMgmtNet_noopsForLabRuntime(t *testing.T) { + t.Parallel() + + c := &CLab{ + LabRuntime: noopLabRuntime{}, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, + } + + if err := WithKeepMgmtNet()(c); err != nil { + t.Fatalf("WithKeepMgmtNet returned error for lab runtime: %v", err) + } +} diff --git a/core/events/stream.go b/core/events/stream.go index fd7082207f..c96ea57610 100644 --- a/core/events/stream.go +++ b/core/events/stream.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcore "github.com/srl-labs/containerlab/core" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -27,6 +28,10 @@ func Stream(ctx context.Context, opts Options) error { return err } + if clab.LabRuntime != nil { + return streamLabRuntimeEvents(ctx, clab, opts) + } + runtime, ok := clab.Runtimes[opts.Runtime] if !ok { return fmt.Errorf("runtime %q is not initialized", opts.Runtime) @@ -102,6 +107,72 @@ func Stream(ctx context.Context, opts Options) error { } } +func streamLabRuntimeEvents(ctx context.Context, clab *clabcore.CLab, opts Options) error { + printer, err := newFormatter(opts.Format, opts.writer()) + if err != nil { + return err + } + + runtimeEvents, runtimeErrs, err := clab.LabRuntime.StreamEvents( + ctx, + clablabruntime.EventStreamRequest{ + AllNamespaces: true, + IncludeInitialState: opts.IncludeInitialState, + IncludeInterfaceStats: opts.IncludeInterfaceStats, + StatsInterval: opts.StatsInterval, + }, + ) + if err != nil { + return fmt.Errorf("failed to stream events for lab runtime %q: %w", opts.Runtime, err) + } + + for runtimeEvents != nil || runtimeErrs != nil { + select { + case ev, ok := <-runtimeEvents: + if !ok { + runtimeEvents = nil + + continue + } + + if err := printer(aggregatedEventFromLabRuntimeEvent(ev)); err != nil { + log.Debugf("failed to write event: %v", err) + } + case err, ok := <-runtimeErrs: + if !ok { + runtimeErrs = nil + + continue + } + + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + case <-ctx.Done(): + return nil + } + } + + return nil +} + +func aggregatedEventFromLabRuntimeEvent(ev clablabruntime.Event) aggregatedEvent { + ts := ev.Timestamp + if ts.IsZero() { + ts = time.Now() + } + + return aggregatedEvent{ + Timestamp: ts, + Type: ev.Type, + Action: ev.Action, + ActorID: ev.ActorID, + ActorName: ev.ActorName, + ActorFullID: ev.ActorFullID, + Attributes: cloneStringMap(ev.Attributes), + } +} + func forwardRuntimeEvents( ctx context.Context, runtime clabruntime.ContainerRuntime, diff --git a/core/exec.go b/core/exec.go index 6874f5b0a9..3346be4fa8 100644 --- a/core/exec.go +++ b/core/exec.go @@ -16,6 +16,10 @@ func (c *CLab) Exec( cmds []string, listOptions ...ListOption, ) (*clabexec.ExecCollection, error) { + if c.LabRuntime != nil { + return c.execWithLabRuntime(ctx, cmds, listOptions...) + } + err := clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) if err != nil { return nil, err diff --git a/core/file.go b/core/file.go index 8ec46fa55d..d2ca0f2bd3 100644 --- a/core/file.go +++ b/core/file.go @@ -88,6 +88,8 @@ func (c *CLab) LoadTopologyFromFile(topo string, varsFiles []string) error { return err } + c.renderedTopology = append(c.renderedTopology[:0], yamlFile...) + // save the rendered topology to disk if requested if ExportRenderedTopology != "" { if err := os.WriteFile(ExportRenderedTopology, yamlFile, 0644); err != nil { diff --git a/core/labruntime.go b/core/labruntime.go new file mode 100644 index 0000000000..e1f17fdf64 --- /dev/null +++ b/core/labruntime.go @@ -0,0 +1,594 @@ +package core + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" + clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + clabruntime "github.com/srl-labs/containerlab/runtime" + clabtypes "github.com/srl-labs/containerlab/types" + "golang.org/x/term" +) + +func (c *CLab) deployWithLabRuntime( + ctx context.Context, + options *DeployOptions, +) ([]clabruntime.GenericContainer, error) { + if len(c.nodeFilter) != 0 { + return nil, fmt.Errorf("node-filter is not supported for lab runtime %q", + c.globalRuntimeName) + } + + if len(c.renderedTopology) == 0 { + return nil, fmt.Errorf("rendered topology is empty") + } + + if options != nil && options.reconfigure { + err := c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ + Name: c.Config.Name, + Wait: true, + Timeout: c.timeout, + }) + if err != nil { + return nil, err + } + } + + req, err := c.labRuntimeDeployRequest() + if err != nil { + return nil, err + } + req.Wait = true + + state, err := c.LabRuntime.Deploy(ctx, req) + if err != nil { + return nil, err + } + + return c.containersFromLabState(state), nil +} + +func (c *CLab) labRuntimeDeployRequest() (clablabruntime.DeployRequest, error) { + if c.Config.Name == "" { + return clablabruntime.DeployRequest{}, fmt.Errorf("topology name is required") + } + if len(c.renderedTopology) == 0 { + return clablabruntime.DeployRequest{}, fmt.Errorf("rendered topology is empty") + } + + topologyFile := "" + topologyLabDir := "" + if c.TopoPaths != nil { + topologyFile = c.TopoPaths.TopologyFilenameAbsPath() + topologyLabDir = c.TopoPaths.TopologyLabDir() + } + + return clablabruntime.DeployRequest{ + Name: c.Config.Name, + Owner: c.labOwner(), + TopologyFile: topologyFile, + TopologyLabDir: topologyLabDir, + TopologyDefinition: c.renderedTopology, + Timeout: c.timeout, + }, nil +} + +// ValidateLabRuntimeTopology executes the selected runtime's compiler without mutation. +func (c *CLab) ValidateLabRuntimeTopology(ctx context.Context) error { + if c.LabRuntime == nil { + return fmt.Errorf("no lab runtime selected") + } + + validator, ok := c.LabRuntime.(clablabruntime.TopologyValidator) + if !ok { + return fmt.Errorf("lab runtime %q does not support topology validation", c.globalRuntimeName) + } + + req, err := c.labRuntimeDeployRequest() + if err != nil { + return err + } + + return validator.Validate(ctx, req) +} + +func (c *CLab) destroyWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { + if len(opts.nodeFilter) != 0 { + return fmt.Errorf("node-filter is not supported for lab runtime %q; no resources were deleted", + c.globalRuntimeName) + } + + if opts.all { + return c.destroyAllWithLabRuntime(ctx, opts) + } + + if c.Config.Name == "" { + return fmt.Errorf("topology name is required") + } + + return c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ + Name: c.Config.Name, + Wait: true, + Timeout: c.timeout, + }) +} + +func (c *CLab) destroyAllWithLabRuntime(ctx context.Context, opts *DestroyOptions) error { + states, err := c.LabRuntime.List(ctx, clablabruntime.ListRequest{AllNamespaces: true}) + if err != nil { + return err + } + + if len(states) == 0 { + log.Info("no clabernetes topologies found") + return nil + } + + topos := make(map[string]string, len(states)) + for _, state := range states { + topos[state.TopologyPath] = "" + } + + if opts.terminalPrompt && term.IsTerminal(int(os.Stdin.Fd())) { + if err := cliPromptToDestroyAll(topos); err != nil { + return err + } + } + + var errs []error + for _, state := range states { + if err := c.LabRuntime.Destroy(ctx, clablabruntime.DestroyRequest{ + Name: state.Name, + Namespace: state.Namespace, + Wait: true, + Timeout: c.timeout, + }); err != nil { + errs = append(errs, err) + } + } + + if len(errs) != 0 { + return fmt.Errorf("error(s) occurred during clabernetes topology deletion: %w", + errors.Join(errs...)) + } + + return nil +} + +func (c *CLab) unsupportedLabRuntimeOperation(operation string) error { + return fmt.Errorf("%s is not supported for lab runtime %q yet", + operation, c.globalRuntimeName) +} + +func (c *CLab) ListLabRuntimeContainers( + ctx context.Context, + all bool, + options ...ListOption, +) ([]clabruntime.GenericContainer, error) { + opts := NewListOptions() + for _, opt := range options { + opt(opts) + } + + if all || c.Config.Name == "" { + states, err := c.LabRuntime.List(ctx, clablabruntime.ListRequest{AllNamespaces: true}) + if err != nil { + return nil, err + } + + var containers []clabruntime.GenericContainer + for _, state := range states { + containers = append(containers, c.containersFromLabState(state)...) + } + + return filterLabRuntimeContainers(containers, opts.ToFilters()), nil + } + + if c.Config.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + state, err := c.LabRuntime.Inspect(ctx, clablabruntime.InspectRequest{Name: c.Config.Name}) + if err != nil { + return nil, err + } + + return filterLabRuntimeContainers(c.containersFromLabState(state), opts.ToFilters()), nil +} + +func (c *CLab) execWithLabRuntime( + ctx context.Context, + cmds []string, + listOptions ...ListOption, +) (*clabexec.ExecCollection, error) { + containers, err := c.ListLabRuntimeContainers(ctx, false, listOptions...) + if err != nil { + return nil, err + } + if len(containers) == 0 { + return nil, fmt.Errorf("filter did not match any containers") + } + + var execCmds []*clabexec.ExecCmd + for _, execCmdStr := range cmds { + execCmd, err := clabexec.NewExecCmdFromString(execCmdStr) + if err != nil { + return nil, err + } + execCmds = append(execCmds, execCmd) + } + + resultCollection := clabexec.NewExecCollection() + var execErrors []error + for idx := range containers { + namespace, labName, nodeName, err := labRuntimeContainerParts(containers[idx]) + if err != nil { + log.Warnf("exec target %s is invalid: %v", containers[idx].Names[0], err) + execErrors = append(execErrors, fmt.Errorf( + "exec target %s is invalid: %w", containers[idx].Names[0], err)) + continue + } + + for _, execCmd := range execCmds { + result, err := c.LabRuntime.Exec(ctx, clablabruntime.ExecRequest{ + Name: labName, + Namespace: namespace, + NodeName: nodeName, + Command: execCmd.GetCmd(), + }) + if err != nil { + log.Warnf("exec on %s failed: %v", containers[idx].Names[0], err) + execErrors = append(execErrors, fmt.Errorf( + "exec on %s failed: %w", containers[idx].Names[0], err)) + continue + } + + resultCollection.Add(containers[idx].Names[0], result) + if result.GetReturnCode() != 0 { + execErrors = append(execErrors, fmt.Errorf( + "exec on %s returned exit code %d", + containers[idx].Names[0], + result.GetReturnCode(), + )) + } + } + } + + return resultCollection, errors.Join(execErrors...) +} + +func (c *CLab) startNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Start(ctx, clablabruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) stopNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Stop(ctx, clablabruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) restartNodesWithLabRuntime(ctx context.Context, nodeNames []string) error { + return c.LabRuntime.Restart(ctx, clablabruntime.NodeRequest{ + Name: c.Config.Name, + Nodes: nodeNames, + Timeout: c.timeout, + }) +} + +func (c *CLab) saveWithLabRuntime(ctx context.Context, opts *SaveOptions) error { + if opts.copyDst != "" { + resolvedDst, err := c.resolveCopyOutDst(opts.copyDst) + if err != nil { + return err + } + opts.copyDst = resolvedDst + } + + result, err := c.LabRuntime.Save(ctx, clablabruntime.SaveRequest{ + Name: c.Config.Name, + Nodes: c.nodeFilter, + Copy: opts.copyDst != "", + }) + if err != nil { + return err + } + + if opts.copyDst == "" || result == nil { + return nil + } + + return c.copyLabRuntimeSavedFiles(result, opts.copyDst) +} + +func (c *CLab) containersFromLabState( + state *clablabruntime.LabState, +) []clabruntime.GenericContainer { + if state == nil { + return nil + } + + nodes := state.Nodes + if len(nodes) == 0 && c.Config.Topology != nil { + nodeNames := make([]string, 0, len(c.Config.Topology.Nodes)) + for nodeName := range c.Config.Topology.Nodes { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) + for _, nodeName := range nodeNames { + nodes = append(nodes, clablabruntime.NodeState{ + Name: nodeName, + Kind: c.Config.Topology.GetNodeKind(nodeName), + Image: c.Config.Topology.GetNodeImage(nodeName), + State: state.State, + Ready: state.Ready, + }) + } + } + + containers := make([]clabruntime.GenericContainer, 0, len(nodes)) + for _, node := range nodes { + containers = append(containers, c.containerFromLabNode(state, node)) + } + + return containers +} + +func (c *CLab) containerFromLabNode( + state *clablabruntime.LabState, + node clablabruntime.NodeState, +) clabruntime.GenericContainer { + nodeName := fmt.Sprintf("%s-%s", state.Name, node.Name) + containerState := node.State + containerStatus := node.State + if containerState == "" { + containerState = state.State + } + if node.Ready { + containerState = "running" + containerStatus = "healthy" + } + + labels := map[string]string{ + clabconstants.Containerlab: state.Name, + clabconstants.NodeName: node.Name, + clabconstants.LongName: nodeName, + clabconstants.TopoFile: labRuntimeTopologyPath(c, state), + } + + var image string + if c.Config.Topology != nil { + labels[clabconstants.NodeKind] = c.Config.Topology.GetNodeKind(node.Name) + image = c.Config.Topology.GetNodeImage(node.Name) + + if group := c.Config.Topology.GetNodeGroup(node.Name); group != "" { + labels[clabconstants.NodeGroup] = group + } + } + if node.Kind != "" { + labels[clabconstants.NodeKind] = node.Kind + } + if node.Image != "" { + image = node.Image + } + + if state.Owner != "" { + labels[clabconstants.Owner] = state.Owner + } else if c.customOwner != "" { + labels[clabconstants.Owner] = c.customOwner + } + + return clabruntime.GenericContainer{ + Names: []string{nodeName}, + ID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), + ShortID: "c9s", + Image: image, + State: containerState, + Status: containerStatus, + Labels: labels, + NetworkName: state.Namespace, + NetworkSettings: managementAddress(node.LoadBalancerAddress), + } +} + +func managementAddress(addr string) clabruntime.GenericMgmtIPs { + ip := net.ParseIP(addr) + if ip == nil { + return clabruntime.GenericMgmtIPs{} + } + + if ip.To4() != nil { + return clabruntime.GenericMgmtIPs{ + IPv4addr: addr, + IPv4pLen: 32, + } + } + + return clabruntime.GenericMgmtIPs{ + IPv6addr: addr, + IPv6pLen: 128, + } +} + +func labRuntimeTopologyPath(c *CLab, state *clablabruntime.LabState) string { + if c.TopoPaths.TopologyFileIsSet() { + return c.TopoPaths.TopologyFilenameAbsPath() + } + + if state.TopologyPath != "" { + return state.TopologyPath + } + + return fmt.Sprintf("k8s://%s/topologies/%s", state.Namespace, state.Name) +} + +func labRuntimeContainerParts(c clabruntime.GenericContainer) (string, string, string, error) { + parts := strings.Split(c.ID, "/") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("expected namespace/lab/node id, got %q", c.ID) + } + + return parts[0], parts[1], parts[2], nil +} + +func filterLabRuntimeContainers( + containers []clabruntime.GenericContainer, + filters []*clabtypes.GenericFilter, +) []clabruntime.GenericContainer { + if len(filters) == 0 { + return containers + } + + filtered := make([]clabruntime.GenericContainer, 0, len(containers)) + for _, container := range containers { + if labRuntimeContainerMatches(container, filters) { + filtered = append(filtered, container) + } + } + + return filtered +} + +func labRuntimeContainerMatches( + container clabruntime.GenericContainer, + filters []*clabtypes.GenericFilter, +) bool { + for _, filter := range filters { + switch filter.FilterType { + case "name": + if !labRuntimeNameMatches(container.Names, filter.Match) { + return false + } + case "id": + if container.ID != filter.Match && !strings.HasPrefix(container.ID, filter.Match) { + return false + } + case "label": + if !labRuntimeLabelMatches(container.Labels, filter) { + return false + } + } + } + + return true +} + +func labRuntimeNameMatches(names []string, match string) bool { + for _, name := range names { + if name == match || strings.Contains(name, match) { + return true + } + } + + return false +} + +func labRuntimeLabelMatches(labels map[string]string, filter *clabtypes.GenericFilter) bool { + value, ok := labels[filter.Field] + + switch filter.Operator { + case "exists": + return ok + case "!=": + return !ok || value != filter.Match + default: + return ok && value == filter.Match + } +} + +func (c *CLab) copyLabRuntimeSavedFiles( + result *clablabruntime.SaveResult, + dstRoot string, +) error { + for _, file := range result.Files { + if file.NodeName == "" || file.Name == "" { + continue + } + + relPath, ok := cleanLabRuntimeSavedPath(file.Name) + if !ok { + return fmt.Errorf("refusing to copy unsafe saved config path %q", file.Name) + } + + nodeDstDir := filepath.Join(dstRoot, file.NodeName) + dstPath := filepath.Join(nodeDstDir, relPath) + if err := os.MkdirAll( + filepath.Dir(dstPath), + clabconstants.PermissionsDirDefault, + ); err != nil { + return fmt.Errorf("failed to create save dst directory %q: %w", + filepath.Dir(dstPath), err) + } + + if file.LinkTarget != "" { + linkTarget, ok := cleanLabRuntimeSavedLinkTarget(file.LinkTarget) + if !ok { + return fmt.Errorf("refusing to create unsafe saved config symlink %q -> %q", + file.Name, file.LinkTarget) + } + + _ = os.Remove(dstPath) + if err := os.Symlink(linkTarget, dstPath); err != nil { + return fmt.Errorf("failed to create symlink %q -> %q: %w", + dstPath, linkTarget, err) + } + + continue + } + + mode := os.FileMode(file.Mode) + if mode == 0 { + mode = clabconstants.PermissionsFileDefault + } + + if err := os.WriteFile(dstPath, file.Data, mode); err != nil { + return fmt.Errorf("failed to write saved config %q: %w", dstPath, err) + } + + log.Info( + "copied saved config", + "node", file.NodeName, + "dst", dstPath, + ) + } + + return nil +} + +func cleanLabRuntimeSavedPath(name string) (string, bool) { + cleaned := filepath.Clean(name) + if cleaned == "." || filepath.IsAbs(cleaned) || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || + cleaned == ".." { + return "", false + } + + return cleaned, true +} + +func cleanLabRuntimeSavedLinkTarget(target string) (string, bool) { + cleaned := filepath.Clean(target) + if filepath.IsAbs(cleaned) || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || + cleaned == ".." { + return "", false + } + + return cleaned, true +} diff --git a/core/labruntime_test.go b/core/labruntime_test.go new file mode 100644 index 0000000000..cbb364e170 --- /dev/null +++ b/core/labruntime_test.go @@ -0,0 +1,74 @@ +package core + +import ( + "context" + "strings" + "testing" + + clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + clabtypes "github.com/srl-labs/containerlab/types" +) + +type failingExecLabRuntime struct { + clablabruntime.LabRuntime + returnCode int +} + +func (r *failingExecLabRuntime) Inspect( + context.Context, + clablabruntime.InspectRequest, +) (*clablabruntime.LabState, error) { + return &clablabruntime.LabState{ + Name: "lab1", Namespace: "lab-ns", Nodes: []clablabruntime.NodeState{{Name: "node1"}}, + }, nil +} + +func (r *failingExecLabRuntime) Exec( + _ context.Context, + req clablabruntime.ExecRequest, +) (*clabexec.ExecResult, error) { + return &clabexec.ExecResult{Cmd: req.Command, ReturnCode: r.returnCode, Stderr: "failed"}, nil +} + +func TestExecWithLabRuntimeReturnsNestedFailureAndPreservesResult(t *testing.T) { + t.Parallel() + + runtime := &failingExecLabRuntime{returnCode: 7} + c := &CLab{ + Config: &Config{Name: "lab1", Topology: clabtypes.NewTopology()}, + TopoPaths: &clabtypes.TopoPaths{}, + LabRuntime: runtime, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, + } + + results, err := c.execWithLabRuntime(context.Background(), []string{"false"}) + if err == nil || !strings.Contains(err.Error(), "exit code 7") { + t.Fatalf("execWithLabRuntime() error = %v, want exit code 7", err) + } + if results == nil { + t.Fatal("execWithLabRuntime() discarded the failed command result") + } + dumped, dumpErr := results.Dump("json") + if dumpErr != nil { + t.Fatal(dumpErr) + } + if !strings.Contains(dumped, `"return-code": 7`) { + t.Fatalf("failed result was not retained: %s", dumped) + } +} + +func TestDestroyWithLabRuntimeRejectsNodeFilterBeforeDeletion(t *testing.T) { + t.Parallel() + + c := &CLab{ + Config: &Config{Name: "lab1"}, + LabRuntime: &failingExecLabRuntime{}, + globalRuntimeName: clablabruntime.ClabernetesRuntimeName, + } + + err := c.destroyWithLabRuntime(context.Background(), &DestroyOptions{nodeFilter: []string{"node1"}}) + if err == nil || !strings.Contains(err.Error(), "no resources were deleted") { + t.Fatalf("destroyWithLabRuntime() error = %v, want safe node-filter rejection", err) + } +} diff --git a/core/options_clab.go b/core/options_clab.go index 74476c46df..8509f640a0 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" @@ -132,11 +133,29 @@ func WithDebug(debug bool) ClabOption { // WithRuntime option sets a container runtime to be used by containerlab. func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { return func(c *CLab) error { + name = resolveRuntimeName(name) + + if clablabruntime.IsLabRuntimeName(name) { + c.globalRuntimeName = name + + lr, err := clablabruntime.Init(name, clablabruntime.Config{ + Debug: rtconfig != nil && rtconfig.Debug, + Timeout: runtimeTimeout(rtconfig), + Namespace: labRuntimeNamespace(rtconfig), + }) + if err != nil { + return fmt.Errorf("failed to init the lab runtime: %w", err) + } + + c.LabRuntime = lr + + return nil + } + name, rInit, err := RuntimeInitializer(name) if err != nil { return err } - c.globalRuntimeName = name r := rInit() @@ -159,9 +178,37 @@ func WithRuntime(name string, rtconfig *clabruntime.RuntimeConfig) ClabOption { } } +func labRuntimeNamespace(config *clabruntime.RuntimeConfig) string { + if config == nil { + return "" + } + + return config.LabNamespace +} + +func runtimeTimeout(rtconfig *clabruntime.RuntimeConfig) time.Duration { + if rtconfig == nil { + return 0 + } + + return rtconfig.Timeout +} + func WithKeepMgmtNet() ClabOption { return func(c *CLab) error { - c.globalRuntime().WithKeepMgmtNet() + if c.LabRuntime != nil { + log.Debug("Ignoring keep management network option for lab runtime", + "runtime", c.globalRuntimeName) + + return nil + } + + r := c.globalRuntime() + if r == nil { + return fmt.Errorf("container runtime %q is not initialized", c.globalRuntimeName) + } + + r.WithKeepMgmtNet() return nil } diff --git a/core/restart.go b/core/restart.go index e752dbd1e3..273c4acf0f 100644 --- a/core/restart.go +++ b/core/restart.go @@ -6,6 +6,10 @@ import ( // RestartNodes performs stop+start for each node, restoring parked interfaces. func (c *CLab) RestartNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.restartNodesWithLabRuntime(ctx, nodeNames) + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/runtime_state.go b/core/runtime_state.go index 07be4dd0f8..75c36060ff 100644 --- a/core/runtime_state.go +++ b/core/runtime_state.go @@ -7,6 +7,7 @@ import ( "strings" clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" clabruntime "github.com/srl-labs/containerlab/runtime" ) @@ -97,6 +98,19 @@ func (c *CLab) runtimeNodeGroups( // NeedsInitialDeploy reports whether the lab has no runtime state yet, i.e. whether // Deploy would perform a fresh deployment instead of reconciling a running lab. func (c *CLab) NeedsInitialDeploy(ctx context.Context) (bool, error) { + if c.LabRuntime != nil { + checker, ok := c.LabRuntime.(clablabruntime.LabExistenceChecker) + if !ok { + return false, fmt.Errorf( + "lab runtime %q cannot determine whether the lab already exists", + c.globalRuntimeName, + ) + } + exists, err := checker.LabExists(ctx, clablabruntime.InspectRequest{Name: c.Config.Name}) + + return !exists, err + } + currentNodes, err := c.runtimeNodeGroups(ctx) if err != nil { return false, err diff --git a/core/save.go b/core/save.go index 1b2dfce585..4b7876114d 100644 --- a/core/save.go +++ b/core/save.go @@ -25,6 +25,10 @@ func (c *CLab) Save( opt(opts) } + if c.LabRuntime != nil { + return c.saveWithLabRuntime(ctx, opts) + } + err := clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) if err != nil { return err @@ -80,7 +84,11 @@ func (c *CLab) resolveCopyOutDst(dst string) (string, error) { labDir := c.TopoPaths.TopologyLabDir() labDirName := filepath.Base(labDir) if labDirName == "" || labDirName == "." { - return "", fmt.Errorf("failed to resolve save dst: lab directory is empty") + if c.Config != nil && c.Config.Name != "" { + labDirName = "clab-" + c.Config.Name + } else { + return "", fmt.Errorf("failed to resolve save dst: lab directory is empty") + } } dstLabDir := filepath.Join(resolvedDst, labDirName) diff --git a/core/start.go b/core/start.go index ff16ec21ed..89ac3779db 100644 --- a/core/start.go +++ b/core/start.go @@ -7,6 +7,10 @@ import ( // StartNodes starts one or more stopped nodes and restores their parked interfaces back into the // container network namespace. func (c *CLab) StartNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.startNodesWithLabRuntime(ctx, nodeNames) + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/core/stop.go b/core/stop.go index 0b51db8178..79b7e5c39a 100644 --- a/core/stop.go +++ b/core/stop.go @@ -7,6 +7,10 @@ import ( // StopNodes stops one or more deployed nodes without losing their dataplane links by parking // the node's interfaces in a dedicated network namespace before stopping the container. func (c *CLab) StopNodes(ctx context.Context, nodeNames []string) error { + if c.LabRuntime != nil { + return c.stopNodesWithLabRuntime(ctx, nodeNames) + } + if err := c.ResolveLinks(); err != nil { return err } diff --git a/docs/cmd/deploy.md b/docs/cmd/deploy.md index 922c8866ed..cddee13f63 100644 --- a/docs/cmd/deploy.md +++ b/docs/cmd/deploy.md @@ -247,16 +247,29 @@ With `--max-workers` flag, it is possible to limit the number of concurrent work #### runtime -Containerlab nodes can be started by different runtimes, with `docker` being the default one. Besides that, containerlab has experimental support for `podman` runtime. +Containerlab nodes can be started by different local container runtimes, with `docker` being the default one. Besides that, containerlab has experimental support for `podman` runtime. -A global runtime can be selected with a global `--runtime | -r` flag that will select a runtime to use. The possible value are: +A global runtime can be selected with the `--runtime | -r` flag. The possible values are: -* `docker` - default -* `podman` - experimental support +* `docker` - default local container runtime +* `podman` - experimental local container runtime +* `clabernetes` - Clabernetes lab runtime that deploys the whole topology to a kubernetes cluster + +/// note +`clabernetes` is a lab runtime, not a per-node container runtime. When it is selected, containerlab renders the topology and creates a Clabernetes `Topology` custom resource. See [Containerlab runtime](../manual/clabernetes/runtime.md) for details. + +By default, the Clabernetes runtime deploys each lab into a managed +`c9s-` namespace. Use the global `--namespace` option, which has no +short form, to target an existing namespace instead: + +```bash +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml +``` +/// #### timeout -A global `--timeout` flag drives the timeout of API requests that containerlab send toward external resources. Currently the only external resource is the container runtime (i.e. docker). +A global `--timeout` flag drives the timeout of API requests that containerlab sends toward external resources, such as the selected container runtime or the kubernetes API used by the Clabernetes lab runtime. In a busy compute the runtime may respond longer than anticipated, in that case increasing the timeout may help. @@ -297,6 +310,10 @@ Node filtering applies to fresh deployments (including `--reconfigure`) only. Wh Read more about [node filtering](../manual/node-filtering.md) in the documentation. +/// warning | Clabernetes runtime +`deploy --node-filter` is not supported with `--runtime clabernetes`. Clabernetes reconciles the complete topology stored in a `Topology` custom resource. After the topology exists, use node filtering with commands such as `start`, `stop`, `restart`, `exec`, or `save`. +/// + #### skip-post-deploy The `--skip-post-deploy` flag skips the post-deploy phase of the lab deployment, affecting all nodes. @@ -393,12 +410,16 @@ In this example: #### `CLAB_RUNTIME` -Default value of "runtime" key for nodes, same as global `--runtime | -r` flag described above. -Affects all containerlab commands in the same way, not just `deploy`. +Default value for the global `--runtime | -r` flag described above. It affects all containerlab commands in the same way, not just `deploy`. + +For `docker` or `podman`, it selects the default local container runtime. For `clabernetes`, it selects the whole-lab Clabernetes runtime. -Intended to be set in environments where non-default container runtime should be used, to avoid needing to specify it for every command invocation or in every configuration file. +Example command-line usage: -Example command-line usage: `CLAB_RUNTIME=podman containerlab deploy` +```bash +CLAB_RUNTIME=podman containerlab deploy +CLAB_RUNTIME=clabernetes containerlab deploy -t topo.clab.yml +``` #### `CLAB_VERSION_CHECK` diff --git a/docs/cmd/events.md b/docs/cmd/events.md index 85c2b91480..2051d2e14f 100644 --- a/docs/cmd/events.md +++ b/docs/cmd/events.md @@ -71,10 +71,17 @@ Statistics are disabled by default. Enabling them augments the feed with periodi Containerlab streams events from the runtime selected via the global `--runtime` flag. -> **Currently supported runtime:** `docker` -> Runtimes that do not implement the `events` API (or are not yet supported by Containerlab) will exit with an explanatory error. +Currently supported runtimes: + +- `docker` +- `clabernetes` + +With `--runtime clabernetes`, events are backed by kubernetes watches for Clabernetes `Topology` resources and labeled Pods. Interface statistics are sampled through the launcher pods, not through local host netlink. + +Runtimes that do not implement the `events` API, or are not yet supported by Containerlab, will exit with an explanatory error. ## See also - [`inspect interfaces`](inspect/interfaces.md) – produces a point-in-time view of the same interface details that `events` reports continuously. +- [Clabernetes containerlab runtime](../manual/clabernetes/runtime.md) - explains how c9s events differ from Docker events. - `docker events` – the raw runtime feed that Containerlab builds upon. diff --git a/docs/cmd/exec.md b/docs/cmd/exec.md index cb082bac4b..21f0533fb6 100644 --- a/docs/cmd/exec.md +++ b/docs/cmd/exec.md @@ -10,6 +10,10 @@ This command is similar to `docker exec`, but it allows a user to run the same c Like `docker exec`, `exec` runs inside the node's container namespace. For VM-based kinds (vrnetlab integration, e.g. `sonic-vm`), that container is the QEMU launcher wrapping the VM, not the guest VM itself, so guest network-OS commands are not reachable via `exec` and fail with `executable file not found in $PATH`. Use SSH to the node's management address (or its native CLI) to run guest-OS commands. See the [`exec` node property](../manual/nodes.md#exec) for details. /// +/// note | Clabernetes runtime +With `--runtime clabernetes`, containerlab reaches the node through kubernetes pod exec into the launcher pod and then runs the command in the nested node container. The kube identity must be allowed to create `pods/exec`. See [Containerlab runtime](../manual/clabernetes/runtime.md#exec) for details. +/// + --8<-- "docs/cmd/deploy.md:env-vars-flags" ## Usage diff --git a/docs/cmd/inspect/index.md b/docs/cmd/inspect/index.md index 6f79a8dfcd..cc6e76ddb2 100644 --- a/docs/cmd/inspect/index.md +++ b/docs/cmd/inspect/index.md @@ -9,6 +9,10 @@ tags: The `inspect` command provides the information about the deployed labs. +/// note | Clabernetes runtime +With `--runtime clabernetes`, `inspect` reads Clabernetes `Topology` resources and their related kubernetes objects instead of local Docker containers. `inspect --all` lists c9s topologies across all namespaces. See [Containerlab runtime](../../manual/clabernetes/runtime.md#inspect) for details. +/// + ### Usage `containerlab [global-flags] inspect [local-flags]` diff --git a/docs/cmd/save.md b/docs/cmd/save.md index ee49d34a42..e1aece8b65 100644 --- a/docs/cmd/save.md +++ b/docs/cmd/save.md @@ -4,6 +4,10 @@ The `save` command perform configuration save for all the containers running in a lab. +/// note | Clabernetes runtime +With `--runtime clabernetes`, `save` runs the inner `containerlab save` command inside each launcher pod. `save --copy` streams the resulting files back to the local destination using the same copy layout as Docker labs. See [Containerlab runtime](../manual/clabernetes/runtime.md#save) for details. +/// + The exact command that performs configuration save depends on a given kind. The below table explains the method used for each kind: | Kind | Command | Notes | diff --git a/docs/manual/clabernetes/configuration.md b/docs/manual/clabernetes/configuration.md index 5463008067..b27eabdcd5 100644 --- a/docs/manual/clabernetes/configuration.md +++ b/docs/manual/clabernetes/configuration.md @@ -1,13 +1,13 @@ # Topology Configuration -The Topology custom resource (CR) is the primary way to deploy containerlab topologies in Kubernetes. This page covers all available configuration options. +The Topology custom resource (CR) is the high-level compatibility API for deploying a complete containerlab definition. c9s compiles it into the primary Node, Link, and LauncherProfile resources. This page covers the Topology configuration options. ## Definition The `definition` field contains the containerlab topology in YAML format: ```yaml -apiVersion: clabernetes.containerlab.dev/v1alpha1 +apiVersion: c9s.run/v1alpha1 kind: Topology metadata: name: my-lab @@ -244,7 +244,7 @@ spec: ## Complete Example ```yaml -apiVersion: clabernetes.containerlab.dev/v1alpha1 +apiVersion: c9s.run/v1alpha1 kind: Topology metadata: name: production-lab diff --git a/docs/manual/clabernetes/index.md b/docs/manual/clabernetes/index.md index bdaac184b3..7f8e7d4bdc 100644 --- a/docs/manual/clabernetes/index.md +++ b/docs/manual/clabernetes/index.md @@ -8,7 +8,7 @@ tags: pronounciation: *Kla-ber-net-ees* -Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Enter [**clabernetes**](https://github.com/srl-labs/clabernetes/) or simply **c9s**. +Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Enter [**clabernetes**](https://github.com/clabernetes/clabernetes/) or simply **c9s**. ![Clabernetes](https://gitlab.com/rdodin/pics/-/wikis/uploads/9d8c5abcb8db2c80811635d928aa98df/c9s_logo1_border_2.webp){ align=left width="300" } @@ -19,6 +19,11 @@ Love containerlab? Want containerlab, just distributed in a kubernetes cluster? Clabernetes deploys containerlab topologies into a kubernetes cluster. The goal of Clabernetes is to scale Containerlab beyond a single node while keeping the user experience you love. +You can use clabernetes in two ways: + +- with the native [`containerlab --runtime clabernetes`](runtime.md) workflow +- with [`clabverter`](install.md#clabverter), which converts topology files into kubernetes manifests + If all goes to plan, Clabernetes is going to be one of the solutions to enable [multi-node labs](../multi-node.md) and allow its users to create large topologies powered by a k8s cluster. Eager to try it out? Check out the [Quickstart](quickstart.md)! Have questions, join our [Discord](https://discord.gg/2A8ZxM7hD9). @@ -32,5 +37,6 @@ In the beta release we focus on the core topology constructs working our way tow ## Quick Links * [Helm chart on ArtifactHub](https://artifacthub.io/packages/helm/clabernetes/clabernetes) -* [CRD reference](https://crds.r8y.page/repo/github.com/srl-labs/clabernetes) -* Source code on [GitHub](https://github.com/srl-labs/clabernetes) +* [CRD reference](https://c9s.run/docs/crd) +* [Native containerlab runtime](runtime.md) +* Source code on [GitHub](https://github.com/clabernetes/clabernetes) diff --git a/docs/manual/clabernetes/install.md b/docs/manual/clabernetes/install.md index 350131fa6c..7724d9e52b 100644 --- a/docs/manual/clabernetes/install.md +++ b/docs/manual/clabernetes/install.md @@ -1,11 +1,19 @@ # Installation -Clabernetes runs on a Kubernetes cluster and hence requires one to be available before you start your Clabernetes journey. Although we don't have a strict requirement on the k8s version, we recommend using the version 1.21 or higher. +Clabernetes runs on a Kubernetes cluster and hence requires one to be available before you start your Clabernetes journey. c9s 0.7 and newer require Kubernetes 1.31 or higher for the field selectors used by the Link API. -Clabernetes project consists of two components: +Clabernetes project consists of two main components: -- Clabernetes manager (a.k.a. controller) - a k8s controller that watches for the Clabernetes topology resources and deploys them to the cluster. -- Clabverter - a CLI tool that converts containerlab topology files into Clabernetes topology resources. +- Clabernetes manager (a.k.a. controller) - k8s controllers that reconcile c9s `Node`, `Link`, `LauncherProfile`, and compatibility `Topology` resources. +- Clabverter - a CLI tool that converts containerlab topology files into Clabernetes resources. + +/// note | Using the containerlab runtime +When you use [`containerlab --runtime clabernetes`](runtime.md), containerlab +compiles the topology in memory and creates the primary `Node`, `Link`, and +`LauncherProfile` custom resources directly. In that workflow you still need +the Clabernetes manager and CRDs installed in the cluster, but you don't need +to run `clabverter` for every deployment. +/// ## Clabernetes Manager @@ -20,7 +28,7 @@ To install the latest Clabernetes release with Helm to an existing k8s cluster[^ ```bash helm upgrade --install --create-namespace --namespace c9s \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` @@ -31,7 +39,7 @@ To install a specific clabernetes version add `--version` flag like so: ```bash helm upgrade --version 0.0.25 --install \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` /// @@ -45,7 +53,7 @@ helm upgrade --install --version 0.0.0 --create-namespace --namespace c9s \ --set manager.imagePullPolicy=Always \ --set globalConfig.deployment.launcherImagePullPolicy=Always \ --set globalConfig.deployment.launcherLogLevel=debug \ - clabernetes oci://ghcr.io/srl-labs/clabernetes/clabernetes + clabernetes oci://ghcr.io/clabernetes/clabernetes/clabernetes ``` We also set the log level to `debug` for all the components to see more verbose logs. Trust us, you might need it :smile: @@ -70,7 +78,7 @@ Clabverter is versioned in the same way as Clabernetes, and the easiest way to u ```bash title="set up clabverter alias" alias clabverter='sudo docker run --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter' + ghcr.io/clabernetes/clabernetes/clabverter' ``` /// @@ -80,7 +88,7 @@ In case you need to install a specific version: ```bash alias clabverter='sudo docker run --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter:0.0.22' + ghcr.io/clabernetes/clabernetes/clabverter:0.7.0' ``` /// @@ -90,7 +98,7 @@ To use the latest development version of clabverter: ```bash alias clabverter='sudo docker run --pull always --user $(id -u) \ -v $(pwd):/clabernetes/work --rm \ - ghcr.io/srl-labs/clabernetes/clabverter:dev-latest' + ghcr.io/clabernetes/clabernetes/clabverter:dev-latest' ``` /// diff --git a/docs/manual/clabernetes/quickstart.md b/docs/manual/clabernetes/quickstart.md index 257074f9cb..f8ae607e20 100644 --- a/docs/manual/clabernetes/quickstart.md +++ b/docs/manual/clabernetes/quickstart.md @@ -21,7 +21,7 @@ Let's see how it all works, buckle up! ## Creating a cluster -Clabernetes goal is to allow users to run networking labs with containerlab's simplicity and ease of use, but with the scaling powers of kubernetes. Surely, it is best to have a real deal available to you, but for demo purposes we'll use [`kind`](https://kind.sigs.k8s.io/) v0.22.0 to create a local multi-node kubernetes cluster. If you already have a k8s cluster, feel free to use it instead -- clabernetes can run in any kubernetes cluster[^1]! +Clabernetes goal is to allow users to run networking labs with containerlab's simplicity and ease of use, but with the scaling powers of kubernetes. Surely, it is best to have a real deal available to you, but for demo purposes we'll use [`kind`](https://kind.sigs.k8s.io/) v0.32.0 to create a local multi-node kubernetes cluster. c9s 0.7 requires Kubernetes 1.31 or newer. If you already have a compatible k8s cluster, feel free to use it instead[^1]! With the following command we instruct kind to set up a three node k8s cluster with two worker and one control plane nodes. @@ -47,9 +47,9 @@ Check that the cluster is ready and proceed with installing clabernetes. ```bash ❯ kubectl get nodes NAME STATUS ROLES AGE VERSION -c9s-control-plane Ready control-plane 5m6s v1.29.2 -c9s-worker Ready 4m46s v1.29.2 -c9s-worker2 Ready 4m42s v1.29.2 +c9s-control-plane Ready control-plane 5m6s v1.35.0 +c9s-worker Ready 4m46s v1.35.0 +c9s-worker2 Ready 4m42s v1.35.0 ``` ## Installing clabernetes @@ -66,7 +66,7 @@ alias helm='docker run --network host -ti --rm -v $(pwd):/apps -w /apps \ -v ~/.kube:/root/.kube -v ~/.helm:/root/.helm \ -v ~/.config/helm:/root/.config/helm \ -v ~/.cache/helm:/root/.cache/helm \ - alpine/helm:3.12.3' +alpine/helm:3.18.2' ``` /// @@ -79,7 +79,7 @@ alias helm='docker run --network host -ti --rm -v $(pwd):/apps -w /apps \ -v ~/.kube:/root/.kube -v ~/.helm:/root/.helm \ -v ~/.config/helm:/root/.config/helm \ -v ~/.cache/helm:/root/.cache/helm \ - alpine/helm:3.12.3' + alpine/helm:3.18.2' ``` /// @@ -162,6 +162,14 @@ To make sure you have a smooth sailing in the clabernetes waters we've created a Clabverter is not a requirement to run clabernetes, but it is a helper tool to convert containerlab topologies to clabernetes resources and kubernetes objects. +/// note | Native containerlab runtime +Recent containerlab versions can also deploy to clabernetes directly with +`containerlab --runtime clabernetes`. This quickstart keeps using `clabverter` +because it shows the generated kubernetes manifests explicitly. If you want the +regular containerlab CLI lifecycle, see the [Containerlab runtime](runtime.md) +page. +/// + As per clabverter's [installation instructions](install.md#clabverter) we will setup an alias that uses the latest available clabverter container image: --8<-- "docs/manual/clabernetes/install.md:cv-install" @@ -223,7 +231,7 @@ As you can see, we have two namespaces: `c9s` and `c9s-vlan`. The `c9s` namespac ### Topology resource -The *main* clabernetes resource is called `Topology` and we should be able to find it in the `c9s-vlan` namespace where all lab resources are deployed: +The `Topology` compatibility resource holds the original containerlab definition. c9s compiles it into the primary `Node`, `Link`, and `LauncherProfile` resources in the `c9s-vlan` namespace: ``` {.bash .no-select} kubectl get --namespace c9s-vlan Topology @@ -236,7 +244,7 @@ vlan containerlab 14h ``` -Looking in the Topology CR we can see that the original containerlab topology definition can be found under the `spec.definition.containerlab` field of the custom resource. Clabernetes took the original topology and split it to sub-topologies that are outlined in the `status.configs` section of the resource: +Looking in the Topology CR we can see that the original containerlab topology definition can be found under the `spec.definition.containerlab` field. Topology status now contains bounded node/link counts and conditions; per-node readiness and exposed-port allocations live on the generated `Node` resources: ``` {.bash .no-select} kubectl get --namespace c9s-vlan Topology vlan -o yaml diff --git a/docs/manual/clabernetes/runtime.md b/docs/manual/clabernetes/runtime.md new file mode 100644 index 0000000000..7cdcc10bd6 --- /dev/null +++ b/docs/manual/clabernetes/runtime.md @@ -0,0 +1,757 @@ +# Containerlab runtime + +Containerlab can use Clabernetes as a lab runtime. With the c9s runtime selected, +containerlab keeps the familiar CLI workflow, but the actual lab runs in a +kubernetes cluster. + +```bash +containerlab --runtime clabernetes deploy -t topo.clab.yml +``` + +or, if you prefer environment variables: + +```bash +export CLAB_RUNTIME=clabernetes +containerlab deploy -t topo.clab.yml +``` + +/// note | Runtime, not converter +This page describes the native `containerlab --runtime clabernetes` workflow. +The [Quickstart](quickstart.md) still shows the manifest-driven `clabverter` +workflow, which remains useful when you want to generate and apply kubernetes +manifests yourself. +/// + +## How it works + +When the c9s runtime is selected, containerlab does not create local Docker or +Podman containers for the lab nodes. It compiles the rendered topology in +memory, using the same compiler as Clabernetes, and creates the primary c9s +resources directly: + +```yaml +apiVersion: c9s.run/v1alpha1 +kind: Node +metadata: + name: + namespace: c9s- + labels: + c9s.run/topologyOwner: +spec: + kind: + image: +--- +apiVersion: c9s.run/v1alpha1 +kind: Link +metadata: + name: + namespace: c9s- +spec: + endpointA: {nodeName: , interfaceName: } + endpointB: {nodeName: , interfaceName: } +``` + +`LauncherProfile` resources carry reusable launcher policy. No `Topology` +object or complete topology definition is persisted, so every c9s object stays +bounded as the lab grows. Each launcher pod runs containerlab inside the pod +and starts the real node container there. Nodes using `network-mode: +container:` share the primary node's launcher pod. + +For backward compatibility, all-namespace inspection and destruction still +discover older labs that have a `Topology` compatibility resource. New runtime +deployments are Node/Link-first. + +/// note +The node containers are nested inside the launcher pods. A `docker ps` on the +machine where you ran the outer `containerlab` command is not the source of +truth for c9s labs. +/// + +The c9s runtime currently supports the main lab lifecycle and node operations: + +| Command | c9s behavior | +| ------- | ------------ | +| `deploy` | creates `LauncherProfile`, `Node`, and `Link` resources and waits for readiness | +| `destroy` | deletes the lab's resources and its containerlab-managed namespace | +| `inspect` | reads Node, Deployment, Pod, and service status | +| `exec` | execs through the launcher pod into the nested node container | +| `start` | scales node Deployments to `1` | +| `stop` | scales node Deployments to `0` and pauses reconciliation | +| `restart` | restarts node Deployments | +| `save` | runs `containerlab save` inside launcher pods | +| `events` | watches Clabernetes resources and pods | +| `validate` | checks c9s compatibility without creating Kubernetes resources | +| `deploy --dry-run` | compiles and diffs Namespace, ConfigMap, LauncherProfile, Link, and Node resources without changing them | + +## Requirements + +The c9s runtime expects: + +- a reachable kubernetes cluster +- kubernetes 1.31 or newer +- Clabernetes CRDs installed in the cluster +- the Clabernetes manager running and watching all lab namespaces +- kubernetes RBAC allowing containerlab to manage the required resources + +/// note +Containerlab creates a dedicated namespace for each c9s lab. The kube identity +therefore needs cluster-scoped permission to get, create, and delete namespaces +unless you select an existing namespace with `CLAB_KUBE_NAMESPACE`. +/// + +## Selecting the cluster + +The runtime uses the kubernetes client-go configuration loader. It selects the +kubeconfig in this order: + +1. `CLAB_KUBECONFIG`, when set +2. normal client-go kubeconfig loading rules + +You can override the kube context with: + +```bash +export CLAB_KUBE_CONTEXT= +``` + +To deploy into an existing namespace instead of a per-lab namespace, use the +global `--namespace` option: + +```bash +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml +``` + +For a persistent shell or automation setting, use: + +```bash +export CLAB_KUBE_NAMESPACE=default +``` + +An explicit namespace override must already exist. Containerlab places the lab +resources there but does not create, label, or delete the namespace itself. +`--namespace` takes precedence over `CLAB_KUBE_NAMESPACE`. + +/// tip +`CLAB_RUNTIME=clabernetes` is worth exporting in shell profiles, CI jobs, or +automation environments that always target c9s. +/// + +## Namespace rules + +When `CLAB_KUBE_NAMESPACE` is unset, every lab deployed through the c9s runtime +gets a dedicated namespace named `c9s-`. For example, this command: + +```bash +containerlab --runtime clabernetes deploy -t clos.clab.yml +``` + +creates the `c9s-clos` namespace and places the lab's `LauncherProfile`, `Node`, +`Link`, ConfigMap, Deployment, Pod, Service, and PVC resources there. Inspect, +exec, start, stop, restart, save, and destroy derive the same namespace from the +lab name. + +Containerlab labels namespaces it creates with the runtime and lab owner. A +normal destroy removes such a managed namespace after its lab resources are +gone. If `c9s-` existed before deploy and does not carry those +ownership labels, containerlab uses it but preserves it during destroy. + +Set `--namespace` when a shared or externally managed namespace is required: + +```bash +containerlab --runtime clabernetes --namespace default deploy -t clos.clab.yml +``` + +All single-lab lifecycle commands must use the same flag or environment +override. Because the namespace is then shared, node and other resource names +can conflict between labs. + +/// warning | Lab names +`c9s-` must be a valid Kubernetes DNS label and cannot exceed 63 +characters. This leaves at most 59 characters for the lab name. +/// + +Some commands intentionally look across namespaces: + +- `inspect --all` +- `destroy --all` +- `events` + +For these commands, containerlab uses all-namespaces kubernetes listing or +watching. The synthetic c9s container ID includes the namespace so follow-up +actions can still target the right lab: + +```text +// +``` + +For example: + +```text +c9s-clos/clos/srl1 +``` + +Primitive-only labs created outside containerlab are also manageable when +their Nodes, Links, and LauncherProfiles carry the common +`c9s.run/topologyOwner=` label. Containerlab uses that label as the +lab boundary for list, inspect, lifecycle, events, and destroy operations. + +## Deploy + +Deploying with the c9s runtime looks like a regular containerlab deployment: + +```bash +containerlab --runtime clabernetes deploy -t topo.clab.yml +``` + +The deploy flow is: + +1. containerlab parses and checks the topology file. +2. It stages local files into per-node ConfigMaps. +3. It compiles the final topology in memory into self-contained Nodes, Links, + and LauncherProfiles. +4. It creates profiles and Links first, then Nodes after the complete wiring + policy exists. +5. It waits until all Nodes report ready. +6. It inspects the resulting kubernetes state and prints the node table. + +The runtime enables c9s startup and readiness probes on the generated +`LauncherProfile`. c9s checks that each nested Docker container exists, is +running, and is not paused, restarting, or dead. When an image defines a Docker +healthcheck, that healthcheck must also be healthy. Containerlab does not guess +readiness ports or special-case kinds and images, so the same baseline works for +arbitrary containerlab nodes. + +Readiness is atomic for a `network-mode: container:` group. The one +launcher Pod is ready only while every nested group member satisfies the generic +readiness contract. Because every Node in the group inherits that Deployment's +readiness, a restarting secondary makes both the primary and secondary Nodes +not ready. + +For an image without a Docker healthcheck, this is a process-level signal: a +running network OS may still be booting services or converging protocols. Use an +image-defined healthcheck or an explicit c9s TCP/SSH probe when the lab requires +application-level readiness. + +The c9s runtime uses a ten-minute timeout by default because large NOS images +can take several minutes to load and boot. Override it when a lab needs a +different startup window, for example: + +```bash +containerlab --runtime clabernetes --timeout 10m deploy -t topo.clab.yml +``` + +`deploy --reconfigure` first deletes all resources in the existing lab and +then deploys them again. + +/// warning | Node filtering +`deploy --node-filter` is not supported with the c9s runtime. Clabernetes owns +reconciliation of the complete set of Node and Link resources. Deploy the full +topology, then use node filtering with commands such as `start`, `stop`, +`restart`, `exec`, or `save` after the lab exists. +/// + +Deploy reconciles an existing lab in place. Containerlab compiles the requested topology and +creates, updates, or removes the corresponding c9s `Node`, `Link`, `LauncherProfile`, and staged +`ConfigMap` resources. New Nodes remain staged until the complete Link set is present. Labs +created through the older compatibility `Topology` API retain that controller ownership and +have their `Topology` definition updated in place. + +Use `deploy --reconfigure` when you explicitly want to delete and recreate every resource. Use a +different lab name or namespace when you want a separate lab: + +```bash +containerlab --runtime clabernetes --name deploy -t topo.clab.yml +``` + +A failure while waiting for a newly created lab to become ready rolls back the +resources and managed namespace created by that deployment. A timeout while +reconciling a lab that already existed retains the lab for diagnosis and a +later corrective reconciliation. + +### Validation and dry-run + +Both commands use the same c9s preparation path as deploy, including extended-link +normalization, compatibility warnings, and local-file staging checks: + +```bash +containerlab --runtime clabernetes validate -t topo.clab.yml +containerlab --runtime clabernetes deploy --dry-run -t topo.clab.yml +containerlab --runtime clabernetes deploy --dry-run --format json -t topo.clab.yml +``` + +`validate` reports whether the topology can be represented by the c9s runtime without reading +or changing lab resources. Fields whose semantics cannot be preserved exactly are reported as +warnings and normalized or omitted. Structurally impossible constructs remain errors. +`deploy --dry-run` additionally reads the selected cluster and reports the exact create, update, +and delete plan for Namespace, ConfigMap, LauncherProfile, Link, Node, or an older compatibility +Topology. An empty `changes` list means the deployed resources already conform. + +## Inspect + +Inspect works with a topology file, a lab name, or all known c9s labs: + +```bash +containerlab --runtime clabernetes inspect -t topo.clab.yml +containerlab --runtime clabernetes inspect --name clos +containerlab --runtime clabernetes inspect --all +``` + +For c9s labs, inspect reads kubernetes resources instead of local container +runtime state. It collects the lab name, namespace, aggregate state, node +readiness, node kind and image, and load-balancer management address when +Clabernetes exposes one. + +/// note +`inspect --all` groups c9s Nodes by `c9s.run/topologyOwner` across all +namespaces. A single-lab inspect uses its canonical `c9s-` namespace. +/// + +Useful kubernetes checks for the same state are: + +```bash +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run,deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= +``` + +## Exec + +`exec` runs the user command in the nested node container: + +```bash +containerlab --runtime clabernetes exec -t topo.clab.yml --cmd 'ip addr' +``` + +Under the hood, containerlab: + +1. resolves the target nodes from the Clabernetes lab state +2. finds the launcher pod for each node +3. uses kubernetes pod exec into the launcher pod +4. runs `docker exec ` inside that launcher pod + +/// note +The command executes in the node container, not in the launcher pod shell. RBAC +must allow `pods/exec`, and the launcher pod must be ready. +/// + +If any selected nested command returns nonzero, or pod exec itself fails, the +outer `containerlab exec` also returns nonzero. Successful and failed results +that were received are still printed, so automation can use both the output and +the process exit status. + +## Start, stop, and restart + +Node lifecycle commands operate on the kubernetes Deployments created by +Clabernetes. + +```bash +containerlab --runtime clabernetes stop -t topo.clab.yml +containerlab --runtime clabernetes start -t topo.clab.yml +containerlab --runtime clabernetes restart -t topo.clab.yml +``` + +`stop` sets the Clabernetes ignore-reconcile label on the selected launcher +`Node` resources, then scales their Deployments to `0`: + +```text +c9s.run/ignoreReconcile=true +``` + +The label prevents the Clabernetes manager from immediately reconciling the +nodes back to the running state. + +`start` scales the selected Deployments back to `1` and clears the corresponding +Node labels. For an older compatibility lab, it also clears the Topology label +when all launchers are running again. A grouped secondary shares lifecycle +with its primary launcher node. + +`restart` patches each selected Deployment with a restart annotation and waits +for it to become ready: + +```text +kubectl.kubernetes.io/restartedAt= +``` + +## Save + +Saving a c9s lab uses the containerlab process running inside each launcher pod: + +```bash +containerlab --runtime clabernetes save -t topo.clab.yml +``` + +For each selected node, the outer containerlab process finds the launcher pod +and runs: + +```bash +containerlab save -t /clabernetes/topo.clab.yaml +``` + +inside that pod. + +`save --copy` streams the saved files back to the machine where the outer +containerlab command runs: + +```bash +containerlab --runtime clabernetes save -t topo.clab.yml --copy ./startup-configs +``` + +The copied files follow the normal containerlab copy layout: + +```text +/// +``` + +For example: + +```text +./startup-configs/clab-clos/srl1/config-260605_085424.json +./startup-configs/clab-clos/srl1/config.json -> config-260605_085424.json +``` + +/// note +`save` still depends on node kind support. If a node kind does not produce saved +files, the c9s runtime has nothing to copy for that node. +/// + +## Events + +The c9s runtime can stream Node, compatibility Topology, pod, and +interface-stat events: + +```bash +containerlab --runtime clabernetes events --format json +containerlab --runtime clabernetes events --initial-state +containerlab --runtime clabernetes events --interface-stats --format json +``` + +For c9s, events do not come from Docker events on the outer host. Containerlab +watches: + +- c9s `Node` resources carrying `c9s.run/topologyOwner` +- compatibility `Topology` resources for older labs +- Pods labeled with `c9s.run/topologyOwner` + +With `--initial-state`, the stream starts with synthetic events for the current +c9s node state and then continues with live watches. + +With `--interface-stats`, containerlab periodically execs through the launcher +pod and reads `/proc/net/dev` from the nested node container: + +```bash +docker exec cat /proc/net/dev +``` + +/// note | Polling, not netlink +c9s interface statistics are sampled periodically. The first sample seeds the +counters, and rates start with the second sample. Short-lived changes between +samples can be missed. +/// + +## Lab artifacts + +With c9s, the primary artifacts are kubernetes resources and files inside the +launcher pods. + +The primary kubernetes resources are: + +```bash +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run \ + -l c9s.run/topologyOwner= +``` + +Related resources are selected with Clabernetes labels: + +```bash +kubectl -n get deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= +``` + +To find one node launcher pod: + +```bash +kubectl -n get pod \ + -l c9s.run/topologyOwner=,c9s.run/topologyNode= +``` + +Inside each launcher pod, Clabernetes uses: + +```text +/clabernetes +``` + +The topology used by the inner containerlab process lives at: + +```text +/clabernetes/topo.clab.yaml +``` + +Per-node containerlab artifacts commonly live under: + +```text +/clabernetes/clab-clabernetes-// +``` + +Local startup configurations, licenses, bind sources, `env-files`, +`extras.srl-agents`, and `extras.ceos-copy-to-flash` paths are copied into +per-node ConfigMaps and projected at the paths the inner containerlab process +expects. Each staged file is currently limited to 950 KB. These projections +are snapshots taken at deploy time, not mutable host bind mounts; run deploy +again after changing a source file. + +/// tip +When debugging from inside a launcher pod, the usual containerlab and Docker +commands are useful again: + +```bash +containerlab inspect +docker ps +docker exec ip addr +ls -la /clabernetes +``` +/// + +## RBAC requirements + +The kube identity used by the outer containerlab process must be able to: + +- get, create, and delete namespaces when using automatic per-lab namespaces +- create, get, list, watch, update, and delete c9s `Node` resources +- create, get, list, watch, and delete c9s `Link` and `LauncherProfile` resources +- get, list, watch, and delete compatibility `Topology` resources when older + Topology-based labs must remain manageable +- list and watch Pods +- list, get, and update Deployments +- create, get, list, update, and delete ConfigMaps used for local files +- exec into launcher Pods with `pods/exec` + +Useful checks: + +```bash +kubectl auth can-i get namespaces +kubectl auth can-i create namespaces +kubectl auth can-i delete namespaces +kubectl auth can-i create nodes.c9s.run -n +kubectl auth can-i list nodes.c9s.run -n +kubectl auth can-i update nodes.c9s.run -n +kubectl auth can-i delete nodes.c9s.run -n +kubectl auth can-i create links.c9s.run -n +kubectl auth can-i delete links.c9s.run -n +kubectl auth can-i create launcherprofiles.c9s.run -n +kubectl auth can-i delete launcherprofiles.c9s.run -n +kubectl auth can-i list pods -n +kubectl auth can-i watch pods -A +kubectl auth can-i create pods/exec -n +kubectl auth can-i update deployments -n +``` + +## Troubleshooting + +### No kubeconfig or wrong context + +Typical symptoms: + +```text +failed to init the lab runtime: failed to load Kubernetes client config: ... +``` + +Check: + +```bash +kubectl config current-context +kubectl cluster-info +echo "$CLAB_KUBECONFIG" +echo "$CLAB_KUBE_CONTEXT" +``` + +Fix the kubeconfig, context, or cluster access, then run the containerlab command +again. + +### Namespace creation fails + +Typical symptoms: + +```text +failed to create c9s namespace "c9s-": ... +``` + +Check: + +```bash +kubectl auth can-i get namespaces +kubectl auth can-i create namespaces +kubectl get namespace c9s- +``` + +Grant the selected kube identity permission to manage namespaces. You may also +pre-create the canonical namespace; containerlab will use it and will not +delete it unless it carries containerlab's runtime and lab-owner labels: + +```bash +kubectl create namespace c9s- +``` + +With `--namespace` or `CLAB_KUBE_NAMESPACE` set, containerlab requires that +namespace to exist and never creates or deletes it: + +```bash +kubectl get namespace default +containerlab --runtime clabernetes --namespace default deploy -t topo.clab.yml +``` + +### CRDs are missing + +The c9s runtime talks to: + +```text +nodes.c9s.run +links.c9s.run +launcherprofiles.c9s.run +``` + +Typical symptoms: + +```text +the server could not find the requested resource +``` + +Check: + +```bash +kubectl api-resources | grep -i clabernetes +kubectl get crd nodes.c9s.run links.c9s.run launcherprofiles.c9s.run +``` + +Install Clabernetes and its CRDs before using `--runtime clabernetes`. + +### Manager is not reconciling + +The primitive resources may exist, but no node Deployments or Pods appear. + +Check: + +```bash +kubectl get pods -A | grep -i clabernetes +kubectl -n get node.c9s.run,link.c9s.run,launcherprofile.c9s.run \ + -l c9s.run/topologyOwner= +kubectl -n get deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= +``` + +If deploy waits until timeout, check the Clabernetes manager logs and verify +that it watches the namespace where the primitive resources were created. + +### Nodes do not become ready + +Deploy waits for every Node to report `status.readiness=ready`. + +Check: + +```bash +kubectl -n get node.c9s.run,link.c9s.run \ + -l c9s.run/topologyOwner= -o yaml +kubectl -n describe node.c9s.run +kubectl -n get deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= +``` + +Common causes include bad topology data, image pull failures, missing pull +secrets, unsupported node settings, pod security policy, or a launcher pod that +cannot run nested Docker. + +### Inspect shows no containers + +For c9s, `inspect` looks for c9s Nodes grouped by +`c9s.run/topologyOwner` (and compatibility Topologies), not local Docker +containers. + +Check: + +```bash +containerlab --runtime clabernetes inspect --all +kubectl get nodes.c9s.run -A -l c9s.run/topologyOwner +``` + +If `docker ps` on the outer host is empty, that can be perfectly normal for c9s. +The node containers live inside launcher pods. + +### Exec, save, or stats cannot reach a node + +`exec`, `save`, `save --copy`, and `events --interface-stats` need pod exec into +the launcher pod. + +Check: + +```bash +kubectl -n get pod \ + -l c9s.run/topologyOwner=,c9s.run/topologyNode= \ + -o wide +kubectl auth can-i create pods/exec -n +kubectl -n exec -it -- sh +``` + +From inside the launcher pod: + +```bash +docker ps +docker exec true +ls -la /clabernetes/topo.clab.yaml +``` + +## Current limitations + +The c9s runtime is not a complete drop-in replacement for the local Docker or +Podman runtime. Several containerlab features still assume local containers, +local network namespaces, or direct access to the host container runtime. + +The runtime divides compatibility into three categories: + +- Native-equivalent: normal point-to-point links and MTU, lifecycle operations, + node configuration, staged startup files, exec, inspect, and group-atomic + readiness. +- Documented c9s semantics: Kubernetes Service/LoadBalancer management access, + `host:` endpoints in the launcher Pod network namespace, c9s internal + cross-Pod link transport, and ConfigMap-backed local files. +- Accepted with warnings: topology fields that c9s cannot preserve exactly, including + shared-management-network settings, pinned host-side ports, node groups, link labels or vars, + and other unsupported vocabulary. These fields are normalized or omitted before resources are + created. +- Rejected: external bridge/host pseudo-nodes, macvlan and `mgmt-net:` links, + explicit native VXLAN/stitch/dummy link types, invalid launcher grouping, and commands or flags + with no c9s implementation. + +Management access is not a shared management network. Each launcher has its +own nested Docker management network, so static `mgmt-ipv4`/`mgmt-ipv6` +addresses are launcher-local and are not cluster-routable between Pods. +Kubernetes Services, LoadBalancers, and DNS are the supported management access path. Explicit +topology `mgmt` settings produce a warning and apply only inside each launcher. The `--network`, +`--ipv4-subnet`, and `--ipv6-subnet` flags remain rejected because they imply native +Docker-network semantics. + +Known command differences: + +- `deploy --node-filter` and `destroy --node-filter` are rejected; a filtered + destroy never falls through to whole-lab deletion. +- Deploy flags `--graph`, `--max-workers`, `--skip-post-deploy`, + `--skip-labdir-acl`, `--export-template`, `--restore`, and `--restore-all` + are rejected. +- Destroy flags `--graceful`, `--cleanup`, `--keep-mgmt-net`, and + `--max-workers` are rejected. +- Local Docker commands on the outer host are not authoritative for c9s labs. +- Local network namespace features are not equivalent in c9s. +- `inspect interfaces` is rejected. Host-side `tc` or netem operations do not + have the same local namespace access they have with Docker labs. +- `graph` and `tools` commands operate on local containers and host networking + and are rejected with an error when the `clabernetes` runtime is selected. +- Per-node `runtime: docker` or `runtime: podman` is not the same as selecting + the global `clabernetes` lab runtime. +- A lab name maps to one canonical `c9s-` namespace in the selected + cluster. + +/// note +Use kubernetes and launcher-pod state as the source of truth for c9s labs: + +```bash +kubectl get nodes.c9s.run -A -l c9s.run/topologyOwner +kubectl -n get deploy,pod,svc,cm,pvc \ + -l c9s.run/topologyOwner= +``` +/// diff --git a/docs/manual/dev/test.md b/docs/manual/dev/test.md index bfb53f8fe4..85b8f3be3e 100644 --- a/docs/manual/dev/test.md +++ b/docs/manual/dev/test.md @@ -41,7 +41,12 @@ CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh ``` /// note -The test runner script requires you to specify the runtime as its first argument. The runtime can be either `docker` or `podman`. Containerlab primarily uses Docker as the default runtime, hence the number of tests written for docker outnumber the podman tests. +The test runner script requires you to specify the runtime as its first +argument. The runtime can be `docker`, `podman`, or `clabernetes`. Containerlab +primarily uses Docker as the default runtime, hence the number of tests written +for Docker outnumber the Podman and Clabernetes tests. When `clabernetes` is +selected, the runner automatically includes only Robot tests tagged +`clabernetes`. /// #### Selecting the test suite @@ -64,6 +69,26 @@ CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh docker tests/01-smoke/01-basi Selecting a specific test case in a test suite is not supported, since test suites are written in a way that test cases depend on previous ones. /// +#### Running Clabernetes tests + +The c9s/Clabernetes Robot tests require the same Kubernetes prerequisites as +the `containerlab --runtime clabernetes` command: a reachable cluster, +Clabernetes CRDs and manager installed, and RBAC (including namespace +management when automatic per-lab namespaces are used) for the selected +kubeconfig. + +To run all currently supported Clabernetes Robot tests: + +```bash +CLAB_BIN=$(pwd)/bin/containerlab ./tests/rf-run.sh clabernetes tests +``` + +or with the existing Makefile target: + +```bash +make robot-test runtime=clabernetes +``` + #### Inspecting the test results RobotFramework generates a detailed report in HTML and XML formats that can be found in the `tests/out` directory. The exact paths to the reports are printed to the console after the test run. diff --git a/docs/manual/nodes.md b/docs/manual/nodes.md index 2f5df36680..e9ea364a14 100644 --- a/docs/manual/nodes.md +++ b/docs/manual/nodes.md @@ -605,16 +605,22 @@ If you want to completely disable the networking stack on a container, you can u ### runtime -By default containerlab nodes will be started by `docker` container runtime. Besides that, containerlab has experimental support for `podman` runtime. +By default containerlab nodes will be started by the `docker` container runtime. Besides that, containerlab has experimental support for the `podman` runtime. -It is possible to specify a global runtime with a global `--runtime` flag, or set the runtime on a per-node basis: +It is possible to specify a global local container runtime with the global `--runtime` flag, or set the runtime on a per-node basis: -Options for the runtime parameter are: +Options for the per-node `runtime` parameter are: - `docker` - `podman` -The default runtime can also be influenced via the `CLAB_RUNTIME` environment variable, which takes the same values as mentioned above. +The default runtime can also be influenced via the `CLAB_RUNTIME` environment variable. + +/// note | Clabernetes lab runtime +The global `--runtime` flag and `CLAB_RUNTIME` environment variable also accept `clabernetes`. This is a whole-lab runtime that sends the topology to kubernetes as a Clabernetes `Topology` resource. It is not a valid per-node `runtime:` value. + +See [Containerlab runtime](clabernetes/runtime.md) for details. +/// ```yaml # example node definition with per-node runtime definition diff --git a/go.mod b/go.mod index d2986a0161..4489e877df 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/log v0.4.2 github.com/charmbracelet/x/term v0.2.2 + github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81 github.com/containernetworking/plugins v1.9.0 github.com/containers/podman/v5 v5.8.2 github.com/digitalocean/go-openvswitch v0.0.0-20250625173537-a00eb8d2cfce @@ -50,10 +51,12 @@ require ( go.podman.io/common v0.67.1 go.podman.io/image/v5 v5.39.2 go.uber.org/mock v0.6.0 - golang.org/x/crypto v0.47.0 - golang.org/x/sys v0.42.0 - golang.org/x/term v0.39.0 + golang.org/x/crypto v0.55.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.35.4 + k8s.io/client-go v0.35.4 sigs.k8s.io/kind v0.31.0 ) @@ -62,8 +65,10 @@ require ( charm.land/lipgloss/v2 v2.0.1 // indirect dario.cat/mergo v1.0.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298 // indirect + github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect @@ -81,14 +86,33 @@ require ( github.com/containerd/platforms v1.0.0-rc.1 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag v0.27.1 // indirect + github.com/go-openapi/swag/cmdutils v0.27.1 // indirect + github.com/go-openapi/swag/conv v0.27.1 // indirect + github.com/go-openapi/swag/fileutils v0.27.1 // indirect + github.com/go-openapi/swag/jsonutils v0.27.1 // indirect + github.com/go-openapi/swag/loading v0.27.1 // indirect + github.com/go-openapi/swag/mangling v0.27.1 // indirect + github.com/go-openapi/swag/netutils v0.27.1 // indirect + github.com/go-openapi/swag/pools v0.27.1 // indirect + github.com/go-openapi/swag/stringutils v0.27.1 // indirect + github.com/go-openapi/swag/typeutils v0.27.1 // indirect + github.com/go-openapi/swag/yamlutils v0.27.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-containerregistry v0.20.6 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect @@ -100,6 +124,7 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/mistifyio/go-zfs/v3 v3.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect @@ -111,6 +136,8 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/cgroups v0.0.5 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -118,6 +145,10 @@ require ( github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect @@ -135,16 +166,28 @@ require ( github.com/tinylib/msgp v1.6.1 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/vbauerster/mpb/v8 v8.10.2 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect go.podman.io/storage v1.62.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.35.4 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/controller-runtime v0.23.3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.4.1 // indirect tags.cncf.io/container-device-interface v1.0.1 // indirect ) @@ -217,19 +260,19 @@ require ( github.com/vbatts/tar-split v0.12.1 // indirect github.com/vishvananda/netns v0.0.5 github.com/xanzy/ssh-agent v0.3.3 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/mod v0.37.0 - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 - golang.org/x/text v0.33.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/mod v0.38.0 + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/grpc v1.72.2 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apimachinery v0.34.3 + k8s.io/apimachinery v0.35.4 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 70c2ac6bea..63c68b85f4 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,9 @@ github.com/bramvdbogaerde/go-scp v1.2.1 h1:BKTqrqXiQYovrDlfuVFaEGz0r4Ou6EED8L7jC github.com/bramvdbogaerde/go-scp v1.2.1/go.mod h1:s4ZldBoRAOgUg8IrRP2Urmq5qqd2yPXQTPshACY8vQ0= github.com/brunoga/deep v1.3.1 h1:bSrL6FhAZa6JlVv4vsi7Hg8SLwroDb1kgDERRVipBCo= github.com/brunoga/deep v1.3.1/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= -github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298 h1:Y8rTum6LZ8oP/2aC+OaaP76OCjHbunKMkim81mzNCH0= github.com/carlmontanari/difflibgo v0.0.0-20210718194309-31b9e131c298/go.mod h1:+3MuSIeC3qmdSesR12cTLeb47R/Vvo+bHdB6hC5HShk= +github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae h1:h4sxL/AXg3FRPf+sT2Y4daEQQE/UAkNAM3U0t4Cgha8= +github.com/carlmontanari/difflibgo v0.0.0-20240227210139-93685b1c22ae/go.mod h1:+3MuSIeC3qmdSesR12cTLeb47R/Vvo+bHdB6hC5HShk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -89,6 +90,8 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u github.com/cilium/ebpf v0.8.1/go.mod h1:f5zLIM0FSNuAkSyLAN7X+Hy6yznlF1mNiWUMfxMtrgk= github.com/cilium/ebpf v0.17.3 h1:FnP4r16PWYSE4ux6zN+//jMcW4nMVRvuTLVTvCjyyjg= github.com/cilium/ebpf v0.17.3/go.mod h1:G5EDHij8yiLzaqn0WjyfJHvRa+3aDlReIaLVRMvOyJk= +github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81 h1:MAPKcVwQcmJvxg/LG8D2GY5L1uHpY4RGbZDniuu/pv4= +github.com/clabernetes/clabernetes v0.7.2-0.20260812182737-3dcb223b4a81/go.mod h1:uJgJfgl+nHOS0ELOWNRd0gtPymplP+KOFKPSY9L77h4= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= @@ -158,12 +161,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= -github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/florianl/go-tc v0.4.7 h1:Ysai5TIx4PgOzqI/1cse/pquOFCEkWofKtc/EPumfrg= @@ -175,6 +182,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -196,6 +205,42 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg= +github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE= +github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE= +github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU= +github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs= +github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM= +github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU= +github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1 h1:mJu3COL9WEaZVp/Kf2PRMi7tPszPEJfSr/OO75ynCs8= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.1/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY= +github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE= +github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA= +github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU= +github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E= +github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8= +github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc= +github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA= +github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -210,6 +255,10 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -227,6 +276,8 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2 github.com/google/go-intervals v0.0.2 h1:FGrVEiUnTRKR8yE04qzXYaJMtnIYqobR5QbblK3ixcM= github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= @@ -240,6 +291,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -257,7 +310,6 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= @@ -305,6 +357,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2TSgRbAhD7yjZzTQmcN25sDRPEeinR51yQ= github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -352,6 +406,8 @@ github.com/mistifyio/go-zfs/v3 v3.1.0 h1:FZaylcg0hjUp27i23VcJJQiuBeAZjrC8lPqCGM1 github.com/mistifyio/go-zfs/v3 v3.1.0/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -390,6 +446,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= @@ -418,7 +476,6 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= @@ -430,14 +487,14 @@ github.com/pmorjan/kmod v1.1.1 h1:Vfw6bMaOg/sYSBCqJPT9TbqHHf5zK00GbaL5JQLO4r0= github.com/pmorjan/kmod v1.1.1/go.mod h1:jR4fVosEpQ6b5U0rpxaqoShTDPvCjLIP8vEESZyvnqQ= github.com/proglottis/gpgme v0.1.5 h1:KCGyOw8sQ+SI96j6G8D8YkOGn+1TwbQTT9/zQXoVlz0= github.com/proglottis/gpgme v0.1.5/go.mod h1:5LoXMgpE4bttgwwdv9bLs/vwqv3qV7F4glEEZ7mRKrM= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -494,6 +551,8 @@ github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h github.com/steiler/acls v0.1.5 h1:BjnpIqK3TIov+fq6fK80SXBrd/oDMSEgOpVLllAB78A= github.com/steiler/acls v0.1.5/go.mod h1:lFfnRSiSCWLgKiuxu7PFgaUPVanCn7o6m4dHe/cz128= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -521,6 +580,8 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= @@ -532,24 +593,24 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.podman.io/common v0.67.1 h1:HddYLJfkfFUmFJ0V3PVoewguFM9eHkqk0g+fOc2B9R4= @@ -558,8 +619,14 @@ go.podman.io/image/v5 v5.39.2 h1:EJua/pRtvgLV/a5y8/RvA+ekKukZh0UuKMvLdTmEWFk= go.podman.io/image/v5 v5.39.2/go.mod h1:SlaR6Pra1ATIx4BcuZ16oafb3QcCHISaKcJbtlN/G/0= go.podman.io/storage v1.62.0 h1:0QjX1XlzVmbiaulb+aR/CG6p9+pzaqwIeZPe3tEjHbY= go.podman.io/storage v1.62.0/go.mod h1:A3UBK0XypjNZ6pghRhuxg62+2NIm5lcUGv/7XyMhMUI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -574,8 +641,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -583,8 +650,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -607,8 +674,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -617,8 +686,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -662,8 +731,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -673,8 +742,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -685,8 +754,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -695,25 +764,31 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= @@ -726,12 +801,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= +k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= +k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU= +k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c= +k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= +k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= +k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1 h1:AkER7js0XVWi/F/V2Iwl5N7O/B9VP2JyrOMmHPdco+g= +sigs.k8s.io/structured-merge-diff/v6 v6.4.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc= diff --git a/labruntime/all/all.go b/labruntime/all/all.go new file mode 100644 index 0000000000..456705145f --- /dev/null +++ b/labruntime/all/all.go @@ -0,0 +1,7 @@ +// Copyright 2020 Nokia +// Licensed under the BSD 3-Clause License. +// SPDX-License-Identifier: BSD-3-Clause + +package all + +import _ "github.com/srl-labs/containerlab/labruntime/clabernetes" diff --git a/labruntime/clabernetes/clabernetes.go b/labruntime/clabernetes/clabernetes.go new file mode 100644 index 0000000000..9be3b78127 --- /dev/null +++ b/labruntime/clabernetes/clabernetes.go @@ -0,0 +1,105 @@ +package clabernetes + +import ( + "fmt" + "time" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + defaultNamespace = "default" + pollInterval = 2 * time.Second + + envKubeconfig = "CLAB_KUBECONFIG" + envContext = "CLAB_KUBE_CONTEXT" + envNamespace = "CLAB_KUBE_NAMESPACE" + + c9sAPIVersion = "c9s.run/v1alpha1" + labelRuntime = "containerlab.dev/runtime" + labelApp = "c9s.run/app" + labelTopologyOwner = "c9s.run/topologyOwner" + labelTopologyNode = "c9s.run/topologyNode" + labelIgnoreReconcile = "c9s.run/ignoreReconcile" + clabernetesAppValue = "clabernetes" + restartedAtAnnotation = "kubectl.kubernetes.io/restartedAt" +) + +var topologyGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "topologies", +} + +var nodeGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "nodes", +} + +var linkGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "links", +} + +var launcherProfileGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "launcherprofiles", +} + +var imageRequestGVR = schema.GroupVersionResource{ + Group: "c9s.run", + Version: "v1alpha1", + Resource: "imagerequests", +} + +type Runtime struct { + client dynamic.Interface + kubeClient kubernetes.Interface + restConfig *rest.Config + namespace string + // labNamespaceOverride is set by --namespace or CLAB_KUBE_NAMESPACE. When + // empty, lab-scoped operations derive their namespace from the lab name. + labNamespaceOverride string + timeout time.Duration +} + +func init() { + clablabruntime.Register(clablabruntime.ClabernetesRuntimeName, New) +} + +func New(cfg clablabruntime.Config) (clablabruntime.LabRuntime, error) { + kubeConfig, namespace, err := kubeClientConfig() + if err != nil { + return nil, err + } + + client, err := dynamic.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes dynamic client: %w", err) + } + + kubeClient, err := kubernetes.NewForConfig(kubeConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + if namespace == "" { + namespace = defaultNamespace + } + + return &Runtime{ + client: client, + kubeClient: kubeClient, + restConfig: kubeConfig, + namespace: namespace, + labNamespaceOverride: configuredLabNamespace(cfg.Namespace), + timeout: cfg.Timeout, + }, nil +} diff --git a/labruntime/clabernetes/clabernetes_test.go b/labruntime/clabernetes/clabernetes_test.go new file mode 100644 index 0000000000..cc6c50a2e6 --- /dev/null +++ b/labruntime/clabernetes/clabernetes_test.go @@ -0,0 +1,2107 @@ +package clabernetes + +import ( + "archive/tar" + "bytes" + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/charmbracelet/log" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + dynamicfake "k8s.io/client-go/dynamic/fake" + kubefake "k8s.io/client-go/kubernetes/fake" +) + +func TestParseProcNetDev(t *testing.T) { + t.Parallel() + + data := []byte(`Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + eth0: 1234 12 0 0 0 0 0 0 5678 56 0 0 0 0 0 0 +`) + + stats, err := parseProcNetDev(data) + if err != nil { + t.Fatal(err) + } + if len(stats) != 1 { + t.Fatalf("len(stats) = %d, want 1", len(stats)) + } + + got := stats[0] + if got.Name != "eth0" || + got.RxBytes != 1234 || + got.RxPackets != 12 || + got.TxBytes != 5678 || + got.TxPackets != 56 { + t.Fatalf("unexpected stats: %+v", got) + } +} + +func TestParseProcNetDevRejectsMalformedLine(t *testing.T) { + t.Parallel() + + _, err := parseProcNetDev([]byte("eth0: 1 2 3\n")) + if err == nil { + t.Fatal("expected malformed /proc/net/dev line to return an error") + } +} + +func TestPreferredNestedContainerName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + nodeName string + exactNames []string + componentNames []string + want string + wantError string + }{ + { + name: "regular-node", + nodeName: "router", + exactNames: []string{"router"}, + want: "router", + }, + { + name: "components-prefer-cpm-a", + nodeName: "srsim", + componentNames: []string{"srsim-2", "srsim-b", "srsim-1", "srsim-a"}, + want: "srsim-a", + }, + { + name: "components-fall-back-to-cpm-b", + nodeName: "srsim", + componentNames: []string{"srsim-1", "srsim-b"}, + want: "srsim-b", + }, + { + name: "missing-node", + nodeName: "missing", + wantError: "was not found", + }, + { + name: "missing-cpm", + nodeName: "srsim", + componentNames: []string{"srsim-1", "srsim-2"}, + wantError: "CPM component container", + }, + { + name: "ambiguous-exact-node", + nodeName: "router", + exactNames: []string{"router-duplicate", "router"}, + wantError: "multiple nested containers", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := preferredNestedContainerName( + tt.nodeName, + tt.exactNames, + tt.componentNames, + ) + if tt.wantError != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf( + "preferredNestedContainerName() error = %v, want %q", + err, + tt.wantError, + ) + } + + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("preferredNestedContainerName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCleanTarPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + ok bool + }{ + { + name: "relative path", + in: "./configs/startup.json", + want: "configs/startup.json", + ok: true, + }, + {name: "current directory", in: ".", want: ".", ok: true}, + {name: "parent path", in: "../secret", ok: false}, + {name: "absolute path", in: "/etc/passwd", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, ok := cleanTarPath(tt.in) + if ok != tt.ok || got != tt.want { + t.Fatalf("cleanTarPath(%q) = %q, %v; want %q, %v", + tt.in, got, ok, tt.want, tt.ok) + } + }) + } +} + +func TestNamespaceForLab(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labName string + explicit string + configuredOverride string + want string + wantError bool + }{ + { + name: "derived from lab name", + labName: "clos", + want: "c9s-clos", + }, + { + name: "configured namespace override", + labName: "clos", + configuredOverride: "default", + want: "default", + }, + { + name: "explicit namespace for discovered lab", + labName: "clos", + explicit: "legacy-namespace", + configuredOverride: "default", + want: "legacy-namespace", + }, + { + name: "invalid derived namespace", + labName: strings.Repeat("a", 60), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := &Runtime{labNamespaceOverride: tt.configuredOverride} + got, err := r.namespaceForLab(tt.labName, tt.explicit) + if tt.wantError { + if err == nil { + t.Fatalf("namespaceForLab(%q, %q) returned no error", tt.labName, tt.explicit) + } + + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("namespaceForLab(%q, %q) = %q, want %q", + tt.labName, tt.explicit, got, tt.want) + } + }) + } +} + +func TestConfiguredLabNamespacePrecedence(t *testing.T) { + t.Setenv(envNamespace, "from-environment") + + if got := configuredLabNamespace("from-flag"); got != "from-flag" { + t.Fatalf("configured namespace = %q, want flag value", got) + } + if got := configuredLabNamespace(""); got != "from-environment" { + t.Fatalf("configured namespace = %q, want environment value", got) + } + + t.Setenv(envNamespace, "") + if got := configuredLabNamespace(""); got != "" { + t.Fatalf("configured namespace = %q, want automatic namespace selection", got) + } +} + +func TestDeployUsesNamespaceOverrideWithoutManagingIt(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + r.labNamespaceOverride = defaultNamespace + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Namespace != defaultNamespace { + t.Fatalf("deployed namespace = %q, want %q", state.Namespace, defaultNamespace) + } + _ = getTestPrimitive(t, r, nodeGVR, defaultNamespace, "node1") + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + if _, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + defaultNamespace, + metav1.GetOptions{}, + ); err != nil { + t.Fatalf("namespace override was deleted: %v", err) + } +} + +func TestDeployCreatesAndDestroyRemovesDedicatedLabNamespace(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Namespace != "c9s-lab1" { + t.Fatalf("deployed namespace = %q, want c9s-lab1", state.Namespace) + } + + namespace, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ) + if err != nil { + t.Fatal(err) + } + if namespace.Labels[labelRuntime] != clabernetesAppValue || + namespace.Labels[labelTopologyOwner] != "lab1" { + t.Fatalf("unexpected namespace labels: %v", namespace.Labels) + } + _ = getTestPrimitive(t, r, nodeGVR, "c9s-lab1", "node1") + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + _, err = r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected managed lab namespace to be deleted, got %v", err) + } +} + +func TestDestroyPreservesPreexistingLabNamespace(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "c9s-lab1", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + }} + r := newTestRuntime(node) + _, err := r.kubeClient.CoreV1().Namespaces().Create( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "c9s-lab1"}}, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "lab1", + }); err != nil { + t.Fatal(err) + } + if _, err := r.kubeClient.CoreV1().Namespaces().Get( + context.Background(), + "c9s-lab1", + metav1.GetOptions{}, + ); err != nil { + t.Fatalf("pre-existing namespace was deleted: %v", err) + } +} + +func TestSavedFilesFromTarSkipsUnsafeEntries(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + writeTarEntry(t, tw, &tar.Header{ + Name: "config.txt", + Mode: 0o644, + Size: int64(len("startup")), + }, []byte("startup")) + writeTarEntry(t, tw, &tar.Header{ + Name: "latest", + Typeflag: tar.TypeSymlink, + Mode: 0o777, + Linkname: "config.txt", + }, nil) + writeTarEntry(t, tw, &tar.Header{ + Name: "../secret", + Mode: 0o644, + Size: int64(len("secret")), + }, []byte("secret")) + + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + files, err := savedFilesFromTar("node1", buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Fatalf("len(files) = %d, want 2: %+v", len(files), files) + } + if files[0].NodeName != "node1" || files[0].Name != "config.txt" || + string(files[0].Data) != "startup" { + t.Fatalf("unexpected regular file: %+v", files[0]) + } + if files[1].Name != "latest" || files[1].LinkTarget != "config.txt" { + t.Fatalf("unexpected symlink: %+v", files[1]) + } +} + +func TestStateFromTopology(t *testing.T) { + t.Parallel() + + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "topologyReady": true, + "topologyState": "ready", + "nodeReadiness": map[string]any{ + "client": "ready", + "server": "notready", + }, + "exposedPorts": map[string]any{ + "client": map[string]any{ + "loadBalancerAddress": "192.0.2.10", + }, + "server": map[string]any{ + "loadBalancerAddress": "not-an-ip", + }, + }, + }, + "spec": map[string]any{ + "definition": map[string]any{ + "containerlab": `topology: + nodes: + client: + kind: linux + image: client:latest + server: + kind: srl + image: server:latest +`, + }, + }, + }, + } + obj.SetName("lab1") + obj.SetNamespace("lab-ns") + obj.SetLabels(map[string]string{clabconstants.Owner: "alice"}) + + state := stateFromTopology(obj, "fallback-ns") + if state.Name != "lab1" || + state.Namespace != "lab-ns" || + state.Owner != "alice" || + state.TopologyPath != "k8s://lab-ns/topologies/lab1" || + !state.Ready || + state.State != "ready" { + t.Fatalf("unexpected state metadata: %+v", state) + } + if len(state.Nodes) != 2 { + t.Fatalf("len(state.Nodes) = %d, want 2: %+v", len(state.Nodes), state.Nodes) + } + + client := state.Nodes[0] + if client.Name != "client" || + client.Kind != "linux" || + client.Image != "client:latest" || + client.State != "ready" || + !client.Ready || + client.LoadBalancerAddress != "192.0.2.10" { + t.Fatalf("unexpected client state: %+v", client) + } + + server := state.Nodes[1] + if server.Name != "server" || + server.Kind != "srl" || + server.Image != "server:latest" || + server.Ready || + server.LoadBalancerAddress != "" { + t.Fatalf("unexpected server state: %+v", server) + } +} + +func TestPrimitiveResourcesUseCurrentC9sAPI(t *testing.T) { + t.Parallel() + + for name, gvr := range map[string]schema.GroupVersionResource{ + "topology": topologyGVR, + "node": nodeGVR, + "link": linkGVR, + "launcher profile": launcherProfileGVR, + } { + if gvr.Group != "c9s.run" || gvr.Version != "v1alpha1" { + t.Fatalf("unexpected %s GVR: %+v", name, gvr) + } + } +} + +func TestPrimitiveResourceGroupOrder(t *testing.T) { + t.Parallel() + + set := &primitiveResourceSet{} + groups := set.groups() + want := []string{"LauncherProfile", "Link", "Node"} + got := make([]string, 0, len(groups)) + for _, group := range groups { + got = append(got, group.kind) + } + + if !slices.Equal(got, want) { + t.Fatalf("primitive creation order = %v, want %v", got, want) + } +} + +func TestStageAndEnablePrimitiveNodeDeployments(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + "keep": "value", + }, + }, + }} + set := &primitiveResourceSet{nodes: []*unstructured.Unstructured{node}} + stagePrimitiveNodeDeployments(set) + + if node.GetLabels()[clabernetesconstants.LabelDisableDeployments] != "true" { + t.Fatalf("staged node labels = %v", node.GetLabels()) + } + + r := newTestRuntime() + created, err := r.client.Resource(nodeGVR).Namespace("lab-ns").Create( + context.Background(), + node, + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + if err := r.enablePrimitiveNodeDeployments( + context.Background(), + "lab-ns", + map[string]*unstructured.Unstructured{"node1": created}, + ); err != nil { + t.Fatal(err) + } + + actual := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if _, exists := actual.GetLabels()[clabernetesconstants.LabelDisableDeployments]; exists { + t.Fatalf("enabled node retains staging label: %v", actual.GetLabels()) + } + if actual.GetLabels()["keep"] != "value" { + t.Fatalf("enable patch did not preserve labels: %v", actual.GetLabels()) + } +} + +func TestPrimitiveLinkPendingReason(t *testing.T) { + t.Parallel() + + link := &unstructured.Unstructured{Object: map[string]any{ + "status": map[string]any{ + "resolvedEndpoints": map[string]any{ + "endpointA": map[string]any{"nodeName": "node1", "uid": "uid-1"}, + "endpointB": map[string]any{"nodeName": "host"}, + }, + }, + }} + if reason := primitiveLinkPendingReason(link); reason != "" { + t.Fatalf("resolved link reported pending: %q", reason) + } + + if err := unstructured.SetNestedField( + link.Object, + "endpoint node missing", + "status", + "error", + ); err != nil { + t.Fatal(err) + } + if reason := primitiveLinkPendingReason(link); reason != "endpoint node missing" { + t.Fatalf("pending reason = %q, want endpoint status error", reason) + } +} + +func TestEnrichStateUsesNodeResources(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "client", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + "spec": map[string]any{ + "kind": "linux", + "image": "client:latest", + }, + "status": map[string]any{ + "readiness": "ready", + "exposedPorts": map[string]any{ + "loadBalancerAddress": "192.0.2.10", + }, + }, + }} + + r := newTestRuntime(node) + state := &clablabruntime.LabState{Name: "lab1", Namespace: "lab-ns"} + if err := r.enrichState(context.Background(), state); err != nil { + t.Fatal(err) + } + + if len(state.Nodes) != 1 { + t.Fatalf("len(state.Nodes) = %d, want 1: %+v", len(state.Nodes), state.Nodes) + } + got := state.Nodes[0] + if got.Name != "client" || got.Kind != "linux" || got.Image != "client:latest" || + !got.Ready || got.State != "ready" || got.LoadBalancerAddress != "192.0.2.10" { + t.Fatalf("unexpected Node-derived state: %+v", got) + } + if !state.Ready || state.State != "running" { + t.Fatalf("unexpected aggregate state: %+v", state) + } +} + +func TestWaitReadyTimeoutReportsPendingNodes(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "slow-node", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "lab1", + }, + }, + "spec": map[string]any{ + "kind": "arbitrary-kind", + "image": "example.invalid/arbitrary-kind:latest", + }, + "status": map[string]any{"readiness": "notready"}, + }} + + r := newTestRuntime(node) + err := r.waitReady(context.Background(), "lab1", "lab-ns", 20*time.Millisecond) + if err == nil { + t.Fatal("expected readiness timeout") + } + if !strings.Contains(err.Error(), "timed out after 20ms") || + !strings.Contains(err.Error(), "pending nodes: slow-node (notready)") || + strings.Contains(err.Error(), "rate limiter") { + t.Fatalf("unexpected readiness timeout: %v", err) + } +} + +func TestNodeReadinessProgressReportsTransitions(t *testing.T) { + var output bytes.Buffer + oldLevel := log.GetLevel() + log.SetLevel(log.InfoLevel) + log.SetOutput(&output) + defer func() { + log.SetLevel(oldLevel) + log.SetOutput(os.Stderr) + }() + + progress := nodeReadinessProgress{} + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "notready"}, + {Name: "node2", State: "ready", Ready: true}, + }}) + + got := output.String() + if strings.Contains(got, "node=node1") { + t.Fatalf("initial non-ready node should not produce a log line:\n%s", got) + } + if !strings.Contains(got, "Clabernetes node is ready") || + !strings.Contains(got, "node=node2") || + !strings.Contains(got, "ready=1") || + !strings.Contains(got, "total=2") { + t.Fatalf("initial ready node progress was not reported:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "ready", Ready: true}, + }}) + got = output.String() + if strings.Count(got, "Clabernetes node is ready") != 1 || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "ready=2") { + t.Fatalf("newly ready node progress was not reported exactly once:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "notready"}, + }}) + got = output.String() + if !strings.Contains(got, "Clabernetes node is not ready") || + !strings.Contains(got, "node=node2") || + !strings.Contains(got, "state=notready") || + !strings.Contains(got, "ready=1") { + t.Fatalf("readiness regression was not reported:\n%s", got) + } + + output.Reset() + progress.report(&clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", State: "ready", Ready: true}, + {Name: "node2", State: "notready"}, + }}) + if output.Len() != 0 { + t.Fatalf("unchanged readiness should not produce repeated log lines:\n%s", output.String()) + } +} + +func TestImagePullRequestsFilterAndProgress(t *testing.T) { + request := func(name, node, image, kubernetesNode string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "ImageRequest", + "metadata": map[string]any{ + "name": name, + "namespace": "lab-ns", + }, + "spec": map[string]any{ + "topologyNodeName": node, + "requestedImage": image, + "kubernetesNode": kubernetesNode, + }, + }} + } + + r := newTestRuntime( + request("node1-image", "node1", "example/node1:latest", "worker-a"), + request("node1-other-image", "node1", "example/other:latest", "worker-a"), + request("foreign-image", "foreign", "example/foreign:latest", "worker-b"), + ) + requests, err := r.imagePullRequests( + context.Background(), + "lab-ns", + &clablabruntime.LabState{Nodes: []clablabruntime.NodeState{ + {Name: "node1", Image: "example/node1:latest"}, + }}, + ) + if err != nil { + t.Fatal(err) + } + if len(requests) != 1 || requests[0].name != "node1-image" { + t.Fatalf("image pull requests = %+v, want only node1-image", requests) + } + + var output bytes.Buffer + oldLevel := log.GetLevel() + log.SetLevel(log.InfoLevel) + log.SetOutput(&output) + defer func() { + log.SetLevel(oldLevel) + log.SetOutput(os.Stderr) + }() + + progress := imagePullProgress{} + progress.report(requests) + got := output.String() + if !strings.Contains(got, "Pulling clabernetes node image") || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "image=example/node1:latest") || + !strings.Contains(got, "kubernetes-node=worker-a") { + t.Fatalf("image pull start was not reported:\n%s", got) + } + + output.Reset() + progress.report(requests) + if output.Len() != 0 { + t.Fatalf("unchanged image pull should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.report(nil) + got = output.String() + if !strings.Contains(got, "Clabernetes node image pull completed") || + !strings.Contains(got, "node=node1") || + !strings.Contains(got, "image=example/node1:latest") { + t.Fatalf("image pull completion was not reported:\n%s", got) + } + + output.Reset() + progress.report(nil) + if output.Len() != 0 { + t.Fatalf("completed image pull should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod1", + node: "node1", + image: "example/node1:latest", + kubernetesNode: "worker-a", + content: "image \"example/node1:latest\" is present, begin copy to docker daemon...\n" + + "Loaded image: example/node1:latest\n", + }) + got = output.String() + if !strings.Contains(got, "already present on Kubernetes node") || + strings.Contains(got, "copied to launcher Docker daemon") || + strings.Count(got, "node=node1") != 1 { + t.Fatalf("cached image copy lifecycle was not reported:\n%s", got) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod1", + node: "node1", + image: "example/node1:latest", + kubernetesNode: "worker-a", + content: "Loaded image: example/node1:latest\n", + }) + if output.Len() != 0 { + t.Fatalf("completed image copy should not produce repeated log lines:\n%s", output.String()) + } + + output.Reset() + progress.reportLauncherLog(launcherImageLog{ + podName: "pod2", + node: "node2", + image: "example/node2:latest", + kubernetesNode: "worker-b", + content: "image \"example/node2:latest\" is now available on node, continuing...\n" + + "Loaded image: example/node2:latest\n", + }) + got = output.String() + if !strings.Contains(got, "Copying clabernetes node image") || + strings.Contains(got, "already present") || + strings.Contains(got, "copied to launcher Docker daemon") { + t.Fatalf("pulled image copy lifecycle was not reported:\n%s", got) + } +} + +func TestManagePrimitiveOnlyLab(t *testing.T) { + t.Parallel() + + node := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + clabconstants.Owner: "alice", + }, + }, + "spec": map[string]any{"kind": "linux", "image": "alpine:3"}, + "status": map[string]any{ + "readiness": "ready", + }, + }} + link := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Link", + "metadata": map[string]any{ + "name": "node1-eth1-host-eth1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + }, + }, + }} + profile := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "LauncherProfile", + "metadata": map[string]any{ + "name": "primitive-lab", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "primitive-lab", + }, + }, + }} + + r := newTestRuntime(node, link, profile) + state, err := r.Inspect(context.Background(), clablabruntime.InspectRequest{ + Name: "primitive-lab", + Namespace: "lab-ns", + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "primitive-lab" || state.Owner != "alice" || !state.Ready || + len(state.Nodes) != 1 || state.Nodes[0].Name != "node1" { + t.Fatalf("unexpected primitive-only inspect state: %+v", state) + } + + states, err := r.List(context.Background(), clablabruntime.ListRequest{Namespace: "lab-ns"}) + if err != nil { + t.Fatal(err) + } + if len(states) != 1 || states[0].Name != "primitive-lab" { + t.Fatalf("unexpected primitive-only list state: %+v", states) + } + + if err := r.Destroy(context.Background(), clablabruntime.DestroyRequest{ + Name: "primitive-lab", + Namespace: "lab-ns", + }); err != nil { + t.Fatal(err) + } + for _, resource := range []struct { + gvr schema.GroupVersionResource + name string + }{ + {gvr: nodeGVR, name: "node1"}, + {gvr: linkGVR, name: "node1-eth1-host-eth1"}, + {gvr: launcherProfileGVR, name: "primitive-lab"}, + } { + _, err := r.client.Resource(resource.gvr).Namespace("lab-ns"). + Get(context.Background(), resource.name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected %s to be deleted, got %v", resource.name, err) + } + } +} + +func TestResolveLauncherNode(t *testing.T) { + t.Parallel() + + networkModes := map[string]string{ + "primary": "", + "secondary": "container:primary", + "nested": "container:secondary", + } + + if got := resolveLauncherNode("nested", networkModes); got != "primary" { + t.Fatalf("resolveLauncherNode(nested) = %q, want primary", got) + } + if got := resolveLauncherNode("standalone", networkModes); got != "standalone" { + t.Fatalf("resolveLauncherNode(standalone) = %q, want standalone", got) + } +} + +func TestDeployCreatesPrimitiveResources(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest + node2: + kind: linux + image: alpine:3 + links: + - endpoints: ["node1:eth1", "node2:eth1"] +` + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + Owner: "alice", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" || state.Owner != "alice" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + assertNoTestTopology(t, r, "lab-ns", "lab1") + node := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if node.GetLabels()[labelTopologyOwner] != "lab1" || + node.GetLabels()[labelRuntime] != clabernetesAppValue || + node.GetLabels()[clabconstants.Owner] != "alice" { + t.Fatalf("unexpected node labels: %v", node.GetLabels()) + } + if got, _, _ := unstructured.NestedString( + node.Object, + "spec", + "image", + ); got != "alpine:latest" { + t.Fatalf("node image = %q, want alpine:latest", got) + } + + profiles, err := r.client.Resource(launcherProfileGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(profiles.Items) != 1 || profiles.Items[0].GetName() != "lab1" { + t.Fatalf("unexpected launcher profiles: %+v", profiles.Items) + } + + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) + } + if got, _, _ := unstructured.NestedString( + links.Items[0].Object, + "spec", + "endpointA", + "nodeName", + ); got != "node1" { + t.Fatalf("link endpointA nodeName = %q, want node1", got) + } +} + +func TestDeployStagesLocalFilesFromTopology(t *testing.T) { + t.Parallel() + + topologyDir := t.TempDir() + writeFile(t, filepath.Join(topologyDir, "configs", "client2", "iperf.sh"), "#!/bin/sh\n", 0o755) + writeFile( + t, + filepath.Join(topologyDir, "configs", "prometheus", "prometheus.yml"), + "global: {}\n", + 0o644, + ) + writeFile( + t, + filepath.Join(topologyDir, "configs", "fabric", "leaf1.cfg"), + "set / system name leaf1\n", + 0o644, + ) + writeFile(t, filepath.Join(topologyDir, "configs", "client2.env"), "MODE=test\n", 0o644) + writeFile(t, filepath.Join(topologyDir, "configs", "agent.yml"), "name: agent\n", 0o644) + writeFile(t, filepath.Join(topologyDir, "configs", "flash.cfg"), "hostname ceos\n", 0o644) + + const definition = `name: lab1 +topology: + defaults: + kind: linux + nodes: + leaf1: + startup-config: configs/fabric/leaf1.cfg + client2: + kind: linux + env-files: + - configs/client2.env + extras: + srl-agents: + - configs/agent.yml + ceos-copy-to-flash: + - configs/flash.cfg + binds: + - configs/client2:/config + prometheus: + kind: linux + binds: + - configs/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro +` + + topologyFile := filepath.Join(topologyDir, "lab.clab.yml") + writeFile(t, topologyFile, definition, 0o644) + + r := newTestRuntime() + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyFile: topologyFile, + TopologyLabDir: filepath.Join(topologyDir, "clab-lab1"), + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + clientConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-client2-files") + if got := string(clientConfigMap.BinaryData["configs-client2-iperf-sh"]); got != "#!/bin/sh\n" { + t.Fatalf("unexpected client2 staged file content: %q", got) + } + for key, want := range map[string]string{ + "configs-client2-env": "MODE=test\n", + "configs-agent-yml": "name: agent\n", + "configs-flash-cfg": "hostname ceos\n", + } { + if got := string(clientConfigMap.BinaryData[key]); got != want { + t.Fatalf("staged %s content = %q, want %q", key, got, want) + } + } + + prometheusConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-prometheus-files") + if got := string( + prometheusConfigMap.BinaryData["configs-prometheus-prometheus-yml"], + ); got != "global: {}\n" { + t.Fatalf("unexpected prometheus staged file content: %q", got) + } + + startupConfigMap := getTestConfigMap(t, r, "lab-ns", "lab1-leaf1-startup-config") + if got := string( + startupConfigMap.BinaryData["startup-config"], + ); got != "set / system name leaf1\n" { + t.Fatalf("unexpected startup config content: %q", got) + } + + assertFileMount( + t, + getTestPrimitive(t, r, nodeGVR, "lab-ns", "client2"), + "configs/client2/iperf.sh", + "lab1-client2-files", + "configs-client2-iperf-sh", + "execute", + ) + for _, filePath := range []string{ + "configs/client2.env", + "configs/agent.yml", + "configs/flash.cfg", + } { + assertFileMount( + t, + getTestPrimitive(t, r, nodeGVR, "lab-ns", "client2"), + filePath, + "lab1-client2-files", + safeConfigMapKey(filePath), + "read", + ) + } + assertFileMount( + t, + getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus"), + "configs/prometheus/prometheus.yml", + "lab1-prometheus-files", + "configs-prometheus-prometheus-yml", + "read", + ) + assertFileMount( + t, + getTestPrimitive(t, r, nodeGVR, "lab-ns", "leaf1"), + "configs/fabric/leaf1.cfg", + "lab1-leaf1-startup-config", + "startup-config", + "read", + ) + + for _, configMap := range []*corev1.ConfigMap{ + clientConfigMap, + prometheusConfigMap, + startupConfigMap, + } { + if len(configMap.OwnerReferences) != 1 || configMap.OwnerReferences[0].Kind != "Node" { + t.Fatalf("ConfigMap %s owner references = %+v, want one Node owner", + configMap.Name, configMap.OwnerReferences) + } + } +} + +func TestValidateAcceptsLossyContainerlabSemantics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + definition string + }{ + { + name: "management network", + definition: `mgmt: + network: shared +topology: + nodes: + node1: + kind: linux + image: alpine +`, + }, + { + name: "group and pinned host port", + definition: `topology: + nodes: + node1: + kind: linux + image: alpine + group: clients + ports: + - 8080:80 +`, + }, + { + name: "lossy link metadata", + definition: `topology: + nodes: + node1: + kind: linux + image: alpine + node2: + kind: linux + image: alpine + links: + - endpoints: ["node1:eth1", "node2:eth1"] + labels: + purpose: test +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + if err := r.Validate(context.Background(), clablabruntime.DeployRequest{ + Name: "lossy", + Namespace: "lab-ns", + TopologyDefinition: []byte(tt.definition), + }); err != nil { + t.Fatalf("Validate() error = %v, want lossy topology to be accepted", err) + } + }) + } +} + +func TestValidateRejectsStructurallyUnsupportedContainerlabSemantics(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + err := r.Validate(context.Background(), clablabruntime.DeployRequest{ + Name: "structurally-unsupported", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + br0: + kind: bridge +`), + }) + if err == nil || !strings.Contains(err.Error(), "pseudo-node") { + t.Fatalf("Validate() error = %v, want structurally unsupported pseudo-node error", err) + } +} + +func TestPlanReportsPrimitiveDiffWithoutMutation(t *testing.T) { + t.Parallel() + + const initial = `topology: + nodes: + node1: + kind: linux + image: alpine:3 + node2: + kind: linux + image: alpine:3 + links: + - endpoints: ["node1:eth1", "node2:eth1"] +` + const changed = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + freshPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }) + if err != nil { + t.Fatal(err) + } + if len(freshPlan.Changes) != 4 { + t.Fatalf("fresh plan changes = %+v, want profile, two nodes, and link creates", freshPlan.Changes) + } + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), Wait: false, + }); err != nil { + t.Fatal(err) + } + + noOpPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }) + if err != nil { + t.Fatal(err) + } + if len(noOpPlan.Changes) != 0 { + t.Fatalf("no-op plan changes = %+v, want none", noOpPlan.Changes) + } + + changedPlan, err := r.Plan(context.Background(), clablabruntime.DeployRequest{ + Name: "plan-lab", Namespace: "lab-ns", TopologyDefinition: []byte(changed), + }) + if err != nil { + t.Fatal(err) + } + wantChanges := map[string]bool{ + "update/Node/node1": false, + "delete/Node/node2": false, + "delete/Link/node1-eth1-node2-eth1": false, + } + for _, change := range changedPlan.Changes { + key := string(change.Action) + "/" + change.Kind + "/" + change.Name + if _, ok := wantChanges[key]; ok { + wantChanges[key] = true + } + } + for change, found := range wantChanges { + if !found { + t.Fatalf("changed plan = %+v, missing %s", changedPlan.Changes, change) + } + } +} + +func TestPrimitiveObjectsConformIgnoresMaterializedZeroDefaults(t *testing.T) { + t.Parallel() + + desired := &unstructured.Unstructured{Object: map[string]any{ + "spec": map[string]any{"statusProbes": map[string]any{"enabled": true}}, + }} + existing := desired.DeepCopy() + existing.Object["spec"] = map[string]any{ + "statusProbes": map[string]any{ + "enabled": true, + "probeConfiguration": map[string]any{"startupSeconds": int64(0)}, + }, + "expose": map[string]any{ + "disableAutoExpose": false, + "disableExpose": false, + "useNodeMgmtIpv4Address": false, + }, + } + + if !primitiveObjectsConform(existing, desired) { + t.Fatal("API-materialized zero/default fields were reported as drift") + } + + existing.Object["spec"].(map[string]any)["expose"].(map[string]any)["disableExpose"] = true + if primitiveObjectsConform(existing, desired) { + t.Fatal("nonzero API field was incorrectly ignored") + } +} + +func TestFreshDeployReadinessTimeoutRollsBackCreatedPrimitives(t *testing.T) { + t.Parallel() + + r := newTestRuntime() + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "timeout-lab", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + node1: + kind: linux + image: alpine:3 +`), + Wait: true, + Timeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected readiness timeout") + } + + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + assertNoTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "timeout-lab") +} + +func TestReconcileReadinessTimeoutRetainsExistingLab(t *testing.T) { + t.Parallel() + + const initial = `topology: + nodes: + node1: + kind: linux + image: alpine:3 +` + const changed = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "retained-lab", Namespace: "lab-ns", TopologyDefinition: []byte(initial), + }); err != nil { + t.Fatal(err) + } + + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "retained-lab", Namespace: "lab-ns", TopologyDefinition: []byte(changed), + Wait: true, Timeout: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected reconcile readiness timeout") + } + + node := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + image, _, nestedErr := unstructured.NestedString(node.Object, "spec", "image") + if nestedErr != nil { + t.Fatal(nestedErr) + } + if image != "alpine:latest" { + t.Fatalf("retained node image = %q, want reconciled image", image) + } + _ = getTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "retained-lab") +} + +func TestDeployReconcileDeletesStaleStagedConfigMaps(t *testing.T) { + t.Parallel() + + topologyDir := t.TempDir() + writeFile(t, filepath.Join(topologyDir, "configs", "node1", "startup.sh"), "#!/bin/sh\n", 0o755) + + const initialDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest + binds: + - configs/node1:/config +` + const updatedDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + topologyFile := filepath.Join(topologyDir, "lab.clab.yml") + writeFile(t, topologyFile, initialDefinition, 0o644) + + r := newTestRuntime() + req := clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyFile: topologyFile, + TopologyDefinition: []byte(initialDefinition), + Wait: false, + } + if _, err := r.Deploy(context.Background(), req); err != nil { + t.Fatal(err) + } + _ = getTestConfigMap(t, r, "lab-ns", "lab1-node1-files") + + req.TopologyDefinition = []byte(updatedDefinition) + if _, err := r.Deploy(context.Background(), req); err != nil { + t.Fatal(err) + } + _, err := r.kubeClient.CoreV1().ConfigMaps("lab-ns").Get( + context.Background(), + "lab1-node1-files", + metav1.GetOptions{}, + ) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected stale ConfigMap to be deleted, got %v", err) + } + if files, found, err := unstructured.NestedSlice( + getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1").Object, + "spec", + "filesFromConfigMap", + ); err != nil || found || len(files) != 0 { + t.Fatalf("stale node file mounts remain: found=%t files=%v err=%v", found, files, err) + } +} + +func TestDeployPreservesDockerCompatibleNamesForEmptyPrefixTopology(t *testing.T) { + t.Parallel() + + const definition = `name: st +prefix: "" +topology: + nodes: + leaf1: + kind: nokia_srlinux + image: ghcr.io/nokia/srlinux:24.10.1 + prometheus: + kind: linux + image: quay.io/prometheus/prometheus:v2.54.1 +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "st", + Namespace: "lab-ns", + TopologyDefinition: []byte(definition), + Wait: false, + }); err != nil { + t.Fatal(err) + } + + assertNoTestTopology(t, r, "lab-ns", "st") + _ = getTestPrimitive(t, r, nodeGVR, "lab-ns", "leaf1") + _ = getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") +} + +func TestDeployDoesNotInferApplicationPorts(t *testing.T) { + t.Parallel() + + const definition = `name: st +prefix: "" +mgmt: + network: st + ipv4-subnet: 172.20.20.0/24 +topology: + nodes: + leaf1: + kind: nokia_srlinux + image: ghcr.io/nokia/srlinux:24.10.1 + gnmic: + kind: linux + image: ghcr.io/openconfig/gnmic:0.39.1 + cmd: --config /gnmic-config.yml --log subscribe + prometheus: + kind: linux + image: quay.io/prometheus/prometheus:v2.54.1 + ports: + - 9090:9090 + links: + - endpoints: ["leaf1:e1-1", "prometheus:eth1"] +` + + r := newTestRuntime() + if _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "st", + Namespace: "lab-ns", + TopologyDefinition: []byte(definition), + Wait: false, + }); err != nil { + t.Fatal(err) + } + + assertNoTestTopology(t, r, "lab-ns", "st") + gnmic := getTestPrimitive(t, r, nodeGVR, "lab-ns", "gnmic") + ports, found, err := unstructured.NestedStringSlice(gnmic.Object, "spec", "ports") + if err != nil { + t.Fatalf("failed to read gnmic ports: %v", err) + } + if found || len(ports) != 0 { + t.Fatalf("gnmic ports = %v, found=%t; want no inferred application ports", ports, found) + } + prometheus := getTestPrimitive(t, r, nodeGVR, "lab-ns", "prometheus") + ports, found, err = unstructured.NestedStringSlice(prometheus.Object, "spec", "ports") + if err != nil || !found || !slices.Contains(ports, "9090") { + t.Fatalf("prometheus normalized ports = %v, found=%t, err=%v; want 9090", ports, found, err) + } + + profile := getTestPrimitive(t, r, launcherProfileGVR, "lab-ns", "st") + if enabled, found, err := unstructured.NestedBool( + profile.Object, + "spec", + "statusProbes", + "enabled", + ); err != nil || !found || !enabled { + t.Fatalf("status probes enabled = %t, found=%t, err=%v; want true", enabled, found, err) + } + statusProbes, found, err := unstructured.NestedMap(profile.Object, "spec", "statusProbes") + if err != nil || !found { + t.Fatalf("failed to read status probe configuration: found=%t err=%v", found, err) + } + if configurations, found := statusProbes["nodeProbeConfigurations"]; found && + configurations != nil { + t.Fatalf("containerlab must not infer node probe configurations: %v", statusProbes) + } + if excludedNodes, found := statusProbes["excludedNodes"]; found && excludedNodes != nil { + t.Fatalf("containerlab must not exclude kinds from generic readiness: %v", statusProbes) + } + if got, _, _ := unstructured.NestedString( + profile.Object, + "spec", + "mgmt", + "ipv4-subnet", + ); got != "172.20.20.0/24" { + t.Fatalf("launcher profile management subnet = %q, want 172.20.20.0/24", got) + } + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) + } +} + +func TestDeployReconcilesCompatibilityTopology(t *testing.T) { + t.Parallel() + + const existingDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.20 +` + const newDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.21 +` + + r := newTestRuntime(topologyObject("lab1", "lab-ns", "", existingDefinition)) + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(newDefinition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" { + t.Fatalf("unexpected reconciled state: %+v", state) + } + + obj := getTestTopology(t, r, "lab-ns", "lab1") + if got := topologyDefinition(t, obj); got != newDefinition { + t.Fatalf("topology definition was updated to %q, want %q", got, newDefinition) + } +} + +func TestDeployReconcilesPrimitiveResources(t *testing.T) { + t.Parallel() + + const initialDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.20 + old-node: + kind: linux + image: alpine:3.20 + links: + - endpoints: ["node1:eth1", "old-node:eth1"] +` + const updatedDefinition = `topology: + nodes: + node1: + kind: linux + image: alpine:3.21 + node2: + kind: linux + image: alpine:3.21 + links: + - endpoints: ["node1:eth2", "node2:eth1"] +` + + r := newTestRuntime() + request := clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(initialDefinition), + Wait: false, + } + if _, err := r.Deploy(context.Background(), request); err != nil { + t.Fatal(err) + } + + node1 := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + node1.SetLabels(mergeDesiredMetadata(node1.GetLabels(), map[string]string{ + "example.com/preserved": "true", + labelIgnoreReconcile: "true", + })) + if err := unstructured.SetNestedField(node1.Object, "ready", "status", "readiness"); err != nil { + t.Fatal(err) + } + if _, err := r.client.Resource(nodeGVR).Namespace("lab-ns").Update( + context.Background(), + node1, + metav1.UpdateOptions{}, + ); err != nil { + t.Fatal(err) + } + + request.TopologyDefinition = []byte(updatedDefinition) + state, err := r.Deploy(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-ns" || len(state.Nodes) != 2 { + t.Fatalf("unexpected reconciled state: %+v", state) + } + + node1 = getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if got, _, _ := unstructured.NestedString(node1.Object, "spec", "image"); got != "alpine:3.21" { + t.Fatalf("node1 image = %q, want alpine:3.21", got) + } + if got, _, _ := unstructured.NestedString(node1.Object, "status", "readiness"); got != "ready" { + t.Fatalf("node1 status readiness = %q, want preserved ready", got) + } + if node1.GetLabels()["example.com/preserved"] != "true" { + t.Fatalf("node1 extra labels were not preserved: %v", node1.GetLabels()) + } + if _, exists := node1.GetLabels()[labelIgnoreReconcile]; exists { + t.Fatalf("node1 retained stop lifecycle label: %v", node1.GetLabels()) + } + + node2 := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node2") + if _, exists := node2.GetLabels()[clabernetesconstants.LabelDisableDeployments]; exists { + t.Fatalf("new node retained deployment staging label: %v", node2.GetLabels()) + } + assertNoTestPrimitive(t, r, nodeGVR, "lab-ns", "old-node") + + links, err := r.client.Resource(linkGVR).Namespace("lab-ns"). + List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(links.Items) != 1 { + t.Fatalf("len(links) = %d, want 1", len(links.Items)) + } + if got, _, _ := unstructured.NestedString( + links.Items[0].Object, + "spec", + "endpointB", + "nodeName", + ); got != "node2" { + t.Fatalf("reconciled link endpointB = %q, want node2", got) + } +} + +func TestDeployRejectsPrimitiveResourceOwnedByAnotherLab(t *testing.T) { + t.Parallel() + + foreignNode := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Node", + "metadata": map[string]any{ + "name": "node1", + "namespace": "lab-ns", + "labels": map[string]any{ + labelTopologyOwner: "other-lab", + }, + }, + }} + r := newTestRuntime(foreignNode) + _, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-ns", + TopologyDefinition: []byte(`topology: + nodes: + node1: + kind: linux + image: alpine:latest +`), + Wait: false, + }) + if err == nil || !strings.Contains(err.Error(), "belongs to another lab") { + t.Fatalf("unexpected ownership collision error: %v", err) + } + + actual := getTestPrimitive(t, r, nodeGVR, "lab-ns", "node1") + if actual.GetLabels()[labelTopologyOwner] != "other-lab" { + t.Fatalf("foreign node ownership changed: %v", actual.GetLabels()) + } +} + +func TestDeployDuplicateCheckIsNamespaceScoped(t *testing.T) { + t.Parallel() + + const definition = `topology: + nodes: + node1: + kind: linux + image: alpine:latest +` + + r := newTestRuntime(topologyObject("lab1", "lab-a", "", definition)) + state, err := r.Deploy(context.Background(), clablabruntime.DeployRequest{ + Name: "lab1", + Namespace: "lab-b", + TopologyDefinition: []byte(definition), + Wait: false, + }) + if err != nil { + t.Fatal(err) + } + if state.Name != "lab1" || state.Namespace != "lab-b" { + t.Fatalf("unexpected deploy state: %+v", state) + } + + _ = getTestTopology(t, r, "lab-a", "lab1") + assertNoTestTopology(t, r, "lab-b", "lab1") + _ = getTestPrimitive(t, r, nodeGVR, "lab-b", "node1") +} + +func TestForwardPodWatchReconnectsOnClosedChannel(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + watcher.Stop() + + r := &Runtime{} + if !r.forwardPodWatch( + context.Background(), + watcher, + make(chan clablabruntime.Event, 1), + make(chan error, 1), + ) { + t.Fatal("expected closed pod watch to request reconnect") + } +} + +func TestForwardTopologyWatchReconnectsOnClosedChannel(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + watcher.Stop() + + r := &Runtime{} + if !r.forwardTopologyWatch( + context.Background(), + "default", + watcher, + make(chan clablabruntime.Event, 1), + make(chan error, 1), + ) { + t.Fatal("expected closed topology watch to request reconnect") + } +} + +func TestForwardPodWatchEmitsPodEvent(t *testing.T) { + t.Parallel() + + watcher := watch.NewFake() + events := make(chan clablabruntime.Event, 1) + errs := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + r := &Runtime{} + done := make(chan bool, 1) + go func() { + done <- r.forwardPodWatch(ctx, watcher, events, errs) + }() + + watcher.Add(&corev1.Pod{}) + watcher.Add(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "lab-ns", + Labels: map[string]string{ + labelTopologyOwner: "lab1", + labelTopologyNode: "node1", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + PodIP: "10.0.0.1", + }, + }) + + got := <-events + if got.ActorID != "lab-ns/lab1/node1" || + got.ActorName != "lab1-node1" || + got.ActorFullID != "pod1" || + got.Attributes["phase"] != string(corev1.PodRunning) || + got.Attributes["pod_ip"] != "10.0.0.1" { + t.Fatalf("unexpected pod event: %+v", got) + } + + cancel() + watcher.Stop() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("forwardPodWatch did not exit after cancellation") + } +} + +func newTestRuntime(objects ...*unstructured.Unstructured) *Runtime { + runtimeObjects := make([]k8sruntime.Object, 0, len(objects)) + for _, obj := range objects { + runtimeObjects = append(runtimeObjects, obj) + } + + return &Runtime{ + client: dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + k8sruntime.NewScheme(), + map[schema.GroupVersionResource]string{ + topologyGVR: "TopologyList", + nodeGVR: "NodeList", + linkGVR: "LinkList", + launcherProfileGVR: "LauncherProfileList", + imageRequestGVR: "ImageRequestList", + }, + runtimeObjects..., + ), + kubeClient: kubefake.NewSimpleClientset( + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: defaultNamespace}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-ns"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-a"}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "lab-b"}}, + ), + namespace: defaultNamespace, + } +} + +func getTestTopology( + t *testing.T, + r *Runtime, + namespace string, + name string, +) *unstructured.Unstructured { + t.Helper() + + obj, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return obj +} + +func assertNoTestTopology( + t *testing.T, + r *Runtime, + namespace string, + name string, +) { + t.Helper() + + _, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected compatibility Topology %s/%s not to exist, got %v", + namespace, name, err) + } +} + +func getTestPrimitive( + t *testing.T, + r *Runtime, + gvr schema.GroupVersionResource, + namespace string, + name string, +) *unstructured.Unstructured { + t.Helper() + + obj, err := r.client.Resource(gvr).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return obj +} + +func assertNoTestPrimitive( + t *testing.T, + r *Runtime, + gvr schema.GroupVersionResource, + namespace, + name string, +) { + t.Helper() + + _, err := r.client.Resource(gvr).Namespace(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected c9s %s %s/%s not to exist, got %v", + gvr.Resource, namespace, name, err) + } +} + +func topologyDefinition(t *testing.T, obj *unstructured.Unstructured) string { + t.Helper() + + definition, found, err := unstructured.NestedString( + obj.Object, + "spec", + "definition", + "containerlab", + ) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("topology definition was not found") + } + + return definition +} + +func topologyNaming(t *testing.T, obj *unstructured.Unstructured) string { + t.Helper() + + naming, found, err := unstructured.NestedString(obj.Object, "spec", "naming") + if err != nil { + t.Fatal(err) + } + if !found { + return "" + } + + return naming +} + +func getTestConfigMap( + t *testing.T, + r *Runtime, + namespace string, + name string, +) *corev1.ConfigMap { + t.Helper() + + configMap, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + return configMap +} + +func assertFileMount( + t *testing.T, + obj *unstructured.Unstructured, + filePath, + configMapName, + configMapPath, + mode string, +) { + t.Helper() + + filesFromConfigMap, found, err := unstructured.NestedSlice( + obj.Object, + "spec", + "filesFromConfigMap", + ) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("filesFromConfigMap was not found") + } + + for _, rawMount := range filesFromConfigMap { + mount, ok := rawMount.(map[string]any) + if !ok { + t.Fatalf("mount has unexpected type %T", rawMount) + } + if mount["filePath"] == filePath && + mount["configMapName"] == configMapName && + mount["configMapPath"] == configMapPath && + mount["mode"] == mode { + return + } + } + + t.Fatalf( + "mount %s/%s/%s/%s was not found in %+v", + filePath, + configMapName, + configMapPath, + mode, + filesFromConfigMap, + ) +} + +func writeFile(t *testing.T, path string, content string, mode os.FileMode) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func writeTarEntry(t *testing.T, tw *tar.Writer, hdr *tar.Header, data []byte) { + t.Helper() + + if hdr.Typeflag == 0 { + hdr.Typeflag = tar.TypeReg + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if len(data) == 0 { + return + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } +} diff --git a/labruntime/clabernetes/config.go b/labruntime/clabernetes/config.go new file mode 100644 index 0000000000..4e2fa37d38 --- /dev/null +++ b/labruntime/clabernetes/config.go @@ -0,0 +1,234 @@ +package clabernetes + +import ( + "context" + "fmt" + "os" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const labNamespacePrefix = "c9s-" + +func configuredLabNamespace(namespace string) string { + if namespace != "" { + return namespace + } + + return os.Getenv(envNamespace) +} + +func (r *Runtime) namespaceFor(namespace string) string { + if namespace != "" { + return namespace + } + if r.namespace != "" { + return r.namespace + } + return defaultNamespace +} + +func canonicalNamespaceForLab(name string) (string, error) { + namespace := labNamespacePrefix + name + if errs := validation.IsDNS1123Label(namespace); len(errs) != 0 { + return "", fmt.Errorf( + "cannot derive Kubernetes namespace for c9s lab %q: %s", + name, + errs[0], + ) + } + + return namespace, nil +} + +func (r *Runtime) namespaceForLab(name, namespace string) (string, error) { + if namespace != "" { + return namespace, nil + } + if r.labNamespaceOverride != "" { + return r.labNamespaceOverride, nil + } + + return canonicalNamespaceForLab(name) +} + +func (r *Runtime) ensureLabNamespace( + ctx context.Context, + name, + namespace string, + managed bool, +) (bool, error) { + namespaces := r.kubeClient.CoreV1().Namespaces() + existing, err := namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if err == nil { + if owner := existing.Labels[labelTopologyOwner]; managed && owner != "" && owner != name { + return false, fmt.Errorf( + "c9s namespace %q belongs to lab %q, not %q", + namespace, + owner, + name, + ) + } + + return false, nil + } + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if !managed { + return false, fmt.Errorf( + "c9s namespace override %q does not exist; create it before deploying the lab", + namespace, + ) + } + + _, err = namespaces.Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Labels: map[string]string{ + labelRuntime: clabernetesAppValue, + labelTopologyOwner: name, + }, + }, + }, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, err = namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf( + "failed to get concurrently created c9s namespace %q: %w", + namespace, + err, + ) + } + if owner := existing.Labels[labelTopologyOwner]; owner != "" && owner != name { + return false, fmt.Errorf( + "c9s namespace %q belongs to lab %q, not %q", + namespace, + owner, + name, + ) + } + + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to create c9s namespace %q: %w", namespace, err) + } + + return true, nil +} + +func (r *Runtime) deleteManagedLabNamespace( + ctx context.Context, + name, + namespace string, +) (bool, error) { + canonicalNamespace, err := canonicalNamespaceForLab(name) + if err != nil || namespace != canonicalNamespace { + return false, nil + } + + namespaces := r.kubeClient.CoreV1().Namespaces() + existing, err := namespaces.Get(ctx, namespace, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if existing.Labels[labelRuntime] != clabernetesAppValue || + existing.Labels[labelTopologyOwner] != name { + return false, nil + } + + err = namespaces.Delete(ctx, namespace, metav1.DeleteOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to delete c9s namespace %q: %w", namespace, err) + } + + return true, nil +} + +func (r *Runtime) waitNamespaceDeleted( + ctx context.Context, + namespace string, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + for { + _, err := r.kubeClient.CoreV1().Namespaces().Get( + waitCtx, + namespace, + metav1.GetOptions{}, + ) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + + select { + case <-waitCtx.Done(): + return fmt.Errorf( + "timed out after %s waiting for c9s namespace %q to be deleted", + r.timeoutFor(timeout), + namespace, + ) + case <-time.After(pollInterval): + } + } +} + +func (r *Runtime) timeoutFor(timeout time.Duration) time.Duration { + if timeout > 0 { + return timeout + } + if r.timeout > 0 { + return r.timeout + } + return 10 * time.Minute +} + +func kubeClientConfig() (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + if kubeconfig := os.Getenv(envKubeconfig); kubeconfig != "" { + loadingRules.ExplicitPath = kubeconfig + } + + overrides := &clientcmd.ConfigOverrides{} + if contextName := os.Getenv(envContext); contextName != "" { + overrides.CurrentContext = contextName + } + if namespace := os.Getenv(envNamespace); namespace != "" { + overrides.Context.Namespace = namespace + } + + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + overrides, + ) + + namespace, _, err := clientConfig.Namespace() + if err != nil { + namespace = defaultNamespace + } + + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, "", fmt.Errorf("failed to load Kubernetes client config: %w", err) + } + + return restConfig, namespace, nil +} diff --git a/labruntime/clabernetes/events.go b/labruntime/clabernetes/events.go new file mode 100644 index 0000000000..ee5c23bd35 --- /dev/null +++ b/labruntime/clabernetes/events.go @@ -0,0 +1,358 @@ +package clabernetes + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" +) + +func (r *Runtime) StreamEvents( + ctx context.Context, + req clablabruntime.EventStreamRequest, +) (<-chan clablabruntime.Event, <-chan error, error) { + events := make(chan clablabruntime.Event, 128) + errs := make(chan error, 3) + + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + if req.IncludeInitialState { + go r.emitInitialEvents(ctx, namespace, events, errs) + } + + if req.IncludeInterfaceStats { + go r.pollInterfaceStats(ctx, namespace, req.StatsInterval, events) + } + + go r.watchTopologies(ctx, namespace, events, errs) + go r.watchNodes(ctx, namespace, events, errs) + go r.watchPods(ctx, namespace, events, errs) + + return events, errs, nil +} + +func (r *Runtime) watchNodes( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + resource := r.client.Resource(nodeGVR).Namespace(namespace) + + for { + watcher, err := resource.Watch(ctx, metav1.ListOptions{LabelSelector: labelTopologyOwner}) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf("failed to watch c9s nodes: %w", err)) + return + } + + if !r.forwardNodeWatch(ctx, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardNodeWatch( + ctx context.Context, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("c9s node watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf("c9s node watch returned an error")) + return false + } + + node, ok := ev.Object.(*unstructured.Unstructured) + if !ok { + continue + } + + labName := node.GetLabels()[labelTopologyOwner] + if labName == "" { + continue + } + readiness, _, _ := unstructured.NestedString(node.Object, "status", "readiness") + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s/%s", node.GetNamespace(), labName, node.GetName()), + ActorName: fmt.Sprintf("%s-%s", labName, node.GetName()), + Attributes: map[string]string{ + "namespace": node.GetNamespace(), + "lab": labName, + "node": node.GetName(), + "state": readiness, + }, + }) + } + } +} + +func (r *Runtime) emitInitialEvents( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + states, err := r.List(ctx, clablabruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + sendEventError(ctx, errSink, err) + return + } + + for _, state := range states { + for _, node := range state.Nodes { + action := node.State + if node.Ready { + action = "running" + } + if action == "" { + action = state.State + } + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: action, + ActorID: fmt.Sprintf("%s/%s/%s", state.Namespace, state.Name, node.Name), + ActorName: fmt.Sprintf("%s-%s", state.Name, node.Name), + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "state": node.State, + }, + }) + } + } +} + +func (r *Runtime) watchTopologies( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + for { + watcher, err := resource.Watch(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError( + ctx, + errSink, + fmt.Errorf("failed to watch clabernetes topologies: %w", err), + ) + return + } + + if !r.forwardTopologyWatch(ctx, namespace, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardTopologyWatch( + ctx context.Context, + namespace string, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("clabernetes topology watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError( + ctx, + errSink, + fmt.Errorf("clabernetes topology watch returned an error"), + ) + return false + } + + obj, ok := ev.Object.(*unstructured.Unstructured) + if !ok { + continue + } + + state := stateFromTopology(obj, namespace) + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "topology", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s", state.Namespace, state.Name), + ActorName: state.Name, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "state": state.State, + "ready": fmt.Sprintf("%t", state.Ready), + }, + }) + } + } +} + +func (r *Runtime) watchPods( + ctx context.Context, + namespace string, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) { + for { + watcher, err := r.kubeClient.CoreV1().Pods(namespace).Watch(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + if ctx.Err() != nil { + return + } + + sendEventError(ctx, errSink, fmt.Errorf( + "failed to watch clabernetes pods: %w", err)) + return + } + + if !r.forwardPodWatch(ctx, watcher, eventSink, errSink) { + return + } + + if !sleepContext(ctx, pollInterval) { + return + } + } +} + +func (r *Runtime) forwardPodWatch( + ctx context.Context, + watcher watch.Interface, + eventSink chan<- clablabruntime.Event, + errSink chan<- error, +) bool { + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return false + case ev, ok := <-watcher.ResultChan(): + if !ok { + log.Debug("clabernetes pod watch closed, reconnecting") + return true + } + if ev.Type == watch.Error { + sendEventError(ctx, errSink, fmt.Errorf( + "clabernetes pod watch returned an error")) + return false + } + + pod, ok := ev.Object.(*corev1.Pod) + if !ok { + continue + } + + labName := pod.Labels[labelTopologyOwner] + nodeName := pod.Labels[labelTopologyNode] + if labName == "" || nodeName == "" { + continue + } + + r.sendEvent(ctx, eventSink, clablabruntime.Event{ + Timestamp: time.Now(), + Type: "container", + Action: strings.ToLower(string(ev.Type)), + ActorID: fmt.Sprintf("%s/%s/%s", pod.Namespace, labName, nodeName), + ActorName: fmt.Sprintf("%s-%s", labName, nodeName), + ActorFullID: pod.Name, + Attributes: map[string]string{ + "namespace": pod.Namespace, + "lab": labName, + "node": nodeName, + "pod": pod.Name, + "phase": string(pod.Status.Phase), + "pod_ip": pod.Status.PodIP, + }, + }) + } + } +} + +func sleepContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +func (r *Runtime) sendEvent( + ctx context.Context, + eventSink chan<- clablabruntime.Event, + event clablabruntime.Event, +) { + select { + case eventSink <- event: + case <-ctx.Done(): + } +} + +func sendEventError(ctx context.Context, errSink chan<- error, err error) { + select { + case errSink <- err: + case <-ctx.Done(): + } +} diff --git a/labruntime/clabernetes/exec.go b/labruntime/clabernetes/exec.go new file mode 100644 index 0000000000..8c39459902 --- /dev/null +++ b/labruntime/clabernetes/exec.go @@ -0,0 +1,285 @@ +package clabernetes + +import ( + "bytes" + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" + clabexec "github.com/srl-labs/containerlab/exec" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/remotecommand" + kubeexec "k8s.io/client-go/util/exec" +) + +func (r *Runtime) Exec( + ctx context.Context, + req clablabruntime.ExecRequest, +) (*clabexec.ExecResult, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + if req.NodeName == "" { + return nil, fmt.Errorf("node name is required") + } + if len(req.Command) == 0 { + return nil, fmt.Errorf("command is required") + } + + pod, err := r.launcherPod(ctx, req.Name, req.Namespace, req.NodeName) + if err != nil { + return nil, err + } + + containerName, err := r.nestedContainerName(ctx, pod, req.NodeName) + if err != nil { + return nil, err + } + + execCmd := clabexec.NewExecCmdFromSlice(req.Command) + result := clabexec.NewExecResult(execCmd) + cmd := append([]string{"docker", "exec", containerName}, req.Command...) + + stdout, stderr, rc, err := r.execInPod(ctx, pod, cmd) + if err != nil { + return nil, err + } + + result.SetReturnCode(rc) + result.SetStdOut(stdout) + result.SetStdErr(stderr) + + return result, nil +} + +func (r *Runtime) nestedContainerName( + ctx context.Context, + pod *corev1.Pod, + nodeName string, +) (string, error) { + exactNames, err := r.nestedContainerNamesByLabel( + ctx, + pod, + clabconstants.NodeName, + nodeName, + ) + if err != nil { + return "", err + } + + componentNames := []string(nil) + if len(exactNames) == 0 { + componentNames, err = r.nestedContainerNamesByLabel( + ctx, + pod, + clabconstants.RootNodeName, + nodeName, + ) + if err != nil { + return "", err + } + } + + return preferredNestedContainerName(nodeName, exactNames, componentNames) +} + +func (r *Runtime) nestedContainerNamesByLabel( + ctx context.Context, + pod *corev1.Pod, + label, + value string, +) ([]string, error) { + stdout, stderr, rc, err := r.execInPod(ctx, pod, []string{ + "docker", + "ps", + "--all", + "--filter", + fmt.Sprintf("label=%s=%s", label, value), + "--format", + "{{.Names}}", + }) + if err != nil { + return nil, err + } + if rc != 0 { + return nil, fmt.Errorf( + "failed listing nested containers for node %q: rc=%d stderr=%s", + value, + rc, + strings.TrimSpace(string(stderr)), + ) + } + + return strings.Fields(string(stdout)), nil +} + +func preferredNestedContainerName( + nodeName string, + exactNames, + componentNames []string, +) (string, error) { + switch len(exactNames) { + case 1: + return exactNames[0], nil + case 0: + default: + return "", fmt.Errorf("multiple nested containers matched node %q: %v", nodeName, exactNames) + } + + slices.Sort(componentNames) + + for _, cpmSuffix := range []string{"-a", "-b"} { + for _, componentName := range componentNames { + if strings.EqualFold(componentName, nodeName+cpmSuffix) { + return componentName, nil + } + } + } + + if len(componentNames) == 0 { + return "", fmt.Errorf("nested container for node %q was not found", nodeName) + } + + return "", fmt.Errorf( + "CPM component container for node %q was not found among %v", + nodeName, + componentNames, + ) +} + +func (r *Runtime) launcherPod( + ctx context.Context, + name, + namespace, + nodeName string, +) (*corev1.Pod, error) { + var err error + namespace, err = r.namespaceForLab(name, namespace) + if err != nil { + return nil, err + } + launchers, err := r.launcherNodeNames(ctx, name, namespace, []string{nodeName}) + if err != nil { + return nil, err + } + launcherNode := launchers[nodeName] + + list, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: launcherNode, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes launcher pods for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes launcher pod for node %s/%s/%s was not found", + namespace, name, nodeName) + } + + candidates := make([]*corev1.Pod, 0, len(list.Items)) + for idx := range list.Items { + if list.Items[idx].Status.Phase == corev1.PodRunning { + candidates = append(candidates, &list.Items[idx]) + } + } + if len(candidates) == 0 { + for idx := range list.Items { + candidates = append(candidates, &list.Items[idx]) + } + } + + // more than one pod can match during a rolling update; use the newest one + pod := candidates[0] + for _, candidate := range candidates[1:] { + if candidate.CreationTimestamp.After(pod.CreationTimestamp.Time) { + pod = candidate + } + } + + if len(list.Items) > 1 { + log.Warn("multiple clabernetes launcher pods matched node, using newest", + "namespace", namespace, + "lab", name, + "node", nodeName, + "pod", pod.Name, + ) + } + + return pod, nil +} + +func (r *Runtime) execInPod( + ctx context.Context, + pod *corev1.Pod, + command []string, +) ([]byte, []byte, int, error) { + if pod == nil { + return nil, nil, 0, fmt.Errorf("launcher pod is nil") + } + if len(command) == 0 { + return nil, nil, 0, fmt.Errorf("command is required") + } + + containerName := "" + if len(pod.Spec.Containers) != 0 { + containerName = pod.Spec.Containers[0].Name + } + + req := r.kubeClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod.Name). + Namespace(pod.Namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: command, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(r.restConfig, "POST", req.URL()) + if err != nil { + return nil, nil, 0, fmt.Errorf("failed to create Kubernetes exec executor: %w", err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + Tty: false, + }) + + rc := 0 + if err != nil { + var exitErr kubeexec.ExitError + if errors.As(err, &exitErr) { + rc = exitErr.ExitStatus() + err = nil + } + } + if err != nil { + return stdout.Bytes(), stderr.Bytes(), rc, fmt.Errorf( + "failed to execute command in pod %s/%s: %w", + pod.Namespace, + pod.Name, + err, + ) + } + + return stdout.Bytes(), stderr.Bytes(), rc, nil +} diff --git a/labruntime/clabernetes/files.go b/labruntime/clabernetes/files.go new file mode 100644 index 0000000000..078a52f803 --- /dev/null +++ b/labruntime/clabernetes/files.go @@ -0,0 +1,964 @@ +package clabernetes + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + clablinks "github.com/srl-labs/containerlab/links" + clabtypes "github.com/srl-labs/containerlab/types" + clabutils "github.com/srl-labs/containerlab/utils" + "gopkg.in/yaml.v2" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + fileModeRead = "read" + fileModeExecute = "execute" + inlineStartupConfigMountPath = "/clabernetes/startup-config" + maxConfigMapFileBytes = 950_000 + kubernetesNameMaxLen = 63 + clabernetesNamingNonPrefixed = "non-prefixed" + + clabDirVar = "__clabDir__" + clabLabNameVar = "__clabLabName__" + nodeDirVar = "__clabNodeDir__" + nodeNameVar = "__clabNodeName__" +) + +var ( + invalidDNSLabelChars = regexp.MustCompile(`[^a-z0-9\-]`) //nolint:gochecknoglobals + startsWithNonAlpha = regexp.MustCompile(`^[^a-z]`) //nolint:gochecknoglobals + endsWithNonAlpha = regexp.MustCompile(`[^a-z]$`) //nolint:gochecknoglobals +) + +type clabRuntimeConfig struct { + Name string `yaml:"name,omitempty"` + Prefix *string `yaml:"prefix,omitempty"` + Mgmt *clabtypes.MgmtNet `yaml:"mgmt,omitempty"` + Settings *clabtypes.Settings `yaml:"settings,omitempty"` + Topology *clabtypes.Topology `yaml:"topology,omitempty"` +} + +type clabernetesRenderConfig struct { + Name string `yaml:"name,omitempty"` + Prefix *string `yaml:"prefix,omitempty"` + Mgmt *clabtypes.MgmtNet `yaml:"mgmt,omitempty"` + Settings *clabtypes.Settings `yaml:"settings,omitempty"` + Topology *clabernetesRenderTopology `yaml:"topology,omitempty"` +} + +type clabernetesRenderTopology struct { + Defaults *clabtypes.NodeDefinition `yaml:"defaults,omitempty"` + Kinds map[string]*clabtypes.NodeDefinition `yaml:"kinds,omitempty"` + Nodes map[string]*clabtypes.NodeDefinition `yaml:"nodes,omitempty"` + Groups map[string]*clabtypes.NodeDefinition `yaml:"groups,omitempty"` + Links []*clablinks.LinkBriefRaw `yaml:"links,omitempty"` +} + +type stagedConfigMap struct { + name string + nodeName string + binaryData map[string][]byte + keyByFilePath map[string]string + mounts []stagedConfigMapMount +} + +type stagedConfigMapMount struct { + nodeName string + filePath string + configMapName string + configMapPath string + mode string +} + +type stagedLocalFile struct { + filePath string + resolvedPath string + mode string + content []byte +} + +func stageTopologyLocalFiles( + req clablabruntime.DeployRequest, +) ([]byte, []stagedConfigMap, string, error) { + config := &clabRuntimeConfig{} + if err := yaml.Unmarshal(req.TopologyDefinition, config); err != nil { + return nil, nil, "", fmt.Errorf( + "failed to parse rendered topology for clabernetes preparation: %w", + err, + ) + } + + naming := clabernetesNamingMode(config) + if config.Topology == nil || len(config.Topology.Nodes) == 0 { + return req.TopologyDefinition, nil, naming, nil + } + + extraConfigMaps := map[string]*stagedConfigMap{} + startupConfigMaps := map[string]*stagedConfigMap{} + // Always render parsed links through the c9s brief-link boundary so an extended link's + // behavior does not depend on whether some unrelated field changed the definition. + definitionChanged := len(config.Topology.Links) > 0 + + nodeNames := make([]string, 0, len(config.Topology.Nodes)) + for nodeName := range config.Topology.Nodes { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + if req.TopologyFile != "" { + topologyFileDir := filepath.Dir(req.TopologyFile) + topologyLabDir := req.TopologyLabDir + if topologyLabDir == "" && config.Name != "" { + topologyLabDir = filepath.Join(topologyFileDir, "clab-"+config.Name) + } + + for _, nodeName := range nodeNames { + if err := stageStartupConfig( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + startupConfigMaps, + &definitionChanged, + ); err != nil { + return nil, nil, "", err + } + + if err := stageLicenseFile( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } + + if err := stageBindFiles( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } + + if err := stageAdditionalNodeFiles( + config, + req.Name, + nodeName, + topologyFileDir, + topologyLabDir, + extraConfigMaps, + ); err != nil { + return nil, nil, "", err + } + } + } + + topologyDefinition := req.TopologyDefinition + if definitionChanged { + updatedDefinition, err := renderClabernetesTopologyDefinition(config) + if err != nil { + return nil, nil, "", fmt.Errorf( + "failed to render updated clabernetes topology definition: %w", + err, + ) + } + topologyDefinition = updatedDefinition + } + + stagedConfigMaps := collectStagedConfigMaps(startupConfigMaps, extraConfigMaps) + + return topologyDefinition, stagedConfigMaps, naming, nil +} + +func renderClabernetesTopologyDefinition(config *clabRuntimeConfig) ([]byte, error) { + if config == nil { + return nil, fmt.Errorf("topology config is nil") + } + + rendered := &clabernetesRenderConfig{ + Name: config.Name, + Prefix: config.Prefix, + Mgmt: config.Mgmt, + Settings: config.Settings, + } + + if config.Topology != nil { + links, err := clabernetesBriefLinks(config.Topology.Links) + if err != nil { + return nil, err + } + + rendered.Topology = &clabernetesRenderTopology{ + Defaults: config.Topology.Defaults, + Kinds: config.Topology.Kinds, + Nodes: config.Topology.Nodes, + Groups: config.Topology.Groups, + Links: links, + } + } + + return yaml.Marshal(rendered) +} + +func clabernetesBriefLinks( + links []*clablinks.LinkDefinition, +) ([]*clablinks.LinkBriefRaw, error) { + if len(links) == 0 { + return nil, nil + } + + briefLinks := make([]*clablinks.LinkBriefRaw, 0, len(links)) + for _, link := range links { + if link == nil || link.Link == nil { + continue + } + + brief, err := clabernetesBriefLink(link) + if err != nil { + return nil, err + } + briefLinks = append(briefLinks, brief) + } + + return briefLinks, nil +} + +func clabernetesBriefLink( + link *clablinks.LinkDefinition, +) (*clablinks.LinkBriefRaw, error) { + switch raw := link.Link.(type) { + case *clablinks.LinkBriefRaw: + return raw, nil + case *clablinks.LinkVEthRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkHostRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkMgmtNetRaw: + return raw.ToLinkBriefRaw(), nil + case *clablinks.LinkMacVlanRaw: + return raw.ToLinkBriefRaw(), nil + default: + return nil, fmt.Errorf( + "failed to render clabernetes-compatible brief link for %s link", + link.Link.GetType(), + ) + } +} + +func clabernetesNamingMode(config *clabRuntimeConfig) string { + if config == nil || config.Prefix == nil || *config.Prefix != "" { + return "" + } + + return clabernetesNamingNonPrefixed +} + +func stageStartupConfig( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, + definitionChanged *bool, +) error { + startupConfig := config.Topology.GetNodeStartupConfig(nodeName) + if startupConfig == "" { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "startup-config"), + ) + + if strings.Contains(startupConfig, "\n") { + nodeDefinition := config.Topology.Nodes[nodeName] + if nodeDefinition == nil { + nodeDefinition = &clabtypes.NodeDefinition{} + config.Topology.Nodes[nodeName] = nodeDefinition + } + nodeDefinition.StartupConfig = inlineStartupConfigMountPath + *definitionChanged = true + + return addStagedConfigMapData( + configMap, + inlineStartupConfigMountPath, + "startup-config", + fileModeRead, + []byte(startupConfig), + ) + } + + files, err := resolveLocalFiles(startupConfig, nodeName, topologyFileDir, topologyLabDir) + if err != nil { + return fmt.Errorf("failed staging startup-config for node %q: %w", nodeName, err) + } + + for _, file := range files { + if err := addStagedConfigMapData( + configMap, + file.filePath, + "startup-config", + file.mode, + file.content, + ); err != nil { + return err + } + } + + return nil +} + +func stageLicenseFile( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + license := config.Topology.GetNodeLicense(nodeName) + if license == "" { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + return stageSourcePathIntoConfigMap( + configMap, + license, + nodeName, + topologyFileDir, + topologyLabDir, + ) +} + +func stageBindFiles( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + binds, err := config.Topology.GetNodeBinds(nodeName) + if err != nil { + return fmt.Errorf("failed parsing bind mounts for node %q: %w", nodeName, err) + } + if len(binds) == 0 { + return nil + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + for _, bind := range binds { + parsedBind, err := clabtypes.NewBindFromString(bind) + if err != nil { + return fmt.Errorf("failed parsing bind %q for node %q: %w", bind, nodeName, err) + } + if parsedBind.Src() == "" { + continue + } + + if err := stageSourcePathIntoConfigMap( + configMap, + parsedBind.Src(), + nodeName, + topologyFileDir, + topologyLabDir, + ); err != nil { + return err + } + } + + return nil +} + +// stageAdditionalNodeFiles covers path-bearing fields that containerlab normally resolves on +// the machine running the CLI. A remote launcher cannot see those paths unless the adapter copies +// them into the node's ConfigMap-backed file projection first. +func stageAdditionalNodeFiles( + config *clabRuntimeConfig, + topologyName, + nodeName, + topologyFileDir, + topologyLabDir string, + configMaps map[string]*stagedConfigMap, +) error { + pathsByField := map[string][]string{ + "env-files": config.Topology.GetNodeEnvFiles(nodeName), + } + + if extras := config.Topology.GetNodeExtras(nodeName); extras != nil { + pathsByField["extras.srl-agents"] = extras.SRLAgents + pathsByField["extras.ceos-copy-to-flash"] = extras.CeosCopyToFlash + } + + fieldNames := make([]string, 0, len(pathsByField)) + for fieldName := range pathsByField { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + + for _, fieldName := range fieldNames { + for _, sourcePath := range pathsByField[fieldName] { + if strings.TrimSpace(sourcePath) == "" { + continue + } + + configMap := getOrCreateStagedConfigMap( + configMaps, + nodeName, + safeKubernetesName(topologyName, nodeName, "files"), + ) + + if err := stageSourcePathIntoConfigMap( + configMap, + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir, + ); err != nil { + return fmt.Errorf( + "failed staging %s path %q for node %q: %w", + fieldName, + sourcePath, + nodeName, + err, + ) + } + } + } + + return nil +} + +func stageSourcePathIntoConfigMap( + configMap *stagedConfigMap, + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir string, +) error { + files, err := resolveLocalFiles(sourcePath, nodeName, topologyFileDir, topologyLabDir) + if err != nil { + return fmt.Errorf( + "failed staging source path %q for node %q: %w", + sourcePath, + nodeName, + err, + ) + } + + for _, file := range files { + configMapKey := uniqueConfigMapKey(configMap, file.filePath) + if err := addStagedConfigMapData( + configMap, + file.filePath, + configMapKey, + file.mode, + file.content, + ); err != nil { + return err + } + } + + return nil +} + +func resolveLocalFiles( + sourcePath, + nodeName, + topologyFileDir, + topologyLabDir string, +) ([]stagedLocalFile, error) { + displayPath := replaceClabPathVariables(sourcePath, nodeName, topologyLabDir) + resolvedPath := clabutils.ResolvePath(displayPath, topologyFileDir) + + fileInfo, err := os.Stat(resolvedPath) + if err != nil { + return nil, err + } + + if !fileInfo.IsDir() { + file, err := loadStagedLocalFile(displayPath, resolvedPath, fileInfo) + if err != nil { + return nil, err + } + + return []stagedLocalFile{file}, nil + } + + files := []stagedLocalFile{} + err = filepath.WalkDir(resolvedPath, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + + fileInfo, err := d.Info() + if err != nil { + return err + } + + relativePath, err := filepath.Rel(resolvedPath, path) + if err != nil { + return err + } + + file, err := loadStagedLocalFile( + filepath.ToSlash(filepath.Join(displayPath, relativePath)), + path, + fileInfo, + ) + if err != nil { + return err + } + + files = append(files, file) + + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(files, func(i, j int) bool { + return files[i].filePath < files[j].filePath + }) + + return files, nil +} + +func loadStagedLocalFile( + filePath, + resolvedPath string, + fileInfo os.FileInfo, +) (stagedLocalFile, error) { + content, err := os.ReadFile(resolvedPath) //nolint:gosec + if err != nil { + return stagedLocalFile{}, err + } + if len(content) > maxConfigMapFileBytes { + return stagedLocalFile{}, fmt.Errorf( + "file %q is %d bytes, larger than the supported ConfigMap file limit of %d bytes", + resolvedPath, + len(content), + maxConfigMapFileBytes, + ) + } + + mode := fileModeRead + if fileInfo.Mode()&0o111 != 0 { + mode = fileModeExecute + } + + return stagedLocalFile{ + filePath: filepath.ToSlash(filePath), + resolvedPath: resolvedPath, + mode: mode, + content: content, + }, nil +} + +func replaceClabPathVariables(sourcePath, nodeName, topologyLabDir string) string { + labName := filepath.Base(topologyLabDir) + nodeDir := "" + if topologyLabDir != "" && nodeName != "" { + nodeDir = filepath.Join(topologyLabDir, nodeName) + } + + replacer := strings.NewReplacer( + clabDirVar, topologyLabDir, + clabLabNameVar, labName, + nodeDirVar, nodeDir, + nodeNameVar, nodeName, + ) + + return replacer.Replace(sourcePath) +} + +func getOrCreateStagedConfigMap( + configMaps map[string]*stagedConfigMap, + nodeName, + name string, +) *stagedConfigMap { + configMap, ok := configMaps[nodeName] + if ok { + return configMap + } + + configMap = &stagedConfigMap{ + name: name, + nodeName: nodeName, + binaryData: map[string][]byte{}, + keyByFilePath: map[string]string{}, + } + configMaps[nodeName] = configMap + + return configMap +} + +func addStagedConfigMapData( + configMap *stagedConfigMap, + filePath, + configMapKey, + mode string, + content []byte, +) error { + if existingKey, ok := configMap.keyByFilePath[filePath]; ok { + if !bytes.Equal(configMap.binaryData[existingKey], content) { + return fmt.Errorf("staged file path %q has conflicting content", filePath) + } + + return nil + } + + configMap.binaryData[configMapKey] = content + configMap.keyByFilePath[filePath] = configMapKey + configMap.mounts = append(configMap.mounts, stagedConfigMapMount{ + nodeName: configMap.nodeName, + filePath: filePath, + configMapName: configMap.name, + configMapPath: configMapKey, + mode: mode, + }) + + return nil +} + +func uniqueConfigMapKey(configMap *stagedConfigMap, filePath string) string { + configMapKey := safeConfigMapKey(filePath) + if _, exists := configMap.binaryData[configMapKey]; !exists { + return configMapKey + } + + digest := sha256.Sum256([]byte(filePath)) + for idx := 0; ; idx++ { + candidate := safeKubernetesName( + configMapKey, + hex.EncodeToString(digest[:])[0:7], + fmt.Sprintf("%d", idx), + ) + if _, exists := configMap.binaryData[candidate]; !exists { + return candidate + } + } +} + +func collectStagedConfigMaps(configMapGroups ...map[string]*stagedConfigMap) []stagedConfigMap { + configMaps := []stagedConfigMap{} + + for _, configMapGroup := range configMapGroups { + nodeNames := make([]string, 0, len(configMapGroup)) + for nodeName := range configMapGroup { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + for _, nodeName := range nodeNames { + configMap := configMapGroup[nodeName] + sort.Slice(configMap.mounts, func(i, j int) bool { + return configMap.mounts[i].filePath < configMap.mounts[j].filePath + }) + configMaps = append(configMaps, *configMap) + } + } + + return configMaps +} + +func setTopologyFilesFromConfigMaps( + topology *unstructured.Unstructured, + configMaps []stagedConfigMap, +) error { + if len(configMaps) == 0 { + return nil + } + + filesFromConfigMap := map[string]any{} + for _, configMap := range configMaps { + for _, mount := range configMap.mounts { + nodeFiles, _ := filesFromConfigMap[mount.nodeName].([]any) + nodeFiles = append(nodeFiles, map[string]any{ + "filePath": mount.filePath, + "configMapName": mount.configMapName, + "configMapPath": mount.configMapPath, + "mode": mount.mode, + }) + filesFromConfigMap[mount.nodeName] = nodeFiles + } + } + + deployment, found, err := unstructured.NestedMap(topology.Object, "spec", "deployment") + if err != nil { + return err + } + if !found { + deployment = map[string]any{} + } + + deployment["filesFromConfigMap"] = filesFromConfigMap + + return unstructured.SetNestedMap(topology.Object, deployment, "spec", "deployment") +} + +func (r *Runtime) applyStagedConfigMaps( + ctx context.Context, + namespace string, + topologyName string, + configMaps []stagedConfigMap, +) error { + for _, staged := range configMaps { + configMap := stagedConfigMapObject(namespace, topologyName, staged, nil) + + created, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Create(ctx, configMap, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + existing, getErr := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(ctx, staged.name, metav1.GetOptions{}) + if getErr != nil { + return fmt.Errorf("failed to get existing staged ConfigMap %s/%s: %w", + namespace, + staged.name, + getErr, + ) + } + + updated := existing.DeepCopy() + updated.Labels = mergeDesiredMetadata(existing.Labels, configMap.Labels) + updated.Data = configMap.Data + updated.BinaryData = configMap.BinaryData + if stagedConfigMapsConform(existing, updated) { + created = existing + err = nil + } else { + created, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Update(ctx, updated, metav1.UpdateOptions{}) + } + } + if err != nil { + return fmt.Errorf("failed to apply staged ConfigMap %s/%s: %w", + namespace, + staged.name, + err, + ) + } + + _ = created + } + + return nil +} + +func stagedConfigMapsConform(existing, desired *corev1.ConfigMap) bool { + return apiequality.Semantic.DeepEqual(existing.Labels, desired.Labels) && + apiequality.Semantic.DeepEqual(existing.Data, desired.Data) && + apiequality.Semantic.DeepEqual(existing.BinaryData, desired.BinaryData) +} + +func stagedConfigMapObject( + namespace string, + topologyName string, + staged stagedConfigMap, + ownerReferences []metav1.OwnerReference, +) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: staged.name, + Namespace: namespace, + OwnerReferences: ownerReferences, + Labels: map[string]string{ + labelApp: clabernetesAppValue, + labelTopologyOwner: topologyName, + labelTopologyNode: staged.nodeName, + }, + }, + BinaryData: staged.binaryData, + } +} + +func (r *Runtime) setStagedConfigMapNodeOwnerReferences( + ctx context.Context, + namespace string, + configMaps []stagedConfigMap, + nodes map[string]*unstructured.Unstructured, +) error { + if len(configMaps) == 0 { + return nil + } + + for _, staged := range configMaps { + node := nodes[staged.nodeName] + if node == nil { + return fmt.Errorf("failed to find c9s node %s/%s for staged ConfigMap ownership", + namespace, staged.nodeName) + } + ownerReferences := []metav1.OwnerReference{ + { + APIVersion: c9sAPIVersion, + Kind: "Node", + Name: node.GetName(), + UID: node.GetUID(), + }, + } + + configMap, err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Get(ctx, staged.name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + configMap = stagedConfigMapObject( + namespace, + node.GetLabels()[labelTopologyOwner], + staged, + ownerReferences, + ) + if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Create(ctx, configMap, metav1.CreateOptions{}); err != nil { + return fmt.Errorf( + "failed to recreate staged ConfigMap %s/%s with owner references: %w", + namespace, + staged.name, + err, + ) + } + + continue + } + if err != nil { + return fmt.Errorf("failed to get staged ConfigMap %s/%s for owner update: %w", + namespace, + staged.name, + err, + ) + } + + if apiequality.Semantic.DeepEqual(configMap.OwnerReferences, ownerReferences) { + continue + } + configMap.OwnerReferences = ownerReferences + + if _, err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Update(ctx, configMap, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update staged ConfigMap %s/%s owner references: %w", + namespace, + staged.name, + err, + ) + } + } + + return nil +} + +func (r *Runtime) deleteStagedConfigMaps( + ctx context.Context, + namespace string, + configMaps []stagedConfigMap, +) { + for _, staged := range configMaps { + err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, staged.name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + log.Debug("failed to delete staged clabernetes ConfigMap", + "namespace", namespace, + "name", staged.name, + "error", err, + ) + } + } +} + +func (r *Runtime) deleteStaleConfigMaps( + ctx context.Context, + namespace, + topologyName string, + desired []stagedConfigMap, +) error { + desiredNames := make(map[string]struct{}, len(desired)) + for _, staged := range desired { + desiredNames[staged.name] = struct{}{} + } + + configMaps, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: labelTopologyOwner + "=" + topologyName}, + ) + if err != nil { + return fmt.Errorf("failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, topologyName, err) + } + + for idx := range configMaps.Items { + name := configMaps.Items[idx].Name + if _, keep := desiredNames[name]; keep { + continue + } + if err := r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete stale staged ConfigMap %s/%s: %w", + namespace, name, err) + } + } + + return nil +} + +func safeConfigMapKey(filePath string) string { + parts := strings.FieldsFunc(filepath.ToSlash(filePath), func(r rune) bool { + return r == '/' || r == '\\' + }) + if len(parts) == 0 { + return "file" + } + + return safeKubernetesName(parts...) +} + +func safeKubernetesName(parts ...string) string { + name := strings.Join(parts, "-") + if len(name) > kubernetesNameMaxLen { + digest := sha256.Sum256([]byte(name)) + name = name[0:kubernetesNameMaxLen-8] + "-" + hex.EncodeToString(digest[:])[0:7] + } + + name = strings.ToLower(name) + name = invalidDNSLabelChars.ReplaceAllString(name, "-") + name = startsWithNonAlpha.ReplaceAllString(name, "z") + name = endsWithNonAlpha.ReplaceAllString(name, "z") + + return name +} diff --git a/labruntime/clabernetes/iface_stats.go b/labruntime/clabernetes/iface_stats.go new file mode 100644 index 0000000000..25e06e4967 --- /dev/null +++ b/labruntime/clabernetes/iface_stats.go @@ -0,0 +1,260 @@ +package clabernetes + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (r *Runtime) pollInterfaceStats( + ctx context.Context, + namespace string, + interval time.Duration, + eventSink chan<- clablabruntime.Event, +) { + if interval <= 0 { + interval = time.Second + } + + samples := map[string]c9sIfaceStatsSample{} + + sample := func() { + states, err := r.List(ctx, clablabruntime.ListRequest{ + Namespace: namespace, + AllNamespaces: namespace == metav1.NamespaceAll, + }) + if err != nil { + log.Debug("failed to list clabernetes topologies for interface stats", "error", err) + return + } + + now := time.Now() + for _, state := range states { + for _, node := range state.Nodes { + if !node.Ready { + continue + } + + pod, err := r.launcherPod(ctx, state.Name, state.Namespace, node.Name) + if err != nil { + log.Debug("failed to resolve clabernetes launcher pod for interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + containerName, err := r.nestedContainerName(ctx, pod, node.Name) + if err != nil { + log.Debug("failed to resolve nested container for interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + + continue + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"docker", "exec", containerName, "cat", "/proc/net/dev"}) + if err != nil { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + if rc != 0 { + log.Debug("failed to collect clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "rc", rc, + "stderr", strings.TrimSpace(string(stderr)), + ) + continue + } + + stats, err := parseProcNetDev(stdout) + if err != nil { + log.Debug("failed to parse clabernetes interface stats", + "namespace", state.Namespace, + "lab", state.Name, + "node", node.Name, + "error", err, + ) + continue + } + + for _, stat := range stats { + key := c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name) + current := c9sIfaceStatsSample{ + Stats: stat, + Timestamp: now, + } + + if previous, ok := samples[key]; ok { + event := c9sIfaceStatsEvent(state, node, pod, stat, previous, current) + r.sendEvent(ctx, eventSink, event) + } + + samples[key] = current + } + } + } + } + + sample() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sample() + } + } +} + +type c9sIfaceStats struct { + Name string + RxBytes uint64 + RxPackets uint64 + TxBytes uint64 + TxPackets uint64 +} + +type c9sIfaceStatsSample struct { + Stats c9sIfaceStats + Timestamp time.Time +} + +func parseProcNetDev(data []byte) ([]c9sIfaceStats, error) { + lines := strings.Split(string(data), "\n") + stats := make([]c9sIfaceStats, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, ":") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + + ifName := strings.TrimSpace(parts[0]) + fields := strings.Fields(parts[1]) + if len(fields) < 16 { + return nil, fmt.Errorf("unexpected /proc/net/dev line for %q: %q", ifName, line) + } + + rxBytes, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx bytes for %q: %w", ifName, err) + } + rxPackets, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse rx packets for %q: %w", ifName, err) + } + txBytes, err := strconv.ParseUint(fields[8], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx bytes for %q: %w", ifName, err) + } + txPackets, err := strconv.ParseUint(fields[9], 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse tx packets for %q: %w", ifName, err) + } + + stats = append(stats, c9sIfaceStats{ + Name: ifName, + RxBytes: rxBytes, + RxPackets: rxPackets, + TxBytes: txBytes, + TxPackets: txPackets, + }) + } + + return stats, nil +} + +func c9sIfaceStatsKey(namespace, lab, node, ifName string) string { + return namespace + "/" + lab + "/" + node + "/" + ifName +} + +func c9sIfaceStatsEvent( + state *clablabruntime.LabState, + node clablabruntime.NodeState, + pod *corev1.Pod, + stat c9sIfaceStats, + previous, + current c9sIfaceStatsSample, +) clablabruntime.Event { + interval := current.Timestamp.Sub(previous.Timestamp) + if interval <= 0 { + interval = time.Second + } + + seconds := interval.Seconds() + rxBytesDelta := counterDelta(stat.RxBytes, previous.Stats.RxBytes) + txBytesDelta := counterDelta(stat.TxBytes, previous.Stats.TxBytes) + rxPacketsDelta := counterDelta(stat.RxPackets, previous.Stats.RxPackets) + txPacketsDelta := counterDelta(stat.TxPackets, previous.Stats.TxPackets) + + actorName := fmt.Sprintf("%s-%s", state.Name, node.Name) + podName := "" + if pod != nil { + podName = pod.Name + } + + return clablabruntime.Event{ + Timestamp: current.Timestamp, + Type: "interface", + Action: "stats", + ActorID: c9sIfaceStatsKey(state.Namespace, state.Name, node.Name, stat.Name), + ActorName: actorName, + ActorFullID: podName, + Attributes: map[string]string{ + "namespace": state.Namespace, + "lab": state.Name, + "node": node.Name, + "name": actorName, + "pod": podName, + "ifname": stat.Name, + "origin": "clabernetes", + "rx_bytes": strconv.FormatUint(stat.RxBytes, 10), + "tx_bytes": strconv.FormatUint(stat.TxBytes, 10), + "rx_packets": strconv.FormatUint(stat.RxPackets, 10), + "tx_packets": strconv.FormatUint(stat.TxPackets, 10), + "rx_bps": strconv.FormatFloat(float64(rxBytesDelta*8)/seconds, 'f', -1, 64), + "tx_bps": strconv.FormatFloat(float64(txBytesDelta*8)/seconds, 'f', -1, 64), + "rx_pps": strconv.FormatFloat(float64(rxPacketsDelta)/seconds, 'f', -1, 64), + "tx_pps": strconv.FormatFloat(float64(txPacketsDelta)/seconds, 'f', -1, 64), + "interval_seconds": strconv.FormatFloat(seconds, 'f', -1, 64), + }, + } +} + +func counterDelta(current, previous uint64) uint64 { + if current < previous { + return 0 + } + + return current - previous +} diff --git a/labruntime/clabernetes/image_progress.go b/labruntime/clabernetes/image_progress.go new file mode 100644 index 0000000000..65fb25fece --- /dev/null +++ b/labruntime/clabernetes/image_progress.go @@ -0,0 +1,264 @@ +package clabernetes + +import ( + "context" + "sort" + "strings" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +const launcherLogTailLines int64 = 200 + +type imagePullRequest struct { + name string + node string + image string + kubernetesNode string + complete bool +} + +type imagePullProgress struct { + active map[string]imagePullRequest + listErrorReported bool + launcherListErrorReported bool + launcherLogErrors map[string]struct{} + launcherCopies map[string]launcherImageCopyProgress +} + +type launcherImageLog struct { + podName string + node string + image string + kubernetesNode string + content string +} + +type launcherImageCopyProgress struct { + copying bool + complete bool +} + +func (r *Runtime) imagePullRequests( + ctx context.Context, + namespace string, + state *clablabruntime.LabState, +) ([]imagePullRequest, error) { + labNodes := make(map[string]string, len(state.Nodes)) + for _, node := range state.Nodes { + labNodes[node.Name] = node.Image + } + + list, err := r.client.Resource(imageRequestGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + requests := make([]imagePullRequest, 0, len(list.Items)) + for idx := range list.Items { + request := imagePullRequestFromResource(&list.Items[idx]) + expectedImage, belongsToLab := labNodes[request.node] + if !belongsToLab || request.image != expectedImage { + continue + } + requests = append(requests, request) + } + sort.Slice(requests, func(i, j int) bool { + return requests[i].name < requests[j].name + }) + + return requests, nil +} + +func imagePullRequestFromResource(resource *unstructured.Unstructured) imagePullRequest { + request := imagePullRequest{name: resource.GetName()} + request.node, _, _ = unstructured.NestedString( + resource.Object, "spec", "topologyNodeName", + ) + request.image, _, _ = unstructured.NestedString( + resource.Object, "spec", "requestedImage", + ) + request.kubernetesNode, _, _ = unstructured.NestedString( + resource.Object, "spec", "kubernetesNode", + ) + request.complete, _, _ = unstructured.NestedBool( + resource.Object, "status", "complete", + ) + + return request +} + +func (p *imagePullProgress) report(requests []imagePullRequest) { + p.listErrorReported = false + next := make(map[string]imagePullRequest, len(requests)) + for _, request := range requests { + previous, seen := p.active[request.name] + next[request.name] = request + if !seen { + logImagePullProgress("Pulling clabernetes node image", request) + } + if request.complete && (!seen || !previous.complete) { + logImagePullProgress("Clabernetes node image pull completed", request) + } + } + + for name, request := range p.active { + if _, stillActive := next[name]; stillActive || request.complete { + continue + } + // The controller deletes an ImageRequest after its puller leaves Pending. Usually that + // deletion is observed between polls, before the brief complete status can be listed. + logImagePullProgress("Clabernetes node image pull completed", request) + } + + p.active = next +} + +func (p *imagePullProgress) reportListError(err error) { + if p.listErrorReported { + return + } + log.Debug("Unable to inspect clabernetes image pulls", "error", err) + p.listErrorReported = true +} + +func (p *imagePullProgress) inspectLauncherCopies( + ctx context.Context, + r *Runtime, + namespace string, + state *clablabruntime.LabState, +) { + labNodes := make(map[string]string, len(state.Nodes)) + for _, node := range state.Nodes { + labNodes[node.Name] = node.Image + } + + pods, err := r.kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: state.Name, + }.String(), + }) + if err != nil { + if !p.launcherListErrorReported { + log.Debug("Unable to inspect clabernetes launcher pods", "error", err) + p.launcherListErrorReported = true + } + + return + } + p.launcherListErrorReported = false + sort.Slice(pods.Items, func(i, j int) bool { + return pods.Items[i].Name < pods.Items[j].Name + }) + + for idx := range pods.Items { + pod := &pods.Items[idx] + nodeName := pod.Labels[labelTopologyNode] + image, belongsToLab := labNodes[nodeName] + if !belongsToLab || image == "" || pod.Status.Phase != corev1.PodRunning { + continue + } + + key := string(pod.UID) + if key == "" { + key = pod.Name + } + if p.launcherCopies[key].complete { + continue + } + + content, err := r.kubeClient.CoreV1().Pods(namespace).GetLogs( + pod.Name, + &corev1.PodLogOptions{TailLines: pointerTo(launcherLogTailLines)}, + ).DoRaw(ctx) + if err != nil { + p.reportLauncherLogError(key, pod.Name, err) + continue + } + delete(p.launcherLogErrors, key) + p.reportLauncherLog(launcherImageLog{ + podName: key, + node: nodeName, + image: image, + kubernetesNode: pod.Spec.NodeName, + content: string(content), + }) + } +} + +func (p *imagePullProgress) reportLauncherLog(observation launcherImageLog) { + progress := p.launcherCopies[observation.podName] + fields := imagePullRequest{ + node: observation.node, + image: observation.image, + kubernetesNode: observation.kubernetesNode, + } + + imageQuoted := `image "` + observation.image + `"` + if !progress.copying && strings.Contains( + observation.content, + imageQuoted+" is present, begin copy to docker daemon", + ) { + logImagePullProgress( + "Clabernetes node image already present on Kubernetes node; "+ + "copying to launcher Docker daemon", + fields, + ) + progress.copying = true + } + + if !progress.copying && strings.Contains( + observation.content, + imageQuoted+" is now available on node, continuing", + ) { + logImagePullProgress( + "Copying clabernetes node image to launcher Docker daemon", + fields, + ) + progress.copying = true + } + + if !progress.complete && strings.Contains( + observation.content, + "Loaded image: "+observation.image, + ) { + progress.copying = true + progress.complete = true + } + + if p.launcherCopies == nil { + p.launcherCopies = map[string]launcherImageCopyProgress{} + } + p.launcherCopies[observation.podName] = progress +} + +func (p *imagePullProgress) reportLauncherLogError(key, podName string, err error) { + if _, reported := p.launcherLogErrors[key]; reported { + return + } + log.Debug("Unable to inspect clabernetes launcher logs", "pod", podName, "error", err) + if p.launcherLogErrors == nil { + p.launcherLogErrors = map[string]struct{}{} + } + p.launcherLogErrors[key] = struct{}{} +} + +func pointerTo[T any](value T) *T { + return &value +} + +func logImagePullProgress(message string, request imagePullRequest) { + log.Info( + message, + "node", request.node, + "image", request.image, + "kubernetes-node", request.kubernetesNode, + ) +} diff --git a/labruntime/clabernetes/lifecycle.go b/labruntime/clabernetes/lifecycle.go new file mode 100644 index 0000000000..e3bc77d3fe --- /dev/null +++ b/labruntime/clabernetes/lifecycle.go @@ -0,0 +1,628 @@ +package clabernetes + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (r *Runtime) Deploy( + ctx context.Context, + req clablabruntime.DeployRequest, +) (*clablabruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + if len(req.TopologyDefinition) == 0 { + return nil, fmt.Errorf("rendered containerlab topology is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } + log.Info("Preparing clabernetes lab", "name", req.Name, "namespace", namespace) + topologyResource := r.client.Resource(topologyGVR).Namespace(namespace) + + existingTopology, err := topologyResource.Get(ctx, req.Name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + existingTopology = nil + // Expected for the primary Node/Link path. + case err != nil: + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + primitiveExists, err := r.primitiveLabExists(ctx, req.Name, namespace) + if err != nil { + return nil, err + } + + prepared, err := prepareDesiredDeployment(req, namespace) + if err != nil { + return nil, err + } + desiredTopology := prepared.topology + stagedConfigMaps := prepared.configMaps + primitives := prepared.primitives + log.Info( + "Staging clabernetes lab artifacts", + "name", req.Name, + "namespace", namespace, + "config-maps", len(stagedConfigMaps), + ) + + // Compatibility Topologies are still supported for labs created by older versions. Keep + // their controller ownership intact and reconcile the definition in place. New labs and + // current primitive labs are reconciled directly through Node, Link, and LauncherProfile. + if existingTopology != nil { + return r.reconcileCompatibilityTopology( + ctx, + req, + namespace, + desiredTopology, + primitives, + stagedConfigMaps, + ) + } + + managedNamespace := req.Namespace == "" && r.labNamespaceOverride == "" + namespaceCreated, err := r.ensureLabNamespace( + ctx, + req.Name, + namespace, + managedNamespace, + ) + if err != nil { + return nil, err + } + cleanupNamespace := func() { + if namespaceCreated { + _, _ = r.deleteManagedLabNamespace(ctx, req.Name, namespace) + } + } + + if err = r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + cleanupNamespace() + + return nil, err + } + + operation := "Creating" + if primitiveExists { + operation = "Reconciling" + } + log.Info( + operation+" clabernetes lab resources", + "name", req.Name, + "namespace", namespace, + "nodes", len(primitives.nodes), + "links", len(primitives.links), + ) + appliedNodes, createdNodes, createdResources, err := r.reconcilePrimitiveResources( + ctx, + namespace, + req.Name, + primitives, + ) + if err != nil { + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + + return nil, err + } + + if err = r.setStagedConfigMapNodeOwnerReferences( + ctx, + namespace, + stagedConfigMaps, + appliedNodes, + ); err != nil { + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + + return nil, err + } + if err = r.deleteStaleConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + if req.Wait { + if err = r.waitPrimitiveLinksResolved( + ctx, + namespace, + primitives.links, + req.Timeout, + ); err != nil { + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + + return nil, err + } + } + + if err = r.enablePrimitiveNodeDeployments(ctx, namespace, createdNodes); err != nil { + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + + return nil, err + } + + if !req.Wait { + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + } + + if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + // A fresh deployment is transactional: timeout/failure removes only resources this + // operation created. A failed reconciliation retains the existing lab for diagnosis and + // recovery because rolling it back would destroy prior working state. + if !primitiveExists { + r.deleteCreatedPrimitiveResources(ctx, namespace, createdResources) + r.deleteStagedConfigMaps(ctx, namespace, stagedConfigMaps) + cleanupNamespace() + } + + return nil, err + } + + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) +} + +func (r *Runtime) Destroy(ctx context.Context, req clablabruntime.DestroyRequest) error { + if req.Name == "" { + return fmt.Errorf("topology name is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return err + } + selector := labels.Set{labelTopologyOwner: req.Name}.String() + + log.Info("Deleting clabernetes lab resources", "name", req.Name, "namespace", namespace) + + var deleteErrors []error + // Delete the compatibility owner first when present so it cannot recreate compiler output + // while the primitive resources are being removed. + err = r.client.Resource(topologyGVR).Namespace(namespace). + Delete(ctx, req.Name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete compatibility clabernetes topology %s/%s: %w", + namespace, req.Name, err)) + } + + for _, gvr := range []struct { + name string + gvr schema.GroupVersionResource + }{ + {name: "nodes", gvr: nodeGVR}, + {name: "links", gvr: linkGVR}, + {name: "launcher profiles", gvr: launcherProfileGVR}, + } { + list, listErr := r.primitiveResourcesForTopology(ctx, gvr.gvr, req.Name, namespace) + if listErr != nil { + deleteErrors = append(deleteErrors, listErr) + continue + } + for idx := range list.Items { + err = r.client.Resource(gvr.gvr).Namespace(namespace). + Delete(ctx, list.Items[idx].GetName(), metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete c9s %s %s/%s: %w", + gvr.name, namespace, list.Items[idx].GetName(), err)) + } + } + } + + configMaps, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: selector}, + ) + if err != nil { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, req.Name, err)) + } else { + for idx := range configMaps.Items { + err = r.kubeClient.CoreV1().ConfigMaps(namespace). + Delete(ctx, configMaps.Items[idx].Name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete staged ConfigMap %s/%s: %w", + namespace, configMaps.Items[idx].Name, err)) + } + } + } + + if len(deleteErrors) != 0 { + return errors.Join(deleteErrors...) + } + + if req.Wait { + if err := r.waitDeleted(ctx, req.Name, namespace, req.Timeout); err != nil { + return err + } + } + + namespaceDeleted, err := r.deleteManagedLabNamespace(ctx, req.Name, namespace) + if err != nil { + return err + } + if !req.Wait || !namespaceDeleted { + return nil + } + + return r.waitNamespaceDeleted(ctx, namespace, req.Timeout) +} + +func (r *Runtime) Inspect( + ctx context.Context, + req clablabruntime.InspectRequest, +) (*clablabruntime.LabState, error) { + if req.Name == "" { + return nil, fmt.Errorf("topology name is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } + obj, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to inspect clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + var state *clablabruntime.LabState + if err == nil { + state = stateFromTopology(obj, namespace) + } else { + nodes, listErr := r.nodesForTopology(ctx, req.Name, namespace) + if listErr != nil { + return nil, listErr + } + if len(nodes.Items) == 0 { + return nil, fmt.Errorf("c9s lab %s/%s was not found", namespace, req.Name) + } + state = stateFromNodeResources(req.Name, namespace, nodes.Items) + } + + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes lab state", "error", err) + } + + return state, nil +} + +func (r *Runtime) LabExists( + ctx context.Context, + req clablabruntime.InspectRequest, +) (bool, error) { + if req.Name == "" { + return false, fmt.Errorf("topology name is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return false, err + } + _, err = r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + switch { + case err == nil: + return true, nil + case !apierrors.IsNotFound(err): + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + return r.primitiveLabExists(ctx, req.Name, namespace) +} + +func (r *Runtime) List( + ctx context.Context, + req clablabruntime.ListRequest, +) ([]*clablabruntime.LabState, error) { + namespace := r.namespaceFor(req.Namespace) + if req.AllNamespaces { + namespace = metav1.NamespaceAll + } + + topologyList, err := r.client.Resource(topologyGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes topologies: %w", err) + } + + statesByLab := make(map[string]*clablabruntime.LabState, len(topologyList.Items)) + for idx := range topologyList.Items { + state := stateFromTopology(&topologyList.Items[idx], namespace) + statesByLab[state.Namespace+"/"+state.Name] = state + } + + nodeList, err := r.client.Resource(nodeGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelTopologyOwner, + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s nodes: %w", err) + } + + nodesByLab := map[string][]unstructured.Unstructured{} + for idx := range nodeList.Items { + node := nodeList.Items[idx] + labName := node.GetLabels()[labelTopologyOwner] + if labName == "" { + continue + } + key := node.GetNamespace() + "/" + labName + nodesByLab[key] = append(nodesByLab[key], node) + } + for key, nodes := range nodesByLab { + if _, exists := statesByLab[key]; exists { + continue + } + statesByLab[key] = stateFromNodeResources( + nodes[0].GetLabels()[labelTopologyOwner], + nodes[0].GetNamespace(), + nodes, + ) + } + + states := make([]*clablabruntime.LabState, 0, len(statesByLab)) + for _, state := range statesByLab { + if err := r.enrichState(ctx, state); err != nil { + log.Debug("failed to enrich clabernetes lab state", + "name", state.Name, + "namespace", state.Namespace, + "error", err, + ) + } + states = append(states, state) + } + + sort.Slice(states, func(i, j int) bool { + if states[i].Namespace == states[j].Namespace { + return states[i].Name < states[j].Name + } + return states[i].Namespace < states[j].Namespace + }) + + return states, nil +} + +func (r *Runtime) waitReady( + ctx context.Context, + name, namespace string, + timeout time.Duration, +) error { + effectiveTimeout := r.timeoutFor(timeout) + waitCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + + var lastState *clablabruntime.LabState + readinessProgress := nodeReadinessProgress{} + imageProgress := imagePullProgress{} + log.Info("Waiting for clabernetes lab to become ready", "name", name, "namespace", namespace) + + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + state, err := r.Inspect(ctx, clablabruntime.InspectRequest{ + Name: name, + Namespace: namespace, + }) + if err != nil { + // client-go's rate limiter can refuse a request shortly before the context + // expires with "would exceed context deadline". Let the poller return the + // authoritative timeout instead of masking it with that implementation detail. + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, fmt.Errorf("failed to inspect clabernetes lab %s/%s: %w", + namespace, name, err) + } + lastState = state + imageRequests, imageErr := r.imagePullRequests(ctx, namespace, state) + if imageErr != nil { + imageProgress.reportListError(imageErr) + } else { + imageProgress.report(imageRequests) + } + imageProgress.inspectLauncherCopies(ctx, r, namespace, state) + readinessProgress.report(state) + + if state.Ready { + return true, nil + } + if state.State == "deployfailed" { + return false, fmt.Errorf("clabernetes topology %s/%s reported deployfailed", + namespace, name) + } + + log.Debug("Waiting for clabernetes lab", + "name", name, + "namespace", namespace, + "state", state.State, + ) + + return false, nil + }) + if err == nil { + log.Info("Clabernetes lab is ready", "name", name, "namespace", namespace) + + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + pendingNodes := make([]string, 0) + if lastState != nil { + for _, node := range lastState.Nodes { + if node.Ready { + continue + } + state := node.State + if state == "" { + state = "unknown" + } + pendingNodes = append(pendingNodes, fmt.Sprintf("%s (%s)", node.Name, state)) + } + } + + if len(pendingNodes) == 0 { + return fmt.Errorf( + "timed out after %s waiting for clabernetes lab %s/%s to become ready", + effectiveTimeout, + namespace, + name, + ) + } + + return fmt.Errorf( + "timed out after %s waiting for clabernetes lab %s/%s to become ready; "+ + "pending nodes: %s", + effectiveTimeout, + namespace, + name, + strings.Join(pendingNodes, ", "), + ) +} + +type trackedNodeReadiness struct { + state string + ready bool +} + +type nodeReadinessProgress struct { + nodes map[string]trackedNodeReadiness +} + +func (p *nodeReadinessProgress) report(state *clablabruntime.LabState) { + if state == nil { + return + } + + readyCount := 0 + for _, node := range state.Nodes { + if node.Ready { + readyCount++ + } + } + + next := make(map[string]trackedNodeReadiness, len(state.Nodes)) + for _, node := range state.Nodes { + current := trackedNodeReadiness{state: node.State, ready: node.Ready} + previous, seen := p.nodes[node.Name] + next[node.Name] = current + + // The initial non-ready snapshot is summarized by the wait message. Report nodes that + // are already ready on the first poll, and every meaningful transition after that. + if !node.Ready && (!seen || previous == current) { + continue + } + if seen && previous == current { + continue + } + + if node.Ready { + log.Info( + "Clabernetes node is ready", + "node", node.Name, + "ready", readyCount, + "total", len(state.Nodes), + ) + continue + } + + nodeState := node.State + if nodeState == "" { + nodeState = "unknown" + } + log.Info( + "Clabernetes node is not ready", + "node", node.Name, + "state", nodeState, + "ready", readyCount, + "total", len(state.Nodes), + ) + } + + p.nodes = next +} + +func contextDeadlineIsImminent(ctx context.Context) bool { + deadline, ok := ctx.Deadline() + + return ok && time.Until(deadline) <= pollInterval +} + +func (r *Runtime) waitDeleted( + ctx context.Context, + name, namespace string, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + _, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, name, metav1.GetOptions{}) + switch { + case err != nil: + if apierrors.IsNotFound(err) { + break + } + return false, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + default: + return false, nil + } + + for _, gvr := range []schema.GroupVersionResource{ + nodeGVR, + linkGVR, + launcherProfileGVR, + } { + list, err := r.primitiveResourcesForTopology(ctx, gvr, name, namespace) + if err != nil { + return false, err + } + if len(list.Items) != 0 { + return false, nil + } + } + + return true, nil + }) +} diff --git a/labruntime/clabernetes/nodes.go b/labruntime/clabernetes/nodes.go new file mode 100644 index 0000000000..d48531d0a7 --- /dev/null +++ b/labruntime/clabernetes/nodes.go @@ -0,0 +1,411 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + "time" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (r *Runtime) Start(ctx context.Context, req clablabruntime.NodeRequest) error { + return r.setNodesReplicas(ctx, req, 1) +} + +func (r *Runtime) Stop(ctx context.Context, req clablabruntime.NodeRequest) error { + return r.setNodesReplicas(ctx, req, 0) +} + +func (r *Runtime) Restart(ctx context.Context, req clablabruntime.NodeRequest) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + launchers, err := r.launcherNodeNames(ctx, req.Name, namespace, targets) + if err != nil { + return err + } + launcherNodes := uniqueLauncherNodes(targets, launchers) + + now := time.Now().UTC().Format(time.RFC3339) + for _, nodeName := range launcherNodes { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + if deployment.Spec.Template.ObjectMeta.Annotations == nil { + deployment.Spec.Template.ObjectMeta.Annotations = map[string]string{} + } + deployment.Spec.Template.ObjectMeta.Annotations[restartedAtAnnotation] = now + + if deployment.Spec.Replicas != nil && *deployment.Spec.Replicas == 0 { + replicas := int32(1) + deployment.Spec.Replicas = &replicas + } + + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to restart clabernetes node %s/%s/%s: %w", + namespace, req.Name, nodeName, err) + } + + if err := r.waitDeploymentReplicas( + ctx, + namespace, + deployment.Name, + 1, + req.Timeout, + ); err != nil { + return err + } + } + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile(ctx, req.Name, namespace, nodeName, false); err != nil { + return err + } + } + + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) +} + +func (r *Runtime) targetNodes( + ctx context.Context, + req clablabruntime.NodeRequest, +) ([]string, string, error) { + if req.Name == "" { + return nil, "", fmt.Errorf("topology name is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, "", err + } + nodes, err := r.nodesForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, "", err + } + + known := map[string]struct{}{} + for idx := range nodes.Items { + known[nodes.Items[idx].GetName()] = struct{}{} + } + + if len(known) == 0 { + deployments, err := r.deploymentsForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, "", err + } + for idx := range deployments.Items { + nodeName := deployments.Items[idx].Labels[labelTopologyNode] + if nodeName != "" { + known[nodeName] = struct{}{} + } + } + if len(known) == 0 { + state, err := r.Inspect( + ctx, + clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}, + ) + if err != nil { + return nil, "", err + } + for _, node := range state.Nodes { + known[node.Name] = struct{}{} + } + } + } + + if len(known) == 0 { + return nil, "", fmt.Errorf("topology %s/%s has no nodes", namespace, req.Name) + } + + var targets []string + if len(req.Nodes) == 0 { + targets = make([]string, 0, len(known)) + for nodeName := range known { + targets = append(targets, nodeName) + } + sort.Strings(targets) + + return targets, namespace, nil + } + + for _, nodeName := range req.Nodes { + if _, ok := known[nodeName]; !ok { + return nil, "", fmt.Errorf("node %q was not found in topology %s/%s", + nodeName, namespace, req.Name) + } + targets = append(targets, nodeName) + } + + return targets, namespace, nil +} + +func (r *Runtime) setNodesReplicas( + ctx context.Context, + req clablabruntime.NodeRequest, + replicas int32, +) error { + targets, namespace, err := r.targetNodes(ctx, req) + if err != nil { + return err + } + launchers, err := r.launcherNodeNames(ctx, req.Name, namespace, targets) + if err != nil { + return err + } + launcherNodes := uniqueLauncherNodes(targets, launchers) + + if replicas == 0 { + if err := r.setTopologyIgnoreReconcile(ctx, req.Name, namespace, true); err != nil { + return err + } + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile( + ctx, + req.Name, + namespace, + nodeName, + true, + ); err != nil { + return err + } + } + } + + for _, nodeName := range launcherNodes { + deployment, err := r.deploymentForNode(ctx, req.Name, namespace, nodeName) + if err != nil { + return err + } + + deployment.Spec.Replicas = &replicas + _, err = r.kubeClient.AppsV1().Deployments(namespace). + Update(ctx, deployment, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to set clabernetes node %s/%s/%s replicas to %d: %w", + namespace, req.Name, nodeName, replicas, err) + } + + if err := r.waitDeploymentReplicas( + ctx, + namespace, + deployment.Name, + replicas, + req.Timeout, + ); err != nil { + return err + } + } + + if replicas > 0 { + for _, nodeName := range launcherNodes { + if err := r.setNodeIgnoreReconcile( + ctx, + req.Name, + namespace, + nodeName, + false, + ); err != nil { + return err + } + } + + return r.clearIgnoreWhenAllStarted(ctx, req.Name, namespace) + } + + return nil +} + +func (r *Runtime) setNodeIgnoreReconcile( + ctx context.Context, + topologyName, + namespace, + nodeName string, + enabled bool, +) error { + namespace = r.namespaceFor(namespace) + resource := r.client.Resource(nodeGVR).Namespace(namespace) + + node, err := resource.Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("failed to get c9s node %s/%s/%s: %w", + namespace, topologyName, nodeName, err) + } + + labelsMap := node.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + if enabled { + labelsMap[labelIgnoreReconcile] = "true" + } else { + delete(labelsMap, labelIgnoreReconcile) + } + node.SetLabels(labelsMap) + + if _, err = resource.Update(ctx, node, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update c9s node %s/%s/%s labels: %w", + namespace, topologyName, nodeName, err) + } + + return nil +} + +func (r *Runtime) clearIgnoreWhenAllStarted(ctx context.Context, name, namespace string) error { + deployments, err := r.deploymentsForTopology(ctx, name, namespace) + if err != nil { + return err + } + + for idx := range deployments.Items { + replicas := int32(1) + if deployments.Items[idx].Spec.Replicas != nil { + replicas = *deployments.Items[idx].Spec.Replicas + } + if replicas == 0 { + return nil + } + } + + return r.setTopologyIgnoreReconcile(ctx, name, namespace, false) +} + +func (r *Runtime) setTopologyIgnoreReconcile( + ctx context.Context, + name, + namespace string, + enabled bool, +) error { + if name == "" { + return fmt.Errorf("topology name is required") + } + + namespace = r.namespaceFor(namespace) + resource := r.client.Resource(topologyGVR).Namespace(namespace) + + obj, err := resource.Get(ctx, name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + // Primary Node/Link labs deliberately have no compatibility Topology object. + return nil + } + if err != nil { + return fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, name, err) + } + + labelsMap := obj.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + + if enabled { + labelsMap[labelIgnoreReconcile] = "true" + } else { + delete(labelsMap, labelIgnoreReconcile) + } + + obj.SetLabels(labelsMap) + + _, err = resource.Update(ctx, obj, metav1.UpdateOptions{}) + if err != nil { + return fmt.Errorf("failed to update clabernetes topology %s/%s labels: %w", + namespace, name, err) + } + + return nil +} + +func (r *Runtime) waitDeploymentReplicas( + ctx context.Context, + namespace, + name string, + replicas int32, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + return wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + deployment, err := r.kubeClient.AppsV1().Deployments(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, fmt.Errorf("failed to get clabernetes deployment %s/%s: %w", + namespace, name, err) + } + + if replicas == 0 { + return deployment.Status.Replicas == 0 && + deployment.Status.AvailableReplicas == 0, nil + } + + return deployment.Status.ReadyReplicas >= replicas && + deployment.Status.AvailableReplicas >= replicas, nil + }) +} + +func (r *Runtime) deploymentsForTopology( + ctx context.Context, + name, + namespace string, +) (*appsv1.DeploymentList, error) { + namespace = r.namespaceFor(namespace) + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployments for topology %s/%s: %w", + namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) deploymentForNode( + ctx context.Context, + name, + namespace, + nodeName string, +) (*appsv1.Deployment, error) { + namespace = r.namespaceFor(namespace) + launchers, err := r.launcherNodeNames(ctx, name, namespace, []string{nodeName}) + if err != nil { + return nil, err + } + launcherNode := launchers[nodeName] + + list, err := r.kubeClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: name, + labelTopologyNode: launcherNode, + }.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list clabernetes deployment for node %s/%s/%s: %w", + namespace, name, nodeName, err) + } + if len(list.Items) == 0 { + return nil, fmt.Errorf("clabernetes deployment for node %s/%s/%s was not found", + namespace, name, nodeName) + } + if len(list.Items) > 1 { + return nil, fmt.Errorf( + "expected exactly one clabernetes deployment for node %s/%s/%s, found %d", + namespace, name, nodeName, len(list.Items)) + } + + return &list.Items[0], nil +} diff --git a/labruntime/clabernetes/plan.go b/labruntime/clabernetes/plan.go new file mode 100644 index 0000000000..61b84b6df6 --- /dev/null +++ b/labruntime/clabernetes/plan.go @@ -0,0 +1,312 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type preparedDeployment struct { + topology *unstructured.Unstructured + configMaps []stagedConfigMap + primitives *primitiveResourceSet +} + +func prepareDesiredDeployment( + req clablabruntime.DeployRequest, + namespace string, +) (*preparedDeployment, error) { + topologyDefinition, stagedConfigMaps, naming, err := stageTopologyLocalFiles(req) + if err != nil { + return nil, err + } + + desiredTopology := topologyObject( + req.Name, + namespace, + req.Owner, + string(topologyDefinition), + topologyWithNaming(naming), + ) + if err := setTopologyFilesFromConfigMaps(desiredTopology, stagedConfigMaps); err != nil { + return nil, err + } + + primitives, err := compilePrimitiveResources(desiredTopology) + if err != nil { + return nil, err + } + + return &preparedDeployment{ + topology: desiredTopology, + configMaps: stagedConfigMaps, + primitives: primitives, + }, nil +} + +// Validate compiles the containerlab/c9s subset and stages all local-path inputs in memory. +// Lossy-but-deployable fields produce warnings; structurally impossible constructs remain +// errors. Validation deliberately performs no Kubernetes reads or writes. +func (r *Runtime) Validate(_ context.Context, req clablabruntime.DeployRequest) error { + if req.Name == "" { + return fmt.Errorf("topology name is required") + } + if len(req.TopologyDefinition) == 0 { + return fmt.Errorf("rendered containerlab topology is required") + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return err + } + + _, err = prepareDesiredDeployment(req, namespace) + + return err +} + +// Plan compiles the c9s topology and returns the complete primitive/configuration diff against +// the running cluster without changing any resource. +func (r *Runtime) Plan( + ctx context.Context, + req clablabruntime.DeployRequest, +) (*clablabruntime.DeployPlan, error) { + if err := r.Validate(ctx, req); err != nil { + return nil, err + } + + namespace, err := r.namespaceForLab(req.Name, req.Namespace) + if err != nil { + return nil, err + } + prepared, err := prepareDesiredDeployment(req, namespace) + if err != nil { + return nil, err + } + + plan := &clablabruntime.DeployPlan{ + LabName: req.Name, Namespace: namespace, Changes: []clablabruntime.ResourceChange{}, + } + namespaceExists, err := r.planNamespace(ctx, req, namespace, plan) + if err != nil { + return nil, err + } + if !namespaceExists { + appendAllDesiredCreates(plan, namespace, prepared) + sortDeployPlan(plan) + + return plan, nil + } + + existingTopology, err := r.client.Resource(topologyGVR).Namespace(namespace). + Get(ctx, req.Name, metav1.GetOptions{}) + if err == nil { + updated := reconciledPrimitiveObject(existingTopology, prepared.topology, false) + if !primitiveObjectsConform(existingTopology, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, "Topology", namespace, req.Name) + } + if err := r.planConfigMaps(ctx, namespace, req.Name, prepared.configMaps, plan); err != nil { + return nil, err + } + sortDeployPlan(plan) + + return plan, nil + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if err := r.planPrimitiveResources(ctx, namespace, req.Name, prepared.primitives, plan); err != nil { + return nil, err + } + if err := r.planConfigMaps(ctx, namespace, req.Name, prepared.configMaps, plan); err != nil { + return nil, err + } + + sortDeployPlan(plan) + + return plan, nil +} + +func (r *Runtime) planNamespace( + ctx context.Context, + req clablabruntime.DeployRequest, + namespace string, + plan *clablabruntime.DeployPlan, +) (bool, error) { + existing, err := r.kubeClient.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + managed := req.Namespace == "" && r.labNamespaceOverride == "" + if err == nil { + if owner := existing.Labels[labelTopologyOwner]; managed && owner != "" && owner != req.Name { + return false, fmt.Errorf("c9s namespace %q belongs to lab %q, not %q", + namespace, owner, req.Name) + } + + return true, nil + } + if !apierrors.IsNotFound(err) { + return false, fmt.Errorf("failed to get c9s namespace %q: %w", namespace, err) + } + if !managed { + return false, fmt.Errorf( + "c9s namespace override %q does not exist; create it before deploying the lab", + namespace, + ) + } + + appendPlanChange(plan, clablabruntime.ChangeCreate, "Namespace", "", namespace) + + return false, nil +} + +func appendAllDesiredCreates( + plan *clablabruntime.DeployPlan, + namespace string, + prepared *preparedDeployment, +) { + for _, group := range prepared.primitives.groups() { + for _, obj := range group.objects { + appendPlanChange(plan, clablabruntime.ChangeCreate, group.kind, namespace, obj.GetName()) + } + } + for _, configMap := range prepared.configMaps { + appendPlanChange(plan, clablabruntime.ChangeCreate, "ConfigMap", namespace, configMap.name) + } +} + +func (r *Runtime) planPrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, + plan *clablabruntime.DeployPlan, +) error { + existing, err := r.primitiveResourceInventory(ctx, namespace) + if err != nil { + return err + } + if err := validatePrimitiveResourceOwnership(existing, desired, topologyName, namespace); err != nil { + return err + } + + desiredNames := map[schema.GroupVersionResource]map[string]struct{}{} + for _, group := range desired.groups() { + desiredNames[group.gvr] = map[string]struct{}{} + for _, obj := range group.objects { + desiredNames[group.gvr][obj.GetName()] = struct{}{} + actual := existing[group.gvr][obj.GetName()] + if actual == nil { + appendPlanChange(plan, clablabruntime.ChangeCreate, group.kind, namespace, obj.GetName()) + continue + } + + updated := reconciledPrimitiveObject(actual, obj, false) + if !primitiveObjectsConform(actual, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, group.kind, namespace, obj.GetName()) + } + } + } + + for _, group := range []primitiveResourceGroup{ + {gvr: linkGVR, kind: "Link"}, + {gvr: nodeGVR, kind: "Node"}, + {gvr: launcherProfileGVR, kind: "LauncherProfile"}, + } { + for name, actual := range existing[group.gvr] { + if actual.GetLabels()[labelTopologyOwner] != topologyName { + continue + } + if _, keep := desiredNames[group.gvr][name]; !keep { + appendPlanChange(plan, clablabruntime.ChangeDelete, group.kind, namespace, name) + } + } + } + + return nil +} + +func (r *Runtime) planConfigMaps( + ctx context.Context, + namespace, + topologyName string, + desired []stagedConfigMap, + plan *clablabruntime.DeployPlan, +) error { + existingList, err := r.kubeClient.CoreV1().ConfigMaps(namespace).List( + ctx, + metav1.ListOptions{LabelSelector: labels.Set{labelTopologyOwner: topologyName}.String()}, + ) + if err != nil { + return fmt.Errorf("failed to list staged ConfigMaps for c9s lab %s/%s: %w", + namespace, topologyName, err) + } + existing := make(map[string]*corev1.ConfigMap, len(existingList.Items)) + for idx := range existingList.Items { + existing[existingList.Items[idx].Name] = &existingList.Items[idx] + } + + desiredNames := make(map[string]struct{}, len(desired)) + for _, staged := range desired { + desiredNames[staged.name] = struct{}{} + wanted := stagedConfigMapObject(namespace, topologyName, staged, nil) + actual := existing[staged.name] + if actual == nil { + appendPlanChange(plan, clablabruntime.ChangeCreate, "ConfigMap", namespace, staged.name) + continue + } + + updated := actual.DeepCopy() + updated.Labels = mergeDesiredMetadata(actual.Labels, wanted.Labels) + updated.Data = wanted.Data + updated.BinaryData = wanted.BinaryData + if !stagedConfigMapsConform(actual, updated) { + appendPlanChange(plan, clablabruntime.ChangeUpdate, "ConfigMap", namespace, staged.name) + } + } + + for name := range existing { + if _, keep := desiredNames[name]; !keep { + appendPlanChange(plan, clablabruntime.ChangeDelete, "ConfigMap", namespace, name) + } + } + + return nil +} + +func appendPlanChange( + plan *clablabruntime.DeployPlan, + action clablabruntime.ChangeAction, + kind, + namespace, + name string, +) { + plan.Changes = append(plan.Changes, clablabruntime.ResourceChange{ + Action: action, Kind: kind, Namespace: namespace, Name: name, + }) +} + +func sortDeployPlan(plan *clablabruntime.DeployPlan) { + sort.Slice(plan.Changes, func(i, j int) bool { + left, right := plan.Changes[i], plan.Changes[j] + if left.Action != right.Action { + return left.Action < right.Action + } + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + if left.Namespace != right.Namespace { + return left.Namespace < right.Namespace + } + + return left.Name < right.Name + }) +} diff --git a/labruntime/clabernetes/primitives.go b/labruntime/clabernetes/primitives.go new file mode 100644 index 0000000000..b54e6a74e5 --- /dev/null +++ b/labruntime/clabernetes/primitives.go @@ -0,0 +1,188 @@ +package clabernetes + +import ( + "fmt" + + "github.com/charmbracelet/log" + clabernetesapisv1alpha1 "github.com/clabernetes/clabernetes/apis/v1alpha1" + clabernetesconfig "github.com/clabernetes/clabernetes/config" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + clabernetescontrollerstopology "github.com/clabernetes/clabernetes/controllers/topology" + clabconstants "github.com/srl-labs/containerlab/constants" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type primitiveResourceSet struct { + launcherProfiles []*unstructured.Unstructured + links []*unstructured.Unstructured + nodes []*unstructured.Unstructured +} + +type primitiveResourceGroup struct { + gvr schema.GroupVersionResource + kind string + objects []*unstructured.Unstructured +} + +func (s *primitiveResourceSet) groups() []primitiveResourceGroup { + return []primitiveResourceGroup{ + {gvr: launcherProfileGVR, kind: "LauncherProfile", objects: s.launcherProfiles}, + {gvr: linkGVR, kind: "Link", objects: s.links}, + {gvr: nodeGVR, kind: "Node", objects: s.nodes}, + } +} + +// stagePrimitiveNodeDeployments prevents the asynchronous Node controller from creating launcher +// workloads while the Link controller is still binding the complete, already-created wiring set. +func stagePrimitiveNodeDeployments(set *primitiveResourceSet) { + for _, node := range set.nodes { + labels := node.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[clabernetesconstants.LabelDisableDeployments] = "true" + node.SetLabels(labels) + } +} + +// compilePrimitiveResources runs the same compiler and renderer used by c9s' compatibility +// Topology controller, but keeps the Topology in memory. Only the resulting O(1)-per-object +// LauncherProfile, Link, and Node resources are sent to the Kubernetes API server. +func compilePrimitiveResources( + desiredTopology *unstructured.Unstructured, +) (*primitiveResourceSet, error) { + if desiredTopology == nil { + return nil, fmt.Errorf("clabernetes topology is nil") + } + + topology := &clabernetesapisv1alpha1.Topology{} + if err := k8sruntime.DefaultUnstructuredConverter.FromUnstructured( + desiredTopology.Object, + topology, + ); err != nil { + return nil, fmt.Errorf("failed to prepare c9s primitive resources: %w", err) + } + + // In-memory objects do not pass through API-server defaulting. + if topology.Spec.Connectivity == "" { + topology.Spec.Connectivity = string(clabernetesapisv1alpha1.LinkConnectivityVXLAN) + } + + compiled, err := clabernetescontrollerstopology.CompileTopologyWithOptions( + c9sCompileLogger{}, + topology, + clabernetescontrollerstopology.CompileOptions{ + UnsupportedFieldPolicy: clabernetescontrollerstopology.UnsupportedFieldPolicyWarn, + }, + ) + if err != nil { + return nil, fmt.Errorf("failed to compile containerlab topology for c9s: %w", err) + } + + configurePrimitiveReadiness(topology) + + set := &primitiveResourceSet{} + for _, profile := range clabernetescontrollerstopology.RenderLauncherProfiles( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(profile, "LauncherProfile", desiredTopology) + if err != nil { + return nil, err + } + set.launcherProfiles = append(set.launcherProfiles, obj) + } + + for _, link := range clabernetescontrollerstopology.RenderLinks( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(link, "Link", desiredTopology) + if err != nil { + return nil, err + } + set.links = append(set.links, obj) + } + + for _, node := range clabernetescontrollerstopology.RenderNodes( + topology, + compiled, + clabernetesconfig.GetFakeManager, + ) { + obj, err := primitiveObject(node, "Node", desiredTopology) + if err != nil { + return nil, err + } + set.nodes = append(set.nodes, obj) + } + + return set, nil +} + +// In-memory compatibility Topologies do not pass through API-server defaulting. Enable c9s' +// generic nested-container readiness explicitly, without inferring behavior from kinds, images, +// ports, or credentials in containerlab. +func configurePrimitiveReadiness(topology *clabernetesapisv1alpha1.Topology) { + topology.Spec.StatusProbes = clabernetesapisv1alpha1.StatusProbes{Enabled: true} +} + +func primitiveObject( + typedObject any, + kind string, + desiredTopology *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + object, err := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(typedObject) + if err != nil { + return nil, fmt.Errorf("failed to render c9s %s: %w", kind, err) + } + + obj := &unstructured.Unstructured{Object: object} + obj.SetAPIVersion(c9sAPIVersion) + obj.SetKind(kind) + + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[labelRuntime] = clabernetesAppValue + if owner := desiredTopology.GetLabels()[clabconstants.Owner]; owner != "" { + labels[clabconstants.Owner] = owner + } + obj.SetLabels(labels) + + if owner := desiredTopology.GetAnnotations()[clabconstants.Owner]; owner != "" { + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[clabconstants.Owner] = owner + obj.SetAnnotations(annotations) + } + + return obj, nil +} + +// c9sCompileLogger adapts c9s compiler diagnostics to containerlab's logger. +type c9sCompileLogger struct{} + +func (c9sCompileLogger) Debug(message string) { log.Debug(message) } +func (c9sCompileLogger) Debugf(format string, args ...any) { log.Debugf(format, args...) } +func (c9sCompileLogger) Info(message string) { log.Info(message) } +func (c9sCompileLogger) Infof(format string, args ...any) { log.Infof(format, args...) } +func (c9sCompileLogger) Warn(message string) { log.Warn(message) } +func (c9sCompileLogger) Warnf(format string, args ...any) { log.Warnf(format, args...) } +func (c9sCompileLogger) Critical(message string) { log.Error(message) } +func (c9sCompileLogger) Criticalf(format string, args ...any) { log.Errorf(format, args...) } +func (c9sCompileLogger) Fatal(message string) { log.Error(message) } +func (c9sCompileLogger) Fatalf(format string, args ...any) { log.Errorf(format, args...) } +func (c9sCompileLogger) GetName() string { return "containerlab-c9s" } +func (c9sCompileLogger) GetLevel() string { return "info" } +func (c9sCompileLogger) Write(data []byte) (int, error) { + log.Info(string(data)) + + return len(data), nil +} diff --git a/labruntime/clabernetes/reconcile.go b/labruntime/clabernetes/reconcile.go new file mode 100644 index 0000000000..d3e16bda9a --- /dev/null +++ b/labruntime/clabernetes/reconcile.go @@ -0,0 +1,540 @@ +package clabernetes + +import ( + "context" + "errors" + "fmt" + "time" + + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/util/retry" +) + +// reconcilePrimitiveResources converges the directly managed c9s resources on the compiled +// topology. New Nodes are staged until the complete Link set is present, preventing their +// launchers from starting against a partially reconciled wiring set. +func (r *Runtime) reconcilePrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, +) ( + map[string]*unstructured.Unstructured, + map[string]*unstructured.Unstructured, + []createdPrimitiveResource, + error, +) { + existing, err := r.primitiveResourceInventory(ctx, namespace) + if err != nil { + return nil, nil, nil, err + } + if err := validatePrimitiveResourceOwnership(existing, desired, topologyName, namespace); err != nil { + return nil, nil, nil, err + } + + appliedNodes := map[string]*unstructured.Unstructured{} + createdNodes := map[string]*unstructured.Unstructured{} + created := []createdPrimitiveResource{} + + // Profiles must exist before Nodes can resolve them. + for _, profile := range desired.launcherProfiles { + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + launcherProfileGVR, + "LauncherProfile", + profile, + false, + ) + if err != nil { + return nil, nil, created, err + } + if wasCreated { + created = append(created, createdPrimitiveResource{ + gvr: launcherProfileGVR, name: actual.GetName(), + }) + } + } + + // Materialize new Node identities first, but keep their launcher deployments disabled until + // every desired Link has been created or updated. + for _, node := range desired.nodes { + if existing[nodeGVR][node.GetName()] != nil { + continue + } + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + nodeGVR, + "Node", + node, + true, + ) + if err != nil { + return nil, nil, created, err + } + appliedNodes[actual.GetName()] = actual + if wasCreated { + createdNodes[actual.GetName()] = actual + created = append(created, createdPrimitiveResource{gvr: nodeGVR, name: actual.GetName()}) + } + } + + for _, link := range desired.links { + actual, wasCreated, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + linkGVR, + "Link", + link, + false, + ) + if err != nil { + return nil, nil, created, err + } + if wasCreated { + created = append(created, createdPrimitiveResource{gvr: linkGVR, name: actual.GetName()}) + } + } + + // Existing Nodes are updated only after the full desired Link set is present. This also + // clears lifecycle staging/stop labels so deploy converges stopped or interrupted labs. + for _, node := range desired.nodes { + if existing[nodeGVR][node.GetName()] == nil { + continue + } + actual, _, err := r.reconcilePrimitiveResource( + ctx, + namespace, + topologyName, + nodeGVR, + "Node", + node, + false, + ) + if err != nil { + return nil, nil, created, err + } + appliedNodes[actual.GetName()] = actual + } + + if err := r.deleteStalePrimitiveResources(ctx, namespace, topologyName, desired); err != nil { + return nil, nil, created, err + } + + return appliedNodes, createdNodes, created, nil +} + +func (r *Runtime) primitiveResourceInventory( + ctx context.Context, + namespace string, +) (map[schema.GroupVersionResource]map[string]*unstructured.Unstructured, error) { + result := map[schema.GroupVersionResource]map[string]*unstructured.Unstructured{} + for _, gvr := range []schema.GroupVersionResource{launcherProfileGVR, linkGVR, nodeGVR} { + list, err := r.client.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list c9s %s in namespace %s: %w", + gvr.Resource, namespace, err) + } + + result[gvr] = make(map[string]*unstructured.Unstructured, len(list.Items)) + for idx := range list.Items { + obj := &list.Items[idx] + // Include resources from other labs so ownership validation can reject same-named + // collisions instead of overwriting them. + result[gvr][obj.GetName()] = obj + } + } + + return result, nil +} + +func validatePrimitiveResourceOwnership( + existing map[schema.GroupVersionResource]map[string]*unstructured.Unstructured, + desired *primitiveResourceSet, + topologyName, + namespace string, +) error { + for _, group := range desired.groups() { + for _, obj := range group.objects { + actual := existing[group.gvr][obj.GetName()] + if actual == nil || actual.GetLabels()[labelTopologyOwner] == topologyName { + continue + } + + return fmt.Errorf( + "c9s %s %s/%s already exists and belongs to another lab", + group.kind, + namespace, + obj.GetName(), + ) + } + } + + return nil +} + +func (r *Runtime) reconcilePrimitiveResource( + ctx context.Context, + namespace, + topologyName string, + gvr schema.GroupVersionResource, + kind string, + desired *unstructured.Unstructured, + stageNewNode bool, +) (*unstructured.Unstructured, bool, error) { + resource := r.client.Resource(gvr).Namespace(namespace) + var actual *unstructured.Unstructured + created := false + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, err := resource.Get(ctx, desired.GetName(), metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + toCreate := desired.DeepCopy() + if stageNewNode { + setPrimitiveNodeDeploymentStaged(toCreate, true) + } + actual, err = resource.Create(ctx, toCreate, metav1.CreateOptions{}) + if err == nil { + created = true + return nil + } + if apierrors.IsAlreadyExists(err) { + return apierrors.NewConflict(gvr.GroupResource(), desired.GetName(), err) + } + + return err + } + if err != nil { + return err + } + if existing.GetLabels()[labelTopologyOwner] != topologyName { + return fmt.Errorf("c9s %s %s/%s already exists and belongs to another lab", + kind, namespace, desired.GetName()) + } + + updated := reconciledPrimitiveObject(existing, desired, stageNewNode) + if primitiveObjectsConform(existing, updated) { + actual = existing + return nil + } + + actual, err = resource.Update(ctx, updated, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, false, fmt.Errorf("failed to reconcile c9s %s %s/%s: %w", + kind, namespace, desired.GetName(), err) + } + + return actual, created, nil +} + +func reconciledPrimitiveObject( + existing, + desired *unstructured.Unstructured, + stageNode bool, +) *unstructured.Unstructured { + updated := existing.DeepCopy() + if desiredSpec, ok := desired.Object["spec"]; ok { + updated.Object["spec"] = k8sruntime.DeepCopyJSONValue(desiredSpec) + } else { + delete(updated.Object, "spec") + } + + updated.SetLabels(mergeDesiredMetadata(existing.GetLabels(), desired.GetLabels())) + updated.SetAnnotations(mergeDesiredMetadata(existing.GetAnnotations(), desired.GetAnnotations())) + deleteLifecycleLabels(updated) + if stageNode { + setPrimitiveNodeDeploymentStaged(updated, true) + } + + return updated +} + +func mergeDesiredMetadata(existing, desired map[string]string) map[string]string { + result := make(map[string]string, len(existing)+len(desired)) + for key, value := range existing { + result[key] = value + } + for key, value := range desired { + result[key] = value + } + + return result +} + +func deleteLifecycleLabels(obj *unstructured.Unstructured) { + labelsMap := obj.GetLabels() + delete(labelsMap, labelIgnoreReconcile) + delete(labelsMap, clabernetesconstants.LabelDisableDeployments) + obj.SetLabels(labelsMap) +} + +func setPrimitiveNodeDeploymentStaged(obj *unstructured.Unstructured, staged bool) { + labelsMap := obj.GetLabels() + if labelsMap == nil { + labelsMap = map[string]string{} + } + if staged { + labelsMap[clabernetesconstants.LabelDisableDeployments] = "true" + } else { + delete(labelsMap, clabernetesconstants.LabelDisableDeployments) + } + obj.SetLabels(labelsMap) +} + +func primitiveObjectsConform(existing, desired *unstructured.Unstructured) bool { + return apiequality.Semantic.DeepEqual( + normalizeAPIDefaultedJSON(existing.Object["spec"]), + normalizeAPIDefaultedJSON(desired.Object["spec"]), + ) && + apiequality.Semantic.DeepEqual(existing.GetLabels(), desired.GetLabels()) && + apiequality.Semantic.DeepEqual(existing.GetAnnotations(), desired.GetAnnotations()) +} + +// normalizeAPIDefaultedJSON removes recursively empty/zero JSON values. Kubernetes CRD +// defaulting materializes fields such as false booleans and zero-second probe configuration in +// stored objects even when the renderer omitted them. Those values are declaratively equivalent; +// treating them as drift would make every plan and reconciliation report an update forever. +func normalizeAPIDefaultedJSON(value any) any { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for key, child := range typed { + normalized := normalizeAPIDefaultedJSON(child) + if !isZeroJSONValue(normalized) { + result[key] = normalized + } + } + + return result + case []any: + result := make([]any, len(typed)) + for idx := range typed { + result[idx] = normalizeAPIDefaultedJSON(typed[idx]) + } + + return result + default: + return value + } +} + +func isZeroJSONValue(value any) bool { + switch typed := value.(type) { + case nil: + return true + case bool: + return !typed + case string: + return typed == "" + case int: + return typed == 0 + case int32: + return typed == 0 + case int64: + return typed == 0 + case float32: + return typed == 0 + case float64: + return typed == 0 + case map[string]any: + return len(typed) == 0 + case []any: + return len(typed) == 0 + default: + return false + } +} + +func (r *Runtime) deleteStalePrimitiveResources( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, +) error { + desiredNames := map[schema.GroupVersionResource]map[string]struct{}{} + for _, group := range desired.groups() { + desiredNames[group.gvr] = make(map[string]struct{}, len(group.objects)) + for _, obj := range group.objects { + desiredNames[group.gvr][obj.GetName()] = struct{}{} + } + } + + var deleteErrors []error + // Remove obsolete wiring before obsolete Nodes, then remove profiles after no Node can refer + // to them. + for _, group := range []primitiveResourceGroup{ + {gvr: linkGVR, kind: "Link"}, + {gvr: nodeGVR, kind: "Node"}, + {gvr: launcherProfileGVR, kind: "LauncherProfile"}, + } { + resource := r.client.Resource(group.gvr).Namespace(namespace) + list, err := resource.List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: topologyName}.String(), + }) + if err != nil { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to list c9s %s resources for lab %s/%s: %w", + group.kind, namespace, topologyName, err)) + continue + } + + for idx := range list.Items { + name := list.Items[idx].GetName() + if _, keep := desiredNames[group.gvr][name]; keep { + continue + } + if err := resource.Delete(ctx, name, metav1.DeleteOptions{}); err != nil && + !apierrors.IsNotFound(err) { + deleteErrors = append(deleteErrors, fmt.Errorf( + "failed to delete stale c9s %s %s/%s: %w", + group.kind, namespace, name, err)) + } + } + } + + return errors.Join(deleteErrors...) +} + +func (r *Runtime) reconcileCompatibilityTopology( + ctx context.Context, + req clablabruntime.DeployRequest, + namespace string, + desired *unstructured.Unstructured, + desiredPrimitives *primitiveResourceSet, + stagedConfigMaps []stagedConfigMap, +) (*clablabruntime.LabState, error) { + if err := r.applyStagedConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + resource := r.client.Resource(topologyGVR).Namespace(namespace) + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest, err := resource.Get(ctx, req.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + updated := reconciledPrimitiveObject(latest, desired, false) + if primitiveObjectsConform(latest, updated) { + return nil + } + + _, err = resource.Update(ctx, updated, metav1.UpdateOptions{}) + return err + }) + if err != nil { + return nil, fmt.Errorf("failed to reconcile compatibility clabernetes topology %s/%s: %w", + namespace, req.Name, err) + } + + if !req.Wait { + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) + } + if err := r.waitCompatibilityPrimitivesReconciled( + ctx, + namespace, + req.Name, + desiredPrimitives, + req.Timeout, + ); err != nil { + return nil, err + } + if err := r.waitReady(ctx, req.Name, namespace, req.Timeout); err != nil { + return nil, err + } + + nodes, err := r.nodesForTopology(ctx, req.Name, namespace) + if err != nil { + return nil, err + } + nodesByName := make(map[string]*unstructured.Unstructured, len(nodes.Items)) + for idx := range nodes.Items { + nodesByName[nodes.Items[idx].GetName()] = &nodes.Items[idx] + } + if err := r.setStagedConfigMapNodeOwnerReferences( + ctx, + namespace, + stagedConfigMaps, + nodesByName, + ); err != nil { + return nil, err + } + if err := r.deleteStaleConfigMaps(ctx, namespace, req.Name, stagedConfigMaps); err != nil { + return nil, err + } + + return r.Inspect(ctx, clablabruntime.InspectRequest{Name: req.Name, Namespace: namespace}) +} + +func (r *Runtime) waitCompatibilityPrimitivesReconciled( + ctx context.Context, + namespace, + topologyName string, + desired *primitiveResourceSet, + timeout time.Duration, +) error { + waitCtx, cancel := context.WithTimeout(ctx, r.timeoutFor(timeout)) + defer cancel() + + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + for _, group := range desired.groups() { + list, err := r.primitiveResourcesForTopology( + ctx, + group.gvr, + topologyName, + namespace, + ) + if err != nil { + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, err + } + if len(list.Items) != len(group.objects) { + return false, nil + } + + actualByName := make(map[string]*unstructured.Unstructured, len(list.Items)) + for idx := range list.Items { + actualByName[list.Items[idx].GetName()] = &list.Items[idx] + } + for _, expected := range group.objects { + actual := actualByName[expected.GetName()] + if actual == nil || !apiequality.Semantic.DeepEqual( + actual.Object["spec"], + expected.Object["spec"], + ) { + return false, nil + } + } + } + + return true, nil + }) + if err == nil { + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + return fmt.Errorf("timed out after %s waiting for compatibility clabernetes topology %s/%s "+ + "resources to reconcile", r.timeoutFor(timeout), namespace, topologyName) +} diff --git a/labruntime/clabernetes/resources.go b/labruntime/clabernetes/resources.go new file mode 100644 index 0000000000..93ac9d19a7 --- /dev/null +++ b/labruntime/clabernetes/resources.go @@ -0,0 +1,332 @@ +package clabernetes + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/log" + clabernetesapisv1alpha1 "github.com/clabernetes/clabernetes/apis/v1alpha1" + clabernetesconstants "github.com/clabernetes/clabernetes/constants" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" +) + +func (r *Runtime) nodesForTopology( + ctx context.Context, + name, + namespace string, +) (*unstructured.UnstructuredList, error) { + namespace = r.namespaceFor(namespace) + + list, err := r.client.Resource(nodeGVR).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: name}.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s nodes for topology %s/%s: %w", + namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) primitiveResourcesForTopology( + ctx context.Context, + gvr schema.GroupVersionResource, + name, + namespace string, +) (*unstructured.UnstructuredList, error) { + namespace = r.namespaceFor(namespace) + + list, err := r.client.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{labelTopologyOwner: name}.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list c9s %s for lab %s/%s: %w", + gvr.Resource, namespace, name, err) + } + + return list, nil +} + +func (r *Runtime) primitiveLabExists( + ctx context.Context, + name, + namespace string, +) (bool, error) { + for _, gvr := range []schema.GroupVersionResource{nodeGVR, linkGVR, launcherProfileGVR} { + list, err := r.primitiveResourcesForTopology(ctx, gvr, name, namespace) + if err != nil { + return false, err + } + if len(list.Items) != 0 { + return true, nil + } + } + + return false, nil +} + +type createdPrimitiveResource struct { + gvr schema.GroupVersionResource + name string +} + +func (r *Runtime) waitPrimitiveLinksResolved( + ctx context.Context, + namespace string, + desiredLinks []*unstructured.Unstructured, + timeout time.Duration, +) error { + if len(desiredLinks) == 0 { + return nil + } + log.Info( + "Waiting for clabernetes links to resolve", + "namespace", namespace, + "links", len(desiredLinks), + ) + + desiredNames := make(map[string]struct{}, len(desiredLinks)) + for _, link := range desiredLinks { + desiredNames[link.GetName()] = struct{}{} + } + + effectiveTimeout := r.timeoutFor(timeout) + waitCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + + var pending []string + err := wait.PollUntilContextCancel(waitCtx, pollInterval, true, + func(ctx context.Context) (bool, error) { + links, err := r.client.Resource(linkGVR).Namespace(namespace). + List(ctx, metav1.ListOptions{}) + if err != nil { + if ctx.Err() != nil || contextDeadlineIsImminent(ctx) { + return false, nil + } + + return false, fmt.Errorf("failed to inspect c9s links in namespace %s: %w", + namespace, err) + } + + linksByName := make(map[string]*unstructured.Unstructured, len(links.Items)) + for idx := range links.Items { + linksByName[links.Items[idx].GetName()] = &links.Items[idx] + } + + pending = pending[:0] + for name := range desiredNames { + link, ok := linksByName[name] + if !ok { + pending = append(pending, name+" (not found)") + continue + } + + if reason := primitiveLinkPendingReason(link); reason != "" { + pending = append(pending, fmt.Sprintf("%s (%s)", name, reason)) + } + } + sort.Strings(pending) + + return len(pending) == 0, nil + }) + if err == nil { + log.Info( + "Clabernetes links are resolved", + "namespace", namespace, + "links", len(desiredLinks), + ) + + return nil + } + if !errors.Is(err, context.DeadlineExceeded) { + return err + } + + return fmt.Errorf( + "timed out after %s waiting for c9s links in namespace %s to resolve; pending links: %s", + effectiveTimeout, + namespace, + strings.Join(pending, ", "), + ) +} + +func primitiveLinkPendingReason(link *unstructured.Unstructured) string { + if link == nil { + return "not found" + } + + if statusError, _, _ := unstructured.NestedString( + link.Object, + "status", + "error", + ); statusError != "" { + return statusError + } + + for _, endpointName := range []string{"endpointA", "endpointB"} { + nodeName, _, _ := unstructured.NestedString( + link.Object, + "status", + "resolvedEndpoints", + endpointName, + "nodeName", + ) + if nodeName == "" { + return "waiting for endpoint binding" + } + if nodeName == clabernetesapisv1alpha1.LinkHostNodeName { + continue + } + + uid, _, _ := unstructured.NestedString( + link.Object, + "status", + "resolvedEndpoints", + endpointName, + "uid", + ) + if uid == "" { + return "waiting for endpoint identity" + } + } + + return "" +} + +func (r *Runtime) enablePrimitiveNodeDeployments( + ctx context.Context, + namespace string, + nodes map[string]*unstructured.Unstructured, +) error { + patch := []byte(fmt.Sprintf( + `{"metadata":{"labels":{"%s":null}}}`, + clabernetesconstants.LabelDisableDeployments, + )) + + names := make([]string, 0, len(nodes)) + for name := range nodes { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + _, err := r.client.Resource(nodeGVR).Namespace(namespace).Patch( + ctx, + name, + types.MergePatchType, + patch, + metav1.PatchOptions{}, + ) + if err != nil { + return fmt.Errorf("failed to enable c9s Node deployment %s/%s: %w", + namespace, name, err) + } + } + + return nil +} + +func (r *Runtime) deleteCreatedPrimitiveResources( + ctx context.Context, + namespace string, + created []createdPrimitiveResource, +) { + for idx := len(created) - 1; idx >= 0; idx-- { + resource := created[idx] + err := r.client.Resource(resource.gvr).Namespace(namespace). + Delete(ctx, resource.name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + // This is rollback after a more useful create error; keep the original error as the one + // returned to the caller. + continue + } + } +} + +func nodeNetworkMode(node *unstructured.Unstructured) string { + if node == nil { + return "" + } + + networkMode, _, _ := unstructured.NestedString(node.Object, "spec", "network-mode") + + return networkMode +} + +func resolveLauncherNode(nodeName string, networkModes map[string]string) string { + current := nodeName + seen := map[string]struct{}{} + + for { + if _, ok := seen[current]; ok { + return current + } + seen[current] = struct{}{} + + primary, ok := strings.CutPrefix(networkModes[current], "container:") + if !ok || primary == "" { + return current + } + + current = primary + } +} + +func (r *Runtime) launcherNodeNames( + ctx context.Context, + topologyName, + namespace string, + nodeNames []string, +) (map[string]string, error) { + nodes, err := r.nodesForTopology(ctx, topologyName, namespace) + if err != nil { + return nil, err + } + + networkModes := make(map[string]string, len(nodes.Items)) + for idx := range nodes.Items { + node := &nodes.Items[idx] + networkModes[node.GetName()] = nodeNetworkMode(node) + } + + resolved := make(map[string]string, len(nodeNames)) + for _, nodeName := range nodeNames { + if _, ok := networkModes[nodeName]; !ok { + return nil, fmt.Errorf("node %q was not found in topology %s/%s", + nodeName, r.namespaceFor(namespace), topologyName) + } + resolved[nodeName] = resolveLauncherNode(nodeName, networkModes) + } + + return resolved, nil +} + +func uniqueLauncherNodes(nodeNames []string, launchers map[string]string) []string { + unique := make([]string, 0, len(nodeNames)) + seen := map[string]struct{}{} + + for _, nodeName := range nodeNames { + launcher := launchers[nodeName] + if launcher == "" { + launcher = nodeName + } + if _, ok := seen[launcher]; ok { + continue + } + + seen[launcher] = struct{}{} + unique = append(unique, launcher) + } + + return unique +} diff --git a/labruntime/clabernetes/save.go b/labruntime/clabernetes/save.go new file mode 100644 index 0000000000..b2757f7f8e --- /dev/null +++ b/labruntime/clabernetes/save.go @@ -0,0 +1,187 @@ +package clabernetes + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/charmbracelet/log" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" +) + +func (r *Runtime) Save( + ctx context.Context, + req clablabruntime.SaveRequest, +) (*clablabruntime.SaveResult, error) { + targets, namespace, err := r.targetNodes(ctx, clablabruntime.NodeRequest{ + Name: req.Name, + Namespace: req.Namespace, + Nodes: req.Nodes, + }) + if err != nil { + return nil, err + } + + result := &clablabruntime.SaveResult{} + for _, nodeName := range targets { + pod, err := r.launcherPod(ctx, req.Name, namespace, nodeName) + if err != nil { + return nil, err + } + + copyDir := "" + command := []string{"containerlab", "save", "-t", "/clabernetes/topo.clab.yaml"} + if req.Copy { + copyDir = fmt.Sprintf("/tmp/clab-save-copy-%s-%s-%d", + req.Name, nodeName, time.Now().UnixNano()) + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", copyDir}) + command = append(command, "--copy", copyDir) + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, command) + if err != nil { + return nil, err + } + + if len(stdout) != 0 { + log.Info( + "clabernetes save output", + "node", + nodeName, + "stdout", + strings.TrimSpace(string(stdout)), + ) + } + if len(stderr) != 0 { + log.Info( + "clabernetes save output", + "node", + nodeName, + "stderr", + strings.TrimSpace(string(stderr)), + ) + } + if rc != 0 { + return nil, fmt.Errorf("save failed for clabernetes node %s/%s/%s: rc=%d", + namespace, req.Name, nodeName, rc) + } + + if req.Copy { + files, err := r.collectSavedFiles(ctx, pod, nodeName, copyDir) + if cleanupDir := copyDir; cleanupDir != "" { + _, _, _, _ = r.execInPod(ctx, pod, []string{"rm", "-rf", cleanupDir}) + } + if err != nil { + return nil, err + } + result.Files = append(result.Files, files...) + } + } + + return result, nil +} + +func (r *Runtime) collectSavedFiles( + ctx context.Context, + pod *corev1.Pod, + nodeName, + copyDir string, +) ([]clablabruntime.SavedFile, error) { + if copyDir == "" { + return nil, nil + } + + nodeCopyDir := path.Join(copyDir, "clab-clabernetes-"+nodeName, nodeName) + _, _, rc, err := r.execInPod(ctx, pod, []string{"test", "-d", nodeCopyDir}) + if err != nil { + return nil, err + } + if rc != 0 { + log.Debug("no clabernetes saved config copy directory found", + "node", nodeName, + "path", nodeCopyDir, + ) + + return nil, nil + } + + stdout, stderr, rc, err := r.execInPod(ctx, pod, + []string{"tar", "cf", "-", "-C", nodeCopyDir, "."}) + if err != nil { + return nil, err + } + if rc != 0 { + return nil, fmt.Errorf("failed to archive saved config copy for node %s: rc=%d stderr=%s", + nodeName, rc, strings.TrimSpace(string(stderr))) + } + + files, err := savedFilesFromTar(nodeName, stdout) + if err != nil { + return nil, fmt.Errorf("failed to read saved config archive for node %s: %w", + nodeName, err) + } + + return files, nil +} + +func savedFilesFromTar(nodeName string, data []byte) ([]clablabruntime.SavedFile, error) { + reader := tar.NewReader(bytes.NewReader(data)) + var files []clablabruntime.SavedFile + + for { + header, err := reader.Next() + switch { + case errors.Is(err, io.EOF): + return files, nil + case err != nil: + return nil, err + } + + name, ok := cleanTarPath(header.Name) + if !ok || name == "." { + continue + } + + switch header.Typeflag { + case tar.TypeReg, tar.TypeRegA: + content, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + files = append(files, clablabruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Data: content, + Mode: header.Mode, + }) + case tar.TypeSymlink: + files = append(files, clablabruntime.SavedFile{ + NodeName: nodeName, + Name: name, + Mode: header.Mode, + LinkTarget: header.Linkname, + }) + } + } +} + +func cleanTarPath(name string) (string, bool) { + name = strings.TrimPrefix(name, "./") + cleaned := path.Clean(name) + if cleaned == "." || cleaned == "" { + return cleaned, true + } + if strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") || cleaned == ".." { + return "", false + } + + return cleaned, true +} diff --git a/labruntime/clabernetes/state.go b/labruntime/clabernetes/state.go new file mode 100644 index 0000000000..57fd7adcd0 --- /dev/null +++ b/labruntime/clabernetes/state.go @@ -0,0 +1,200 @@ +package clabernetes + +import ( + "context" + "fmt" + "sort" + "strings" + + clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" +) + +func stateFromNodeResources( + name, + namespace string, + nodeResources []unstructured.Unstructured, +) *clablabruntime.LabState { + state := &clablabruntime.LabState{ + Name: name, + Namespace: namespace, + TopologyPath: fmt.Sprintf("k8s://%s/labs/%s", namespace, name), + } + if len(nodeResources) != 0 { + state.Owner = nodeResources[0].GetLabels()[clabconstants.Owner] + if state.Owner == "" { + state.Owner = nodeResources[0].GetAnnotations()[clabconstants.Owner] + } + } + + state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeResources)) + for idx := range nodeResources { + nodeResource := &nodeResources[idx] + node := clablabruntime.NodeState{Name: nodeResource.GetName()} + node.Kind, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "kind") + node.Image, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "image") + node.State, _, _ = unstructured.NestedString(nodeResource.Object, "status", "readiness") + node.Ready = node.State == "ready" + node.LoadBalancerAddress, _, _ = unstructured.NestedString( + nodeResource.Object, + "status", + "exposedPorts", + "loadBalancerAddress", + ) + state.Nodes = append(state.Nodes, node) + } + + return state +} + +func (r *Runtime) enrichState(ctx context.Context, state *clablabruntime.LabState) error { + if state == nil || state.Name == "" { + return nil + } + + nodeResources, err := r.nodesForTopology(ctx, state.Name, state.Namespace) + if err != nil { + return err + } + + nodesByName := map[string]clablabruntime.NodeState{} + for _, node := range state.Nodes { + nodesByName[node.Name] = node + } + + networkModes := make(map[string]string, len(nodeResources.Items)) + for idx := range nodeResources.Items { + nodeResource := &nodeResources.Items[idx] + nodeName := nodeResource.GetName() + node := nodesByName[nodeName] + node.Name = nodeName + node.Kind, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "kind") + node.Image, _, _ = unstructured.NestedString(nodeResource.Object, "spec", "image") + node.State, _, _ = unstructured.NestedString(nodeResource.Object, "status", "readiness") + node.Ready = node.State == "ready" + node.LoadBalancerAddress, _, _ = unstructured.NestedString( + nodeResource.Object, + "status", + "exposedPorts", + "loadBalancerAddress", + ) + nodesByName[nodeName] = node + networkModes[nodeName] = nodeNetworkMode(nodeResource) + } + + deployments, err := r.deploymentsForTopology(ctx, state.Name, state.Namespace) + if err != nil { + return err + } + + pods, err := r.kubeClient.CoreV1().Pods(state.Namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.Set{ + labelApp: clabernetesAppValue, + labelTopologyOwner: state.Name, + }.String(), + }) + if err != nil { + return fmt.Errorf("failed to list clabernetes pods for topology %s/%s: %w", + state.Namespace, state.Name, err) + } + + podsByNode := map[string]*corev1.Pod{} + for idx := range pods.Items { + nodeName := pods.Items[idx].Labels[labelTopologyNode] + if nodeName == "" { + continue + } + if pods.Items[idx].Status.Phase == corev1.PodRunning { + podsByNode[nodeName] = &pods.Items[idx] + continue + } + if _, ok := podsByNode[nodeName]; !ok { + podsByNode[nodeName] = &pods.Items[idx] + } + } + + for idx := range deployments.Items { + deployment := &deployments.Items[idx] + nodeName := deployment.Labels[labelTopologyNode] + if nodeName == "" { + continue + } + + replicas := int32(1) + if deployment.Spec.Replicas != nil { + replicas = *deployment.Spec.Replicas + } + + deploymentState := "notready" + deploymentReady := false + switch { + case replicas == 0: + deploymentState = "stopped" + case deployment.Status.ReadyReplicas > 0: + deploymentState = "ready" + deploymentReady = true + case podsByNode[nodeName] != nil && podsByNode[nodeName].Status.Phase != "": + deploymentState = strings.ToLower(string(podsByNode[nodeName].Status.Phase)) + } + + matched := false + for logicalNodeName, node := range nodesByName { + if resolveLauncherNode(logicalNodeName, networkModes) != nodeName { + continue + } + + // Node status is the authoritative signal that the nested containerlab node is ready. + // A launcher Deployment can be ready before containerlab has created the inner + // container. Deployment state remains the fallback for older resources without Node + // readiness, and replicas=0 is always an explicit stopped state. + if replicas == 0 || node.State == "" { + node.State = deploymentState + node.Ready = deploymentReady + } + nodesByName[logicalNodeName] = node + matched = true + } + + if !matched { + node := nodesByName[nodeName] + node.Name = nodeName + node.State = deploymentState + node.Ready = deploymentReady + nodesByName[nodeName] = node + } + } + + nodeNames := make([]string, 0, len(nodesByName)) + for nodeName := range nodesByName { + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + state.Nodes = make([]clablabruntime.NodeState, 0, len(nodeNames)) + allReady := len(nodeNames) > 0 + allStopped := len(nodeNames) > 0 + for _, nodeName := range nodeNames { + node := nodesByName[nodeName] + state.Nodes = append(state.Nodes, node) + allReady = allReady && node.Ready + allStopped = allStopped && node.State == "stopped" + } + + switch { + case allReady: + state.State = "running" + state.Ready = true + case allStopped: + state.State = "stopped" + state.Ready = false + case len(nodeNames) != 0: + state.State = "partial" + state.Ready = false + } + + return nil +} diff --git a/labruntime/clabernetes/topology.go b/labruntime/clabernetes/topology.go new file mode 100644 index 0000000000..f6c82f13b3 --- /dev/null +++ b/labruntime/clabernetes/topology.go @@ -0,0 +1,201 @@ +package clabernetes + +import ( + "fmt" + "net" + "sort" + + "github.com/charmbracelet/log" + clabconstants "github.com/srl-labs/containerlab/constants" + clablabruntime "github.com/srl-labs/containerlab/labruntime" + "gopkg.in/yaml.v2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation" +) + +type topologyObjectOption func(map[string]any) + +func topologyWithNaming(naming string) topologyObjectOption { + return func(spec map[string]any) { + if naming != "" { + spec["naming"] = naming + } + } +} + +func topologyObject( + name, + namespace, + owner, + definition string, + opts ...topologyObjectOption, +) *unstructured.Unstructured { + topologyLabels := map[string]any{ + "containerlab.dev/runtime": clablabruntime.ClabernetesRuntimeName, + } + topologyAnnotations := map[string]any{} + if owner != "" { + topologyAnnotations[clabconstants.Owner] = owner + if len(validation.IsValidLabelValue(owner)) == 0 { + topologyLabels[clabconstants.Owner] = owner + } + } + + metadata := map[string]any{ + "name": name, + "namespace": namespace, + "labels": topologyLabels, + } + if len(topologyAnnotations) != 0 { + metadata["annotations"] = topologyAnnotations + } + + spec := map[string]any{ + "definition": map[string]any{ + "containerlab": definition, + }, + } + for _, opt := range opts { + opt(spec) + } + + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": c9sAPIVersion, + "kind": "Topology", + "metadata": metadata, + "spec": spec, + }, + } +} + +func stateFromTopology(obj *unstructured.Unstructured, namespace string) *clablabruntime.LabState { + if obj.GetNamespace() != "" { + namespace = obj.GetNamespace() + } + + ready, _, _ := unstructured.NestedBool(obj.Object, "status", "topologyReady") + state, _, _ := unstructured.NestedString(obj.Object, "status", "topologyState") + owner := obj.GetLabels()[clabconstants.Owner] + if owner == "" { + owner = obj.GetAnnotations()[clabconstants.Owner] + } + nodeReadiness, _, _ := unstructured.NestedStringMap( + obj.Object, + "status", + "nodeReadiness", + ) + exposedPorts, _, _ := unstructured.NestedMap(obj.Object, "status", "exposedPorts") + nodeSpecs := nodeSpecsFromTopology(obj) + + nodeNames := make([]string, 0, len(nodeSpecs)+len(nodeReadiness)) + seenNodes := map[string]struct{}{} + for nodeName := range nodeSpecs { + nodeNames = append(nodeNames, nodeName) + seenNodes[nodeName] = struct{}{} + } + for nodeName := range nodeReadiness { + if _, ok := seenNodes[nodeName]; ok { + continue + } + nodeNames = append(nodeNames, nodeName) + } + sort.Strings(nodeNames) + + nodes := make([]clablabruntime.NodeState, 0, len(nodeNames)) + for _, nodeName := range nodeNames { + nodeState := nodeReadiness[nodeName] + spec := nodeSpecs[nodeName] + nodes = append(nodes, clablabruntime.NodeState{ + Name: nodeName, + Kind: spec.Kind, + Image: spec.Image, + State: nodeState, + Ready: nodeState == "ready", + LoadBalancerAddress: loadBalancerAddress(exposedPorts, nodeName), + }) + } + + return &clablabruntime.LabState{ + Name: obj.GetName(), + Namespace: namespace, + Owner: owner, + TopologyPath: fmt.Sprintf("k8s://%s/topologies/%s", namespace, obj.GetName()), + State: state, + Ready: ready, + Nodes: nodes, + } +} + +type nodeSpec struct { + Kind string `yaml:"kind"` + Image string `yaml:"image"` +} + +type containerlabDefinition struct { + Topology struct { + Nodes map[string]nodeSpec `yaml:"nodes"` + } `yaml:"topology"` +} + +func nodeSpecsFromTopology(obj *unstructured.Unstructured) map[string]nodeSpec { + specs := map[string]nodeSpec{} + + statusConfigs, _, _ := unstructured.NestedStringMap(obj.Object, "status", "configs") + for _, config := range statusConfigs { + mergeNodeSpecs(specs, config) + } + + if len(specs) != 0 { + return specs + } + + definition, _, _ := unstructured.NestedString( + obj.Object, + "spec", + "definition", + "containerlab", + ) + mergeNodeSpecs(specs, definition) + + return specs +} + +func mergeNodeSpecs(specs map[string]nodeSpec, definition string) { + if definition == "" { + return + } + + var parsed containerlabDefinition + if err := yaml.Unmarshal([]byte(definition), &parsed); err != nil { + log.Debug("failed to parse clabernetes topology definition", "error", err) + return + } + + for nodeName, spec := range parsed.Topology.Nodes { + specs[nodeName] = spec + } +} + +func loadBalancerAddress(exposedPorts map[string]any, nodeName string) string { + raw, ok := exposedPorts[nodeName] + if !ok { + return "" + } + + nodeExpose, ok := raw.(map[string]any) + if !ok { + return "" + } + + addr, ok := nodeExpose["loadBalancerAddress"].(string) + if !ok { + return "" + } + + if net.ParseIP(addr) == nil { + return "" + } + + return addr +} diff --git a/labruntime/runtime.go b/labruntime/runtime.go new file mode 100644 index 0000000000..bf68075aae --- /dev/null +++ b/labruntime/runtime.go @@ -0,0 +1,191 @@ +package labruntime + +import ( + "context" + "fmt" + "time" + + clabexec "github.com/srl-labs/containerlab/exec" +) + +const ( + ClabernetesRuntimeName = "clabernetes" +) + +type Config struct { + Debug bool + Timeout time.Duration + Namespace string +} + +type DeployRequest struct { + Name string + Namespace string + Owner string + TopologyFile string + TopologyLabDir string + TopologyDefinition []byte + Wait bool + Timeout time.Duration +} + +type DestroyRequest struct { + Name string + Namespace string + Wait bool + Timeout time.Duration +} + +type InspectRequest struct { + Name string + Namespace string +} + +type ListRequest struct { + Namespace string + AllNamespaces bool +} + +type NodeRequest struct { + Name string + Namespace string + Nodes []string + Timeout time.Duration +} + +type ExecRequest struct { + Name string + Namespace string + NodeName string + Command []string +} + +type SaveRequest struct { + Name string + Namespace string + Nodes []string + Copy bool +} + +type EventStreamRequest struct { + Namespace string + AllNamespaces bool + IncludeInitialState bool + IncludeInterfaceStats bool + StatsInterval time.Duration +} + +type SavedFile struct { + NodeName string + Name string + Data []byte + Mode int64 + LinkTarget string +} + +type SaveResult struct { + Files []SavedFile +} + +type NodeState struct { + Name string + Kind string + Image string + State string + Ready bool + LoadBalancerAddress string +} + +type LabState struct { + Name string + Namespace string + Owner string + TopologyPath string + State string + Ready bool + Nodes []NodeState +} + +type ChangeAction string + +const ( + ChangeCreate ChangeAction = "create" + ChangeUpdate ChangeAction = "update" + ChangeDelete ChangeAction = "delete" +) + +type ResourceChange struct { + Action ChangeAction `json:"action"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` +} + +// DeployPlan describes the remote resources a lab runtime would change without mutating them. +type DeployPlan struct { + LabName string `json:"lab-name"` + Namespace string `json:"namespace"` + Changes []ResourceChange `json:"changes"` +} + +type Event struct { + Timestamp time.Time + Type string + Action string + ActorID string + ActorName string + ActorFullID string + Attributes map[string]string +} + +type LabRuntime interface { + Deploy(context.Context, DeployRequest) (*LabState, error) + Destroy(context.Context, DestroyRequest) error + Inspect(context.Context, InspectRequest) (*LabState, error) + List(context.Context, ListRequest) ([]*LabState, error) + Exec(context.Context, ExecRequest) (*clabexec.ExecResult, error) + Start(context.Context, NodeRequest) error + Stop(context.Context, NodeRequest) error + Restart(context.Context, NodeRequest) error + Save(context.Context, SaveRequest) (*SaveResult, error) + StreamEvents(context.Context, EventStreamRequest) (<-chan Event, <-chan error, error) +} + +// LabExistenceChecker lets controller-driven runtimes report whether a lab has remote state. +// Core uses it to distinguish a fresh deployment from reconciliation without consulting the +// local container runtime. +type LabExistenceChecker interface { + LabExists(context.Context, InspectRequest) (bool, error) +} + +// TopologyValidator validates the runtime-specific topology subset without changing remote state. +type TopologyValidator interface { + Validate(context.Context, DeployRequest) error +} + +// TopologyPlanner compiles and diffs a desired topology without changing remote state. +type TopologyPlanner interface { + Plan(context.Context, DeployRequest) (*DeployPlan, error) +} + +type Initializer func(Config) (LabRuntime, error) + +var LabRuntimes = map[string]Initializer{} + +func Register(name string, init Initializer) { + LabRuntimes[name] = init +} + +func IsLabRuntimeName(name string) bool { + _, ok := LabRuntimes[name] + return ok +} + +func Init(name string, cfg Config) (LabRuntime, error) { + init, ok := LabRuntimes[name] + if !ok { + return nil, fmt.Errorf("unknown lab runtime %q", name) + } + + return init(cfg) +} diff --git a/mkdocs.yml b/mkdocs.yml index aa7b47250d..502a39cd6b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -85,6 +85,7 @@ nav: - manual/clabernetes/index.md - Install: manual/clabernetes/install.md - Quickstart: manual/clabernetes/quickstart.md + - Containerlab runtime: manual/clabernetes/runtime.md - Configuration: manual/clabernetes/configuration.md - Packet capture in c9s: manual/clabernetes/pcap.md - Node filtering: manual/node-filtering.md diff --git a/runtime/runtime.go b/runtime/runtime.go index 937893af51..3c2f6c91e3 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -155,6 +155,7 @@ type RuntimeConfig struct { GracefulShutdown bool Debug bool KeepMgmtNet bool + LabNamespace string VerifyLinkParams *clablinks.VerifyLinkParams } diff --git a/tests/14-clabernetes/01-linux-lifecycle.clab.yml b/tests/14-clabernetes/01-linux-lifecycle.clab.yml new file mode 100644 index 0000000000..c1316416b4 --- /dev/null +++ b/tests/14-clabernetes/01-linux-lifecycle.clab.yml @@ -0,0 +1,20 @@ +# yaml-language-server: $schema=../../schemas/clab.schema.json +name: c9s-linux-lifecycle + +topology: + nodes: + client: + kind: linux + image: ghcr.io/srl-labs/network-multitool + exec: + - ip link set dev eth1 up + - ip addr add dev eth1 10.10.10.1/30 + server: + kind: linux + image: ghcr.io/srl-labs/network-multitool + exec: + - ip link set dev eth1 up + - ip addr add dev eth1 10.10.10.2/30 + + links: + - endpoints: ["client:eth1", "server:eth1"] diff --git a/tests/14-clabernetes/01-linux-lifecycle.robot b/tests/14-clabernetes/01-linux-lifecycle.robot new file mode 100644 index 0000000000..95a264e67f --- /dev/null +++ b/tests/14-clabernetes/01-linux-lifecycle.robot @@ -0,0 +1,182 @@ +*** Settings *** +Library OperatingSystem +Library Process +Library String +Resource ../common.robot + +Suite Setup Setup +Suite Teardown Teardown +Test Tags clabernetes c9s + + +*** Variables *** +${runtime} clabernetes +${lab-name} c9s-linux-lifecycle +${lab-file} 01-linux-lifecycle.clab.yml +${topo} ${CURDIR}/${lab-file} +${lab-namespace} c9s-${lab-name} +${client-label} clab-node-name\=client +${events-log} /tmp/clab-c9s-events.log +${events-err} /tmp/clab-c9s-events.err +${recovery-timeout} 180s +${retry-interval} 5s + + +*** Test Cases *** +Deploy c9s linux lab + ${output} = Run Clab Command deploy -t ${topo} + Should Be Equal As Integers ${output.rc} 0 + +Deploy emits primary c9s resources without a Topology payload + ${nodes} = Process.Run Process + ... kubectl -n ${lab-namespace} get nodes.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... shell=True + Should Be Equal As Integers ${nodes.rc} 0 + Should Contain ${nodes.stdout} node.c9s.run/client + Should Contain ${nodes.stdout} node.c9s.run/server + + ${links} = Process.Run Process + ... kubectl -n ${lab-namespace} get links.c9s.run -l c9s.run/topologyOwner\=${lab-name} -o name + ... shell=True + Should Be Equal As Integers ${links.rc} 0 + Should Not Be Empty ${links.stdout} + + ${topology} = Process.Run Process + ... kubectl -n ${lab-namespace} get topology.c9s.run ${lab-name} --ignore-not-found -o name + ... shell=True + Should Be Equal As Integers ${topology.rc} 0 + Should Be Empty ${topology.stdout} + +Inspect c9s linux lab by topology and name + ${topology_inspect} = Run Clab Command inspect -t ${topo} + Should Be Equal As Integers ${topology_inspect.rc} 0 + Should Contain ${topology_inspect.stdout} ${lab-name} + Should Contain ${topology_inspect.stdout} client + Should Contain ${topology_inspect.stdout} server + + ${name_inspect} = Run Clab Command inspect --name ${lab-name} + Should Be Equal As Integers ${name_inspect.rc} 0 + Should Contain ${name_inspect.stdout} ${lab-name} + Should Contain ${name_inspect.stdout} client + Should Contain ${name_inspect.stdout} server + +Exec into client and verify dataplane + Wait Until Keyword Succeeds 60s ${retry-interval} Client Eth1 Should Be Visible + Wait Until Keyword Succeeds 60s ${retry-interval} Ping From Client Should Succeed + +Stop server and verify dataplane interruption + ${output} = Run Clab Command stop -t ${topo} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds 60s ${retry-interval} Ping From Client Should Fail + +Start server by lab name and verify dataplane restore + ${output} = Run Clab Command start --name ${lab-name} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds ${recovery-timeout} ${retry-interval} Ping From Client Should Succeed + +Restart server and keep dataplane working + ${output} = Run Clab Command restart -t ${topo} --node server + Should Be Equal As Integers ${output.rc} 0 + + Wait Until Keyword Succeeds ${recovery-timeout} ${retry-interval} Ping From Client Should Succeed + +Events command emits c9s initial state + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + TRY + ${cmd} = Set Variable ${CLAB_BIN} --runtime ${runtime} events --format json --initial-state --interface-stats=false + Start Process ${cmd} + ... shell=True + ... alias=c9s_events + ... stdout=${events-log} + ... stderr=${events-err} + Sleep 5s + Stop Events Process + ${events} = Get File ${events-log} + Log ${events} + Should Contain ${events} "type":"container" + Should Contain ${events} ${lab-name}/client + Should Contain ${events} ${lab-name}/server + Validate JSON Lines ${events-log} + FINALLY + Stop Events Process + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + END + +Destroy c9s linux lab + ${output} = Run Clab Command destroy -t ${topo} --cleanup + Should Be Equal As Integers ${output.rc} 0 + + ${inspect_all} = Run Clab Command inspect --all + Should Not Contain ${inspect_all.stdout} ${lab-name} + + ${namespace} = Process.Run Process + ... kubectl get namespace ${lab-namespace} --ignore-not-found -o name + ... shell=True + Should Be Equal As Integers ${namespace.rc} 0 + Should Be Empty ${namespace.stdout} + + +*** Keywords *** +Setup + Skip If '${runtime}' != 'clabernetes' This suite targets the clabernetes runtime. + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + ${output} = Run Clab Command destroy -t ${topo} --cleanup + Log Cleanup return code: ${output.rc} + +Teardown + Stop Events Process + Run Clab Command destroy -t ${topo} --cleanup + Remove File If Exists ${events-log} + Remove File If Exists ${events-err} + +Run Clab Command + [Arguments] ${args} + ${output} = Process.Run Process + ... ${CLAB_BIN} --runtime ${runtime} ${args} + ... shell=True + Log stdout:${\n}${output.stdout} console=${True} + Log stderr:${\n}${output.stderr} console=${True} + RETURN ${output} + +Ping From Client Should Succeed + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ping -c 1 -W 2 10.10.10.2' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Be Equal As Integers ${output.rc} 0 + Should Match Regexp ${combined} (?s).*1 (packets )?received, 0% packet loss.* + +Ping From Client Should Fail + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ping -c 1 -W 2 10.10.10.2' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Match Regexp ${combined} (?s).*0 (packets )?received, 100% packet loss.* + +Client Eth1 Should Be Visible + ${output} = Run Clab Command + ... exec -t ${topo} --label ${client-label} --cmd 'ip link show eth1' + ${combined} = Catenate SEPARATOR=\n ${output.stdout} ${output.stderr} + Should Be Equal As Integers ${output.rc} 0 + Should Contain ${combined} eth1 + +Remove File If Exists + [Arguments] ${path} + Run Keyword And Ignore Error Remove File ${path} + +Stop Events Process + Run Keyword And Ignore Error Terminate Process c9s_events kill=True + Run Keyword And Ignore Error Wait For Process c9s_events + +Validate JSON Lines + [Arguments] ${path} + ${contents} = Get File ${path} + @{lines} = Split To Lines ${contents} + FOR ${line} IN @{lines} + ${stripped} = Strip String ${line} + IF $stripped == '' CONTINUE + Evaluate json.loads($stripped) modules=json + END diff --git a/tests/rf-run.sh b/tests/rf-run.sh index 1368aa15b3..e655298574 100755 --- a/tests/rf-run.sh +++ b/tests/rf-run.sh @@ -4,24 +4,41 @@ # SPDX-License-Identifier: BSD-3-Clause # arguments -# $1 - container runtime: [docker, podman] +# $1 - runtime: [docker, podman, clabernetes] # $2 - test suite to execute +# $3... - optional arguments passed to robot + +set -euo pipefail + +if [ "$#" -lt 2 ]; then + echo "usage: $0 " + exit 1 +fi + +runtime=$1 +suite=$2 +shift 2 +extra_robot_args=("$@") + +if [ "${runtime}" = "c9s" ]; then + runtime=clabernetes +fi # set containerlab binary path to a value of CLAB_BIN env variable # unless it is not set, then use 'containerlab' as a default value -if [ -z "${CLAB_BIN}" ]; then +if [ -z "${CLAB_BIN:-}" ]; then CLAB_BIN=containerlab fi export AWS_ACCESS_KEY_ID export AWS_SECRET_ACCESS_KEY -echo "Running tests with containerlab binary at $(which ${CLAB_BIN}) path and selected runtime: $1" +echo "Running tests with containerlab binary at $(command -v "${CLAB_BIN}") path and selected runtime: ${runtime}" COV_DIR=/tmp/clab-tests/coverage # coverage output directory -mkdir -p ${COV_DIR} +mkdir -p "${COV_DIR}" # parses the dir or file name passed to the rf-run.sh script # and in case of a directory, it returns the name of the directory @@ -38,7 +55,18 @@ function get_logname() { fi } -# activate venv -source .venv/bin/activate +robot_args=() +if [ "${runtime}" = "clabernetes" ]; then + robot_args+=(--include clabernetes) +fi -GOCOVERDIR=${COV_DIR} robot --consolecolors on -r none --variable CLAB_BIN:${CLAB_BIN} --variable runtime:$1 -l ./tests/out/$(get_logname $2)-$1-log --output ./tests/out/$(basename $2)-$1-out.xml $2 +GOCOVERDIR=${COV_DIR} uv run --project . -m robot \ + --consolecolors on \ + -r none \ + --variable CLAB_BIN:${CLAB_BIN} \ + --variable runtime:${runtime} \ + -l ./tests/out/$(get_logname "${suite}")-${runtime}-log \ + --output ./tests/out/$(basename "${suite}")-${runtime}-out.xml \ + "${extra_robot_args[@]}" \ + "${robot_args[@]}" \ + "${suite}"