Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Docker mode #63

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ _testmain.go
# tools and virtualenv
.idea
env/
.vscode/

# test coverage reports
overalls.coverprofile
Expand Down
79 changes: 66 additions & 13 deletions cmd/tcpgoon.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/dachad/tcpgoon/debugging"
"github.com/dachad/tcpgoon/mtcpclient"
"github.com/dachad/tcpgoon/tcpclient"
"github.com/dachad/tcpgoon/docker"

"github.com/spf13/cobra"
)

Expand All @@ -24,6 +26,8 @@ type tcpgoonParams struct {
debug bool
reportingInterval int
assumeyes bool
docker bool
containerID string
}

var params tcpgoonParams
Expand All @@ -33,12 +37,12 @@ var runCmd = &cobra.Command{
Short: "Run tcpgoon test",
Long: ``,
PreRun: func(cmd *cobra.Command, args []string) {
enableDebuggingIfFlagSet(params)
if err := validateRequiredArgs(&params, args); err != nil {
cmd.Println(err)
cmd.Println(cmd.UsageString())
os.Exit(1)
}
enableDebuggingIfFlagSet(params)
autorunValidation(params)
},
Run: func(cmd *cobra.Command, args []string) {
Expand All @@ -53,26 +57,65 @@ func init() {
runCmd.Flags().BoolVarP(&params.debug, "debug", "d", false, "Print debugging information to the standard error")
runCmd.Flags().IntVarP(&params.reportingInterval, "interval", "i", 1, "Interval, in seconds, between stats updates")
runCmd.Flags().BoolVarP(&params.assumeyes, "assume-yes", "y", false, "Force execution without asking for confirmation")
runCmd.Flags().BoolVarP(&params.docker, "docker", "", false, "Running against a docker image. " +
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-using things like the "host" to inject the image name is a bit hacky. But that is a minor thing

"Use the image reference instead of the <host>, and a custom mapped port if the first exposed one is not ok (optional")
}

func validateRequiredArgs(params *tcpgoonParams, args []string) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we must redo how we are validating parms. Relying on a max/min num of required params looks ugly: we should just check which parm is required and which are not in each execution mode. If execution modes parameters differ a lot in the end, maybe we should recover your idea of using different commands

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the execution mode is the first question to answer

if len(args) != 2 {
return errors.New("Number of required parameters doesn't match")
var minNumberRequiredArgs int
var maxNumberRequiredArgs int

if params.docker {
minNumberRequiredArgs = 1
maxNumberRequiredArgs = 2
} else {
minNumberRequiredArgs = 2
maxNumberRequiredArgs = 2
}
params.target = args[0]
addrs, err := net.LookupIP(params.target)
if err != nil || len(addrs) == 0 {
return errors.New("Domain name not resolvable")

if len(args) < minNumberRequiredArgs || len(args) > maxNumberRequiredArgs{
return errors.New("Number of required parameters doesn't match")
}

params.targetip = addrs[0].String()
fmt.Fprintln(debugging.DebugOut, "TCPGOON target: Hostname(", params.target, "), IP (", params.targetip, ")")
if params.docker {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this massive if/else is... :) . That smells to two different validation functions depending on the "mode", but again, we should rethink the parms in general

dockerImage := args[0]

fmt.Println("Starting Docker test for", dockerImage)

var port int
if len(args) == 2 {

portInt, err := strconv.Atoi(args[1])
port = portInt

port, err := strconv.Atoi(args[1])
if err != nil && port <= 0 {
return errors.New("Port argument is not a valid integer")
if err != nil && port <= 0 {
return errors.New("Port argument is not a valid integer")
}
} else {
port = 0
}

target, containerID := docker.DownloadAndRun(dockerImage, port)
params.containerID = containerID
params.target = target.IP
params.port = target.Port

} else {
params.target = args[0]
addrs, err := net.LookupIP(params.target)
if err != nil || len(addrs) == 0 {
return errors.New("Domain name not resolvable")
}

params.targetip = addrs[0].String()
fmt.Fprintln(debugging.DebugOut, "TCPGOON target: Hostname(", params.target, "), IP (", params.targetip, ")")

port, err := strconv.Atoi(args[1])
if err != nil && port <= 0 {
return errors.New("Port argument is not a valid integer")
}
params.port = port
}
params.port = port

return nil
}
Expand All @@ -86,18 +129,28 @@ func enableDebuggingIfFlagSet(params tcpgoonParams) {
func autorunValidation(params tcpgoonParams) {
if !(params.assumeyes || cmdutil.AskForUserConfirmation(params.target, params.port, params.numberConnections)) {
fmt.Fprintln(debugging.DebugOut, "Execution not approved by the user")
if params.docker{
docker.Stop(params.containerID)
}
cmdutil.CloseAbruptly()
}
}

func run(params tcpgoonParams) {

tcpclient.DefaultDialTimeoutInMs = params.connDialTimeout

// TODO: we should decouple the caller from the mtcpclient package (too many structures being moved from
// one side to the other.. everything in a single structure, or applying something like the builder pattern,
// may help
connStatusCh, connStatusTracker := mtcpclient.StartBackgroundReporting(params.numberConnections, params.reportingInterval)
closureCh := mtcpclient.StartBackgroundClosureTrigger(*connStatusTracker)
if params.docker{
go func() {
<-closureCh
docker.Stop(params.containerID)
}()
}
mtcpclient.MultiTCPConnect(params.numberConnections, params.delay, params.target, params.port, connStatusCh, closureCh)
fmt.Fprintln(debugging.DebugOut, "Tests execution completed")

Expand Down
163 changes: 163 additions & 0 deletions docker/docker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package docker

import (
"context"
"fmt"
"io"
"math/rand"
"net"

//"os"
"strconv"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"

"github.com/dachad/tcpgoon/debugging"
)

type IPandPort struct {
IP string
Port int
}

func lookForAvailableLocalPort() (availablePort int){
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

up to you, but i would try the same the docker is exposing, and if that fails, require the user to be the one that chooses one

minPort := 1025
maxPort := 65535

for {
availablePort = rand.Intn(maxPort - minPort) + minPort
ln, err := net.Listen("tcp", ":" + strconv.Itoa(availablePort))
ln.Close()
if err == nil {
break
}
}
return availablePort
}

func DownloadAndRun(image string, port int) (targetinfo IPandPort, containerID string) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess this requires refactor / better encapsulation... long and with too much statements inside if/elses. I would also not panic in this function, but return an error and make the caller to manage the errors accordingly

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, do not take this PR as ready to review. The point here is, having a first working version, understand if this docker mode should be part of the regular command or have a custom one.

ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
panic(err)
}

fmt.Fprintln(debugging.DebugOut,"Pulling Docker image", image)

out, err := cli.ImagePull(ctx, image, types.ImagePullOptions{})
if err != nil {
panic(err)
}
io.Copy(debugging.DebugOut, out)

defer out.Close()

fmt.Fprintln(debugging.DebugOut, "Docker image", image, "downloaded")

mappedPort := ""
mapperProto := ""
if port == 0 {
// if not specific mapping defined we look for exposed ports
// getting the exposed port before decide the mapping

// TODO support mapping volumes, some containers required them
respCreate, err := cli.ContainerCreate(ctx, &container.Config{
Image: image,
}, nil, nil, "")
if err != nil {
panic(err)
}

if err := cli.ContainerStart(ctx, respCreate.ID, types.ContainerStartOptions{}); err != nil {
panic(err)
}

respInspect := inspect(respCreate.ID)
natPorts := respInspect.NetworkSettings.NetworkSettingsBase.Ports

for k, _ := range natPorts {
mappedPort = k.Port()
mapperProto = k.Proto()
break
}
// Stopping container used to get the port mapping
Stop(respCreate.ID)
} else {
mappedPort = strconv.Itoa(port)
mapperProto = "tcp"
}

if mappedPort == "" || mapperProto != "tcp" {
panic("Not found any TCP port mapping")
} else {
fmt.Println("Internal Docker port binding:", mapperProto, mappedPort)
}

// hack https://stackoverflow.com/questions/47395973/issue-while-using-docker-api-for-go-cannot-import-nat
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to nat / expose the port in the host 0.0.0.0? Maybe we can target the container IP if an internal subnet/bridge network is being used

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing the network defaults is another option, but I considered this simpler


hostBinding := nat.PortBinding{
HostIP: "0.0.0.0",
HostPort: strconv.Itoa(lookForAvailableLocalPort()),
}
containerPort, err := nat.NewPort(mapperProto, mappedPort)
if err != nil {
panic("Unable to get the port")
}

portBinding := nat.PortMap{containerPort: []nat.PortBinding{hostBinding}}
resp, err := cli.ContainerCreate(
context.Background(),
&container.Config{
Image: image,
},
&container.HostConfig{
PortBindings: portBinding,
}, nil, "")
if err != nil {
panic(err)
}
fmt.Fprintln(debugging.DebugOut, "Docker container built")

if err := cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
panic(err)
}
fmt.Fprintln(debugging.DebugOut, "Started docker container", resp.ID)

targetinfo.IP = "127.0.0.1"
targetinfo.Port, _ = strconv.Atoi(hostBinding.HostPort)

return targetinfo, resp.ID
}

func Stop(containerID string) {
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
panic(err)
}

fmt.Fprintln(debugging.DebugOut, "Stopping container", containerID, "...")
if err := cli.ContainerStop(ctx, containerID, nil); err != nil {
panic(err)
}
fmt.Fprintln(debugging.DebugOut, "Container stopped successfully")
}

func inspect(containerID string) types.ContainerJSON {
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
panic(err)
}

fmt.Fprintln(debugging.DebugOut, "Getting container info", containerID, "...")
resp, err := cli.ContainerInspect(ctx, containerID)
if err != nil {
panic(err)
}

return resp
}