diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml index 76387a04d4..5d58b99aed 100644 --- a/.github/workflows/smoke-tests.yml +++ b/.github/workflows/smoke-tests.yml @@ -53,6 +53,7 @@ jobs: - "27*.robot" - "28*.robot" - "29*.robot" + - "30*.robot" # allow podman job to fail, since it started to fail on github actions continue-on-error: ${{ inputs.runtime == 'podman' }} diff --git a/README.md b/README.md index 8803553f89..9bc46b53e5 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ This short clip briefly demonstrates containerlab features and explains its purp Although being kick-started by Nokia engineers, containerlab doesn't take sides and supports NOSes from other vendors and opensource projects. * **Lab orchestration** Starting the containers and interconnecting them alone is already good, but containerlab packages even more features like managing lab lifecycle: [deploy](https://containerlab.dev/cmd/deploy), [destroy](https://containerlab.dev/cmd/destroy), [save](https://containerlab.dev/cmd/save), [inspect](https://containerlab.dev/cmd/inspect), [graph](https://containerlab.dev/cmd/graph) operations. +* **Systemd-capable container runtime controls** + Container nodes can opt into init-friendly runtime settings such as cgroup namespace mode, PID namespace mode, tmpfs mounts, security options, shared memory sizing, and privileged mode. Docker and Podman runtimes honor these settings consistently for containers that need to run systemd or other PID 1 supervisors. * **Scaled labs generator** With [`generate`](https://containerlab.dev/cmd/generate) capabilities of containerlab it possible to define/launch CLOS-based topologies of arbitrary scale. Just say how many tiers you need and how big each tier is, the rest will be done in a split second. * **Simplicity and convenience** diff --git a/cmd/deploy.go b/cmd/deploy.go index ea898bfa1c..336241a640 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -188,7 +188,7 @@ func deployFn(cobraCmd *cobra.Command, o *Options) error { clabcore.ExportRenderedTopology = o.Deploy.ExportRenderedTopology - c, err := clabcore.NewContainerLab(o.ToClabOptions()...) + c, err := clabcore.NewContainerLab(o.ToClabDeployOptions()...) if err != nil { return err } diff --git a/cmd/options.go b/cmd/options.go index 2232946553..299eb9f823 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -175,6 +175,17 @@ func (o *Options) ToClabOptions() []clabcore.ClabOption { return clabOptions } +// ToClabDeployOptions defers node filtering until Deploy knows whether it is +// creating a new lab or reconciling an existing one. +func (o *Options) ToClabDeployOptions() []clabcore.ClabOption { + var options []clabcore.ClabOption + options = append(options, o.Global.toClabOptions()...) + options = append(options, o.Deploy.toClabOptions()...) + options = append(options, o.Destroy.toClabOptions()...) + options = append(options, clabcore.WithDeployNodeFilter(o.Filter.NodeFilter)) + return options +} + func (o *Options) ToClabDestroyOptions() []clabcore.DestroyOption { destroyOptions := []clabcore.DestroyOption{ clabcore.WithDestroyMaxWorkers(o.Deploy.MaxWorkers), diff --git a/core/apply.go b/core/apply.go index 58a0ddf6b0..c09762707e 100644 --- a/core/apply.go +++ b/core/apply.go @@ -4,7 +4,9 @@ import ( "context" "fmt" + "github.com/charmbracelet/log" clablinks "github.com/srl-labs/containerlab/links" + clabtypes "github.com/srl-labs/containerlab/types" ) // ApplyResult summarizes the changes applied by an apply operation. @@ -48,6 +50,7 @@ func applyResultFromPlan(plan *applyPlan) *ApplyResult { result.RestartedNodes = sortedStringSet(unionStringSets( plan.restartNodeSet, plan.linkRestartNodeSet, + plan.postLinkRestartNodeSet, )) for nodeName, reason := range plan.nodeChangeReasons { @@ -132,7 +135,7 @@ func (c *CLab) apply( return nil, err } - if err := c.ResolveLinks(); err != nil { + if err := c.resolveApplyLinks(); err != nil { return nil, err } @@ -140,7 +143,7 @@ func (c *CLab) apply( return nil, err } - plan, err := c.planApply(ctx, currentNodes) + plan, err := c.planApply(ctx, currentNodes, c.nodeFilter) if err != nil { return nil, err } @@ -156,7 +159,9 @@ func (c *CLab) apply( } if plan.empty() { - if options.finalizeNoop { + if plan.mutableNodeSet != nil { + c.writeApplyState(plan) + } else if options.finalizeNoop { if err := c.prepareApply(ctx, nil, options.skipLabDirFileACLs); err != nil { return nil, err } @@ -186,43 +191,295 @@ func (c *CLab) apply( return nil, err } - if err := c.DeployNodes(ctx, deployNodeNames, options.maxWorkers); err != nil { + if err := c.startStoppedNodes(ctx, plan); err != nil { return nil, err } - if err := c.restoreRecreatedNodes(ctx, plan); err != nil { + if err := c.deployApplyNodesAndLinks(ctx, plan, deployNodeNames, options.maxWorkers); err != nil { return nil, err } - if err := c.startStoppedNodes(ctx, plan); err != nil { + if err := c.postDeployApplyNodes(ctx, deployNodeNames, options.skipPostDeploy); err != nil { return nil, err } - if err := c.removeApplyLinkEndpoints(ctx, plan.addedLinks); err != nil { + if err := c.restartApplyNodes(ctx, plan.linkRestartNodeSet); err != nil { return nil, err } - if err := c.deployLinks(ctx, plan.addedLinks, deployNodeNames); err != nil { + if err := c.updateRuntimeInfoForExistingNodes(ctx); err != nil { return nil, err } - if err := c.postDeployApplyNodes(ctx, deployNodeNames, options.skipPostDeploy); err != nil { - return nil, err + if plan.mutableNodeSet != nil { + c.writeApplyState(plan) + if err := c.regenerateApplyArtifacts(ctx, options.exportTemplate); err != nil { + log.Warnf( + "filtered apply completed and state was persisted, but shared artifacts could not be regenerated: %v", + err, + ) + } + } else { + if _, err := c.finalize(ctx, options.exportTemplate, options.graph); err != nil { + return nil, err + } } - if err := c.restartApplyNodes(ctx, plan.linkRestartNodeSet); err != nil { - return nil, err + return applyResultFromPlan(plan), nil +} + +// deployApplyNodesAndLinks advances one dependency batch at a time. Links whose +// endpoint containers are available are attached before the batch health check, +// so shared-netns children cannot start against an unready provider namespace. +func (c *CLab) deployApplyNodesAndLinks( + ctx context.Context, + plan *applyPlan, + nodeNames []string, + maxWorkers uint, +) error { + batches, err := c.applyDeployBatches(nodeNames) + if err != nil { + return err } - if err := c.updateRuntimeInfoForExistingNodes(ctx); err != nil { - return nil, err + available := map[string]struct{}{"host": {}, "mgmt-net": {}} + for nodeName := range plan.currentNodes { + if _, deleted := plan.deletedNodeSet[nodeName]; deleted { + continue + } + if _, recreated := plan.recreatedNodeSet[nodeName]; recreated { + continue + } + available[nodeName] = struct{}{} } + pendingLinks := append([]clablinks.Link(nil), plan.addedLinks...) + postLinkRestarted := map[string]struct{}{} - if _, err := c.finalize(ctx, options.exportTemplate, options.graph); err != nil { - return nil, err + for _, batch := range batches { + // Existing providers may gain a link in the same filtered apply that + // introduces a new shared-netns child. Attach those links and satisfy + // provider health before the child process can observe the namespace. + ready, remaining := applyPartitionReadyLinks(pendingLinks, available) + if err := c.deployFreshApplyLinks(ctx, ready, nil); err != nil { + return err + } + pendingLinks = remaining + if err := c.restartRecreatedApplyLinkNodes( + ctx, plan, ready, pendingLinks, postLinkRestarted, + ); err != nil { + return err + } + for _, nodeName := range batch { + provider, shared := c.applySharedNetNSProvider(c.Nodes[nodeName]) + if !shared { + continue + } + if _, exists := available[provider]; !exists { + continue + } + if err := c.waitNodeHealthyIfAvailable(ctx, c.Nodes[provider]); err != nil { + return err + } + } + + if err := c.deployNodeBatch(ctx, batch, maxWorkers); err != nil { + return err + } + for _, nodeName := range batch { + if err := c.waitNodeRunning(ctx, c.Nodes[nodeName]); err != nil { + return err + } + available[nodeName] = struct{}{} + } + if err := c.restoreRecreatedNodeBatch(ctx, plan, batch, pendingLinks); err != nil { + return err + } + + ready, remaining = applyPartitionReadyLinks(pendingLinks, available) + if err := c.deployFreshApplyLinks(ctx, ready, batch); err != nil { + return err + } + pendingLinks = remaining + if err := c.restartRecreatedApplyLinkNodes( + ctx, plan, ready, pendingLinks, postLinkRestarted, + ); err != nil { + return err + } + + if err := c.waitNodesHealthy(ctx, batch); err != nil { + return err + } + } + ready, remaining := applyPartitionReadyLinks(pendingLinks, available) + if err := c.deployFreshApplyLinks(ctx, ready, nil); err != nil { + return err + } + pendingLinks = remaining + if err := c.restartRecreatedApplyLinkNodes( + ctx, plan, ready, pendingLinks, postLinkRestarted, + ); err != nil { + return err } - return applyResultFromPlan(plan), nil + if len(pendingLinks) != 0 { + return fmt.Errorf("apply links still depend on unavailable nodes: %v", applyLinkNames(pendingLinks)) + } + + return nil +} + +func (c *CLab) restartRecreatedApplyLinkNodes( + ctx context.Context, + plan *applyPlan, + links []clablinks.Link, + pendingLinks []clablinks.Link, + restarted map[string]struct{}, +) error { + nodeSet := map[string]struct{}{} + for _, link := range links { + for _, ep := range clablinks.RuntimeEndpoints(link) { + nodeName := endpointKeyFromEndpoint(ep).node + if _, required := plan.postLinkRestartNodeSet[nodeName]; !required { + continue + } + if _, done := restarted[nodeName]; done { + continue + } + nodeSet[nodeName] = struct{}{} + } + } + for nodeName := range nodeSet { + if linkTouchesNodeSetInList(pendingLinks, map[string]struct{}{nodeName: {}}) { + delete(nodeSet, nodeName) + } + } + if err := c.restartApplyNodes(ctx, nodeSet); err != nil { + return err + } + for nodeName := range nodeSet { + restarted[nodeName] = struct{}{} + } + return nil +} + +func linkTouchesNodeSetInList(links []clablinks.Link, nodeSet map[string]struct{}) bool { + for _, link := range links { + if linkTouchesNodeSet(link, nodeSet) { + return true + } + } + return false +} + +func (c *CLab) deployFreshApplyLinks( + ctx context.Context, + links []clablinks.Link, + additionalNodeNames []string, +) error { + if err := c.removeApplyLinkEndpoints(ctx, links); err != nil { + return err + } + return c.deployLinks(ctx, links, additionalNodeNames) +} + +func applyPartitionReadyLinks( + links []clablinks.Link, + available map[string]struct{}, +) (ready, pending []clablinks.Link) { + for _, link := range links { + linkReady := true + for _, ep := range clablinks.RuntimeEndpoints(link) { + key := endpointKeyFromEndpoint(ep) + if _, exists := available[key.node]; !exists { + linkReady = false + break + } + } + if linkReady { + ready = append(ready, link) + } else { + pending = append(pending, link) + } + } + return ready, pending +} + +// resolveApplyLinks keeps the full desired topology available to reconciliation. +// ResolveLinks normally uses nodeFilter to discard links during a fresh filtered +// deploy; apply instead uses that filter only to limit which nodes may change. +func (c *CLab) resolveApplyLinks() error { + nodeFilter := c.nodeFilter + c.nodeFilter = nil + err := c.ResolveLinks() + c.nodeFilter = nodeFilter + return err +} + +func (c *CLab) writeApplyState(plan *applyPlan) { + var err error + if plan == nil || plan.mutableNodeSet == nil { + err = c.WriteState() + } else { + state := plan.state + if state == nil { + state = &LabState{Topology: clabtypes.NewTopology()} + } + if state.NodeConfigs == nil { + state.NodeConfigs = map[string]*clabtypes.NodeConfig{} + } + for nodeName := range plan.mutableNodeSet { + node, exists := c.Nodes[nodeName] + if !exists { + delete(state.NodeConfigs, nodeName) + continue + } + state.NodeConfigs[nodeName] = c.applyStateNodeConfig(nodeName, node.Config()) + } + err = c.writeLabState(state) + } + if err != nil { + log.Warnf("failed to write state file: %v", err) + } +} + +func (c *CLab) applyStateNodeConfig( + nodeName string, + runtimeConfig *clabtypes.NodeConfig, +) *clabtypes.NodeConfig { + if runtimeConfig == nil { + return nil + } + + applied := *runtimeConfig + desired := resolveNodeConfigFromTopology(c.Config.Topology, nodeName) + if desired == nil { + return &applied + } + + applied.NodeType = desired.NodeType + applied.Kind = desired.Kind + applied.Image = desired.Image + applied.Entrypoint = desired.Entrypoint + applied.Cmd = desired.Cmd + applied.Exec = desired.Exec + applied.Env = desired.Env + applied.Binds = desired.Binds + applied.Devices = desired.Devices + applied.CapAdd = desired.CapAdd + applied.ShmSize = desired.ShmSize + applied.PortSet = desired.PortSet + applied.User = desired.User + applied.NetworkMode = desired.NetworkMode + if desired.Runtime != "" { + applied.Runtime = desired.Runtime + } + applied.CPU = desired.CPU + applied.CPUSet = desired.CPUSet + applied.Memory = desired.Memory + applied.License = desired.License + applied.Components = desired.Components + + return &applied } func (c *CLab) checkApplyTopologyDefinition(ctx context.Context) error { diff --git a/core/apply_test.go b/core/apply_test.go index f66f00e010..2971763f37 100644 --- a/core/apply_test.go +++ b/core/apply_test.go @@ -11,7 +11,9 @@ import ( "time" "github.com/containernetworking/plugins/pkg/ns" + "github.com/google/go-cmp/cmp" clabconstants "github.com/srl-labs/containerlab/constants" + clabexec "github.com/srl-labs/containerlab/exec" clablinks "github.com/srl-labs/containerlab/links" clabmocksmocknodes "github.com/srl-labs/containerlab/mocks/mocknodes" clabmocksmockruntime "github.com/srl-labs/containerlab/mocks/mockruntime" @@ -151,6 +153,139 @@ func TestDeployLinksPostDeploysSelectedNodeWithoutLinks(t *testing.T) { } } +func TestApplyPartitionReadyLinksWaitsForSharedNetNSChildContainer(t *testing.T) { + t.Parallel() + + provider := &applyFakeLinkNode{name: "provider"} + child := &applyFakeLinkNode{name: "child"} + peer := &applyFakeLinkNode{name: "peer"} + providerLink := &applyFakeLink{linkType: clablinks.LinkTypeVEth} + providerLink.endpoints = []clablinks.Endpoint{ + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(provider, "eth1", providerLink)), + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(peer, "eth1", providerLink)), + } + childLink := &applyFakeLink{linkType: clablinks.LinkTypeVEth} + childLink.endpoints = []clablinks.Endpoint{ + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(child, "eth2", childLink)), + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(peer, "eth2", childLink)), + } + + ready, pending := applyPartitionReadyLinks( + []clablinks.Link{childLink, providerLink}, + map[string]struct{}{"provider": {}, "peer": {}}, + ) + if len(ready) != 1 || ready[0] != providerLink { + t.Fatalf("ready links = %v, want provider link only", applyLinkNames(ready)) + } + if len(pending) != 1 || pending[0] != childLink { + t.Fatalf("pending links = %v, want child link only", applyLinkNames(pending)) + } +} + +func TestPendingAddedLinkWaitsForAbsentPeerAndDiscardsParkedEndpoint(t *testing.T) { + t.Parallel() + + btor := &applyFakeLinkNode{name: "btor2"} + wic := &applyFakeLinkNode{name: "wic2"} + link := &applyFakeLink{linkType: clablinks.LinkTypeVEth} + link.endpoints = []clablinks.Endpoint{ + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(btor, "eth1", link)), + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(wic, "cpp2s", link)), + } + + ready, pending := applyPartitionReadyLinks( + []clablinks.Link{link}, + map[string]struct{}{"btor2": {}}, + ) + if len(ready) != 0 || len(pending) != 1 { + t.Fatalf("before WIC creation ready=%v pending=%v, want only pending link", ready, pending) + } + if got := pendingApplyEndpointNames("btor2", pending); !slices.Equal(got, []string{"eth1"}) { + t.Fatalf("parked endpoints to discard = %v, want [eth1]", got) + } + if link.deployCalls != 0 { + t.Fatalf("link deployed %d times before WIC existed", link.deployCalls) + } + + ready, pending = applyPartitionReadyLinks( + pending, + map[string]struct{}{"btor2": {}, "wic2": {}}, + ) + if len(ready) != 1 || len(pending) != 0 { + t.Fatalf("after WIC creation ready=%v pending=%v, want only ready link", ready, pending) + } + for _, ep := range clablinks.RuntimeEndpoints(ready[0]) { + if err := ep.Deploy(context.Background()); err != nil { + t.Fatal(err) + } + } + if err := ready[0].PostDeploy(context.Background()); err != nil { + t.Fatal(err) + } + if link.deployCalls != 2 { + t.Fatalf("endpoint deploy calls = %d, want one per endpoint after both nodes exist", link.deployCalls) + } +} + +func TestPlanRecreatedNodePostLinkRestart(t *testing.T) { + t.Parallel() + + btor := &applyFakeLinkNode{name: "btor1"} + wic := &applyFakeLinkNode{name: "wic1"} + link := &applyFakeLink{linkType: clablinks.LinkTypeVEth} + link.endpoints = []clablinks.Endpoint{ + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(btor, "eth1", link)), + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(wic, "cpp2s", link)), + } + plan := newApplyPlan(nil, nil) + plan.recreatedNodeSet["btor1"] = struct{}{} + plan.addedLinks = []clablinks.Link{link} + + (&CLab{}).planRecreatedNodePostLinkRestarts(plan) + + if _, restart := plan.postLinkRestartNodeSet["btor1"]; !restart { + t.Fatal("recreated link endpoint must restart after its new link is attached") + } + if _, restart := plan.postLinkRestartNodeSet["wic1"]; restart { + t.Fatal("new peer must not be treated as a recreated post-link restart") + } +} + +func TestRestartRecreatedNodeAfterAllAddedLinksAreReady(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + btor := clabmocksmocknodes.NewMockNode(ctrl) + wic := &applyFakeLinkNode{name: "wic1"} + link := &applyFakeLink{linkType: clablinks.LinkTypeVEth} + link.endpoints = []clablinks.Endpoint{ + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(btor, "eth1", link)), + clablinks.NewEndpointDummy(clablinks.NewEndpointGeneric(wic, "cpp2s", link)), + } + btor.EXPECT().GetShortName().Return("btor1").AnyTimes() + btor.EXPECT().Stop(gomock.Any()).Return(nil) + btor.EXPECT().Start(gomock.Any()).Return(nil) + btor.EXPECT().GetContainerStatus(gomock.Any()).Return(clabruntime.Running) + btor.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "btor1"}).AnyTimes() + + c := &CLab{ + Nodes: map[string]clabnodes.Node{"btor1": btor}, + timeout: time.Second, + } + plan := newApplyPlan(nil, nil) + plan.postLinkRestartNodeSet["btor1"] = struct{}{} + restarted := map[string]struct{}{} + + if err := c.restartRecreatedApplyLinkNodes( + context.Background(), plan, []clablinks.Link{link}, nil, restarted, + ); err != nil { + t.Fatal(err) + } + if _, ok := restarted["btor1"]; !ok { + t.Fatal("recreated cEOS-shaped endpoint was not restarted after link readiness") + } +} + func TestApplyRequiresTopologyFile(t *testing.T) { t.Parallel() @@ -379,6 +514,53 @@ func TestApplyPlanLinkNeedsDeploy(t *testing.T) { } } +func TestResolveApplyLinksPreservesCrossFilterLink(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + selected := clabmocksmocknodes.NewMockNode(ctrl) + unselected := clabmocksmocknodes.NewMockNode(ctrl) + for name, node := range map[string]*clabmocksmocknodes.MockNode{ + "selected": selected, "unselected": unselected, + } { + node.EXPECT().GetLinkEndpointType().Return( + clablinks.LinkEndpointType(clablinks.LinkEndpointTypeVeth), + ).AnyTimes() + node.EXPECT().AddEndpoint(gomock.Any()).Return(nil).AnyTimes() + node.EXPECT().GetShortName().Return(name).AnyTimes() + } + topoLink := &clablinks.LinkDefinition{ + Type: "veth", + Link: &clablinks.LinkVEthRaw{ + Endpoints: []*clablinks.EndpointRaw{ + {Node: "selected", Iface: "eth1"}, + {Node: "unselected", Iface: "eth1"}, + }, + }, + } + c := &CLab{ + Config: &Config{ + Topology: &clabtypes.Topology{Links: []*clablinks.LinkDefinition{topoLink}}, + Mgmt: &clabtypes.MgmtNet{}, + }, + Nodes: map[string]clabnodes.Node{ + "selected": selected, + "unselected": unselected, + }, + nodeFilter: []string{"selected"}, + } + + if err := c.resolveApplyLinks(); err != nil { + t.Fatalf("resolveApplyLinks() failed: %v", err) + } + if got, want := len(c.Links), 1; got != want { + t.Fatalf("resolved link count = %d, want %d", got, want) + } + if got, want := c.nodeFilter, []string{"selected"}; !slices.Equal(got, want) { + t.Fatalf("nodeFilter = %v, want %v", got, want) + } +} + func TestPlanRecreatedNodeLinksDeploysAllTouchingLinks(t *testing.T) { t.Parallel() @@ -496,6 +678,293 @@ func TestPlanDeletedEndpointsUsesDiscoveredEndpointNode(t *testing.T) { } } +func TestPlanDeletedEndpointsPreservesUnselectedNodes(t *testing.T) { + t.Parallel() + + selected := &applyFakeLinkNode{name: "selected"} + unrelated := &applyFakeLinkNode{name: "unrelated"} + plan := newApplyPlan(nil, nil) + plan.mutableNodeSet = map[string]struct{}{"selected": {}} + plan.liveEndpointSet = map[applyEndpointKey]struct{}{ + {node: "selected", iface: "eth1"}: {}, + {node: "unrelated", iface: "eth9"}: {}, + } + plan.endpointNodes = map[string]clablinks.Node{ + "selected": selected, + "unrelated": unrelated, + } + + c := &CLab{} + c.planDeletedEndpoints(context.Background(), plan) + + if got, want := len(plan.staleEndpoints), 1; got != want { + t.Fatalf("stale endpoints = %d, want %d", got, want) + } + if got, want := plan.staleEndpoints[0].key.node, "selected"; got != want { + t.Fatalf("stale endpoint node = %q, want %q", got, want) + } +} + +func TestApplyEndpointDiscoveryNodesUsesSharedNetNSProvider(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + provider := clabmocksmocknodes.NewMockNode(ctrl) + child1 := clabmocksmocknodes.NewMockNode(ctrl) + child2 := clabmocksmocknodes.NewMockNode(ctrl) + unrelated := clabmocksmocknodes.NewMockNode(ctrl) + + provider.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "provider", + LongName: "clab-test-provider", + }).AnyTimes() + child1.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "child1", + NetworkMode: "container:provider", + }).AnyTimes() + child2.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "child2", + NetworkMode: "container:clab-test-provider", + }).AnyTimes() + unrelated.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "unrelated"}).AnyTimes() + + c := &CLab{Nodes: map[string]clabnodes.Node{ + "provider": provider, + "child1": child1, + "child2": child2, + "unrelated": unrelated, + }} + nodes := c.applyEndpointDiscoveryNodes(newApplyPlan(nil, nil)) + + if _, exists := nodes["provider"]; !exists { + t.Fatal("provider missing from endpoint discovery") + } + if _, exists := nodes["unrelated"]; !exists { + t.Fatal("unrelated node missing from endpoint discovery") + } + for _, child := range []string{"child1", "child2"} { + if _, exists := nodes[child]; exists { + t.Fatalf("shared-netns child %q must not own discovered interfaces", child) + } + } + + filteredPlan := newApplyPlan(nil, nil) + filteredPlan.mutableNodeSet = map[string]struct{}{"provider": {}} + filteredPlan.endpointOwner = c.applyEndpointOwnerMap() + filteredNodes := c.applyEndpointDiscoveryNodes(filteredPlan) + if _, exists := filteredNodes["provider"]; !exists { + t.Fatal("filtered discovery omitted selected provider") + } + if _, exists := filteredNodes["unrelated"]; exists { + t.Fatal("filtered discovery must not inspect unrelated nodes") + } + if got, want := filteredPlan.endpointKey(applyEndpointKey{node: "child2", iface: "eth1"}).node, "provider"; got != want { + t.Fatalf("shared-netns endpoint owner = %q, want %q", got, want) + } +} + +func TestPlanParkedNodesSkipsSharedNetNSChildren(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + provider := clabmocksmocknodes.NewMockNode(ctrl) + child := clabmocksmocknodes.NewMockNode(ctrl) + + provider.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "provider", + }).AnyTimes() + provider.EXPECT().GetContainerStatus(gomock.Any()).Return(clabruntime.Running) + child.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "child", + NetworkMode: "container:provider", + }).AnyTimes() + + c := &CLab{Nodes: map[string]clabnodes.Node{ + "provider": provider, + "child": child, + }} + plan := newApplyPlan(nil, nil) + plan.recreatedNodeSet = map[string]struct{}{ + "provider": {}, + "child": {}, + } + + c.planParkedNodes(context.Background(), plan) + + if _, parked := plan.parkedNodeSet["provider"]; !parked { + t.Fatal("provider must retain ownership of its parked endpoints") + } + if _, parked := plan.parkedNodeSet["child"]; parked { + t.Fatal("shared-netns child must not park provider-owned endpoints") + } +} + +func TestApplyNodeFilterClosureIncludesDependencies(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + selected := clabmocksmocknodes.NewMockNode(ctrl) + provider := clabmocksmocknodes.NewMockNode(ctrl) + dependency := clabmocksmocknodes.NewMockNode(ctrl) + unrelated := clabmocksmocknodes.NewMockNode(ctrl) + + stages := clabtypes.NewStages() + stages.Create.WaitFor = clabtypes.WaitForList{ + &clabtypes.WaitFor{Node: "dependency", Stage: clabtypes.WaitForCreate}, + } + selected.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "selected", + NetworkMode: "container:clab-test-provider", + Stages: stages, + }).AnyTimes() + provider.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "provider", + LongName: "clab-test-provider", + }).AnyTimes() + dependency.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "dependency"}).AnyTimes() + unrelated.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "unrelated"}).AnyTimes() + + c := &CLab{Nodes: map[string]clabnodes.Node{ + "selected": selected, + "provider": provider, + "dependency": dependency, + "unrelated": unrelated, + }} + closure, err := c.applyNodeFilterClosure([]string{"selected"}) + if err != nil { + t.Fatal(err) + } + for _, nodeName := range []string{"selected", "provider", "dependency"} { + if _, exists := closure[nodeName]; !exists { + t.Fatalf("dependency closure missing %q", nodeName) + } + } + if _, exists := closure["unrelated"]; exists { + t.Fatal("unrelated node unexpectedly included in dependency closure") + } + + if _, err := c.applyNodeFilterClosure([]string{"missing"}); err == nil { + t.Fatal("expected unknown filtered node to fail") + } +} + +func TestWriteFilteredApplyStatePreservesUnselectedBaseline(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + labDir := t.TempDir() + topoFile := filepath.Join(labDir, "filtered.clab.yml") + if err := os.WriteFile(topoFile, []byte("name: filtered\n"), 0o644); err != nil { + t.Fatal(err) + } + topoPaths, err := clabtypes.NewTopoPaths(topoFile, nil) + if err != nil { + t.Fatal(err) + } + if err := topoPaths.SetLabDir(labDir); err != nil { + t.Fatal(err) + } + + oldTopo := clabtypes.NewTopology() + oldTopo.Nodes["selected"] = &clabtypes.NodeDefinition{Image: "old-selected"} + oldTopo.Nodes["unrelated"] = &clabtypes.NodeDefinition{Image: "old-unrelated"} + desiredTopo := clabtypes.NewTopology() + desiredTopo.Nodes["selected"] = &clabtypes.NodeDefinition{ + Kind: "linux", + Image: "new-selected", + Env: map[string]string{"USER_SETTING": "desired"}, + } + desiredTopo.Nodes["unrelated"] = &clabtypes.NodeDefinition{Image: "new-unrelated"} + + selectedNode := clabmocksmocknodes.NewMockNode(ctrl) + selectedNode.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "selected", + LongName: "clab-filtered-selected", + Kind: "linux", + Image: "new-selected", + Runtime: clabruntimedocker.RuntimeName, + Env: map[string]string{ + "USER_SETTING": "desired", + "CLAB_INTFS": "0", + }, + }) + + c := &CLab{ + Config: &Config{Topology: desiredTopo}, + TopoPaths: topoPaths, + Nodes: map[string]clabnodes.Node{ + "selected": selectedNode, + "unrelated": nil, + }, + } + plan := newApplyPlan(nil, &LabState{Topology: oldTopo}) + plan.mutableNodeSet = map[string]struct{}{"selected": {}} + c.writeApplyState(plan) + + state, err := c.LoadState() + if err != nil { + t.Fatal(err) + } + if got, want := state.Topology.Nodes["unrelated"].Image, "old-unrelated"; got != want { + t.Fatalf("unselected baseline image = %q, want %q", got, want) + } + if got, want := state.NodeConfigs["selected"].Image, "new-selected"; got != want { + t.Fatalf("selected applied image = %q, want %q", got, want) + } + if got, want := state.NodeConfigs["selected"].Kind, "linux"; got != want { + t.Fatalf("selected applied kind = %q, want %q", got, want) + } + if got, want := state.NodeConfigs["selected"].LongName, "clab-filtered-selected"; got != want { + t.Fatalf("selected applied long name = %q, want %q", got, want) + } + if got, want := state.NodeConfigs["selected"].Runtime, clabruntimedocker.RuntimeName; got != want { + t.Fatalf("selected applied runtime = %q, want %q", got, want) + } + if got, want := state.NodeConfigs["selected"].Env, map[string]string{"USER_SETTING": "desired"}; !cmp.Equal(got, want) { + t.Fatalf("selected applied env = %v, want %v", got, want) + } + if _, exists := state.NodeConfigs["unrelated"]; exists { + t.Fatal("unselected node must not receive an applied config checkpoint") + } +} + +func TestApplyDeployBatchesOrdersSharedNetNSProvider(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + provider := clabmocksmocknodes.NewMockNode(ctrl) + child1 := clabmocksmocknodes.NewMockNode(ctrl) + child2 := clabmocksmocknodes.NewMockNode(ctrl) + provider.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "provider"}).AnyTimes() + child1.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "child1", + NetworkMode: "container:provider", + }).AnyTimes() + child2.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "child2", + NetworkMode: "container:provider", + }).AnyTimes() + + c := &CLab{Nodes: map[string]clabnodes.Node{ + "provider": provider, + "child1": child1, + "child2": child2, + }} + batches, err := c.applyDeployBatches([]string{"child2", "provider", "child1"}) + if err != nil { + t.Fatal(err) + } + if got, want := len(batches), 2; got != want { + t.Fatalf("batch count = %d, want %d: %v", got, want, batches) + } + if diff := cmp.Diff([]string{"provider"}, batches[0]); diff != "" { + t.Fatalf("provider batch mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff([]string{"child1", "child2"}, batches[1]); diff != "" { + t.Fatalf("child batch mismatch (-want +got):\n%s", diff) + } +} + func TestApplyNodeLinkApplyMode(t *testing.T) { t.Parallel() @@ -751,11 +1220,34 @@ func TestRestartApplyNodesRestartsLinkAffectedNodes(t *testing.T) { ctrl := gomock.NewController(t) mockNode := clabmocksmocknodes.NewMockNode(ctrl) + stages := clabtypes.NewStages() + stages.Configure.Execs = clabtypes.Execs{ + &clabtypes.Exec{ + Command: "configure-enter", + Target: clabtypes.CommandTargetContainer, + Phase: clabtypes.CommandExecutionPhaseEnter, + }, + &clabtypes.Exec{ + Command: "configure-exit", + Target: clabtypes.CommandTargetContainer, + Phase: clabtypes.CommandExecutionPhaseExit, + }, + } mockNode.EXPECT().Stop(gomock.Any()).Return(nil) - mockNode.EXPECT().Start(gomock.Any()).Return(nil) - mockNode.EXPECT().GetContainerStatus(gomock.Any()).Return(clabruntime.Running) - mockNode.EXPECT().Config().Return(&clabtypes.NodeConfig{ShortName: "n1"}).AnyTimes() + mockNode.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "n1", + Stages: stages, + }).AnyTimes() + mockNode.EXPECT().GetShortName().Return("n1").AnyTimes() + gomock.InOrder( + mockNode.EXPECT().Start(gomock.Any()).Return(nil), + mockNode.EXPECT().RunExec(gomock.Any(), execCmdMatcher("configure-enter")). + Return(clabexec.NewExecResult(mustExecCmd(t, "configure-enter")), nil), + mockNode.EXPECT().RunExec(gomock.Any(), execCmdMatcher("configure-exit")). + Return(clabexec.NewExecResult(mustExecCmd(t, "configure-exit")), nil), + mockNode.EXPECT().GetContainerStatus(gomock.Any()).Return(clabruntime.Running), + ) c := &CLab{ Nodes: map[string]clabnodes.Node{ @@ -769,6 +1261,68 @@ func TestRestartApplyNodesRestartsLinkAffectedNodes(t *testing.T) { } } +func TestPostDeployApplyNodesRunsConfigureStageAroundPostDeploy(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mockNode := clabmocksmocknodes.NewMockNode(ctrl) + stages := clabtypes.NewStages() + stages.Configure.Execs = clabtypes.Execs{ + &clabtypes.Exec{ + Command: "configure-enter", + Target: clabtypes.CommandTargetContainer, + Phase: clabtypes.CommandExecutionPhaseEnter, + }, + &clabtypes.Exec{ + Command: "configure-exit", + Target: clabtypes.CommandTargetContainer, + Phase: clabtypes.CommandExecutionPhaseExit, + }, + } + mockNode.EXPECT().Config().Return(&clabtypes.NodeConfig{ + ShortName: "bridge", + Stages: stages, + }).AnyTimes() + mockNode.EXPECT().GetShortName().Return("bridge").AnyTimes() + gomock.InOrder( + mockNode.EXPECT().RunExec(gomock.Any(), execCmdMatcher("configure-enter")). + Return(clabexec.NewExecResult(mustExecCmd(t, "configure-enter")), nil), + mockNode.EXPECT().PostDeploy(gomock.Any(), gomock.Any()).Return(nil), + mockNode.EXPECT().RunExec(gomock.Any(), execCmdMatcher("configure-exit")). + Return(clabexec.NewExecResult(mustExecCmd(t, "configure-exit")), nil), + mockNode.EXPECT().RunExecFromConfig(gomock.Any(), gomock.Any()).Return(nil), + ) + + c := &CLab{Nodes: map[string]clabnodes.Node{"bridge": mockNode}} + if err := c.postDeployApplyNodes(context.Background(), []string{"bridge"}, false); err != nil { + t.Fatal(err) + } +} + +type execCmdStringMatcher string + +func execCmdMatcher(command string) gomock.Matcher { + return execCmdStringMatcher(command) +} + +func (m execCmdStringMatcher) Matches(value any) bool { + cmd, ok := value.(*clabexec.ExecCmd) + return ok && cmd.GetCmdString() == string(m) +} + +func (m execCmdStringMatcher) String() string { + return "exec command " + string(m) +} + +func mustExecCmd(t *testing.T, command string) *clabexec.ExecCmd { + t.Helper() + cmd, err := clabexec.NewExecCmdFromString(command) + if err != nil { + t.Fatal(err) + } + return cmd +} + func TestRuntimeNodeGroupsDistributedComponents(t *testing.T) { t.Parallel() diff --git a/core/config.go b/core/config.go index 1299aef627..97fac9f926 100644 --- a/core/config.go +++ b/core/config.go @@ -247,6 +247,7 @@ func (c *CLab) createNodeCfg( //nolint: funlen nodeCfg := &clabtypes.NodeConfig{ ShortName: nodeName, // just the node name as seen in the topo file LongName: longName, // by default clab-$labName-$nodeName + Hostname: c.Config.Topology.GetNodeHostname(nodeName), Fqdn: strings.Join([]string{nodeName, c.Config.Name, "io"}, "."), LabDir: c.TopoPaths.NodeDir(nodeName), Index: idx, @@ -266,6 +267,11 @@ func (c *CLab) createNodeCfg( //nolint: funlen Runtime: c.Config.Topology.GetNodeRuntime(nodeName), Devices: c.Config.Topology.GetNodeDevices(nodeName), CapAdd: c.Config.Topology.GetNodeCapAdd(nodeName), + Privileged: c.Config.Topology.GetNodePrivileged(nodeName), + CgroupnsMode: c.Config.Topology.GetNodeCgroupnsMode(nodeName), + PidMode: c.Config.Topology.GetNodePidMode(nodeName), + Tmpfs: c.Config.Topology.GetNodeTmpfs(nodeName), + SecurityOpts: c.Config.Topology.GetNodeSecurityOpts(nodeName), ShmSize: c.Config.Topology.GetNodeShmSize(nodeName), CPU: c.Config.Topology.GetNodeCPU(nodeName), CPUSet: c.Config.Topology.GetNodeCPUSet(nodeName), diff --git a/core/dependency_manager/dependency_node.go b/core/dependency_manager/dependency_node.go index 25905737b6..ede2a550e0 100644 --- a/core/dependency_manager/dependency_node.go +++ b/core/dependency_manager/dependency_node.go @@ -70,19 +70,46 @@ func (d *DependencyNode) getStageWG(n clabtypes.WaitForStage) *sync.WaitGroup { func (d *DependencyNode) getExecs(stage clabtypes.WaitForStage, execPhase clabtypes.ExecPhase, ) ([]*clabtypes.Exec, error) { + return getStageExecs(d.Config(), stage, execPhase) +} + +func getStageExecs( + cfg *clabtypes.NodeConfig, + stage clabtypes.WaitForStage, + execPhase clabtypes.ExecPhase, +) ([]*clabtypes.Exec, error) { + if cfg == nil || cfg.Stages == nil { + return nil, nil + } + var sb clabtypes.StageBase switch stage { case clabtypes.WaitForCreate: - sb = d.Config().Stages.Create.StageBase + if cfg.Stages.Create == nil { + return nil, nil + } + sb = cfg.Stages.Create.StageBase case clabtypes.WaitForCreateLinks: - sb = d.Config().Stages.CreateLinks.StageBase + if cfg.Stages.CreateLinks == nil { + return nil, nil + } + sb = cfg.Stages.CreateLinks.StageBase case clabtypes.WaitForConfigure: - sb = d.Config().Stages.Configure.StageBase + if cfg.Stages.Configure == nil { + return nil, nil + } + sb = cfg.Stages.Configure.StageBase case clabtypes.WaitForHealthy: - sb = d.Config().Stages.Healthy.StageBase + if cfg.Stages.Healthy == nil { + return nil, nil + } + sb = cfg.Stages.Healthy.StageBase case clabtypes.WaitForExit: - sb = d.Config().Stages.Exit.StageBase + if cfg.Stages.Exit == nil { + return nil, nil + } + sb = cfg.Stages.Exit.StageBase default: return nil, fmt.Errorf("stage %s unknown", stage) } @@ -116,9 +143,21 @@ func (d *DependencyNode) runExecs( execPhase clabtypes.ExecPhase, stage clabtypes.WaitForStage, ) { - execs, err := d.getExecs(stage, execPhase) + RunStageExecs(ctx, d, execPhase, stage) +} + +// RunStageExecs executes a node's commands for one deployment stage phase. +// Lifecycle operations use this after restoring links because they do not run +// through the normal dependency worker stage transitions. +func RunStageExecs( + ctx context.Context, + node clabnodes.Node, + execPhase clabtypes.ExecPhase, + stage clabtypes.WaitForStage, +) { + execs, err := getStageExecs(node.Config(), stage, execPhase) if err != nil { - log.Errorf("error getting exec commands defined for %s: %v", d.GetShortName(), err) + log.Errorf("error getting exec commands defined for %s: %v", node.GetShortName(), err) } if len(execs) == 0 { @@ -136,23 +175,24 @@ func (d *DependencyNode) runExecs( execCmd, err := exec.GetExecCmd() if err != nil { log.Errorf( - "%s stage %s error parsing command: %s", d.GetShortName(), stage, exec.String(), + "%s stage %s error parsing command: %s", node.GetShortName(), stage, exec.String(), ) + continue } switch exec.Target { case clabtypes.CommandTargetContainer: - execResult, err = d.RunExec(ctx, execCmd) - hostname = d.GetShortName() + execResult, err = node.RunExec(ctx, execCmd) + hostname = node.GetShortName() case clabtypes.CommandTargetHost: execResult, err = clabnodeshost.RunExec(ctx, execCmd) - hostname = fmt.Sprintf("host via %s", d.GetShortName()) + hostname = fmt.Sprintf("host via %s", node.GetShortName()) default: continue } if err != nil { - log.Errorf("error on exec in node %s for stage %s: %v", d.GetShortName(), stage, err) + log.Errorf("error on exec in node %s for stage %s: %v", node.GetShortName(), stage, err) } else { execResultCollection.Add(hostname, execResult) } diff --git a/core/deploy.go b/core/deploy.go index bddbc3a5d6..dfb1e20006 100644 --- a/core/deploy.go +++ b/core/deploy.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -13,10 +14,12 @@ import ( "github.com/charmbracelet/log" clabcert "github.com/srl-labs/containerlab/cert" clabconstants "github.com/srl-labs/containerlab/constants" + clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" clabexec "github.com/srl-labs/containerlab/exec" clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" + clabtypes "github.com/srl-labs/containerlab/types" clabutils "github.com/srl-labs/containerlab/utils" ) @@ -67,6 +70,11 @@ func (c *CLab) Deploy( return nil, err } if initialDeploy { + if len(c.nodeFilter) > 0 { + if err := c.filterClabNodes(c.nodeFilter); err != nil { + return nil, err + } + } if options.dryRun { return &DeployResult{ Apply: &ApplyResult{ @@ -107,7 +115,12 @@ func (c *CLab) Deploy( return &DeployResult{Apply: applyResult}, nil } - containers, err := c.ListNodesContainers(ctx) + var containers []clabruntime.GenericContainer + if len(c.nodeFilter) > 0 { + containers, err = c.ListNodesContainersIgnoreNotFound(ctx) + } else { + containers, err = c.ListNodesContainers(ctx) + } if err != nil { return nil, err } @@ -134,15 +147,6 @@ func (c *CLab) checkReconcileDeployOptions(options *DeployOptions) error { ) } - if len(c.nodeFilter) > 0 { - return fmt.Errorf( - "node filter is not supported when reconciling deployed lab %q: "+ - "running nodes excluded by the filter would be deleted; "+ - "remove the filter or use --reconfigure", - c.Config.Name, - ) - } - return nil } @@ -460,10 +464,60 @@ func (c *CLab) DeployNodes( ctx context.Context, nodeNames []string, maxWorkers uint, +) error { + if err := c.deployNodesUntilRunning(ctx, nodeNames, maxWorkers); err != nil { + return err + } + return c.waitNodesHealthy(ctx, nodeNames) +} + +// deployNodesUntilRunning creates dependency batches without waiting for +// healthchecks that may require topology interfaces. Apply attaches or restores +// those interfaces before calling waitNodesHealthy. +func (c *CLab) deployNodesUntilRunning( + ctx context.Context, + nodeNames []string, + maxWorkers uint, ) error { if len(nodeNames) == 0 { return nil } + batches, err := c.applyDeployBatches(nodeNames) + if err != nil { + return err + } + for _, batch := range batches { + if err := c.deployNodeBatch(ctx, batch, maxWorkers); err != nil { + return err + } + for _, nodeName := range batch { + node := c.Nodes[nodeName] + if err := c.waitNodeRunning(ctx, node); err != nil { + return err + } + } + } + return nil +} + +func (c *CLab) waitNodesHealthy(ctx context.Context, nodeNames []string) error { + for _, nodeName := range nodeNames { + node, exists := c.Nodes[nodeName] + if !exists { + return fmt.Errorf("node %q not found", nodeName) + } + if err := c.waitNodeHealthyIfAvailable(ctx, node); err != nil { + return err + } + } + return nil +} + +func (c *CLab) deployNodeBatch( + ctx context.Context, + nodeNames []string, + maxWorkers uint, +) error { if maxWorkers == 0 || int(maxWorkers) > len(nodeNames) { maxWorkers = uint(len(nodeNames)) @@ -505,6 +559,68 @@ func (c *CLab) DeployNodes( return nil } +func (c *CLab) applyDeployBatches(nodeNames []string) ([][]string, error) { + pending := make(map[string]map[string]struct{}, len(nodeNames)) + deploySet := make(map[string]struct{}, len(nodeNames)) + for _, nodeName := range nodeNames { + deploySet[nodeName] = struct{}{} + } + for nodeName := range deploySet { + dependencies := map[string]struct{}{} + node := c.Nodes[nodeName] + if provider, shared := c.applySharedNetNSProvider(node); shared { + if _, deploying := deploySet[provider]; deploying { + dependencies[provider] = struct{}{} + } + } + if node != nil && node.Config() != nil && node.Config().Stages != nil { + for _, waitForList := range node.Config().Stages.GetWaitFor() { + for _, waitFor := range waitForList { + if waitFor == nil { + continue + } + if _, deploying := deploySet[waitFor.Node]; deploying { + dependencies[waitFor.Node] = struct{}{} + } + } + } + } + pending[nodeName] = dependencies + } + + var batches [][]string + completed := map[string]struct{}{} + for len(pending) > 0 { + var ready []string + for nodeName, dependencies := range pending { + allComplete := true + for dependency := range dependencies { + if _, complete := completed[dependency]; !complete { + allComplete = false + break + } + } + if allComplete { + ready = append(ready, nodeName) + } + } + if len(ready) == 0 { + return nil, fmt.Errorf( + "apply node dependencies contain a cycle among %v", + sortedStringSet(deploySet), + ) + } + sort.Strings(ready) + batches = append(batches, ready) + for _, nodeName := range ready { + delete(pending, nodeName) + completed[nodeName] = struct{}{} + } + } + + return batches, nil +} + func (c *CLab) deployNode(ctx context.Context, node clabnodes.Node) error { nodeName := node.GetShortName() if err := node.PreDeploy(ctx, &clabnodes.PreDeployParams{ @@ -578,12 +694,18 @@ func (c *CLab) postDeployApplyNodes( for _, nodeName := range nodeNames { node := c.Nodes[nodeName] + clabcoredependency_manager.RunStageExecs( + ctx, node, clabtypes.CommandExecutionPhaseEnter, clabtypes.WaitForConfigure, + ) if !skipPostDeploy { if err := node.PostDeploy(ctx, &clabnodes.PostDeployParams{Nodes: c.Nodes}); err != nil { return fmt.Errorf("node %q post-deploy: %w", nodeName, err) } } + clabcoredependency_manager.RunStageExecs( + ctx, node, clabtypes.CommandExecutionPhaseExit, clabtypes.WaitForConfigure, + ) if err := node.RunExecFromConfig(ctx, execCollection); err != nil { log.Errorf("failed to run exec commands for %s: %v", nodeName, err) @@ -595,6 +717,53 @@ func (c *CLab) postDeployApplyNodes( return nil } +func (c *CLab) regenerateApplyArtifacts(ctx context.Context, exportTemplate string) error { + runtimeNodes, err := c.runtimeNodeGroups(ctx) + if err != nil { + return err + } + artifactLab := c.applyArtifactLab(runtimeNodes) + + if err := artifactLab.GenerateInventories(); err != nil { + return err + } + + topoDataF, err := os.Create(artifactLab.TopoPaths.TopoExportFile()) + if err != nil { + return err + } + defer topoDataF.Close() + + if err := artifactLab.GenerateExports(ctx, topoDataF, exportTemplate); err != nil { + return err + } + + if !artifactLab.skipMgmtNetwork() { + log.Info("Updating host entries", "path", "/etc/hosts") + if err := artifactLab.appendHostsFileEntries(ctx); err != nil { + log.Errorf("failed to update hosts file: %v", err) + } + } + + log.Info("Updating SSH config for nodes", "path", artifactLab.TopoPaths.SSHConfigPath()) + if err := artifactLab.addSSHConfig(); err != nil { + log.Errorf("failed to create ssh config file: %v", err) + } + + return nil +} + +func (c *CLab) applyArtifactLab(runtimeNodes map[string]*runtimeNodeGroup) *CLab { + artifactLab := *c + artifactLab.Nodes = make(map[string]clabnodes.Node, len(runtimeNodes)) + for nodeName := range runtimeNodes { + if node, exists := c.Nodes[nodeName]; exists { + artifactLab.Nodes[nodeName] = node + } + } + return &artifactLab +} + func (c *CLab) finalize( ctx context.Context, exportTemplate string, diff --git a/core/destroy.go b/core/destroy.go index 03a69c5fa0..07e74a0af2 100644 --- a/core/destroy.go +++ b/core/destroy.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" "sync" @@ -163,6 +164,7 @@ func (c *CLab) makeCopyForDestroy( if err != nil { return nil, err } + cc.nodeFilter = append([]string(nil), opts.nodeFilter...) if labDir != "" && clabutils.FileOrDirExists(labDir) { // adjust the labdir. Usually we take the PWD. but now on destroy time, @@ -173,6 +175,10 @@ func (c *CLab) makeCopyForDestroy( } } + if err := cc.restoreAppliedStateNodes(); err != nil { + return nil, err + } + err = clablinks.SetMgmtNetUnderlyingBridge(cc.Config.Mgmt.Bridge) if err != nil { return nil, err @@ -196,6 +202,67 @@ func (c *CLab) makeCopyForDestroy( return cc, nil } +// restoreAppliedStateNodes adds nodes introduced by a filtered apply back to the +// destroy graph. The original topology template may not render those nodes +// without the vars used by apply, while the applied state contains their fully +// resolved configuration. +func (c *CLab) restoreAppliedStateNodes() error { + state, err := c.LoadState() + if err != nil { + return fmt.Errorf("failed to load applied state for destroy: %w", err) + } + if state == nil { + return nil + } + + for _, nodeName := range sortedNodeConfigNames(state.NodeConfigs) { + if !c.nodeInDestroyScope(nodeName) { + continue + } + if _, exists := c.Nodes[nodeName]; exists { + continue + } + + cfg := state.NodeConfigs[nodeName] + if cfg == nil { + continue + } + + runtimeName := cfg.Runtime + if runtimeName == "" { + runtimeName = c.globalRuntimeName + } + r, exists := c.Runtimes[runtimeName] + if !exists { + return fmt.Errorf("runtime %q for applied node %q is not configured", runtimeName, nodeName) + } + + n, err := c.Reg.NewNodeOfKind(cfg.Kind) + if err != nil { + return fmt.Errorf("failed to restore applied node %q: %w", nodeName, err) + } + if err := n.Init(cfg, clabnodes.WithRuntime(r), clabnodes.WithMgmtNet(c.Config.Mgmt)); err != nil { + return fmt.Errorf("failed to initialize applied node %q for destroy: %w", nodeName, err) + } + c.Nodes[nodeName] = n + } + + return nil +} + +func (c *CLab) nodeInDestroyScope(nodeName string) bool { + return len(c.nodeFilter) == 0 || slices.Contains(c.nodeFilter, nodeName) +} + +func sortedNodeConfigNames(configs map[string]*clabtypes.NodeConfig) []string { + names := make([]string, 0, len(configs)) + for name := range configs { + names = append(names, name) + } + sort.Strings(names) + return names +} + func (c *CLab) destroyLabDirs(topos map[string]string, all bool) error { if len(topos) == 0 { log.Info("no containerlab containers found") @@ -269,11 +336,23 @@ func (c *CLab) destroy(ctx context.Context, maxWorkers uint, keepMgmtNet bool) e // If we have nodes defined, use the normal node-based deletion. // Otherwise, delete containers directly via the runtime (for destroy-by-name-only case). if len(c.Nodes) > 0 { - c.deleteNodes(ctx, maxWorkers) + c.deleteNodes(ctx, maxWorkers, len(c.nodeFilter) == 0) } else { c.deleteContainersDirect(ctx, containers) } + if len(c.nodeFilter) != 0 { + // A filtered destroy only owns the selected node containers and their + // netns symlinks. Lab-wide tools, host/SSH files, and the management + // network remain owned by the unrelated nodes still in the lab. + for _, node := range c.Nodes { + if err = node.DeleteNetnsSymlink(); err != nil { + return fmt.Errorf("error while deleting netns symlinks: %w", err) + } + } + return nil + } + c.deleteToolContainers(ctx) log.Info("Removing host entries", "path", "/etc/hosts") @@ -362,47 +441,21 @@ func (c *CLab) deleteApplyNodes(ctx context.Context, plan *applyPlan) error { return nil } -func (c *CLab) deleteNodes(ctx context.Context, workers uint) { - wg := new(sync.WaitGroup) - - concurrentChan := make(chan clabnodes.Node) - - workerFunc := func(i uint, input chan clabnodes.Node, wg *sync.WaitGroup) { - defer wg.Done() - - for { - select { - case n := <-input: - if n == nil { - log.Debugf("Worker %d terminating...", i) - return - } - - err := n.Delete(ctx) - if err != nil { - log.Errorf("could not remove container %q: %v", n.Config().LongName, err) - } - case <-ctx.Done(): - return - } - } +func (c *CLab) deleteNodes(ctx context.Context, workers uint, deleteSpecialNodes bool) { + nodeNames := sortedNodeNames(c.Nodes) + batches, err := c.applyDeployBatches(nodeNames) + if err != nil { + log.Errorf("could not determine dependency-safe node deletion order: %v", err) + batches = [][]string{nodeNames} } - - // start concurrent workers - wg.Add(int(workers)) - - for i := range workers { - go workerFunc(i, concurrentChan, wg) + for i := len(batches) - 1; i >= 0; i-- { + c.deleteNodeBatch(ctx, batches[i], workers) } - // send nodes to workers - for _, n := range c.Nodes { - concurrentChan <- n + if !deleteSpecialNodes { + return } - // close channel to terminate the workers - close(concurrentChan) - // also call delete on the special nodes for _, n := range c.getSpecialLinkNodes() { err := n.Delete(ctx) @@ -411,6 +464,34 @@ func (c *CLab) deleteNodes(ctx context.Context, workers uint) { } } +} + +func (c *CLab) deleteNodeBatch(ctx context.Context, nodeNames []string, workers uint) { + if len(nodeNames) == 0 { + return + } + if workers == 0 || int(workers) > len(nodeNames) { + workers = uint(len(nodeNames)) + } + + wg := new(sync.WaitGroup) + input := make(chan clabnodes.Node) + wg.Add(int(workers)) + for i := range workers { + go func() { + defer wg.Done() + for n := range input { + if err := n.Delete(ctx); err != nil { + log.Errorf("could not remove container %q: %v", n.Config().LongName, err) + } + } + log.Debugf("Worker %d terminating...", i) + }() + } + for _, nodeName := range nodeNames { + input <- c.Nodes[nodeName] + } + close(input) wg.Wait() } diff --git a/core/destroy_test.go b/core/destroy_test.go index e1b4cb714e..a06a054cfe 100644 --- a/core/destroy_test.go +++ b/core/destroy_test.go @@ -72,3 +72,18 @@ func TestWithLabNameOnly_setsNameWithoutTopologyFile(t *testing.T) { t.Fatal("topology file should not be set for lab-name-only init") } } + +func TestFilteredDestroyScopesAppliedStateRestoration(t *testing.T) { + t.Parallel() + + c := &CLab{nodeFilter: []string{"selected"}} + if !c.nodeInDestroyScope("selected") { + t.Fatal("selected node is outside filtered destroy scope") + } + if c.nodeInDestroyScope("unrelated") { + t.Fatal("unrelated applied-state node entered filtered destroy scope") + } + if !(&CLab{}).nodeInDestroyScope("any") { + t.Fatal("unfiltered destroy must include every restored state node") + } +} diff --git a/core/hostsfile_test.go b/core/hostsfile_test.go index a810acf155..ca1cf32044 100644 --- a/core/hostsfile_test.go +++ b/core/hostsfile_test.go @@ -13,9 +13,49 @@ import ( "sync" "testing" + clabmocksmocknodes "github.com/srl-labs/containerlab/mocks/mocknodes" clabnodes "github.com/srl-labs/containerlab/nodes" + clabtypes "github.com/srl-labs/containerlab/types" + "go.uber.org/mock/gomock" ) +func TestFilteredApplyHostsArtifactsExcludeAbsentUnselectedNodes(t *testing.T) { + withTestHostsFiles(t, t.TempDir()) + + ctrl := gomock.NewController(t) + live := clabmocksmocknodes.NewMockNode(ctrl) + absent := clabmocksmocknodes.NewMockNode(ctrl) + live.EXPECT().GetHostsEntries(gomock.Any()).Return(clabtypes.HostEntries{ + clabtypes.NewHostEntry("192.0.2.10", "selected", clabtypes.IpVersionV4), + }, nil) + + c := &CLab{ + Config: &Config{Name: "filtered-artifacts"}, + Nodes: map[string]clabnodes.Node{ + "selected": live, + "absent": absent, + }, + } + artifactLab := c.applyArtifactLab(map[string]*runtimeNodeGroup{"selected": {}}) + if err := artifactLab.appendHostsFileEntries(context.Background()); err != nil { + t.Fatal(err) + } + + contents, err := os.ReadFile(clabHostsFilename) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), "192.0.2.10\tselected") { + t.Fatalf("selected live node missing from hosts artifacts:\n%s", contents) + } + if len(artifactLab.Nodes) != 1 || artifactLab.Nodes["selected"] != live { + t.Fatalf("artifact node scope = %#v, want selected live node only", artifactLab.Nodes) + } + if len(c.Nodes) != 2 || c.Nodes["absent"] != absent { + t.Fatal("filtered artifact scoping mutated the full desired node map") + } +} + // withTestHostsFiles redirects the package hosts/lock paths to files in tmp // and seeds a baseline hosts file. func withTestHostsFiles(t *testing.T, tmp string) { diff --git a/core/lifecycle.go b/core/lifecycle.go index e0fece77f7..7355449e3c 100644 --- a/core/lifecycle.go +++ b/core/lifecycle.go @@ -3,15 +3,28 @@ package core import ( "context" "fmt" + "slices" "strings" "time" "github.com/charmbracelet/log" + clabcoredependency_manager "github.com/srl-labs/containerlab/core/dependency_manager" claberrors "github.com/srl-labs/containerlab/errors" + clablinks "github.com/srl-labs/containerlab/links" clabnodes "github.com/srl-labs/containerlab/nodes" clabruntime "github.com/srl-labs/containerlab/runtime" + clabtypes "github.com/srl-labs/containerlab/types" ) +func runLifecycleConfigureStage(ctx context.Context, node clabnodes.Node) { + clabcoredependency_manager.RunStageExecs( + ctx, node, clabtypes.CommandExecutionPhaseEnter, clabtypes.WaitForConfigure, + ) + clabcoredependency_manager.RunStageExecs( + ctx, node, clabtypes.CommandExecutionPhaseExit, clabtypes.WaitForConfigure, + ) +} + // lifecycleNodes resolves the requested node names without applying lifecycle policy. func (c *CLab) lifecycleNodes(nodeNames []string) ([]clabnodes.Node, error) { var nodes []clabnodes.Node @@ -39,6 +52,176 @@ func (c *CLab) lifecycleNodes(nodeNames []string) ([]clabnodes.Node, error) { return nodes, nil } +func (c *CLab) lifecycleStartNodes( + nodeNames []string, +) ([]clabnodes.Node, map[string]clabtypes.WaitForList, error) { + selected := map[string]struct{}{} + deps := map[string]clabtypes.WaitForList{} + + var addWithDependencies func(string) error + addWithDependencies = func(name string) error { + n, ok := c.Nodes[name] + if !ok { + return fmt.Errorf( + "%w: node %q is not present in the topology", + claberrors.ErrIncorrectInput, + name, + ) + } + + if _, exists := selected[name]; exists { + return nil + } + selected[name] = struct{}{} + + for _, dep := range c.lifecycleDependencies(n) { + if _, ok := c.Nodes[dep.Node]; !ok { + return fmt.Errorf("dependee node %s not found", dep.Node) + } + deps[name] = append(deps[name], dep) + if err := addWithDependencies(dep.Node); err != nil { + return err + } + } + + return nil + } + + if len(nodeNames) > 0 { + for _, name := range nodeNames { + if err := addWithDependencies(name); err != nil { + return nil, nil, err + } + } + } else { + names := make([]string, 0, len(c.Nodes)) + for name := range c.Nodes { + names = append(names, name) + } + slices.Sort(names) + + for _, name := range names { + if err := addWithDependencies(name); err != nil { + return nil, nil, err + } + } + } + + orderedNames, err := lifecycleToposort(selected, deps) + if err != nil { + return nil, nil, err + } + + nodes := make([]clabnodes.Node, 0, len(orderedNames)) + for _, name := range orderedNames { + nodes = append(nodes, c.Nodes[name]) + } + + return nodes, deps, nil +} + +func (c *CLab) lifecycleDependencies(n clabnodes.Node) clabtypes.WaitForList { + var deps clabtypes.WaitForList + + if stages := n.Config().Stages; stages != nil { + for _, waitForNodes := range stages.GetWaitFor() { + deps = append(deps, waitForNodes...) + } + } + + netModeArr := strings.SplitN(n.Config().NetworkMode, ":", 2) //nolint: mnd + if netModeArr[0] == "container" && len(netModeArr) == 2 { + if _, exists := c.Nodes[netModeArr[1]]; exists { + deps = append(deps, &clabtypes.WaitFor{ + Node: netModeArr[1], + Stage: clabtypes.WaitForCreate, + }) + } + } + + return deps +} + +func lifecycleToposort( + selected map[string]struct{}, + deps map[string]clabtypes.WaitForList, +) ([]string, error) { + var ordered []string + visiting := map[string]bool{} + visited := map[string]bool{} + + var visit func(string) error + visit = func(name string) error { + if visited[name] { + return nil + } + if visiting[name] { + return fmt.Errorf("cyclic lifecycle dependencies found at node %q", name) + } + + visiting[name] = true + for _, dep := range deps[name] { + if _, ok := selected[dep.Node]; !ok { + continue + } + if err := visit(dep.Node); err != nil { + return err + } + } + visiting[name] = false + visited[name] = true + ordered = append(ordered, name) + + return nil + } + + names := make([]string, 0, len(selected)) + for name := range selected { + names = append(names, name) + } + slices.Sort(names) + + for _, name := range names { + if err := visit(name); err != nil { + return nil, err + } + } + + return ordered, nil +} + +func (c *CLab) waitForLifecycleNodeHealthy( + ctx context.Context, + nodeName string, + depName string, +) error { + dep, ok := c.Nodes[depName] + if !ok { + return fmt.Errorf("dependee node %s not found", depName) + } + + for { + healthy, err := dep.IsHealthy(ctx) + if err != nil { + return fmt.Errorf( + "node %q waiting for node %q healthy stage: %w", + nodeName, + depName, + err, + ) + } + if healthy { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } +} + func (c *CLab) parkRecreatedNodes(ctx context.Context, plan *applyPlan) error { for _, nodeName := range sortedStringSet(plan.parkedNodeSet) { node, exists := c.Nodes[nodeName] @@ -55,11 +238,36 @@ func (c *CLab) parkRecreatedNodes(ctx context.Context, plan *applyPlan) error { } func (c *CLab) restoreRecreatedNodes(ctx context.Context, plan *applyPlan) error { - for _, nodeName := range sortedStringSet(plan.parkedNodeSet) { + return c.restoreRecreatedNodeBatch(ctx, plan, sortedStringSet(plan.parkedNodeSet), nil) +} + +func (c *CLab) restoreRecreatedNodeBatch( + ctx context.Context, + plan *applyPlan, + nodeNames []string, + pendingLinks []clablinks.Link, +) error { + for _, nodeName := range nodeNames { + if _, parked := plan.parkedNodeSet[nodeName]; !parked { + continue + } node, exists := c.Nodes[nodeName] if !exists { continue } + pendingIfaceNames := pendingApplyEndpointNames(nodeName, pendingLinks) + if len(pendingIfaceNames) > 0 { + log.Info( + "Discarding parked interfaces for pending added links", + "node", nodeName, + "interfaces", pendingIfaceNames, + ) + if err := clablinks.RemoveParkedInterfaces( + ctx, node.Config().LongName, pendingIfaceNames, + ); err != nil { + return fmt.Errorf("failed preparing parked endpoints for node %q: %w", nodeName, err) + } + } log.Info("Restoring links after recreate", "node", nodeName) if err := node.RestoreEndpoints(ctx); err != nil { return fmt.Errorf("failed restoring endpoints for node %q: %w", nodeName, err) @@ -69,6 +277,18 @@ func (c *CLab) restoreRecreatedNodes(ctx context.Context, plan *applyPlan) error return nil } +func pendingApplyEndpointNames(nodeName string, links []clablinks.Link) []string { + ifaceSet := map[string]struct{}{} + for _, link := range links { + for _, ep := range clablinks.RuntimeEndpoints(link) { + if endpointKeyFromEndpoint(ep).node == nodeName { + ifaceSet[ep.GetIfaceName()] = struct{}{} + } + } + } + return sortedStringSet(ifaceSet) +} + func (c *CLab) startStoppedNodes(ctx context.Context, plan *applyPlan) error { for _, nodeName := range sortedStringSet(plan.startNodeSet) { node, exists := c.Nodes[nodeName] @@ -79,6 +299,7 @@ func (c *CLab) startStoppedNodes(ctx context.Context, plan *applyPlan) error { if err := node.Start(ctx); err != nil { return fmt.Errorf("failed starting node %q: %w", nodeName, err) } + runLifecycleConfigureStage(ctx, node) } return nil @@ -103,6 +324,7 @@ func (c *CLab) restartApplyNodes( if err := node.Start(ctx); err != nil { return err } + runLifecycleConfigureStage(ctx, node) if err := c.waitNodeRunning(ctx, node); err != nil { return err } diff --git a/core/lifecycle_test.go b/core/lifecycle_test.go new file mode 100644 index 0000000000..e4356c2669 --- /dev/null +++ b/core/lifecycle_test.go @@ -0,0 +1,84 @@ +package core + +import ( + "slices" + "testing" + + clabtypes "github.com/srl-labs/containerlab/types" + "go.uber.org/mock/gomock" +) + +func TestLifecycleStartNodesIncludesDependenciesInOrder(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + c := CLab{ + Nodes: getNodeMap(mockCtrl), + } + + nodes, deps, err := c.lifecycleStartNodes([]string{"node5"}) + if err != nil { + t.Fatalf("lifecycleStartNodes() error = %v", err) + } + + got := make([]string, 0, len(nodes)) + for _, n := range nodes { + got = append(got, n.Config().ShortName) + } + + want := []string{"node1", "node2", "node3", "node4", "node5"} + if !slices.Equal(got, want) { + t.Fatalf("lifecycleStartNodes() order = %v, want %v", got, want) + } + + if len(deps["node5"]) != 2 { + t.Fatalf("node5 dependencies = %d, want 2", len(deps["node5"])) + } +} + +func TestLifecycleStartNodesIncludesContainerNetworkModeDependency(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + c := CLab{ + Nodes: getNodeMap(mockCtrl), + } + + _, deps, err := c.lifecycleStartNodes([]string{"node3"}) + if err != nil { + t.Fatalf("lifecycleStartNodes() error = %v", err) + } + + var found bool + for _, dep := range deps["node3"] { + if dep.Node == "node2" && dep.Stage == clabtypes.WaitForCreate { + found = true + } + } + + if !found { + t.Fatalf("node3 dependencies = %#v, want network-mode dependency on node2 create", deps["node3"]) + } +} + +func TestLifecycleStartNodesDetectsCycles(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + nodes := getNodeMap(mockCtrl) + nodes["node1"].Config().Stages.Create.WaitFor = clabtypes.WaitForList{ + &clabtypes.WaitFor{ + Node: "node2", + Stage: clabtypes.WaitForCreate, + }, + } + + c := CLab{ + Nodes: nodes, + } + + _, _, err := c.lifecycleStartNodes([]string{"node2"}) + if err == nil { + t.Fatal("lifecycleStartNodes() error = nil, want cycle error") + } +} diff --git a/core/options_clab.go b/core/options_clab.go index 6f7c9e1e2c..9c5b530232 100644 --- a/core/options_clab.go +++ b/core/options_clab.go @@ -301,3 +301,13 @@ func WithNodeFilter(nodeFilter []string) ClabOption { return c.filterClabNodes(nodeFilter) } } + +// WithDeployNodeFilter records a deploy filter without immediately removing +// other topology nodes. Deploy applies the destructive filter only for a fresh +// lab; reconciliation needs the full topology as immutable context. +func WithDeployNodeFilter(nodeFilter []string) ClabOption { + return func(c *CLab) error { + c.nodeFilter = append([]string(nil), nodeFilter...) + return nil + } +} diff --git a/core/restart.go b/core/restart.go index e752dbd1e3..bae9db1553 100644 --- a/core/restart.go +++ b/core/restart.go @@ -23,6 +23,7 @@ func (c *CLab) RestartNodes(ctx context.Context, nodeNames []string) error { if err := n.Start(ctx); err != nil { return err } + runLifecycleConfigureStage(ctx, n) } return nil diff --git a/core/runtime_state.go b/core/runtime_state.go index 07be4dd0f8..3945a19115 100644 --- a/core/runtime_state.go +++ b/core/runtime_state.go @@ -155,9 +155,14 @@ func (c *CLab) setMgmtBridgeFromRuntime( } func (c *CLab) updateRuntimeInfoForExistingNodes(ctx context.Context) error { - for _, nodeName := range sortedNodeNames(c.Nodes) { - node := c.Nodes[nodeName] - if node.GetContainerStatus(ctx) == clabruntime.NotFound { + runtimeNodes, err := c.runtimeNodeGroups(ctx) + if err != nil { + return err + } + + for _, nodeName := range sortedRuntimeNodeGroupNames(runtimeNodes) { + node, exists := c.Nodes[nodeName] + if !exists { continue } if err := node.UpdateConfigWithRuntimeInfo(ctx); err != nil { diff --git a/core/start.go b/core/start.go index ff16ec21ed..b7c37a0f8a 100644 --- a/core/start.go +++ b/core/start.go @@ -2,6 +2,8 @@ package core import ( "context" + + clabtypes "github.com/srl-labs/containerlab/types" ) // StartNodes starts one or more stopped nodes and restores their parked interfaces back into the @@ -11,15 +13,26 @@ func (c *CLab) StartNodes(ctx context.Context, nodeNames []string) error { return err } - nodes, err := c.lifecycleNodes(nodeNames) + nodes, deps, err := c.lifecycleStartNodes(nodeNames) if err != nil { return err } for _, n := range nodes { + for _, dep := range deps[n.GetShortName()] { + if dep.Stage != clabtypes.WaitForHealthy { + continue + } + + if err := c.waitForLifecycleNodeHealthy(ctx, n.GetShortName(), dep.Node); err != nil { + return err + } + } + if err := n.Start(ctx); err != nil { return err } + runLifecycleConfigureStage(ctx, n) } return nil diff --git a/core/state.go b/core/state.go index 2082ad614a..dcbb1ef0b4 100644 --- a/core/state.go +++ b/core/state.go @@ -11,7 +11,8 @@ import ( ) type LabState struct { - Topology *clabtypes.Topology `yaml:"topology"` + Topology *clabtypes.Topology `yaml:"topology"` + NodeConfigs map[string]*clabtypes.NodeConfig `yaml:"node-configs,omitempty"` } // WriteState saves the topology to the state file. @@ -19,7 +20,10 @@ func (c *CLab) WriteState() error { state := &LabState{ Topology: c.Config.Topology, } + return c.writeLabState(state) +} +func (c *CLab) writeLabState(state *LabState) error { data, err := yaml.Marshal(state) if err != nil { return fmt.Errorf("failed to marshal state: %w", err) diff --git a/core/topology_reconcile.go b/core/topology_reconcile.go index 18af7ddf93..58837b0e29 100644 --- a/core/topology_reconcile.go +++ b/core/topology_reconcile.go @@ -31,25 +31,47 @@ type applyEndpointRef struct { } type applyPlan struct { - currentNodes map[string]*runtimeNodeGroup - addedNodeSet map[string]struct{} - deletedNodeSet map[string]struct{} - recreatedNodeSet map[string]struct{} - restartNodeSet map[string]struct{} - linkRestartNodeSet map[string]struct{} - nodeDiffs map[string]*clabtypes.TopologyDiff - addedLinks []clablinks.Link - plannedLinkSet map[int]struct{} - staleEndpoints []applyEndpointRef - desiredEndpointSet map[applyEndpointKey]struct{} - liveEndpointSet map[applyEndpointKey]struct{} - endpointNodes map[string]clablinks.Node - state *LabState - parkedNodeSet map[string]struct{} - startNodeSet map[string]struct{} + currentNodes map[string]*runtimeNodeGroup + addedNodeSet map[string]struct{} + deletedNodeSet map[string]struct{} + recreatedNodeSet map[string]struct{} + restartNodeSet map[string]struct{} + linkRestartNodeSet map[string]struct{} + postLinkRestartNodeSet map[string]struct{} + nodeDiffs map[string]*clabtypes.TopologyDiff + addedLinks []clablinks.Link + plannedLinkSet map[int]struct{} + staleEndpoints []applyEndpointRef + desiredEndpointSet map[applyEndpointKey]struct{} + liveEndpointSet map[applyEndpointKey]struct{} + endpointNodes map[string]clablinks.Node + state *LabState + parkedNodeSet map[string]struct{} + startNodeSet map[string]struct{} // nodeChangeReasons explains per node why apply restarts or recreates it, // e.g. "added link" or "config drift: image". nodeChangeReasons map[string]string + // mutableNodeSet limits reconciliation when --node-filter is set. A nil set + // preserves full-topology reconciliation behavior. + mutableNodeSet map[string]struct{} + endpointOwner map[string]string +} + +func (p *applyPlan) nodeMutable(nodeName string) bool { + if p == nil || p.mutableNodeSet == nil { + return true + } + _, ok := p.mutableNodeSet[nodeName] + return ok +} + +func (p *applyPlan) endpointKey(key applyEndpointKey) applyEndpointKey { + if p != nil && p.endpointOwner != nil { + if owner, exists := p.endpointOwner[key.node]; exists { + key.node = owner + } + } + return key } func (p *applyPlan) empty() bool { @@ -59,27 +81,29 @@ func (p *applyPlan) empty() bool { len(p.startNodeSet) == 0 && len(p.restartNodeSet) == 0 && len(p.linkRestartNodeSet) == 0 && + len(p.postLinkRestartNodeSet) == 0 && len(p.addedLinks) == 0 && len(p.staleEndpoints) == 0 } func newApplyPlan(currentNodes map[string]*runtimeNodeGroup, state *LabState) *applyPlan { return &applyPlan{ - currentNodes: currentNodes, - addedNodeSet: map[string]struct{}{}, - deletedNodeSet: map[string]struct{}{}, - recreatedNodeSet: map[string]struct{}{}, - parkedNodeSet: map[string]struct{}{}, - startNodeSet: map[string]struct{}{}, - restartNodeSet: map[string]struct{}{}, - linkRestartNodeSet: map[string]struct{}{}, - nodeDiffs: map[string]*clabtypes.TopologyDiff{}, - plannedLinkSet: map[int]struct{}{}, - desiredEndpointSet: map[applyEndpointKey]struct{}{}, - liveEndpointSet: map[applyEndpointKey]struct{}{}, - endpointNodes: map[string]clablinks.Node{}, - state: state, - nodeChangeReasons: map[string]string{}, + currentNodes: currentNodes, + addedNodeSet: map[string]struct{}{}, + deletedNodeSet: map[string]struct{}{}, + recreatedNodeSet: map[string]struct{}{}, + parkedNodeSet: map[string]struct{}{}, + startNodeSet: map[string]struct{}{}, + restartNodeSet: map[string]struct{}{}, + linkRestartNodeSet: map[string]struct{}{}, + postLinkRestartNodeSet: map[string]struct{}{}, + nodeDiffs: map[string]*clabtypes.TopologyDiff{}, + plannedLinkSet: map[int]struct{}{}, + desiredEndpointSet: map[applyEndpointKey]struct{}{}, + liveEndpointSet: map[applyEndpointKey]struct{}{}, + endpointNodes: map[string]clablinks.Node{}, + state: state, + nodeChangeReasons: map[string]string{}, } } @@ -94,6 +118,7 @@ func (p *applyPlan) isExternallyManaged(nodeName string) bool { func (c *CLab) planApply( ctx context.Context, currentNodes map[string]*runtimeNodeGroup, + nodeFilter []string, ) (*applyPlan, error) { state, err := c.LoadState() if err != nil { @@ -101,8 +126,16 @@ func (c *CLab) planApply( } plan := newApplyPlan(currentNodes, state) + plan.mutableNodeSet, err = c.applyNodeFilterClosure(nodeFilter) + if err != nil { + return nil, err + } + plan.endpointOwner = c.applyEndpointOwnerMap() for _, nodeName := range sortedNodeNames(c.Nodes) { + if !plan.nodeMutable(nodeName) { + continue + } if _, exists := currentNodes[nodeName]; !exists { status := c.Nodes[nodeName].GetContainerStatus(ctx) if status != clabruntime.NotFound { @@ -118,6 +151,9 @@ func (c *CLab) planApply( } for _, nodeName := range sortedRuntimeNodeGroupNames(currentNodes) { + if !plan.nodeMutable(nodeName) { + continue + } if _, exists := c.Nodes[nodeName]; !exists { plan.deletedNodeSet[nodeName] = struct{}{} } @@ -131,8 +167,11 @@ func (c *CLab) planApply( c.planStoppedNodes(ctx, plan) for _, linkIdx := range sortedLinkIndexes(c.Links) { + if plan.mutableNodeSet != nil && !linkTouchesNodeSet(c.Links[linkIdx], plan.mutableNodeSet) { + continue + } for _, ep := range clablinks.RuntimeEndpoints(c.Links[linkIdx]) { - plan.desiredEndpointSet[endpointKeyFromEndpoint(ep)] = struct{}{} + plan.desiredEndpointSet[plan.endpointKey(endpointKeyFromEndpoint(ep))] = struct{}{} } } @@ -144,6 +183,9 @@ func (c *CLab) planApply( for _, linkIdx := range sortedLinkIndexes(c.Links) { link := c.Links[linkIdx] + if plan.mutableNodeSet != nil && !linkTouchesNodeSet(link, plan.mutableNodeSet) { + continue + } if !plan.linkNeedsDeploy(link) { continue } @@ -151,7 +193,7 @@ func (c *CLab) planApply( plan.addDeployApplyLink(linkIdx, link) for _, ep := range clablinks.RuntimeEndpoints(link) { - nodeName := ep.GetNode().GetShortName() + nodeName := plan.endpointKey(endpointKeyFromEndpoint(ep)).node if _, exists := currentNodes[nodeName]; !exists { continue } @@ -164,6 +206,7 @@ func (c *CLab) planApply( } c.planRecreatedNodeLinks(plan) + c.planRecreatedNodePostLinkRestarts(plan) for nodeName := range plan.recreatedNodeSet { delete(plan.restartNodeSet, nodeName) delete(plan.linkRestartNodeSet, nodeName) @@ -172,6 +215,73 @@ func (c *CLab) planApply( return plan, nil } +func (c *CLab) planRecreatedNodePostLinkRestarts(plan *applyPlan) { + for _, link := range plan.addedLinks { + for _, ep := range clablinks.RuntimeEndpoints(link) { + key := endpointKeyFromEndpoint(ep) + if _, recreated := plan.recreatedNodeSet[key.node]; recreated { + plan.postLinkRestartNodeSet[key.node] = struct{}{} + } + } + } +} + +func (c *CLab) applyNodeFilterClosure(nodeFilter []string) (map[string]struct{}, error) { + if len(nodeFilter) == 0 { + return nil, nil + } + + closure := make(map[string]struct{}, len(nodeFilter)) + visiting := append([]string(nil), nodeFilter...) + for len(visiting) > 0 { + nodeName := visiting[0] + visiting = visiting[1:] + if _, seen := closure[nodeName]; seen { + continue + } + + node, exists := c.Nodes[nodeName] + if !exists { + return nil, fmt.Errorf("node %q is not present in the topology", nodeName) + } + closure[nodeName] = struct{}{} + + cfg := node.Config() + if cfg == nil { + continue + } + if provider, ok := c.applySharedNetNSProvider(node); ok { + visiting = append(visiting, provider) + } + if cfg.Stages != nil { + for _, dependencies := range cfg.Stages.GetWaitFor() { + for _, dependency := range dependencies { + if dependency != nil { + visiting = append(visiting, dependency.Node) + } + } + } + } + } + + return closure, nil +} + +func sharedNetNSProvider(networkMode string) (string, bool) { + provider, ok := strings.CutPrefix(networkMode, "container:") + return provider, ok && provider != "" +} + +func (c *CLab) applyEndpointOwnerMap() map[string]string { + owners := map[string]string{} + for nodeName, node := range c.Nodes { + if provider, shared := c.applySharedNetNSProvider(node); shared { + owners[nodeName] = provider + } + } + return owners +} + func (p *applyPlan) addDeployApplyLink(linkIdx int, link clablinks.Link) { if _, planned := p.plannedLinkSet[linkIdx]; planned { return @@ -191,6 +301,12 @@ func (c *CLab) planParkedNodes(ctx context.Context, plan *applyPlan) { if !exists { continue } + // A container sharing another node's network namespace does not own + // that namespace's endpoints. Parking it would detach the provider's + // links and can strip the provider's live address and route state. + if _, shared := c.applySharedNetNSProvider(node); shared { + continue + } if clabruntime.ContainerHasJoinableNetns(node.GetContainerStatus(ctx)) { plan.parkedNodeSet[nodeName] = struct{}{} } @@ -203,7 +319,7 @@ func (c *CLab) planStoppedNodes(ctx context.Context, plan *applyPlan) { } for nodeName := range plan.currentNodes { - if plan.isExternallyManaged(nodeName) { + if !plan.nodeMutable(nodeName) || plan.isExternallyManaged(nodeName) { continue } node, exists := c.Nodes[nodeName] @@ -269,6 +385,11 @@ func (c *CLab) planDeletedEndpoints(ctx context.Context, plan *applyPlan) { plannedEndpointSet := map[applyEndpointKey]struct{}{} for _, key := range sortedEndpointKeys(plan.liveEndpointSet) { + if plan.mutableNodeSet != nil { + if isApplySpecialNode(key.node) || !plan.nodeMutable(key.node) { + continue + } + } if _, exists := plan.desiredEndpointSet[key]; exists { continue } @@ -407,7 +528,9 @@ func (c *CLab) discoverLiveApplyEndpoints( } for _, ifaceName := range ifaceNames { - plan.liveEndpointSet[applyEndpointKey{node: nodeName, iface: ifaceName}] = struct{}{} + plan.liveEndpointSet[plan.endpointKey(applyEndpointKey{ + node: nodeName, iface: ifaceName, + })] = struct{}{} } } @@ -451,6 +574,18 @@ func (c *CLab) applyKnownEndpointNames( func (c *CLab) applyEndpointDiscoveryNodes(plan *applyPlan) map[string]clablinks.Node { nodes := make(map[string]clablinks.Node, len(c.Nodes)+2) for nodeName, node := range c.Nodes { + if plan != nil && plan.isExternallyManaged(nodeName) { + nodes[nodeName] = node + continue + } + if _, shared := c.applySharedNetNSProvider(node); shared { + continue + } + if plan != nil && plan.mutableNodeSet != nil && !plan.nodeMutable(nodeName) { + if !endpointSetContainsNode(plan.desiredEndpointSet, nodeName) { + continue + } + } nodes[nodeName] = node } @@ -466,6 +601,36 @@ func (c *CLab) applyEndpointDiscoveryNodes(plan *applyPlan) map[string]clablinks return nodes } +func endpointSetContainsNode(endpoints map[applyEndpointKey]struct{}, nodeName string) bool { + for endpoint := range endpoints { + if endpoint.node == nodeName { + return true + } + } + return false +} + +func (c *CLab) applySharedNetNSProvider(node clabnodes.Node) (string, bool) { + if node == nil || node.Config() == nil { + return "", false + } + + provider, shared := sharedNetNSProvider(node.Config().NetworkMode) + if !shared { + return "", false + } + if _, exists := c.Nodes[provider]; exists { + return provider, true + } + for nodeName, candidate := range c.Nodes { + if candidate != nil && candidate.Config() != nil && candidate.Config().LongName == provider { + return nodeName, true + } + } + + return "", false +} + func (c *CLab) applyLinkNode(nodeName string) (clablinks.Node, bool) { if node, exists := c.Nodes[nodeName]; exists { return node, true @@ -487,6 +652,9 @@ func (c *CLab) planAffectedApplyNode( if nodeName == "" { return } + if !plan.nodeMutable(nodeName) { + return + } if _, planned := plan.linkRestartNodeSet[nodeName]; planned { return } @@ -549,6 +717,7 @@ func resolveNodeConfigFromTopology(topo *clabtypes.Topology, nodeName string) *c return &clabtypes.NodeConfig{ ShortName: nodeName, Kind: topo.GetNodeKind(nodeName), + Hostname: topo.GetNodeHostname(nodeName), NodeType: topo.GetNodeType(nodeName), Image: topo.GetNodeImage(nodeName), Entrypoint: topo.GetNodeEntrypoint(nodeName), @@ -582,6 +751,9 @@ func (c *CLab) planNodeReconciliation(ctx context.Context, plan *applyPlan) erro } for nodeName := range plan.currentNodes { + if !plan.nodeMutable(nodeName) { + continue + } node, exists := c.Nodes[nodeName] if !exists { continue @@ -591,6 +763,11 @@ func (c *CLab) planNodeReconciliation(ctx context.Context, plan *applyPlan) erro } oldNodeConfig := resolveNodeConfigFromTopology(oldTopo, nodeName) + if plan.state != nil && plan.state.NodeConfigs != nil { + if appliedConfig, exists := plan.state.NodeConfigs[nodeName]; exists { + oldNodeConfig = appliedConfig + } + } newNodeConfig := resolveNodeConfigFromTopology(c.Config.Topology, nodeName) diff := node.ComputeDiff(oldNodeConfig, newNodeConfig) plan.nodeDiffs[nodeName] = diff @@ -635,6 +812,9 @@ func (c *CLab) reconcileNodes(ctx context.Context, plan *applyPlan) error { } for nodeName := range plan.currentNodes { + if !plan.nodeMutable(nodeName) { + continue + } node, exists := c.Nodes[nodeName] if !exists { continue @@ -672,7 +852,7 @@ func (c *CLab) reconcileNodes(ctx context.Context, plan *applyPlan) error { func (p *applyPlan) linkNeedsDeploy(link clablinks.Link) bool { for _, ep := range clablinks.RuntimeEndpoints(link) { - key := endpointKeyFromEndpoint(ep) + key := p.endpointKey(endpointKeyFromEndpoint(ep)) if _, parked := p.parkedNodeSet[key.node]; !parked { if _, added := p.addedNodeSet[key.node]; added { return true diff --git a/docs/cmd/deploy.md b/docs/cmd/deploy.md index 922c8866ed..45d39d6378 100644 --- a/docs/cmd/deploy.md +++ b/docs/cmd/deploy.md @@ -293,7 +293,21 @@ The local `--node-filter` flag allows users to specify a subset of topology node When a subset of nodes is specified, containerlab will only deploy those nodes and links belonging to all selected nodes and ignore the rest. This can be useful e.g. in CI/CD test case scenarios, where resource constraints may prohibit the deployment of a full topology. -Node filtering applies to fresh deployments (including `--reconfigure`) only. When deploy [reconciles](#reconciliation-behavior) an already deployed lab, the filter is rejected, because running nodes excluded by the filter would otherwise be deleted. +For a fresh deployment (including `--reconfigure`), node filtering deploys only the selected nodes. +When `deploy` reconciles an existing lab, the selected nodes form a mutable scope. Containerlab +automatically includes nodes required through `wait-for` or +`network-mode: container:` and links needed by selected added or recreated nodes. Nodes +and endpoints outside that closure remain immutable: they are not deleted, restarted, recreated, +or reconciled. + +```bash +containerlab deploy -t mylab.clab.yml --node-filter app1,app2 +``` + +Filtered reconciliation checkpoints resolved state only for nodes in the selected closure, so +pending topology changes outside the filter remain pending. For shared network namespaces, the +provider owns discovered interfaces; children using `network-mode: container:` do not +independently claim the same interfaces. Read more about [node filtering](../manual/node-filtering.md) in the documentation. diff --git a/docs/manual/nodes.md b/docs/manual/nodes.md index d184ad9ac5..d888189dde 100644 --- a/docs/manual/nodes.md +++ b/docs/manual/nodes.md @@ -449,6 +449,24 @@ topology: user: clab # clab user will be used for node1 ``` +### hostname + +The `hostname` option overrides the hostname configured inside the node's +container. It can be set at the defaults, kind, group, or node level. When it is +not set, containerlab uses the topology node name. + +```yaml +topology: + nodes: + app1: + kind: linux + hostname: app-production-01001 +``` + +Podman supports this option for nodes using `network-mode: container:`. +Docker does not permit setting a hostname while joining another container's +network namespace, so Docker ignores the override for that network mode. + ### entrypoint Changing the entrypoint of the container is done with `entrypoint` config option. It accepts the "shell" form and can be set on all levels. @@ -751,8 +769,7 @@ my-node: ### cap-add The `cap-add` parameter can be used to add capabilities to the container. -Docker containers are currently executed in privileged mode, so this should not be needed. -If this becomes configurable, specifying the capabilities required for a container will be useful. +By default, containers are executed in privileged mode, so this should not be needed unless [`privileged`](#privileged) is set to `false`. ```yaml # my-node will be given the NET_ADMIN and the SYS_ADMIN capabilities @@ -764,6 +781,74 @@ my-node: - SYS_ADMIN ``` +### privileged + +The `privileged` parameter controls whether the container runs in privileged mode. +It defaults to `true` to preserve containerlab's historical behavior. + +```yaml +# my-node will not run as a privileged container. +my-node: + image: alpine:3 + kind: linux + privileged: false +``` + +### cgroupns-mode + +The `cgroupns-mode` parameter controls the cgroup namespace mode used by the container runtime. +Supported values are `host` and `private`. + +```yaml +# my-node will use the host cgroup namespace. +my-node: + image: alpine:3 + kind: linux + cgroupns-mode: host +``` + +### pid-mode + +The `pid-mode` parameter controls the PID namespace mode used by the container runtime. +For Docker, this accepts the same values as Docker's PID mode setting, such as `host` or `container:`. + +```yaml +# my-node will use the host PID namespace. +my-node: + image: alpine:3 + kind: linux + pid-mode: host +``` + +### tmpfs + +The `tmpfs` parameter adds tmpfs mounts to the container. +It is a map keyed by container path, with mount options as the value. + +```yaml +# my-node will have tmpfs mounts commonly used by init-style containers. +my-node: + image: alpine:3 + kind: linux + tmpfs: + /run: rw,nosuid,nodev + /run/lock: rw,nosuid,nodev,noexec + /tmp: rw,nosuid,nodev +``` + +### security-opts + +The `security-opts` parameter passes security options to the container runtime. + +```yaml +# my-node will disable the default seccomp profile. +my-node: + image: alpine:3 + kind: linux + security-opts: + - seccomp=unconfined +``` + ### sysctls The sysctl container' setting can be set via the `sysctls` knob under the `defaults`, `kind` and `node` levels. @@ -850,6 +935,8 @@ In the example below node four nodes are defined with different stages and `wait Containerlab's built-in Dependency Manger takes care of all the dependencies, both explicitly-defined and implicit ones. It will inspect the dependency graph and make sure it is acyclic. The output of the Dependency Manager graph is visible in the debug mode. +The same dependencies are honored by `containerlab start`. When a stopped lab is started with dependency-aware stages, containerlab starts prerequisites first. A dependency on the `healthy` stage waits until the prerequisite node reports healthy before starting the dependent node. + Note, that `wait-for` is a list, a node's stage may depend on several other nodes' stages. /// admonition | Usage scenarios diff --git a/docs/rn/0.77.md b/docs/rn/0.77.md index aadd3d79e2..db29f5be96 100644 --- a/docs/rn/0.77.md +++ b/docs/rn/0.77.md @@ -2,6 +2,13 @@ :material-calendar: 2026-06-28 ยท :material-list-status: [Full Changelog](https://github.com/srl-labs/containerlab/releases) +## Node hostnames + +Nodes can set an explicit runtime hostname with the inherited `hostname` +property. If omitted, the topology node name remains the default. Hostname +changes are included in `clab apply` reconciliation and recreate the affected +node. + ## Apply topology changes The new `apply` command reconciles the topology you want with the lab that is already running. If the lab is not deployed yet, `apply` behaves like `deploy`; if the lab is running, it compares the desired topology with the live runtime and applies supported deltas without destroying and redeploying the whole lab. @@ -17,6 +24,12 @@ Link changes can be handled live, with a restart, or with a recreate depending o Use [`redeploy`](../cmd/redeploy.md) when you need a clean artifact state, startup configuration changes, or link parameter/type changes that `apply` intentionally does not mutate in place. +`deploy --node-filter` (also available through the `apply` alias) can target additions or +recreations in an existing lab without reconciling +unrelated nodes. It includes declared dependencies and required links, preserves unselected +runtime endpoints, and treats a `network-mode: container:` namespace as owned only by +the provider during live endpoint discovery. + Thanks @FloSch62 @kaelemc #3207 ## Cisco XRd vRouter diff --git a/links/parking_node.go b/links/parking_node.go index bc337e6e30..ffd1735455 100644 --- a/links/parking_node.go +++ b/links/parking_node.go @@ -26,6 +26,39 @@ func NewParkingNode(containerName, nsPath string) *ParkingNode { } } +// RemoveParkedInterfaces deletes interfaces from a container-owned parking +// namespace before the remaining endpoints are restored. Apply uses this for +// added links whose peer containers do not exist yet; those links are created +// fresh once both endpoints are available. +func RemoveParkedInterfaces( + ctx context.Context, + containerName string, + ifaceNames []string, +) error { + if len(ifaceNames) == 0 { + return nil + } + + parkPath, err := clabutils.GetNamedNetNS(clabutils.ParkingNetnsName(containerName)) + if err != nil { + return nil + } + parkingNode := NewParkingNode(containerName, parkPath) + + for _, ifaceName := range ifaceNames { + if err := RemoveOwnedInterface(ctx, parkingNode, ifaceName); err != nil { + return fmt.Errorf( + "failed to discard parked interface %q for container %q: %w", + ifaceName, + containerName, + err, + ) + } + } + + return nil +} + func (p *ParkingNode) RepointSymlink() error { return clabutils.LinkContainerNS(p.nspath, p.containerName) } diff --git a/nodes/ceos/ceos.go b/nodes/ceos/ceos.go index 5800b92532..295b2a5583 100644 --- a/nodes/ceos/ceos.go +++ b/nodes/ceos/ceos.go @@ -8,7 +8,6 @@ import ( "context" _ "embed" "encoding/json" - "errors" "fmt" "net" "os" @@ -16,6 +15,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "github.com/charmbracelet/log" clabconstants "github.com/srl-labs/containerlab/constants" @@ -347,16 +347,10 @@ func setMgmtInterface(node *clabtypes.NodeConfig) error { } // ceosPostDeploy runs postdeploy actions which are required for ceos nodes. -func (n *ceos) ceosPostDeploy(_ context.Context) error { +func (n *ceos) ceosPostDeploy(ctx context.Context) error { nodeCfg := n.Config() - d, err := clabutils.SpawnCLIviaExec("arista_eos", nodeCfg.LongName, n.Runtime.GetName()) - if err != nil { - return err - } - - defer d.Close() - cfgs := []string{ + "configure terminal", "interface " + nodeCfg.MgmtIntf, "no ip address", "no ipv6 address", @@ -410,18 +404,40 @@ func (n *ceos) ceosPostDeploy(_ context.Context) error { } // add save to startup cmd - cfgs = append(cfgs, "wr") + cfgs = append(cfgs, "end", "write memory") log.Debugf("cEOS PostDeploy configuration for node %s: %v", n.Cfg.ShortName, cfgs) - resp, err := d.SendConfigs(cfgs) - if err != nil { - return err - } else if resp.Failed != nil { - return errors.New("failed CLI configuration") + var lastErr error + var lastResp *clabexec.ExecResult + cliCmd := "Cli -p 15 --abort-on-error -c $'" + strings.Join(cfgs, "\n") + "'" + + for range 60 { + execCmd := clabexec.NewExecCmdFromSlice([]string{"/bin/bash", "-lc", cliCmd}) + resp, err := n.RunExec(ctx, execCmd) + if err == nil && resp.GetReturnCode() == 0 { + return nil + } + + lastErr = err + lastResp = resp + log.Debugf("%s - Cli not ready (%v, %v) - waiting.", nodeCfg.LongName, err, resp) + time.Sleep(2 * time.Second) } - return err + if lastErr != nil { + return lastErr + } + if lastResp != nil { + return fmt.Errorf( + "failed CLI configuration: rc=%d stdout=%q stderr=%q", + lastResp.GetReturnCode(), + lastResp.GetStdOutString(), + lastResp.GetStdErrString(), + ) + } + + return fmt.Errorf("failed CLI configuration") } // CheckInterfaceName checks if a name of the interface referenced in the topology file correct. diff --git a/nodes/default_node.go b/nodes/default_node.go index b6bd52d420..73559867b2 100644 --- a/nodes/default_node.go +++ b/nodes/default_node.go @@ -13,6 +13,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "strconv" "strings" "sync" @@ -285,6 +286,9 @@ func (d *DefaultNode) ComputeDiff(oldCfg, newCfg *clabtypes.NodeConfig) *clabtyp if oldCfg.Image != newCfg.Image { diff.Fields = append(diff.Fields, "Image") } + if oldCfg.GetHostname() != newCfg.GetHostname() { + diff.Fields = append(diff.Fields, "Hostname") + } if oldCfg.Entrypoint != newCfg.Entrypoint { diff.Fields = append(diff.Fields, "Entrypoint") } @@ -297,7 +301,11 @@ func (d *DefaultNode) ComputeDiff(oldCfg, newCfg *clabtypes.NodeConfig) *clabtyp if !clabutils.MapsEqualOrBothEmpty(oldCfg.Env, newCfg.Env) { diff.Fields = append(diff.Fields, "Env") } - if !clabutils.SlicesEqualOrBothEmpty(oldCfg.Binds, newCfg.Binds) { + oldBinds := slices.Clone(oldCfg.Binds) + newBinds := slices.Clone(newCfg.Binds) + slices.Sort(oldBinds) + slices.Sort(newBinds) + if !slices.Equal(oldBinds, newBinds) { diff.Fields = append(diff.Fields, "Binds") } if !clabutils.SlicesEqualOrBothEmpty(oldCfg.Devices, newCfg.Devices) { @@ -1104,6 +1112,11 @@ func (d *DefaultNode) IsHealthy(ctx context.Context) (bool, error) { // ParkEndpoints moves all tracked endpoints into the node-owned parking namespace. func (d *DefaultNode) ParkEndpoints(ctx context.Context) error { + if _, shared := strings.CutPrefix(d.Cfg.NetworkMode, "container:"); shared { + log.Debugf("node %q shares a network namespace and owns no endpoints to park", d.Cfg.ShortName) + return nil + } + parkPath, err := clabutils.CreateOrGetNamedNetNS(d.parkingNetNSName()) if err != nil { return fmt.Errorf("failed to create parking netns for node %q: %w", d.Cfg.ShortName, err) @@ -1133,6 +1146,11 @@ func (d *DefaultNode) ParkEndpoints(ctx context.Context) error { // RestoreEndpoints tries to get the parking node to unpark its interfaces. func (d *DefaultNode) RestoreEndpoints(ctx context.Context) error { + if _, shared := strings.CutPrefix(d.Cfg.NetworkMode, "container:"); shared { + log.Debugf("node %q shares a network namespace and owns no endpoints to restore", d.Cfg.ShortName) + return nil + } + parkPath, err := d.parkingNetNSPath() if err != nil { // No parking netns means the node had no parked interfaces - e.g. it was diff --git a/nodes/default_node_test.go b/nodes/default_node_test.go index 1223cbd456..f0bdbf9a07 100644 --- a/nodes/default_node_test.go +++ b/nodes/default_node_test.go @@ -84,6 +84,64 @@ func TestDefaultNodeConfigChangesRecreate(t *testing.T) { } } +func TestDefaultNodeComputeDiffDetectsHostnameChange(t *testing.T) { + d := &DefaultNode{} + diff := d.ComputeDiff( + &clabtypes.NodeConfig{ShortName: "node1"}, + &clabtypes.NodeConfig{ShortName: "node1", Hostname: "production-host"}, + ) + + if len(diff.Fields) != 1 || diff.Fields[0] != "Hostname" { + t.Fatalf("ComputeDiff fields = %#v, want [Hostname]", diff.Fields) + } + if got := diff.DefaultAction(); got != clabtypes.TopologyDiffActionRecreate { + t.Fatalf("DefaultAction() = %q, want %q", got, clabtypes.TopologyDiffActionRecreate) + } +} + +func TestDefaultNodeComputeDiffIgnoresEquivalentDefaultHostname(t *testing.T) { + d := &DefaultNode{} + diff := d.ComputeDiff( + &clabtypes.NodeConfig{ShortName: "node1"}, + &clabtypes.NodeConfig{ShortName: "node1", Hostname: "node1"}, + ) + + if diff.HasDiff() { + t.Fatalf("ComputeDiff fields = %#v, want no diff", diff.Fields) + } +} + +func TestDefaultNodeComputeDiffTreatsBindsAsUnordered(t *testing.T) { + d := &DefaultNode{} + oldCfg := &clabtypes.NodeConfig{Binds: []string{ + "/host/config:/etc/config:ro", + "/host/data:/var/lib/data", + }} + + t.Run("reordered binds", func(t *testing.T) { + newCfg := &clabtypes.NodeConfig{Binds: []string{ + "/host/data:/var/lib/data", + "/host/config:/etc/config:ro", + }} + + if diff := d.ComputeDiff(oldCfg, newCfg); diff.HasDiff() { + t.Fatalf("ComputeDiff fields = %#v, want no diff", diff.Fields) + } + }) + + t.Run("changed bind", func(t *testing.T) { + newCfg := &clabtypes.NodeConfig{Binds: []string{ + "/host/data:/var/lib/data", + "/host/config:/etc/config", + }} + + diff := d.ComputeDiff(oldCfg, newCfg) + if len(diff.Fields) != 1 || diff.Fields[0] != "Binds" { + t.Fatalf("ComputeDiff fields = %#v, want [Binds]", diff.Fields) + } + }) +} + func TestDefaultNodeAdoptEndpointRejectsForeignOwner(t *testing.T) { d := &DefaultNode{ Cfg: &clabtypes.NodeConfig{ @@ -296,6 +354,26 @@ func TestDefaultNodeRestoreEndpointsMissingParkingNetNSError(t *testing.T) { } } +func TestDefaultNodeSharedNetNSNeverParksProviderEndpoints(t *testing.T) { + t.Parallel() + + d := &DefaultNode{Cfg: &clabtypes.NodeConfig{ + ShortName: "child", + LongName: "clab-test-child", + NetworkMode: "container:provider", + }} + + if err := d.ParkEndpoints(context.Background()); err != nil { + t.Fatalf("shared-netns ParkEndpoints returned error: %v", err) + } + if err := d.RestoreEndpoints(context.Background()); err != nil { + t.Fatalf("shared-netns RestoreEndpoints returned error: %v", err) + } + if _, err := d.parkingNetNSPath(); err == nil { + t.Fatal("shared-netns child created a parking namespace") + } +} + func TestGenerateConfigs(t *testing.T) { defCfg := "default config" oldCfg := "old config" diff --git a/nodes/ext_container/ext_container.go b/nodes/ext_container/ext_container.go index 1dcba87f45..b42a7603d8 100644 --- a/nodes/ext_container/ext_container.go +++ b/nodes/ext_container/ext_container.go @@ -55,8 +55,10 @@ func (e *extcont) Deploy(ctx context.Context, _ *clabnodes.DeployParams) error { // Delete we will not mess with external containers on delete. func (*extcont) Delete(_ context.Context) error { return nil } -func (*extcont) Stop(context.Context) error { return nil } +// Stop we will not mess with external containers on stop. +func (*extcont) Stop(_ context.Context) error { return nil } +// Start waits for external containers to be running without attempting to start them. func (e *extcont) Start(ctx context.Context) error { return clabruntime.WaitForContainerRunning(ctx, e.Runtime, e.Cfg.ShortName, e.Cfg.ShortName) } diff --git a/runtime/docker/docker.go b/runtime/docker/docker.go index f4c8c77b9e..f2da5e0f8e 100644 --- a/runtime/docker/docker.go +++ b/runtime/docker/docker.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "net/http" "os" "path" "path/filepath" @@ -165,18 +166,14 @@ func (d *DockerRuntime) WithMgmtNet(n *clabtypes.MgmtNet) { if d.mgmt.Bridge == "" && d.mgmt.Network != "" { // fetch the network by the name set in the topo and populate the bridge name used by this // network - netRes, err := d.Client.NetworkInspect( + netRes, raw, err := d.Client.NetworkInspectWithRaw( context.TODO(), d.mgmt.Network, networkapi.InspectOptions{}, ) // if the network is successfully found, set the bridge used by it if err == nil { - if name, exists := netRes.Options[bridgeNameOption]; exists { - d.mgmt.Bridge = name - } else { - d.mgmt.Bridge = "br-" + netRes.ID[:12] - } + d.mgmt.Bridge = d.detectMgmtBridgeName(context.TODO(), d.mgmt.Network, netRes, raw) log.Debugf( "detected network name in use: %s, backed by a bridge %s", d.mgmt.Network, @@ -195,19 +192,34 @@ func (d *DockerRuntime) CreateNet(ctx context.Context) (err error) { bridgeName := d.mgmt.Bridge log.Debugf("Checking if docker network %q exists", d.mgmt.Network) - netResource, err := d.Client.NetworkInspect(nctx, d.mgmt.Network, networkapi.InspectOptions{}) + netResource, rawNetResource, err := d.Client.NetworkInspectWithRaw(nctx, d.mgmt.Network, networkapi.InspectOptions{}) switch { case dockerC.IsErrNotFound(err): bridgeName, err = d.createMgmtBridge(nctx, bridgeName) if err != nil { return err } + netResource, rawNetResource, err = d.Client.NetworkInspectWithRaw(nctx, d.mgmt.Network, networkapi.InspectOptions{}) + if err != nil { + return err + } + if inspectedBridgeName := d.detectMgmtBridgeName(nctx, d.mgmt.Network, netResource, rawNetResource); inspectedBridgeName != "" { + bridgeName = inspectedBridgeName + } case err == nil: log.Debugf("network %q was found. Reusing it...", d.mgmt.Network) bridgeName, err = bridgeNameFromInspect(&netResource, d.mgmt.Network) if err != nil { return err } + if inspectedBridgeName := d.detectMgmtBridgeName( + nctx, + d.mgmt.Network, + netResource, + rawNetResource, + ); inspectedBridgeName != "" { + bridgeName = inspectedBridgeName + } default: return err @@ -231,6 +243,96 @@ func (d *DockerRuntime) CreateNet(ctx context.Context) (err error) { return d.postCreateNetActions() } +func (d *DockerRuntime) detectMgmtBridgeName( + ctx context.Context, + networkName string, + netResource networkapi.Inspect, + raw []byte, +) string { + if name := netavarkNetworkInterface(raw); name != "" { + return name + } + + if name := d.libpodNetworkInterface(ctx, networkName); name != "" { + return name + } + + return mgmtBridgeNameFromInspect(networkName, netResource, "") +} + +func mgmtBridgeNameFromInspect( + networkName string, + netResource networkapi.Inspect, + podmanNetworkInterface string, +) string { + if podmanNetworkInterface != "" { + return podmanNetworkInterface + } + + if networkName == "bridge" { + return "docker0" + } + + if name := netResource.Options["com.docker.network.bridge.name"]; name != "" { + return name + } + + if len(netResource.ID) >= 12 { + return "br-" + netResource.ID[:12] + } + + return "" +} + +func netavarkNetworkInterface(raw []byte) string { + var inspect struct { + NetworkInterface string `json:"network_interface"` + } + if err := json.Unmarshal(raw, &inspect); err != nil { + return "" + } + + return inspect.NetworkInterface +} + +func (d *DockerRuntime) libpodNetworkInterface(ctx context.Context, networkName string) string { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "http://"+dockerC.DummyHost+"/v5.0.0/libpod/networks/json", + nil, + ) + if err != nil { + return "" + } + + resp, err := d.Client.HTTPClient().Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "" + } + + var networks []struct { + Name string `json:"name"` + NetworkInterface string `json:"network_interface"` + } + if err := json.NewDecoder(resp.Body).Decode(&networks); err != nil { + return "" + } + + for _, network := range networks { + if network.Name == networkName { + return network.NetworkInterface + } + } + + return "" +} + // skipcq: GO-R1005 func (d *DockerRuntime) createMgmtBridge( //nolint: funlen nctx context.Context, @@ -602,7 +704,7 @@ func (d *DockerRuntime) CreateContainer( //nolint: funlen Env: clabutils.ConvertEnvs(node.Env), AttachStdout: true, AttachStderr: true, - Hostname: node.ShortName, + Hostname: node.GetHostname(), Tty: true, OpenStdin: true, User: node.User, @@ -664,9 +766,10 @@ func (d *DockerRuntime) CreateContainer( //nolint: funlen Binds: node.Binds, PortBindings: node.PortBindings, Sysctls: node.Sysctls, - Privileged: true, + Privileged: node.Privileged, Tmpfs: node.Tmpfs, PidMode: "", + SecurityOpt: node.SecurityOpts, // Network mode will be defined below via switch NetworkMode: "", ExtraHosts: node.ExtraHosts, // add static /etc/hosts entries @@ -702,6 +805,10 @@ func (d *DockerRuntime) CreateContainer( //nolint: funlen return "", err } + if err := d.processCgroupnsMode(node, containerHostConfig); err != nil { + return "", err + } + // regular linux containers may benefit from automatic restart on failure // note, that veth pairs added to this container (outside of eth0) will be lost on restart if !node.AutoRemove && node.RestartPolicy != "" { @@ -1481,6 +1588,20 @@ func (*DockerRuntime) processPidMode( return nil } +func (*DockerRuntime) processCgroupnsMode( + node *clabtypes.NodeConfig, + containerHostConfig *container.HostConfig, +) error { + cgroupnsMode := container.CgroupnsMode(node.CgroupnsMode) + if !cgroupnsMode.Valid() { + return fmt.Errorf("cgroupns mode %q invalid", node.CgroupnsMode) + } + + containerHostConfig.CgroupnsMode = cgroupnsMode + + return nil +} + func (d *DockerRuntime) processNetworkMode( ctx context.Context, containerNetworkingConfig *networkapi.NetworkingConfig, diff --git a/runtime/docker/docker_test.go b/runtime/docker/docker_test.go new file mode 100644 index 0000000000..07a4655194 --- /dev/null +++ b/runtime/docker/docker_test.go @@ -0,0 +1,77 @@ +package docker + +import ( + "testing" + + networkapi "github.com/docker/docker/api/types/network" +) + +func TestMgmtBridgeNameFromInspect(t *testing.T) { + tests := []struct { + name string + networkName string + inspect networkapi.Inspect + podman string + want string + }{ + { + name: "podman netavark network_interface wins", + networkName: "clab-mgmt", + inspect: networkapi.Inspect{ + ID: "cd38716161df05aff76bae83d9fc31415f843e2c2f939597307d4be0c1551fe6", + Options: map[string]string{"com.docker.network.bridge.name": "br-cd38716161df"}, + }, + podman: "podman1", + want: "podman1", + }, + { + name: "docker explicit bridge option", + networkName: "clab-mgmt", + inspect: networkapi.Inspect{ + ID: "cd38716161df05aff76bae83d9fc31415f843e2c2f939597307d4be0c1551fe6", + Options: map[string]string{"com.docker.network.bridge.name": "clab0"}, + }, + want: "clab0", + }, + { + name: "docker default bridge", + networkName: "bridge", + inspect: networkapi.Inspect{ + ID: "cd38716161df05aff76bae83d9fc31415f843e2c2f939597307d4be0c1551fe6", + }, + want: "docker0", + }, + { + name: "docker generated bridge name", + networkName: "clab-mgmt", + inspect: networkapi.Inspect{ + ID: "cd38716161df05aff76bae83d9fc31415f843e2c2f939597307d4be0c1551fe6", + }, + want: "br-cd38716161df", + }, + { + name: "short id has no generated bridge", + networkName: "clab-mgmt", + inspect: networkapi.Inspect{ + ID: "short", + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mgmtBridgeNameFromInspect(tt.networkName, tt.inspect, tt.podman) + if got != tt.want { + t.Fatalf("got bridge name %q, want %q", got, tt.want) + } + }) + } +} + +func TestNetavarkNetworkInterface(t *testing.T) { + got := netavarkNetworkInterface([]byte(`{"network_interface":"podman1"}`)) + if got != "podman1" { + t.Fatalf("got network interface %q, want %q", got, "podman1") + } +} diff --git a/runtime/docker/runtime_options_test.go b/runtime/docker/runtime_options_test.go new file mode 100644 index 0000000000..45233a91df --- /dev/null +++ b/runtime/docker/runtime_options_test.go @@ -0,0 +1,38 @@ +package docker + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + clabtypes "github.com/srl-labs/containerlab/types" +) + +func TestProcessCgroupnsMode(t *testing.T) { + tests := []struct { + name string + mode string + want container.CgroupnsMode + wantErr bool + }{ + {name: "default", want: ""}, + {name: "host", mode: "host", want: "host"}, + {name: "private", mode: "private", want: "private"}, + {name: "invalid", mode: "invalid", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostConfig := &container.HostConfig{} + err := new(DockerRuntime).processCgroupnsMode( + &clabtypes.NodeConfig{CgroupnsMode: tt.mode}, + hostConfig, + ) + if (err != nil) != tt.wantErr { + t.Fatalf("processCgroupnsMode() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && hostConfig.CgroupnsMode != tt.want { + t.Fatalf("CgroupnsMode = %q, want %q", hostConfig.CgroupnsMode, tt.want) + } + }) + } +} diff --git a/runtime/podman/podman.go b/runtime/podman/podman.go index cd04bfeede..4ea159ced9 100644 --- a/runtime/podman/podman.go +++ b/runtime/podman/podman.go @@ -206,6 +206,25 @@ func (r *PodmanRuntime) CreateContainer( err, ) } + if strings.HasPrefix(cfg.NetworkMode, "container:") { + providerName, err := containerModeProviderName(cfg) + if err != nil { + return "", err + } + provider, err := containers.Inspect(ctx, providerName, &containers.InspectOptions{}) + if err != nil { + return "", fmt.Errorf( + "failed to inspect shared namespace provider %q for container %q: %w", + providerName, + cfg.LongName, + err, + ) + } + if provider.ID != "" { + sg.NetNS.Value = provider.ID + sg.UtsNS.Value = provider.ID + } + } res, err := containers.CreateWithSpec(ctx, &sg, &containers.CreateOptions{}) if err != nil { return "", fmt.Errorf("failed to create container %q: %w", cfg.LongName, err) diff --git a/runtime/podman/util.go b/runtime/podman/util.go index e9190c72c1..b9a7ec895b 100644 --- a/runtime/podman/util.go +++ b/runtime/podman/util.go @@ -47,13 +47,20 @@ func (r *PodmanRuntime) createContainerSpec( cfg *types.NodeConfig, ) (specgen.SpecGenerator, error) { sg := specgen.SpecGenerator{} - cmd, err := shlex.Split(cfg.Cmd) - if err != nil { - return sg, err + var err error + var cmd []string + if strings.TrimSpace(cfg.Cmd) != "" { + cmd, err = shlex.Split(cfg.Cmd) + if err != nil { + return sg, err + } } - entrypoint, err := shlex.Split(cfg.Entrypoint) - if err != nil { - return sg, err + var entrypoint []string + if strings.TrimSpace(cfg.Entrypoint) != "" { + entrypoint, err = shlex.Split(cfg.Entrypoint) + if err != nil { + return sg, err + } } // Main container specs labels := cfg.Labels @@ -69,7 +76,8 @@ func (r *PodmanRuntime) createContainerSpec( Terminal: utils.Pointer(true), Stdin: utils.Pointer(true), Labels: cfg.Labels, - Hostname: cfg.ShortName, + Hostname: cfg.GetHostname(), + UtsNS: specgen.Namespace{NSMode: specgen.Private}, Sysctl: cfg.Sysctls, Remove: utils.Pointer(false), } @@ -79,6 +87,7 @@ func (r *PodmanRuntime) createContainerSpec( log.Errorf("Cannot convert mounts %v: %v", cfg.Binds, err) mounts = nil } + mounts = append(mounts, convertTmpfsMounts(cfg.Tmpfs)...) specStorageConfig := specgen.ContainerStorageConfig{ Image: cfg.Image, // Rootfs: "", @@ -99,15 +108,33 @@ func (r *PodmanRuntime) createContainerSpec( // Secrets: nil, // Volatile: false, } + if cfg.ShmSize != "" { + shmSize, err := humanize.ParseBytes(cfg.ShmSize) + if err != nil { + return sg, fmt.Errorf("failed to parse shm-size %q for container %q: %w", cfg.ShmSize, cfg.LongName, err) + } + shmSizeInt := int64(shmSize) + specStorageConfig.ShmSize = &shmSizeInt + } // Security specSecurityConfig := specgen.ContainerSecurityConfig{ - Privileged: utils.Pointer(true), + Privileged: utils.Pointer(cfg.Privileged), User: cfg.User, + CapAdd: cfg.CapAdd, + } + if err := applySecurityOpts(cfg.SecurityOpts, &specSecurityConfig); err != nil { + return sg, err } - // Going with the defaults for cgroups + specCgroupConfig := specgen.ContainerCgroupConfig{ CgroupNS: specgen.Namespace{}, } + if cfg.CgroupnsMode != "" { + specCgroupConfig.CgroupNS, err = specgen.ParseCgroupNamespace(cfg.CgroupnsMode) + if err != nil { + return sg, err + } + } // Resource limits var ( resLimits specs.LinuxResources @@ -170,29 +197,24 @@ func (r *PodmanRuntime) createContainerSpec( netMode := strings.SplitN(cfg.NetworkMode, ":", 2) switch netMode[0] { case "container": - // We expect exactly two arguments in this case ("container" keyword & cont. name/ID) - if len(netMode) != 2 { - return sg, fmt.Errorf( - "container network mode was specified for container %q, but no container name was found: %q", - cfg.ShortName, - netMode, - ) - } - // also cont. ID shouldn't be empty - if netMode[1] == "" { - return sg, fmt.Errorf( - "container network mode was specified for container %q, but no container name was found: %q", - cfg.ShortName, - netMode, - ) - } - // Extract lab/topo prefix to provide a full (long) container name. Hackish way. - prefix := strings.SplitN(cfg.LongName, cfg.ShortName, 2)[0] + providerName, err := containerModeProviderName(cfg) + if err != nil { + return sg, err + } + // A container that shares another container's network namespace must also + // share its UTS namespace. The provider owns the hostname; requesting a + // separate hostname for the child is ignored by Podman and leaves the child + // with its generated container ID instead. + specBasicConfig.Hostname = "" + specBasicConfig.UtsNS = specgen.Namespace{ + NSMode: specgen.FromContainer, + Value: providerName, + } // Compile the net spec specNetConfig = specgen.ContainerNetworkConfig{ NetNS: specgen.Namespace{ - NSMode: "container", - Value: prefix + netMode[1], + NSMode: specgen.FromContainer, + Value: providerName, }, } case "host": @@ -203,6 +225,12 @@ func (r *PodmanRuntime) createContainerSpec( HostAdd: cfg.ExtraHosts, // NetworkOptions: nil, } + case "none": + specNetConfig = specgen.ContainerNetworkConfig{ + NetNS: specgen.Namespace{NSMode: specgen.NoNetwork}, + UseImageHosts: utils.Pointer(false), + HostAdd: cfg.ExtraHosts, + } // Bridge will be used if none provided case "bridge", "": netName := r.mgmt.Network @@ -288,6 +316,25 @@ func (r *PodmanRuntime) createContainerSpec( return sg, nil } +func containerModeProviderName(cfg *types.NodeConfig) (string, error) { + netMode := strings.SplitN(cfg.NetworkMode, ":", 2) + if len(netMode) != 2 || netMode[0] != "container" || netMode[1] == "" { + return "", fmt.Errorf( + "container network mode was specified for container %q, but no container name was found: %q", + cfg.ShortName, + netMode, + ) + } + + // If the topology already provides the long containerlab name, keep it. + // Otherwise derive it from the child long/short name pair. + prefix := strings.SplitN(cfg.LongName, cfg.ShortName, 2)[0] + if strings.HasPrefix(netMode[1], prefix) { + return netMode[1], nil + } + return prefix + netMode[1], nil +} + // convertMounts takes a list of filesystem mount binds in docker/clab format (src:dest:options) // and converts it into an opencontainers spec format. func (*PodmanRuntime) convertMounts(_ context.Context, mounts []string) ([]specs.Mount, error) { @@ -322,6 +369,52 @@ func (*PodmanRuntime) convertMounts(_ context.Context, mounts []string) ([]specs return mntSpec, nil } +func convertTmpfsMounts(tmpfs map[string]string) []specs.Mount { + mounts := make([]specs.Mount, 0, len(tmpfs)) + + for dst, options := range tmpfs { + mount := specs.Mount{ + Destination: dst, + Type: "tmpfs", + Source: "tmpfs", + } + if options != "" { + mount.Options = strings.Split(options, ",") + } + mounts = append(mounts, mount) + } + + return mounts +} + +func applySecurityOpts( + opts []string, + securityConfig *specgen.ContainerSecurityConfig, +) error { + for _, opt := range opts { + key, val, ok := strings.Cut(opt, "=") + if !ok { + key = opt + } + + switch key { + case "label": + securityConfig.SelinuxOpts = append(securityConfig.SelinuxOpts, val) + case "apparmor": + securityConfig.ApparmorProfile = val + case "seccomp": + securityConfig.SeccompProfilePath = val + case "no-new-privileges": + noNewPrivileges := val == "" || val == "true" + securityConfig.NoNewPrivileges = &noNewPrivileges + default: + return fmt.Errorf("unsupported podman security option %q", opt) + } + } + + return nil +} + // produceGenericContainerList takes a list of containers in a podman entities.ListContainer format // and transforms it into a GenericContainer type. func (r *PodmanRuntime) produceGenericContainerList(ctx context.Context, diff --git a/runtime/podman/util_test.go b/runtime/podman/util_test.go new file mode 100644 index 0000000000..08a5752429 --- /dev/null +++ b/runtime/podman/util_test.go @@ -0,0 +1,204 @@ +//go:build linux && podman +// +build linux,podman + +package podman + +import ( + "context" + "testing" + + "github.com/containers/podman/v5/pkg/specgen" + "github.com/srl-labs/containerlab/types" +) + +func TestCreateContainerSpecAppliesRuntimeNamespaceAndTmpfs(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-node1", + ShortName: "node1", + Image: "localhost/test:latest", + Labels: map[string]string{}, + NetworkMode: "host", + CgroupnsMode: "host", + ShmSize: "64m", + Tmpfs: map[string]string{"/run": "rw,nosuid,nodev", "/run/lock": "rw"}, + ExtraHosts: []string{"example:127.0.0.1"}, + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.CgroupNS.NSMode != specgen.Host { + t.Fatalf("CgroupNS mode = %q, want %q", sg.CgroupNS.NSMode, specgen.Host) + } + if sg.ShmSize == nil || *sg.ShmSize != 64*1000*1000 { + t.Fatalf("ShmSize = %v, want 64000000", sg.ShmSize) + } + + tmpfs := map[string][]string{} + for _, mount := range sg.Mounts { + if mount.Type == "tmpfs" { + tmpfs[mount.Destination] = mount.Options + } + } + if _, ok := tmpfs["/run"]; !ok { + t.Fatalf("tmpfs mounts = %#v, missing /run", tmpfs) + } + if _, ok := tmpfs["/run/lock"]; !ok { + t.Fatalf("tmpfs mounts = %#v, missing /run/lock", tmpfs) + } +} + +func TestCreateContainerSpecPreservesImageCommandDefaults(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-node1", + ShortName: "node1", + Image: "localhost/test:latest", + Labels: map[string]string{}, + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.Command != nil { + t.Fatalf("Command = %#v, want nil to preserve the image default", sg.Command) + } + if sg.Entrypoint != nil { + t.Fatalf("Entrypoint = %#v, want nil to preserve the image default", sg.Entrypoint) + } +} + +func TestCreateContainerSpecAppliesConfiguredHostname(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-node1", + ShortName: "node1", + Hostname: "dns-private-master-01001", + Image: "localhost/test:latest", + Labels: map[string]string{}, + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.Hostname != cfg.Hostname { + t.Fatalf("Hostname = %q, want %q", sg.Hostname, cfg.Hostname) + } +} + +func TestCreateContainerSpecAppliesHostnameWithContainerNetworkMode(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-child", + ShortName: "child", + Hostname: "dns-private-master-01001", + Image: "localhost/test:latest", + Labels: map[string]string{}, + NetworkMode: "container:provider", + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.Hostname != "" { + t.Fatalf("Hostname = %q, want empty so the provider owns the hostname", sg.Hostname) + } + if sg.UtsNS.NSMode != specgen.FromContainer { + t.Fatalf("UtsNS mode = %q, want %q", sg.UtsNS.NSMode, specgen.FromContainer) + } + if sg.UtsNS.Value != "clab-test-provider" { + t.Fatalf("UtsNS value = %q, want clab-test-provider", sg.UtsNS.Value) + } + if sg.NetNS.NSMode != specgen.FromContainer { + t.Fatalf("NetNS mode = %q, want %q", sg.NetNS.NSMode, specgen.FromContainer) + } + if sg.NetNS.Value != "clab-test-provider" { + t.Fatalf("NetNS value = %q, want clab-test-provider", sg.NetNS.Value) + } +} + +func TestContainerModeProviderName(t *testing.T) { + tests := []struct { + name string + networkMode string + want string + }{ + { + name: "short provider name", + networkMode: "container:provider", + want: "clab-test-provider", + }, + { + name: "long provider name", + networkMode: "container:clab-test-provider", + want: "clab-test-provider", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &types.NodeConfig{ + LongName: "clab-test-child", + ShortName: "child", + NetworkMode: tt.networkMode, + } + + got, err := containerModeProviderName(cfg) + if err != nil { + t.Fatalf("containerModeProviderName returned error: %v", err) + } + if got != tt.want { + t.Fatalf("containerModeProviderName = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCreateContainerSpecDefaultsHostnameToNodeName(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-node1", + ShortName: "node1", + Image: "localhost/test:latest", + Labels: map[string]string{}, + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.Hostname != cfg.ShortName { + t.Fatalf("Hostname = %q, want %q", sg.Hostname, cfg.ShortName) + } +} + +func TestCreateContainerSpecAppliesNoneNetworkMode(t *testing.T) { + r := &PodmanRuntime{mgmt: &types.MgmtNet{Network: "clab"}} + cfg := &types.NodeConfig{ + LongName: "clab-test-node1", + ShortName: "node1", + Image: "localhost/test:latest", + Labels: map[string]string{}, + NetworkMode: "none", + ExtraHosts: []string{"example:127.0.0.1"}, + } + + sg, err := r.createContainerSpec(context.Background(), cfg) + if err != nil { + t.Fatalf("createContainerSpec returned error: %v", err) + } + + if sg.NetNS.NSMode != specgen.NoNetwork { + t.Fatalf("NetNS mode = %q, want %q", sg.NetNS.NSMode, specgen.NoNetwork) + } +} diff --git a/schemas/clab.schema.json b/schemas/clab.schema.json index 0d4a753a7d..a432646cab 100644 --- a/schemas/clab.schema.json +++ b/schemas/clab.schema.json @@ -250,6 +250,12 @@ } ] }, + "hostname": { + "type": "string", + "minLength": 1, + "description": "hostname configured inside the container", + "markdownDescription": "container [hostname](https://containerlab.dev/manual/nodes/#hostname); defaults to the topology node name" + }, "entrypoint": { "type": "string", "description": "container's entrypoint", @@ -487,6 +493,45 @@ }, "uniqueItems": true }, + "privileged": { + "type": "boolean", + "description": "run the container in privileged mode", + "markdownDescription": "run the container in [privileged](https://containerlab.dev/manual/nodes/#privileged) mode" + }, + "cgroupns-mode": { + "type": "string", + "description": "cgroup namespace mode for the container", + "markdownDescription": "[cgroup namespace mode](https://containerlab.dev/manual/nodes/#cgroupns-mode) for the container", + "enum": [ + "host", + "private" + ] + }, + "pid-mode": { + "type": "string", + "description": "PID namespace mode for the container", + "markdownDescription": "[PID namespace mode](https://containerlab.dev/manual/nodes/#pid-mode) for the container" + }, + "tmpfs": { + "type": "object", + "description": "tmpfs mounts to add to the container", + "markdownDescription": "[tmpfs mounts](https://containerlab.dev/manual/nodes/#tmpfs) to add to the container", + "patternProperties": { + "^/.*": { + "type": "string" + } + }, + "additionalProperties": false + }, + "security-opts": { + "type": "array", + "description": "security options to apply to the container runtime", + "markdownDescription": "[security options](https://containerlab.dev/manual/nodes/#security-opts) to apply to the container runtime", + "items": { + "type": "string" + }, + "uniqueItems": true + }, "sysctls": { "type": "object", "description": "sysctl kernel parameters to set in the container", @@ -2253,4 +2298,4 @@ "name", "topology" ] -} \ No newline at end of file +} diff --git a/tests/01-smoke/30-apply-filter.clab.yml b/tests/01-smoke/30-apply-filter.clab.yml new file mode 100644 index 0000000000..bdc1204bbd --- /dev/null +++ b/tests/01-smoke/30-apply-filter.clab.yml @@ -0,0 +1,71 @@ +# yaml-language-server: $schema=../../schemas/clab.schema.json +# Copyright 2020 Nokia +# Licensed under the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause + +name: apply-filter + +topology: + nodes: + provider: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + stages: + configure: + exec: + - command: >- + sh -c 'ip link show eth1 >/dev/null && + echo configured >> /tmp/configure-stage-runs' + env: + PROVIDER_DRIFT: "{{ .providerDrift }}" + child1: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + network-mode: container:provider + child2: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + network-mode: container:provider + unrelated1: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + unrelated2: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + env: + UNRELATED2_DRIFT: "{{ .unrelated2Drift }}" +{{- if .addGroup }} + new-provider: + kind: linux + image: alpine:3 + cmd: tail -f /dev/null + healthcheck: + test: + - CMD-SHELL + - ip link show eth1 + interval: 1 + timeout: 1 + retries: 10 + new-child1: + kind: linux + image: alpine:3 + cmd: sh -c "ip link show eth1 >/dev/null && tail -f /dev/null" + network-mode: container:new-provider + new-child2: + kind: linux + image: alpine:3 + cmd: sh -c "ip link show eth1 >/dev/null && tail -f /dev/null" + network-mode: container:new-provider +{{- end }} + + links: + - endpoints: ["provider:eth1", "unrelated1:eth1"] + - endpoints: ["unrelated1:eth2", "unrelated2:eth1"] +{{- if .addGroup }} + - endpoints: ["new-provider:eth1", "unrelated2:eth2"] +{{- end }} diff --git a/tests/01-smoke/30-apply-filter.robot b/tests/01-smoke/30-apply-filter.robot new file mode 100644 index 0000000000..86f51108b0 --- /dev/null +++ b/tests/01-smoke/30-apply-filter.robot @@ -0,0 +1,145 @@ +*** Settings *** +Library OperatingSystem +Library Process +Resource ../common.robot + +Suite Setup Cleanup +Suite Teardown Cleanup + + +*** Variables *** +${lab-name} apply-filter +${runtime} docker +${topo} 30-apply-filter.clab.yml +${initial-vars} 30-apply-filter.vars.initial.yml +${add-group-vars} 30-apply-filter.vars.add-group.yml +${peer-recreate-vars} 30-apply-filter.vars.peer-recreate.yml + + +*** Test Cases *** +Filtered apply adds shared-netns group without touching existing nodes + ${rc} ${output} = Apply Topology ${initial-vars} + Should Be Equal As Integers ${rc} 0 + ${provider_before} = Node Runtime Identity provider + ${unrelated1_before} = Node Runtime Identity unrelated1 + ${unrelated2_before} = Node Runtime Identity unrelated2 + + ${rc} ${output} = Apply Topology + ... ${add-group-vars} + ... --node-filter new-child1,new-child2 + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} added nodes + Should Contain ${output} new-provider + Should Contain ${output} new-child1 + Should Contain ${output} new-child2 + Should Not Contain ${output} deleted endpoints + + Node Should Be Running new-provider + Node Should Be Running new-child1 + Node Should Be Running new-child2 + Interface Should Exist provider eth1 + Interface Should Exist unrelated1 eth1 + Interface Should Exist unrelated1 eth2 + Interface Should Exist unrelated2 eth1 + Interface Should Exist unrelated2 eth2 + Interface Should Exist new-provider eth1 + Interface Should Exist new-child1 eth1 + Interface Should Exist new-child2 eth1 + + ${provider_after} = Node Runtime Identity provider + ${unrelated1_after} = Node Runtime Identity unrelated1 + ${unrelated2_after} = Node Runtime Identity unrelated2 + Should Be Equal As Strings ${provider_after} ${provider_before} + Should Be Equal As Strings ${unrelated1_after} ${unrelated1_before} + Should Be Equal As Strings ${unrelated2_after} ${unrelated2_before} + +Targeted restart restores links before replaying configure stage + Configure Stage Run Count Should Be provider 1 + + ${rc} ${output} = Run Clab Command + ... restart -t ${CURDIR}/${topo} --node provider + Should Be Equal As Integers ${rc} 0 + Interface Should Exist provider eth1 + Configure Stage Run Count Should Be provider 2 + +Filtered apply recreates a provider link only after its absent peer exists + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} rm --force clab-${lab-name}-unrelated1 2>&1 + Log ${output} + Should Be Equal As Integers ${rc} 0 + + ${rc} ${output} = Apply Topology + ... ${peer-recreate-vars} + ... --node-filter unrelated1,unrelated2 + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} added nodes + Should Contain ${output} unrelated1 + Should Contain ${output} recreated nodes + Should Contain ${output} unrelated2 + Interface Should Exist unrelated1 eth1 + Interface Should Exist unrelated1 eth2 + Interface Should Exist unrelated2 eth1 + Interface Should Exist unrelated2 eth2 + + ${rc} ${output} = Apply Topology + ... ${peer-recreate-vars} + ... --node-filter unrelated1,unrelated2 --dry-run + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} no changes + + +*** Keywords *** +Cleanup + Run Clab Command destroy --name ${lab-name} --cleanup + +Run Clab Command + [Arguments] ${args} + ${rc} ${output} = Run And Return Rc And Output + ... ${CLAB_BIN} --runtime ${runtime} ${args} 2>&1 + Log ${output} + RETURN ${rc} ${output} + +Apply Topology + [Arguments] ${vars_file} ${extra_args}=${EMPTY} + ${rc} ${output} = Run Clab Command + ... apply -t ${CURDIR}/${topo} --vars ${CURDIR}/${vars_file} ${extra_args} + RETURN ${rc} ${output} + +Interface Should Exist + [Arguments] ${node} ${interface} + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} exec clab-${lab-name}-${node} ip link show ${interface} + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Contain ${output} ${interface} + +Configure Stage Run Count Should Be + [Arguments] ${node} ${expected} + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} exec clab-${lab-name}-${node} sh -c 'wc -l < /tmp/configure-stage-runs' + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Be Equal As Integers ${output} ${expected} + +Node Should Be Running + [Arguments] ${node} + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} inspect -f '{{.State.Status}}' clab-${lab-name}-${node} + Log ${output} + Should Be Equal As Integers ${rc} 0 + Should Match Regexp ${output} (?im)^running\\s*$ + +Node Should Not Exist + [Arguments] ${node} + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} inspect clab-${lab-name}-${node} + Log ${output} + Should Not Be Equal As Integers ${rc} 0 + +Node Runtime Identity + [Arguments] ${node} + ${rc} ${output} = Run And Return Rc And Output + ... ${runtime} inspect -f '{{.State.Pid}} {{.State.StartedAt}}' clab-${lab-name}-${node} + Log ${output} + Should Be Equal As Integers ${rc} 0 + RETURN ${output} diff --git a/tests/01-smoke/30-apply-filter.vars.add-group.yml b/tests/01-smoke/30-apply-filter.vars.add-group.yml new file mode 100644 index 0000000000..8ed0dda25b --- /dev/null +++ b/tests/01-smoke/30-apply-filter.vars.add-group.yml @@ -0,0 +1,3 @@ +addGroup: true +providerDrift: false +unrelated2Drift: false diff --git a/tests/01-smoke/30-apply-filter.vars.initial.yml b/tests/01-smoke/30-apply-filter.vars.initial.yml new file mode 100644 index 0000000000..846e90dbd2 --- /dev/null +++ b/tests/01-smoke/30-apply-filter.vars.initial.yml @@ -0,0 +1,3 @@ +addGroup: false +providerDrift: false +unrelated2Drift: false diff --git a/tests/01-smoke/30-apply-filter.vars.peer-recreate.yml b/tests/01-smoke/30-apply-filter.vars.peer-recreate.yml new file mode 100644 index 0000000000..33eca72ad9 --- /dev/null +++ b/tests/01-smoke/30-apply-filter.vars.peer-recreate.yml @@ -0,0 +1,3 @@ +addGroup: true +providerDrift: false +unrelated2Drift: true diff --git a/types/node_definition.go b/types/node_definition.go index daac4c564a..e5a85a66d8 100644 --- a/types/node_definition.go +++ b/types/node_definition.go @@ -66,8 +66,10 @@ type NodeDefinition struct { ImagePullPolicy string `yaml:"image-pull-policy,omitempty"` License string `yaml:"license,omitempty"` Position string `yaml:"position,omitempty"` - Entrypoint string `yaml:"entrypoint,omitempty"` - Cmd string `yaml:"cmd,omitempty"` + // Hostname overrides the container hostname. When unset, the topology node name is used. + Hostname string `yaml:"hostname,omitempty"` + Entrypoint string `yaml:"entrypoint,omitempty"` + Cmd string `yaml:"cmd,omitempty"` // list of commands to run in container Exec []string `yaml:"exec,omitempty"` // list of bind mount compatible strings @@ -76,6 +78,16 @@ type NodeDefinition struct { Devices []string `yaml:"devices,omitempty"` // List of capabilities to add for the container CapAdd []string `yaml:"cap-add,omitempty"` + // Run the container in privileged mode. + Privileged *bool `yaml:"privileged,omitempty"` + // Cgroup namespace mode for the container. + CgroupnsMode string `yaml:"cgroupns-mode,omitempty"` + // PID namespace mode for the container. + PidMode string `yaml:"pid-mode,omitempty"` + // Tmpfs mounts to add to the container, keyed by destination path. + Tmpfs map[string]string `yaml:"tmpfs,omitempty"` + // Security options to apply to the container runtime. + SecurityOpts []string `yaml:"security-opts,omitempty"` // Set the shared memory size allocated to the container ShmSize string `yaml:"shm-size,omitempty"` // list of port bindings diff --git a/types/topology.go b/types/topology.go index b740ff82e4..424a425336 100644 --- a/types/topology.go +++ b/types/topology.go @@ -415,6 +415,65 @@ func (t *Topology) GetNodeCapAdd(nodeName string) []string { ) } +func (t *Topology) GetNodePrivileged(nodeName string) bool { + return getFieldPtr( + t, + nodeName, + func(node *NodeDefinition) *bool { return node.Privileged }, + func(group *NodeDefinition) *bool { return group.Privileged }, + func(kind *NodeDefinition) *bool { return kind.Privileged }, + func(defaults *NodeDefinition) *bool { return defaults.Privileged }, + func(v *bool) bool { return v != nil }, + true, + ) +} + +func (t *Topology) GetNodeCgroupnsMode(nodeName string) string { + return getField( + t, + nodeName, + func(node *NodeDefinition) string { return node.CgroupnsMode }, + func(group *NodeDefinition) string { return group.CgroupnsMode }, + func(kind *NodeDefinition) string { return kind.CgroupnsMode }, + func(defaults *NodeDefinition) string { return defaults.CgroupnsMode }, + func(v string) bool { return v != "" }, + ) +} + +func (t *Topology) GetNodePidMode(nodeName string) string { + return getField( + t, + nodeName, + func(node *NodeDefinition) string { return node.PidMode }, + func(group *NodeDefinition) string { return group.PidMode }, + func(kind *NodeDefinition) string { return kind.PidMode }, + func(defaults *NodeDefinition) string { return defaults.PidMode }, + func(v string) bool { return v != "" }, + ) +} + +func (t *Topology) GetNodeTmpfs(nodeName string) map[string]string { + return mergeStringMapFields( + t, + nodeName, + func(node *NodeDefinition) map[string]string { return node.Tmpfs }, + func(group *NodeDefinition) map[string]string { return group.Tmpfs }, + func(kind *NodeDefinition) map[string]string { return kind.Tmpfs }, + func(defaults *NodeDefinition) map[string]string { return defaults.Tmpfs }, + ) +} + +func (t *Topology) GetNodeSecurityOpts(nodeName string) []string { + return mergeStringSliceFields( + t, + nodeName, + func(node *NodeDefinition) []string { return node.SecurityOpts }, + func(group *NodeDefinition) []string { return group.SecurityOpts }, + func(kind *NodeDefinition) []string { return kind.SecurityOpts }, + func(defaults *NodeDefinition) []string { return defaults.SecurityOpts }, + ) +} + func (t *Topology) GetNodeShmSize(nodeName string) string { return getField( t, @@ -564,6 +623,18 @@ func (t *Topology) GetNodePosition(nodeName string) string { ) } +func (t *Topology) GetNodeHostname(nodeName string) string { + return getField( + t, + nodeName, + func(node *NodeDefinition) string { return node.Hostname }, + func(group *NodeDefinition) string { return group.Hostname }, + func(kind *NodeDefinition) string { return kind.Hostname }, + func(defaults *NodeDefinition) string { return defaults.Hostname }, + func(v string) bool { return v != "" }, + ) +} + func (t *Topology) GetNodeEntrypoint(nodeName string) string { return getField( t, diff --git a/types/topology_test.go b/types/topology_test.go index 6ce33df467..1bf47c341e 100644 --- a/types/topology_test.go +++ b/types/topology_test.go @@ -835,6 +835,44 @@ func TestGetNodePosition(t *testing.T) { } } +func TestGetNodeHostname(t *testing.T) { + topology := &Topology{ + Defaults: &NodeDefinition{Hostname: "default-host"}, + Kinds: map[string]*NodeDefinition{ + "linux": {Hostname: "kind-host"}, + }, + Groups: map[string]*NodeDefinition{ + "apps": {Hostname: "group-host"}, + }, + Nodes: map[string]*NodeDefinition{ + "node-default": {}, + "node-kind": {Kind: "linux"}, + "node-group": {Kind: "linux", Group: "apps"}, + "node-explicit": { + Kind: "linux", Group: "apps", Hostname: "node-host", + }, + }, + } + + tests := []struct { + name string + want string + }{ + {name: "node-default", want: "default-host"}, + {name: "node-kind", want: "kind-host"}, + {name: "node-group", want: "group-host"}, + {name: "node-explicit", want: "node-host"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := topology.GetNodeHostname(tt.name); got != tt.want { + t.Fatalf("GetNodeHostname(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} + func TestGetNodeCmd(t *testing.T) { for name, item := range topologyTestSet { t.Logf("%q test item", name) @@ -1363,3 +1401,97 @@ func TestGetNodeCredentialTopologySource(t *testing.T) { }) } } + +func TestGetNodeRuntimeOptions(t *testing.T) { + defaultPrivileged := false + kindPrivileged := true + nodePrivileged := false + + topo := &Topology{ + Defaults: &NodeDefinition{ + Privileged: &defaultPrivileged, + CgroupnsMode: "private", + PidMode: "host", + Tmpfs: map[string]string{"/run": "rw"}, + SecurityOpts: []string{"label=disable"}, + }, + Kinds: map[string]*NodeDefinition{ + "linux": { + Privileged: &kindPrivileged, + CgroupnsMode: "host", + Tmpfs: map[string]string{"/run/lock": "rw"}, + SecurityOpts: []string{"seccomp=unconfined"}, + }, + }, + Groups: map[string]*NodeDefinition{ + "systemd": { + PidMode: "container:infra", + Tmpfs: map[string]string{"/tmp": "rw,nosuid"}, + }, + }, + Nodes: map[string]*NodeDefinition{ + "node1": { + Kind: "linux", + Group: "systemd", + Privileged: &nodePrivileged, + CgroupnsMode: "host", + Tmpfs: map[string]string{"/run": "rw,nosuid,nodev"}, + SecurityOpts: []string{"apparmor=unconfined"}, + }, + "node2": { + Kind: "linux", + }, + "node3": {}, + }, + } + + if got := topo.GetNodePrivileged("node1"); got { + t.Fatalf("node1 privileged = %v, want false", got) + } + + if got := topo.GetNodePrivileged("node2"); !got { + t.Fatalf("node2 privileged = %v, want true", got) + } + + if got := topo.GetNodePrivileged("node3"); got { + t.Fatalf("node3 privileged = %v, want false", got) + } + + if got := topo.GetNodeCgroupnsMode("node1"); got != "host" { + t.Fatalf("node1 cgroupns-mode = %q, want host", got) + } + + if got := topo.GetNodePidMode("node1"); got != "container:infra" { + t.Fatalf("node1 pid-mode = %q, want container:infra", got) + } + + wantTmpfs := map[string]string{ + "/run": "rw,nosuid,nodev", + "/run/lock": "rw", + "/tmp": "rw,nosuid", + } + if diff := cmp.Diff(wantTmpfs, topo.GetNodeTmpfs("node1")); diff != "" { + t.Fatalf("node1 tmpfs mismatch (-want +got):\n%s", diff) + } + + wantSecurityOpts := []string{ + "label=disable", + "seccomp=unconfined", + "apparmor=unconfined", + } + if diff := cmp.Diff(wantSecurityOpts, topo.GetNodeSecurityOpts("node1")); diff != "" { + t.Fatalf("node1 security-opts mismatch (-want +got):\n%s", diff) + } +} + +func TestGetNodePrivilegedDefault(t *testing.T) { + topo := &Topology{ + Nodes: map[string]*NodeDefinition{ + "node1": {Kind: "linux"}, + }, + } + + if got := topo.GetNodePrivileged("node1"); !got { + t.Fatalf("privileged = %v, want true", got) + } +} diff --git a/types/types.go b/types/types.go index 3806158b84..bc61167f29 100644 --- a/types/types.go +++ b/types/types.go @@ -124,6 +124,8 @@ type NodeConfig struct { ShortName string `json:"shortname,omitempty"` // containerlab-prefixed unique container name LongName string `json:"longname,omitempty"` + // Hostname is the runtime hostname. An empty value falls back to ShortName. + Hostname string `json:"hostname,omitempty"` Fqdn string `json:"fqdn,omitempty"` // LabDir is a directory related to the node, it contains config items and/or other persistent // state @@ -168,6 +170,16 @@ type NodeConfig struct { Devices []string `json:"devices,omitempty"` // Capabilities required by the container (if not run in privileged mode) CapAdd []string `json:"cap-add,omitempty"` + // Run the container in privileged mode. + Privileged bool `json:"privileged,omitempty"` + // Cgroup namespace mode for the container. + CgroupnsMode string `json:"cgroupns-mode,omitempty"` + // PID namespace mode for the container. + PidMode string `json:"pidmode,omitempty"` + // Tmpfs mounts to add to the container, keyed by destination path. + Tmpfs map[string]string `json:"tmpfs,omitempty"` + // Security options to apply to the container runtime. + SecurityOpts []string `json:"security-opts,omitempty"` // Size of the shared memory allocated to the container ShmSize string `json:"shm-size,omitempty"` // PortBindings define the bindings between the container ports and host ports @@ -179,7 +191,6 @@ type NodeConfig struct { // NetworkMode defines container networking mode. // If set to `host` the host networking will be used for this node, else bridged network NetworkMode string `json:"networkmode,omitempty"` - PidMode string `json:"pidmode,omitempty"` // MgmtNet is the name of the docker network this node is connected to with its first interface MgmtNet string `json:"mgmt-net,omitempty"` // MgmtIntf can be used to be rendered by the default node template @@ -229,7 +240,15 @@ type NodeConfig struct { // they should be present by definition. SkipUniquenessCheck bool Components []*Component - Tmpfs map[string]string `json:"tmpfs,omitempty"` +} + +// GetHostname returns the configured runtime hostname or the topology node name. +func (n *NodeConfig) GetHostname() string { + if n.Hostname != "" { + return n.Hostname + } + + return n.ShortName } type GenericFilter struct {