Skip to content
This repository was archived by the owner on Jul 18, 2025. It is now read-only.

Commit 54fa1a2

Browse files
Add services column to docker app ls command
- List all the required services and the running services - Tries to call status json action on all installed app Signed-off-by: Silvin Lubecki <[email protected]>
1 parent dab1159 commit 54fa1a2

File tree

4 files changed

+215
-46
lines changed

4 files changed

+215
-46
lines changed

e2e/commands_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,8 @@ func TestDockerAppLifecycle(t *testing.T) {
316316
cmd.Command = dockerCli.Command("app", "ls")
317317
checkContains(t, icmd.RunCmd(cmd).Assert(t, icmd.Success).Combined(),
318318
[]string{
319-
`RUNNING APP\s+APP NAME\s+LAST ACTION\s+RESULT\s+CREATED\s+MODIFIED\s+REFERENCE`,
320-
fmt.Sprintf(`%s\s+simple \(1.1.0-beta1\)\s+install\s+failure\s+.+second[s]?\sago\s+.+second[s]?\sago\s+`, appName),
319+
`RUNNING APP\s+APP NAME\s+SERVICES\s+LAST ACTION\s+RESULT\s+CREATED\s+MODIFIED\s+REFERENCE`,
320+
fmt.Sprintf(`%s\s+simple \(1.1.0-beta1\)\s+0/3\s+install\s+failure\s+.+second[s]?\sago\s+.+second[s]?\sago\s+`, appName),
321321
})
322322

323323
// Upgrading a failed installation is not allowed
@@ -344,8 +344,8 @@ func TestDockerAppLifecycle(t *testing.T) {
344344
cmd.Command = dockerCli.Command("app", "ls")
345345
checkContains(t, icmd.RunCmd(cmd).Assert(t, icmd.Success).Combined(),
346346
[]string{
347-
`RUNNING APP\s+APP NAME\s+LAST ACTION\s+RESULT\s+CREATED\s+MODIFIED\s+REFERENCE`,
348-
fmt.Sprintf(`%s\s+simple \(1.1.0-beta1\)\s+install\s+success\s+.+second[s]?\sago\s+.+second[s]?\sago\s+`, appName),
347+
`RUNNING APP\s+APP NAME\s+SERVICES\s+LAST ACTION\s+RESULT\s+CREATED\s+MODIFIED\s+REFERENCE`,
348+
fmt.Sprintf(`%s\s+simple \(1.1.0-beta1\)\s+\d/3\s+install\s+success\s+.+second[s]?\sago\s+.+second[s]?\sago\s+`, appName),
349349
})
350350

351351
// Installing again the same application is forbidden

e2e/pushpull_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func TestPushPullInstall(t *testing.T) {
9191
cmd.Command = dockerCli.Command("app", "ls")
9292
checkContains(t, icmd.RunCmd(cmd).Assert(t, icmd.Success).Combined(),
9393
[]string{
94-
fmt.Sprintf(`%s\s+push-pull \(1.1.0-beta1\)\s+install\s+success\s+.+second[s]?\sago\s+.+second[s]?\sago\s+%s`, t.Name(), ref+tag),
94+
fmt.Sprintf(`%s\s+push-pull \(1.1.0-beta1\)\s+\d/1\s+install\s+success\s+.+second[s]?\sago\s+.+second[s]?\sago\s+%s`, t.Name(), ref+tag),
9595
})
9696

9797
// install should fail (registry is stopped)

internal/commands/list.go

Lines changed: 148 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package commands
22

33
import (
4+
"bytes"
45
"fmt"
56
"io"
67
"sort"
78
"strings"
89
"text/tabwriter"
910
"time"
1011

12+
"github.com/deislabs/cnab-go/action"
13+
"github.com/docker/app/internal"
14+
"github.com/docker/app/internal/cliopts"
15+
"github.com/docker/app/internal/cnab"
1116
"github.com/docker/app/internal/store"
1217
"github.com/docker/cli/cli"
1318
"github.com/docker/cli/cli/command"
@@ -22,55 +27,79 @@ import (
2227
var (
2328
listColumns = []struct {
2429
header string
25-
value func(i *store.Installation) string
30+
value func(i Installation) string
2631
}{
27-
{"RUNNING APP", func(i *store.Installation) string { return i.Name }},
28-
{"APP NAME", func(i *store.Installation) string { return fmt.Sprintf("%s (%s)", i.Bundle.Name, i.Bundle.Version) }},
29-
{"LAST ACTION", func(i *store.Installation) string { return i.Result.Action }},
30-
{"RESULT", func(i *store.Installation) string { return i.Result.Status }},
31-
{"CREATED", func(i *store.Installation) string {
32+
{"RUNNING APP", func(i Installation) string { return i.Name }},
33+
{"APP NAME", func(i Installation) string { return fmt.Sprintf("%s (%s)", i.Bundle.Name, i.Bundle.Version) }},
34+
{"SERVICES", printServices},
35+
{"LAST ACTION", func(i Installation) string { return i.Result.Action }},
36+
{"RESULT", func(i Installation) string { return i.Result.Status }},
37+
{"CREATED", func(i Installation) string {
3238
return fmt.Sprintf("%s ago", units.HumanDuration(time.Since(i.Created)))
3339
}},
34-
{"MODIFIED", func(i *store.Installation) string {
40+
{"MODIFIED", func(i Installation) string {
3541
return fmt.Sprintf("%s ago", units.HumanDuration(time.Since(i.Modified)))
3642
}},
37-
{"REFERENCE", func(i *store.Installation) string { return i.Reference }},
43+
{"REFERENCE", func(i Installation) string { return i.Reference }},
3844
}
3945
)
4046

41-
func listCmd(dockerCli command.Cli) *cobra.Command {
42-
var template string
47+
type listOptions struct {
48+
template string
49+
}
50+
51+
func listCmd(dockerCli command.Cli, installerContext *cliopts.InstallerContextOptions) *cobra.Command {
52+
var opts listOptions
4353
cmd := &cobra.Command{
4454
Use: "ls [OPTIONS]",
4555
Short: "List running Apps",
4656
Aliases: []string{"list"},
4757
Args: cli.NoArgs,
4858
RunE: func(cmd *cobra.Command, args []string) error {
49-
return runList(dockerCli, template)
59+
return runList(dockerCli, opts, installerContext)
5060
},
5161
}
5262

53-
cmd.Flags().StringVarP(&template, "format", "f", "", "Format the output using the given syntax or Go template")
63+
cmd.Flags().StringVarP(&opts.template, "format", "f", "", "Format the output using the given syntax or Go template")
5464
cmd.Flags().SetAnnotation("format", "experimentalCLI", []string{"true"}) //nolint:errcheck
5565
return cmd
5666
}
5767

58-
func runList(dockerCli command.Cli, template string) error {
59-
installations, err := getInstallations(dockerCli.CurrentContext(), config.Dir())
68+
func runList(dockerCli command.Cli, opts listOptions, installerContext *cliopts.InstallerContextOptions) error {
69+
// initialize stores
70+
appstore, err := store.NewApplicationStore(config.Dir())
71+
if err != nil {
72+
return err
73+
}
74+
targetContext := dockerCli.CurrentContext()
75+
installationStore, err := appstore.InstallationStore(targetContext)
76+
if err != nil {
77+
return err
78+
}
79+
80+
fetcher := &serviceFetcher{
81+
dockerCli: dockerCli,
82+
opts: opts,
83+
installerContext: installerContext,
84+
}
85+
installations, err := getInstallations(installationStore, fetcher)
86+
if installations == nil && err != nil {
87+
return err
88+
}
6089
if err != nil {
6190
return err
6291
}
6392

64-
if template == "json" {
93+
if opts.template == "json" {
6594
bytes, err := json.MarshalIndent(installations, "", " ")
6695
if err != nil {
6796
return errors.Errorf("Failed to marshall json: %s", err)
6897
}
6998
_, err = dockerCli.Out().Write(bytes)
7099
return err
71100
}
72-
if template != "" {
73-
tmpl, err := templates.Parse(template)
101+
if opts.template != "" {
102+
tmpl, err := templates.Parse(opts.template)
74103
if err != nil {
75104
return errors.Errorf("Template parsing error: %s", err)
76105
}
@@ -94,38 +123,128 @@ func printHeaders(w io.Writer) {
94123
fmt.Fprintln(w, strings.Join(headers, "\t"))
95124
}
96125

97-
func printValues(w io.Writer, installation *store.Installation) {
126+
func printValues(w io.Writer, installation Installation) {
98127
var values []string
99128
for _, column := range listColumns {
100129
values = append(values, column.value(installation))
101130
}
102131
fmt.Fprintln(w, strings.Join(values, "\t"))
103132
}
104133

105-
func getInstallations(targetContext, configDir string) ([]*store.Installation, error) {
106-
appstore, err := store.NewApplicationStore(configDir)
107-
if err != nil {
108-
return nil, err
109-
}
110-
installationStore, err := appstore.InstallationStore(targetContext)
111-
if err != nil {
112-
return nil, err
113-
}
134+
type Installation struct {
135+
*store.Installation
136+
Services appServices `json:",omitempty"`
137+
}
138+
139+
func getInstallations(installationStore store.InstallationStore, fetcher ServiceFetcher) ([]Installation, error) {
114140
installationNames, err := installationStore.List()
115141
if err != nil {
116142
return nil, err
117143
}
118-
installations := make([]*store.Installation, len(installationNames))
144+
installations := make([]Installation, len(installationNames))
119145
for i, name := range installationNames {
120146
installation, err := installationStore.Read(name)
121147
if err != nil {
122148
return nil, err
123149
}
124-
installations[i] = installation
150+
services, err := fetcher.getServices(installation)
151+
if err != nil {
152+
return nil, err
153+
}
154+
installations[i] = Installation{Installation: installation, Services: services}
125155
}
126156
// Sort installations with last modified first
127157
sort.Slice(installations, func(i, j int) bool {
128158
return installations[i].Modified.After(installations[j].Modified)
129159
})
160+
130161
return installations, nil
131162
}
163+
164+
type ServiceStatus struct {
165+
DesiredTasks int
166+
RunningTasks int
167+
}
168+
169+
type appServices map[string]ServiceStatus
170+
171+
type runningService struct {
172+
Spec struct {
173+
Name string
174+
}
175+
ServiceStatus ServiceStatus
176+
}
177+
178+
type serviceFetcher struct {
179+
dockerCli command.Cli
180+
opts listOptions
181+
installerContext *cliopts.InstallerContextOptions
182+
}
183+
184+
type ServiceFetcher interface {
185+
getServices(*store.Installation) (appServices, error)
186+
}
187+
188+
func (s *serviceFetcher) getServices(installation *store.Installation) (appServices, error) {
189+
defer muteDockerCli(s.dockerCli)()
190+
191+
// bundle without status action returns empty services
192+
if !hasAction(installation.Bundle, internal.ActionStatusJSONName) {
193+
return nil, nil
194+
}
195+
creds, err := prepareCredentialSet(installation.Bundle,
196+
addDockerCredentials(s.dockerCli.CurrentContext(), s.dockerCli.ContextStore()),
197+
addRegistryCredentials(false, s.dockerCli),
198+
)
199+
if err != nil {
200+
return nil, err
201+
}
202+
203+
var buf bytes.Buffer
204+
driverImpl, errBuf, err := cnab.SetupDriver(installation, s.dockerCli, s.installerContext, &buf)
205+
if err != nil {
206+
return nil, err
207+
}
208+
a := &action.RunCustom{
209+
Driver: driverImpl,
210+
Action: internal.ActionStatusJSONName,
211+
}
212+
// fetch output from status JSON action and parse it
213+
if err := a.Run(&installation.Claim, creds); err != nil {
214+
return nil, fmt.Errorf("failed to get app %q status : %s\n%s", installation.Name, err, errBuf)
215+
}
216+
var runningServices []runningService
217+
if err := json.Unmarshal(buf.Bytes(), &runningServices); err != nil {
218+
return nil, err
219+
}
220+
221+
services := make(appServices, len(installation.Bundle.Images))
222+
for name := range installation.Bundle.Images {
223+
services[name] = getRunningService(runningServices, installation.Name, name)
224+
}
225+
226+
return services, nil
227+
}
228+
229+
func getRunningService(services []runningService, app, name string) ServiceStatus {
230+
for _, s := range services {
231+
// swarm services are prefixed by app name
232+
if s.Spec.Name == name || s.Spec.Name == fmt.Sprintf("%s_%s", app, name) {
233+
return s.ServiceStatus
234+
}
235+
}
236+
return ServiceStatus{}
237+
}
238+
239+
func printServices(i Installation) string {
240+
if len(i.Services) == 0 {
241+
return "N/A"
242+
}
243+
var runningServices int
244+
for _, s := range i.Services {
245+
if s.RunningTasks > 0 {
246+
runningServices++
247+
}
248+
}
249+
return fmt.Sprintf("%d/%d", runningServices, len(i.Services))
250+
}

internal/commands/list_test.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,33 @@ import (
77
"github.com/deislabs/cnab-go/claim"
88
"github.com/docker/app/internal/store"
99
"gotest.tools/assert"
10-
"gotest.tools/fs"
1110
)
1211

12+
type mockInstallationStore struct {
13+
installations map[string]*store.Installation
14+
}
15+
16+
func (m mockInstallationStore) List() ([]string, error) {
17+
l := []string{}
18+
for k := range m.installations {
19+
l = append(l, k)
20+
}
21+
return l, nil
22+
}
23+
func (m mockInstallationStore) Store(installation *store.Installation) error { return nil }
24+
func (m mockInstallationStore) Read(installationName string) (*store.Installation, error) {
25+
return m.installations[installationName], nil
26+
}
27+
func (m mockInstallationStore) Delete(installationName string) error { return nil }
28+
29+
type stubServiceFetcher struct{}
30+
31+
func (s stubServiceFetcher) getServices(*store.Installation) (appServices, error) {
32+
return map[string]ServiceStatus{"service1": {DesiredTasks: 1, RunningTasks: 1}}, nil
33+
}
34+
1335
func TestGetInstallationsSorted(t *testing.T) {
14-
tmpDir := fs.NewDir(t, "")
15-
defer tmpDir.Remove()
16-
appstore, err := store.NewApplicationStore(tmpDir.Path())
17-
assert.NilError(t, err)
18-
installationStore, err := appstore.InstallationStore("my-context")
19-
assert.NilError(t, err)
2036
now := time.Now()
21-
2237
oldInstallation := &store.Installation{
2338
Claim: claim.Claim{
2439
Name: "old-installation",
@@ -31,13 +46,48 @@ func TestGetInstallationsSorted(t *testing.T) {
3146
Modified: now,
3247
},
3348
}
34-
assert.NilError(t, installationStore.Store(newInstallation))
35-
assert.NilError(t, installationStore.Store(oldInstallation))
36-
37-
installations, err := getInstallations("my-context", tmpDir.Path())
49+
installationStore := mockInstallationStore{installations: map[string]*store.Installation{"old-installation": oldInstallation, "new-installation": newInstallation}}
50+
installations, err := getInstallations(installationStore, &stubServiceFetcher{})
3851
assert.NilError(t, err)
3952
assert.Equal(t, len(installations), 2)
4053
// First installation is the last modified
4154
assert.Equal(t, installations[0].Name, "new-installation")
55+
assert.Equal(t, installations[0].Services["service1"].DesiredTasks, 1)
4256
assert.Equal(t, installations[1].Name, "old-installation")
57+
assert.Equal(t, installations[1].Services["service1"].RunningTasks, 1)
58+
}
59+
60+
func TestPrintServices(t *testing.T) {
61+
testCases := []struct {
62+
name string
63+
installation Installation
64+
expected string
65+
}{
66+
{
67+
"Failed installation",
68+
Installation{},
69+
"N/A",
70+
},
71+
{
72+
"Non running service",
73+
Installation{Services: map[string]ServiceStatus{
74+
"service1": {DesiredTasks: 1, RunningTasks: 0},
75+
}},
76+
"0/1",
77+
},
78+
{
79+
"Mixed running services and non running",
80+
Installation{Services: map[string]ServiceStatus{
81+
"service1": {DesiredTasks: 1, RunningTasks: 0},
82+
"service2": {DesiredTasks: 5, RunningTasks: 1},
83+
}},
84+
"1/2",
85+
},
86+
}
87+
for _, testCase := range testCases {
88+
t.Run(testCase.name, func(t *testing.T) {
89+
output := printServices(testCase.installation)
90+
assert.Equal(t, testCase.expected, output)
91+
})
92+
}
4393
}

0 commit comments

Comments
 (0)