Repository: buildpack/pack
Pull request #397 (2019-11-26 15:40:44)

Include processes in inspect-image output when available
When using lifecycle version 0.6.0 or greater to build an image, the output of `pack inspect-image` will now include a list of processes defined by a buildpack. The output will indicate which process, if any, is the default.

Calls to `pack.Client`'s `InspectImage` command will include an additional `Processes` element of `ProcessDetails` including a `DefaultProcess` if there is one, as well as a slice of `OtherProcesses`.


Patch:
diff --git a/go.mod b/go.mod
index cbeca02e..ba557fa9 100644
--- a/go.mod
+++ b/go.mod
@@ -16,7 +16,7 @@ require (
 	github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e
 	github.com/onsi/gomega v1.7.0
 	github.com/pkg/errors v0.8.1
-	github.com/sclevine/spec v1.2.0
+	github.com/sclevine/spec v1.3.0
 	github.com/spf13/cobra v0.0.5
 )
 
diff --git a/go.sum b/go.sum
index c6eb4be3..ac2edde7 100644
--- a/go.sum
+++ b/go.sum
@@ -150,6 +150,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR
 github.com/sclevine/spec v1.0.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
 github.com/sclevine/spec v1.2.0 h1:1Jwdf9jSfDl9NVmt8ndHqbTZ7XCCPbh1jI3hkDBHVYA=
 github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
+github.com/sclevine/spec v1.3.0 h1:iTB51CYlnju5oRh0/l67fg1+RlQ2nqmFecwdvN+5TrI=
+github.com/sclevine/spec v1.3.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/sirupsen/logrus v1.1.1/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
diff --git a/inspect_image.go b/inspect_image.go
index d61f3799..0d1c49ee 100644
--- a/inspect_image.go
+++ b/inspect_image.go
@@ -17,6 +17,12 @@ type ImageInfo struct {
 	Base       lifecycle.RunImageMetadata
 	BOM        []lifecycle.BOMEntry
 	Stack      lifecycle.StackMetadata
+	Processes  ProcessDetails
+}
+
+type ProcessDetails struct {
+	DefaultProcess *lifecycle.Process
+	OtherProcesses []lifecycle.Process
 }
 
 func (c *Client) InspectImage(name string, daemon bool) (*ImageInfo, error) {
@@ -50,11 +56,27 @@ func (c *Client) InspectImage(name string, daemon bool) (*ImageInfo, error) {
 		return nil, err
 	}
 
+	defaultProcessType, err := img.Env("CNB_PROCESS_TYPE")
+	if err != nil || defaultProcessType == "" {
+		defaultProcessType = "web"
+	}
+
+	var processDetails ProcessDetails
+	for _, proc := range buildMD.Processes {
+		proc := proc
+		if proc.Type == defaultProcessType {
+			processDetails.DefaultProcess = &proc
+			continue
+		}
+		processDetails.OtherProcesses = append(processDetails.OtherProcesses, proc)
+	}
+
 	return &ImageInfo{
 		StackID:    stackID,
 		Stack:      layersMd.Stack,
 		Base:       layersMd.RunImage,
 		BOM:        buildMD.BOM,
 		Buildpacks: buildMD.Buildpacks,
+		Processes:  processDetails,
 	}, nil
 }
diff --git a/internal/commands/inspect_image.go b/internal/commands/inspect_image.go
index cbbfc41b..b3abdbc5 100644
--- a/internal/commands/inspect_image.go
+++ b/internal/commands/inspect_image.go
@@ -3,12 +3,16 @@ package commands
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
+	"strings"
 	"text/tabwriter"
 	"text/template"
 
 	"github.com/pkg/errors"
 	"github.com/spf13/cobra"
 
+	"github.com/buildpack/lifecycle"
+
 	"github.com/buildpack/pack"
 	"github.com/buildpack/pack/internal/config"
 	"github.com/buildpack/pack/internal/style"
