mirror of
https://github.com/containers/podman.git
synced 2025-06-23 02:18:13 +08:00
Add support for multiple CNI networks in podman inspect
When inspecting containers, info on CNI networks added to the container by name (e.g. --net=name1) should be displayed separately from the configuration of the default network, in a separate map called Networks. This patch adds this separation, improving our Docker compatibility and also adding the ability to see if a container has more than one IPv4 and IPv6 address and more than one MAC address. Fixes #4907 Signed-off-by: Matthew Heon <matthew.heon@pm.me>
This commit is contained in:
@ -606,11 +606,45 @@ type InspectContainerState struct {
|
|||||||
Healthcheck HealthCheckResults `json:"Healthcheck,omitempty"`
|
Healthcheck HealthCheckResults `json:"Healthcheck,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InspectBasicNetworkConfig holds basic configuration information (e.g. IP
|
||||||
|
// addresses, MAC address, subnet masks, etc) that are common for all networks
|
||||||
|
// (both additional and main).
|
||||||
|
type InspectBasicNetworkConfig struct {
|
||||||
|
// EndpointID is unused, maintained exclusively for compatibility.
|
||||||
|
EndpointID string `json:"EndpointID"`
|
||||||
|
// Gateway is the IP address of the gateway this network will use.
|
||||||
|
Gateway string `json:"Gateway"`
|
||||||
|
// IPAddress is the IP address for this network.
|
||||||
|
IPAddress string `json:"IPAddress"`
|
||||||
|
// IPPrefixLen is the length of the subnet mask of this network.
|
||||||
|
IPPrefixLen int `json:"IPPrefixLen"`
|
||||||
|
// SecondaryIPAddresses is a list of extra IP Addresses that the
|
||||||
|
// container has been assigned in this network.
|
||||||
|
SecondaryIPAddresses []string `json:"SecondaryIPAddresses,omitempty"`
|
||||||
|
// IPv6Gateway is the IPv6 gateway this network will use.
|
||||||
|
IPv6Gateway string `json:"IPv6Gateway"`
|
||||||
|
// GlobalIPv6Address is the global-scope IPv6 Address for this network.
|
||||||
|
GlobalIPv6Address string `json:"GlobalIPv6Address"`
|
||||||
|
// GlobalIPv6PrefixLen is the length of the subnet mask of this network.
|
||||||
|
GlobalIPv6PrefixLen int `json:"GlobalIPv6PrefixLen"`
|
||||||
|
// SecondaryIPv6Addresses is a list of extra IPv6 Addresses that the
|
||||||
|
// container has been assigned in this networ.
|
||||||
|
SecondaryIPv6Addresses []string `json:"SecondaryIPv6Addresses,omitempty"`
|
||||||
|
// MacAddress is the MAC address for the interface in this network.
|
||||||
|
MacAddress string `json:"MacAddress"`
|
||||||
|
// AdditionalMacAddresses is a set of additional MAC Addresses beyond
|
||||||
|
// the first. CNI may configure more than one interface for a single
|
||||||
|
// network, which can cause this.
|
||||||
|
AdditionalMacAddresses []string `json:"AdditionalMACAddresses,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// InspectNetworkSettings holds information about the network settings of the
|
// InspectNetworkSettings holds information about the network settings of the
|
||||||
// container.
|
// container.
|
||||||
// Many fields are maintained only for compatibility with `docker inspect` and
|
// Many fields are maintained only for compatibility with `docker inspect` and
|
||||||
// are unused within Libpod.
|
// are unused within Libpod.
|
||||||
type InspectNetworkSettings struct {
|
type InspectNetworkSettings struct {
|
||||||
|
InspectBasicNetworkConfig
|
||||||
|
|
||||||
Bridge string `json:"Bridge"`
|
Bridge string `json:"Bridge"`
|
||||||
SandboxID string `json:"SandboxID"`
|
SandboxID string `json:"SandboxID"`
|
||||||
HairpinMode bool `json:"HairpinMode"`
|
HairpinMode bool `json:"HairpinMode"`
|
||||||
@ -618,16 +652,30 @@ type InspectNetworkSettings struct {
|
|||||||
LinkLocalIPv6PrefixLen int `json:"LinkLocalIPv6PrefixLen"`
|
LinkLocalIPv6PrefixLen int `json:"LinkLocalIPv6PrefixLen"`
|
||||||
Ports []ocicni.PortMapping `json:"Ports"`
|
Ports []ocicni.PortMapping `json:"Ports"`
|
||||||
SandboxKey string `json:"SandboxKey"`
|
SandboxKey string `json:"SandboxKey"`
|
||||||
SecondaryIPAddresses []string `json:"SecondaryIPAddresses"`
|
// Networks contains information on non-default CNI networks this
|
||||||
SecondaryIPv6Addresses []string `json:"SecondaryIPv6Addresses"`
|
// container has joined.
|
||||||
EndpointID string `json:"EndpointID"`
|
// It is a map of network name to network information.
|
||||||
Gateway string `json:"Gateway"`
|
Networks map[string]*InspectAdditionalNetwork `json:"Networks,omitempty"`
|
||||||
GlobalIPv6Address string `json:"GlobalIPv6Address"`
|
}
|
||||||
GlobalIPv6PrefixLen int `json:"GlobalIPv6PrefixLen"`
|
|
||||||
IPAddress string `json:"IPAddress"`
|
// InspectAdditionalNetwork holds information about non-default CNI networks the
|
||||||
IPPrefixLen int `json:"IPPrefixLen"`
|
// container has been connected to.
|
||||||
IPv6Gateway string `json:"IPv6Gateway"`
|
// As with InspectNetworkSettings, many fields are unused and maintained only
|
||||||
MacAddress string `json:"MacAddress"`
|
// for compatibility with Docker.
|
||||||
|
type InspectAdditionalNetwork struct {
|
||||||
|
InspectBasicNetworkConfig
|
||||||
|
|
||||||
|
// Name of the network we're connecting to.
|
||||||
|
NetworkID string `json:"NetworkID,omitempty"`
|
||||||
|
// DriverOpts is presently unused and maintained exclusively for
|
||||||
|
// compatibility.
|
||||||
|
DriverOpts map[string]string `json:"DriverOpts"`
|
||||||
|
// IPAMConfig is presently unused and maintained exlusively for
|
||||||
|
// compabitility.
|
||||||
|
IPAMConfig map[string]string `json:"IPAMConfig"`
|
||||||
|
// Links is presently unused and maintained exclusively for
|
||||||
|
// compatibility.
|
||||||
|
Links []string `json:"Links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// inspectLocked inspects a container for low-level information.
|
// inspectLocked inspects a container for low-level information.
|
||||||
@ -754,27 +802,7 @@ func (c *Container) getContainerInspectData(size bool, driverData *driver.Data)
|
|||||||
GraphDriver: driverData,
|
GraphDriver: driverData,
|
||||||
Mounts: inspectMounts,
|
Mounts: inspectMounts,
|
||||||
Dependencies: c.Dependencies(),
|
Dependencies: c.Dependencies(),
|
||||||
NetworkSettings: &InspectNetworkSettings{
|
IsInfra: c.IsInfra(),
|
||||||
Bridge: "", // TODO
|
|
||||||
SandboxID: "", // TODO - is this even relevant?
|
|
||||||
HairpinMode: false, // TODO
|
|
||||||
LinkLocalIPv6Address: "", // TODO - do we even support IPv6?
|
|
||||||
LinkLocalIPv6PrefixLen: 0, // TODO - do we even support IPv6?
|
|
||||||
|
|
||||||
Ports: []ocicni.PortMapping{}, // TODO - maybe worth it to put this in Docker format?
|
|
||||||
SandboxKey: "", // Network namespace path
|
|
||||||
SecondaryIPAddresses: nil, // TODO - do we support this?
|
|
||||||
SecondaryIPv6Addresses: nil, // TODO - do we support this?
|
|
||||||
EndpointID: "", // TODO - is this even relevant?
|
|
||||||
Gateway: "", // TODO
|
|
||||||
GlobalIPv6Address: "",
|
|
||||||
GlobalIPv6PrefixLen: 0,
|
|
||||||
IPAddress: "",
|
|
||||||
IPPrefixLen: 0,
|
|
||||||
IPv6Gateway: "",
|
|
||||||
MacAddress: "", // TODO
|
|
||||||
},
|
|
||||||
IsInfra: c.IsInfra(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.state.ConfigPath != "" {
|
if c.state.ConfigPath != "" {
|
||||||
@ -792,13 +820,11 @@ func (c *Container) getContainerInspectData(size bool, driverData *driver.Data)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy port mappings into network settings
|
networkConfig, err := c.getContainerNetworkInfo()
|
||||||
if config.PortMappings != nil {
|
if err != nil {
|
||||||
data.NetworkSettings.Ports = config.PortMappings
|
return nil, err
|
||||||
}
|
}
|
||||||
|
data.NetworkSettings = networkConfig
|
||||||
// Get information on the container's network namespace (if present)
|
|
||||||
data = c.getContainerNetworkInfo(data)
|
|
||||||
|
|
||||||
inspectConfig, err := c.generateInspectContainerConfig(ctrSpec)
|
inspectConfig, err := c.generateInspectContainerConfig(ctrSpec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -12,13 +12,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cnitypes "github.com/containernetworking/cni/pkg/types/current"
|
cnitypes "github.com/containernetworking/cni/pkg/types/current"
|
||||||
"github.com/containernetworking/plugins/pkg/ns"
|
"github.com/containernetworking/plugins/pkg/ns"
|
||||||
|
"github.com/containers/libpod/libpod/define"
|
||||||
"github.com/containers/libpod/pkg/errorhandling"
|
"github.com/containers/libpod/pkg/errorhandling"
|
||||||
"github.com/containers/libpod/pkg/netns"
|
"github.com/containers/libpod/pkg/netns"
|
||||||
"github.com/containers/libpod/pkg/rootless"
|
"github.com/containers/libpod/pkg/rootless"
|
||||||
@ -556,37 +556,105 @@ func getContainerNetIO(ctr *Container) (*netlink.LinkStatistics, error) {
|
|||||||
return netStats, err
|
return netStats, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) getContainerNetworkInfo(data *InspectContainerData) *InspectContainerData {
|
// Produce an InspectNetworkSettings containing information on the container
|
||||||
if c.state.NetNS != nil && len(c.state.NetworkStatus) > 0 {
|
// network.
|
||||||
// Report network settings from the first pod network
|
func (c *Container) getContainerNetworkInfo() (*InspectNetworkSettings, error) {
|
||||||
result := c.state.NetworkStatus[0]
|
settings := new(InspectNetworkSettings)
|
||||||
// Go through our IP addresses
|
settings.Ports = []ocicni.PortMapping{}
|
||||||
for _, ctrIP := range result.IPs {
|
if c.config.PortMappings != nil {
|
||||||
ipWithMask := ctrIP.Address.String()
|
// TODO: This may not be safe.
|
||||||
splitIP := strings.Split(ipWithMask, "/")
|
settings.Ports = c.config.PortMappings
|
||||||
mask, _ := strconv.Atoi(splitIP[1])
|
}
|
||||||
if ctrIP.Version == "4" {
|
|
||||||
data.NetworkSettings.IPAddress = splitIP[0]
|
// We can't do more if the network is down.
|
||||||
data.NetworkSettings.IPPrefixLen = mask
|
if c.state.NetNS == nil {
|
||||||
data.NetworkSettings.Gateway = ctrIP.Gateway.String()
|
return settings, nil
|
||||||
} else {
|
}
|
||||||
data.NetworkSettings.GlobalIPv6Address = splitIP[0]
|
|
||||||
data.NetworkSettings.GlobalIPv6PrefixLen = mask
|
// Set network namespace path
|
||||||
data.NetworkSettings.IPv6Gateway = ctrIP.Gateway.String()
|
settings.SandboxKey = c.state.NetNS.Path()
|
||||||
}
|
|
||||||
|
// If this is empty, we're probably slirp4netns
|
||||||
|
if len(c.state.NetworkStatus) == 0 {
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have CNI networks - handle that here
|
||||||
|
if len(c.config.Networks) > 0 {
|
||||||
|
if len(c.config.Networks) != len(c.state.NetworkStatus) {
|
||||||
|
return nil, errors.Wrapf(define.ErrInternal, "network inspection mismatch: asked to join %d CNI networks but have information on %d networks", len(c.config.Networks), len(c.state.NetworkStatus))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set network namespace path
|
settings.Networks = make(map[string]*InspectAdditionalNetwork)
|
||||||
data.NetworkSettings.SandboxKey = c.state.NetNS.Path()
|
|
||||||
|
|
||||||
// Set MAC address of interface linked with network namespace path
|
// CNI results should be in the same order as the list of
|
||||||
for _, i := range result.Interfaces {
|
// networks we pass into CNI.
|
||||||
if i.Sandbox == data.NetworkSettings.SandboxKey {
|
for index, name := range c.config.Networks {
|
||||||
data.NetworkSettings.MacAddress = i.Mac
|
cniResult := c.state.NetworkStatus[index]
|
||||||
|
addedNet := new(InspectAdditionalNetwork)
|
||||||
|
addedNet.NetworkID = name
|
||||||
|
|
||||||
|
basicConfig, err := resultToBasicNetworkConfig(cniResult)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
addedNet.InspectBasicNetworkConfig = basicConfig
|
||||||
|
|
||||||
|
settings.Networks[name] = addedNet
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not joining networks, we should have at most 1 result
|
||||||
|
if len(c.state.NetworkStatus) > 1 {
|
||||||
|
return nil, errors.Wrapf(define.ErrInternal, "should have at most 1 CNI result if not joining networks, instead got %d", len(c.state.NetworkStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.state.NetworkStatus) == 1 {
|
||||||
|
basicConfig, err := resultToBasicNetworkConfig(c.state.NetworkStatus[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.InspectBasicNetworkConfig = basicConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultToBasicNetworkConfig produces an InspectBasicNetworkConfig from a CNI
|
||||||
|
// result
|
||||||
|
func resultToBasicNetworkConfig(result *cnitypes.Result) (InspectBasicNetworkConfig, error) {
|
||||||
|
config := InspectBasicNetworkConfig{}
|
||||||
|
|
||||||
|
for _, ctrIP := range result.IPs {
|
||||||
|
size, _ := ctrIP.Address.Mask.Size()
|
||||||
|
switch {
|
||||||
|
case ctrIP.Version == "4" && config.IPAddress == "":
|
||||||
|
config.IPAddress = ctrIP.Address.IP.String()
|
||||||
|
config.IPPrefixLen = size
|
||||||
|
config.Gateway = ctrIP.Gateway.String()
|
||||||
|
if ctrIP.Interface != nil && *ctrIP.Interface < len(result.Interfaces) && *ctrIP.Interface > 0 {
|
||||||
|
config.MacAddress = result.Interfaces[*ctrIP.Interface].Mac
|
||||||
|
}
|
||||||
|
case ctrIP.Version == "4" && config.IPAddress != "":
|
||||||
|
config.SecondaryIPAddresses = append(config.SecondaryIPAddresses, ctrIP.Address.String())
|
||||||
|
if ctrIP.Interface != nil && *ctrIP.Interface < len(result.Interfaces) && *ctrIP.Interface > 0 {
|
||||||
|
config.AdditionalMacAddresses = append(config.AdditionalMacAddresses, result.Interfaces[*ctrIP.Interface].Mac)
|
||||||
|
}
|
||||||
|
case ctrIP.Version == "6" && config.IPAddress == "":
|
||||||
|
config.GlobalIPv6Address = ctrIP.Address.IP.String()
|
||||||
|
config.GlobalIPv6PrefixLen = size
|
||||||
|
config.IPv6Gateway = ctrIP.Gateway.String()
|
||||||
|
case ctrIP.Version == "6" && config.IPAddress != "":
|
||||||
|
config.SecondaryIPv6Addresses = append(config.SecondaryIPv6Addresses, ctrIP.Address.String())
|
||||||
|
default:
|
||||||
|
return config, errors.Wrapf(define.ErrInternal, "unrecognized IP version %q", ctrIP.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data
|
|
||||||
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type logrusDebugWriter struct {
|
type logrusDebugWriter struct {
|
||||||
|
@ -20,6 +20,6 @@ func (r *Runtime) createNetNS(ctr *Container) (err error) {
|
|||||||
return define.ErrNotImplemented
|
return define.ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) getContainerNetworkInfo(data *InspectContainerData) *InspectContainerData {
|
func (c *Container) getContainerNetworkInfo() (*InspectNetworkSettings, error) {
|
||||||
return nil
|
return nil, define.ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,15 @@ package integration
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
. "github.com/containers/libpod/test/utils"
|
. "github.com/containers/libpod/test/utils"
|
||||||
"github.com/containers/storage/pkg/stringid"
|
"github.com/containers/storage/pkg/stringid"
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeConf(conf []byte, confPath string) {
|
func writeConf(conf []byte, confPath string) {
|
||||||
@ -155,4 +157,76 @@ var _ = Describe("Podman network", func() {
|
|||||||
Expect(session.IsJSONOutputValid()).To(BeTrue())
|
Expect(session.IsJSONOutputValid()).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("podman inspect container single CNI network", func() {
|
||||||
|
SkipIfRootless()
|
||||||
|
netName := "testNetSingleCNI"
|
||||||
|
network := podmanTest.Podman([]string{"network", "create", "--subnet", "10.50.50.0/24", netName})
|
||||||
|
network.WaitWithDefaultTimeout()
|
||||||
|
Expect(network.ExitCode()).To(BeZero())
|
||||||
|
defer podmanTest.removeCNINetwork(netName)
|
||||||
|
|
||||||
|
ctrName := "testCtr"
|
||||||
|
container := podmanTest.Podman([]string{"run", "-dt", "--network", netName, "--name", ctrName, ALPINE, "top"})
|
||||||
|
container.WaitWithDefaultTimeout()
|
||||||
|
Expect(container.ExitCode()).To(BeZero())
|
||||||
|
|
||||||
|
inspect := podmanTest.Podman([]string{"inspect", ctrName})
|
||||||
|
inspect.WaitWithDefaultTimeout()
|
||||||
|
Expect(inspect.ExitCode()).To(BeZero())
|
||||||
|
conData := inspect.InspectContainerToJSON()
|
||||||
|
Expect(len(conData)).To(Equal(1))
|
||||||
|
Expect(len(conData[0].NetworkSettings.Networks)).To(Equal(1))
|
||||||
|
net, ok := conData[0].NetworkSettings.Networks[netName]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(net.NetworkID).To(Equal(netName))
|
||||||
|
Expect(net.IPPrefixLen).To(Equal(24))
|
||||||
|
Expect(strings.HasPrefix(net.IPAddress, "10.50.50.")).To(BeTrue())
|
||||||
|
|
||||||
|
// Necessary to ensure the CNI network is removed cleanly
|
||||||
|
rmAll := podmanTest.Podman([]string{"rm", "-f", ctrName})
|
||||||
|
rmAll.WaitWithDefaultTimeout()
|
||||||
|
Expect(rmAll.ExitCode()).To(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("podman inspect container two CNI networks", func() {
|
||||||
|
SkipIfRootless()
|
||||||
|
netName1 := "testNetTwoCNI1"
|
||||||
|
network1 := podmanTest.Podman([]string{"network", "create", "--subnet", "10.50.51.0/25", netName1})
|
||||||
|
network1.WaitWithDefaultTimeout()
|
||||||
|
Expect(network1.ExitCode()).To(BeZero())
|
||||||
|
defer podmanTest.removeCNINetwork(netName1)
|
||||||
|
|
||||||
|
netName2 := "testNetTwoCNI2"
|
||||||
|
network2 := podmanTest.Podman([]string{"network", "create", "--subnet", "10.50.51.128/26", netName2})
|
||||||
|
network2.WaitWithDefaultTimeout()
|
||||||
|
Expect(network2.ExitCode()).To(BeZero())
|
||||||
|
defer podmanTest.removeCNINetwork(netName2)
|
||||||
|
|
||||||
|
ctrName := "testCtr"
|
||||||
|
container := podmanTest.Podman([]string{"run", "-dt", "--network", fmt.Sprintf("%s,%s", netName1, netName2), "--name", ctrName, ALPINE, "top"})
|
||||||
|
container.WaitWithDefaultTimeout()
|
||||||
|
Expect(container.ExitCode()).To(BeZero())
|
||||||
|
|
||||||
|
inspect := podmanTest.Podman([]string{"inspect", ctrName})
|
||||||
|
inspect.WaitWithDefaultTimeout()
|
||||||
|
Expect(inspect.ExitCode()).To(BeZero())
|
||||||
|
conData := inspect.InspectContainerToJSON()
|
||||||
|
Expect(len(conData)).To(Equal(1))
|
||||||
|
Expect(len(conData[0].NetworkSettings.Networks)).To(Equal(2))
|
||||||
|
net1, ok := conData[0].NetworkSettings.Networks[netName1]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(net1.NetworkID).To(Equal(netName1))
|
||||||
|
Expect(net1.IPPrefixLen).To(Equal(25))
|
||||||
|
Expect(strings.HasPrefix(net1.IPAddress, "10.50.51.")).To(BeTrue())
|
||||||
|
net2, ok := conData[0].NetworkSettings.Networks[netName2]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(net2.NetworkID).To(Equal(netName2))
|
||||||
|
Expect(net2.IPPrefixLen).To(Equal(26))
|
||||||
|
Expect(strings.HasPrefix(net2.IPAddress, "10.50.51.")).To(BeTrue())
|
||||||
|
|
||||||
|
// Necessary to ensure the CNI network is removed cleanly
|
||||||
|
rmAll := podmanTest.Podman([]string{"rm", "-f", ctrName})
|
||||||
|
rmAll.WaitWithDefaultTimeout()
|
||||||
|
Expect(rmAll.ExitCode()).To(BeZero())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user