diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34bd7028..10697135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,11 +54,11 @@ jobs: - name: Install Protoc uses: arduino/setup-protoc@master with: - version: '3.9.1' + version: "3.9.1" - uses: actions/setup-node@v1 with: - node-version: '10.x' + node-version: "10.x" - name: E2E Test - run: make e2e-local \ No newline at end of file + run: make e2e-local diff --git a/cli/cmd/context/ls.go b/cli/cmd/context/ls.go index 569fce52..5c7e7ffb 100644 --- a/cli/cmd/context/ls.go +++ b/cli/cmd/context/ls.go @@ -31,6 +31,8 @@ import ( "context" "fmt" "os" + "sort" + "strings" "text/tabwriter" "github.com/spf13/cobra" @@ -60,17 +62,43 @@ func runList(ctx context.Context) error { return err } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0) - fmt.Fprintln(w, "NAME\tDESCRIPTION\tTYPE") - format := "%s\t%s\t%s\n" + sort.Slice(contexts, func(i, j int) bool { + return strings.Compare(contexts[i].Name, contexts[j].Name) == -1 + }) + + w := tabwriter.NewWriter(os.Stdout, 20, 1, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tTYPE\tDESCRIPTION\tDOCKER ENPOINT\tKUBERNETES ENDPOINT\tORCHESTRATOR") + format := "%s\t%s\t%s\t%s\t%s\t%s\n" for _, c := range contexts { contextName := c.Name if c.Name == currentContext { contextName += " *" } - fmt.Fprintf(w, format, contextName, c.Metadata.Description, c.Metadata.Type) + + fmt.Fprintf(w, + format, + contextName, + c.Metadata.Type, + c.Metadata.Description, + getEndpoint("docker", c.Endpoints), + getEndpoint("kubernetes", c.Endpoints), + c.Metadata.StackOrchestrator) } return w.Flush() } + +func getEndpoint(name string, meta map[string]store.Endpoint) string { + d, ok := meta[name] + if !ok { + return "" + } + + result := d.Host + if d.DefaultNamespace != "" { + result += fmt.Sprintf(" (%s)", d.DefaultNamespace) + } + + return result +} diff --git a/cli/cmd/context/ls_test.go b/cli/cmd/context/ls_test.go new file mode 100644 index 00000000..31bb114b --- /dev/null +++ b/cli/cmd/context/ls_test.go @@ -0,0 +1,78 @@ +package context + +import ( + "context" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "gotest.tools/v3/golden" + + apicontext "github.com/docker/api/context" + "github.com/docker/api/context/store" +) + +type ContextSuite struct { + suite.Suite + ctx context.Context + writer *os.File + reader *os.File + originalStdout *os.File + storeRoot string +} + +func (sut *ContextSuite) BeforeTest(suiteName, testName string) { + ctx := context.Background() + ctx = apicontext.WithCurrentContext(ctx, "example") + dir, err := ioutil.TempDir("", "store") + require.Nil(sut.T(), err) + s, err := store.New( + store.WithRoot(dir), + ) + require.Nil(sut.T(), err) + + err = s.Create("example", store.TypedContext{ + Type: "example", + }) + require.Nil(sut.T(), err) + + sut.storeRoot = dir + + ctx = store.WithContextStore(ctx, s) + sut.ctx = ctx + + sut.originalStdout = os.Stdout + r, w, err := os.Pipe() + require.Nil(sut.T(), err) + + os.Stdout = w + sut.writer = w + sut.reader = r +} + +func (sut *ContextSuite) getStdOut() string { + err := sut.writer.Close() + require.Nil(sut.T(), err) + + out, _ := ioutil.ReadAll(sut.reader) + + return string(out) +} + +func (sut *ContextSuite) AfterTest(suiteName, testName string) { + os.Stdout = sut.originalStdout + err := os.RemoveAll(sut.storeRoot) + require.Nil(sut.T(), err) +} + +func (sut *ContextSuite) TestLs() { + err := runList(sut.ctx) + require.Nil(sut.T(), err) + golden.Assert(sut.T(), sut.getStdOut(), "ls-out.golden") +} + +func TestPs(t *testing.T) { + suite.Run(t, new(ContextSuite)) +} diff --git a/cli/cmd/context/testdata/ls-out.golden b/cli/cmd/context/testdata/ls-out.golden new file mode 100644 index 00000000..44d5702a --- /dev/null +++ b/cli/cmd/context/testdata/ls-out.golden @@ -0,0 +1,3 @@ +NAME TYPE DESCRIPTION DOCKER ENPOINT KUBERNETES ENDPOINT ORCHESTRATOR +default docker Current DOCKER_HOST based configuration unix:///var/run/docker.sock https://35.205.93.167 (default) swarm +example * example diff --git a/cli/cmd/ps.go b/cli/cmd/ps.go index 0ea4c749..1b253ab6 100644 --- a/cli/cmd/ps.go +++ b/cli/cmd/ps.go @@ -54,7 +54,7 @@ func runPs(ctx context.Context, opts psOpts) error { return nil } - w := tabwriter.NewWriter(os.Stdout, 0, 0, 8, ' ', 0) + w := tabwriter.NewWriter(os.Stdout, 20, 1, 3, ' ', 0) fmt.Fprintf(w, "CONTAINER ID\tIMAGE\tCOMMAND\tSTATUS\tPORTS\n") format := "%s\t%s\t%s\t%s\t%s\n" for _, c := range containers { diff --git a/cli/cmd/ps_test.go b/cli/cmd/ps_test.go index e078d12c..8011f6f2 100644 --- a/cli/cmd/ps_test.go +++ b/cli/cmd/ps_test.go @@ -6,9 +6,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "gotest.tools/v3/assert" "gotest.tools/v3/golden" apicontext "github.com/docker/api/context" diff --git a/cli/cmd/testdata/ps-out.golden b/cli/cmd/testdata/ps-out.golden index 34dd15f6..ee7ef979 100644 --- a/cli/cmd/testdata/ps-out.golden +++ b/cli/cmd/testdata/ps-out.golden @@ -1,3 +1,3 @@ -CONTAINER ID IMAGE COMMAND STATUS PORTS -id nginx -1234 alpine +CONTAINER ID IMAGE COMMAND STATUS PORTS +id nginx +1234 alpine diff --git a/context/store/store.go b/context/store/store.go index 2675ca50..d31bd044 100644 --- a/context/store/store.go +++ b/context/store/store.go @@ -104,35 +104,31 @@ func New(opts ...Opt) (Store, error) { if err != nil { return nil, err } + + root := filepath.Join(home, configDir) + if err := createDirIfNotExist(root); err != nil { + return nil, err + } + s := &store{ - root: filepath.Join(home, configDir), - } - if _, err := os.Stat(s.root); os.IsNotExist(err) { - if err = os.Mkdir(s.root, 0755); err != nil { - return nil, err - } + root: root, } + for _, opt := range opts { opt(s) } - cd := filepath.Join(s.root, contextsDir) - if _, err := os.Stat(cd); os.IsNotExist(err) { - if err = os.Mkdir(cd, 0755); err != nil { - return nil, err - } - } - m := filepath.Join(cd, metadataDir) - if _, err := os.Stat(m); os.IsNotExist(err) { - if err = os.Mkdir(m, 0755); err != nil { - return nil, err - } + + m := filepath.Join(s.root, contextsDir, metadataDir) + if err := createDirIfNotExist(m); err != nil { + return nil, err } + return s, nil } // Get returns the context with the given name func (s *store) Get(name string, getter func() interface{}) (*Metadata, error) { - meta := filepath.Join(s.root, contextsDir, metadataDir, contextdirOf(name), metaFile) + meta := filepath.Join(s.root, contextsDir, metadataDir, contextDirOf(name), metaFile) m, err := read(meta, getter) if os.IsNotExist(err) { return nil, errors.Wrap(errdefs.ErrNotFound, objectName(name)) @@ -150,31 +146,42 @@ func read(meta string, getter func() interface{}) (*Metadata, error) { } var um untypedMetadata - if err := json.Unmarshal(bytes, &um); err != nil { + if err := marshalTyped(bytes, &um); err != nil { return nil, err } var uc untypedContext - if err := json.Unmarshal(um.Metadata, &uc); err != nil { + if err := marshalTyped(um.Metadata, &uc); err != nil { return nil, err } + if uc.Type == "" { + uc.Type = "docker" + } - data, err := parse(uc.Data, getter) - if err != nil { - return nil, err + var data interface{} + if uc.Data != nil { + data, err = parse(uc.Data, getter) + if err != nil { + return nil, err + } } return &Metadata{ Name: um.Name, Endpoints: um.Endpoints, Metadata: TypedContext{ - Description: uc.Description, - Type: uc.Type, - Data: data, + StackOrchestrator: uc.StackOrchestrator, + Description: uc.Description, + Type: uc.Type, + Data: data, }, }, nil } +func marshalTyped(in []byte, val interface{}) error { + return json.Unmarshal(in, val) +} + func parse(payload []byte, getter func() interface{}) (interface{}, error) { if getter == nil { var res map[string]interface{} @@ -183,10 +190,12 @@ func parse(payload []byte, getter func() interface{}) (interface{}, error) { } return res, nil } + typed := getter() if err := json.Unmarshal(payload, &typed); err != nil { return nil, err } + return reflect.ValueOf(typed).Elem().Interface(), nil } @@ -204,7 +213,7 @@ func (s *store) Create(name string, data TypedContext) error { if name == DefaultContextName { return errors.Wrap(errdefs.ErrAlreadyExists, objectName(name)) } - dir := contextdirOf(name) + dir := contextDirOf(name) metaDir := filepath.Join(s.root, contextsDir, metadataDir, dir) if _, err := os.Stat(metaDir); !os.IsNotExist(err) { return errors.Wrap(errdefs.ErrAlreadyExists, objectName(name)) @@ -222,9 +231,9 @@ func (s *store) Create(name string, data TypedContext) error { meta := Metadata{ Name: name, Metadata: data, - Endpoints: map[string]interface{}{ - (dockerEndpointKey): dummyContext{}, - (data.Type): dummyContext{}, + Endpoints: map[string]Endpoint{ + (dockerEndpointKey): {}, + (data.Type): {}, }, } @@ -255,6 +264,12 @@ func (s *store) List() ([]*Metadata, error) { } } + dockerDefault, err := dockerGefaultContext() + if err != nil { + return nil, err + } + + result = append(result, dockerDefault) return result, nil } @@ -262,7 +277,7 @@ func (s *store) Remove(name string) error { if name == DefaultContextName { return errors.Wrap(errdefs.ErrForbidden, objectName(name)) } - dir := filepath.Join(s.root, contextsDir, metadataDir, contextdirOf(name)) + dir := filepath.Join(s.root, contextsDir, metadataDir, contextDirOf(name)) // Check if directory exists because os.RemoveAll returns nil if it doesn't if _, err := os.Stat(dir); os.IsNotExist(err) { return errors.Wrap(errdefs.ErrNotFound, objectName(name)) @@ -273,7 +288,7 @@ func (s *store) Remove(name string) error { return nil } -func contextdirOf(name string) string { +func contextDirOf(name string) string { return digest.FromString(name).Encoded() } @@ -281,32 +296,49 @@ func objectName(name string) string { return fmt.Sprintf("context %q", name) } +func createDirIfNotExist(dir string) error { + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err = os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return nil +} + type dummyContext struct{} +// Endpoint holds the Docker or the Kubernetes endpoint +type Endpoint struct { + Host string `json:",omitempty"` + DefaultNamespace string `json:",omitempty"` +} + // Metadata represents the docker context metadata type Metadata struct { - Name string `json:",omitempty"` - Metadata TypedContext `json:",omitempty"` - Endpoints map[string]interface{} `json:",omitempty"` + Name string `json:",omitempty"` + Metadata TypedContext `json:",omitempty"` + Endpoints map[string]Endpoint `json:",omitempty"` } type untypedMetadata struct { - Name string `json:",omitempty"` - Metadata json.RawMessage `json:",omitempty"` - Endpoints map[string]interface{} `json:",omitempty"` + Name string `json:",omitempty"` + Metadata json.RawMessage `json:",omitempty"` + Endpoints map[string]Endpoint `json:",omitempty"` } type untypedContext struct { - Data json.RawMessage `json:",omitempty"` - Description string `json:",omitempty"` - Type string `json:",omitempty"` + StackOrchestrator string `json:",omitempty"` + Type string `json:",omitempty"` + Description string `json:",omitempty"` + Data json.RawMessage `json:",omitempty"` } // TypedContext is a context with a type (moby, aci, etc...) type TypedContext struct { - Type string `json:",omitempty"` - Description string `json:",omitempty"` - Data interface{} `json:",omitempty"` + StackOrchestrator string `json:",omitempty"` + Type string `json:",omitempty"` + Description string `json:",omitempty"` + Data interface{} `json:",omitempty"` } // AciContext is the context for ACI diff --git a/context/store/store_test.go b/context/store/store_test.go index f76275f4..c901afaa 100644 --- a/context/store/store_test.go +++ b/context/store/store_test.go @@ -103,9 +103,10 @@ func (suite *StoreTestSuite) TestList() { contexts, err := suite.store.List() require.Nil(suite.T(), err) - require.Equal(suite.T(), len(contexts), 2) - require.Equal(suite.T(), contexts[0].Name, "test1") - require.Equal(suite.T(), contexts[1].Name, "test2") + require.Equal(suite.T(), len(contexts), 3) + require.Equal(suite.T(), "test1", contexts[0].Name) + require.Equal(suite.T(), "test2", contexts[1].Name) + require.Equal(suite.T(), "default", contexts[2].Name) } func (suite *StoreTestSuite) TestRemoveNotFound() { @@ -119,13 +120,15 @@ func (suite *StoreTestSuite) TestRemove() { require.Nil(suite.T(), err) contexts, err := suite.store.List() require.Nil(suite.T(), err) - require.Equal(suite.T(), len(contexts), 1) + require.Equal(suite.T(), len(contexts), 2) err = suite.store.Remove("testremove") require.Nil(suite.T(), err) contexts, err = suite.store.List() require.Nil(suite.T(), err) - require.Equal(suite.T(), len(contexts), 0) + // The default context is always here, that's why we + // have len(contexts) == 1 + require.Equal(suite.T(), len(contexts), 1) } func TestExampleTestSuite(t *testing.T) { diff --git a/context/store/storedefault.go b/context/store/storedefault.go new file mode 100644 index 00000000..fb656ae3 --- /dev/null +++ b/context/store/storedefault.go @@ -0,0 +1,75 @@ +package store + +import ( + "bytes" + "encoding/json" + "os/exec" + + "github.com/pkg/errors" +) + +// Represents a context as created by the docker cli +type defaultContext struct { + Metadata TypedContext + Endpoints endpoints +} + +// Normally (in docker/cli code), the endpoints are mapped as map[string]interface{} +// but docker cli contexts always have a "docker" and "kubernetes" key so we +// create real types for those to no have to juggle around with interfaces. +type endpoints struct { + Docker endpoint `json:"docker,omitempty"` + Kubernetes endpoint `json:"kubernetes,omitempty"` +} + +// Both "docker" and "kubernetes" endpoints in the docker cli created contexts +// have a "Host", only kubernetes has the "DefaultNamespace", we put both of +// those here for easier manipulation and to not have to create two distinct +// structs +type endpoint struct { + Host string + DefaultNamespace string +} + +func dockerGefaultContext() (*Metadata, error) { + cmd := exec.Command("docker", "context", "inspect", "default") + var stdout bytes.Buffer + cmd.Stdout = &stdout + err := cmd.Run() + if err != nil { + return nil, err + } + + var ctx []defaultContext + err = json.Unmarshal(stdout.Bytes(), &ctx) + if err != nil { + return nil, err + } + + if len(ctx) != 1 { + return nil, errors.New("found more than one default context") + } + + defaultCtx := ctx[0] + + meta := Metadata{ + Name: "default", + Endpoints: map[string]Endpoint{ + "docker": { + Host: defaultCtx.Endpoints.Docker.Host, + }, + "kubernetes": { + Host: defaultCtx.Endpoints.Kubernetes.Host, + DefaultNamespace: defaultCtx.Endpoints.Kubernetes.DefaultNamespace, + }, + }, + Metadata: TypedContext{ + Description: "Current DOCKER_HOST based configuration", + Type: "docker", + StackOrchestrator: defaultCtx.Metadata.StackOrchestrator, + Data: defaultCtx.Metadata, + }, + } + + return &meta, nil +} diff --git a/context/store/storedefault_test.go b/context/store/storedefault_test.go new file mode 100644 index 00000000..3a0daf50 --- /dev/null +++ b/context/store/storedefault_test.go @@ -0,0 +1,13 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultContext(t *testing.T) { + s, err := dockerGefaultContext() + assert.Nil(t, err) + assert.Equal(t, "default", s.Name) +} diff --git a/go.mod b/go.mod index 4270c349..907c7c7c 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/stretchr/testify v1.5.1 golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect golang.org/x/text v0.3.2 // indirect google.golang.org/grpc v1.29.1 google.golang.org/protobuf v1.21.0 diff --git a/go.sum b/go.sum index 6f8c8169..dbdc66cb 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,7 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -217,7 +218,9 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T github.com/robpike/filter v0.0.0-20150108201509-2984852a2183 h1:qDhD/wJDGyWrXKLIKmEKpKK/ejaZlguyeEaLZzmrtzo= github.com/robpike/filter v0.0.0-20150108201509-2984852a2183/go.mod h1:3dvYi47BCPInRb2ILlNnrXfl++XpwTWLbIxPyJsUvCw= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= @@ -301,6 +304,8 @@ golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=