@@ -76,6 +80,7 @@ func logBOM(remote *pack.ImageInfo, local *pack.ImageInfo, logger logging.Logger
 func logDetails(remote *pack.ImageInfo, local *pack.ImageInfo, cfg config.Config, logger logging.Logger) {
 	imgTpl := template.Must(template.New("runImages").Parse(runImagesTemplate))
 	imgTpl = template.Must(imgTpl.New("buildpacks").Parse(buildpacksTemplate))
+	imgTpl = template.Must(imgTpl.New("processes").Parse(processesTemplate))
 	imgTpl = template.Must(imgTpl.New("image").Parse(imageTemplate))
 	remoteOutput, err := inspectImageOutput(remote, cfg, imgTpl)
 	if err != nil {
@@ -91,6 +96,13 @@ func logDetails(remote *pack.ImageInfo, local *pack.ImageInfo, cfg config.Config
 	}
 }
 
+type process struct {
+	PType   string
+	Shell   string
+	Command string
+	Args    string
+}
+
 func inspectImageOutput(
 	info *pack.ImageInfo,
 	cfg config.Config,
@@ -101,20 +113,56 @@ func inspectImageOutput(
 	}
 	var buf bytes.Buffer
 	localMirrors := getLocalMirrors(info.Stack.RunImage.Image, cfg)
+	processes := displayProcesses(info.Processes)
 	tw := tabwriter.NewWriter(&buf, 0, 0, 8, ' ', 0)
 	defer tw.Flush()
 	if err := tpl.Execute(tw, &struct {
 		Info         *pack.ImageInfo
 		LocalMirrors []string
+		Processes    []process
 	}{
 		info,
 		localMirrors,
+		processes,
 	}); err != nil {
 		return "", err
 	}
 	return buf.String(), nil
 }
 
+func displayProcess(p lifecycle.Process, d bool) process {
+	shell := ""
+	if !p.Direct {
+		shell = "bash"
+	}
+
+	pType := p.Type
+	if d {
+		pType = fmt.Sprintf("%s (default)", pType)
+	}
+
+	return process{
+		PType:   pType,
+		Shell:   shell,
+		Command: p.Command,
+		Args:    strings.Join(p.Args, " "),
+	}
+}
+
+func displayProcesses(sourceProcesses pack.ProcessDetails) []process {
+	processes := []process{}
+
+	if sourceProcesses.DefaultProcess != nil {
+		processes = append(processes, displayProcess(*sourceProcesses.DefaultProcess, true))
+	}
+
+	for _, p := range sourceProcesses.OtherProcesses {
+		processes = append(processes, displayProcess(p, false))
+	}
+
+	return processes
+}
+
 var runImagesTemplate = `
 Run Images:
 {{- range $_, $m := .LocalMirrors }}
@@ -138,8 +186,17 @@ Buildpacks:
 {{- end }}
 {{- else }}
   (buildpacks metadata not present)
+{{- end }}`
+
+var processesTemplate = `
+{{- if .Processes }}
+
+Processes:
+  TYPE	SHELL	COMMAND	ARGS
+{{- range $_, $p := .Processes }}
+  {{ $p.PType }}	{{ $p.Shell }}	{{ $p.Command }}	{{ $p.Args }}
 {{- end }}
-`
+{{- end }}`
 
 var imageTemplate = `
 Stack: {{ .Info.StackID }}
@@ -150,5 +207,6 @@ Base Image:
 {{- end}}
   Top Layer: {{ .Info.Base.TopLayer }}
 {{ template "runImages" . }}
-{{ template "buildpacks" . }}
+{{ template "buildpacks" . }}{{ template "processes" . }}
+
 `


Test patch:
diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
index 683200f8..7a26be04 100644
--- a/acceptance/acceptance_test.go
+++ b/acceptance/acceptance_test.go
@@ -362,6 +362,12 @@ func testAcceptance(t *testing.T, when spec.G, it spec.S, packFixturesDir, packP
 							if _, err := os.Stat(outputTemplate); err != nil {
 								t.Fatal(err.Error())
 							}
+							type process struct {
+								shell       string
+								processType string
+								command     string
+								args        []string
+							}
 							expectedOutput := fillTemplate(t, outputTemplate,
 								map[string]interface{}{
 									"image_name":             repoName,
@@ -370,6 +376,7 @@ func testAcceptance(t *testing.T, when spec.G, it spec.S, packFixturesDir, packP
 									"run_image_local_mirror": localRunImageMirror,
 									"run_image_mirror":       runImageMirror,
 									"show_reference":         !lifecycleDescriptor.Info.Version.LessThan(semver.MustParse("0.5.0")),
+									"show_processes":         !lifecycleDescriptor.Info.Version.LessThan(semver.MustParse("0.6.0")),
 								},
 							)
 							h.AssertEq(t, output, expectedOutput)
@@ -721,6 +728,7 @@ func testAcceptance(t *testing.T, when spec.G, it spec.S, packFixturesDir, packP
 										"base_image_top_layer": h.TopLayerDiffID(t, runImageMirror),
 										"run_image_mirror":     runImageMirror,
 										"show_reference":       !lifecycleDescriptor.Info.Version.LessThan(semver.MustParse("0.5.0")),
+										"show_processes":       !lifecycleDescriptor.Info.Version.LessThan(semver.MustParse("0.6.0")),
 									},
 								)
 								h.AssertEq(t, output, expectedOutput)
diff --git a/acceptance/testdata/mock_app/run b/acceptance/testdata/mock_app/run
index 56b221e8..d4f6530b 100755
--- a/acceptance/testdata/mock_app/run
+++ b/acceptance/testdata/mock_app/run
@@ -2,9 +2,11 @@
 
 set -x
 
+port="${1-8080}"
+
 echo "listening on port 8080"
 
 resp=$(echo "HTTP/1.1 200 OK\n" && cat "$PWD"/*-dep /contents*.txt)
 while true; do
-  nc -l -p 8080 -c "echo \"$resp\""
-done
\ No newline at end of file
+  nc -l -p "$port" -c "echo \"$resp\""
+done
diff --git a/acceptance/testdata/mock_buildpacks/0.2/simple-layers-buildpack/bin/build b/acceptance/testdata/mock_buildpacks/0.2/simple-layers-buildpack/bin/build
index 3f4475e6..cb1ba08b 100755
--- a/acceptance/testdata/mock_buildpacks/0.2/simple-layers-buildpack/bin/build
+++ b/acceptance/testdata/mock_buildpacks/0.2/simple-layers-buildpack/bin/build
@@ -29,6 +29,17 @@ else
 fi
 
 ## adds a process
-echo 'processes = [{ type = "web", command = "./run"}]' > "$launch_dir/launch.toml"
+cat <<EOF > "$launch_dir/launch.toml"
+[[processes]]
+  type = "web"
+  command = "./run"
+  args = ["8080"]
+
+[[processes]]
+  type = "hello"
+  command = "echo"
+  args = ["hello", "world"]
+  direct = true
+EOF
 
 echo "---> Done"
diff --git a/acceptance/testdata/pack_current/inspect_image_local_output.txt b/acceptance/testdata/pack_current/inspect_image_local_output.txt
index be38bf6a..5b235e0c 100644
--- a/acceptance/testdata/pack_current/inspect_image_local_output.txt
+++ b/acceptance/testdata/pack_current/inspect_image_local_output.txt
@@ -21,4 +21,11 @@ Run Images:
 Buildpacks:
   ID                   VERSION
   simple/layers        simple-layers-version
+{{- if .show_processes}}
+
+Processes:
+  TYPE                 SHELL        COMMAND        ARGS
+  web (default)        bash         ./run          8080
+  hello                             echo           hello world
+{{- end }}
 
diff --git a/acceptance/testdata/pack_current/inspect_image_published_output.txt b/acceptance/testdata/pack_current/inspect_image_published_output.txt
index 7512b509..c37989c0 100644
--- a/acceptance/testdata/pack_current/inspect_image_published_output.txt
+++ b/acceptance/testdata/pack_current/inspect_image_published_output.txt
@@ -17,6 +17,13 @@ Run Images:
 Buildpacks:
   ID                   VERSION
   simple/layers        simple-layers-version
+{{- if .show_processes}}
+
+Processes:
+  TYPE                 SHELL        COMMAND        ARGS
+  web (default)        bash         ./run          8080
+  hello                             echo           hello world
+{{- end }}
 
 
 LOCAL:
@@ -36,4 +43,11 @@ Run Images:
 Buildpacks:
   ID                   VERSION
   simple/layers        simple-layers-version
+{{- if .show_processes}}
+
+Processes:
+  TYPE                 SHELL        COMMAND        ARGS
+  web (default)        bash         ./run          8080
+  hello                             echo           hello world
+{{- end }}
 
diff --git a/inspect_image_test.go b/inspect_image_test.go
index 4e625d55..8fb3f2bb 100644
--- a/inspect_image_test.go
+++ b/inspect_image_test.go
@@ -82,6 +82,20 @@ func testInspectImage(t *testing.T, when spec.G, it spec.S) {
       "version": "other-version"
     }
   ],
+  "processes": [
+    {
+      "type": "other-process",
+      "command": "/other/process",
+      "args": ["opt", "1"],
+      "direct": true
+    },
+    {
+      "type": "web",
+      "command": "/start/web-process",
+      "args": ["-p", "1234"],
+      "direct": false
+    }
+  ],
   "launcher": {
     "version": "0.5.0"
   }
@@ -157,6 +171,124 @@ func testInspectImage(t *testing.T, when spec.G, it spec.S) {
 					h.AssertEq(t, info.Buildpacks[1].ID, "other-buildpack")
 					h.AssertEq(t, info.Buildpacks[1].Version, "other-version")
 				})
+
+				it("returns the processes setting the web process as default", func() {
+					info, err := subject.InspectImage("some/image", useDaemon)
+					h.AssertNil(t, err)
+
+					h.AssertEq(t, info.Processes,
+						ProcessDetails{
+							DefaultProcess: &lifecycle.Process{
+								Type:    "web",
+								Command: "/start/web-process",
+								Args:    []string{"-p", "1234"},
+								Direct:  false,
+							},
+							OtherProcesses: []lifecycle.Process{
+								{
+									Type:    "other-process",
+									Command: "/other/process",
+									Args:    []string{"opt", "1"},
+									Direct:  true,
+								},
+							},
+						},
+					)
+				})
+
+				when("CNB_PROCESS_TYPE is set", func() {
+					it.Before(func() {
+						h.AssertNil(t, fakeImage.SetEnv("CNB_PROCESS_TYPE", "other-process"))
+					})
+
+					it("returns processes setting the correct default process", func() {
+						info, err := subject.InspectImage("some/image", useDaemon)
+						h.AssertNil(t, err)
+
+						h.AssertEq(t, info.Processes,
+							ProcessDetails{
+								DefaultProcess: &lifecycle.Process{
+									Type:    "other-process",
+									Command: "/other/process",
+									Args:    []string{"opt", "1"},
+									Direct:  true,
+								},
+								OtherProcesses: []lifecycle.Process{
+									{
+										Type:    "web",
+										Command: "/start/web-process",
+										Args:    []string{"-p", "1234"},
+										Direct:  false,
+									},
+								},
+							},
+						)
+					})
+				})
+
+				when("CNB_PROCESS_TYPE is set, but doesn't match an existing process", func() {
+					it.Before(func() {
+						h.AssertNil(t, fakeImage.SetEnv("CNB_PROCESS_TYPE", "missing-process"))
+					})
+
+					it("returns a nil default process", func() {
+						info, err := subject.InspectImage("some/image", useDaemon)
+						h.AssertNil(t, err)
+
+						h.AssertEq(t, info.Processes,
+							ProcessDetails{
+								DefaultProcess: nil,
+								OtherProcesses: []lifecycle.Process{
+									{
+										Type:    "other-process",
+										Command: "/other/process",
+										Args:    []string{"opt", "1"},
+										Direct:  true,
+									},
+									{
+										Type:    "web",
+										Command: "/start/web-process",
+										Args:    []string{"-p", "1234"},
+										Direct:  false,
+									},
+								},
+							},
+						)
+					})
+				})
+
+				it("returns a nil default process when CNB_PROCESS_TYPE is not set and there is no web process", func() {
+					h.AssertNil(t, fakeImage.SetLabel(
+						"io.buildpacks.build.metadata",
+						`{
+  "processes": [
+    {
+      "type": "other-process",
+      "command": "/other/process",
+      "args": ["opt", "1"],
+      "direct": true
+    }
+  ]
+}`,
+					))
+
+					info, err := subject.InspectImage("some/image", useDaemon)
+					h.AssertNil(t, err)
+
+					h.AssertEq(t, info.Processes,
+						ProcessDetails{
+							DefaultProcess: nil,
+							OtherProcesses: []lifecycle.Process{
+								{
+									Type:    "other-process",
+									Command: "/other/process",
+									Args:    []string{"opt", "1"},
+									Direct:  true,
+								},
+							},
+						},
+					)
+				})
 			})
 		}
 	})
diff --git a/internal/commands/inspect_image_test.go b/internal/commands/inspect_image_test.go
index d3793849..33c44c2e 100644
--- a/internal/commands/inspect_image_test.go
+++ b/internal/commands/inspect_image_test.go
@@ -139,6 +139,22 @@ func testInspectImageCommand(t *testing.T, when spec.G, it spec.S) {
 						},
 						Buildpack: lifecycle.Buildpack{ID: "test.bp.one.remote", Version: "1.0.0"},
 					}},
+					Processes: pack.ProcessDetails{
+						DefaultProcess: &lifecycle.Process{
+							Type:    "some-remote-type",
+							Command: "/some/remote command",
+							Args:    []string{"some", "remote", "args"},
+							Direct:  false,
+						},
+						OtherProcesses: []lifecycle.Process{
+							{
+								Type:    "other-remote-type",
+								Command: "/other/remote/command",
+								Args:    []string{"other", "remote", "args"},
+								Direct:  true,
+							},
+						},
+					},
 				}
 				localInfo = &pack.ImageInfo{
 					StackID: "test.stack.id.local",
@@ -169,6 +185,22 @@ func testInspectImageCommand(t *testing.T, when spec.G, it spec.S) {
 						},
 						Buildpack: lifecycle.Buildpack{ID: "test.bp.one.remote", Version: "1.0.0"},
 					}},
+					Processes: pack.ProcessDetails{
+						DefaultProcess: &lifecycle.Process{
+							Type:    "some-local-type",
+							Command: "/some/local command",
+							Args:    []string{"some", "local", "args"},
+							Direct:  false,
+						},
+						OtherProcesses: []lifecycle.Process{
+							{
+								Type:    "other-local-type",
+								Command: "/other/local/command",
+								Args:    []string{"other", "local", "args"},
+								Direct:  true,
+							},
+						},
+					},
 				}
 				mockClient.EXPECT().InspectImage("some/image", false).Return(remoteInfo, nil)
 				mockClient.EXPECT().InspectImage("some/image", true).Return(localInfo, nil)
@@ -215,6 +247,35 @@ func testInspectImageCommand(t *testing.T, when spec.G, it spec.S) {
   test.bp.two.local\s+2.0.0`)
 				})
 
