From a2e29968c941d4d95d4eca65ead04051b6e74647 Mon Sep 17 00:00:00 2001 From: Alexei Shevchenko Date: Sun, 10 Sep 2023 02:20:07 +0300 Subject: [PATCH] client refactored and tested (#43) --- .golangci.yml | 1 + Makefile | 5 +- README.md | 43 +- cmd/decompose/main.go | 28 +- go.mod | 2 +- internal/builder/dot.go | 2 + internal/builder/json_test.go | 6 +- internal/builder/sdsl.go | 2 + internal/builder/stat_test.go | 4 +- internal/builder/yaml.go | 2 + internal/client/defaults.go | 55 +++ internal/client/docker.go | 151 +++--- internal/client/docker_test.go | 811 ++++++++++++++++++++++++++++++++ internal/client/helpers_test.go | 118 +++++ internal/client/mode.go | 21 + internal/client/mode_test.go | 15 + internal/client/options.go | 27 ++ internal/graph/load.go | 2 +- internal/graph/load_test.go | 30 +- 19 files changed, 1195 insertions(+), 130 deletions(-) create mode 100644 internal/client/defaults.go create mode 100644 internal/client/docker_test.go create mode 100644 internal/client/helpers_test.go create mode 100644 internal/client/mode.go create mode 100644 internal/client/mode_test.go create mode 100644 internal/client/options.go diff --git a/.golangci.yml b/.golangci.yml index 652b855..9fe2d45 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,6 +73,7 @@ issues: - path: ._test\.go linters: - goerr113 + - gocritic - errcheck - funlen - dupl diff --git a/Makefile b/Makefile index ea2cf2a..9f329da 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,10 @@ GIT_TAG=`git describe --abbrev=0 2>/dev/null || echo -n "no-tag"` GIT_HASH=`git rev-parse --short HEAD 2>/dev/null || echo -n "no-git"` BUILD_AT=`date +%FT%T%z` -LDFLAGS=-w -s -X main.gitHash=${GIT_HASH} -X main.buildDate=${BUILD_AT} -X main.gitVersion=${GIT_TAG} +LDFLAGS=-w -s \ + -X main.buildDate=${BUILD_AT} \ + -X main.gitVersion=${GIT_TAG} \ + -X main.gitHash=${GIT_HASH} export CGO_ENABLED=0 diff --git a/README.md b/README.md index fbc2349..98f8a6e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,22 @@ Takes all network connections from your docker containers and exports them as: - json stream - statistics (nodes, edges and listen ports) -# features +## rationale + +I was in need for a tool to visualize and inspect big (more than 470 containers) dockerized legacy system without any schemes and having a bare minimum of documentation. + +## analogs + +Closest analogs, i can find, that not suit my needs very well: + +- [Red5d/docker-autocompose](https://github.com/Red5d/docker-autocompose) - produces only `compose yaml` +- [justone/dockviz](https://github.com/justone/dockviz) - produces only `dot`, links and ports are taken + from compose configuration (`links` and `ports` sections) directly, therefore can miss some of them +- [LeoVerto/docker-network-graph](https://github.com/LeoVerto/docker-network-graph) - very same as above, build in + python +- [weaveworks/scope](https://github.com/weaveworks/scope) - deprecated, no cli + +## features - os-independent, it uses different strategies to get container connections: - running on **linux as root** is the fastest way and it will work with all types of containers (even @@ -33,20 +48,20 @@ Takes all network connections from your docker containers and exports them as: (i.e. for missing `netstat` binary), no connections for such container will be gathered - produces detailed connections graph **with ports** - save `json` stream once and process it later in any way you want -- fast, scans ~400 containers in around 5 sec +- fast, scans ~470 containers in around 5 sec - 100% test-coverage -# known limitations +## known limitations - only established and listen connections are listed (but script like [snapshots.sh](examples/snapshots.sh) can beat this) - `composer-yaml` is not intended to be working out from the box, it can lack some of crucial information (even in `-full` mode), or may contains cycles between nodes (removing `links` section in services may help), its main purpose is for system overview -# installation +## installation - [binaries / deb / rpm](https://github.com/s0rg/decompose/releases) for Linux, FreeBSD, macOS and Windows. -# usage +## usage ``` decompose [flags] @@ -83,13 +98,13 @@ possible flags with default values: show version ``` -## environment variables: +### environment variables: - `DOCKER_HOST` - connection uri - `DOCKER_CERT_PATH` - directory path containing key.pem, cert.pm and ca.pem - `DOCKER_TLS_VERIFY` - enable client TLS verification -# json stream format +## json stream format ```go type Item struct { @@ -155,7 +170,7 @@ example with full info and metadata filled: See [stream.json](examples/stream.json) for simple stream example. -# metadata format +## metadata format To enrich output with detailed descriptions, you can provide additional `json` file, with metadata i.e.: @@ -178,10 +193,10 @@ one of provided keys, like `foo-1` or `bar1` for this example. See [csv2meta.py](examples/csv2meta.py) for example how to create such `json` fom csv, and [meta.json](examples/meta.json) for metadata sample. -# clusterization rules +## clusterization rules -You can join your services into `clusters` by exposed ports, in `dot` or `structurizr` output formats. -With clusterization rules, in `json` (order matters): +You can join your services into `clusters` by flexible rules, in `dot`, `structurizr` and `stat` output formats. +Example `json` (order matters): ```json [ @@ -213,7 +228,7 @@ type Node struct { See: [cluster.json](examples/cluster.json) for detailed example. -# examples +## examples Save full json stream: @@ -245,7 +260,7 @@ Load json stream, enrich and save as `structurizr dsl`: decompose -load nodes-1.json -meta metadata.json -format sdsl > workspace.dsl ``` -# example result +## example result Scheme taken from [redis-cluster](https://github.com/s0rg/redis-cluster-compose): @@ -266,6 +281,6 @@ in other terminal: decompose -format dot | dot -Tsvg > redis-cluster.svg ``` -# license +## license [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fs0rg%2Fdecompose.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fs0rg%2Fdecompose?ref=badge_large) diff --git a/cmd/decompose/main.go b/cmd/decompose/main.go index 76a67b0..6987895 100644 --- a/cmd/decompose/main.go +++ b/cmd/decompose/main.go @@ -21,6 +21,7 @@ import ( const ( appName = "Decompose" appSite = "https://github.com/s0rg/decompose" + linuxOS = "linux" defaultProto = "all" defaultOutput = "-" ) @@ -250,15 +251,7 @@ func doLoad( ldr := graph.NewLoader(cfg) for _, fn := range files { - fd, err := os.Open(fn) - if err != nil { - return fmt.Errorf("open %s: %w", fn, err) - } - - err = ldr.LoadStream(fd) - fd.Close() - - if err != nil { + if err := feed(fn, ldr.FromReader); err != nil { return fmt.Errorf("load %s: %w", fn, err) } } @@ -273,14 +266,25 @@ func doLoad( func doBuild( cfg *graph.Config, ) error { - cli, err := client.NewDocker() + opts := []client.Option{ + client.WithClientCreator(client.Default), + } + + mode := client.InContainer + + if runtime.GOOS == linuxOS && os.Geteuid() == 0 { + opts = append(opts, client.WithNsEnter(client.Nsenter)) + mode = client.LinuxNsenter + } + + cli, err := client.NewDocker(append(opts, client.WithMode(mode))...) if err != nil { - return fmt.Errorf("docker: %w", err) + return fmt.Errorf("client: %w", err) } defer cli.Close() - log.Println("Starting with method:", cli.Kind()) + log.Println("Starting with method:", cli.Mode()) if err = graph.Build(cfg, cli); err != nil { return fmt.Errorf("graph: %w", err) diff --git a/go.mod b/go.mod index 62526e6..a6b6a98 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/s0rg/decompose -go 1.21.0 +go 1.21.1 require ( github.com/antonmedv/expr v1.15.1 diff --git a/internal/builder/dot.go b/internal/builder/dot.go index 35cb84f..c39b0ba 100644 --- a/internal/builder/dot.go +++ b/internal/builder/dot.go @@ -1,5 +1,7 @@ //go:build !test +// i/o here, nothing to test + package builder import ( diff --git a/internal/builder/json_test.go b/internal/builder/json_test.go index f97a6bd..d211ed5 100644 --- a/internal/builder/json_test.go +++ b/internal/builder/json_test.go @@ -53,7 +53,7 @@ func TestJSON(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(bytes.NewBuffer(jnode)); err != nil { + if err := ldr.FromReader(bytes.NewBuffer(jnode)); err != nil { t.Fatal("load err=", err) } @@ -108,7 +108,7 @@ func TestJSONAddEdge(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(bytes.NewBufferString(raw)); err != nil { + if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { t.Fatal("load err=", err) } @@ -166,7 +166,7 @@ func TestJSONAddBadEdges(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(&buf); err != nil { + if err := ldr.FromReader(&buf); err != nil { t.Fatal("load err=", err) } diff --git a/internal/builder/sdsl.go b/internal/builder/sdsl.go index f785541..6b0de61 100644 --- a/internal/builder/sdsl.go +++ b/internal/builder/sdsl.go @@ -1,5 +1,7 @@ //go:build !test +// i/o here, nothing to test + package builder import ( diff --git a/internal/builder/stat_test.go b/internal/builder/stat_test.go index e94dcaa..69585f3 100644 --- a/internal/builder/stat_test.go +++ b/internal/builder/stat_test.go @@ -51,7 +51,7 @@ func TestStat(t *testing.T) { } ` - if err := ldr.LoadStream(bytes.NewBufferString(raw)); err != nil { + if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { t.Fatal("load err=", err) } @@ -137,7 +137,7 @@ func TestStatCluster(t *testing.T) { } ` - if err := ldr.LoadStream(bytes.NewBufferString(raw)); err != nil { + if err := ldr.FromReader(bytes.NewBufferString(raw)); err != nil { t.Fatal("load err=", err) } diff --git a/internal/builder/yaml.go b/internal/builder/yaml.go index ae1b805..80e7c4d 100644 --- a/internal/builder/yaml.go +++ b/internal/builder/yaml.go @@ -1,5 +1,7 @@ //go:build !test +// i/o here, nothing to test + package builder import ( diff --git a/internal/client/defaults.go b/internal/client/defaults.go new file mode 100644 index 0000000..3921966 --- /dev/null +++ b/internal/client/defaults.go @@ -0,0 +1,55 @@ +//go:build !test + +package client + +import ( + "context" + "fmt" + "io" + "os/exec" + "strconv" + + "github.com/docker/docker/client" + "github.com/s0rg/decompose/internal/graph" +) + +func Default() (rv DockerClient, err error) { + rv, err = client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("docker: %w", err) + } + + return rv, nil +} + +func Nsenter( + ctx context.Context, + pid int, + proto graph.NetProto, + parse func(io.Reader) error, +) ( + err error, +) { + arg := append([]string{"-t", strconv.Itoa(pid), "-n"}, netstat(proto)...) + cmd := exec.CommandContext(ctx, nsenterCmd, arg...) + + pipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("pipe: %w", err) + } + + defer pipe.Close() + + if err = cmd.Start(); err != nil { + return fmt.Errorf("exec: %w", err) + } + + if err = parse(pipe); err != nil { + return fmt.Errorf("parse: %w", err) + } + + return nil +} diff --git a/internal/client/docker.go b/internal/client/docker.go index f1d9202..0d0099a 100644 --- a/internal/client/docker.go +++ b/internal/client/docker.go @@ -1,53 +1,61 @@ -//go:build !test - package client import ( "context" + "errors" "fmt" - "os" - "os/exec" - "runtime" + "io" "slices" - "strconv" "strings" "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" "github.com/s0rg/set" "github.com/s0rg/decompose/internal/graph" ) const ( - linuxOS = "linux" stateRunning = "running" nsenterCmd = "nsenter" + netstatCmd = "netstat" pingTimeout = time.Second ) -type connExtactor func(context.Context, int, string, graph.NetProto) ([]*graph.Connection, error) +var ErrModeNone = errors.New("mode not set") + +type createClient func() (DockerClient, error) +type nsEnter func(context.Context, int, graph.NetProto, func(io.Reader) error) error + +type DockerClient interface { + ContainerList(context.Context, types.ContainerListOptions) ([]types.Container, error) + ContainerInspect(context.Context, string) (types.ContainerJSON, error) + ContainerExecCreate(context.Context, string, types.ExecConfig) (types.IDResponse, error) + ContainerExecAttach(context.Context, string, types.ExecStartCheck) (types.HijackedResponse, error) + Ping(context.Context) (types.Ping, error) + Close() error +} type Docker struct { - cli *client.Client - connections connExtactor - cmd string - kind string + opt *options + cli DockerClient } -func NewDocker() (rv *Docker, err error) { - rv = &Docker{} +func NewDocker(opts ...Option) (rv *Docker, err error) { + rv = &Docker{ + opt: &options{}, + } + + for _, op := range opts { + op(rv.opt) + } - if err = rv.init(); err != nil { - return nil, fmt.Errorf("init: %w", err) + if rv.opt.Mode == None { + return nil, fmt.Errorf("options: %w", ErrModeNone) } - if rv.cli, err = client.NewClientWithOpts( - client.FromEnv, - client.WithAPIVersionNegotiation(), - ); err != nil { + if rv.cli, err = rv.opt.Create(); err != nil { return nil, fmt.Errorf("client: %w", err) } @@ -61,27 +69,8 @@ func NewDocker() (rv *Docker, err error) { return rv, nil } -func (d *Docker) init() (err error) { - if runtime.GOOS == linuxOS && os.Geteuid() == 0 { - if d.cmd, err = exec.LookPath(nsenterCmd); err != nil { - return fmt.Errorf("looking for %s: %w", nsenterCmd, err) - } - - d.kind = "netns" - d.connections = d.conectionsNetns - - return nil - } - - // non-linux or non-root - d.kind = "in-container" - d.connections = d.conectionsContainer - - return nil -} - -func (d *Docker) Kind() string { - return d.kind +func (d *Docker) Mode() string { + return d.opt.Mode.String() } func (d *Docker) Containers( @@ -125,7 +114,7 @@ func (d *Docker) Containers( return nil, fmt.Errorf("inspect: %w", err) } - con.Volumes = extractVolumesInfo(doc) + con.Volumes = extractVolumesInfo(info.Mounts) con.Process = extractProcessInfo(&info, skeys) pid = info.State.Pid } @@ -157,88 +146,88 @@ func (d *Docker) Close() (err error) { return nil } -func (d *Docker) conectionsNetns( +func (d *Docker) connections( ctx context.Context, pid int, - containerID string, + cid string, proto graph.NetProto, -) ( - rv []*graph.Connection, - err error, -) { - if pid == 0 { - info, ierr := d.cli.ContainerInspect(ctx, containerID) - if ierr != nil { - return nil, fmt.Errorf("inspect: %w", ierr) +) (rv []*graph.Connection, err error) { + parse := func(r io.Reader) (err error) { + if rv, err = graph.ParseNetstat(r); err != nil { + return fmt.Errorf("parse: %w", err) } - pid = info.State.Pid + return nil } - arg := append([]string{"-t", strconv.Itoa(pid), "-n"}, netstatCmd(proto)...) - cmd := exec.CommandContext(ctx, d.cmd, arg...) - - pipe, err := cmd.StdoutPipe() - if err != nil { - return nil, fmt.Errorf("pipe: %w", err) - } + switch d.opt.Mode { + case InContainer: + err = d.connectionsContainer(ctx, cid, proto, parse) + case LinuxNsenter: + if pid == 0 { + info, ierr := d.cli.ContainerInspect(ctx, cid) + if ierr != nil { + return nil, fmt.Errorf("inspect: %w", ierr) + } - defer pipe.Close() + pid = info.State.Pid + } - if err = cmd.Start(); err != nil { - return nil, fmt.Errorf("exec: %w", err) + err = d.opt.Nsenter(ctx, pid, proto, parse) + case None: } - if rv, err = graph.ParseNetstat(pipe); err != nil { - return nil, fmt.Errorf("parse: %w", err) + if err != nil { + return nil, fmt.Errorf("%s: %w", d.opt.Mode, err) } return rv, nil } -func (d *Docker) conectionsContainer( +func (d *Docker) connectionsContainer( ctx context.Context, - _ int, containerID string, proto graph.NetProto, + parse func(io.Reader) error, ) ( - rv []*graph.Connection, err error, ) { exe, err := d.cli.ContainerExecCreate(ctx, containerID, types.ExecConfig{ Tty: true, AttachStdout: true, - Cmd: netstatCmd(proto), + Cmd: netstat(proto), }) if err != nil { - return nil, fmt.Errorf("exec-create: %w", err) + return fmt.Errorf("exec-create: %w", err) } resp, err := d.cli.ContainerExecAttach(ctx, exe.ID, types.ExecStartCheck{ Tty: true, }) if err != nil { - return nil, fmt.Errorf("exec-attach: %w", err) + return fmt.Errorf("exec-attach: %w", err) } defer resp.Close() - if rv, err = graph.ParseNetstat(resp.Reader); err != nil { - return nil, fmt.Errorf("parse: %w", err) + if err = parse(resp.Reader); err != nil { + return fmt.Errorf("parse: %w", err) } - return rv, nil + return nil } -func netstatCmd(p graph.NetProto) []string { - return []string{"netstat", "-an" + p.Flag()} +func netstat(p graph.NetProto) []string { + return []string{netstatCmd, "-an" + p.Flag()} } func extractProcessInfo( c *types.ContainerJSON, s set.Unordered[string], ) (rv *graph.ProcessInfo) { - rv = &graph.ProcessInfo{Cmd: c.Config.Cmd} + rv = &graph.ProcessInfo{ + Cmd: c.Config.Cmd, + } if s.Len() == 0 { rv.Env = c.Config.Env @@ -277,11 +266,11 @@ func extractEndpoints( } func extractVolumesInfo( - c *types.Container, + mounts []types.MountPoint, ) (rv []*graph.VolumeInfo) { - rv = make([]*graph.VolumeInfo, len(c.Mounts)) + rv = make([]*graph.VolumeInfo, len(mounts)) - for i, m := range c.Mounts { + for i, m := range mounts { rv[i] = &graph.VolumeInfo{ Type: string(m.Type), Src: m.Source, diff --git a/internal/client/docker_test.go b/internal/client/docker_test.go new file mode 100644 index 0000000..63b5d01 --- /dev/null +++ b/internal/client/docker_test.go @@ -0,0 +1,811 @@ +package client_test + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + + "github.com/s0rg/decompose/internal/client" + "github.com/s0rg/decompose/internal/graph" +) + +func TestDockerClientCreateModeError(t *testing.T) { + t.Parallel() + + _, err := client.NewDocker() + if err == nil { + t.Fail() + } +} + +func TestDockerClientCreateError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-error") + + _, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return nil, testErr + }), + client.WithMode(client.InContainer), + ) + if err == nil || !errors.Is(err, testErr) { + t.Fail() + } +} + +func TestDockerClientPingError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-error") + + cm := &clientMock{Err: testErr} + + _, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err == nil || !errors.Is(err, testErr) { + t.Fail() + } +} + +func TestDockerClientContainersError(t *testing.T) { + t.Parallel() + + cm := &clientMock{} + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal(err) + } + + cm.Err = errors.New("test-error") + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err == nil || !errors.Is(err, cm.Err) { + t.Fail() + } +} + +func TestDockerClientContainersEmpty(t *testing.T) { + t.Parallel() + + cm := &clientMock{ + OnList: func() (rv []types.Container) { + return rv + }, + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 0 { + t.Fail() + } +} + +func TestDockerClientContainersSingleExited(t *testing.T) { + t.Parallel() + + cm := &clientMock{ + OnList: func() (rv []types.Container) { + return []types.Container{ + { + State: "exited", + }, + } + }, + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 0 { + t.Fail() + } +} + +func TestDockerClientContainersExecCreateError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + cm.Err = testErr + + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil && !errors.Is(err, cm.Err) { + t.Fail() + } +} + +func TestDockerClientContainersInspectError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + cm.Err = testErr + + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + true, + nil, + voidProgress, + ) + if err != nil && !errors.Is(err, cm.Err) { + t.Fail() + } +} + +func TestDockerClientContainersExecAttachError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cm.OnExecCreate = func() (rv types.IDResponse) { + cm.Err = testErr + + return + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil && !errors.Is(err, cm.Err) { + t.Fail() + } +} + +func TestDockerClientContainersParseError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cm.OnExecCreate = func() (rv types.IDResponse) { + return + } + + cm.OnExecAttach = func() (rv types.HijackedResponse) { + rv.Conn = &connMock{} + rv.Reader = bufio.NewReader(&connMock{Err: testErr}) + + return + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil && !errors.Is(err, testErr) { + t.Fail() + } +} + +func TestDockerClientContainersCloseError(t *testing.T) { + t.Parallel() + + cm := &clientMock{} + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + cm.Err = errors.New("test-err") + + if err = cli.Close(); !errors.Is(err, cm.Err) { + t.Fail() + } +} + +func TestDockerClientContainersSingle(t *testing.T) { + t.Parallel() + + cm := &clientMock{ + OnList: func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + }, + OnExecCreate: func() (rv types.IDResponse) { + return + }, + OnExecAttach: func() (rv types.HijackedResponse) { + rv.Conn = &connMock{} + rv.Reader = bufio.NewReader(bytes.NewBufferString("")) + + return + }, + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + cli.Close() + + if len(rv) != 1 { + t.Fail() + } + + if rv[0].Name != "test" { + t.Fail() + } + + if len(rv[0].Endpoints) != 1 { + t.Fail() + } +} + +func TestDockerClientContainersSingleFull(t *testing.T) { + t.Parallel() + + cm := &clientMock{ + OnList: func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + }, + OnInspect: func() (rv types.ContainerJSON) { + rv.ContainerJSONBase = &types.ContainerJSONBase{} + rv.State = &types.ContainerState{Pid: 1} + rv.Config = &container.Config{ + Cmd: []string{"foo"}, + Env: []string{"BAR=1"}, + } + rv.Mounts = []types.MountPoint{ + { + Type: "bind", + Source: "src", + Destination: "dst", + }, + } + + return rv + }, + OnExecCreate: func() (rv types.IDResponse) { + return + }, + OnExecAttach: func() (rv types.HijackedResponse) { + rv.Conn = &connMock{} + rv.Reader = bufio.NewReader(bytes.NewBufferString("")) + + return + }, + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + true, + nil, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 1 { + t.Fail() + } + + if rv[0].Process == nil { + t.Fail() + } + + if len(rv[0].Volumes) != 1 { + t.Fail() + } +} + +func TestDockerClientContainersSingleFullSkipEnv(t *testing.T) { + t.Parallel() + + cm := &clientMock{ + OnList: func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + }, + OnInspect: func() (rv types.ContainerJSON) { + rv.ContainerJSONBase = &types.ContainerJSONBase{} + rv.State = &types.ContainerState{Pid: 1} + rv.Config = &container.Config{ + Cmd: []string{"foo"}, + Env: []string{"BAR=1", "BAZ=2"}, + } + rv.Mounts = []types.MountPoint{} + + return rv + }, + OnExecCreate: func() (rv types.IDResponse) { + return + }, + OnExecAttach: func() (rv types.HijackedResponse) { + rv.Conn = &connMock{} + rv.Reader = bufio.NewReader(bytes.NewBufferString("")) + + return + }, + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + rv, err := cli.Containers( + context.Background(), + graph.ALL, + true, + []string{"BAZ"}, + voidProgress, + ) + if err != nil { + t.Fatal("containers:", err) + } + + if len(rv) != 1 { + t.Fail() + } + + if rv[0].Process == nil { + t.Fail() + } + + if len(rv[0].Process.Env) != 1 { + t.Fail() + } +} + +func TestDockerClientMode(t *testing.T) { + t.Parallel() + + cm := &clientMock{} + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.InContainer), + ) + if err != nil { + t.Fatal("client:", err) + } + + if cli.Mode() != "in-container" { + t.Fail() + } + + cli, err = client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.LinuxNsenter), + ) + if err != nil { + t.Fatal("client:", err) + } + + if cli.Mode() != "linux-nsenter" { + t.Fail() + } + + _, err = client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + ) + if err == nil { + t.Fail() + } +} + +func TestDockerClientNsEnterInspectError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + cm.Err = testErr + + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.LinuxNsenter), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + + if !errors.Is(err, testErr) { + t.Fail() + } +} + +func TestDockerClientNsEnterConnectionsError(t *testing.T) { + t.Parallel() + + testErr := errors.New("test-err") + + cm := &clientMock{} + + cm.OnList = func() (rv []types.Container) { + return []types.Container{ + { + ID: "1", + Names: []string{"test"}, + Image: "test-image", + State: "running", + NetworkSettings: &types.SummaryNetworkSettings{ + Networks: map[string]*network.EndpointSettings{ + "test-net": { + EndpointID: "1", + IPAddress: "1.1.1.1", + }, + "empty-id": { + IPAddress: "1.1.1.2", + }, + }, + }, + }, + } + } + + cm.OnInspect = func() (rv types.ContainerJSON) { + rv.ContainerJSONBase = &types.ContainerJSONBase{} + rv.State = &types.ContainerState{Pid: 1} + + return rv + } + + failEnter := func(_ context.Context, _ int, _ graph.NetProto, _ func(io.Reader) error) error { + return testErr + } + + cli, err := client.NewDocker( + client.WithClientCreator(func() (client.DockerClient, error) { + return cm, nil + }), + client.WithMode(client.LinuxNsenter), + client.WithNsEnter(failEnter), + ) + if err != nil { + t.Fatal("client:", err) + } + + _, err = cli.Containers( + context.Background(), + graph.ALL, + false, + nil, + voidProgress, + ) + + if !errors.Is(err, testErr) { + t.Fail() + } +} diff --git a/internal/client/helpers_test.go b/internal/client/helpers_test.go new file mode 100644 index 0000000..d1812e6 --- /dev/null +++ b/internal/client/helpers_test.go @@ -0,0 +1,118 @@ +package client_test + +import ( + "context" + "io" + "net" + "time" + + "github.com/docker/docker/api/types" +) + +func voidProgress(_, _ int) {} + +type clientMock struct { + Err error + OnList func() []types.Container + OnInspect func() types.ContainerJSON + OnExecCreate func() types.IDResponse + OnExecAttach func() types.HijackedResponse +} + +func (cm *clientMock) ContainerList( + _ context.Context, + _ types.ContainerListOptions, +) (rv []types.Container, err error) { + if cm.Err != nil { + err = cm.Err + + return + } + + return cm.OnList(), nil +} + +func (cm *clientMock) ContainerInspect( + _ context.Context, + _ string, +) (rv types.ContainerJSON, err error) { + if cm.Err != nil { + err = cm.Err + + return + } + + return cm.OnInspect(), nil +} + +func (cm *clientMock) ContainerExecCreate( + _ context.Context, + _ string, + _ types.ExecConfig, +) (rv types.IDResponse, err error) { + if cm.Err != nil { + err = cm.Err + + return + } + + return cm.OnExecCreate(), nil +} + +func (cm *clientMock) ContainerExecAttach( + _ context.Context, + _ string, + _ types.ExecStartCheck, +) (rv types.HijackedResponse, err error) { + if cm.Err != nil { + err = cm.Err + + return + } + + return cm.OnExecAttach(), nil +} + +func (cm *clientMock) Ping(_ context.Context) (rv types.Ping, err error) { + if cm.Err != nil { + err = cm.Err + + return + } + + return rv, nil +} + +func (cm *clientMock) Close() (err error) { + if cm.Err != nil { + err = cm.Err + } + + return +} + +type connMock struct { + Err error +} + +func (cnm *connMock) Read(_ []byte) (n int, err error) { + if cnm.Err != nil { + return 0, cnm.Err + } + + return 0, io.EOF +} + +func (cnm *connMock) Write(b []byte) (n int, err error) { + return len(b), nil +} + +func (cnm *connMock) Close() error { + return cnm.Err +} + +func (cnm *connMock) LocalAddr() (rv net.Addr) { return } +func (cnm *connMock) RemoteAddr() (rv net.Addr) { return } +func (cnm *connMock) SetDeadline(_ time.Time) error { return nil } +func (cnm *connMock) SetReadDeadline(_ time.Time) error { return nil } +func (cnm *connMock) SetWriteDeadline(_ time.Time) error { return nil } diff --git a/internal/client/mode.go b/internal/client/mode.go new file mode 100644 index 0000000..14a6bff --- /dev/null +++ b/internal/client/mode.go @@ -0,0 +1,21 @@ +package client + +type mode byte + +const ( + None mode = 0 + InContainer mode = 1 + LinuxNsenter mode = 2 +) + +func (m mode) String() (rv string) { + switch m { + case InContainer: + return "in-container" + case LinuxNsenter: + return "linux-nsenter" + case None: + } + + return +} diff --git a/internal/client/mode_test.go b/internal/client/mode_test.go new file mode 100644 index 0000000..2cc9c97 --- /dev/null +++ b/internal/client/mode_test.go @@ -0,0 +1,15 @@ +package client_test + +import ( + "testing" + + "github.com/s0rg/decompose/internal/client" +) + +func TestModeNone(t *testing.T) { + t.Parallel() + + if client.None.String() != "" { + t.Fail() + } +} diff --git a/internal/client/options.go b/internal/client/options.go new file mode 100644 index 0000000..aa47ba2 --- /dev/null +++ b/internal/client/options.go @@ -0,0 +1,27 @@ +package client + +type Option func(*options) + +type options struct { + Create createClient + Nsenter nsEnter + Mode mode +} + +func WithMode(m mode) Option { + return func(o *options) { + o.Mode = m + } +} + +func WithClientCreator(c createClient) Option { + return func(o *options) { + o.Create = c + } +} + +func WithNsEnter(e nsEnter) Option { + return func(o *options) { + o.Nsenter = e + } +} diff --git a/internal/graph/load.go b/internal/graph/load.go index 399d062..7cff7e9 100644 --- a/internal/graph/load.go +++ b/internal/graph/load.go @@ -27,7 +27,7 @@ func NewLoader(cfg *Config) *Loader { } } -func (l *Loader) LoadStream(r io.Reader) error { +func (l *Loader) FromReader(r io.Reader) error { jr := json.NewDecoder(r) for jr.More() { diff --git a/internal/graph/load_test.go b/internal/graph/load_test.go index 17eb2a4..ccbc14c 100644 --- a/internal/graph/load_test.go +++ b/internal/graph/load_test.go @@ -20,7 +20,7 @@ func TestLoaderLoadError(t *testing.T) { ldr := graph.NewLoader(cfg) buf := bytes.NewBufferString(`{`) - if err := ldr.LoadStream(buf); err == nil { + if err := ldr.FromReader(buf); err == nil { t.Fail() } } @@ -48,7 +48,7 @@ func TestLoaderBuildError(t *testing.T) { "connected": null }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("err=", err) } @@ -84,7 +84,7 @@ func TestLoaderSingle(t *testing.T) { "connected": null }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -118,7 +118,7 @@ func TestLoaderBadPorts(t *testing.T) { "connected": null }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -156,7 +156,7 @@ func TestLoaderEdges(t *testing.T) { "connected": {"test1":["1/tcp"]} }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -183,7 +183,7 @@ func TestLoaderSeveral(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(bytes.NewBufferString(`{ + if err := ldr.FromReader(bytes.NewBufferString(`{ "name": "test1", "listen": ["1/tcp"], "networks": ["foo"], @@ -192,7 +192,7 @@ func TestLoaderSeveral(t *testing.T) { t.Fatal("load1 err=", err) } - if err := ldr.LoadStream(bytes.NewBufferString(`{ + if err := ldr.FromReader(bytes.NewBufferString(`{ "name": "test2", "listen": ["2/tcp"], "networks": ["foo"], @@ -237,7 +237,7 @@ func TestLoaderEdgesProto(t *testing.T) { "connected": {"test1":["1/udp"]} }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -276,7 +276,7 @@ func TestLoaderEdgesFollowNone(t *testing.T) { "connected": {"test1":["1/udp"]} }`) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -320,7 +320,7 @@ func TestLoaderEdgesFollowOne(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -362,7 +362,7 @@ func TestLoaderLocal(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -405,7 +405,7 @@ func TestLoaderMeta(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -449,7 +449,7 @@ func TestLoaderFull(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(buf); err != nil { + if err := ldr.FromReader(buf); err != nil { t.Fatal("load err=", err) } @@ -490,7 +490,7 @@ func TestLoaderLoops(t *testing.T) { ldr := graph.NewLoader(cfg) - if err := ldr.LoadStream(bytes.NewBufferString(rawJSON)); err != nil { + if err := ldr.FromReader(bytes.NewBufferString(rawJSON)); err != nil { t.Fatal("load err=", err) } @@ -507,7 +507,7 @@ func TestLoaderLoops(t *testing.T) { cfg.NoLoops = true ldr = graph.NewLoader(cfg) - if err := ldr.LoadStream(bytes.NewBufferString(rawJSON)); err != nil { + if err := ldr.FromReader(bytes.NewBufferString(rawJSON)); err != nil { t.Fatal("load err=", err) }