github.1git.de/docker/cli@v26.1.3+incompatible/cli/command/container/opts_test.go (about) 1 package container 2 3 import ( 4 "fmt" 5 "io" 6 "os" 7 "runtime" 8 "strings" 9 "testing" 10 "time" 11 12 "github.com/docker/docker/api/types/container" 13 networktypes "github.com/docker/docker/api/types/network" 14 "github.com/docker/go-connections/nat" 15 "github.com/pkg/errors" 16 "github.com/spf13/pflag" 17 "gotest.tools/v3/assert" 18 is "gotest.tools/v3/assert/cmp" 19 "gotest.tools/v3/skip" 20 ) 21 22 func TestValidateAttach(t *testing.T) { 23 valid := []string{ 24 "stdin", 25 "stdout", 26 "stderr", 27 "STDIN", 28 "STDOUT", 29 "STDERR", 30 } 31 if _, err := validateAttach("invalid"); err == nil { 32 t.Fatal("Expected error with [valid streams are STDIN, STDOUT and STDERR], got nothing") 33 } 34 35 for _, attach := range valid { 36 value, err := validateAttach(attach) 37 if err != nil { 38 t.Fatal(err) 39 } 40 if value != strings.ToLower(attach) { 41 t.Fatalf("Expected [%v], got [%v]", attach, value) 42 } 43 } 44 } 45 46 func parseRun(args []string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig, error) { 47 flags, copts := setupRunFlags() 48 if err := flags.Parse(args); err != nil { 49 return nil, nil, nil, err 50 } 51 // TODO: fix tests to accept ContainerConfig 52 containerCfg, err := parse(flags, copts, runtime.GOOS) 53 if err != nil { 54 return nil, nil, nil, err 55 } 56 return containerCfg.Config, containerCfg.HostConfig, containerCfg.NetworkingConfig, err 57 } 58 59 func setupRunFlags() (*pflag.FlagSet, *containerOptions) { 60 flags := pflag.NewFlagSet("run", pflag.ContinueOnError) 61 flags.SetOutput(io.Discard) 62 flags.Usage = nil 63 copts := addFlags(flags) 64 return flags, copts 65 } 66 67 func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) { 68 t.Helper() 69 config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash")) 70 assert.NilError(t, err) 71 return config, hostConfig, nwConfig 72 } 73 74 func TestParseRunLinks(t *testing.T) { 75 if _, hostConfig, _ := mustParse(t, "--link a:b"); len(hostConfig.Links) == 0 || hostConfig.Links[0] != "a:b" { 76 t.Fatalf("Error parsing links. Expected []string{\"a:b\"}, received: %v", hostConfig.Links) 77 } 78 if _, hostConfig, _ := mustParse(t, "--link a:b --link c:d"); len(hostConfig.Links) < 2 || hostConfig.Links[0] != "a:b" || hostConfig.Links[1] != "c:d" { 79 t.Fatalf("Error parsing links. Expected []string{\"a:b\", \"c:d\"}, received: %v", hostConfig.Links) 80 } 81 if _, hostConfig, _ := mustParse(t, ""); len(hostConfig.Links) != 0 { 82 t.Fatalf("Error parsing links. No link expected, received: %v", hostConfig.Links) 83 } 84 } 85 86 func TestParseRunAttach(t *testing.T) { 87 tests := []struct { 88 input string 89 expected container.Config 90 }{ 91 { 92 input: "", 93 expected: container.Config{ 94 AttachStdout: true, 95 AttachStderr: true, 96 }, 97 }, 98 { 99 input: "-i", 100 expected: container.Config{ 101 AttachStdin: true, 102 AttachStdout: true, 103 AttachStderr: true, 104 }, 105 }, 106 { 107 input: "-a stdin", 108 expected: container.Config{ 109 AttachStdin: true, 110 }, 111 }, 112 { 113 input: "-a stdin -a stdout", 114 expected: container.Config{ 115 AttachStdin: true, 116 AttachStdout: true, 117 }, 118 }, 119 { 120 input: "-a stdin -a stdout -a stderr", 121 expected: container.Config{ 122 AttachStdin: true, 123 AttachStdout: true, 124 AttachStderr: true, 125 }, 126 }, 127 } 128 for _, tc := range tests { 129 tc := tc 130 t.Run(tc.input, func(t *testing.T) { 131 config, _, _ := mustParse(t, tc.input) 132 assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin) 133 assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout) 134 assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr) 135 }) 136 } 137 } 138 139 func TestParseRunWithInvalidArgs(t *testing.T) { 140 tests := []struct { 141 args []string 142 error string 143 }{ 144 { 145 args: []string{"-a", "ubuntu", "bash"}, 146 error: `invalid argument "ubuntu" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`, 147 }, 148 { 149 args: []string{"-a", "invalid", "ubuntu", "bash"}, 150 error: `invalid argument "invalid" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`, 151 }, 152 { 153 args: []string{"-a", "invalid", "-a", "stdout", "ubuntu", "bash"}, 154 error: `invalid argument "invalid" for "-a, --attach" flag: valid streams are STDIN, STDOUT and STDERR`, 155 }, 156 { 157 args: []string{"-a", "stdout", "-a", "stderr", "-z", "ubuntu", "bash"}, 158 error: `unknown shorthand flag: 'z' in -z`, 159 }, 160 { 161 args: []string{"-a", "stdin", "-z", "ubuntu", "bash"}, 162 error: `unknown shorthand flag: 'z' in -z`, 163 }, 164 { 165 args: []string{"-a", "stdout", "-z", "ubuntu", "bash"}, 166 error: `unknown shorthand flag: 'z' in -z`, 167 }, 168 { 169 args: []string{"-a", "stderr", "-z", "ubuntu", "bash"}, 170 error: `unknown shorthand flag: 'z' in -z`, 171 }, 172 { 173 args: []string{"-z", "--rm", "ubuntu", "bash"}, 174 error: `unknown shorthand flag: 'z' in -z`, 175 }, 176 } 177 flags, _ := setupRunFlags() 178 for _, tc := range tests { 179 t.Run(strings.Join(tc.args, " "), func(t *testing.T) { 180 assert.Error(t, flags.Parse(tc.args), tc.error) 181 }) 182 } 183 } 184 185 //nolint:gocyclo 186 func TestParseWithVolumes(t *testing.T) { 187 // A single volume 188 arr, tryit := setupPlatformVolume([]string{`/tmp`}, []string{`c:\tmp`}) 189 if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { 190 t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) 191 } else if _, exists := config.Volumes[arr[0]]; !exists { 192 t.Fatalf("Error parsing volume flags, %q is missing from volumes. Received %v", tryit, config.Volumes) 193 } 194 195 // Two volumes 196 arr, tryit = setupPlatformVolume([]string{`/tmp`, `/var`}, []string{`c:\tmp`, `c:\var`}) 197 if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds != nil { 198 t.Fatalf("Error parsing volume flags, %q should not mount-bind anything. Received %v", tryit, hostConfig.Binds) 199 } else if _, exists := config.Volumes[arr[0]]; !exists { 200 t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[0], config.Volumes) 201 } else if _, exists := config.Volumes[arr[1]]; !exists { //nolint:govet // ignore shadow-check 202 t.Fatalf("Error parsing volume flags, %s is missing from volumes. Received %v", arr[1], config.Volumes) 203 } 204 205 // A single bind mount 206 arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`}) 207 if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || hostConfig.Binds[0] != arr[0] { 208 t.Fatalf("Error parsing volume flags, %q should mount-bind the path before the colon into the path after the colon. Received %v %v", arr[0], hostConfig.Binds, config.Volumes) 209 } 210 211 // Two bind mounts. 212 arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/hostVar:/containerVar`}, []string{os.Getenv("ProgramData") + `:c:\ContainerPD`, os.Getenv("TEMP") + `:c:\containerTmp`}) 213 if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { 214 t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) 215 } 216 217 // Two bind mounts, first read-only, second read-write. 218 // TODO Windows: The Windows version uses read-write as that's the only mode it supports. Can change this post TP4 219 arr, tryit = setupPlatformVolume( 220 []string{`/hostTmp:/containerTmp:ro`, `/hostVar:/containerVar:rw`}, 221 []string{os.Getenv("TEMP") + `:c:\containerTmp:rw`, os.Getenv("ProgramData") + `:c:\ContainerPD:rw`}) 222 if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { 223 t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) 224 } 225 226 // Similar to previous test but with alternate modes which are only supported by Linux 227 if runtime.GOOS != "windows" { 228 arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:ro,Z`, `/hostVar:/containerVar:rw,Z`}, []string{}) 229 if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { 230 t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) 231 } 232 233 arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp:Z`, `/hostVar:/containerVar:z`}, []string{}) 234 if _, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || compareRandomizedStrings(hostConfig.Binds[0], hostConfig.Binds[1], arr[0], arr[1]) != nil { 235 t.Fatalf("Error parsing volume flags, `%s and %s` did not mount-bind correctly. Received %v", arr[0], arr[1], hostConfig.Binds) 236 } 237 } 238 239 // One bind mount and one volume 240 arr, tryit = setupPlatformVolume([]string{`/hostTmp:/containerTmp`, `/containerVar`}, []string{os.Getenv("TEMP") + `:c:\containerTmp`, `c:\containerTmp`}) 241 if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] { 242 t.Fatalf("Error parsing volume flags, %s and %s should only one and only one bind mount %s. Received %s", arr[0], arr[1], arr[0], hostConfig.Binds) 243 } else if _, exists := config.Volumes[arr[1]]; !exists { 244 t.Fatalf("Error parsing volume flags %s and %s. %s is missing from volumes. Received %v", arr[0], arr[1], arr[1], config.Volumes) 245 } 246 247 // Root to non-c: drive letter (Windows specific) 248 if runtime.GOOS == "windows" { 249 arr, tryit = setupPlatformVolume([]string{}, []string{os.Getenv("SystemDrive") + `\:d:`}) 250 if config, hostConfig, _ := mustParse(t, tryit); hostConfig.Binds == nil || len(hostConfig.Binds) > 1 || hostConfig.Binds[0] != arr[0] || len(config.Volumes) != 0 { 251 t.Fatalf("Error parsing %s. Should have a single bind mount and no volumes", arr[0]) 252 } 253 } 254 } 255 256 // setupPlatformVolume takes two arrays of volume specs - a Unix style 257 // spec and a Windows style spec. Depending on the platform being unit tested, 258 // it returns one of them, along with a volume string that would be passed 259 // on the docker CLI (e.g. -v /bar -v /foo). 260 func setupPlatformVolume(u []string, w []string) ([]string, string) { 261 var a []string 262 if runtime.GOOS == "windows" { 263 a = w 264 } else { 265 a = u 266 } 267 s := "" 268 for _, v := range a { 269 s = s + "-v " + v + " " 270 } 271 return a, s 272 } 273 274 // check if (a == c && b == d) || (a == d && b == c) 275 // because maps are randomized 276 func compareRandomizedStrings(a, b, c, d string) error { 277 if a == c && b == d { 278 return nil 279 } 280 if a == d && b == c { 281 return nil 282 } 283 return errors.Errorf("strings don't match") 284 } 285 286 // Simple parse with MacAddress validation 287 func TestParseWithMacAddress(t *testing.T) { 288 invalidMacAddress := "--mac-address=invalidMacAddress" 289 validMacAddress := "--mac-address=92:d0:c6:0a:29:33" 290 if _, _, _, err := parseRun([]string{invalidMacAddress, "img", "cmd"}); err != nil && err.Error() != "invalidMacAddress is not a valid mac address" { 291 t.Fatalf("Expected an error with %v mac-address, got %v", invalidMacAddress, err) 292 } 293 config, hostConfig, nwConfig := mustParse(t, validMacAddress) 294 if config.MacAddress != "92:d0:c6:0a:29:33" { //nolint:staticcheck // ignore SA1019: field is deprecated, but still used on API < v1.44. 295 t.Fatalf("Expected the config to have '92:d0:c6:0a:29:33' as container-wide MacAddress, got '%v'", 296 config.MacAddress) //nolint:staticcheck // ignore SA1019: field is deprecated, but still used on API < v1.44. 297 } 298 defaultNw := hostConfig.NetworkMode.NetworkName() 299 if nwConfig.EndpointsConfig[defaultNw].MacAddress != "92:d0:c6:0a:29:33" { 300 t.Fatalf("Expected the default endpoint to have the MacAddress '92:d0:c6:0a:29:33' set, got '%v'", nwConfig.EndpointsConfig[defaultNw].MacAddress) 301 } 302 } 303 304 func TestRunFlagsParseWithMemory(t *testing.T) { 305 flags, _ := setupRunFlags() 306 args := []string{"--memory=invalid", "img", "cmd"} 307 err := flags.Parse(args) 308 assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`) 309 310 _, hostconfig, _ := mustParse(t, "--memory=1G") 311 assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory)) 312 } 313 314 func TestParseWithMemorySwap(t *testing.T) { 315 flags, _ := setupRunFlags() 316 args := []string{"--memory-swap=invalid", "img", "cmd"} 317 err := flags.Parse(args) 318 assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`) 319 320 _, hostconfig, _ := mustParse(t, "--memory-swap=1G") 321 assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap)) 322 323 _, hostconfig, _ = mustParse(t, "--memory-swap=-1") 324 assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap)) 325 } 326 327 func TestParseHostname(t *testing.T) { 328 validHostnames := map[string]string{ 329 "hostname": "hostname", 330 "host-name": "host-name", 331 "hostname123": "hostname123", 332 "123hostname": "123hostname", 333 "hostname-of-63-bytes-long-should-be-valid-and-without-any-error": "hostname-of-63-bytes-long-should-be-valid-and-without-any-error", 334 } 335 hostnameWithDomain := "--hostname=hostname.domainname" 336 hostnameWithDomainTld := "--hostname=hostname.domainname.tld" 337 for hostname, expectedHostname := range validHostnames { 338 if config, _, _ := mustParse(t, fmt.Sprintf("--hostname=%s", hostname)); config.Hostname != expectedHostname { 339 t.Fatalf("Expected the config to have 'hostname' as %q, got %q", expectedHostname, config.Hostname) 340 } 341 } 342 if config, _, _ := mustParse(t, hostnameWithDomain); config.Hostname != "hostname.domainname" || config.Domainname != "" { 343 t.Fatalf("Expected the config to have 'hostname' as hostname.domainname, got %q", config.Hostname) 344 } 345 if config, _, _ := mustParse(t, hostnameWithDomainTld); config.Hostname != "hostname.domainname.tld" || config.Domainname != "" { 346 t.Fatalf("Expected the config to have 'hostname' as hostname.domainname.tld, got %q", config.Hostname) 347 } 348 } 349 350 func TestParseHostnameDomainname(t *testing.T) { 351 validDomainnames := map[string]string{ 352 "domainname": "domainname", 353 "domain-name": "domain-name", 354 "domainname123": "domainname123", 355 "123domainname": "123domainname", 356 "domainname-63-bytes-long-should-be-valid-and-without-any-errors": "domainname-63-bytes-long-should-be-valid-and-without-any-errors", 357 } 358 for domainname, expectedDomainname := range validDomainnames { 359 if config, _, _ := mustParse(t, "--domainname="+domainname); config.Domainname != expectedDomainname { 360 t.Fatalf("Expected the config to have 'domainname' as %q, got %q", expectedDomainname, config.Domainname) 361 } 362 } 363 if config, _, _ := mustParse(t, "--hostname=some.prefix --domainname=domainname"); config.Hostname != "some.prefix" || config.Domainname != "domainname" { 364 t.Fatalf("Expected the config to have 'hostname' as 'some.prefix' and 'domainname' as 'domainname', got %q and %q", config.Hostname, config.Domainname) 365 } 366 if config, _, _ := mustParse(t, "--hostname=another-prefix --domainname=domainname.tld"); config.Hostname != "another-prefix" || config.Domainname != "domainname.tld" { 367 t.Fatalf("Expected the config to have 'hostname' as 'another-prefix' and 'domainname' as 'domainname.tld', got %q and %q", config.Hostname, config.Domainname) 368 } 369 } 370 371 func TestParseWithExpose(t *testing.T) { 372 invalids := map[string]string{ 373 ":": "invalid port format for --expose: :", 374 "8080:9090": "invalid port format for --expose: 8080:9090", 375 "/tcp": "invalid range format for --expose: /tcp, error: empty string specified for ports", 376 "/udp": "invalid range format for --expose: /udp, error: empty string specified for ports", 377 "NaN/tcp": `invalid range format for --expose: NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, 378 "NaN-NaN/tcp": `invalid range format for --expose: NaN-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, 379 "8080-NaN/tcp": `invalid range format for --expose: 8080-NaN/tcp, error: strconv.ParseUint: parsing "NaN": invalid syntax`, 380 "1234567890-8080/tcp": `invalid range format for --expose: 1234567890-8080/tcp, error: strconv.ParseUint: parsing "1234567890": value out of range`, 381 } 382 valids := map[string][]nat.Port{ 383 "8080/tcp": {"8080/tcp"}, 384 "8080/udp": {"8080/udp"}, 385 "8080/ncp": {"8080/ncp"}, 386 "8080-8080/udp": {"8080/udp"}, 387 "8080-8082/tcp": {"8080/tcp", "8081/tcp", "8082/tcp"}, 388 } 389 for expose, expectedError := range invalids { 390 if _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}); err == nil || err.Error() != expectedError { 391 t.Fatalf("Expected error '%v' with '--expose=%v', got '%v'", expectedError, expose, err) 392 } 393 } 394 for expose, exposedPorts := range valids { 395 config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}) 396 if err != nil { 397 t.Fatal(err) 398 } 399 if len(config.ExposedPorts) != len(exposedPorts) { 400 t.Fatalf("Expected %v exposed port, got %v", len(exposedPorts), len(config.ExposedPorts)) 401 } 402 for _, port := range exposedPorts { 403 if _, ok := config.ExposedPorts[port]; !ok { 404 t.Fatalf("Expected %v, got %v", exposedPorts, config.ExposedPorts) 405 } 406 } 407 } 408 // Merge with actual published port 409 config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"}) 410 if err != nil { 411 t.Fatal(err) 412 } 413 if len(config.ExposedPorts) != 2 { 414 t.Fatalf("Expected 2 exposed ports, got %v", config.ExposedPorts) 415 } 416 ports := []nat.Port{"80/tcp", "81/tcp"} 417 for _, port := range ports { 418 if _, ok := config.ExposedPorts[port]; !ok { 419 t.Fatalf("Expected %v, got %v", ports, config.ExposedPorts) 420 } 421 } 422 } 423 424 func TestParseDevice(t *testing.T) { 425 skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side 426 testCases := []struct { 427 devices []string 428 deviceMapping *container.DeviceMapping 429 deviceRequests []container.DeviceRequest 430 }{ 431 { 432 devices: []string{"/dev/snd"}, 433 deviceMapping: &container.DeviceMapping{ 434 PathOnHost: "/dev/snd", 435 PathInContainer: "/dev/snd", 436 CgroupPermissions: "rwm", 437 }, 438 }, 439 { 440 devices: []string{"/dev/snd:rw"}, 441 deviceMapping: &container.DeviceMapping{ 442 PathOnHost: "/dev/snd", 443 PathInContainer: "/dev/snd", 444 CgroupPermissions: "rw", 445 }, 446 }, 447 { 448 devices: []string{"/dev/snd:/something"}, 449 deviceMapping: &container.DeviceMapping{ 450 PathOnHost: "/dev/snd", 451 PathInContainer: "/something", 452 CgroupPermissions: "rwm", 453 }, 454 }, 455 { 456 devices: []string{"/dev/snd:/something:rw"}, 457 deviceMapping: &container.DeviceMapping{ 458 PathOnHost: "/dev/snd", 459 PathInContainer: "/something", 460 CgroupPermissions: "rw", 461 }, 462 }, 463 { 464 devices: []string{"vendor.com/class=name"}, 465 deviceMapping: nil, 466 deviceRequests: []container.DeviceRequest{ 467 { 468 Driver: "cdi", 469 DeviceIDs: []string{"vendor.com/class=name"}, 470 }, 471 }, 472 }, 473 { 474 devices: []string{"vendor.com/class=name", "/dev/snd:/something:rw"}, 475 deviceMapping: &container.DeviceMapping{ 476 PathOnHost: "/dev/snd", 477 PathInContainer: "/something", 478 CgroupPermissions: "rw", 479 }, 480 deviceRequests: []container.DeviceRequest{ 481 { 482 Driver: "cdi", 483 DeviceIDs: []string{"vendor.com/class=name"}, 484 }, 485 }, 486 }, 487 } 488 489 for _, tc := range testCases { 490 t.Run(fmt.Sprintf("%s", tc.devices), func(t *testing.T) { 491 var args []string 492 for _, d := range tc.devices { 493 args = append(args, fmt.Sprintf("--device=%v", d)) 494 } 495 args = append(args, "img", "cmd") 496 497 _, hostconfig, _, err := parseRun(args) 498 499 assert.NilError(t, err) 500 501 if tc.deviceMapping != nil { 502 if assert.Check(t, is.Len(hostconfig.Devices, 1)) { 503 assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0])) 504 } 505 } else { 506 assert.Check(t, is.Len(hostconfig.Devices, 0)) 507 } 508 509 assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests)) 510 }) 511 } 512 } 513 514 func TestParseNetworkConfig(t *testing.T) { 515 tests := []struct { 516 name string 517 flags []string 518 expected map[string]*networktypes.EndpointSettings 519 expectedCfg container.Config 520 expectedHostCfg container.HostConfig 521 expectedErr string 522 }{ 523 { 524 name: "single-network-legacy", 525 flags: []string{"--network", "net1"}, 526 expected: map[string]*networktypes.EndpointSettings{}, 527 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 528 }, 529 { 530 name: "single-network-advanced", 531 flags: []string{"--network", "name=net1"}, 532 expected: map[string]*networktypes.EndpointSettings{}, 533 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 534 }, 535 { 536 name: "single-network-legacy-with-options", 537 flags: []string{ 538 "--ip", "172.20.88.22", 539 "--ip6", "2001:db8::8822", 540 "--link", "foo:bar", 541 "--link", "bar:baz", 542 "--link-local-ip", "169.254.2.2", 543 "--link-local-ip", "fe80::169:254:2:2", 544 "--network", "name=net1", 545 "--network-alias", "web1", 546 "--network-alias", "web2", 547 }, 548 expected: map[string]*networktypes.EndpointSettings{ 549 "net1": { 550 IPAMConfig: &networktypes.EndpointIPAMConfig{ 551 IPv4Address: "172.20.88.22", 552 IPv6Address: "2001:db8::8822", 553 LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"}, 554 }, 555 Links: []string{"foo:bar", "bar:baz"}, 556 Aliases: []string{"web1", "web2"}, 557 }, 558 }, 559 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 560 }, 561 { 562 name: "multiple-network-advanced-mixed", 563 flags: []string{ 564 "--ip", "172.20.88.22", 565 "--ip6", "2001:db8::8822", 566 "--link", "foo:bar", 567 "--link", "bar:baz", 568 "--link-local-ip", "169.254.2.2", 569 "--link-local-ip", "fe80::169:254:2:2", 570 "--network", "name=net1,driver-opt=field1=value1", 571 "--network-alias", "web1", 572 "--network-alias", "web2", 573 "--network", "net2", 574 "--network", "name=net3,alias=web3,driver-opt=field3=value3,ip=172.20.88.22,ip6=2001:db8::8822", 575 "--network", "name=net4,mac-address=02:32:1c:23:00:04,link-local-ip=169.254.169.254", 576 }, 577 expected: map[string]*networktypes.EndpointSettings{ 578 "net1": { 579 DriverOpts: map[string]string{"field1": "value1"}, 580 IPAMConfig: &networktypes.EndpointIPAMConfig{ 581 IPv4Address: "172.20.88.22", 582 IPv6Address: "2001:db8::8822", 583 LinkLocalIPs: []string{"169.254.2.2", "fe80::169:254:2:2"}, 584 }, 585 Links: []string{"foo:bar", "bar:baz"}, 586 Aliases: []string{"web1", "web2"}, 587 }, 588 "net2": {}, 589 "net3": { 590 DriverOpts: map[string]string{"field3": "value3"}, 591 IPAMConfig: &networktypes.EndpointIPAMConfig{ 592 IPv4Address: "172.20.88.22", 593 IPv6Address: "2001:db8::8822", 594 }, 595 Aliases: []string{"web3"}, 596 }, 597 "net4": { 598 MacAddress: "02:32:1c:23:00:04", 599 IPAMConfig: &networktypes.EndpointIPAMConfig{ 600 LinkLocalIPs: []string{"169.254.169.254"}, 601 }, 602 }, 603 }, 604 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 605 }, 606 { 607 name: "single-network-advanced-with-options", 608 flags: []string{"--network", "name=net1,alias=web1,alias=web2,driver-opt=field1=value1,driver-opt=field2=value2,ip=172.20.88.22,ip6=2001:db8::8822,mac-address=02:32:1c:23:00:04"}, 609 expected: map[string]*networktypes.EndpointSettings{ 610 "net1": { 611 DriverOpts: map[string]string{ 612 "field1": "value1", 613 "field2": "value2", 614 }, 615 IPAMConfig: &networktypes.EndpointIPAMConfig{ 616 IPv4Address: "172.20.88.22", 617 IPv6Address: "2001:db8::8822", 618 }, 619 Aliases: []string{"web1", "web2"}, 620 MacAddress: "02:32:1c:23:00:04", 621 }, 622 }, 623 expectedCfg: container.Config{MacAddress: "02:32:1c:23:00:04"}, 624 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 625 }, 626 { 627 name: "multiple-networks", 628 flags: []string{"--network", "net1", "--network", "name=net2"}, 629 expected: map[string]*networktypes.EndpointSettings{"net1": {}, "net2": {}}, 630 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 631 }, 632 { 633 name: "advanced-options-with-standalone-mac-address-flag", 634 flags: []string{"--network=name=net1,alias=foobar", "--mac-address", "52:0f:f3:dc:50:10"}, 635 expected: map[string]*networktypes.EndpointSettings{ 636 "net1": { 637 Aliases: []string{"foobar"}, 638 MacAddress: "52:0f:f3:dc:50:10", 639 }, 640 }, 641 expectedCfg: container.Config{MacAddress: "52:0f:f3:dc:50:10"}, 642 expectedHostCfg: container.HostConfig{NetworkMode: "net1"}, 643 }, 644 { 645 name: "conflict-network", 646 flags: []string{"--network", "duplicate", "--network", "name=duplicate"}, 647 expectedErr: `network "duplicate" is specified multiple times`, 648 }, 649 { 650 name: "conflict-options-alias", 651 flags: []string{"--network", "name=net1,alias=web1", "--network-alias", "web1"}, 652 expectedErr: `conflicting options: cannot specify both --network-alias and per-network alias`, 653 }, 654 { 655 name: "conflict-options-ip", 656 flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip", "172.20.88.22"}, 657 expectedErr: `conflicting options: cannot specify both --ip and per-network IPv4 address`, 658 }, 659 { 660 name: "conflict-options-ip6", 661 flags: []string{"--network", "name=net1,ip=172.20.88.22,ip6=2001:db8::8822", "--ip6", "2001:db8::8822"}, 662 expectedErr: `conflicting options: cannot specify both --ip6 and per-network IPv6 address`, 663 }, 664 { 665 name: "invalid-mixed-network-types", 666 flags: []string{"--network", "name=host", "--network", "net1"}, 667 expectedErr: `conflicting options: cannot attach both user-defined and non-user-defined network-modes`, 668 }, 669 { 670 name: "conflict-options-link-local-ip", 671 flags: []string{"--network", "name=net1,link-local-ip=169.254.169.254", "--link-local-ip", "169.254.10.8"}, 672 expectedErr: `conflicting options: cannot specify both --link-local-ip and per-network link-local IP addresses`, 673 }, 674 { 675 name: "conflict-options-mac-address", 676 flags: []string{"--network", "name=net1,mac-address=02:32:1c:23:00:04", "--mac-address", "02:32:1c:23:00:04"}, 677 expectedErr: `conflicting options: cannot specify both --mac-address and per-network MAC address`, 678 }, 679 { 680 name: "invalid-mac-address", 681 flags: []string{"--network", "name=net1,mac-address=foobar"}, 682 expectedErr: "foobar is not a valid mac address", 683 }, 684 } 685 686 for _, tc := range tests { 687 t.Run(tc.name, func(t *testing.T) { 688 config, hConfig, nwConfig, err := parseRun(tc.flags) 689 690 if tc.expectedErr != "" { 691 assert.Error(t, err, tc.expectedErr) 692 return 693 } 694 695 assert.NilError(t, err) 696 assert.DeepEqual(t, config.MacAddress, tc.expectedCfg.MacAddress) //nolint:staticcheck // ignore SA1019: field is deprecated, but still used on API < v1.44. 697 assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode) 698 assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected) 699 }) 700 } 701 } 702 703 func TestParseModes(t *testing.T) { 704 // pid ko 705 flags, copts := setupRunFlags() 706 args := []string{"--pid=container:", "img", "cmd"} 707 assert.NilError(t, flags.Parse(args)) 708 _, err := parse(flags, copts, runtime.GOOS) 709 assert.ErrorContains(t, err, "--pid: invalid PID mode") 710 711 // pid ok 712 _, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"}) 713 assert.NilError(t, err) 714 if !hostconfig.PidMode.Valid() { 715 t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode) 716 } 717 718 // uts ko 719 _, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled 720 assert.ErrorContains(t, err, "--uts: invalid UTS mode") 721 722 // uts ok 723 _, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"}) 724 assert.NilError(t, err) 725 if !hostconfig.UTSMode.Valid() { 726 t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode) 727 } 728 } 729 730 func TestRunFlagsParseShmSize(t *testing.T) { 731 // shm-size ko 732 flags, _ := setupRunFlags() 733 args := []string{"--shm-size=a128m", "img", "cmd"} 734 expectedErr := `invalid argument "a128m" for "--shm-size" flag:` 735 err := flags.Parse(args) 736 assert.ErrorContains(t, err, expectedErr) 737 738 // shm-size ok 739 _, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"}) 740 assert.NilError(t, err) 741 if hostconfig.ShmSize != 134217728 { 742 t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize) 743 } 744 } 745 746 func TestParseRestartPolicy(t *testing.T) { 747 tests := []struct { 748 input string 749 expected container.RestartPolicy 750 expectedErr string 751 }{ 752 { 753 input: "", 754 }, 755 { 756 input: "no", 757 expected: container.RestartPolicy{ 758 Name: container.RestartPolicyDisabled, 759 }, 760 }, 761 { 762 input: ":1", 763 expectedErr: "invalid restart policy format: no policy provided before colon", 764 }, 765 { 766 input: "always", 767 expected: container.RestartPolicy{ 768 Name: container.RestartPolicyAlways, 769 }, 770 }, 771 { 772 input: "always:1", 773 expected: container.RestartPolicy{ 774 Name: container.RestartPolicyAlways, 775 MaximumRetryCount: 1, 776 }, 777 }, 778 { 779 input: "always:2:3", 780 expectedErr: "invalid restart policy format: maximum retry count must be an integer", 781 }, 782 { 783 input: "on-failure:1", 784 expected: container.RestartPolicy{ 785 Name: container.RestartPolicyOnFailure, 786 MaximumRetryCount: 1, 787 }, 788 }, 789 { 790 input: "on-failure:invalid", 791 expectedErr: "invalid restart policy format: maximum retry count must be an integer", 792 }, 793 { 794 input: "unless-stopped", 795 expected: container.RestartPolicy{ 796 Name: container.RestartPolicyUnlessStopped, 797 }, 798 }, 799 { 800 input: "unless-stopped:invalid", 801 expectedErr: "invalid restart policy format: maximum retry count must be an integer", 802 }, 803 } 804 for _, tc := range tests { 805 tc := tc 806 t.Run(tc.input, func(t *testing.T) { 807 _, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"}) 808 if tc.expectedErr != "" { 809 assert.Check(t, is.Error(err, tc.expectedErr)) 810 assert.Check(t, is.Nil(hostConfig)) 811 } else { 812 assert.NilError(t, err) 813 assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected)) 814 } 815 }) 816 } 817 } 818 819 func TestParseRestartPolicyAutoRemove(t *testing.T) { 820 expected := "Conflicting options: --restart and --rm" 821 _, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled 822 if err == nil || err.Error() != expected { 823 t.Fatalf("Expected error %v, but got none", expected) 824 } 825 } 826 827 func TestParseHealth(t *testing.T) { 828 checkOk := func(args ...string) *container.HealthConfig { 829 config, _, _, err := parseRun(args) 830 if err != nil { 831 t.Fatalf("%#v: %v", args, err) 832 } 833 return config.Healthcheck 834 } 835 checkError := func(expected string, args ...string) { 836 config, _, _, err := parseRun(args) 837 if err == nil { 838 t.Fatalf("Expected error, but got %#v", config) 839 } 840 if err.Error() != expected { 841 t.Fatalf("Expected %#v, got %#v", expected, err) 842 } 843 } 844 health := checkOk("--no-healthcheck", "img", "cmd") 845 if health == nil || len(health.Test) != 1 || health.Test[0] != "NONE" { 846 t.Fatalf("--no-healthcheck failed: %#v", health) 847 } 848 849 health = checkOk("--health-cmd=/check.sh -q", "img", "cmd") 850 if len(health.Test) != 2 || health.Test[0] != "CMD-SHELL" || health.Test[1] != "/check.sh -q" { 851 t.Fatalf("--health-cmd: got %#v", health.Test) 852 } 853 if health.Timeout != 0 { 854 t.Fatalf("--health-cmd: timeout = %s", health.Timeout) 855 } 856 857 checkError("--no-healthcheck conflicts with --health-* options", 858 "--no-healthcheck", "--health-cmd=/check.sh -q", "img", "cmd") 859 860 health = checkOk("--health-timeout=2s", "--health-retries=3", "--health-interval=4.5s", "--health-start-period=5s", "--health-start-interval=1s", "img", "cmd") 861 if health.Timeout != 2*time.Second || health.Retries != 3 || health.Interval != 4500*time.Millisecond || health.StartPeriod != 5*time.Second || health.StartInterval != 1*time.Second { 862 t.Fatalf("--health-*: got %#v", health) 863 } 864 } 865 866 func TestParseLoggingOpts(t *testing.T) { 867 // logging opts ko 868 if _, _, _, err := parseRun([]string{"--log-driver=none", "--log-opt=anything", "img", "cmd"}); err == nil || err.Error() != "invalid logging opts for driver none" { 869 t.Fatalf("Expected an error with message 'invalid logging opts for driver none', got %v", err) 870 } 871 // logging opts ok 872 _, hostconfig, _, err := parseRun([]string{"--log-driver=syslog", "--log-opt=something", "img", "cmd"}) 873 if err != nil { 874 t.Fatal(err) 875 } 876 if hostconfig.LogConfig.Type != "syslog" || len(hostconfig.LogConfig.Config) != 1 { 877 t.Fatalf("Expected a 'syslog' LogConfig with one config, got %v", hostconfig.RestartPolicy) 878 } 879 } 880 881 func TestParseEnvfileVariables(t *testing.T) { 882 e := "open nonexistent: no such file or directory" 883 if runtime.GOOS == "windows" { 884 e = "open nonexistent: The system cannot find the file specified." 885 } 886 // env ko 887 if _, _, _, err := parseRun([]string{"--env-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e { 888 t.Fatalf("Expected an error with message '%s', got %v", e, err) 889 } 890 // env ok 891 config, _, _, err := parseRun([]string{"--env-file=testdata/valid.env", "img", "cmd"}) 892 if err != nil { 893 t.Fatal(err) 894 } 895 if len(config.Env) != 1 || config.Env[0] != "ENV1=value1" { 896 t.Fatalf("Expected a config with [ENV1=value1], got %v", config.Env) 897 } 898 config, _, _, err = parseRun([]string{"--env-file=testdata/valid.env", "--env=ENV2=value2", "img", "cmd"}) 899 if err != nil { 900 t.Fatal(err) 901 } 902 if len(config.Env) != 2 || config.Env[0] != "ENV1=value1" || config.Env[1] != "ENV2=value2" { 903 t.Fatalf("Expected a config with [ENV1=value1 ENV2=value2], got %v", config.Env) 904 } 905 } 906 907 func TestParseEnvfileVariablesWithBOMUnicode(t *testing.T) { 908 // UTF8 with BOM 909 config, _, _, err := parseRun([]string{"--env-file=testdata/utf8.env", "img", "cmd"}) 910 if err != nil { 911 t.Fatal(err) 912 } 913 env := []string{"FOO=BAR", "HELLO=" + string([]byte{0xe6, 0x82, 0xa8, 0xe5, 0xa5, 0xbd}), "BAR=FOO"} 914 if len(config.Env) != len(env) { 915 t.Fatalf("Expected a config with %d env variables, got %v: %v", len(env), len(config.Env), config.Env) 916 } 917 for i, v := range env { 918 if config.Env[i] != v { 919 t.Fatalf("Expected a config with [%s], got %v", v, []byte(config.Env[i])) 920 } 921 } 922 923 // UTF16 with BOM 924 e := "contains invalid utf8 bytes at line" 925 if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { 926 t.Fatalf("Expected an error with message '%s', got %v", e, err) 927 } 928 // UTF16BE with BOM 929 if _, _, _, err := parseRun([]string{"--env-file=testdata/utf16be.env", "img", "cmd"}); err == nil || !strings.Contains(err.Error(), e) { 930 t.Fatalf("Expected an error with message '%s', got %v", e, err) 931 } 932 } 933 934 func TestParseLabelfileVariables(t *testing.T) { 935 e := "open nonexistent: no such file or directory" 936 if runtime.GOOS == "windows" { 937 e = "open nonexistent: The system cannot find the file specified." 938 } 939 // label ko 940 if _, _, _, err := parseRun([]string{"--label-file=nonexistent", "img", "cmd"}); err == nil || err.Error() != e { 941 t.Fatalf("Expected an error with message '%s', got %v", e, err) 942 } 943 // label ok 944 config, _, _, err := parseRun([]string{"--label-file=testdata/valid.label", "img", "cmd"}) 945 if err != nil { 946 t.Fatal(err) 947 } 948 if len(config.Labels) != 1 || config.Labels["LABEL1"] != "value1" { 949 t.Fatalf("Expected a config with [LABEL1:value1], got %v", config.Labels) 950 } 951 config, _, _, err = parseRun([]string{"--label-file=testdata/valid.label", "--label=LABEL2=value2", "img", "cmd"}) 952 if err != nil { 953 t.Fatal(err) 954 } 955 if len(config.Labels) != 2 || config.Labels["LABEL1"] != "value1" || config.Labels["LABEL2"] != "value2" { 956 t.Fatalf("Expected a config with [LABEL1:value1 LABEL2:value2], got %v", config.Labels) 957 } 958 } 959 960 func TestParseEntryPoint(t *testing.T) { 961 config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"}) 962 if err != nil { 963 t.Fatal(err) 964 } 965 if len(config.Entrypoint) != 1 && config.Entrypoint[0] != "anything" { 966 t.Fatalf("Expected entrypoint 'anything', got %v", config.Entrypoint) 967 } 968 } 969 970 func TestValidateDevice(t *testing.T) { 971 skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side 972 valid := []string{ 973 "/home", 974 "/home:/home", 975 "/home:/something/else", 976 "/with space", 977 "/home:/with space", 978 "relative:/absolute-path", 979 "hostPath:/containerPath:r", 980 "/hostPath:/containerPath:rw", 981 "/hostPath:/containerPath:mrw", 982 } 983 invalid := map[string]string{ 984 "": "bad format for path: ", 985 "./": "./ is not an absolute path", 986 "../": "../ is not an absolute path", 987 "/:../": "../ is not an absolute path", 988 "/:path": "path is not an absolute path", 989 ":": "bad format for path: :", 990 "/tmp:": " is not an absolute path", 991 ":test": "bad format for path: :test", 992 ":/test": "bad format for path: :/test", 993 "tmp:": " is not an absolute path", 994 ":test:": "bad format for path: :test:", 995 "::": "bad format for path: ::", 996 ":::": "bad format for path: :::", 997 "/tmp:::": "bad format for path: /tmp:::", 998 ":/tmp::": "bad format for path: :/tmp::", 999 "path:ro": "ro is not an absolute path", 1000 "path:rr": "rr is not an absolute path", 1001 "a:/b:ro": "bad mode specified: ro", 1002 "a:/b:rr": "bad mode specified: rr", 1003 } 1004 1005 for _, path := range valid { 1006 if _, err := validateDevice(path, runtime.GOOS); err != nil { 1007 t.Fatalf("ValidateDevice(`%q`) should succeed: error %q", path, err) 1008 } 1009 } 1010 1011 for path, expectedError := range invalid { 1012 if _, err := validateDevice(path, runtime.GOOS); err == nil { 1013 t.Fatalf("ValidateDevice(`%q`) should have failed validation", path) 1014 } else if err.Error() != expectedError { 1015 t.Fatalf("ValidateDevice(`%q`) error should contain %q, got %q", path, expectedError, err.Error()) 1016 } 1017 } 1018 } 1019 1020 func TestParseSystemPaths(t *testing.T) { 1021 tests := []struct { 1022 doc string 1023 in, out, masked, readonly []string 1024 }{ 1025 { 1026 doc: "not set", 1027 in: []string{}, 1028 out: []string{}, 1029 }, 1030 { 1031 doc: "not set, preserve other options", 1032 in: []string{ 1033 "seccomp=unconfined", 1034 "apparmor=unconfined", 1035 "label=user:USER", 1036 "foo=bar", 1037 }, 1038 out: []string{ 1039 "seccomp=unconfined", 1040 "apparmor=unconfined", 1041 "label=user:USER", 1042 "foo=bar", 1043 }, 1044 }, 1045 { 1046 doc: "unconfined", 1047 in: []string{"systempaths=unconfined"}, 1048 out: []string{}, 1049 masked: []string{}, 1050 readonly: []string{}, 1051 }, 1052 { 1053 doc: "unconfined and other options", 1054 in: []string{"foo=bar", "bar=baz", "systempaths=unconfined"}, 1055 out: []string{"foo=bar", "bar=baz"}, 1056 masked: []string{}, 1057 readonly: []string{}, 1058 }, 1059 { 1060 doc: "unknown option", 1061 in: []string{"foo=bar", "systempaths=unknown", "bar=baz"}, 1062 out: []string{"foo=bar", "systempaths=unknown", "bar=baz"}, 1063 }, 1064 } 1065 1066 for _, tc := range tests { 1067 securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in) 1068 assert.DeepEqual(t, securityOpts, tc.out) 1069 assert.DeepEqual(t, maskedPaths, tc.masked) 1070 assert.DeepEqual(t, readonlyPaths, tc.readonly) 1071 } 1072 } 1073 1074 func TestConvertToStandardNotation(t *testing.T) { 1075 valid := map[string][]string{ 1076 "20:10/tcp": {"target=10,published=20"}, 1077 "40:30": {"40:30"}, 1078 "20:20 80:4444": {"20:20", "80:4444"}, 1079 "1500:2500/tcp 1400:1300": {"target=2500,published=1500", "1400:1300"}, 1080 "1500:200/tcp 90:80/tcp": {"published=1500,target=200", "target=80,published=90"}, 1081 } 1082 1083 invalid := [][]string{ 1084 {"published=1500,target:444"}, 1085 {"published=1500,444"}, 1086 {"published=1500,target,444"}, 1087 } 1088 1089 for key, ports := range valid { 1090 convertedPorts, err := convertToStandardNotation(ports) 1091 if err != nil { 1092 assert.NilError(t, err) 1093 } 1094 assert.DeepEqual(t, strings.Split(key, " "), convertedPorts) 1095 } 1096 1097 for _, ports := range invalid { 1098 if _, err := convertToStandardNotation(ports); err == nil { 1099 t.Fatalf("ConvertToStandardNotation(`%q`) should have failed conversion", ports) 1100 } 1101 } 1102 }