+				it("displays process info for local and remote", func() {
+					h.AssertNil(t, command.Execute())
+					h.AssertContainsMatch(t, remoteOutput(outBuf), `Processes:
+  TYPE\s+SHELL\s+COMMAND\s+ARGS
+  some-remote-type \(default\)\s+bash\s+/some/remote command\s+some remote args
+  other-remote-type\s+/other/remote/command\s+other remote args`)
+					h.AssertContainsMatch(t, localOutput(outBuf), `Processes:
+  TYPE\s+SHELL\s+COMMAND\s+ARGS
+  some-local-type \(default\)\s+bash\s+/some/local command\s+some local args
+  other-local-type\s+/other/local/command\s+other local args`)
+				})
+
+				when("there are no default processes", func() {
+					it.Before(func() {
+						remoteInfo.Processes.DefaultProcess = nil
+						localInfo.Processes.DefaultProcess = nil
+					})
+
+					it("displays all local and remote processes with no default label", func() {
+						h.AssertNil(t, command.Execute())
+						h.AssertContainsMatch(t, remoteOutput(outBuf), `Processes:
+  TYPE\s+SHELL\s+COMMAND\s+ARGS
+  other-remote-type\s+/other/remote/command\s+other remote args`)
+						h.AssertContainsMatch(t, localOutput(outBuf), `Processes:
+  TYPE\s+SHELL\s+COMMAND\s+ARGS
+  other-local-type\s+/other/local/command\s+other local args`)
+					})
+				})
+
 				when("--bom", func() {
 					it("prints the bom as JSON", func() {
 						command.SetArgs([]string{"some/image", "--bom"})