diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 237e5c685..c369c528b 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "io/ioutil" @@ -21,19 +22,21 @@ import ( pubcfg "github.com/buildpacks/pack/config" - "github.com/buildpacks/pack/acceptance/buildpacks" - - "github.com/docker/docker/pkg/stdcopy" - "github.com/google/go-containerregistry/pkg/name" + "github.com/ghodss/yaml" + "github.com/pelletier/go-toml" dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" + "github.com/google/go-containerregistry/pkg/name" "github.com/pkg/errors" "github.com/sclevine/spec" "github.com/sclevine/spec/report" + "github.com/buildpacks/pack/acceptance/buildpacks" + "github.com/buildpacks/pack/acceptance/assertions" "github.com/buildpacks/pack/acceptance/config" "github.com/buildpacks/pack/acceptance/invoke" @@ -1803,7 +1806,12 @@ include = [ "*.jar", "media/mountain.jpg", "media/person.png" ] ) assert.Equal(output, "Run Image 'pack-test/run' configured with mirror 'some-registry.com/pack-test/run1'\n") - output = pack.RunSuccessfully("inspect-builder", "--depth", "2", builderName) + depth := "2" + if pack.SupportsFeature(invoke.InspectBuilderOutputFormat) { + depth = "1" // The meaning of depth was changed + } + + output = pack.RunSuccessfully("inspect-builder", "--depth", depth, builderName) deprecatedBuildpackAPIs, supportedBuildpackAPIs, @@ -1833,6 +1841,136 @@ include = [ "*.jar", "media/mountain.jpg", "media/person.png" ] assert.TrimmedEq(output, expectedOutput) }) + + when("output format is toml", func() { + it("prints builder information in toml format", func() { + h.SkipUnless(t, + pack.SupportsFeature(invoke.InspectBuilderOutputFormat), + "inspect-builder output format is not yet implemented", + ) + + output := pack.RunSuccessfully( + "set-run-image-mirrors", "pack-test/run", "--mirror", "some-registry.com/pack-test/run1", + ) + assert.Equal(output, "Run Image 'pack-test/run' configured with mirror 'some-registry.com/pack-test/run1'\n") + + output = pack.RunSuccessfully("inspect-builder", builderName, "--output", "toml") + + err := toml.NewDecoder(strings.NewReader(string(output))).Decode(&struct{}{}) + assert.Nil(err) + + deprecatedBuildpackAPIs, + supportedBuildpackAPIs, + deprecatedPlatformAPIs, + supportedPlatformAPIs := lifecycle.TOMLOutputForAPIs() + + expectedOutput := pack.FixtureManager().TemplateVersionedFixture( + "inspect_%s_builder_nested_output_toml.txt", + createBuilderPack.Version(), + "inspect_builder_nested_output_toml.txt", + map[string]interface{}{ + "builder_name": builderName, + "lifecycle_version": lifecycle.Version(), + "deprecated_buildpack_apis": deprecatedBuildpackAPIs, + "supported_buildpack_apis": supportedBuildpackAPIs, + "deprecated_platform_apis": deprecatedPlatformAPIs, + "supported_platform_apis": supportedPlatformAPIs, + "run_image_mirror": runImageMirror, + "pack_version": createBuilderPack.Version(), + }, + ) + + assert.TrimmedEq(string(output), expectedOutput) + }) + }) + + when("output format is yaml", func() { + it("prints builder information in yaml format", func() { + h.SkipUnless(t, + pack.SupportsFeature(invoke.InspectBuilderOutputFormat), + "inspect-builder output format is not yet implemented", + ) + + output := pack.RunSuccessfully( + "set-run-image-mirrors", "pack-test/run", "--mirror", "some-registry.com/pack-test/run1", + ) + assert.Equal(output, "Run Image 'pack-test/run' configured with mirror 'some-registry.com/pack-test/run1'\n") + + output = pack.RunSuccessfully("inspect-builder", builderName, "--output", "yaml") + + err := yaml.Unmarshal([]byte(output), &struct{}{}) + assert.Nil(err) + + deprecatedBuildpackAPIs, + supportedBuildpackAPIs, + deprecatedPlatformAPIs, + supportedPlatformAPIs := lifecycle.YAMLOutputForAPIs(14) + + expectedOutput := pack.FixtureManager().TemplateVersionedFixture( + "inspect_%s_builder_nested_output_yaml.txt", + createBuilderPack.Version(), + "inspect_builder_nested_output_yaml.txt", + map[string]interface{}{ + "builder_name": builderName, + "lifecycle_version": lifecycle.Version(), + "deprecated_buildpack_apis": deprecatedBuildpackAPIs, + "supported_buildpack_apis": supportedBuildpackAPIs, + "deprecated_platform_apis": deprecatedPlatformAPIs, + "supported_platform_apis": supportedPlatformAPIs, + "run_image_mirror": runImageMirror, + "pack_version": createBuilderPack.Version(), + }, + ) + + assert.TrimmedEq(string(output), expectedOutput) + }) + }) + + when("output format is json", func() { + it("prints builder information in json format", func() { + h.SkipUnless(t, + pack.SupportsFeature(invoke.InspectBuilderOutputFormat), + "inspect-builder output format is not yet implemented", + ) + + output := pack.RunSuccessfully( + "set-run-image-mirrors", "pack-test/run", "--mirror", "some-registry.com/pack-test/run1", + ) + assert.Equal(output, "Run Image 'pack-test/run' configured with mirror 'some-registry.com/pack-test/run1'\n") + + output = pack.RunSuccessfully("inspect-builder", builderName, "--output", "json") + + err := json.Unmarshal([]byte(output), &struct{}{}) + assert.Nil(err) + + var prettifiedOutput bytes.Buffer + err = json.Indent(&prettifiedOutput, []byte(output), "", " ") + assert.Nil(err) + + deprecatedBuildpackAPIs, + supportedBuildpackAPIs, + deprecatedPlatformAPIs, + supportedPlatformAPIs := lifecycle.JSONOutputForAPIs(8) + + expectedOutput := pack.FixtureManager().TemplateVersionedFixture( + "inspect_%s_builder_nested_output_json.txt", + createBuilderPack.Version(), + "inspect_builder_nested_output_json.txt", + map[string]interface{}{ + "builder_name": builderName, + "lifecycle_version": lifecycle.Version(), + "deprecated_buildpack_apis": deprecatedBuildpackAPIs, + "supported_buildpack_apis": supportedBuildpackAPIs, + "deprecated_platform_apis": deprecatedPlatformAPIs, + "supported_platform_apis": supportedPlatformAPIs, + "run_image_mirror": runImageMirror, + "pack_version": createBuilderPack.Version(), + }, + ) + + assert.Equal(prettifiedOutput.String(), expectedOutput) + }) + }) }) it("displays configuration for a builder (local and remote)", func() { diff --git a/acceptance/config/lifecycle_asset.go b/acceptance/config/lifecycle_asset.go index 3719b9eb8..2f4c24162 100644 --- a/acceptance/config/lifecycle_asset.go +++ b/acceptance/config/lifecycle_asset.go @@ -3,6 +3,7 @@ package config import ( + "fmt" "strings" "github.com/Masterminds/semver" @@ -85,6 +86,92 @@ func (l *LifecycleAsset) OutputForAPIs() (deprecatedBuildpackAPIs, supportedBuil stringify(l.descriptor.APIs.Platform.Supported) } +func (l *LifecycleAsset) TOMLOutputForAPIs() (deprecatedBuildpacksAPIs, + supportedBuildpacksAPIs, + deprectatedPlatformAPIs, + supportedPlatformAPIS string, +) { + stringify := func(apiSet builder.APISet) string { + if len(apiSet) < 1 || apiSet == nil { + return "[]" + } + + var quotedAPIs []string + for _, a := range apiSet { + quotedAPIs = append(quotedAPIs, fmt.Sprintf("%q", a)) + } + + return fmt.Sprintf("[%s]", strings.Join(quotedAPIs, ", ")) + } + + return stringify(l.descriptor.APIs.Buildpack.Deprecated), + stringify(l.descriptor.APIs.Buildpack.Supported), + stringify(l.descriptor.APIs.Platform.Deprecated), + stringify(l.descriptor.APIs.Platform.Supported) +} + +func (l *LifecycleAsset) YAMLOutputForAPIs(baseIndentationWidth int) (deprecatedBuildpacksAPIs, + supportedBuildpacksAPIs, + deprectatedPlatformAPIs, + supportedPlatformAPIS string, +) { + stringify := func(apiSet builder.APISet, baseIndentationWidth int) string { + if len(apiSet) < 1 || apiSet == nil { + return "[]" + } + + apiIndentation := strings.Repeat(" ", baseIndentationWidth+2) + + var quotedAPIs []string + for _, a := range apiSet { + quotedAPIs = append(quotedAPIs, fmt.Sprintf(`%s- %q`, apiIndentation, a)) + } + + return fmt.Sprintf(` +%s`, strings.Join(quotedAPIs, "\n")) + } + + return stringify(l.descriptor.APIs.Buildpack.Deprecated, baseIndentationWidth), + stringify(l.descriptor.APIs.Buildpack.Supported, baseIndentationWidth), + stringify(l.descriptor.APIs.Platform.Deprecated, baseIndentationWidth), + stringify(l.descriptor.APIs.Platform.Supported, baseIndentationWidth) +} + +func (l *LifecycleAsset) JSONOutputForAPIs(baseIndentationWidth int) ( + deprecatedBuildpacksAPIs, + supportedBuildpacksAPIs, + deprectatedPlatformAPIs, + supportedPlatformAPIS string, +) { + stringify := func(apiSet builder.APISet, baseIndentationWidth int) string { + if len(apiSet) < 1 { + if apiSet == nil { + return "null" + } + return "[]" + } + + apiIndentation := strings.Repeat(" ", baseIndentationWidth+2) + + var quotedAPIs []string + for _, a := range apiSet { + quotedAPIs = append(quotedAPIs, fmt.Sprintf(`%s%q`, apiIndentation, a)) + } + + lineEndSeparator := `, +` + + return fmt.Sprintf(`[ +%s +%s]`, strings.Join(quotedAPIs, lineEndSeparator), strings.Repeat(" ", baseIndentationWidth)) + } + + return stringify(l.descriptor.APIs.Buildpack.Deprecated, baseIndentationWidth), + stringify(l.descriptor.APIs.Buildpack.Supported, baseIndentationWidth), + stringify(l.descriptor.APIs.Platform.Deprecated, baseIndentationWidth), + stringify(l.descriptor.APIs.Platform.Supported, baseIndentationWidth) +} + type LifecycleFeature int const ( diff --git a/acceptance/invoke/pack.go b/acceptance/invoke/pack.go index ec5c49f6a..7ecb94c9a 100644 --- a/acceptance/invoke/pack.go +++ b/acceptance/invoke/pack.go @@ -221,6 +221,7 @@ const ( ReadWriteVolumeMounts NoColorInBuildpacks QuietMode + InspectBuilderOutputFormat ) var featureTests = map[Feature]func(i *PackInvoker) bool{ @@ -242,6 +243,9 @@ var featureTests = map[Feature]func(i *PackInvoker) bool{ QuietMode: func(i *PackInvoker) bool { return i.atLeast("0.13.2") }, + InspectBuilderOutputFormat: func(i *PackInvoker) bool { + return i.laterThan("0.14.2") + }, } func (i *PackInvoker) SupportsFeature(f Feature) bool { diff --git a/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_json.txt b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_json.txt new file mode 100644 index 000000000..445593eab --- /dev/null +++ b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_json.txt @@ -0,0 +1,187 @@ +{ + "builder_name": "{{.builder_name}}", + "trusted": false, + "default": false, + "remote_info": { + "created_by": { + "name": "Pack CLI", + "version": "{{.pack_version}}" + }, + "stack": { + "id": "pack.test.stack", + "mixins": [ + "mixinA", + "netcat", + "mixin3", + "build:mixinTwo" + ] + }, + "lifecycle": { + "version": "{{.lifecycle_version}}", + "buildpack_apis": { + "deprecated": {{.deprecated_buildpack_apis}}, + "supported": {{.supported_buildpack_apis}} + }, + "platform_apis": { + "deprecated": {{.deprecated_platform_apis}}, + "supported": {{.supported_platform_apis}} + } + }, + "run_images": [ + { + "name": "some-registry.com/pack-test/run1", + "user_configured": true + }, + { + "name": "pack-test/run" + }, + { + "name": "{{.run_image_mirror}}" + } + ], + "buildpacks": [ + { + "id": "read/env", + "version": "read-env-version" + }, + { + "id": "noop.buildpack", + "version": "noop.buildpack.version" + }, + { + "id": "noop.buildpack", + "version": "noop.buildpack.later-version", + "homepage": "http://geocities.com/cool-bp" + }, + { + "id": "simple/layers", + "version": "simple-layers-version" + }, + { + "id": "simple/nested-level-2", + "version": "nested-l2-version" + }, + { + "id": "simple/nested-level-1", + "version": "nested-l1-version" + } + ], + "detection_order": [ + { + "buildpacks": [ + { + "id": "simple/nested-level-1", + "buildpacks": [ + { + "id": "simple/nested-level-2", + "version": "nested-l2-version", + "buildpacks": [ + { + "id": "simple/layers", + "version": "simple-layers-version" + } + ] + } + ] + }, + { + "id": "read/env", + "version": "read-env-version", + "optional": true + } + ] + } + ] + }, + "local_info": { + "created_by": { + "name": "Pack CLI", + "version": "{{.pack_version}}" + }, + "stack": { + "id": "pack.test.stack", + "mixins": [ + "mixinA", + "netcat", + "mixin3", + "build:mixinTwo" + ] + }, + "lifecycle": { + "version": "{{.lifecycle_version}}", + "buildpack_apis": { + "deprecated": {{.deprecated_buildpack_apis}}, + "supported": {{.supported_buildpack_apis}} + }, + "platform_apis": { + "deprecated": {{.deprecated_platform_apis}}, + "supported": {{.supported_platform_apis}} + } + }, + "run_images": [ + { + "name": "some-registry.com/pack-test/run1", + "user_configured": true + }, + { + "name": "pack-test/run" + }, + { + "name": "{{.run_image_mirror}}" + } + ], + "buildpacks": [ + { + "id": "read/env", + "version": "read-env-version" + }, + { + "id": "noop.buildpack", + "version": "noop.buildpack.version" + }, + { + "id": "noop.buildpack", + "version": "noop.buildpack.later-version", + "homepage": "http://geocities.com/cool-bp" + }, + { + "id": "simple/layers", + "version": "simple-layers-version" + }, + { + "id": "simple/nested-level-2", + "version": "nested-l2-version" + }, + { + "id": "simple/nested-level-1", + "version": "nested-l1-version" + } + ], + "detection_order": [ + { + "buildpacks": [ + { + "id": "simple/nested-level-1", + "buildpacks": [ + { + "id": "simple/nested-level-2", + "version": "nested-l2-version", + "buildpacks": [ + { + "id": "simple/layers", + "version": "simple-layers-version" + } + ] + } + ] + }, + { + "id": "read/env", + "version": "read-env-version", + "optional": true + } + ] + } + ] + } +} diff --git a/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_toml.txt b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_toml.txt new file mode 100644 index 000000000..89b18e165 --- /dev/null +++ b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_toml.txt @@ -0,0 +1,151 @@ +builder_name = "{{.builder_name}}" +trusted = false +default = false + +[remote_info] + + [remote_info.created_by] + Name = "Pack CLI" + Version = "{{.pack_version}}" + + [remote_info.stack] + id = "pack.test.stack" + mixins = ["mixinA", "netcat", "mixin3", "build:mixinTwo"] + + [remote_info.lifecycle] + version = "{{.lifecycle_version}}" + + [remote_info.lifecycle.buildpack_apis] + deprecated = {{.deprecated_buildpack_apis}} + supported = {{.supported_buildpack_apis}} + + [remote_info.lifecycle.platform_apis] + deprecated = {{.deprecated_platform_apis}} + supported = {{.supported_platform_apis}} + + [[remote_info.run_images]] + name = "some-registry.com/pack-test/run1" + user_configured = true + + [[remote_info.run_images]] + name = "pack-test/run" + + [[remote_info.run_images]] + name = "{{.run_image_mirror}}" + + [[remote_info.buildpacks]] + id = "read/env" + version = "read-env-version" + + [[remote_info.buildpacks]] + id = "noop.buildpack" + version = "noop.buildpack.version" + + [[remote_info.buildpacks]] + id = "noop.buildpack" + version = "noop.buildpack.later-version" + homepage = "http://geocities.com/cool-bp" + + [[remote_info.buildpacks]] + id = "simple/layers" + version = "simple-layers-version" + + [[remote_info.buildpacks]] + id = "simple/nested-level-2" + version = "nested-l2-version" + + [[remote_info.buildpacks]] + id = "simple/nested-level-1" + version = "nested-l1-version" + + [[remote_info.detection_order]] + + [[remote_info.detection_order.buildpacks]] + id = "simple/nested-level-1" + + [[remote_info.detection_order.buildpacks.buildpacks]] + id = "simple/nested-level-2" + version = "nested-l2-version" + + [[remote_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "simple/layers" + version = "simple-layers-version" + + [[remote_info.detection_order.buildpacks]] + id = "read/env" + version = "read-env-version" + optional = true + +[local_info] + + [local_info.created_by] + Name = "Pack CLI" + Version = "{{.pack_version}}" + + [local_info.stack] + id = "pack.test.stack" + mixins = ["mixinA", "netcat", "mixin3", "build:mixinTwo"] + + [local_info.lifecycle] + version = "{{.lifecycle_version}}" + + [local_info.lifecycle.buildpack_apis] + deprecated = {{.deprecated_buildpack_apis}} + supported = {{.supported_buildpack_apis}} + + [local_info.lifecycle.platform_apis] + deprecated = {{.deprecated_platform_apis}} + supported = {{.supported_platform_apis}} + + [[local_info.run_images]] + name = "some-registry.com/pack-test/run1" + user_configured = true + + [[local_info.run_images]] + name = "pack-test/run" + + [[local_info.run_images]] + name = "{{.run_image_mirror}}" + + [[local_info.buildpacks]] + id = "read/env" + version = "read-env-version" + + [[local_info.buildpacks]] + id = "noop.buildpack" + version = "noop.buildpack.version" + + [[local_info.buildpacks]] + id = "noop.buildpack" + version = "noop.buildpack.later-version" + homepage = "http://geocities.com/cool-bp" + + [[local_info.buildpacks]] + id = "simple/layers" + version = "simple-layers-version" + + [[local_info.buildpacks]] + id = "simple/nested-level-2" + version = "nested-l2-version" + + [[local_info.buildpacks]] + id = "simple/nested-level-1" + version = "nested-l1-version" + + [[local_info.detection_order]] + + [[local_info.detection_order.buildpacks]] + id = "simple/nested-level-1" + + [[local_info.detection_order.buildpacks.buildpacks]] + id = "simple/nested-level-2" + version = "nested-l2-version" + + [[local_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "simple/layers" + version = "simple-layers-version" + + [[local_info.detection_order.buildpacks]] + id = "read/env" + version = "read-env-version" + optional = true diff --git a/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_yaml.txt b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_yaml.txt new file mode 100644 index 000000000..c217c7373 --- /dev/null +++ b/acceptance/testdata/pack_fixtures/inspect_builder_nested_output_yaml.txt @@ -0,0 +1,104 @@ +sharedbuilderinfo: + builder_name: {{.builder_name}} + trusted: false + default: false +remote_info: + created_by: + name: Pack CLI + version: {{.pack_version}} + stack: + id: pack.test.stack + mixins: + - mixinA + - netcat + - mixin3 + - build:mixinTwo + lifecycle: + version: {{.lifecycle_version}} + buildpack_apis: + deprecated: {{.deprecated_buildpack_apis}} + supported: {{.supported_buildpack_apis}} + platform_apis: + deprecated: {{.deprecated_platform_apis}} + supported: {{.supported_platform_apis}} + run_images: + - name: some-registry.com/pack-test/run1 + user_configured: true + - name: pack-test/run + - name: {{.run_image_mirror}} + buildpacks: + - id: read/env + version: read-env-version + - id: noop.buildpack + version: noop.buildpack.version + - id: noop.buildpack + version: noop.buildpack.later-version + homepage: http://geocities.com/cool-bp + - id: simple/layers + version: simple-layers-version + - id: simple/nested-level-2 + version: nested-l2-version + - id: simple/nested-level-1 + version: nested-l1-version + detection_order: + - buildpacks: + - id: simple/nested-level-1 + buildpacks: + - id: simple/nested-level-2 + version: nested-l2-version + buildpacks: + - id: simple/layers + version: simple-layers-version + - id: read/env + version: read-env-version + optional: true +local_info: + created_by: + name: Pack CLI + version: {{.pack_version}} + stack: + id: pack.test.stack + mixins: + - mixinA + - netcat + - mixin3 + - build:mixinTwo + lifecycle: + version: {{.lifecycle_version}} + buildpack_apis: + deprecated: {{.deprecated_buildpack_apis}} + supported: {{.supported_buildpack_apis}} + platform_apis: + deprecated: {{.deprecated_platform_apis}} + supported: {{.supported_platform_apis}} + run_images: + - name: some-registry.com/pack-test/run1 + user_configured: true + - name: pack-test/run + - name: {{.run_image_mirror}} + buildpacks: + - id: read/env + version: read-env-version + - id: noop.buildpack + version: noop.buildpack.version + - id: noop.buildpack + version: noop.buildpack.later-version + homepage: http://geocities.com/cool-bp + - id: simple/layers + version: simple-layers-version + - id: simple/nested-level-2 + version: nested-l2-version + - id: simple/nested-level-1 + version: nested-l1-version + detection_order: + - buildpacks: + - id: simple/nested-level-1 + buildpacks: + - id: simple/nested-level-2 + version: nested-l2-version + buildpacks: + - id: simple/layers + version: simple-layers-version + - id: read/env + version: read-env-version + optional: true diff --git a/builder/detection_order.go b/builder/detection_order.go new file mode 100644 index 000000000..bc33d1ee3 --- /dev/null +++ b/builder/detection_order.go @@ -0,0 +1,18 @@ +package builder + +import ( + "github.com/buildpacks/pack/internal/dist" +) + +type DetectionOrderEntry struct { + dist.BuildpackRef `yaml:",inline"` + Cyclical bool `json:"cyclic,omitempty" yaml:"cyclic,omitempty" toml:"cyclic,omitempty"` + GroupDetectionOrder DetectionOrder `json:"buildpacks,omitempty" yaml:"buildpacks,omitempty" toml:"buildpacks,omitempty"` +} + +type DetectionOrder []DetectionOrderEntry + +const ( + OrderDetectionMaxDepth = -1 + OrderDetectionNone = 0 +) diff --git a/cmd/cmd.go b/cmd/cmd.go index 2a45774e8..cf9fb4df1 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -7,6 +7,7 @@ import ( "github.com/buildpacks/pack" "github.com/buildpacks/pack/buildpackage" + "github.com/buildpacks/pack/internal/builder/writer" "github.com/buildpacks/pack/internal/commands" "github.com/buildpacks/pack/internal/config" "github.com/buildpacks/pack/logging" @@ -69,7 +70,7 @@ func NewPackCommand(logger ConfigurableLogger) (*cobra.Command, error) { rootCmd.AddCommand(commands.SetRunImagesMirrors(logger, cfg)) rootCmd.AddCommand(commands.SetDefaultBuilder(logger, cfg, &packClient)) - rootCmd.AddCommand(commands.InspectBuilder(logger, cfg, &packClient)) + rootCmd.AddCommand(commands.InspectBuilder(logger, cfg, &packClient, writer.NewFactory())) rootCmd.AddCommand(commands.SuggestBuilders(logger, &packClient)) rootCmd.AddCommand(commands.TrustBuilder(logger, cfg)) rootCmd.AddCommand(commands.UntrustBuilder(logger, cfg)) diff --git a/go.mod b/go.mod index 6ed6c3e66..7071fd5f3 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41 // indirect github.com/docker/docker v1.4.2-0.20200221181110-62bd5a33f707 github.com/docker/go-connections v0.4.0 + github.com/ghodss/yaml v1.0.0 github.com/golang/mock v1.4.4 github.com/golang/protobuf v1.4.3 // indirect github.com/google/go-cmp v0.5.2 @@ -24,6 +25,7 @@ require ( github.com/opencontainers/image-spec v1.0.1 github.com/opencontainers/runc v0.1.1 // indirect github.com/opencontainers/selinux v1.6.0 // indirect + github.com/pelletier/go-toml v1.8.1 github.com/pkg/errors v0.9.1 github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 github.com/sclevine/spec v1.4.0 @@ -42,6 +44,7 @@ require ( google.golang.org/grpc v1.33.1 // indirect google.golang.org/protobuf v1.25.0 // indirect gopkg.in/src-d/go-git.v4 v4.13.1 + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) go 1.14 diff --git a/go.sum b/go.sum index 9c10003ef..e991cf802 100644 --- a/go.sum +++ b/go.sum @@ -193,6 +193,7 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= @@ -246,7 +247,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= @@ -349,9 +349,7 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -428,7 +426,6 @@ github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoT github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= -github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= @@ -443,6 +440,8 @@ github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqi github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= +github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -491,9 +490,7 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 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= -github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -510,7 +507,6 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= @@ -554,11 +550,9 @@ github.com/vdemeester/k8s-pkg-credentialprovider v0.0.0-20200107171650-7c61ffa44 github.com/vdemeester/k8s-pkg-credentialprovider v1.17.4/go.mod h1:inCTmtUdr5KJbreVojo06krnTgaeAz/Z7lynpPk/Q2c= github.com/vdemeester/k8s-pkg-credentialprovider v1.18.1-0.20201019120933-f1d16962a4db/go.mod h1:grWy0bkr1XO6hqbaaCKaPXqkBVlMGHYG6PGykktwbJc= github.com/vmware/govmomi v0.20.3/go.mod h1:URlwyTFZX72RmxtxuaFL2Uj3fD1JTvZdx59bHWk6aFU= -github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243 h1:R43TdZy32XXSXjJn7M/HhALJ9imq6ztLnChfYJpVDnM= github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE= github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI= -github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= @@ -593,7 +587,6 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= @@ -660,13 +653,11 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 h1:AeiKBIuRw3UomYXSbLy0Mc2dDLfdtbT/IVn4keq83P0= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= @@ -676,10 +667,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -740,7 +729,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 h1:NusfzzA6yGQ+ua51ck7E3omNUX/JuqbFSaRGqU8CcLI= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -808,7 +796,6 @@ google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= @@ -833,7 +820,6 @@ google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece h1:1YM0uhfumvoDu9sx8+RyWwTI63zoCQvI23IYFRlvte0= google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5 h1:YejJbGvoWsTXHab4OKNrzk27Dr7s4lPLnewbHue1+gM= google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= @@ -848,7 +834,6 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.29.1 h1:EC2SB8S04d2r73uptxphDSUG+kTKVgjRPF+N3xpxRB4= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.33.1 h1:DGeFlSan2f+WEtCERJ4J9GJWk15TxUi8QGagfI87Xyc= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= @@ -858,10 +843,8 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0 h1:UhZDfRO8JRQru4/+LlLE0BRKGF8L+PICnvYZmx/fEGA= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= @@ -901,8 +884,9 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo= gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E= diff --git a/inspect_builder.go b/inspect_builder.go index f00ab7296..c445a498f 100644 --- a/inspect_builder.go +++ b/inspect_builder.go @@ -1,16 +1,13 @@ package pack import ( - "context" - "strings" + "errors" - "github.com/pkg/errors" + pubbldr "github.com/buildpacks/pack/builder" - "github.com/buildpacks/pack/config" "github.com/buildpacks/pack/internal/builder" "github.com/buildpacks/pack/internal/dist" "github.com/buildpacks/pack/internal/image" - "github.com/buildpacks/pack/internal/style" ) // BuilderInfo is a collection of metadata describing a builder created using pack. @@ -34,8 +31,8 @@ type BuilderInfo struct { // All buildpacks included within the builder. Buildpacks []dist.BuildpackInfo - // Top level ordering of buildpacks. - Order dist.Order + // Detailed ordering of buildpacks and nested buildpacks where depth is specified. + Order pubbldr.DetectionOrder // Listing of all buildpack layers in a builder. // All elements in the Buildpacks variable are represented in this @@ -59,74 +56,51 @@ type BuildpackInfoKey struct { Version string } +type BuilderInspectionConfig struct { + OrderDetectionDepth int +} + +type BuilderInspectionModifier func(config *BuilderInspectionConfig) + +func WithDetectionOrderDepth(depth int) BuilderInspectionModifier { + return func(config *BuilderInspectionConfig) { + config.OrderDetectionDepth = depth + } +} + // InspectBuilder reads label metadata of a local or remote builder image. It initializes a BuilderInfo // object with this metadata, and returns it. This method will error if the name image cannot be found // both locally and remotely, or if the found image does not contain the proper labels. -func (c *Client) InspectBuilder(name string, daemon bool) (*BuilderInfo, error) { - img, err := c.imageFetcher.Fetch(context.Background(), name, daemon, config.PullNever) - if err != nil { - if errors.Cause(err) == image.ErrNotFound { - return nil, nil - } - return nil, err +func (c *Client) InspectBuilder(name string, daemon bool, modifiers ...BuilderInspectionModifier) (*BuilderInfo, error) { + inspector := builder.NewInspector( + builder.NewImageFetcherWrapper(c.imageFetcher), + builder.NewLabelManagerProvider(), + builder.NewDetectionOrderCalculator(), + ) + + inspectionConfig := BuilderInspectionConfig{OrderDetectionDepth: pubbldr.OrderDetectionNone} + for _, mod := range modifiers { + mod(&inspectionConfig) } - bldr, err := builder.FromImage(img) + info, err := inspector.Inspect(name, daemon, inspectionConfig.OrderDetectionDepth) if err != nil { - return nil, errors.Wrapf(err, "invalid builder %s", style.Symbol(name)) - } - - var commonMixins, buildMixins []string - commonMixins = []string{} - for _, mixin := range bldr.Mixins() { - if strings.HasPrefix(mixin, "build:") { - buildMixins = append(buildMixins, mixin) - } else { - commonMixins = append(commonMixins, mixin) + if errors.Is(err, image.ErrNotFound) { + return nil, nil } - } - - var bpLayers dist.BuildpackLayers - if _, err := dist.GetLabel(img, dist.BuildpackLayersLabel, &bpLayers); err != nil { return nil, err } return &BuilderInfo{ - Description: bldr.Description(), - Stack: bldr.StackID, - Mixins: append(commonMixins, buildMixins...), - RunImage: bldr.Stack().RunImage.Image, - RunImageMirrors: bldr.Stack().RunImage.Mirrors, - Buildpacks: uniqueBuildpacks(bldr.Buildpacks()), - Order: bldr.Order(), - BuildpackLayers: bpLayers, - Lifecycle: bldr.LifecycleDescriptor(), - CreatedBy: bldr.CreatedBy(), + Description: info.Description, + Stack: info.StackID, + Mixins: info.Mixins, + RunImage: info.RunImage, + RunImageMirrors: info.RunImageMirrors, + Buildpacks: info.Buildpacks, + Order: info.Order, + BuildpackLayers: info.BuildpackLayers, + Lifecycle: info.Lifecycle, + CreatedBy: info.CreatedBy, }, nil } - -func uniqueBuildpacks(buildpacks []dist.BuildpackInfo) []dist.BuildpackInfo { - buildpacksSet := map[BuildpackInfoKey]int{} - homePageSet := map[BuildpackInfoKey]string{} - for _, buildpack := range buildpacks { - key := BuildpackInfoKey{ - ID: buildpack.ID, - Version: buildpack.Version, - } - _, ok := buildpacksSet[key] - if !ok { - buildpacksSet[key] = len(buildpacksSet) - homePageSet[key] = buildpack.Homepage - } - } - result := make([]dist.BuildpackInfo, len(buildpacksSet)) - for buildpackKey, index := range buildpacksSet { - result[index] = dist.BuildpackInfo{ - ID: buildpackKey.ID, - Version: buildpackKey.Version, - Homepage: homePageSet[buildpackKey], - } - } - - return result -} diff --git a/inspect_builder_test.go b/inspect_builder_test.go index 9d68e19dd..24e0a72e7 100644 --- a/inspect_builder_test.go +++ b/inspect_builder_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/config" "github.com/buildpacks/imgutil/fakes" @@ -240,16 +242,20 @@ func testInspectBuilder(t *testing.T, when spec.G, it spec.S) { Version: "test.bp.two.version", }, }, - Order: dist.Order{ + Order: pubbldr.DetectionOrder{ { - Group: []dist.BuildpackRef{ + GroupDetectionOrder: pubbldr.DetectionOrder{ { - BuildpackInfo: dist.BuildpackInfo{ID: "test.nested", Version: "test.nested.version"}, - Optional: false, + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ID: "test.nested", Version: "test.nested.version"}, + Optional: false, + }, }, { - BuildpackInfo: dist.BuildpackInfo{ID: "test.bp.two"}, - Optional: true, + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ID: "test.bp.two"}, + Optional: true, + }, }, }, }, @@ -336,15 +342,55 @@ func testInspectBuilder(t *testing.T, when spec.G, it spec.S) { } }) - when("the image has no mixins", func() { - it.Before(func() { - assert.Succeeds(builderImage.SetLabel("io.buildpacks.stack.mixins", "")) - }) + when("order detection depth is higher than None", func() { + it("shows subgroup order as part of order", func() { + builderInfo, err := subject.InspectBuilder( + "some/builder", + useDaemon, + WithDetectionOrderDepth(pubbldr.OrderDetectionMaxDepth), + ) + h.AssertNil(t, err) - it("sets empty stack mixins", func() { - builderInfo, err := subject.InspectBuilder("some/builder", useDaemon) - assert.Nil(err) - assert.Equal(builderInfo.Mixins, []string{}) + want := pubbldr.DetectionOrder{ + { + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ID: "test.nested", Version: "test.nested.version"}, + Optional: false, + }, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ + ID: "test.bp.one", + Version: "test.bp.one.version", + }, + }, + }, + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ + ID: "test.bp.two", + Version: "test.bp.two.version", + }, + }, + }, + }, + }, + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: dist.BuildpackInfo{ID: "test.bp.two"}, + Optional: true, + }, + }, + }, + }, + } + + if diff := cmp.Diff(want, builderInfo.Order); diff != "" { + t.Errorf("\"InspectBuilder() mismatch (-want +got):\b%s", diff) + } }) }) }) @@ -352,17 +398,6 @@ func testInspectBuilder(t *testing.T, when spec.G, it spec.S) { } }) - when("fetcher fails to fetch the image", func() { - it.Before(func() { - mockImageFetcher.EXPECT().Fetch(gomock.Any(), "some/builder", false, config.PullNever).Return(nil, errors.New("some-error")) - }) - - it("returns an error", func() { - _, err := subject.InspectBuilder("some/builder", false) - assert.ErrorContains(err, "some-error") - }) - }) - when("the image does not exist", func() { it.Before(func() { notFoundImage := fakes.NewImage("", "", nil) diff --git a/internal/builder/descriptor.go b/internal/builder/descriptor.go index e6427eec7..22017db2e 100644 --- a/internal/builder/descriptor.go +++ b/internal/builder/descriptor.go @@ -16,7 +16,7 @@ type LifecycleDescriptor struct { // LifecycleInfo contains information about the lifecycle type LifecycleInfo struct { - Version *Version `toml:"version" json:"version"` + Version *Version `toml:"version" json:"version" yaml:"version"` } // LifecycleAPI describes which API versions the lifecycle satisfies @@ -68,8 +68,8 @@ func (a APISet) AsStrings() []string { // APIVersions describes the supported API versions type APIVersions struct { - Deprecated APISet `toml:"deprecated" json:"deprecated"` - Supported APISet `toml:"supported" json:"supported"` + Deprecated APISet `toml:"deprecated" json:"deprecated" yaml:"deprecated"` + Supported APISet `toml:"supported" json:"supported" yaml:"supported"` } // ParseDescriptor parses LifecycleDescriptor from toml formatted string. diff --git a/internal/builder/detection_order_calculator.go b/internal/builder/detection_order_calculator.go new file mode 100644 index 000000000..85a0fbabd --- /dev/null +++ b/internal/builder/detection_order_calculator.go @@ -0,0 +1,106 @@ +package builder + +import ( + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/dist" +) + +type DetectionOrderCalculator struct{} + +func NewDetectionOrderCalculator() *DetectionOrderCalculator { + return &DetectionOrderCalculator{} +} + +type detectionOrderRecurser struct { + layers dist.BuildpackLayers + maxDepth int +} + +func newDetectionOrderRecurser(layers dist.BuildpackLayers, maxDepth int) *detectionOrderRecurser { + return &detectionOrderRecurser{ + layers: layers, + maxDepth: maxDepth, + } +} + +func (c *DetectionOrderCalculator) Order( + order dist.Order, + layers dist.BuildpackLayers, + maxDepth int, +) (pubbldr.DetectionOrder, error) { + recurser := newDetectionOrderRecurser(layers, maxDepth) + + return recurser.detectionOrderFromOrder(order, dist.BuildpackRef{}, 0, map[dist.BuildpackRef]interface{}{}), nil +} + +func (r *detectionOrderRecurser) detectionOrderFromOrder( + order dist.Order, + parentBuildpack dist.BuildpackRef, + currentDepth int, + visited map[dist.BuildpackRef]interface{}, +) pubbldr.DetectionOrder { + var detectionOrder pubbldr.DetectionOrder + for _, orderEntry := range order { + visitedCopy := copyMap(visited) + groupDetectionOrder := r.detectionOrderFromGroup(orderEntry.Group, currentDepth, visitedCopy) + + detectionOrderEntry := pubbldr.DetectionOrderEntry{ + BuildpackRef: parentBuildpack, + GroupDetectionOrder: groupDetectionOrder, + } + + detectionOrder = append(detectionOrder, detectionOrderEntry) + } + + return detectionOrder +} + +func (r *detectionOrderRecurser) detectionOrderFromGroup( + group []dist.BuildpackRef, + currentDepth int, + visited map[dist.BuildpackRef]interface{}, +) pubbldr.DetectionOrder { + var groupDetectionOrder pubbldr.DetectionOrder + + for _, bp := range group { + _, bpSeen := visited[bp] + if !bpSeen { + visited[bp] = true + } + + layer, ok := r.layers.Get(bp.ID, bp.Version) + if ok && len(layer.Order) > 0 && r.shouldGoDeeper(currentDepth) && !bpSeen { + groupOrder := r.detectionOrderFromOrder(layer.Order, bp, currentDepth+1, visited) + groupDetectionOrder = append(groupDetectionOrder, groupOrder...) + } else { + groupDetectionOrderEntry := pubbldr.DetectionOrderEntry{ + BuildpackRef: bp, + Cyclical: bpSeen, + } + groupDetectionOrder = append(groupDetectionOrder, groupDetectionOrderEntry) + } + } + + return groupDetectionOrder +} + +func (r *detectionOrderRecurser) shouldGoDeeper(currentDepth int) bool { + if r.maxDepth == pubbldr.OrderDetectionMaxDepth { + return true + } + + if currentDepth < r.maxDepth { + return true + } + + return false +} + +func copyMap(toCopy map[dist.BuildpackRef]interface{}) map[dist.BuildpackRef]interface{} { + result := make(map[dist.BuildpackRef]interface{}, len(toCopy)) + for key := range toCopy { + result[key] = true + } + + return result +} diff --git a/internal/builder/detection_order_calculator_test.go b/internal/builder/detection_order_calculator_test.go new file mode 100644 index 000000000..18d78867e --- /dev/null +++ b/internal/builder/detection_order_calculator_test.go @@ -0,0 +1,276 @@ +package builder_test + +import ( + "testing" + + "github.com/buildpacks/lifecycle/api" + + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/dist" + h "github.com/buildpacks/pack/testhelpers" + + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestDetectionOrderCalculator(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "testDetectionOrderCalculator", testDetectionOrderCalculator, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testDetectionOrderCalculator(t *testing.T, when spec.G, it spec.S) { + when("Order", func() { + var ( + assert = h.NewAssertionManager(t) + + testBuildpackOne = dist.BuildpackInfo{ + ID: "test.buildpack", + Version: "test.buildpack.version", + } + testBuildpackTwo = dist.BuildpackInfo{ + ID: "test.buildpack.2", + Version: "test.buildpack.2.version", + } + testTopNestedBuildpack = dist.BuildpackInfo{ + ID: "test.top.nested", + Version: "test.top.nested.version", + } + testLevelOneNestedBuildpack = dist.BuildpackInfo{ + ID: "test.nested.level.one", + Version: "test.nested.level.one.version", + } + testLevelOneNestedBuildpackTwo = dist.BuildpackInfo{ + ID: "test.nested.level.one.two", + Version: "test.nested.level.one.two.version", + } + testLevelOneNestedBuildpackThree = dist.BuildpackInfo{ + ID: "test.nested.level.one.three", + Version: "test.nested.level.one.three.version", + } + testLevelTwoNestedBuildpack = dist.BuildpackInfo{ + ID: "test.nested.level.two", + Version: "test.nested.level.two.version", + } + topLevelOrder = dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testBuildpackOne}, + {BuildpackInfo: testBuildpackTwo}, + {BuildpackInfo: testTopNestedBuildpack}, + }, + }, + } + buildpackLayers = dist.BuildpackLayers{ + "test.buildpack": { + "test.buildpack.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + LayerDiffID: "layer:diff", + }, + }, + "test.top.nested": { + "test.top.nested.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + Order: dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testLevelOneNestedBuildpack}, + {BuildpackInfo: testLevelOneNestedBuildpackTwo}, + {BuildpackInfo: testLevelOneNestedBuildpackThree}, + }, + }, + }, + LayerDiffID: "layer:diff", + }, + }, + "test.nested.level.one": { + "test.nested.level.one.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + Order: dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testLevelTwoNestedBuildpack}, + }, + }, + }, + LayerDiffID: "layer:diff", + }, + }, + "test.nested.level.one.three": { + "test.nested.level.one.three.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + Order: dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testLevelTwoNestedBuildpack}, + {BuildpackInfo: testTopNestedBuildpack}, + }, + }, + }, + LayerDiffID: "layer:diff", + }, + }, + } + ) + + when("called with no depth", func() { + it("returns detection order with top level order of buildpacks", func() { + calculator := builder.NewDetectionOrderCalculator() + order, err := calculator.Order(topLevelOrder, buildpackLayers, pubbldr.OrderDetectionNone) + assert.Nil(err) + + expectedOrder := pubbldr.DetectionOrder{ + { + GroupDetectionOrder: pubbldr.DetectionOrder{ + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackOne}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackTwo}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testTopNestedBuildpack}}, + }, + }, + } + + assert.Equal(order, expectedOrder) + }) + }) + + when("called with max depth", func() { + it("returns detection order for nested buildpacks", func() { + calculator := builder.NewDetectionOrderCalculator() + order, err := calculator.Order(topLevelOrder, buildpackLayers, pubbldr.OrderDetectionMaxDepth) + assert.Nil(err) + + expectedOrder := pubbldr.DetectionOrder{ + { + GroupDetectionOrder: pubbldr.DetectionOrder{ + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackOne}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackTwo}}, + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testTopNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelTwoNestedBuildpack}}, + }, + }, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpackTwo}}, + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpackThree}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelTwoNestedBuildpack}, + Cyclical: false, + }, + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testTopNestedBuildpack}, + Cyclical: true, + }, + }, + }, + }, + }, + }, + }, + } + + assert.Equal(order, expectedOrder) + }) + }) + + when("called with a depth of 1", func() { + it("returns detection order for first level of nested buildpacks", func() { + calculator := builder.NewDetectionOrderCalculator() + order, err := calculator.Order(topLevelOrder, buildpackLayers, 1) + assert.Nil(err) + + expectedOrder := pubbldr.DetectionOrder{ + { + GroupDetectionOrder: pubbldr.DetectionOrder{ + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackOne}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testBuildpackTwo}}, + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testTopNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpack}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpackTwo}}, + {BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpackThree}}, + }, + }, + }, + }, + } + + assert.Equal(order, expectedOrder) + }) + }) + + when("a buildpack is referenced in a sub detection group", func() { + it("marks the buildpack is cyclic and doesn't attempt to calculate that buildpacks order", func() { + cyclicBuildpackLayers := dist.BuildpackLayers{ + "test.top.nested": { + "test.top.nested.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + Order: dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testLevelOneNestedBuildpack}, + }, + }, + }, + LayerDiffID: "layer:diff", + }, + }, + "test.nested.level.one": { + "test.nested.level.one.version": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + Order: dist.Order{ + { + Group: []dist.BuildpackRef{ + {BuildpackInfo: testTopNestedBuildpack}, + }, + }, + }, + LayerDiffID: "layer:diff", + }, + }, + } + cyclicOrder := dist.Order{ + { + Group: []dist.BuildpackRef{{BuildpackInfo: testTopNestedBuildpack}}, + }, + } + + calculator := builder.NewDetectionOrderCalculator() + order, err := calculator.Order(cyclicOrder, cyclicBuildpackLayers, pubbldr.OrderDetectionMaxDepth) + assert.Nil(err) + + expectedOrder := pubbldr.DetectionOrder{ + { + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testTopNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testLevelOneNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testTopNestedBuildpack, + }, + Cyclical: true, + }, + }, + }, + }, + }, + }, + }, + } + + assert.Equal(order, expectedOrder) + }) + }) + }) +} diff --git a/internal/builder/fakes/fake_detection_calculator.go b/internal/builder/fakes/fake_detection_calculator.go new file mode 100644 index 000000000..2cd923fdf --- /dev/null +++ b/internal/builder/fakes/fake_detection_calculator.go @@ -0,0 +1,28 @@ +package fakes + +import ( + "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/dist" +) + +type FakeDetectionCalculator struct { + ReturnForOrder builder.DetectionOrder + + ErrorForOrder error + + ReceivedTopOrder dist.Order + ReceivedLayers dist.BuildpackLayers + ReceivedDepth int +} + +func (c *FakeDetectionCalculator) Order( + topOrder dist.Order, + layers dist.BuildpackLayers, + depth int, +) (builder.DetectionOrder, error) { + c.ReceivedTopOrder = topOrder + c.ReceivedLayers = layers + c.ReceivedDepth = depth + + return c.ReturnForOrder, c.ErrorForOrder +} diff --git a/internal/builder/fakes/fake_inspectable.go b/internal/builder/fakes/fake_inspectable.go new file mode 100644 index 000000000..d892856ff --- /dev/null +++ b/internal/builder/fakes/fake_inspectable.go @@ -0,0 +1,15 @@ +package fakes + +type FakeInspectable struct { + ReturnForLabel string + + ErrorForLabel error + + ReceivedName string +} + +func (f *FakeInspectable) Label(name string) (string, error) { + f.ReceivedName = name + + return f.ReturnForLabel, f.ErrorForLabel +} diff --git a/internal/builder/fakes/fake_inspectable_fetcher.go b/internal/builder/fakes/fake_inspectable_fetcher.go new file mode 100644 index 000000000..13caf74e7 --- /dev/null +++ b/internal/builder/fakes/fake_inspectable_fetcher.go @@ -0,0 +1,29 @@ +package fakes + +import ( + "context" + + "github.com/buildpacks/pack/config" + "github.com/buildpacks/pack/internal/builder" +) + +type FakeInspectableFetcher struct { + InspectableToReturn *FakeInspectable + ErrorToReturn error + + CallCount int + + ReceivedName string + ReceivedDaemon bool + ReceivedPullPolicy config.PullPolicy +} + +func (f *FakeInspectableFetcher) Fetch(ctx context.Context, name string, daemon bool, pullPolicy config.PullPolicy) (builder.Inspectable, error) { + f.CallCount++ + + f.ReceivedName = name + f.ReceivedDaemon = daemon + f.ReceivedPullPolicy = pullPolicy + + return f.InspectableToReturn, f.ErrorToReturn +} diff --git a/internal/builder/fakes/fake_label_manager.go b/internal/builder/fakes/fake_label_manager.go new file mode 100644 index 000000000..63cd99d11 --- /dev/null +++ b/internal/builder/fakes/fake_label_manager.go @@ -0,0 +1,40 @@ +package fakes + +import ( + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/dist" +) + +type FakeLabelManager struct { + ReturnForMetadata builder.Metadata + ReturnForStackID string + ReturnForMixins []string + ReturnForOrder dist.Order + ReturnForBuildpackLayers dist.BuildpackLayers + + ErrorForMetadata error + ErrorForStackID error + ErrorForMixins error + ErrorForOrder error + ErrorForBuildpackLayers error +} + +func (m *FakeLabelManager) Metadata() (builder.Metadata, error) { + return m.ReturnForMetadata, m.ErrorForMetadata +} + +func (m *FakeLabelManager) StackID() (string, error) { + return m.ReturnForStackID, m.ErrorForStackID +} + +func (m *FakeLabelManager) Mixins() ([]string, error) { + return m.ReturnForMixins, m.ErrorForMixins +} + +func (m *FakeLabelManager) Order() (dist.Order, error) { + return m.ReturnForOrder, m.ErrorForOrder +} + +func (m *FakeLabelManager) BuildpackLayers() (dist.BuildpackLayers, error) { + return m.ReturnForBuildpackLayers, m.ErrorForBuildpackLayers +} diff --git a/internal/builder/fakes/fake_label_manager_factory.go b/internal/builder/fakes/fake_label_manager_factory.go new file mode 100644 index 000000000..66bb070fa --- /dev/null +++ b/internal/builder/fakes/fake_label_manager_factory.go @@ -0,0 +1,21 @@ +package fakes + +import "github.com/buildpacks/pack/internal/builder" + +type FakeLabelManagerFactory struct { + BuilderLabelManagerToReturn builder.LabelInspector + + ReceivedInspectable builder.Inspectable +} + +func NewFakeLabelManagerFactory(builderLabelManagerToReturn builder.LabelInspector) *FakeLabelManagerFactory { + return &FakeLabelManagerFactory{ + BuilderLabelManagerToReturn: builderLabelManagerToReturn, + } +} + +func (f *FakeLabelManagerFactory) BuilderLabelManager(inspectable builder.Inspectable) builder.LabelInspector { + f.ReceivedInspectable = inspectable + + return f.BuilderLabelManagerToReturn +} diff --git a/internal/builder/image_fetcher_wrapper.go b/internal/builder/image_fetcher_wrapper.go new file mode 100644 index 000000000..1411d0fb5 --- /dev/null +++ b/internal/builder/image_fetcher_wrapper.go @@ -0,0 +1,35 @@ +package builder + +import ( + "context" + + "github.com/buildpacks/imgutil" + + pubcfg "github.com/buildpacks/pack/config" +) + +type ImageFetcher interface { + // Fetch fetches an image by resolving it both remotely and locally depending on provided parameters. + // If daemon is true, it will look return a `local.Image`. Pull, applicable only when daemon is true, will + // attempt to pull a remote image first. + Fetch(ctx context.Context, name string, daemon bool, pullPolicy pubcfg.PullPolicy) (imgutil.Image, error) +} + +type ImageFetcherWrapper struct { + fetcher ImageFetcher +} + +func NewImageFetcherWrapper(fetcher ImageFetcher) *ImageFetcherWrapper { + return &ImageFetcherWrapper{ + fetcher: fetcher, + } +} + +func (w *ImageFetcherWrapper) Fetch( + ctx context.Context, + name string, + daemon bool, + pullPolicy pubcfg.PullPolicy, +) (Inspectable, error) { + return w.fetcher.Fetch(ctx, name, daemon, pullPolicy) +} diff --git a/internal/builder/inspect.go b/internal/builder/inspect.go new file mode 100644 index 000000000..402242a0c --- /dev/null +++ b/internal/builder/inspect.go @@ -0,0 +1,147 @@ +package builder + +import ( + "context" + "fmt" + "strings" + + pubbldr "github.com/buildpacks/pack/builder" + + "github.com/buildpacks/pack/internal/dist" + + "github.com/buildpacks/pack/config" +) + +type Info struct { + Description string + StackID string + Mixins []string + RunImage string + RunImageMirrors []string + Buildpacks []dist.BuildpackInfo + Order pubbldr.DetectionOrder + BuildpackLayers dist.BuildpackLayers + Lifecycle LifecycleDescriptor + CreatedBy CreatorMetadata +} + +type Inspectable interface { + Label(name string) (string, error) +} + +type InspectableFetcher interface { + Fetch(ctx context.Context, name string, daemon bool, pullPolicy config.PullPolicy) (Inspectable, error) +} + +type LabelManagerFactory interface { + BuilderLabelManager(inspectable Inspectable) LabelInspector +} + +type LabelInspector interface { + Metadata() (Metadata, error) + StackID() (string, error) + Mixins() ([]string, error) + Order() (dist.Order, error) + BuildpackLayers() (dist.BuildpackLayers, error) +} + +type DetectionCalculator interface { + Order(topOrder dist.Order, layers dist.BuildpackLayers, depth int) (pubbldr.DetectionOrder, error) +} + +type Inspector struct { + imageFetcher InspectableFetcher + labelManagerFactory LabelManagerFactory + detectionOrderCalculator DetectionCalculator +} + +func NewInspector(fetcher InspectableFetcher, factory LabelManagerFactory, calculator DetectionCalculator) *Inspector { + return &Inspector{ + imageFetcher: fetcher, + labelManagerFactory: factory, + detectionOrderCalculator: calculator, + } +} + +func (i *Inspector) Inspect(name string, daemon bool, orderDetectionDepth int) (Info, error) { + inspectable, err := i.imageFetcher.Fetch(context.Background(), name, daemon, config.PullNever) + if err != nil { + return Info{}, fmt.Errorf("fetching builder image: %w", err) + } + + labelManager := i.labelManagerFactory.BuilderLabelManager(inspectable) + + metadata, err := labelManager.Metadata() + if err != nil { + return Info{}, fmt.Errorf("reading image metadata: %w", err) + } + + stackID, err := labelManager.StackID() + if err != nil { + return Info{}, fmt.Errorf("reading image stack id: %w", err) + } + + mixins, err := labelManager.Mixins() + if err != nil { + return Info{}, fmt.Errorf("reading image mixins: %w", err) + } + + var commonMixins, buildMixins []string + commonMixins = []string{} + for _, mixin := range mixins { + if strings.HasPrefix(mixin, "build:") { + buildMixins = append(buildMixins, mixin) + } else { + commonMixins = append(commonMixins, mixin) + } + } + + order, err := labelManager.Order() + if err != nil { + return Info{}, fmt.Errorf("reading image order: %w", err) + } + + layers, err := labelManager.BuildpackLayers() + if err != nil { + return Info{}, fmt.Errorf("reading image buildpack layers: %w", err) + } + + detectionOrder, err := i.detectionOrderCalculator.Order(order, layers, orderDetectionDepth) + if err != nil { + return Info{}, fmt.Errorf("calculating detection order: %w", err) + } + + lifecycle := CompatDescriptor(LifecycleDescriptor{ + Info: LifecycleInfo{Version: metadata.Lifecycle.Version}, + API: metadata.Lifecycle.API, + APIs: metadata.Lifecycle.APIs, + }) + + return Info{ + Description: metadata.Description, + StackID: stackID, + Mixins: append(commonMixins, buildMixins...), + RunImage: metadata.Stack.RunImage.Image, + RunImageMirrors: metadata.Stack.RunImage.Mirrors, + Buildpacks: uniqueBuildpacks(metadata.Buildpacks), + Order: detectionOrder, + BuildpackLayers: layers, + Lifecycle: lifecycle, + CreatedBy: metadata.CreatedBy, + }, nil +} + +func uniqueBuildpacks(buildpacks []dist.BuildpackInfo) []dist.BuildpackInfo { + foundBuildpacks := map[dist.BuildpackInfo]interface{}{} + var uniqueBuildpacks []dist.BuildpackInfo + + for _, bp := range buildpacks { + _, ok := foundBuildpacks[bp] + if !ok { + uniqueBuildpacks = append(uniqueBuildpacks, bp) + foundBuildpacks[bp] = true + } + } + + return uniqueBuildpacks +} diff --git a/internal/builder/inspect_test.go b/internal/builder/inspect_test.go new file mode 100644 index 000000000..426964c62 --- /dev/null +++ b/internal/builder/inspect_test.go @@ -0,0 +1,441 @@ +package builder_test + +import ( + "errors" + "testing" + + "github.com/buildpacks/lifecycle/api" + + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/config" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/fakes" + "github.com/buildpacks/pack/internal/dist" + h "github.com/buildpacks/pack/testhelpers" + + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +const ( + testBuilderName = "test-builder" + testBuilderDescription = "Test Builder Description" + testStackID = "test-builder-stack-id" + testRunImage = "test/run-image" +) + +var ( + testTopNestedBuildpack = dist.BuildpackInfo{ + ID: "test.top.nested", + Version: "test.top.nested.version", + } + testNestedBuildpack = dist.BuildpackInfo{ + ID: "test.nested", + Version: "test.nested.version", + Homepage: "http://geocities.com/top-bp", + } + testBuildpack = dist.BuildpackInfo{ + ID: "test.bp.two", + Version: "test.bp.two.version", + } + testBuildpacks = []dist.BuildpackInfo{ + testTopNestedBuildpack, + testNestedBuildpack, + testBuildpack, + } + testLifecycleInfo = builder.LifecycleInfo{ + Version: builder.VersionMustParse("1.2.3"), + } + testBuildpackVersions = builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("0.1")}, + Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("1.3")}, + } + testPlatformVersions = builder.APIVersions{ + Supported: builder.APISet{api.MustParse("2.3"), api.MustParse("2.4")}, + } + inspectTestLifecycle = builder.LifecycleMetadata{ + LifecycleInfo: testLifecycleInfo, + APIs: builder.LifecycleAPIs{ + Buildpack: testBuildpackVersions, + Platform: testPlatformVersions, + }, + } + testCreatorData = builder.CreatorMetadata{ + Name: "pack", + Version: "1.2.3", + } + testMetadata = builder.Metadata{ + Description: testBuilderDescription, + Buildpacks: testBuildpacks, + Stack: testStack, + Lifecycle: inspectTestLifecycle, + CreatedBy: testCreatorData, + } + testMixins = []string{"build:mixinA", "mixinX", "mixinY"} + expectedTestMixins = []string{"mixinX", "mixinY", "build:mixinA"} + testRunImageMirrors = []string{"test/first-run-image-mirror", "test/second-run-image-mirror"} + testStack = builder.StackMetadata{ + RunImage: builder.RunImageMetadata{ + Image: testRunImage, + Mirrors: testRunImageMirrors, + }, + } + testOrder = dist.Order{ + dist.OrderEntry{Group: []dist.BuildpackRef{ + {BuildpackInfo: testBuildpack, Optional: false}, + }}, + dist.OrderEntry{Group: []dist.BuildpackRef{ + {BuildpackInfo: testNestedBuildpack, Optional: false}, + {BuildpackInfo: testTopNestedBuildpack, Optional: true}, + }}, + } + testLayers = dist.BuildpackLayers{ + "test.top.nested": { + "test.top.nested.version": { + API: api.MustParse("0.2"), + Order: testOrder, + LayerDiffID: "sha256:test.top.nested.sha256", + Homepage: "http://geocities.com/top-bp", + }, + }, + "test.bp.two": { + "test.bp.two.version": { + API: api.MustParse("0.2"), + Stacks: []dist.Stack{{ID: "test.stack.id"}}, + LayerDiffID: "sha256:test.bp.two.sha256", + Homepage: "http://geocities.com/cool-bp", + }, + }, + } + expectedTestLifecycle = builder.LifecycleDescriptor{ + Info: testLifecycleInfo, + API: builder.LifecycleAPI{ + BuildpackVersion: api.MustParse("0.1"), + PlatformVersion: api.MustParse("2.3"), + }, + APIs: builder.LifecycleAPIs{ + Buildpack: testBuildpackVersions, + Platform: testPlatformVersions, + }, + } + expectedDetectionTestOrder = pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpack, + }, + }, + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testTopNestedBuildpack, + }, + GroupDetectionOrder: pubbldr.DetectionOrder{ + { + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testNestedBuildpack, + }, + }, + }, + }, + } +) + +func TestInspect(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "testInspect", testInspect, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testInspect(t *testing.T, when spec.G, it spec.S) { + when("Inspect", func() { + var assert = h.NewAssertionManager(t) + + it("calls Fetch on inspectableFetcher with expected arguments", func() { + fetcher := newDefaultInspectableFetcher() + + inspector := builder.NewInspector(fetcher, newDefaultLabelManagerFactory(), newDefaultDetectionCalculator()) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + assert.Nil(err) + + assert.Equal(fetcher.CallCount, 1) + assert.Equal(fetcher.ReceivedName, testBuilderName) + assert.Equal(fetcher.ReceivedDaemon, true) + assert.Equal(fetcher.ReceivedPullPolicy, config.PullNever) + }) + + it("instantiates a builder label manager with the correct inspectable", func() { + inspectable := newNoOpInspectable() + + fetcher := &fakes.FakeInspectableFetcher{ + InspectableToReturn: inspectable, + } + + labelManagerFactory := newDefaultLabelManagerFactory() + + inspector := builder.NewInspector(fetcher, labelManagerFactory, newDefaultDetectionCalculator()) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + assert.Nil(err) + + assert.Equal(labelManagerFactory.ReceivedInspectable, inspectable) + }) + + it("calls `Order` on detectionCalculator with expected arguments", func() { + detectionOrderCalculator := newDefaultDetectionCalculator() + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newDefaultLabelManagerFactory(), + detectionOrderCalculator, + ) + _, err := inspector.Inspect(testBuilderName, true, 3) + assert.Nil(err) + + assert.Equal(detectionOrderCalculator.ReceivedTopOrder, testOrder) + assert.Equal(detectionOrderCalculator.ReceivedLayers, testLayers) + assert.Equal(detectionOrderCalculator.ReceivedDepth, 3) + }) + + it("returns Info object with expected fields", func() { + fetcher := newDefaultInspectableFetcher() + + inspector := builder.NewInspector(fetcher, newDefaultLabelManagerFactory(), newDefaultDetectionCalculator()) + info, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + assert.Nil(err) + + assert.Equal(info.Description, testBuilderDescription) + assert.Equal(info.StackID, testStackID) + assert.Equal(info.Mixins, expectedTestMixins) + assert.Equal(info.RunImage, testRunImage) + assert.Equal(info.RunImageMirrors, testRunImageMirrors) + assert.Equal(info.Buildpacks, testBuildpacks) + assert.Equal(info.Order, expectedDetectionTestOrder) + assert.Equal(info.BuildpackLayers, testLayers) + assert.Equal(info.Lifecycle, expectedTestLifecycle) + assert.Equal(info.CreatedBy, testCreatorData) + }) + + when("there are duplicated buildpacks in metadata", func() { + it("returns deduplicated buildpacks", func() { + metadata := builder.Metadata{ + Description: testBuilderDescription, + Buildpacks: []dist.BuildpackInfo{ + testTopNestedBuildpack, + testNestedBuildpack, + testTopNestedBuildpack, + }, + } + labelManager := newLabelManager(returnForMetadata(metadata)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + info, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.Nil(err) + assert.Equal(info.Buildpacks, []dist.BuildpackInfo{testTopNestedBuildpack, testNestedBuildpack}) + }) + }) + + when("label manager returns an error for `Metadata`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("failed to parse") + + labelManager := newLabelManager(errorForMetadata(expectedBaseError)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.ErrorWithMessage(err, "reading image metadata: failed to parse") + }) + }) + + when("label manager returns an error for `StackID`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("label not found") + + labelManager := newLabelManager(errorForStackID(expectedBaseError)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.ErrorWithMessage(err, "reading image stack id: label not found") + }) + }) + + when("label manager returns an error for `Mixins`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("label not found") + + labelManager := newLabelManager(errorForMixins(expectedBaseError)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.ErrorWithMessage(err, "reading image mixins: label not found") + }) + }) + + when("label manager returns an error for `Order`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("label not found") + + labelManager := newLabelManager(errorForOrder(expectedBaseError)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.ErrorWithMessage(err, "reading image order: label not found") + }) + }) + + when("label manager returns an error for `BuildpackLayers`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("label not found") + + labelManager := newLabelManager(errorForBuildpackLayers(expectedBaseError)) + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newLabelManagerFactory(labelManager), + newDefaultDetectionCalculator(), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionNone) + + assert.ErrorWithMessage(err, "reading image buildpack layers: label not found") + }) + }) + + when("detection calculator returns an error for `Order`", func() { + it("returns the wrapped error", func() { + expectedBaseError := errors.New("couldn't read label") + + inspector := builder.NewInspector( + newDefaultInspectableFetcher(), + newDefaultLabelManagerFactory(), + newDetectionCalculator(errorForDetectionOrder(expectedBaseError)), + ) + _, err := inspector.Inspect(testBuilderName, true, pubbldr.OrderDetectionMaxDepth) + + assert.ErrorWithMessage(err, "calculating detection order: couldn't read label") + }) + }) + }) +} + +func newDefaultInspectableFetcher() *fakes.FakeInspectableFetcher { + return &fakes.FakeInspectableFetcher{ + InspectableToReturn: newNoOpInspectable(), + } +} + +func newNoOpInspectable() *fakes.FakeInspectable { + return &fakes.FakeInspectable{} +} + +func newDefaultLabelManagerFactory() *fakes.FakeLabelManagerFactory { + return newLabelManagerFactory(newDefaultLabelManager()) +} + +func newLabelManagerFactory(manager builder.LabelInspector) *fakes.FakeLabelManagerFactory { + return fakes.NewFakeLabelManagerFactory(manager) +} + +func newDefaultLabelManager() *fakes.FakeLabelManager { + return &fakes.FakeLabelManager{ + ReturnForMetadata: testMetadata, + ReturnForStackID: testStackID, + ReturnForMixins: testMixins, + ReturnForOrder: testOrder, + ReturnForBuildpackLayers: testLayers, + } +} + +type labelManagerModifier func(manager *fakes.FakeLabelManager) + +func returnForMetadata(metadata builder.Metadata) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ReturnForMetadata = metadata + } +} + +func errorForMetadata(err error) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ErrorForMetadata = err + } +} + +func errorForStackID(err error) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ErrorForStackID = err + } +} + +func errorForMixins(err error) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ErrorForMixins = err + } +} + +func errorForOrder(err error) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ErrorForOrder = err + } +} + +func errorForBuildpackLayers(err error) labelManagerModifier { + return func(manager *fakes.FakeLabelManager) { + manager.ErrorForBuildpackLayers = err + } +} + +func newLabelManager(modifiers ...labelManagerModifier) *fakes.FakeLabelManager { + manager := newDefaultLabelManager() + + for _, mod := range modifiers { + mod(manager) + } + + return manager +} + +func newDefaultDetectionCalculator() *fakes.FakeDetectionCalculator { + return &fakes.FakeDetectionCalculator{ + ReturnForOrder: expectedDetectionTestOrder, + } +} + +type detectionCalculatorModifier func(calculator *fakes.FakeDetectionCalculator) + +func errorForDetectionOrder(err error) detectionCalculatorModifier { + return func(calculator *fakes.FakeDetectionCalculator) { + calculator.ErrorForOrder = err + } +} + +func newDetectionCalculator(modifiers ...detectionCalculatorModifier) *fakes.FakeDetectionCalculator { + calculator := newDefaultDetectionCalculator() + + for _, mod := range modifiers { + mod(calculator) + } + + return calculator +} diff --git a/internal/builder/label_manager.go b/internal/builder/label_manager.go new file mode 100644 index 000000000..40a04df87 --- /dev/null +++ b/internal/builder/label_manager.go @@ -0,0 +1,91 @@ +package builder + +import ( + "encoding/json" + "fmt" + + "github.com/buildpacks/pack/internal/dist" + + "github.com/buildpacks/pack/internal/stack" +) + +type LabelManager struct { + inspectable Inspectable +} + +func NewLabelManager(inspectable Inspectable) *LabelManager { + return &LabelManager{inspectable: inspectable} +} + +func (m *LabelManager) Metadata() (Metadata, error) { + var parsedMetadata Metadata + err := m.labelJSON(metadataLabel, &parsedMetadata) + return parsedMetadata, err +} + +func (m *LabelManager) StackID() (string, error) { + return m.labelContent(stackLabel) +} + +func (m *LabelManager) Mixins() ([]string, error) { + parsedMixins := []string{} + err := m.labelJSONDefaultEmpty(stack.MixinsLabel, &parsedMixins) + return parsedMixins, err +} + +func (m *LabelManager) Order() (dist.Order, error) { + parsedOrder := dist.Order{} + err := m.labelJSONDefaultEmpty(OrderLabel, &parsedOrder) + return parsedOrder, err +} + +func (m *LabelManager) BuildpackLayers() (dist.BuildpackLayers, error) { + parsedLayers := dist.BuildpackLayers{} + err := m.labelJSONDefaultEmpty(dist.BuildpackLayersLabel, &parsedLayers) + return parsedLayers, err +} + +func (m *LabelManager) labelContent(labelName string) (string, error) { + content, err := m.inspectable.Label(labelName) + if err != nil { + return "", fmt.Errorf("getting label %s: %w", labelName, err) + } + + if content == "" { + return "", fmt.Errorf("builder missing label %s -- try recreating builder", labelName) + } + + return content, nil +} + +func (m *LabelManager) labelJSON(labelName string, targetObject interface{}) error { + rawContent, err := m.labelContent(labelName) + if err != nil { + return err + } + + err = json.Unmarshal([]byte(rawContent), targetObject) + if err != nil { + return fmt.Errorf("parsing label content for %s: %w", labelName, err) + } + + return nil +} + +func (m *LabelManager) labelJSONDefaultEmpty(labelName string, targetObject interface{}) error { + rawContent, err := m.inspectable.Label(labelName) + if err != nil { + return fmt.Errorf("getting label %s: %w", labelName, err) + } + + if rawContent == "" { + return nil + } + + err = json.Unmarshal([]byte(rawContent), targetObject) + if err != nil { + return fmt.Errorf("parsing label content for %s: %w", labelName, err) + } + + return nil +} diff --git a/internal/builder/label_manager_provider.go b/internal/builder/label_manager_provider.go new file mode 100644 index 000000000..4c701eda2 --- /dev/null +++ b/internal/builder/label_manager_provider.go @@ -0,0 +1,11 @@ +package builder + +type LabelManagerProvider struct{} + +func NewLabelManagerProvider() *LabelManagerProvider { + return &LabelManagerProvider{} +} + +func (p *LabelManagerProvider) BuilderLabelManager(inspectable Inspectable) LabelInspector { + return NewLabelManager(inspectable) +} diff --git a/internal/builder/label_manager_test.go b/internal/builder/label_manager_test.go new file mode 100644 index 000000000..f9452f1d5 --- /dev/null +++ b/internal/builder/label_manager_test.go @@ -0,0 +1,516 @@ +package builder_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/fakes" + "github.com/buildpacks/pack/internal/dist" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestLabelManager(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "testLabelManager", testLabelManager, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testLabelManager(t *testing.T, when spec.G, it spec.S) { + var assert = h.NewAssertionManager(t) + + when("Metadata", func() { + const ( + buildpackFormat = `{ + "id": "%s", + "version": "%s", + "homepage": "%s" + }` + lifecycleFormat = `{ + "version": "%s", + "api": { + "buildpack": "%s", + "platform": "%s" + }, + "apis": { + "buildpack": {"deprecated": ["%s"], "supported": ["%s", "%s"]}, + "platform": {"deprecated": ["%s"], "supported": ["%s", "%s"]} + } + }` + metadataFormat = `{ + "description": "%s", + "stack": { + "runImage": { + "image": "%s", + "mirrors": ["%s"] + } + }, + "buildpacks": [ + %s, + %s + ], + "lifecycle": %s, + "createdBy": {"name": "%s", "version": "%s"} +}` + ) + + var ( + expectedDescription = "Test image description" + expectedRunImage = "some/run-image" + expectedRunImageMirror = "gcr.io/some/default" + expectedBuildpacks = []dist.BuildpackInfo{ + { + ID: "test.buildpack", + Version: "test.buildpack.version", + Homepage: "http://geocities.com/test-bp", + }, + { + ID: "test.buildpack.two", + Version: "test.buildpack.two.version", + Homepage: "http://geocities.com/test-bp-two", + }, + } + expectedLifecycleVersion = builder.VersionMustParse("1.2.3") + expectedBuildpackAPI = api.MustParse("0.1") + expectedPlatformAPI = api.MustParse("2.3") + expectedBuildpackDeprecated = "0.1" + expectedBuildpackSupported = []string{"1.2", "1.3"} + expectedPlatformDeprecated = "1.2" + expectedPlatformSupported = []string{"2.3", "2.4"} + expectedCreatorName = "pack" + expectedVersion = "2.3.4" + + rawMetadata = fmt.Sprintf( + metadataFormat, + expectedDescription, + expectedRunImage, + expectedRunImageMirror, + fmt.Sprintf( + buildpackFormat, + expectedBuildpacks[0].ID, + expectedBuildpacks[0].Version, + expectedBuildpacks[0].Homepage, + ), + fmt.Sprintf( + buildpackFormat, + expectedBuildpacks[1].ID, + expectedBuildpacks[1].Version, + expectedBuildpacks[1].Homepage, + ), + fmt.Sprintf( + lifecycleFormat, + expectedLifecycleVersion, + expectedBuildpackAPI, + expectedPlatformAPI, + expectedBuildpackDeprecated, + expectedBuildpackSupported[0], + expectedBuildpackSupported[1], + expectedPlatformDeprecated, + expectedPlatformSupported[0], + expectedPlatformSupported[1], + ), + expectedCreatorName, + expectedVersion, + ) + ) + + it("returns full metadata", func() { + inspectable := newInspectable(returnForLabel(rawMetadata)) + + labelManager := builder.NewLabelManager(inspectable) + metadata, err := labelManager.Metadata() + assert.Nil(err) + assert.Equal(metadata.Description, expectedDescription) + assert.Equal(metadata.Stack.RunImage.Image, expectedRunImage) + assert.Equal(metadata.Stack.RunImage.Mirrors, []string{expectedRunImageMirror}) + assert.Equal(metadata.Buildpacks, expectedBuildpacks) + assert.Equal(metadata.Lifecycle.Version, expectedLifecycleVersion) + assert.Equal(metadata.Lifecycle.API.BuildpackVersion, expectedBuildpackAPI) + assert.Equal(metadata.Lifecycle.API.PlatformVersion, expectedPlatformAPI) + assert.Equal(metadata.Lifecycle.APIs.Buildpack.Deprecated.AsStrings(), []string{expectedBuildpackDeprecated}) + assert.Equal(metadata.Lifecycle.APIs.Buildpack.Supported.AsStrings(), expectedBuildpackSupported) + assert.Equal(metadata.Lifecycle.APIs.Platform.Deprecated.AsStrings(), []string{expectedPlatformDeprecated}) + assert.Equal(metadata.Lifecycle.APIs.Platform.Supported.AsStrings(), expectedPlatformSupported) + assert.Equal(metadata.CreatedBy.Name, expectedCreatorName) + assert.Equal(metadata.CreatedBy.Version, expectedVersion) + }) + + it("requests the expected label", func() { + inspectable := newInspectable(returnForLabel(rawMetadata)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Metadata() + assert.Nil(err) + + assert.Equal(inspectable.ReceivedName, "io.buildpacks.builder.metadata") + }) + + when("inspectable returns an error for `Label`", func() { + it("returns a wrapped error", func() { + expectedError := errors.New("couldn't find label") + + inspectable := newInspectable(errorForLabel(expectedError)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Metadata() + + assert.ErrorWithMessage( + err, + "getting label io.buildpacks.builder.metadata: couldn't find label", + ) + }) + }) + + when("inspectable returns invalid json for `Label`", func() { + it("returns a wrapped error", func() { + inspectable := newInspectable(returnForLabel("{")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Metadata() + + assert.ErrorWithMessage( + err, + "parsing label content for io.buildpacks.builder.metadata: unexpected end of JSON input", + ) + }) + }) + + when("inspectable returns empty content for `Label`", func() { + it("returns an error suggesting rebuilding the builder", func() { + inspectable := newInspectable(returnForLabel("")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Metadata() + + assert.ErrorWithMessage( + err, + "builder missing label io.buildpacks.builder.metadata -- try recreating builder", + ) + }) + }) + }) + + when("StackID", func() { + it("returns the stack ID", func() { + inspectable := newInspectable(returnForLabel("some.stack.id")) + + labelManager := builder.NewLabelManager(inspectable) + stackID, err := labelManager.StackID() + assert.Nil(err) + + assert.Equal(stackID, "some.stack.id") + }) + + it("requests the expected label", func() { + inspectable := newInspectable(returnForLabel("some.stack.id")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.StackID() + assert.Nil(err) + + assert.Equal(inspectable.ReceivedName, "io.buildpacks.stack.id") + }) + + when("inspectable return empty content for `Label`", func() { + it("returns an error suggesting rebuilding the builder", func() { + inspectable := newInspectable(returnForLabel("")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.StackID() + + assert.ErrorWithMessage( + err, + "builder missing label io.buildpacks.stack.id -- try recreating builder", + ) + }) + }) + + when("inspectable returns an error for `Label`", func() { + it("returns a wrapped error", func() { + expectedError := errors.New("couldn't find label") + + inspectable := newInspectable(errorForLabel(expectedError)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.StackID() + + assert.ErrorWithMessage( + err, + "getting label io.buildpacks.stack.id: couldn't find label", + ) + }) + }) + }) + + when("Mixins", func() { + it("returns the mixins", func() { + inspectable := newInspectable(returnForLabel(`["mixinX", "mixinY", "build:mixinA"]`)) + + labelManager := builder.NewLabelManager(inspectable) + mixins, err := labelManager.Mixins() + assert.Nil(err) + + assert.Equal(mixins, []string{"mixinX", "mixinY", "build:mixinA"}) + }) + + it("requests the expected label", func() { + inspectable := newInspectable(returnForLabel(`["mixinX", "mixinY", "build:mixinA"]`)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Mixins() + assert.Nil(err) + + assert.Equal(inspectable.ReceivedName, "io.buildpacks.stack.mixins") + }) + + when("inspectable return empty content for `Label`", func() { + it("returns empty stack mixins", func() { + inspectable := newInspectable(returnForLabel("")) + + labelManager := builder.NewLabelManager(inspectable) + mixins, err := labelManager.Mixins() + assert.Nil(err) + + assert.Equal(mixins, []string{}) + }) + }) + + when("inspectable returns an error for `Label`", func() { + it("returns a wrapped error", func() { + expectedError := errors.New("couldn't find label") + + inspectable := newInspectable(errorForLabel(expectedError)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Mixins() + + assert.ErrorWithMessage( + err, + "getting label io.buildpacks.stack.mixins: couldn't find label", + ) + }) + }) + + when("inspectable returns invalid json for `Label`", func() { + it("returns a wrapped error", func() { + inspectable := newInspectable(returnForLabel("{")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Mixins() + + assert.ErrorWithMessage( + err, + "parsing label content for io.buildpacks.stack.mixins: unexpected end of JSON input", + ) + }) + }) + }) + + when("Order", func() { + var rawOrder = `[{"group": [{"id": "buildpack-1-id", "optional": false}, {"id": "buildpack-2-id", "version": "buildpack-2-version-1", "optional": true}]}]` + + it("returns the order", func() { + inspectable := newInspectable(returnForLabel(rawOrder)) + + labelManager := builder.NewLabelManager(inspectable) + mixins, err := labelManager.Order() + assert.Nil(err) + + expectedOrder := dist.Order{ + { + Group: []dist.BuildpackRef{ + { + BuildpackInfo: dist.BuildpackInfo{ + ID: "buildpack-1-id", + }, + }, + { + BuildpackInfo: dist.BuildpackInfo{ + ID: "buildpack-2-id", + Version: "buildpack-2-version-1", + }, + Optional: true, + }, + }, + }, + } + + assert.Equal(mixins, expectedOrder) + }) + + it("requests the expected label", func() { + inspectable := newInspectable(returnForLabel(rawOrder)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Order() + assert.Nil(err) + + assert.Equal(inspectable.ReceivedName, "io.buildpacks.buildpack.order") + }) + + when("inspectable return empty content for `Label`", func() { + it("returns an empty order object", func() { + inspectable := newInspectable(returnForLabel("")) + + labelManager := builder.NewLabelManager(inspectable) + order, err := labelManager.Order() + assert.Nil(err) + + assert.Equal(order, dist.Order{}) + }) + }) + + when("inspectable returns an error for `Label`", func() { + it("returns a wrapped error", func() { + expectedError := errors.New("couldn't find label") + + inspectable := newInspectable(errorForLabel(expectedError)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Order() + + assert.ErrorWithMessage( + err, + "getting label io.buildpacks.buildpack.order: couldn't find label", + ) + }) + }) + + when("inspectable returns invalid json for `Label`", func() { + it("returns a wrapped error", func() { + inspectable := newInspectable(returnForLabel("{")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.Order() + + assert.ErrorWithMessage( + err, + "parsing label content for io.buildpacks.buildpack.order: unexpected end of JSON input", + ) + }) + }) + }) + + when("BuildpackLayers", func() { + var rawLayers = ` +{ + "buildpack-1-id": { + "buildpack-1-version-1": { + "api": "0.1", + "layerDiffID": "sha256:buildpack-1-version-1-diff-id" + }, + "buildpack-1-version-2": { + "api": "0.2", + "layerDiffID": "sha256:buildpack-1-version-2-diff-id" + } + } +} +` + + it("returns the layers", func() { + inspectable := newInspectable(returnForLabel(rawLayers)) + + labelManager := builder.NewLabelManager(inspectable) + layers, err := labelManager.BuildpackLayers() + assert.Nil(err) + + expectedLayers := dist.BuildpackLayers{ + "buildpack-1-id": { + "buildpack-1-version-1": dist.BuildpackLayerInfo{ + API: api.MustParse("0.1"), + LayerDiffID: "sha256:buildpack-1-version-1-diff-id", + }, + "buildpack-1-version-2": dist.BuildpackLayerInfo{ + API: api.MustParse("0.2"), + LayerDiffID: "sha256:buildpack-1-version-2-diff-id", + }, + }, + } + + assert.Equal(layers, expectedLayers) + }) + + it("requests the expected label", func() { + inspectable := newInspectable(returnForLabel(rawLayers)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.BuildpackLayers() + assert.Nil(err) + + assert.Equal(inspectable.ReceivedName, "io.buildpacks.buildpack.layers") + }) + + when("inspectable return empty content for `Label`", func() { + it("returns an empty buildpack layers object", func() { + inspectable := newInspectable(returnForLabel("")) + + labelManager := builder.NewLabelManager(inspectable) + layers, err := labelManager.BuildpackLayers() + assert.Nil(err) + + assert.Equal(layers, dist.BuildpackLayers{}) + }) + }) + + when("inspectable returns an error for `Label`", func() { + it("returns a wrapped error", func() { + expectedError := errors.New("couldn't find label") + + inspectable := newInspectable(errorForLabel(expectedError)) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.BuildpackLayers() + + assert.ErrorWithMessage( + err, + "getting label io.buildpacks.buildpack.layers: couldn't find label", + ) + }) + }) + + when("inspectable returns invalid json for `Label`", func() { + it("returns a wrapped error", func() { + inspectable := newInspectable(returnForLabel("{")) + + labelManager := builder.NewLabelManager(inspectable) + _, err := labelManager.BuildpackLayers() + + assert.ErrorWithMessage( + err, + "parsing label content for io.buildpacks.buildpack.layers: unexpected end of JSON input", + ) + }) + }) + }) +} + +type inspectableModifier func(i *fakes.FakeInspectable) + +func returnForLabel(response string) inspectableModifier { + return func(i *fakes.FakeInspectable) { + i.ReturnForLabel = response + } +} + +func errorForLabel(err error) inspectableModifier { + return func(i *fakes.FakeInspectable) { + i.ErrorForLabel = err + } +} + +func newInspectable(modifiers ...inspectableModifier) *fakes.FakeInspectable { + inspectable := &fakes.FakeInspectable{} + + for _, mod := range modifiers { + mod(inspectable) + } + + return inspectable +} diff --git a/internal/builder/metadata.go b/internal/builder/metadata.go index 8f8082120..b581e7f03 100644 --- a/internal/builder/metadata.go +++ b/internal/builder/metadata.go @@ -15,8 +15,8 @@ type Metadata struct { } type CreatorMetadata struct { - Name string `json:"name"` - Version string `json:"version"` + Name string `json:"name" yaml:"name"` + Version string `json:"version" yaml:"version"` } type LifecycleMetadata struct { diff --git a/internal/builder/writer/factory.go b/internal/builder/writer/factory.go new file mode 100644 index 000000000..35ece8173 --- /dev/null +++ b/internal/builder/writer/factory.go @@ -0,0 +1,52 @@ +package writer + +import ( + "fmt" + + "github.com/buildpacks/pack" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/logging" + + "github.com/buildpacks/pack/internal/style" +) + +type Factory struct{} + +type BuilderWriter interface { + Print( + logger logging.Logger, + localRunImages []config.RunImage, + local, remote *pack.BuilderInfo, + localErr, remoteErr error, + builderInfo SharedBuilderInfo, + ) error +} + +type SharedBuilderInfo struct { + Name string `json:"builder_name" yaml:"builder_name" toml:"builder_name"` + Trusted bool `json:"trusted" yaml:"trusted" toml:"trusted"` + IsDefault bool `json:"default" yaml:"default" toml:"default"` +} + +type BuilderWriterFactory interface { + Writer(kind string) (BuilderWriter, error) +} + +func NewFactory() *Factory { + return &Factory{} +} + +func (f *Factory) Writer(kind string) (BuilderWriter, error) { + switch kind { + case "human-readable": + return NewHumanReadable(), nil + case "json": + return NewJSON(), nil + case "yaml": + return NewYAML(), nil + case "toml": + return NewTOML(), nil + } + + return nil, fmt.Errorf("output format %s is not supported", style.Symbol(kind)) +} diff --git a/internal/builder/writer/factory_test.go b/internal/builder/writer/factory_test.go new file mode 100644 index 000000000..bb081f3cb --- /dev/null +++ b/internal/builder/writer/factory_test.go @@ -0,0 +1,93 @@ +package writer_test + +import ( + "fmt" + "testing" + + "github.com/buildpacks/pack/internal/builder/writer" + h "github.com/buildpacks/pack/testhelpers" + + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestFactory(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer Factory", testFactory, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testFactory(t *testing.T, when spec.G, it spec.S) { + var assert = h.NewAssertionManager(t) + + when("Writer", func() { + when("output format is human-readable", func() { + it("returns a HumanReadable writer", func() { + factory := writer.NewFactory() + + returnedWriter, err := factory.Writer("human-readable") + assert.Nil(err) + _, ok := returnedWriter.(*writer.HumanReadable) + assert.TrueWithMessage( + ok, + fmt.Sprintf("expected %T to be assignable to type `*writer.HumanReadable`", returnedWriter), + ) + }) + }) + + when("output format is json", func() { + it("return a JSON writer", func() { + factory := writer.NewFactory() + + returnedWriter, err := factory.Writer("json") + assert.Nil(err) + + _, ok := returnedWriter.(*writer.JSON) + assert.TrueWithMessage( + ok, + fmt.Sprintf("expected %T to be assignable to type `*writer.JSON`", returnedWriter), + ) + }) + }) + + when("output format is yaml", func() { + it("return a YAML writer", func() { + factory := writer.NewFactory() + + returnedWriter, err := factory.Writer("yaml") + assert.Nil(err) + + _, ok := returnedWriter.(*writer.YAML) + assert.TrueWithMessage( + ok, + fmt.Sprintf("expected %T to be assignable to type `*writer.YAML`", returnedWriter), + ) + }) + }) + + when("output format is toml", func() { + it("return a TOML writer", func() { + factory := writer.NewFactory() + + returnedWriter, err := factory.Writer("toml") + assert.Nil(err) + + _, ok := returnedWriter.(*writer.TOML) + assert.TrueWithMessage( + ok, + fmt.Sprintf("expected %T to be assignable to type `*writer.TOML`", returnedWriter), + ) + }) + }) + + when("output format is not supported", func() { + it("returns an error", func() { + factory := writer.NewFactory() + + _, err := factory.Writer("mind-beam") + assert.ErrorWithMessage(err, "output format 'mind-beam' is not supported") + }) + }) + }) +} diff --git a/internal/builder/writer/human_readable.go b/internal/builder/writer/human_readable.go new file mode 100644 index 000000000..105a7cff1 --- /dev/null +++ b/internal/builder/writer/human_readable.go @@ -0,0 +1,502 @@ +package writer + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" + "text/template" + + "github.com/buildpacks/pack/internal/style" + + "github.com/buildpacks/pack/internal/dist" + + pubbldr "github.com/buildpacks/pack/builder" + + "github.com/buildpacks/pack/internal/config" + + "github.com/buildpacks/pack" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/logging" +) + +const ( + writerMinWidth = 0 + writerTabWidth = 0 + buildpacksTabWidth = 8 + defaultTabWidth = 4 + writerPadChar = ' ' + writerFlags = 0 + none = "(none)" + + outputTemplate = ` +{{ if ne .Info.Description "" -}} +Description: {{ .Info.Description }} + +{{ end -}} +{{- if ne .Info.CreatedBy.Name "" -}} +Created By: + Name: {{ .Info.CreatedBy.Name }} + Version: {{ .Info.CreatedBy.Version }} + +{{ end -}} + +Trusted: {{.Trusted}} + +Stack: + ID: {{ .Info.Stack }} +{{- if .Verbose}} +{{- if ne (len .Info.Mixins) 0 }} + Mixins: +{{- end }} +{{- range $index, $mixin := .Info.Mixins }} + {{ $mixin }} +{{- end }} +{{- end }} +{{ .Lifecycle }} +{{ .RunImages }} +{{ .Buildpacks }} +{{ .Order }}` +) + +type HumanReadable struct{} + +func NewHumanReadable() *HumanReadable { + return &HumanReadable{} +} + +func (h *HumanReadable) Print( + logger logging.Logger, + localRunImages []config.RunImage, + local, remote *pack.BuilderInfo, + localErr, remoteErr error, + builderInfo SharedBuilderInfo, +) error { + if local == nil && remote == nil { + return fmt.Errorf("unable to find builder '%s' locally or remotely", builderInfo.Name) + } + + if builderInfo.IsDefault { + logger.Infof("Inspecting default builder: %s\n", style.Symbol(builderInfo.Name)) + } else { + logger.Infof("Inspecting builder: %s\n", style.Symbol(builderInfo.Name)) + } + + logger.Info("\nREMOTE:\n") + err := writeBuilderInfo(logger, localRunImages, remote, remoteErr, builderInfo) + if err != nil { + return fmt.Errorf("writing remote builder info: %w", err) + } + logger.Info("\nLOCAL:\n") + err = writeBuilderInfo(logger, localRunImages, local, localErr, builderInfo) + if err != nil { + return fmt.Errorf("writing local builder info: %w", err) + } + + return nil +} + +func writeBuilderInfo( + logger logging.Logger, + localRunImages []config.RunImage, + info *pack.BuilderInfo, + err error, + sharedInfo SharedBuilderInfo, +) error { + if err != nil { + logger.Errorf("%s\n", err) + return nil + } + + if info == nil { + logger.Info("(not present)\n") + return nil + } + + var warnings []string + + runImagesString, runImagesWarnings, err := runImagesOutput(info.RunImage, localRunImages, info.RunImageMirrors, sharedInfo.Name) + if err != nil { + return fmt.Errorf("compiling run images output: %w", err) + } + orderString, orderWarnings, err := detectionOrderOutput(info.Order, sharedInfo.Name) + if err != nil { + return fmt.Errorf("compiling detection order output: %w", err) + } + buildpacksString, buildpacksWarnings, err := buildpacksOutput(info.Buildpacks, sharedInfo.Name) + if err != nil { + return fmt.Errorf("compiling buildpacks output: %w", err) + } + lifecycleString, lifecycleWarnings := lifecycleOutput(info.Lifecycle, sharedInfo.Name) + + warnings = append(warnings, runImagesWarnings...) + warnings = append(warnings, orderWarnings...) + warnings = append(warnings, buildpacksWarnings...) + warnings = append(warnings, lifecycleWarnings...) + + outputTemplate, _ := template.New("").Parse(outputTemplate) + err = outputTemplate.Execute( + logger.Writer(), + &struct { + Info pack.BuilderInfo + Verbose bool + Buildpacks string + RunImages string + Order string + Trusted string + Lifecycle string + }{ + *info, + logger.IsVerbose(), + buildpacksString, + runImagesString, + orderString, + stringFromBool(sharedInfo.Trusted), + lifecycleString, + }, + ) + + for _, warning := range warnings { + logger.Warn(warning) + } + + return err +} + +type trailingSpaceStrippingWriter struct { + output io.Writer + + potentialDiscard []byte +} + +func (w *trailingSpaceStrippingWriter) Write(p []byte) (n int, err error) { + var doWrite []byte + + for _, b := range p { + switch b { + case writerPadChar: + w.potentialDiscard = append(w.potentialDiscard, b) + case '\n': + w.potentialDiscard = []byte{} + doWrite = append(doWrite, b) + default: + doWrite = append(doWrite, w.potentialDiscard...) + doWrite = append(doWrite, b) + w.potentialDiscard = []byte{} + } + } + + if len(doWrite) > 0 { + actualWrote, err := w.output.Write(doWrite) + if err != nil { + return actualWrote, err + } + } + + return len(p), nil +} + +func stringFromBool(subject bool) string { + if subject { + return "Yes" + } + + return "No" +} + +func runImagesOutput( + runImage string, + localRunImages []config.RunImage, + buildRunImages []string, + builderName string, +) (string, []string, error) { + output := "Run Images:\n" + + tabWriterBuf := bytes.Buffer{} + + localMirrorTabWriter := tabwriter.NewWriter(&tabWriterBuf, writerMinWidth, writerTabWidth, defaultTabWidth, writerPadChar, writerFlags) + err := writeLocalMirrors(localMirrorTabWriter, runImage, localRunImages) + if err != nil { + return "", []string{}, fmt.Errorf("writing local mirrors: %w", err) + } + + var warnings []string + + if runImage != "" { + _, err = fmt.Fprintf(localMirrorTabWriter, " %s\n", runImage) + if err != nil { + return "", []string{}, fmt.Errorf("writing to tabwriter: %w", err) + } + } else { + warnings = append( + warnings, + fmt.Sprintf("%s does not specify a run image", builderName), + "Users must build with an explicitly specified run image", + ) + } + for _, m := range buildRunImages { + _, err = fmt.Fprintf(localMirrorTabWriter, " %s\n", m) + if err != nil { + return "", []string{}, fmt.Errorf("writing to tab writer: %w", err) + } + } + err = localMirrorTabWriter.Flush() + if err != nil { + return "", []string{}, fmt.Errorf("flushing tab writer: %w", err) + } + + runImageOutput := tabWriterBuf.String() + if runImageOutput == "" { + runImageOutput = fmt.Sprintf(" %s\n", none) + } + + output += runImageOutput + + return output, warnings, nil +} + +func writeLocalMirrors(logWriter io.Writer, runImage string, localRunImages []config.RunImage) error { + for _, i := range localRunImages { + if i.Image == runImage { + for _, m := range i.Mirrors { + _, err := fmt.Fprintf(logWriter, " %s\t(user-configured)\n", m) + if err != nil { + return fmt.Errorf("writing local mirror: %s: %w", m, err) + } + } + } + } + + return nil +} + +func buildpacksOutput(buildpacks []dist.BuildpackInfo, builderName string) (string, []string, error) { + output := "Buildpacks:\n" + + if len(buildpacks) == 0 { + warnings := []string{ + fmt.Sprintf("%s has no buildpacks", builderName), + "Users must supply buildpacks from the host machine", + } + + return fmt.Sprintf("%s %s\n", output, none), warnings, nil + } + + tabWriterBuf := bytes.Buffer{} + spaceStrippingWriter := &trailingSpaceStrippingWriter{ + output: &tabWriterBuf, + } + + buildpacksTabWriter := tabwriter.NewWriter(spaceStrippingWriter, writerMinWidth, writerPadChar, buildpacksTabWidth, writerPadChar, writerFlags) + _, err := fmt.Fprint(buildpacksTabWriter, " ID\tVERSION\tHOMEPAGE\n") + if err != nil { + return "", []string{}, fmt.Errorf("writing to tab writer: %w", err) + } + for _, b := range buildpacks { + _, err = fmt.Fprintf(buildpacksTabWriter, " %s\t%s\t%s\n", b.ID, b.Version, b.Homepage) + if err != nil { + return "", []string{}, fmt.Errorf("writing to tab writer: %w", err) + } + } + err = buildpacksTabWriter.Flush() + if err != nil { + return "", []string{}, fmt.Errorf("flushing tab writer: %w", err) + } + + output += tabWriterBuf.String() + + return output, []string{}, nil +} + +const lifecycleFormat = ` +Lifecycle: + Version: %s + Buildpack APIs: + Deprecated: %s + Supported: %s + Platform APIs: + Deprecated: %s + Supported: %s +` + +func lifecycleOutput(lifecycleInfo builder.LifecycleDescriptor, builderName string) (string, []string) { + var warnings []string + + version := none + if lifecycleInfo.Info.Version != nil { + version = lifecycleInfo.Info.Version.String() + } + + if version == none { + warnings = append(warnings, fmt.Sprintf("%s does not specify a Lifecycle version", builderName)) + } + + supportedBuildpackAPIs := stringFromAPISet(lifecycleInfo.APIs.Buildpack.Supported) + if supportedBuildpackAPIs == none { + warnings = append(warnings, fmt.Sprintf("%s does not specify supported Lifecycle Buildpack APIs", builderName)) + } + + supportedPlatformAPIs := stringFromAPISet(lifecycleInfo.APIs.Platform.Supported) + if supportedPlatformAPIs == none { + warnings = append(warnings, fmt.Sprintf("%s does not specify supported Lifecycle Platform APIs", builderName)) + } + + return fmt.Sprintf( + lifecycleFormat, + version, + stringFromAPISet(lifecycleInfo.APIs.Buildpack.Deprecated), + supportedBuildpackAPIs, + stringFromAPISet(lifecycleInfo.APIs.Platform.Deprecated), + supportedPlatformAPIs, + ), warnings +} + +func stringFromAPISet(versions builder.APISet) string { + if len(versions) == 0 { + return none + } + + return strings.Join(versions.AsStrings(), ", ") +} + +const ( + branchPrefix = " ├ " + lastBranchPrefix = " └ " + trunkPrefix = " │ " +) + +func detectionOrderOutput(order pubbldr.DetectionOrder, builderName string) (string, []string, error) { + output := "Detection Order:\n" + + if len(order) == 0 { + warnings := []string{ + fmt.Sprintf("%s has no buildpacks", builderName), + "Users must build with explicitly specified buildpacks", + } + + return fmt.Sprintf("%s %s\n", output, none), warnings, nil + } + + tabWriterBuf := bytes.Buffer{} + spaceStrippingWriter := &trailingSpaceStrippingWriter{ + output: &tabWriterBuf, + } + + detectionOrderTabWriter := tabwriter.NewWriter(spaceStrippingWriter, writerMinWidth, writerTabWidth, defaultTabWidth, writerPadChar, writerFlags) + err := writeDetectionOrderGroup(detectionOrderTabWriter, order, "") + if err != nil { + return "", []string{}, fmt.Errorf("writing detection order group: %w", err) + } + err = detectionOrderTabWriter.Flush() + if err != nil { + return "", []string{}, fmt.Errorf("flushing tab writer: %w", err) + } + + output += tabWriterBuf.String() + return output, []string{}, nil +} + +func writeDetectionOrderGroup(writer io.Writer, order pubbldr.DetectionOrder, prefix string) error { + groupNumber := 0 + + for i, orderEntry := range order { + lastInGroup := i == len(order)-1 + includesSubGroup := len(orderEntry.GroupDetectionOrder) > 0 + + orderPrefix, err := writeAndUpdateEntryPrefix(writer, lastInGroup, prefix) + if err != nil { + return fmt.Errorf("writing detection group prefix: %w", err) + } + + if includesSubGroup { + groupPrefix := orderPrefix + + if orderEntry.ID != "" { + err = writeDetectionOrderBuildpack(writer, orderEntry) + if err != nil { + return fmt.Errorf("writing detection order buildpack: %w", err) + } + + if lastInGroup { + _, err = fmt.Fprintf(writer, "%s%s", groupPrefix, lastBranchPrefix) + if err != nil { + return fmt.Errorf("writing to detection order group writer: %w", err) + } + groupPrefix = fmt.Sprintf("%s ", groupPrefix) + } else { + _, err = fmt.Fprintf(writer, "%s%s", orderPrefix, lastBranchPrefix) + if err != nil { + return fmt.Errorf("writing to detection order group writer: %w", err) + } + groupPrefix = fmt.Sprintf("%s ", groupPrefix) + } + } + + groupNumber++ + _, err = fmt.Fprintf(writer, "Group #%d:\n", groupNumber) + if err != nil { + return fmt.Errorf("writing to detection order group writer: %w", err) + } + err = writeDetectionOrderGroup(writer, orderEntry.GroupDetectionOrder, groupPrefix) + if err != nil { + return fmt.Errorf("writing detection order group: %w", err) + } + } else { + err := writeDetectionOrderBuildpack(writer, orderEntry) + if err != nil { + return fmt.Errorf("writing detection order buildpack: %w", err) + } + } + } + + return nil +} + +func writeAndUpdateEntryPrefix(writer io.Writer, last bool, prefix string) (string, error) { + if last { + _, err := fmt.Fprintf(writer, "%s%s", prefix, lastBranchPrefix) + if err != nil { + return "", fmt.Errorf("writing detection order prefix: %w", err) + } + return fmt.Sprintf("%s%s", prefix, " "), nil + } + + _, err := fmt.Fprintf(writer, "%s%s", prefix, branchPrefix) + if err != nil { + return "", fmt.Errorf("writing detection order prefix: %w", err) + } + return fmt.Sprintf("%s%s", prefix, trunkPrefix), nil +} + +func writeDetectionOrderBuildpack(writer io.Writer, entry pubbldr.DetectionOrderEntry) error { + _, err := fmt.Fprintf( + writer, + "%s\t%s%s\n", + entry.FullName(), + stringFromOptional(entry.Optional), + stringFromCyclical(entry.Cyclical), + ) + + if err != nil { + return fmt.Errorf("writing buildpack in detection order: %w", err) + } + + return nil +} + +func stringFromOptional(optional bool) string { + if optional { + return "(optional)" + } + + return "" +} + +func stringFromCyclical(cyclical bool) string { + if cyclical { + return "[cyclic]" + } + + return "" +} diff --git a/internal/builder/writer/human_readable_test.go b/internal/builder/writer/human_readable_test.go new file mode 100644 index 000000000..e961d0dce --- /dev/null +++ b/internal/builder/writer/human_readable_test.go @@ -0,0 +1,498 @@ +package writer_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/Masterminds/semver" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" + ilogging "github.com/buildpacks/pack/internal/logging" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestHumanReadable(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer", testHumanReadable, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testHumanReadable(t *testing.T, when spec.G, it spec.S) { + var ( + assert = h.NewAssertionManager(t) + outBuf bytes.Buffer + + remoteInfo *pack.BuilderInfo + localInfo *pack.BuilderInfo + + expectedRemoteOutput = ` +REMOTE: + +Description: Some remote description + +Created By: + Name: Pack CLI + Version: 1.2.3 + +Trusted: No + +Stack: + ID: test.stack.id + +Lifecycle: + Version: 6.7.8 + Buildpack APIs: + Deprecated: (none) + Supported: 1.2, 2.3 + Platform APIs: + Deprecated: 0.1, 1.2 + Supported: 4.5 + +Run Images: + first/local (user-configured) + second/local (user-configured) + some/run-image + first/default + second/default + +Buildpacks: + ID VERSION HOMEPAGE + test.top.nested test.top.nested.version + test.nested http://geocities.com/top-bp + test.bp.one test.bp.one.version http://geocities.com/cool-bp + test.bp.two test.bp.two.version + test.bp.three test.bp.three.version + +Detection Order: + ├ Group #1: + │ ├ test.top.nested@test.top.nested.version + │ │ └ Group #1: + │ │ ├ test.nested + │ │ │ └ Group #1: + │ │ │ └ test.bp.one@test.bp.one.version (optional) + │ │ ├ test.bp.three@test.bp.three.version (optional) + │ │ └ test.nested.two@test.nested.two.version + │ │ └ Group #2: + │ │ └ test.bp.one@test.bp.one.version (optional)[cyclic] + │ └ test.bp.two@test.bp.two.version (optional) + └ test.bp.three@test.bp.three.version +` + + expectedLocalOutput = ` +LOCAL: + +Description: Some local description + +Created By: + Name: Pack CLI + Version: 4.5.6 + +Trusted: No + +Stack: + ID: test.stack.id + +Lifecycle: + Version: 4.5.6 + Buildpack APIs: + Deprecated: 4.5, 6.7 + Supported: 8.9, 10.11 + Platform APIs: + Deprecated: (none) + Supported: 7.8 + +Run Images: + first/local (user-configured) + second/local (user-configured) + some/run-image + first/local-default + second/local-default + +Buildpacks: + ID VERSION HOMEPAGE + test.top.nested test.top.nested.version + test.nested http://geocities.com/top-bp + test.bp.one test.bp.one.version http://geocities.com/cool-bp + test.bp.two test.bp.two.version + test.bp.three test.bp.three.version + +Detection Order: + ├ Group #1: + │ ├ test.top.nested@test.top.nested.version + │ │ └ Group #1: + │ │ ├ test.nested + │ │ │ └ Group #1: + │ │ │ └ test.bp.one@test.bp.one.version (optional) + │ │ ├ test.bp.three@test.bp.three.version (optional) + │ │ └ test.nested.two@test.nested.two.version + │ │ └ Group #2: + │ │ └ test.bp.one@test.bp.one.version (optional)[cyclic] + │ └ test.bp.two@test.bp.two.version (optional) + └ test.bp.three@test.bp.three.version +` + expectedVerboseStack = ` +Stack: + ID: test.stack.id + Mixins: + mixin1 + mixin2 + build:mixin3 + build:mixin4 +` + expectedNilLifecycleVersion = ` +Lifecycle: + Version: (none) +` + expectedEmptyRunImages = ` +Run Images: + (none) +` + expectedEmptyBuildpacks = ` +Buildpacks: + (none) +` + expectedEmptyOrder = ` +Detection Order: + (none) +` + expectedMissingLocalInfo = ` +LOCAL: +(not present) +` + expectedMissingRemoteInfo = ` +REMOTE: +(not present) +` + ) + + when("Print", func() { + it.Before(func() { + remoteInfo = &pack.BuilderInfo{ + Description: "Some remote description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/default", "second/default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("6.7.8"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("2.3")}, + }, + Platform: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("0.1"), api.MustParse("1.2")}, + Supported: builder.APISet{api.MustParse("4.5")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "1.2.3", + }, + } + + localInfo = &pack.BuilderInfo{ + Description: "Some local description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/local-default", "second/local-default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("4.5.6"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("4.5"), api.MustParse("6.7")}, + Supported: builder.APISet{api.MustParse("8.9"), api.MustParse("10.11")}, + }, + Platform: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("7.8")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "4.5.6", + }, + } + + outBuf = bytes.Buffer{} + }) + + it("prints both local and remote builders in a human readable format", func() { + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), "Inspecting builder: 'test-builder'") + assert.Contains(outBuf.String(), expectedRemoteOutput) + assert.Contains(outBuf.String(), expectedLocalOutput) + }) + + when("builder is default", func() { + it("prints inspecting default builder", func() { + defaultSharedBuildInfo := sharedBuilderInfo + defaultSharedBuildInfo.IsDefault = true + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, defaultSharedBuildInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), "Inspecting default builder: 'test-builder'") + }) + }) + + when("builder doesn't exist locally or remotely", func() { + it("returns an error", func() { + localInfo = nil + remoteInfo = nil + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "unable to find builder 'test-builder' locally or remotely") + }) + }) + + when("builder doesn't exist locally", func() { + it("shows not present for local builder, and normal output for remote", func() { + localInfo = nil + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedMissingLocalInfo) + assert.Contains(outBuf.String(), expectedRemoteOutput) + }) + }) + + when("builder doesn't exist remotely", func() { + it("shows not present for remote builder, and normal output for local", func() { + remoteInfo = nil + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedMissingRemoteInfo) + assert.Contains(outBuf.String(), expectedLocalOutput) + }) + }) + + when("localErr is an error", func() { + it("error is logged, local info is not displayed, but remote info is", func() { + errorMessage := "failed to retrieve local info" + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, errors.New(errorMessage), nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), errorMessage) + assert.NotContains(outBuf.String(), expectedLocalOutput) + assert.Contains(outBuf.String(), expectedRemoteOutput) + }) + }) + + when("remoteErr is an error", func() { + it("error is logged, remote info is not displayed, but local info is", func() { + errorMessage := "failed to retrieve remote info" + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, errors.New(errorMessage), sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), errorMessage) + assert.NotContains(outBuf.String(), expectedRemoteOutput) + assert.Contains(outBuf.String(), expectedLocalOutput) + }) + }) + + when("description is blank", func() { + it("doesn't print the description block", func() { + localInfo.Description = "" + remoteInfo.Description = "" + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.NotContains(outBuf.String(), "Description:") + }) + }) + + when("created by name is blank", func() { + it("doesn't print created by block", func() { + localInfo.CreatedBy.Name = "" + remoteInfo.CreatedBy.Name = "" + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.NotContains(outBuf.String(), "Created By:") + }) + }) + + when("logger is verbose", func() { + it("displays mixins associated with the stack", func() { + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedVerboseStack) + }) + }) + + when("lifecycle version is not set", func() { + it("displays lifecycle version as (none) and warns that version if not set", func() { + localInfo.Lifecycle.Info.Version = nil + remoteInfo.Lifecycle.Info.Version = nil + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedNilLifecycleVersion) + assert.Contains(outBuf.String(), "test-builder does not specify a Lifecycle version") + }) + }) + + when("there are no supported buildpack APIs specified", func() { + it("prints a warning", func() { + localInfo.Lifecycle.APIs.Buildpack.Supported = builder.APISet{} + remoteInfo.Lifecycle.APIs.Buildpack.Supported = builder.APISet{} + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), "test-builder does not specify supported Lifecycle Buildpack APIs") + }) + }) + + when("there are no supported platform APIs specified", func() { + it("prints a warning", func() { + localInfo.Lifecycle.APIs.Platform.Supported = builder.APISet{} + remoteInfo.Lifecycle.APIs.Platform.Supported = builder.APISet{} + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), "test-builder does not specify supported Lifecycle Platform APIs") + }) + }) + + when("no run images are specified", func() { + it("displays run images as (none) and warns about unset run image", func() { + localInfo.RunImage = "" + localInfo.RunImageMirrors = []string{} + remoteInfo.RunImage = "" + remoteInfo.RunImageMirrors = []string{} + emptyLocalRunImages := []config.RunImage{} + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, emptyLocalRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedEmptyRunImages) + assert.Contains(outBuf.String(), "test-builder does not specify a run image") + assert.Contains(outBuf.String(), "Users must build with an explicitly specified run image") + }) + }) + + when("no buildpacks are specified", func() { + it("displays buildpacks as (none) and prints warnings", func() { + localInfo.Buildpacks = []dist.BuildpackInfo{} + remoteInfo.Buildpacks = []dist.BuildpackInfo{} + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedEmptyBuildpacks) + assert.Contains(outBuf.String(), "test-builder has no buildpacks") + assert.Contains(outBuf.String(), "Users must supply buildpacks from the host machine") + }) + }) + + when("multiple top level groups", func() { + it("displays order correctly", func() { + + }) + }) + + when("no detection order is specified", func() { + it("displays detection order as (none) and prints warnings", func() { + localInfo.Order = pubbldr.DetectionOrder{} + remoteInfo.Order = pubbldr.DetectionOrder{} + + humanReadableWriter := writer.NewHumanReadable() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := humanReadableWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Contains(outBuf.String(), expectedEmptyOrder) + assert.Contains(outBuf.String(), "test-builder has no buildpacks") + assert.Contains(outBuf.String(), "Users must build with explicitly specified buildpacks") + }) + }) + }) +} diff --git a/internal/builder/writer/json.go b/internal/builder/writer/json.go new file mode 100644 index 000000000..b7430a76f --- /dev/null +++ b/internal/builder/writer/json.go @@ -0,0 +1,17 @@ +package writer + +import ( + "encoding/json" +) + +type JSON struct { + StructuredFormat +} + +func NewJSON() BuilderWriter { + return &JSON{ + StructuredFormat: StructuredFormat{ + MarshalFunc: json.Marshal, + }, + } +} diff --git a/internal/builder/writer/json_test.go b/internal/builder/writer/json_test.go new file mode 100644 index 000000000..49850285c --- /dev/null +++ b/internal/builder/writer/json_test.go @@ -0,0 +1,479 @@ +package writer_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/Masterminds/semver" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" + ilogging "github.com/buildpacks/pack/internal/logging" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestJSON(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer", testJSON, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testJSON(t *testing.T, when spec.G, it spec.S) { + const ( + expectedRemoteRunImages = `"run_images": [ + { + "name": "first/local", + "user_configured": true + }, + { + "name": "second/local", + "user_configured": true + }, + { + "name": "some/run-image" + }, + { + "name": "first/default" + }, + { + "name": "second/default" + } + ]` + expectedLocalRunImages = `"run_images": [ + { + "name": "first/local", + "user_configured": true + }, + { + "name": "second/local", + "user_configured": true + }, + { + "name": "some/run-image" + }, + { + "name": "first/local-default" + }, + { + "name": "second/local-default" + } + ]` + + expectedBuildpacks = `"buildpacks": [ + { + "id": "test.top.nested", + "version": "test.top.nested.version" + }, + { + "id": "test.nested", + "homepage": "http://geocities.com/top-bp" + }, + { + "id": "test.bp.one", + "version": "test.bp.one.version", + "homepage": "http://geocities.com/cool-bp" + }, + { + "id": "test.bp.two", + "version": "test.bp.two.version" + }, + { + "id": "test.bp.three", + "version": "test.bp.three.version" + } + ]` + expectedDetectionOrder = `"detection_order": [ + { + "buildpacks": [ + { + "id": "test.top.nested", + "version": "test.top.nested.version", + "buildpacks": [ + { + "id": "test.nested", + "homepage": "http://geocities.com/top-bp", + "buildpacks": [ + { + "id": "test.bp.one", + "version": "test.bp.one.version", + "homepage": "http://geocities.com/cool-bp", + "optional": true + } + ] + }, + { + "id": "test.bp.three", + "version": "test.bp.three.version", + "optional": true + }, + { + "id": "test.nested.two", + "version": "test.nested.two.version", + "buildpacks": [ + { + "id": "test.bp.one", + "version": "test.bp.one.version", + "homepage": "http://geocities.com/cool-bp", + "optional": true, + "cyclic": true + } + ] + } + ] + }, + { + "id": "test.bp.two", + "version": "test.bp.two.version", + "optional": true + } + ] + }, + { + "id": "test.bp.three", + "version": "test.bp.three.version" + } + ]` + expectedStackWithMixins = `"stack": { + "id": "test.stack.id", + "mixins": [ + "mixin1", + "mixin2", + "build:mixin3", + "build:mixin4" + ] + }` + ) + + var ( + assert = h.NewAssertionManager(t) + outBuf bytes.Buffer + + remoteInfo *pack.BuilderInfo + localInfo *pack.BuilderInfo + + expectedRemoteInfo = fmt.Sprintf(`"remote_info": { + "description": "Some remote description", + "created_by": { + "name": "Pack CLI", + "version": "1.2.3" + }, + "stack": { + "id": "test.stack.id" + }, + "lifecycle": { + "version": "6.7.8", + "buildpack_apis": { + "deprecated": null, + "supported": [ + "1.2", + "2.3" + ] + }, + "platform_apis": { + "deprecated": [ + "0.1", + "1.2" + ], + "supported": [ + "4.5" + ] + } + }, + %s, + %s, + %s + }`, expectedRemoteRunImages, expectedBuildpacks, expectedDetectionOrder) + + expectedLocalInfo = fmt.Sprintf(`"local_info": { + "description": "Some local description", + "created_by": { + "name": "Pack CLI", + "version": "4.5.6" + }, + "stack": { + "id": "test.stack.id" + }, + "lifecycle": { + "version": "4.5.6", + "buildpack_apis": { + "deprecated": [ + "4.5", + "6.7" + ], + "supported": [ + "8.9", + "10.11" + ] + }, + "platform_apis": { + "deprecated": null, + "supported": [ + "7.8" + ] + } + }, + %s, + %s, + %s + }`, expectedLocalRunImages, expectedBuildpacks, expectedDetectionOrder) + + expectedPrettifiedJSON = fmt.Sprintf(`{ + "builder_name": "test-builder", + "trusted": false, + "default": false, + %s, + %s +} +`, expectedRemoteInfo, expectedLocalInfo) + ) + + when("Print", func() { + it.Before(func() { + remoteInfo = &pack.BuilderInfo{ + Description: "Some remote description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/default", "second/default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("6.7.8"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("2.3")}, + }, + Platform: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("0.1"), api.MustParse("1.2")}, + Supported: builder.APISet{api.MustParse("4.5")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "1.2.3", + }, + } + + localInfo = &pack.BuilderInfo{ + Description: "Some local description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/local-default", "second/local-default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("4.5.6"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("4.5"), api.MustParse("6.7")}, + Supported: builder.APISet{api.MustParse("8.9"), api.MustParse("10.11")}, + }, + Platform: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("7.8")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "4.5.6", + }, + } + }) + + it("prints both local remote builders as valid JSON", func() { + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettyJSON, expectedPrettifiedJSON) + }) + + when("builder doesn't exist locally or remotely", func() { + it("returns an error", func() { + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, nil, nil, nil, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "unable to find builder 'test-builder' locally or remotely") + }) + }) + + when("builder doesn't exist locally", func() { + it("shows null for local builder, and normal output for remote", func() { + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, nil, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettyJSON, `{"local_info": null}`) + assert.ContainsJSON(prettyJSON, fmt.Sprintf("{%s}", expectedRemoteInfo)) + }) + }) + + when("builder doesn't exist remotely", func() { + it("shows null for remote builder, and normal output for local", func() { + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, localInfo, nil, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettyJSON, `{"remote_info": null}`) + assert.ContainsJSON(prettyJSON, fmt.Sprintf("{%s}", expectedLocalInfo)) + }) + }) + + when("localErr is an error", func() { + it("returns the error, and doesn't write any json output", func() { + expectedErr := errors.New("failed to retrieve local info") + + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, expectedErr, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve local info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("remoteErr is an error", func() { + it("returns the error, and doesn't write any json output", func() { + expectedErr := errors.New("failed to retrieve remote info") + + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, expectedErr, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve remote info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("logger is verbose", func() { + it("displays mixins associated with the stack", func() { + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettifiedJSON, fmt.Sprintf("{%s}", expectedStackWithMixins)) + }) + }) + + when("no run images are specified", func() { + it("displays run images as empty list", func() { + localInfo.RunImage = "" + localInfo.RunImageMirrors = []string{} + remoteInfo.RunImage = "" + remoteInfo.RunImageMirrors = []string{} + emptyLocalRunImages := []config.RunImage{} + + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := jsonWriter.Print(logger, emptyLocalRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettifiedJSON, `{"run_images": []}`) + }) + }) + + when("no buildpacks are specified", func() { + it("displays buildpacks as empty list", func() { + localInfo.Buildpacks = []dist.BuildpackInfo{} + remoteInfo.Buildpacks = []dist.BuildpackInfo{} + + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettifiedJSON, `{"buildpacks": []}`) + }) + }) + + when("no detection order is specified", func() { + it("displays detection order as empty list", func() { + localInfo.Order = pubbldr.DetectionOrder{} + remoteInfo.Order = pubbldr.DetectionOrder{} + + jsonWriter := writer.NewJSON() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := jsonWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedJSON, err := validPrettifiedJSONOutput(outBuf) + assert.Nil(err) + + assert.ContainsJSON(prettifiedJSON, `{"detection_order": []}`) + }) + }) + }) +} + +func validPrettifiedJSONOutput(source bytes.Buffer) (string, error) { + err := json.Unmarshal(source.Bytes(), &struct{}{}) + if err != nil { + return "", fmt.Errorf("failed to unmarshal to json: %w", err) + } + + var prettifiedOutput bytes.Buffer + err = json.Indent(&prettifiedOutput, source.Bytes(), "", " ") + if err != nil { + return "", fmt.Errorf("failed to prettify source json: %w", err) + } + + return prettifiedOutput.String(), nil +} diff --git a/internal/builder/writer/shared_builder_test.go b/internal/builder/writer/shared_builder_test.go new file mode 100644 index 000000000..1ca9db495 --- /dev/null +++ b/internal/builder/writer/shared_builder_test.go @@ -0,0 +1,108 @@ +package writer_test + +import ( + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" +) + +var ( + testTopNestedBuildpack = dist.BuildpackInfo{ + ID: "test.top.nested", + Version: "test.top.nested.version", + } + testNestedBuildpack = dist.BuildpackInfo{ + ID: "test.nested", + Homepage: "http://geocities.com/top-bp", + } + testBuildpackOne = dist.BuildpackInfo{ + ID: "test.bp.one", + Version: "test.bp.one.version", + Homepage: "http://geocities.com/cool-bp", + } + testBuildpackTwo = dist.BuildpackInfo{ + ID: "test.bp.two", + Version: "test.bp.two.version", + } + testBuildpackThree = dist.BuildpackInfo{ + ID: "test.bp.three", + Version: "test.bp.three.version", + } + testNestedBuildpackTwo = dist.BuildpackInfo{ + ID: "test.nested.two", + Version: "test.nested.two.version", + } + + buildpacks = []dist.BuildpackInfo{ + testTopNestedBuildpack, + testNestedBuildpack, + testBuildpackOne, + testBuildpackTwo, + testBuildpackThree, + } + + order = pubbldr.DetectionOrder{ + pubbldr.DetectionOrderEntry{ + GroupDetectionOrder: pubbldr.DetectionOrder{ + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testTopNestedBuildpack, + }, + GroupDetectionOrder: pubbldr.DetectionOrder{ + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testNestedBuildpack}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpackOne, + Optional: true, + }, + }, + }, + }, + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpackThree, + Optional: true, + }, + }, + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{BuildpackInfo: testNestedBuildpackTwo}, + GroupDetectionOrder: pubbldr.DetectionOrder{ + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpackOne, + Optional: true, + }, + Cyclical: true, + }, + }, + }, + }, + }, + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpackTwo, + Optional: true, + }, + }, + }, + }, + pubbldr.DetectionOrderEntry{ + BuildpackRef: dist.BuildpackRef{ + BuildpackInfo: testBuildpackThree, + }, + }, + } + + sharedBuilderInfo = writer.SharedBuilderInfo{ + Name: "test-builder", + Trusted: false, + IsDefault: false, + } + + localRunImages = []config.RunImage{ + {Image: "some/run-image", Mirrors: []string{"first/local", "second/local"}}, + } +) diff --git a/internal/builder/writer/structured_format.go b/internal/builder/writer/structured_format.go new file mode 100644 index 000000000..c33ec3551 --- /dev/null +++ b/internal/builder/writer/structured_format.go @@ -0,0 +1,150 @@ +package writer + +import ( + "fmt" + + "github.com/buildpacks/pack/internal/style" + + "github.com/buildpacks/pack" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" + "github.com/buildpacks/pack/logging" +) + +type InspectOutput struct { + SharedBuilderInfo + RemoteInfo *BuilderInfo `json:"remote_info" yaml:"remote_info" toml:"remote_info"` + LocalInfo *BuilderInfo `json:"local_info" yaml:"local_info" toml:"local_info"` +} + +type RunImage struct { + Name string `json:"name" yaml:"name" toml:"name"` + UserConfigured bool `json:"user_configured,omitempty" yaml:"user_configured,omitempty" toml:"user_configured,omitempty"` +} + +type Lifecycle struct { + builder.LifecycleInfo `yaml:"lifecycleinfo,inline"` + BuildpackAPIs builder.APIVersions `json:"buildpack_apis" yaml:"buildpack_apis" toml:"buildpack_apis"` + PlatformAPIs builder.APIVersions `json:"platform_apis" yaml:"platform_apis" toml:"platform_apis"` +} + +type Stack struct { + ID string `json:"id" yaml:"id" toml:"id"` + Mixins []string `json:"mixins,omitempty" yaml:"mixins,omitempty" toml:"mixins,omitempty"` +} + +type BuilderInfo struct { + Description string `json:"description,omitempty" yaml:"description,omitempty" toml:"description,omitempty"` + CreatedBy builder.CreatorMetadata `json:"created_by" yaml:"created_by" toml:"created_by"` + Stack Stack `json:"stack" yaml:"stack" toml:"stack"` + Lifecycle Lifecycle `json:"lifecycle" yaml:"lifecycle" toml:"lifecycle"` + RunImages []RunImage `json:"run_images" yaml:"run_images" toml:"run_images"` + Buildpacks []dist.BuildpackInfo `json:"buildpacks" yaml:"buildpacks" toml:"buildpacks"` + pubbldr.DetectionOrder `json:"detection_order" yaml:"detection_order" toml:"detection_order"` +} + +type StructuredFormat struct { + MarshalFunc func(interface{}) ([]byte, error) +} + +func (w *StructuredFormat) Print( + logger logging.Logger, + localRunImages []config.RunImage, + local, remote *pack.BuilderInfo, + localErr, remoteErr error, + builderInfo SharedBuilderInfo, +) error { + if localErr != nil { + return fmt.Errorf("preparing output for %s: %w", style.Symbol(builderInfo.Name), localErr) + } + + if remoteErr != nil { + return fmt.Errorf("preparing output for %s: %w", style.Symbol(builderInfo.Name), remoteErr) + } + + outputInfo := InspectOutput{SharedBuilderInfo: builderInfo} + + if local != nil { + stack := Stack{ID: local.Stack} + + if logger.IsVerbose() { + stack.Mixins = local.Mixins + } + + outputInfo.LocalInfo = &BuilderInfo{ + Description: local.Description, + CreatedBy: local.CreatedBy, + Stack: stack, + Lifecycle: Lifecycle{ + LifecycleInfo: local.Lifecycle.Info, + BuildpackAPIs: local.Lifecycle.APIs.Buildpack, + PlatformAPIs: local.Lifecycle.APIs.Platform, + }, + RunImages: runImages(local.RunImage, localRunImages, local.RunImageMirrors), + Buildpacks: local.Buildpacks, + DetectionOrder: local.Order, + } + } + + if remote != nil { + stack := Stack{ID: remote.Stack} + + if logger.IsVerbose() { + stack.Mixins = remote.Mixins + } + + outputInfo.RemoteInfo = &BuilderInfo{ + Description: remote.Description, + CreatedBy: remote.CreatedBy, + Stack: stack, + Lifecycle: Lifecycle{ + LifecycleInfo: remote.Lifecycle.Info, + BuildpackAPIs: remote.Lifecycle.APIs.Buildpack, + PlatformAPIs: remote.Lifecycle.APIs.Platform, + }, + RunImages: runImages(remote.RunImage, localRunImages, remote.RunImageMirrors), + Buildpacks: remote.Buildpacks, + DetectionOrder: remote.Order, + } + } + + if outputInfo.LocalInfo == nil && outputInfo.RemoteInfo == nil { + return fmt.Errorf("unable to find builder %s locally or remotely", style.Symbol(builderInfo.Name)) + } + + var ( + output []byte + err error + ) + if output, err = w.MarshalFunc(outputInfo); err != nil { + return fmt.Errorf("untested, unexpected failure while marshaling: %w", err) + } + + logger.Info(string(output)) + + return nil +} + +func runImages(runImage string, localRunImages []config.RunImage, buildRunImages []string) []RunImage { + var images = []RunImage{} + + for _, i := range localRunImages { + if i.Image == runImage { + for _, m := range i.Mirrors { + images = append(images, RunImage{Name: m, UserConfigured: true}) + } + } + } + + if runImage != "" { + images = append(images, RunImage{Name: runImage}) + } + + for _, m := range buildRunImages { + images = append(images, RunImage{Name: m}) + } + + return images +} diff --git a/internal/builder/writer/toml.go b/internal/builder/writer/toml.go new file mode 100644 index 000000000..4bf16fc45 --- /dev/null +++ b/internal/builder/writer/toml.go @@ -0,0 +1,26 @@ +package writer + +import ( + "bytes" + + "github.com/pelletier/go-toml" +) + +type TOML struct { + StructuredFormat +} + +func NewTOML() BuilderWriter { + return &TOML{ + StructuredFormat: StructuredFormat{ + MarshalFunc: func(v interface{}) ([]byte, error) { + buf := bytes.NewBuffer(nil) + err := toml.NewEncoder(buf).Order(toml.OrderPreserve).PromoteAnonymous(false).Encode(v) + if err != nil { + return []byte{}, err + } + return buf.Bytes(), nil + }, + }, + } +} diff --git a/internal/builder/writer/toml_test.go b/internal/builder/writer/toml_test.go new file mode 100644 index 000000000..97f9c2619 --- /dev/null +++ b/internal/builder/writer/toml_test.go @@ -0,0 +1,494 @@ +package writer_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/pelletier/go-toml" + + "github.com/Masterminds/semver" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" + ilogging "github.com/buildpacks/pack/internal/logging" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestTOML(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer", testTOML, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testTOML(t *testing.T, when spec.G, it spec.S) { + const ( + expectedRemoteRunImages = ` [[remote_info.run_images]] + name = "first/local" + user_configured = true + + [[remote_info.run_images]] + name = "second/local" + user_configured = true + + [[remote_info.run_images]] + name = "some/run-image" + + [[remote_info.run_images]] + name = "first/default" + + [[remote_info.run_images]] + name = "second/default"` + + expectedLocalRunImages = ` [[local_info.run_images]] + name = "first/local" + user_configured = true + + [[local_info.run_images]] + name = "second/local" + user_configured = true + + [[local_info.run_images]] + name = "some/run-image" + + [[local_info.run_images]] + name = "first/local-default" + + [[local_info.run_images]] + name = "second/local-default"` + + expectedLocalBuildpacks = ` [[local_info.buildpacks]] + id = "test.top.nested" + version = "test.top.nested.version" + + [[local_info.buildpacks]] + id = "test.nested" + homepage = "http://geocities.com/top-bp" + + [[local_info.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + + [[local_info.buildpacks]] + id = "test.bp.two" + version = "test.bp.two.version" + + [[local_info.buildpacks]] + id = "test.bp.three" + version = "test.bp.three.version"` + + expectedRemoteBuildpacks = ` [[remote_info.buildpacks]] + id = "test.top.nested" + version = "test.top.nested.version" + + [[remote_info.buildpacks]] + id = "test.nested" + homepage = "http://geocities.com/top-bp" + + [[remote_info.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + + [[remote_info.buildpacks]] + id = "test.bp.two" + version = "test.bp.two.version" + + [[remote_info.buildpacks]] + id = "test.bp.three" + version = "test.bp.three.version"` + + expectedLocalDetectionOrder = ` [[local_info.detection_order]] + + [[local_info.detection_order.buildpacks]] + id = "test.top.nested" + version = "test.top.nested.version" + + [[local_info.detection_order.buildpacks.buildpacks]] + id = "test.nested" + homepage = "http://geocities.com/top-bp" + + [[local_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + optional = true + + [[local_info.detection_order.buildpacks.buildpacks]] + id = "test.bp.three" + version = "test.bp.three.version" + optional = true + + [[local_info.detection_order.buildpacks.buildpacks]] + id = "test.nested.two" + version = "test.nested.two.version" + + [[local_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + optional = true + cyclic = true + + [[local_info.detection_order.buildpacks]] + id = "test.bp.two" + version = "test.bp.two.version" + optional = true + + [[local_info.detection_order]] + id = "test.bp.three" + version = "test.bp.three.version"` + + expectedRemoteDetectionOrder = ` [[remote_info.detection_order]] + + [[remote_info.detection_order.buildpacks]] + id = "test.top.nested" + version = "test.top.nested.version" + + [[remote_info.detection_order.buildpacks.buildpacks]] + id = "test.nested" + homepage = "http://geocities.com/top-bp" + + [[remote_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + optional = true + + [[remote_info.detection_order.buildpacks.buildpacks]] + id = "test.bp.three" + version = "test.bp.three.version" + optional = true + + [[remote_info.detection_order.buildpacks.buildpacks]] + id = "test.nested.two" + version = "test.nested.two.version" + + [[remote_info.detection_order.buildpacks.buildpacks.buildpacks]] + id = "test.bp.one" + version = "test.bp.one.version" + homepage = "http://geocities.com/cool-bp" + optional = true + cyclic = true + + [[remote_info.detection_order.buildpacks]] + id = "test.bp.two" + version = "test.bp.two.version" + optional = true + + [[remote_info.detection_order]] + id = "test.bp.three" + version = "test.bp.three.version"` + + stackWithMixins = ` [stack] + id = "test.stack.id" + mixins = ["mixin1", "mixin2", "build:mixin3", "build:mixin4"]` + ) + + var ( + assert = h.NewAssertionManager(t) + outBuf bytes.Buffer + + remoteInfo *pack.BuilderInfo + localInfo *pack.BuilderInfo + + expectedRemoteInfo = fmt.Sprintf(`[remote_info] + description = "Some remote description" + + [remote_info.created_by] + Name = "Pack CLI" + Version = "1.2.3" + + [remote_info.stack] + id = "test.stack.id" + + [remote_info.lifecycle] + version = "6.7.8" + + [remote_info.lifecycle.buildpack_apis] + deprecated = [] + supported = ["1.2", "2.3"] + + [remote_info.lifecycle.platform_apis] + deprecated = ["0.1", "1.2"] + supported = ["4.5"] + +%s + +%s + +%s`, expectedRemoteRunImages, expectedRemoteBuildpacks, expectedRemoteDetectionOrder) + + expectedLocalInfo = fmt.Sprintf(`[local_info] + description = "Some local description" + + [local_info.created_by] + Name = "Pack CLI" + Version = "4.5.6" + + [local_info.stack] + id = "test.stack.id" + + [local_info.lifecycle] + version = "4.5.6" + + [local_info.lifecycle.buildpack_apis] + deprecated = ["4.5", "6.7"] + supported = ["8.9", "10.11"] + + [local_info.lifecycle.platform_apis] + deprecated = [] + supported = ["7.8"] + +%s + +%s + +%s`, expectedLocalRunImages, expectedLocalBuildpacks, expectedLocalDetectionOrder) + + expectedPrettifiedTOML = fmt.Sprintf(`builder_name = "test-builder" +trusted = false +default = false + +%s + +%s`, expectedRemoteInfo, expectedLocalInfo) + ) + + when("Print", func() { + it.Before(func() { + remoteInfo = &pack.BuilderInfo{ + Description: "Some remote description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/default", "second/default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("6.7.8"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("2.3")}, + }, + Platform: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("0.1"), api.MustParse("1.2")}, + Supported: builder.APISet{api.MustParse("4.5")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "1.2.3", + }, + } + + localInfo = &pack.BuilderInfo{ + Description: "Some local description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/local-default", "second/local-default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("4.5.6"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("4.5"), api.MustParse("6.7")}, + Supported: builder.APISet{api.MustParse("8.9"), api.MustParse("10.11")}, + }, + Platform: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("7.8")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "4.5.6", + }, + } + }) + + it("prints both local remote builders as valid TOML", func() { + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + assert.Nil(err) + + assert.ContainsTOML(outBuf.String(), expectedPrettifiedTOML) + }) + + when("builder doesn't exist locally or remotely", func() { + it("returns an error", func() { + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, nil, nil, nil, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "unable to find builder 'test-builder' locally or remotely") + }) + }) + + when("builder doesn't exist locally", func() { + it("shows null for local builder, and normal output for remote", func() { + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, nil, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + assert.Nil(err) + + assert.NotContains(outBuf.String(), "local_info") + assert.ContainsTOML(outBuf.String(), expectedRemoteInfo) + }) + }) + + when("builder doesn't exist remotely", func() { + it("shows null for remote builder, and normal output for local", func() { + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, localInfo, nil, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + assert.Nil(err) + + assert.NotContains(outBuf.String(), "remote_info") + assert.ContainsTOML(outBuf.String(), expectedLocalInfo) + }) + }) + + when("localErr is an error", func() { + it("returns the error, and doesn't write any toml output", func() { + expectedErr := errors.New("failed to retrieve local info") + + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, expectedErr, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve local info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("remoteErr is an error", func() { + it("returns the error, and doesn't write any toml output", func() { + expectedErr := errors.New("failed to retrieve remote info") + + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, expectedErr, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve remote info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("logger is verbose", func() { + it("displays mixins associated with the stack", func() { + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + assert.ContainsTOML(outBuf.String(), stackWithMixins) + }) + }) + + when("no run images are specified", func() { + it("omits run images from output", func() { + localInfo.RunImage = "" + localInfo.RunImageMirrors = []string{} + remoteInfo.RunImage = "" + remoteInfo.RunImageMirrors = []string{} + emptyLocalRunImages := []config.RunImage{} + + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := tomlWriter.Print(logger, emptyLocalRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + + assert.NotContains(outBuf.String(), "run_images") + }) + }) + + when("no buildpacks are specified", func() { + it("omits buildpacks from output", func() { + localInfo.Buildpacks = []dist.BuildpackInfo{} + remoteInfo.Buildpacks = []dist.BuildpackInfo{} + + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + + assert.NotContains(outBuf.String(), "local_info.buildpacks") + assert.NotContains(outBuf.String(), "remote_info.buildpacks") + }) + }) + + when("no detection order is specified", func() { + it("omits dection order in output", func() { + localInfo.Order = pubbldr.DetectionOrder{} + remoteInfo.Order = pubbldr.DetectionOrder{} + + tomlWriter := writer.NewTOML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := tomlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + assert.Succeeds(validTOMLOutput(outBuf)) + assert.NotContains(outBuf.String(), "detection_order") + }) + }) + }) +} + +func validTOMLOutput(source bytes.Buffer) error { + err := toml.NewDecoder(&source).Decode(&struct{}{}) + if err != nil { + return fmt.Errorf("failed to unmarshal to toml: %w", err) + } + return nil +} diff --git a/internal/builder/writer/yaml.go b/internal/builder/writer/yaml.go new file mode 100644 index 000000000..20928bf2a --- /dev/null +++ b/internal/builder/writer/yaml.go @@ -0,0 +1,25 @@ +package writer + +import ( + "bytes" + + "gopkg.in/yaml.v3" +) + +type YAML struct { + StructuredFormat +} + +func NewYAML() BuilderWriter { + return &YAML{ + StructuredFormat: StructuredFormat{ + MarshalFunc: func(v interface{}) ([]byte, error) { + buf := bytes.NewBuffer(nil) + if err := yaml.NewEncoder(buf).Encode(v); err != nil { + return []byte{}, err + } + return buf.Bytes(), nil + }, + }, + } +} diff --git a/internal/builder/writer/yaml_test.go b/internal/builder/writer/yaml_test.go new file mode 100644 index 000000000..a39cf9715 --- /dev/null +++ b/internal/builder/writer/yaml_test.go @@ -0,0 +1,397 @@ +package writer_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + + "github.com/ghodss/yaml" + + "github.com/Masterminds/semver" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack" + pubbldr "github.com/buildpacks/pack/builder" + "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/dist" + ilogging "github.com/buildpacks/pack/internal/logging" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestYAML(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer", testYAML, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testYAML(t *testing.T, when spec.G, it spec.S) { + const ( + expectedRemoteRunImages = ` run_images: + - name: first/local + user_configured: true + - name: second/local + user_configured: true + - name: some/run-image + - name: first/default + - name: second/default` + + expectedLocalRunImages = ` run_images: + - name: first/local + user_configured: true + - name: second/local + user_configured: true + - name: some/run-image + - name: first/local-default + - name: second/local-default` + + expectedBuildpacks = ` buildpacks: + - id: test.top.nested + version: test.top.nested.version + - id: test.nested + homepage: http://geocities.com/top-bp + - id: test.bp.one + version: test.bp.one.version + homepage: http://geocities.com/cool-bp + - id: test.bp.two + version: test.bp.two.version + - id: test.bp.three + version: test.bp.three.version` + + expectedDetectionOrder = ` detection_order: + - buildpacks: + - id: test.top.nested + version: test.top.nested.version + buildpacks: + - id: test.nested + homepage: http://geocities.com/top-bp + buildpacks: + - id: test.bp.one + version: test.bp.one.version + homepage: http://geocities.com/cool-bp + optional: true + - id: test.bp.three + version: test.bp.three.version + optional: true + - id: test.nested.two + version: test.nested.two.version + buildpacks: + - id: test.bp.one + version: test.bp.one.version + homepage: http://geocities.com/cool-bp + optional: true + cyclic: true + - id: test.bp.two + version: test.bp.two.version + optional: true + - id: test.bp.three + version: test.bp.three.version` + expectedStackWithMixins = ` stack: + id: test.stack.id + mixins: + - mixin1 + - mixin2 + - build:mixin3 + - build:mixin4` + ) + + var ( + assert = h.NewAssertionManager(t) + outBuf bytes.Buffer + + remoteInfo *pack.BuilderInfo + localInfo *pack.BuilderInfo + + expectedRemoteInfo = fmt.Sprintf(`remote_info: + description: Some remote description + created_by: + name: Pack CLI + version: 1.2.3 + stack: + id: test.stack.id + lifecycle: + version: 6.7.8 + buildpack_apis: + deprecated: [] + supported: + - "1.2" + - "2.3" + platform_apis: + deprecated: + - "0.1" + - "1.2" + supported: + - "4.5" +%s +%s +%s`, expectedRemoteRunImages, expectedBuildpacks, expectedDetectionOrder) + + expectedLocalInfo = fmt.Sprintf(`local_info: + description: Some local description + created_by: + name: Pack CLI + version: 4.5.6 + stack: + id: test.stack.id + lifecycle: + version: 4.5.6 + buildpack_apis: + deprecated: + - "4.5" + - "6.7" + supported: + - "8.9" + - "10.11" + platform_apis: + deprecated: [] + supported: + - "7.8" +%s +%s +%s`, expectedLocalRunImages, expectedBuildpacks, expectedDetectionOrder) + + expectedPrettifiedYAML = fmt.Sprintf(` builder_name: test-builder + trusted: false + default: false +%s +%s`, expectedRemoteInfo, expectedLocalInfo) + ) + + when("Print", func() { + it.Before(func() { + remoteInfo = &pack.BuilderInfo{ + Description: "Some remote description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/default", "second/default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("6.7.8"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("2.3")}, + }, + Platform: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("0.1"), api.MustParse("1.2")}, + Supported: builder.APISet{api.MustParse("4.5")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "1.2.3", + }, + } + + localInfo = &pack.BuilderInfo{ + Description: "Some local description", + Stack: "test.stack.id", + Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, + RunImage: "some/run-image", + RunImageMirrors: []string{"first/local-default", "second/local-default"}, + Buildpacks: buildpacks, + Order: order, + BuildpackLayers: dist.BuildpackLayers{}, + Lifecycle: builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{ + Version: &builder.Version{ + Version: *semver.MustParse("4.5.6"), + }, + }, + APIs: builder.LifecycleAPIs{ + Buildpack: builder.APIVersions{ + Deprecated: builder.APISet{api.MustParse("4.5"), api.MustParse("6.7")}, + Supported: builder.APISet{api.MustParse("8.9"), api.MustParse("10.11")}, + }, + Platform: builder.APIVersions{ + Deprecated: nil, + Supported: builder.APISet{api.MustParse("7.8")}, + }, + }, + }, + CreatedBy: builder.CreatorMetadata{ + Name: "Pack CLI", + Version: "4.5.6", + }, + } + }) + + it("prints both local remote builders as valid YAML", func() { + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.Contains(prettyYAML, expectedPrettifiedYAML) + }) + + when("builder doesn't exist locally or remotely", func() { + it("returns an error", func() { + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, nil, nil, nil, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "unable to find builder 'test-builder' locally or remotely") + }) + }) + + when("builder doesn't exist locally", func() { + it("shows null for local builder, and normal output for remote", func() { + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, nil, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettyYAML, `local_info: null`) + assert.ContainsYAML(prettyYAML, expectedRemoteInfo) + }) + }) + + when("builder doesn't exist remotely", func() { + it("shows null for remote builder, and normal output for local", func() { + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, localInfo, nil, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettyYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettyYAML, `remote_info: null`) + assert.ContainsYAML(prettyYAML, expectedLocalInfo) + }) + }) + + when("localErr is an error", func() { + it("returns the error, and doesn't write any yaml output", func() { + expectedErr := errors.New("failed to retrieve local info") + + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, expectedErr, nil, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve local info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("remoteErr is an error", func() { + it("returns the error, and doesn't write any yaml output", func() { + expectedErr := errors.New("failed to retrieve remote info") + + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, expectedErr, sharedBuilderInfo) + assert.ErrorWithMessage(err, "preparing output for 'test-builder': failed to retrieve remote info") + + assert.Equal(outBuf.String(), "") + }) + }) + + when("logger is verbose", func() { + it("displays mixins associated with the stack", func() { + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettifiedYAML, expectedStackWithMixins) + }) + }) + + when("no run images are specified", func() { + it("displays run images as empty list", func() { + localInfo.RunImage = "" + localInfo.RunImageMirrors = []string{} + remoteInfo.RunImage = "" + remoteInfo.RunImageMirrors = []string{} + emptyLocalRunImages := []config.RunImage{} + + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := yamlWriter.Print(logger, emptyLocalRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettifiedYAML, `run_images: []`) + }) + }) + + when("no buildpacks are specified", func() { + it("displays buildpacks as empty list", func() { + localInfo.Buildpacks = []dist.BuildpackInfo{} + remoteInfo.Buildpacks = []dist.BuildpackInfo{} + + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettifiedYAML, `buildpacks: []`) + }) + }) + + when("no detection order is specified", func() { + it("displays detection order as empty list", func() { + localInfo.Order = pubbldr.DetectionOrder{} + remoteInfo.Order = pubbldr.DetectionOrder{} + + yamlWriter := writer.NewYAML() + + logger := ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) + err := yamlWriter.Print(logger, localRunImages, localInfo, remoteInfo, nil, nil, sharedBuilderInfo) + assert.Nil(err) + + prettifiedYAML, err := validYAML(outBuf) + assert.Nil(err) + + assert.ContainsYAML(prettifiedYAML, `detection_order: []`) + }) + }) + }) +} + +func validYAML(source bytes.Buffer) (string, error) { + err := yaml.Unmarshal(source.Bytes(), &struct{}{}) + if err != nil { + return "", fmt.Errorf("failed to unmarshal to yaml: %w", err) + } + + return source.String(), nil +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 97a3550f7..2f4530cd1 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -18,7 +18,7 @@ import ( //go:generate mockgen -package testmocks -destination testmocks/mock_pack_client.go github.com/buildpacks/pack/internal/commands PackClient type PackClient interface { - InspectBuilder(string, bool) (*pack.BuilderInfo, error) + InspectBuilder(string, bool, ...pack.BuilderInspectionModifier) (*pack.BuilderInfo, error) InspectImage(string, bool) (*pack.ImageInfo, error) Rebase(context.Context, pack.RebaseOptions) error CreateBuilder(context.Context, pack.CreateBuilderOptions) error diff --git a/internal/commands/fakes/fake_builder_inspector.go b/internal/commands/fakes/fake_builder_inspector.go new file mode 100644 index 000000000..d8f7fff20 --- /dev/null +++ b/internal/commands/fakes/fake_builder_inspector.go @@ -0,0 +1,37 @@ +package fakes + +import "github.com/buildpacks/pack" + +type FakeBuilderInspector struct { + InfoForLocal *pack.BuilderInfo + InfoForRemote *pack.BuilderInfo + ErrorForLocal error + ErrorForRemote error + + ReceivedForLocalName string + ReceivedForRemoteName string + CalculatedConfigForLocal pack.BuilderInspectionConfig + CalculatedConfigForRemote pack.BuilderInspectionConfig +} + +func (i *FakeBuilderInspector) InspectBuilder( + name string, + daemon bool, + modifiers ...pack.BuilderInspectionModifier, +) (*pack.BuilderInfo, error) { + if daemon { + i.CalculatedConfigForLocal = pack.BuilderInspectionConfig{} + for _, mod := range modifiers { + mod(&i.CalculatedConfigForLocal) + } + i.ReceivedForLocalName = name + return i.InfoForLocal, i.ErrorForLocal + } + + i.CalculatedConfigForRemote = pack.BuilderInspectionConfig{} + for _, mod := range modifiers { + mod(&i.CalculatedConfigForRemote) + } + i.ReceivedForRemoteName = name + return i.InfoForRemote, i.ErrorForRemote +} diff --git a/internal/commands/fakes/fake_builder_writer.go b/internal/commands/fakes/fake_builder_writer.go new file mode 100644 index 000000000..58e9cb333 --- /dev/null +++ b/internal/commands/fakes/fake_builder_writer.go @@ -0,0 +1,41 @@ +package fakes + +import ( + "github.com/buildpacks/pack" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/logging" +) + +type FakeBuilderWriter struct { + PrintForLocal string + PrintForRemote string + ErrorForPrint error + + ReceivedInfoForLocal *pack.BuilderInfo + ReceivedInfoForRemote *pack.BuilderInfo + ReceivedErrorForLocal error + ReceivedErrorForRemote error + ReceivedBuilderInfo writer.SharedBuilderInfo + ReceivedLocalRunImages []config.RunImage +} + +func (w *FakeBuilderWriter) Print( + logger logging.Logger, + localRunImages []config.RunImage, + local, remote *pack.BuilderInfo, + localErr, remoteErr error, + builderInfo writer.SharedBuilderInfo, +) error { + w.ReceivedInfoForLocal = local + w.ReceivedInfoForRemote = remote + w.ReceivedErrorForLocal = localErr + w.ReceivedErrorForRemote = remoteErr + w.ReceivedBuilderInfo = builderInfo + w.ReceivedLocalRunImages = localRunImages + + logger.Infof("\nLOCAL:\n%s\n", w.PrintForLocal) + logger.Infof("\nREMOTE:\n%s\n", w.PrintForRemote) + + return w.ErrorForPrint +} diff --git a/internal/commands/fakes/fake_builder_writer_factory.go b/internal/commands/fakes/fake_builder_writer_factory.go new file mode 100644 index 000000000..ffe3ebe12 --- /dev/null +++ b/internal/commands/fakes/fake_builder_writer_factory.go @@ -0,0 +1,18 @@ +package fakes + +import ( + "github.com/buildpacks/pack/internal/builder/writer" +) + +type FakeBuilderWriterFactory struct { + ReturnForWriter writer.BuilderWriter + ErrorForWriter error + + ReceivedForKind string +} + +func (f *FakeBuilderWriterFactory) Writer(kind string) (writer.BuilderWriter, error) { + f.ReceivedForKind = kind + + return f.ReturnForWriter, f.ErrorForWriter +} diff --git a/internal/commands/inspect_builder.go b/internal/commands/inspect_builder.go index d93819278..e849d4894 100644 --- a/internal/commands/inspect_builder.go +++ b/internal/commands/inspect_builder.go @@ -1,39 +1,31 @@ package commands import ( - "bytes" - "fmt" - "io" - "strings" - "text/tabwriter" - "text/template" - - "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/buildpacks/pack" - "github.com/buildpacks/pack/internal/builder" + "github.com/buildpacks/pack/builder" "github.com/buildpacks/pack/internal/config" - "github.com/buildpacks/pack/internal/dist" - "github.com/buildpacks/pack/internal/style" "github.com/buildpacks/pack/logging" ) -const ( - writerMinWidth = 0 - writerTabWidth = 0 - buildpacksTabWidth = 8 - defaultTabWidth = 4 - writerPadChar = ' ' - writerFlags = 0 - none = "(none)" -) +type BuilderInspector interface { + InspectBuilder(name string, daemon bool, modifiers ...pack.BuilderInspectionModifier) (*pack.BuilderInfo, error) +} type InspectBuilderFlags struct { - Depth int + Depth int + OutputFormat string } -func InspectBuilder(logger logging.Logger, cfg config.Config, client PackClient) *cobra.Command { +func InspectBuilder( + logger logging.Logger, + cfg config.Config, + inspector BuilderInspector, + writerFactory writer.BuilderWriterFactory, +) *cobra.Command { var flags InspectBuilderFlags cmd := &cobra.Command{ Use: "inspect-builder ", @@ -41,391 +33,34 @@ func InspectBuilder(logger logging.Logger, cfg config.Config, client PackClient) Short: "Show information about a builder", Example: "pack inspect-builder cnbs/sample-builder:bionic", RunE: logError(logger, func(cmd *cobra.Command, args []string) error { - if cfg.DefaultBuilder == "" && len(args) == 0 { - suggestSettingBuilder(logger, client) - return pack.NewSoftError() - } - imageName := cfg.DefaultBuilder if len(args) >= 1 { imageName = args[0] } - verbose := logger.IsVerbose() - presentRemote, remoteOutput, remoteWarnings, remoteErr := inspectBuilderOutput(client, cfg, imageName, false, verbose, flags.Depth) - presentLocal, localOutput, localWarnings, localErr := inspectBuilderOutput(client, cfg, imageName, true, verbose, flags.Depth) - - if !presentRemote && !presentLocal { - return errors.New(fmt.Sprintf("Unable to find builder '%s' locally or remotely.\n", imageName)) + if imageName == "" { + suggestSettingBuilder(logger, inspector) + return pack.NewSoftError() } - if imageName == cfg.DefaultBuilder { - logger.Infof("Inspecting default builder: %s\n", style.Symbol(imageName)) - } else { - logger.Infof("Inspecting builder: %s\n", style.Symbol(imageName)) + builderInfo := writer.SharedBuilderInfo{ + Name: imageName, + IsDefault: imageName == cfg.DefaultBuilder, + Trusted: isTrustedBuilder(cfg, imageName), } - if remoteErr != nil { - logger.Error(remoteErr.Error()) - } else { - logger.Infof("\nREMOTE:\n%s\n", remoteOutput) - for _, w := range remoteWarnings { - logger.Warn(w) - } - } + localInfo, localErr := inspector.InspectBuilder(imageName, true, pack.WithDetectionOrderDepth(flags.Depth)) + remoteInfo, remoteErr := inspector.InspectBuilder(imageName, false, pack.WithDetectionOrderDepth(flags.Depth)) - if localErr != nil { - logger.Error(localErr.Error()) - } else { - logger.Infof("\nLOCAL:\n%s\n", localOutput) - for _, w := range localWarnings { - logger.Warn(w) - } + writer, err := writerFactory.Writer(flags.OutputFormat) + if err != nil { + return err } - - return nil + return writer.Print(logger, cfg.RunImages, localInfo, remoteInfo, localErr, remoteErr, builderInfo) }), } - cmd.Flags().IntVarP(&flags.Depth, "depth", "d", -1, "Max depth to display for Detection Order.\nOmission of this flag or values < 0 will display the entire tree.") + cmd.Flags().IntVarP(&flags.Depth, "depth", "d", builder.OrderDetectionMaxDepth, "Max depth to display for Detection Order.\nOmission of this flag or values < 0 will display the entire tree.") + cmd.Flags().StringVarP(&flags.OutputFormat, "output", "o", "human-readable", "Output format to display builder detail (json, yaml, toml, human-readable).\nOmission of this flag will display as human-readable.") AddHelpFlag(cmd, "inspect-builder") return cmd } - -func inspectBuilderOutput(client PackClient, cfg config.Config, imageName string, local bool, verbose bool, depth int) (present bool, output string, warning []string, err error) { - source := "remote" - if local { - source = "local" - } - - info, err := client.InspectBuilder(imageName, local) - if err != nil { - return true, "", nil, errors.Wrapf(err, "inspecting %s image '%s'", source, imageName) - } - - if info == nil { - return false, "(not present)", nil, nil - } - - var buf bytes.Buffer - warnings, err := generateBuilderOutput(&buf, imageName, cfg, *info, verbose, depth) - if err != nil { - return true, "", nil, errors.Wrapf(err, "writing output for %s image '%s'", source, imageName) - } - - return true, buf.String(), warnings, nil -} - -func generateBuilderOutput(writer io.Writer, imageName string, cfg config.Config, info pack.BuilderInfo, verbose bool, depth int) (warnings []string, err error) { - tpl := template.Must(template.New("").Parse(` -{{ if ne .Info.Description "" -}} -Description: {{ .Info.Description }} - -{{ end -}} - -{{- if ne .Info.CreatedBy.Name "" -}} -Created By: - Name: {{ .Info.CreatedBy.Name }} - Version: {{ .Info.CreatedBy.Version }} - -{{ end -}} - -Trusted: {{.Trusted}} - -Stack: - ID: {{ .Info.Stack }} -{{- if .Verbose}} -{{- if ne (len .Info.Mixins) 0 }} - Mixins: -{{- end }} -{{- range $index, $mixin := .Info.Mixins }} - {{ $mixin }} -{{- end }} -{{- end }} - -Lifecycle: - Version: {{- if .Info.Lifecycle.Info.Version }} {{ .Info.Lifecycle.Info.Version }}{{- else }} (none){{- end }} - Buildpack APIs: - Deprecated: {{ .DeprecatedBuildpackAPIs }} - Supported: {{ .SupportedBuildpackAPIs }} - Platform APIs: - Deprecated: {{ .DeprecatedPlatformAPIs }} - Supported: {{ .SupportedPlatformAPIs }} - -Run Images: -{{- if ne .RunImages "" }} -{{ .RunImages }} -{{- else }} - (none) -{{- end }} - -Buildpacks: -{{- if .Info.Buildpacks }} -{{ .Buildpacks }} -{{- else }} - (none) -{{- end }} - -Detection Order: -{{- if ne .Order "" }} -{{ .Order }} -{{- else }} - (none) -{{ end }}`, - )) - - bps, err := buildpacksOutput(info.Buildpacks) - if err != nil { - return nil, err - } - - if len(info.Buildpacks) == 0 { - warnings = append(warnings, fmt.Sprintf("%s has no buildpacks", style.Symbol(imageName))) - warnings = append(warnings, "Users must supply buildpacks from the host machine") - } - - order, err := detectionOrderOutput(info.Order, info.BuildpackLayers, depth) - if err != nil { - return nil, err - } - - if len(info.Order) == 0 { - warnings = append(warnings, fmt.Sprintf("%s does not specify detection order", style.Symbol(imageName))) - warnings = append(warnings, "Users must build with explicitly specified buildpacks") - } - - runImgs, err := runImagesOutput(info.RunImage, info.RunImageMirrors, cfg) - if err != nil { - return nil, err - } - - if info.RunImage == "" { - warnings = append(warnings, fmt.Sprintf("%s does not specify a run image", style.Symbol(imageName))) - warnings = append(warnings, "Users must build with an explicitly specified run image") - } - - lcDescriptor := &info.Lifecycle - if lcDescriptor.Info.Version == nil { - warnings = append(warnings, fmt.Sprintf("%s does not specify a Lifecycle version", style.Symbol(imageName))) - } - - deprecatedBuildpackAPIs := stringifyAPISet(lcDescriptor.APIs.Buildpack.Deprecated) - supportedBuildpackAPIs := stringifyAPISet(lcDescriptor.APIs.Buildpack.Supported) - deprecatedPlatformAPIs := stringifyAPISet(lcDescriptor.APIs.Platform.Deprecated) - supportedPlatformAPIs := stringifyAPISet(lcDescriptor.APIs.Platform.Supported) - - if supportedBuildpackAPIs == none { - warnings = append(warnings, fmt.Sprintf("%s does not specify supported Lifecycle Buildpack APIs", style.Symbol(imageName))) - } - if supportedPlatformAPIs == none { - warnings = append(warnings, fmt.Sprintf("%s does not specify supported Lifecycle Platform APIs", style.Symbol(imageName))) - } - - trustedString := "No" - if isTrustedBuilder(cfg, imageName) { - trustedString = "Yes" - } - - return warnings, tpl.Execute(writer, &struct { - Info pack.BuilderInfo - Buildpacks string - RunImages string - Order string - Verbose bool - Trusted string - DeprecatedBuildpackAPIs string - SupportedBuildpackAPIs string - DeprecatedPlatformAPIs string - SupportedPlatformAPIs string - }{ - info, - bps, - runImgs, - order, - verbose, - trustedString, - deprecatedBuildpackAPIs, - supportedBuildpackAPIs, - deprecatedPlatformAPIs, - supportedPlatformAPIs, - }) -} - -func stringifyAPISet(versions builder.APISet) string { - if len(versions) == 0 { - return none - } - - return strings.Join(versions.AsStrings(), ", ") -} - -func buildpacksOutput(bps []dist.BuildpackInfo) (string, error) { - buf := &bytes.Buffer{} - tabWriter := new(tabwriter.Writer).Init(buf, writerMinWidth, writerPadChar, buildpacksTabWidth, writerPadChar, writerFlags) - if _, err := fmt.Fprint(tabWriter, " ID\tVERSION\tHOMEPAGE\n"); err != nil { - return "", err - } - - for _, bp := range bps { - if _, err := fmt.Fprintf(tabWriter, " %s\t%s\t%s\n", bp.ID, bp.Version, bp.Homepage); err != nil { - return "", err - } - } - - if err := tabWriter.Flush(); err != nil { - return "", err - } - - return strings.TrimSuffix(buf.String(), "\n"), nil -} - -func runImagesOutput(runImage string, mirrors []string, cfg config.Config) (string, error) { - buf := &bytes.Buffer{} - - tabWriter := new(tabwriter.Writer).Init(buf, writerMinWidth, writerTabWidth, defaultTabWidth, writerPadChar, writerFlags) - - for _, r := range getLocalMirrors(runImage, cfg) { - if _, err := fmt.Fprintf(tabWriter, " %s\t(user-configured)\n", r); err != nil { - return "", err - } - } - - if runImage != "" { - if _, err := fmt.Fprintf(tabWriter, " %s\n", runImage); err != nil { - return "", err - } - } - - for _, r := range mirrors { - if _, err := fmt.Fprintf(tabWriter, " %s\n", r); err != nil { - return "", err - } - } - - if err := tabWriter.Flush(); err != nil { - return "", err - } - - return strings.TrimSuffix(buf.String(), "\n"), nil -} - -// Unable to easily convert format makes this feel like a poor solution... -func detectionOrderOutput(order dist.Order, layers dist.BuildpackLayers, maxDepth int) (string, error) { - buf := strings.Builder{} - tabWriter := new(tabwriter.Writer).Init(&buf, writerMinWidth, writerTabWidth, defaultTabWidth, writerPadChar, writerFlags) - buildpackSet := map[pack.BuildpackInfoKey]bool{} - - if err := orderOutputRecurrence(tabWriter, "", order, layers, buildpackSet, 0, maxDepth); err != nil { - return "", err - } - if err := tabWriter.Flush(); err != nil { - return "", fmt.Errorf("error flushing tabWriter output: %s", err) - } - return strings.TrimSuffix(buf.String(), "\n"), nil -} - -// Recursively generate output for every buildpack in an order. -func orderOutputRecurrence(w io.Writer, prefix string, order dist.Order, layers dist.BuildpackLayers, buildpackSet map[pack.BuildpackInfoKey]bool, curDepth, maxDepth int) error { - // exit if maxDepth is exceeded - if validMaxDepth(maxDepth) && maxDepth <= curDepth { - return nil - } - - // otherwise iterate over all nested buildpacks - for groupIndex, group := range order { - lastGroup := groupIndex == (len(order) - 1) - if err := displayGroup(w, prefix, groupIndex+1, lastGroup); err != nil { - return fmt.Errorf("error when printing group info: %q", err) - } - for bpIndex, buildpackEntry := range group.Group { - lastBuildpack := bpIndex == len(group.Group)-1 - - key := pack.BuildpackInfoKey{ - ID: buildpackEntry.ID, - Version: buildpackEntry.Version, - } - _, visited := buildpackSet[key] - buildpackSet[key] = true - - curBuildpackLayer, ok := layers.Get(buildpackEntry.ID, buildpackEntry.Version) - if !ok { - return fmt.Errorf("error: missing buildpack %s@%s from layer metadata", buildpackEntry.ID, buildpackEntry.Version) - } - - newBuildpackPrefix := updatePrefix(prefix, lastGroup) - if err := displayBuildpack(w, newBuildpackPrefix, buildpackEntry, visited, bpIndex == len(group.Group)-1); err != nil { - return fmt.Errorf("error when printing buildpack info: %q", err) - } - - newGroupPrefix := updatePrefix(newBuildpackPrefix, lastBuildpack) - if !visited { - if err := orderOutputRecurrence(w, newGroupPrefix, curBuildpackLayer.Order, layers, buildpackSet, curDepth+1, maxDepth); err != nil { - return err - } - } - - // remove key from set after recurrence completes, so we only detect cycles. - delete(buildpackSet, key) - } - } - return nil -} - -const ( - branchPrefix = " ├ " - lastBranchPrefix = " └ " - trunkPrefix = " │ " -) - -func updatePrefix(oldPrefix string, last bool) string { - if last { - return oldPrefix + " " - } - return oldPrefix + trunkPrefix -} - -func validMaxDepth(depth int) bool { - return depth >= 0 -} - -func displayGroup(w io.Writer, prefix string, groupCount int, last bool) error { - treePrefix := branchPrefix - if last { - treePrefix = lastBranchPrefix - } - _, err := fmt.Fprintf(w, "%s%sGroup #%d:\n", prefix, treePrefix, groupCount) - return err -} - -func displayBuildpack(w io.Writer, prefix string, entry dist.BuildpackRef, visited bool, last bool) error { - var optional string - if entry.Optional { - optional = "(optional)" - } - - visitedStatus := "" - if visited { - visitedStatus = "[cyclic]" - } - - bpRef := entry.ID - if entry.Version != "" { - bpRef += "@" + entry.Version - } - - treePrefix := branchPrefix - if last { - treePrefix = lastBranchPrefix - } - - _, err := fmt.Fprintf(w, "%s%s%s\t%s%s\n", prefix, treePrefix, bpRef, optional, visitedStatus) - return err -} - -func getLocalMirrors(runImage string, cfg config.Config) []string { - for _, ri := range cfg.RunImages { - if ri.Image == runImage { - return ri.Mirrors - } - } - return nil -} diff --git a/internal/commands/inspect_builder_test.go b/internal/commands/inspect_builder_test.go index 9674df1bb..2a7756e6e 100644 --- a/internal/commands/inspect_builder_test.go +++ b/internal/commands/inspect_builder_test.go @@ -6,706 +6,374 @@ import ( "regexp" "testing" - "github.com/Masterminds/semver" - "github.com/buildpacks/lifecycle/api" - "github.com/golang/mock/gomock" + "github.com/buildpacks/pack/internal/builder/writer" + "github.com/heroku/color" "github.com/sclevine/spec" "github.com/sclevine/spec/report" - "github.com/spf13/cobra" + + "github.com/buildpacks/lifecycle/api" "github.com/buildpacks/pack" "github.com/buildpacks/pack/internal/builder" "github.com/buildpacks/pack/internal/commands" - "github.com/buildpacks/pack/internal/commands/testmocks" + "github.com/buildpacks/pack/internal/commands/fakes" "github.com/buildpacks/pack/internal/config" - "github.com/buildpacks/pack/internal/dist" ilogging "github.com/buildpacks/pack/internal/logging" "github.com/buildpacks/pack/logging" h "github.com/buildpacks/pack/testhelpers" ) +var ( + minimalLifecycleDescriptor = builder.LifecycleDescriptor{ + Info: builder.LifecycleInfo{Version: builder.VersionMustParse("3.4")}, + API: builder.LifecycleAPI{ + BuildpackVersion: api.MustParse("1.2"), + PlatformVersion: api.MustParse("2.3"), + }, + } + + expectedLocalRunImages = []config.RunImage{ + {Image: "some/run-image", Mirrors: []string{"first/local", "second/local"}}, + } + expectedLocalInfo = &pack.BuilderInfo{ + Description: "test-local-builder", + Stack: "local-stack", + RunImage: "local/image", + Lifecycle: minimalLifecycleDescriptor, + } + expectedRemoteInfo = &pack.BuilderInfo{ + Description: "test-remote-builder", + Stack: "remote-stack", + RunImage: "remote/image", + Lifecycle: minimalLifecycleDescriptor, + } + expectedLocalDisplay = "Sample output for local builder" + expectedRemoteDisplay = "Sample output for remote builder" + expectedBuilderInfo = writer.SharedBuilderInfo{ + Name: "default/builder", + Trusted: false, + IsDefault: true, + } +) + func TestInspectBuilderCommand(t *testing.T) { color.Disable(true) defer color.Disable(false) spec.Run(t, "Commands", testInspectBuilderCommand, spec.Parallel(), spec.Report(report.Terminal{})) } -const inspectBuilderRemoteOutputSection = ` -REMOTE: - -Description: Some remote description - -Created By: - Name: Pack CLI - Version: 1.2.3 - -Trusted: No - -Stack: - ID: test.stack.id - -Lifecycle: - Version: 6.7.8 - Buildpack APIs: - Deprecated: (none) - Supported: 1.2, 2.3 - Platform APIs: - Deprecated: 0.1, 1.2 - Supported: 4.5 - -Run Images: - first/local (user-configured) - second/local (user-configured) - some/run-image - first/default - second/default - -Buildpacks: - ID VERSION HOMEPAGE - test.top.nested test.top.nested.version - test.nested test.nested.version http://geocities.com/top-bp - test.bp.one test.bp.one.version http://geocities.com/cool-bp - test.bp.two test.bp.two.version - test.bp.three test.bp.three.version - -Detection Order: - └ Group #1: - ├ test.top.nested@test.top.nested.version - │ └ Group #1: - │ ├ test.nested@test.nested.version - │ │ └ Group #1: - │ │ └ test.bp.one@test.bp.one.version (optional) - │ └ test.bp.three@test.bp.three.version (optional) - └ test.bp.two (optional) -` - -const inspectBuilderLocalOutputSection = ` -LOCAL: - -Description: Some local description - -Created By: - Name: Pack CLI - Version: 4.5.6 - -Trusted: No - -Stack: - ID: test.stack.id - -Lifecycle: - Version: 4.5.6 - Buildpack APIs: - Deprecated: 4.5, 6.7 - Supported: 8.9, 10.11 - Platform APIs: - Deprecated: (none) - Supported: 7.8 - -Run Images: - first/local (user-configured) - second/local (user-configured) - some/run-image - first/local-default - second/local-default - -Buildpacks: - ID VERSION HOMEPAGE - test.top.nested test.top.nested.version - test.nested test.nested.version http://geocities.com/top-bp - test.bp.one test.bp.one.version http://geocities.com/cool-bp - test.bp.two test.bp.two.version - test.bp.three test.bp.three.version - -Detection Order: - └ Group #1: - ├ test.top.nested@test.top.nested.version - │ └ Group #1: - │ ├ test.nested@test.nested.version - │ │ └ Group #1: - │ │ └ test.bp.one@test.bp.one.version (optional) - │ └ test.bp.three@test.bp.three.version (optional) - └ test.bp.two (optional) -` - -const stackLabelsSection = ` -Stack: - ID: test.stack.id - Mixins: - mixin1 - mixin2 - build:mixin3 - build:mixin4 -` - -const detectionOrderWithDepth = `Detection Order: - └ Group #1: - ├ test.top.nested@test.top.nested.version - │ └ Group #1: - │ ├ test.nested@test.nested.version - │ └ test.bp.three@test.bp.three.version (optional) - └ test.bp.two (optional)` - -const detectionOrderWithCycle = `Detection Order: - ├ Group #1: - │ ├ test.top.nested@test.top.nested.version - │ │ └ Group #1: - │ │ └ test.nested@test.nested.version - │ │ └ Group #1: - │ │ └ test.top.nested@test.top.nested.version [cyclic] - │ └ test.bp.two (optional) - └ Group #2: - └ test.nested@test.nested.version - └ Group #1: - └ test.top.nested@test.top.nested.version - └ Group #1: - └ test.nested@test.nested.version [cyclic] -` -const selectDefaultBuilderOutput = `Please select a default builder with: - - pack set-default-builder ` - func testInspectBuilderCommand(t *testing.T, when spec.G, it spec.S) { - apiVersion, err := api.NewVersion("0.2") - if err != nil { - t.Fail() - } - var ( - command *cobra.Command - logger logging.Logger - outBuf bytes.Buffer - assert = h.NewAssertionManager(t) - mockController *gomock.Controller - mockClient *testmocks.MockPackClient - cfg config.Config - buildpacks = []dist.BuildpackInfo{ - { - ID: "test.top.nested", - Version: "test.top.nested.version", - }, - { - ID: "test.nested", - Version: "test.nested.version", - Homepage: "http://geocities.com/top-bp", - }, - { - ID: "test.bp.one", - Version: "test.bp.one.version", - Homepage: "http://geocities.com/cool-bp", - }, - { - ID: "test.bp.two", - Version: "test.bp.two.version", - }, - { - ID: "test.bp.three", - Version: "test.bp.three.version", - }, - } - order = dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ID: "test.top.nested", Version: "test.top.nested.version"}, - Optional: false, - }, - { - BuildpackInfo: dist.BuildpackInfo{ID: "test.bp.two"}, - Optional: true, - }, - }, - }, - } - buildpackLayers = map[string]map[string]dist.BuildpackLayerInfo{ - "test.top.nested": { - "test.top.nested.version": { - API: apiVersion, - Order: dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ - ID: "test.nested", - Version: "test.nested.version", - }, - Optional: false, - }, - { - BuildpackInfo: dist.BuildpackInfo{ - ID: "test.bp.three", - Version: "test.bp.three.version", - }, - Optional: true, - }, - }, - }, - }, - LayerDiffID: "sha256:test.top.nested.sha256", - }, - }, - "test.nested": { - "test.nested.version": { - API: apiVersion, - Order: dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ - ID: "test.bp.one", - Version: "test.bp.one.version", - }, - Optional: true, - }, - }, - }, - }, - LayerDiffID: "sha256:test.nested.sha256", - Homepage: "http://geocities.com/top-bp", - }, - }, - "test.bp.one": { - "test.bp.one.version": { - API: apiVersion, - Stacks: []dist.Stack{ - { - ID: "test.stack.id", - }, - }, - LayerDiffID: "sha256:test.bp.one.sha256", - Homepage: "http://geocities.com/cool-bp", - }, - }, - "test.bp.two": { - "test.bp.two.version": { - API: apiVersion, - Stacks: []dist.Stack{ - { - ID: "test.stack.id", - }, - }, - LayerDiffID: "sha256:test.bp.two.sha256", - }, - }, - "test.bp.three": { - "test.bp.three.version": { - API: apiVersion, - Stacks: []dist.Stack{ - { - ID: "test.stack.id", - }, - }, - LayerDiffID: "sha256:test.bp.three.sha256", - }, - }, - } - - remoteInfo = &pack.BuilderInfo{ - Description: "Some remote description", - Stack: "test.stack.id", - Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, - RunImage: "some/run-image", - RunImageMirrors: []string{"first/default", "second/default"}, - Buildpacks: buildpacks, - Order: order, - BuildpackLayers: buildpackLayers, - Lifecycle: builder.LifecycleDescriptor{ - Info: builder.LifecycleInfo{ - Version: &builder.Version{ - Version: *semver.MustParse("6.7.8"), - }, - }, - APIs: builder.LifecycleAPIs{ - Buildpack: builder.APIVersions{ - Deprecated: nil, - Supported: builder.APISet{api.MustParse("1.2"), api.MustParse("2.3")}, - }, - Platform: builder.APIVersions{ - Deprecated: builder.APISet{api.MustParse("0.1"), api.MustParse("1.2")}, - Supported: builder.APISet{api.MustParse("4.5")}, - }, - }, - }, - CreatedBy: builder.CreatorMetadata{ - Name: "Pack CLI", - Version: "1.2.3", - }, - } - localInfo = &pack.BuilderInfo{ - Description: "Some local description", - Stack: "test.stack.id", - Mixins: []string{"mixin1", "mixin2", "build:mixin3", "build:mixin4"}, - RunImage: "some/run-image", - RunImageMirrors: []string{"first/local-default", "second/local-default"}, - Buildpacks: buildpacks, - Order: order, - BuildpackLayers: buildpackLayers, - Lifecycle: builder.LifecycleDescriptor{ - Info: builder.LifecycleInfo{ - Version: &builder.Version{ - Version: *semver.MustParse("4.5.6"), - }, - }, - APIs: builder.LifecycleAPIs{ - Buildpack: builder.APIVersions{ - Deprecated: builder.APISet{api.MustParse("4.5"), api.MustParse("6.7")}, - Supported: builder.APISet{api.MustParse("8.9"), api.MustParse("10.11")}, - }, - Platform: builder.APIVersions{ - Deprecated: nil, - Supported: builder.APISet{api.MustParse("7.8")}, - }, - }, - }, - CreatedBy: builder.CreatorMetadata{ - Name: "Pack CLI", - Version: "4.5.6", - }, - } + logger logging.Logger + outBuf bytes.Buffer + cfg config.Config ) + it.Before(func() { cfg = config.Config{ DefaultBuilder: "default/builder", - RunImages: []config.RunImage{ - {Image: "some/run-image", Mirrors: []string{"first/local", "second/local"}}, - }, + RunImages: expectedLocalRunImages, } - mockController = gomock.NewController(t) - mockClient = testmocks.NewMockPackClient(mockController) logger = ilogging.NewLogWithWriters(&outBuf, &outBuf) - - command = commands.InspectBuilder(logger, cfg, mockClient) }) - it.After(func() { - mockController.Finish() - }) + when("InspectBuilder", func() { + var ( + assert = h.NewAssertionManager(t) + ) + + it("passes output of local and remote builders to correct writer", func() { + builderInspector := newDefaultBuilderInspector() + builderWriter := newDefaultBuilderWriter() + builderWriterFactory := newWriterFactory(returnsForWriter(builderWriter)) + + command := commands.InspectBuilder(logger, cfg, builderInspector, builderWriterFactory) + command.SetArgs([]string{}) + err := command.Execute() + assert.Nil(err) + + assert.Equal(builderWriter.ReceivedInfoForLocal, expectedLocalInfo) + assert.Equal(builderWriter.ReceivedInfoForRemote, expectedRemoteInfo) + assert.Equal(builderWriter.ReceivedBuilderInfo, expectedBuilderInfo) + assert.Equal(builderWriter.ReceivedLocalRunImages, expectedLocalRunImages) + assert.Equal(builderWriterFactory.ReceivedForKind, "human-readable") + assert.Equal(builderInspector.ReceivedForLocalName, "default/builder") + assert.Equal(builderInspector.ReceivedForRemoteName, "default/builder") + assert.ContainsF(outBuf.String(), "LOCAL:\n%s", expectedLocalDisplay) + assert.ContainsF(outBuf.String(), "REMOTE:\n%s", expectedRemoteDisplay) + }) - when("#Get", func() { - when("remote builder image cannot be found", func() { - it("warns 'remote image not present'", func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(nil, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(localInfo, nil) + when("image name is provided as first arg", func() { + it("passes that image name to the inspector", func() { + builderInspector := newDefaultBuilderInspector() + writer := newDefaultBuilderWriter() + command := commands.InspectBuilder(logger, cfg, builderInspector, newWriterFactory(returnsForWriter(writer))) command.SetArgs([]string{"some/image"}) - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), `Inspecting builder: 'some/image'`) - assert.Contains(outBuf.String(), "REMOTE:\n(not present)\n\n") - assert.Contains(outBuf.String(), inspectBuilderLocalOutputSection) + + err := command.Execute() + assert.Nil(err) + + assert.Equal(builderInspector.ReceivedForLocalName, "some/image") + assert.Equal(builderInspector.ReceivedForRemoteName, "some/image") + assert.Equal(writer.ReceivedBuilderInfo.IsDefault, false) }) }) - when("local builder image cannot be found", func() { - it("warns 'local image not present'", func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(nil, nil) + when("depth flag is provided", func() { + it("passes a modifier to the builder inspector", func() { + builderInspector := newDefaultBuilderInspector() + command := commands.InspectBuilder(logger, cfg, builderInspector, newDefaultWriterFactory()) + command.SetArgs([]string{"--depth", "5"}) - command.SetArgs([]string{"some/image"}) - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), `Inspecting builder: 'some/image'`) - assert.Contains(outBuf.String(), "LOCAL:\n(not present)\n") + err := command.Execute() + assert.Nil(err) - assert.Contains(outBuf.String(), inspectBuilderRemoteOutputSection) + assert.Equal(builderInspector.CalculatedConfigForLocal.OrderDetectionDepth, 5) + assert.Equal(builderInspector.CalculatedConfigForRemote.OrderDetectionDepth, 5) }) }) - when("image cannot be found", func() { - it("logs 'errors when no image is found'", func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(nil, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(nil, nil) + when("output type is set to json", func() { + it("passes json to the writer factory", func() { + writerFactory := newDefaultWriterFactory() + command := commands.InspectBuilder(logger, cfg, newDefaultBuilderInspector(), writerFactory) + command.SetArgs([]string{"--output", "json"}) - command.SetArgs([]string{"some/image"}) - assert.ErrorContains(command.Execute(), "Unable to find builder 'some/image' locally or remotely.\n") + err := command.Execute() + assert.Nil(err) + + assert.Equal(writerFactory.ReceivedForKind, "json") }) }) - when("inspector returns an error", func() { - it("logs the error message", func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(nil, errors.New("some remote error")) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(nil, errors.New("some local error")) + when("output type is set to toml using the shorthand flag", func() { + it("passes toml to the writer factory", func() { + writerFactory := newDefaultWriterFactory() + command := commands.InspectBuilder(logger, cfg, newDefaultBuilderInspector(), writerFactory) + command.SetArgs([]string{"-o", "toml"}) - command.SetArgs([]string{"some/image"}) - assert.Succeeds(command.Execute()) + err := command.Execute() + assert.Nil(err) - assert.Contains(outBuf.String(), `ERROR: inspecting remote image 'some/image': some remote error`) - assert.Contains(outBuf.String(), `ERROR: inspecting local image 'some/image': some local error`) + assert.Equal(writerFactory.ReceivedForKind, "toml") }) }) - when("the image has empty fields in info", func() { - it.Before(func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(&pack.BuilderInfo{ - Stack: "test.stack.id", - }, nil) + when("builder inspector returns an error for local builder", func() { + it("passes that error to the writer to handle appropriately", func() { + baseError := errors.New("couldn't inspect local") - mockClient.EXPECT().InspectBuilder("some/image", true).Return(&pack.BuilderInfo{ - Stack: "test.stack.id", - }, nil) + builderInspector := newBuilderInspector(errorsForLocal(baseError)) + builderWriter := newDefaultBuilderWriter() + builderWriterFactory := newWriterFactory(returnsForWriter(builderWriter)) - command.SetArgs([]string{"some/image"}) - }) + command := commands.InspectBuilder(logger, cfg, builderInspector, builderWriterFactory) + command.SetArgs([]string{}) + err := command.Execute() + assert.Nil(err) - it("missing creator info is skipped", func() { - assert.Succeeds(command.Execute()) - assert.NotContains(outBuf.String(), "Created By:") + assert.ErrorWithMessage(builderWriter.ReceivedErrorForLocal, "couldn't inspect local") }) + }) - it("missing description is skipped", func() { - assert.Succeeds(command.Execute()) - assert.NotContains(outBuf.String(), "Description:") - }) + when("builder inspector returns an error remote builder", func() { + it("passes that error to the writer to handle appropriately", func() { + baseError := errors.New("couldn't inspect remote") - it("missing stack mixins are skipped", func() { - assert.Succeeds(command.Execute()) - assert.NotContains(outBuf.String(), "Mixins") - }) + builderInspector := newBuilderInspector(errorsForRemote(baseError)) + builderWriter := newDefaultBuilderWriter() + builderWriterFactory := newWriterFactory(returnsForWriter(builderWriter)) - it("missing buildpacks logs a warning", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Buildpacks:\n (none)") - assert.Contains(outBuf.String(), "Warning: 'some/image' has no buildpacks") - assert.Contains(outBuf.String(), "Users must supply buildpacks from the host machine") - }) + command := commands.InspectBuilder(logger, cfg, builderInspector, builderWriterFactory) + command.SetArgs([]string{}) + err := command.Execute() + assert.Nil(err) - it("missing groups logs a warning", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Detection Order:\n (none)") - assert.Contains(outBuf.String(), "Warning: 'some/image' does not specify detection order") - assert.Contains(outBuf.String(), "Users must build with explicitly specified buildpacks") + assert.ErrorWithMessage(builderWriter.ReceivedErrorForRemote, "couldn't inspect remote") }) + }) - it("missing run image logs a warning", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Run Images:\n (none)") - assert.Contains(outBuf.String(), "Warning: 'some/image' does not specify a run image") - assert.Contains(outBuf.String(), "Users must build with an explicitly specified run image") - }) + when("image is trusted", func() { + it("passes builder info with trusted true to the writer's `Print` method", func() { + cfg.TrustedBuilders = []config.TrustedBuilder{ + {Name: "trusted/builder"}, + } + writer := newDefaultBuilderWriter() - it("missing lifecycle version logs a warning", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Warning: 'some/image' does not specify a Lifecycle version") - assert.Contains(outBuf.String(), "Warning: 'some/image' does not specify supported Lifecycle Buildpack APIs") - assert.Contains(outBuf.String(), "Warning: 'some/image' does not specify supported Lifecycle Platform APIs") + command := commands.InspectBuilder( + logger, + cfg, + newDefaultBuilderInspector(), + newWriterFactory(returnsForWriter(writer)), + ) + command.SetArgs([]string{"trusted/builder"}) + + err := command.Execute() + assert.Nil(err) + + assert.Equal(writer.ReceivedBuilderInfo.Trusted, true) }) }) - when("is successful", func() { - when("using the default builder", func() { - it.Before(func() { - cfg.DefaultBuilder = "some/image" - mockClient.EXPECT().InspectBuilder("default/builder", false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder("default/builder", true).Return(localInfo, nil) - command.SetArgs([]string{}) - }) - - it("inspects the default builder", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Inspecting default builder: 'default/builder'") - assert.Contains(outBuf.String(), inspectBuilderRemoteOutputSection) - assert.Contains(outBuf.String(), inspectBuilderLocalOutputSection) - }) - }) + when("default builder is configured and is the same as specified by the command", func() { + it("passes builder info with isDefault true to the writer's `Print` method", func() { + cfg.DefaultBuilder = "the/default-builder" + writer := newDefaultBuilderWriter() - when("a builder arg is passed", func() { - it.Before(func() { - command.SetArgs([]string{"some/image"}) - mockClient.EXPECT().InspectBuilder("some/image", false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(localInfo, nil) - }) - - it("displays builder information for local and remote", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Inspecting builder: 'some/image'") - assert.Contains(outBuf.String(), inspectBuilderRemoteOutputSection) - assert.Contains(outBuf.String(), inspectBuilderLocalOutputSection) - }) - }) + command := commands.InspectBuilder( + logger, + cfg, + newDefaultBuilderInspector(), + newWriterFactory(returnsForWriter(writer)), + ) + command.SetArgs([]string{"the/default-builder"}) + + err := command.Execute() + assert.Nil(err) - when("the logger is verbose", func() { - it.Before(func() { - logger = ilogging.NewLogWithWriters(&outBuf, &outBuf, ilogging.WithVerbose()) - command = commands.InspectBuilder(logger, cfg, mockClient) - - cfg.DefaultBuilder = "some/image" - mockClient.EXPECT().InspectBuilder("default/builder", false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder("default/builder", true).Return(localInfo, nil) - command.SetArgs([]string{}) - }) - - it("displays stack mixins", func() { - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), stackLabelsSection) - }) + assert.Equal(writer.ReceivedBuilderInfo.IsDefault, true) }) + }) - when("the builder is suggested", func() { - it("indicates that it is trusted", func() { - suggestedBuilder := "paketobuildpacks/builder:tiny" + when("default builder is empty and no builder is specified in command args", func() { + it("suggests builders and returns a soft error", func() { + cfg.DefaultBuilder = "" - command.SetArgs([]string{suggestedBuilder}) - mockClient.EXPECT().InspectBuilder(suggestedBuilder, false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder(suggestedBuilder, true).Return(nil, nil) + command := commands.InspectBuilder(logger, cfg, newDefaultBuilderInspector(), newDefaultWriterFactory()) + command.SetArgs([]string{}) - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Trusted: Yes") - }) - }) + err := command.Execute() + assert.Error(err) + if !errors.Is(err, pack.SoftError{}) { + t.Fatalf("expect a pack.SoftError, got: %s", err) + } - when("the builder has been trusted by the user", func() { - it("indicated that it is trusted", func() { - builderName := "trusted/builder" - cfg.TrustedBuilders = []config.TrustedBuilder{{Name: builderName}} - command = commands.InspectBuilder(logger, cfg, mockClient) + assert.Contains(outBuf.String(), `Please select a default builder with: - command.SetArgs([]string{builderName}) - mockClient.EXPECT().InspectBuilder(builderName, false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder(builderName, true).Return(localInfo, nil) + pack set-default-builder `) - assert.Succeeds(command.Execute()) - assert.Contains(outBuf.String(), "Trusted: Yes") - }) + assert.Matches(outBuf.String(), regexp.MustCompile(`Paketo Buildpacks:\s+'paketobuildpacks/builder:base'`)) + assert.Matches(outBuf.String(), regexp.MustCompile(`Paketo Buildpacks:\s+'paketobuildpacks/builder:full'`)) + assert.Matches(outBuf.String(), regexp.MustCompile(`Heroku:\s+'heroku/buildpacks:18'`)) }) }) - when("default builder is not set", func() { - when("no builder arg is passed", func() { - it.Before(func() { - command = commands.InspectBuilder(logger, config.Config{}, mockClient) - command.SetArgs([]string{}) - - // expect client to fetch suggested builder descriptions - mockClient.EXPECT().InspectBuilder(gomock.Any(), false).Return(&pack.BuilderInfo{}, nil).AnyTimes() - }) - - it("informs the user", func() { - assert.Fails(command.Execute()) - assert.Contains(outBuf.String(), selectDefaultBuilderOutput) - assert.Matches(outBuf.String(), regexp.MustCompile(`Paketo Buildpacks:\s+'paketobuildpacks/builder:base'`)) - assert.Matches(outBuf.String(), regexp.MustCompile(`Paketo Buildpacks:\s+'paketobuildpacks/builder:full'`)) - assert.Matches(outBuf.String(), regexp.MustCompile(`Heroku:\s+'heroku/buildpacks:18'`)) - }) + when("print returns an error", func() { + it("returns that error", func() { + baseError := errors.New("couldn't write builder") + + builderWriter := newBuilderWriter(errorsForPrint(baseError)) + command := commands.InspectBuilder( + logger, + cfg, + newDefaultBuilderInspector(), + newWriterFactory(returnsForWriter(builderWriter)), + ) + command.SetArgs([]string{}) + + err := command.Execute() + assert.ErrorWithMessage(err, "couldn't write builder") }) }) - when("a depth is specified", func() { - it.Before(func() { - command = commands.InspectBuilder(logger, config.Config{}, mockClient) - command.SetArgs([]string{"some/image", "--depth", "2"}) - - // expect client to fetch suggested builder descriptions - mockClient.EXPECT().InspectBuilder("some/image", false).Return(remoteInfo, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(localInfo, nil) - }) + when("writer factory returns an error", func() { + it("returns that error", func() { + baseError := errors.New("invalid output format") - it("displays detection order up to the specified depth", func() { - assert.Succeeds(command.Execute()) + writerFactory := newWriterFactory(errorsForWriter(baseError)) + command := commands.InspectBuilder(logger, cfg, newDefaultBuilderInspector(), writerFactory) + command.SetArgs([]string{}) - assert.Contains(outBuf.String(), detectionOrderWithDepth) + err := command.Execute() + assert.ErrorWithMessage(err, "invalid output format") }) }) + }) +} - when("there is a cyclic buildpack dependency in the builder", func() { - it.Before(func() { - localInfo.BuildpackLayers = map[string]map[string]dist.BuildpackLayerInfo{ - "test.top.nested": { - "test.top.nested.version": { - API: apiVersion, - Order: dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ - ID: "test.nested", - Version: "test.nested.version", - }, - Optional: false, - }, - }, - }, - }, - LayerDiffID: "sha256:test.top.nested.sha256", - }, - }, - "test.nested": { - "test.nested.version": { - API: apiVersion, - Order: dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ - // cyclic dependency here - ID: "test.top.nested", - Version: "test.top.nested.version", - }, - Optional: false, - }, - }, - }, - }, - LayerDiffID: "sha256:test.nested.sha256", - Homepage: "http://geocities.com/top-bp", - }, - }, - "test.bp.two": { - "test.bp.two.version": { - API: apiVersion, - Stacks: []dist.Stack{ - { - ID: "test.stack.id", - }, - }, - LayerDiffID: "sha256:test.bp.two.sha256", - }, - }, - } - localInfo.Buildpacks = []dist.BuildpackInfo{ - { - ID: "test.top.nested", - Version: "test.top.nested.version", - }, - { - ID: "test.nested", - Version: "test.nested.version", - Homepage: "http://geocities.com/top-bp", - }, - { - ID: "test.bp.two", - Version: "test.bp.two.version", - }, - } - localInfo.Order = dist.Order{ - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ID: "test.top.nested", Version: "test.top.nested.version"}, - Optional: false, - }, - { - BuildpackInfo: dist.BuildpackInfo{ID: "test.bp.two"}, - Optional: true, - }, - }, - }, - { - Group: []dist.BuildpackRef{ - { - BuildpackInfo: dist.BuildpackInfo{ID: "test.nested", Version: "test.nested.version"}, - Optional: false, - }, - }, - }, - } - }) +func newDefaultBuilderInspector() *fakes.FakeBuilderInspector { + return &fakes.FakeBuilderInspector{ + InfoForLocal: expectedLocalInfo, + InfoForRemote: expectedRemoteInfo, + } +} - it("indicates cycle and succeeds", func() { - mockClient.EXPECT().InspectBuilder("some/image", false).Return(nil, nil) - mockClient.EXPECT().InspectBuilder("some/image", true).Return(localInfo, nil) - command.SetArgs([]string{"some/image"}) +func newDefaultBuilderWriter() *fakes.FakeBuilderWriter { + return &fakes.FakeBuilderWriter{ + PrintForLocal: expectedLocalDisplay, + PrintForRemote: expectedRemoteDisplay, + } +} - assert.Succeeds(command.Execute()) - assert.AssertTrimmedContains(outBuf.String(), detectionOrderWithCycle) - }) - }) - }) +func newDefaultWriterFactory() *fakes.FakeBuilderWriterFactory { + return &fakes.FakeBuilderWriterFactory{ + ReturnForWriter: newDefaultBuilderWriter(), + } +} + +type BuilderWriterModifier func(w *fakes.FakeBuilderWriter) + +func errorsForPrint(err error) BuilderWriterModifier { + return func(w *fakes.FakeBuilderWriter) { + w.ErrorForPrint = err + } +} + +func newBuilderWriter(modifiers ...BuilderWriterModifier) *fakes.FakeBuilderWriter { + w := newDefaultBuilderWriter() + + for _, mod := range modifiers { + mod(w) + } + + return w +} + +type WriterFactoryModifier func(f *fakes.FakeBuilderWriterFactory) + +func returnsForWriter(writer writer.BuilderWriter) WriterFactoryModifier { + return func(f *fakes.FakeBuilderWriterFactory) { + f.ReturnForWriter = writer + } +} + +func errorsForWriter(err error) WriterFactoryModifier { + return func(f *fakes.FakeBuilderWriterFactory) { + f.ErrorForWriter = err + } +} + +func newWriterFactory(modifiers ...WriterFactoryModifier) *fakes.FakeBuilderWriterFactory { + f := newDefaultWriterFactory() + + for _, mod := range modifiers { + mod(f) + } + + return f +} + +type BuilderInspectorModifier func(i *fakes.FakeBuilderInspector) + +func errorsForLocal(err error) BuilderInspectorModifier { + return func(i *fakes.FakeBuilderInspector) { + i.ErrorForLocal = err + } +} + +func errorsForRemote(err error) BuilderInspectorModifier { + return func(i *fakes.FakeBuilderInspector) { + i.ErrorForRemote = err + } +} + +func newBuilderInspector(modifiers ...BuilderInspectorModifier) *fakes.FakeBuilderInspector { + i := newDefaultBuilderInspector() + + for _, mod := range modifiers { + mod(i) + } + + return i } diff --git a/internal/commands/inspect_buildpack.go b/internal/commands/inspect_buildpack.go index d6316c421..9630abede 100644 --- a/internal/commands/inspect_buildpack.go +++ b/internal/commands/inspect_buildpack.go @@ -3,9 +3,13 @@ package commands import ( "bytes" "fmt" + "io" "strings" + "text/tabwriter" "text/template" + "github.com/buildpacks/pack/internal/dist" + "github.com/pkg/errors" "github.com/buildpacks/pack/internal/image" @@ -53,6 +57,15 @@ Detection Order: {{ end }} ` +const ( + writerMinWidth = 0 + writerTabWidth = 0 + buildpacksTabWidth = 8 + defaultTabWidth = 4 + writerPadChar = ' ' + writerFlags = 0 +) + type InspectBuildpackFlags struct { Depth int Registry string @@ -187,3 +200,136 @@ func determinePrefix(name string, locator buildpack.LocatorType, daemon bool) st } return "UNKNOWN SOURCE" } + +func buildpacksOutput(bps []dist.BuildpackInfo) (string, error) { + buf := &bytes.Buffer{} + tabWriter := new(tabwriter.Writer).Init(buf, writerMinWidth, writerPadChar, buildpacksTabWidth, writerPadChar, writerFlags) + if _, err := fmt.Fprint(tabWriter, " ID\tVERSION\tHOMEPAGE\n"); err != nil { + return "", err + } + + for _, bp := range bps { + if _, err := fmt.Fprintf(tabWriter, " %s\t%s\t%s\n", bp.ID, bp.Version, bp.Homepage); err != nil { + return "", err + } + } + + if err := tabWriter.Flush(); err != nil { + return "", err + } + + return strings.TrimSuffix(buf.String(), "\n"), nil +} + +// Unable to easily convert format makes this feel like a poor solution... +func detectionOrderOutput(order dist.Order, layers dist.BuildpackLayers, maxDepth int) (string, error) { + buf := strings.Builder{} + tabWriter := new(tabwriter.Writer).Init(&buf, writerMinWidth, writerTabWidth, defaultTabWidth, writerPadChar, writerFlags) + buildpackSet := map[pack.BuildpackInfoKey]bool{} + + if err := orderOutputRecurrence(tabWriter, "", order, layers, buildpackSet, 0, maxDepth); err != nil { + return "", err + } + if err := tabWriter.Flush(); err != nil { + return "", fmt.Errorf("error flushing tabWriter output: %s", err) + } + return strings.TrimSuffix(buf.String(), "\n"), nil +} + +// Recursively generate output for every buildpack in an order. +func orderOutputRecurrence(w io.Writer, prefix string, order dist.Order, layers dist.BuildpackLayers, buildpackSet map[pack.BuildpackInfoKey]bool, curDepth, maxDepth int) error { + // exit if maxDepth is exceeded + if validMaxDepth(maxDepth) && maxDepth <= curDepth { + return nil + } + + // otherwise iterate over all nested buildpacks + for groupIndex, group := range order { + lastGroup := groupIndex == (len(order) - 1) + if err := displayGroup(w, prefix, groupIndex+1, lastGroup); err != nil { + return fmt.Errorf("error when printing group info: %q", err) + } + for bpIndex, buildpackEntry := range group.Group { + lastBuildpack := bpIndex == len(group.Group)-1 + + key := pack.BuildpackInfoKey{ + ID: buildpackEntry.ID, + Version: buildpackEntry.Version, + } + _, visited := buildpackSet[key] + buildpackSet[key] = true + + curBuildpackLayer, ok := layers.Get(buildpackEntry.ID, buildpackEntry.Version) + if !ok { + return fmt.Errorf("error: missing buildpack %s@%s from layer metadata", buildpackEntry.ID, buildpackEntry.Version) + } + + newBuildpackPrefix := updatePrefix(prefix, lastGroup) + if err := displayBuildpack(w, newBuildpackPrefix, buildpackEntry, visited, bpIndex == len(group.Group)-1); err != nil { + return fmt.Errorf("error when printing buildpack info: %q", err) + } + + newGroupPrefix := updatePrefix(newBuildpackPrefix, lastBuildpack) + if !visited { + if err := orderOutputRecurrence(w, newGroupPrefix, curBuildpackLayer.Order, layers, buildpackSet, curDepth+1, maxDepth); err != nil { + return err + } + } + + // remove key from set after recurrence completes, so we only detect cycles. + delete(buildpackSet, key) + } + } + return nil +} + +const ( + branchPrefix = " ├ " + lastBranchPrefix = " └ " + trunkPrefix = " │ " +) + +func updatePrefix(oldPrefix string, last bool) string { + if last { + return oldPrefix + " " + } + return oldPrefix + trunkPrefix +} + +func validMaxDepth(depth int) bool { + return depth >= 0 +} + +func displayGroup(w io.Writer, prefix string, groupCount int, last bool) error { + treePrefix := branchPrefix + if last { + treePrefix = lastBranchPrefix + } + _, err := fmt.Fprintf(w, "%s%sGroup #%d:\n", prefix, treePrefix, groupCount) + return err +} + +func displayBuildpack(w io.Writer, prefix string, entry dist.BuildpackRef, visited bool, last bool) error { + var optional string + if entry.Optional { + optional = "(optional)" + } + + visitedStatus := "" + if visited { + visitedStatus = "[cyclic]" + } + + bpRef := entry.ID + if entry.Version != "" { + bpRef += "@" + entry.Version + } + + treePrefix := branchPrefix + if last { + treePrefix = lastBranchPrefix + } + + _, err := fmt.Fprintf(w, "%s%s%s\t%s%s\n", prefix, treePrefix, bpRef, optional, visitedStatus) + return err +} diff --git a/internal/commands/inspect_image.go b/internal/commands/inspect_image.go index 0a7a7e37d..7c8c73ad0 100644 --- a/internal/commands/inspect_image.go +++ b/internal/commands/inspect_image.go @@ -162,6 +162,15 @@ func displayProcesses(sourceProcesses pack.ProcessDetails) []process { return processes } +func getLocalMirrors(runImage string, cfg config.Config) []string { + for _, ri := range cfg.RunImages { + if ri.Image == runImage { + return ri.Mirrors + } + } + return nil +} + var runImagesTemplate = ` Run Images: {{- range $_, $m := .LocalMirrors }} diff --git a/internal/commands/suggest_builders.go b/internal/commands/suggest_builders.go index 8d95632f8..2cdf1c486 100644 --- a/internal/commands/suggest_builders.go +++ b/internal/commands/suggest_builders.go @@ -46,14 +46,14 @@ var suggestedBuilders = []SuggestedBuilder{ }, } -func SuggestBuilders(logger logging.Logger, client PackClient) *cobra.Command { +func SuggestBuilders(logger logging.Logger, inspector BuilderInspector) *cobra.Command { cmd := &cobra.Command{ Use: "suggest-builders", Args: cobra.NoArgs, Short: "Display list of recommended builders", Example: "pack suggest-builders", Run: func(cmd *cobra.Command, s []string) { - suggestBuilders(logger, client) + suggestBuilders(logger, inspector) }, } @@ -61,19 +61,19 @@ func SuggestBuilders(logger logging.Logger, client PackClient) *cobra.Command { return cmd } -func suggestSettingBuilder(logger logging.Logger, client PackClient) { +func suggestSettingBuilder(logger logging.Logger, inspector BuilderInspector) { logger.Info("Please select a default builder with:") logger.Info("") logger.Info("\tpack set-default-builder ") logger.Info("") - suggestBuilders(logger, client) + suggestBuilders(logger, inspector) } -func suggestBuilders(logger logging.Logger, client PackClient) { +func suggestBuilders(logger logging.Logger, client BuilderInspector) { WriteSuggestedBuilder(logger, client, suggestedBuilders) } -func WriteSuggestedBuilder(logger logging.Logger, client PackClient, builders []SuggestedBuilder) { +func WriteSuggestedBuilder(logger logging.Logger, inspector BuilderInspector, builders []SuggestedBuilder) { sort.Slice(builders, func(i, j int) bool { if builders[i].Vendor == builders[j].Vendor { return builders[i].Image < builders[j].Image @@ -92,7 +92,7 @@ func WriteSuggestedBuilder(logger logging.Logger, client PackClient, builders [] wg.Add(1) go func(i int, builder SuggestedBuilder) { - descriptions[i] = getBuilderDescription(builder, client) + descriptions[i] = getBuilderDescription(builder, inspector) wg.Done() }(i, builder) } @@ -108,8 +108,8 @@ func WriteSuggestedBuilder(logger logging.Logger, client PackClient, builders [] logger.Info("\tpack inspect-builder ") } -func getBuilderDescription(builder SuggestedBuilder, client PackClient) string { - info, err := client.InspectBuilder(builder.Image, false) +func getBuilderDescription(builder SuggestedBuilder, inspector BuilderInspector) string { + info, err := inspector.InspectBuilder(builder.Image, false) if err == nil && info.Description != "" { return info.Description } diff --git a/internal/commands/testmocks/mock_pack_client.go b/internal/commands/testmocks/mock_pack_client.go index 2c537e89e..6b1f52ed1 100644 --- a/internal/commands/testmocks/mock_pack_client.go +++ b/internal/commands/testmocks/mock_pack_client.go @@ -65,18 +65,23 @@ func (mr *MockPackClientMockRecorder) CreateBuilder(arg0, arg1 interface{}) *gom } // InspectBuilder mocks base method -func (m *MockPackClient) InspectBuilder(arg0 string, arg1 bool) (*pack.BuilderInfo, error) { +func (m *MockPackClient) InspectBuilder(arg0 string, arg1 bool, arg2 ...pack.BuilderInspectionModifier) (*pack.BuilderInfo, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InspectBuilder", arg0, arg1) + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "InspectBuilder", varargs...) ret0, _ := ret[0].(*pack.BuilderInfo) ret1, _ := ret[1].(error) return ret0, ret1 } // InspectBuilder indicates an expected call of InspectBuilder -func (mr *MockPackClientMockRecorder) InspectBuilder(arg0, arg1 interface{}) *gomock.Call { +func (mr *MockPackClientMockRecorder) InspectBuilder(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectBuilder", reflect.TypeOf((*MockPackClient)(nil).InspectBuilder), arg0, arg1) + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InspectBuilder", reflect.TypeOf((*MockPackClient)(nil).InspectBuilder), varargs...) } // InspectBuildpack mocks base method diff --git a/internal/dist/buildpack.go b/internal/dist/buildpack.go index 2d81961b8..5a7efdd53 100644 --- a/internal/dist/buildpack.go +++ b/internal/dist/buildpack.go @@ -40,9 +40,9 @@ type Buildpack interface { } type BuildpackInfo struct { - ID string `toml:"id" json:"id,omitempty"` - Version string `toml:"version" json:"version,omitempty"` - Homepage string `toml:"homepage,omitempty" json:"homepage,omitempty"` + ID string `toml:"id,omitempty" json:"id,omitempty" yaml:"id,omitempty"` + Version string `toml:"version,omitempty" json:"version,omitempty" yaml:"version,omitempty"` + Homepage string `toml:"homepage,omitempty" json:"homepage,omitempty" yaml:"homepage,omitempty"` } func (b BuildpackInfo) FullName() string { diff --git a/internal/dist/dist.go b/internal/dist/dist.go index b7003bfd4..ed75b409a 100644 --- a/internal/dist/dist.go +++ b/internal/dist/dist.go @@ -24,8 +24,8 @@ type OrderEntry struct { } type BuildpackRef struct { - BuildpackInfo - Optional bool `toml:"optional,omitempty" json:"optional,omitempty"` + BuildpackInfo `yaml:"buildpackinfo,inline"` + Optional bool `toml:"optional,omitempty" json:"optional,omitempty" yaml:"optional,omitempty"` } type BuildpackLayers map[string]map[string]BuildpackLayerInfo diff --git a/testhelpers/assertions.go b/testhelpers/assertions.go index 16668c1ba..75047fc10 100644 --- a/testhelpers/assertions.go +++ b/testhelpers/assertions.go @@ -1,11 +1,18 @@ package testhelpers import ( + "bytes" + "encoding/json" "fmt" "regexp" "strings" "testing" + "github.com/pelletier/go-toml" + "gopkg.in/yaml.v3" + + "github.com/buildpacks/pack/testhelpers/comparehelpers" + "github.com/google/go-cmp/cmp" ) @@ -101,6 +108,14 @@ func (a AssertionManager) NilWithMessage(actual interface{}, message string) { } } +func (a AssertionManager) TrueWithMessage(actual bool, message string) { + a.testObject.Helper() + + if !actual { + a.testObject.Fatalf("expected true: %s", message) + } +} + func (a AssertionManager) NotNil(actual interface{}) { a.testObject.Helper() @@ -122,6 +137,153 @@ func (a AssertionManager) Contains(actual, expected string) { } } +func (a AssertionManager) ContainsJSON(actualJSON, expectedJSON string) { + a.testObject.Helper() + + var actual interface{} + err := json.Unmarshal([]byte(actualJSON), &actual) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'actualJSON': %q", err, + ) + } + + var expected interface{} + err = json.Unmarshal([]byte(expectedJSON), &expected) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'expectedJSON': %q", err, + ) + } + + if !comparehelpers.DeepContains(actual, expected) { + expectedJSONDebug, err := json.Marshal(expected) + if err != nil { + a.testObject.Fatalf("unable to render expected failure expectation: %q", err) + } + + actualJSONDebug, err := json.Marshal(actual) + if err != nil { + a.testObject.Fatalf("unable to render actual failure expectation: %q", err) + } + + var prettifiedExpected bytes.Buffer + err = json.Indent(&prettifiedExpected, expectedJSONDebug, "", " ") + if err != nil { + a.testObject.Fatal("failed to format expected TOML output as JSON") + } + + var prettifiedActual bytes.Buffer + err = json.Indent(&prettifiedActual, actualJSONDebug, "", " ") + if err != nil { + a.testObject.Fatal("failed to format actual TOML output as JSON") + } + + actualJSONDiffArray := strings.Split(prettifiedActual.String(), "\n") + expectedJSONDiffArray := strings.Split(prettifiedExpected.String(), "\n") + + a.testObject.Fatalf( + "Expected '%s' to contain '%s'\n\nJSON Diff:%s", + prettifiedActual.String(), + prettifiedExpected.String(), + cmp.Diff(actualJSONDiffArray, expectedJSONDiffArray), + ) + } +} + +func (a AssertionManager) ContainsYAML(actualYAML, expectedYAML string) { + a.testObject.Helper() + + var actual interface{} + err := yaml.Unmarshal([]byte(actualYAML), &actual) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'actualJSON': %q", err, + ) + } + + var expected interface{} + err = yaml.Unmarshal([]byte(expectedYAML), &expected) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'expectedYAML': %q", err, + ) + } + + if !comparehelpers.DeepContains(actual, expected) { + expectedYAMLDebug, err := yaml.Marshal(expected) + if err != nil { + a.testObject.Fatalf("unable to render expected failure expectation: %q", err) + } + + actualYAMLDebug, err := yaml.Marshal(actual) + if err != nil { + a.testObject.Fatalf("unable to render actual failure expectation: %q", err) + } + + actualYAMLDiffArray := strings.Split(string(actualYAMLDebug), "\n") + expectedYAMLDiffArray := strings.Split(string(expectedYAMLDebug), "\n") + + a.testObject.Fatalf( + "Expected '%s' to contain '%s'\n\nDiff:%s", + string(actualYAMLDebug), + string(expectedYAMLDebug), + cmp.Diff(actualYAMLDiffArray, expectedYAMLDiffArray), + ) + } +} + +func (a AssertionManager) ContainsTOML(actualTOML, expectedTOML string) { + a.testObject.Helper() + + var actual interface{} + err := toml.Unmarshal([]byte(actualTOML), &actual) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'actualTOML': %q", err, + ) + } + + var expected interface{} + err = toml.Unmarshal([]byte(expectedTOML), &expected) + if err != nil { + a.testObject.Fatalf( + "Unable to unmarshal 'expectedTOML': %q", err, + ) + } + + if !comparehelpers.DeepContains(actual, expected) { + expectedJSONDebug, err := json.Marshal(expected) + if err != nil { + a.testObject.Fatalf("unable to render expected failure expectation: %q", err) + } + + actualJSONDebug, err := json.Marshal(actual) + if err != nil { + a.testObject.Fatalf("unable to render actual failure expectation: %q", err) + } + + var prettifiedExpected bytes.Buffer + err = json.Indent(&prettifiedExpected, expectedJSONDebug, "", " ") + if err != nil { + a.testObject.Fatal("failed to format expected TOML output as JSON") + } + + var prettifiedActual bytes.Buffer + err = json.Indent(&prettifiedActual, actualJSONDebug, "", " ") + if err != nil { + a.testObject.Fatal("failed to format actual TOML output as JSON") + } + + a.testObject.Fatalf( + "Expected '%s' to contain '%s'\n\nJSON Diff:%s", + prettifiedActual.String(), + prettifiedExpected.String(), + cmp.Diff(prettifiedActual.String(), prettifiedExpected.String()), + ) + } +} + func (a AssertionManager) ContainsF(actual, expected string, formatArgs ...interface{}) { a.testObject.Helper() @@ -206,3 +368,16 @@ func (a AssertionManager) ErrorContains(actual error, expected string) { a.Contains(actual.Error(), expected) } + +func (a AssertionManager) ErrorWithMessage(actual error, message string) { + a.testObject.Helper() + + a.Error(actual) + a.Equal(actual.Error(), message) +} + +func (a AssertionManager) ErrorWithMessageF(actual error, format string, args ...interface{}) { + a.testObject.Helper() + + a.ErrorWithMessage(actual, fmt.Sprintf(format, args...)) +} diff --git a/testhelpers/comparehelpers/deep_compare.go b/testhelpers/comparehelpers/deep_compare.go new file mode 100644 index 000000000..084f5394d --- /dev/null +++ b/testhelpers/comparehelpers/deep_compare.go @@ -0,0 +1,106 @@ +package comparehelpers + +import ( + "fmt" + "reflect" +) + +// Returns true if 'containee' is contained in 'container' +// Note this method searches all objects in 'container' for containee +// Contains is defined by the following relationship +// basic data types (string, float, int,...): +// container == containee +// maps: +// every key-value pair from containee is in container +// Ex: {"a": 1, "b": 2, "c": 3} contains {"a": 1, "c": 3} +// arrays: +// every element in containee is present and ordered in an array in container +// Ex: [1, 1, 4, 3, 10, 4] contains [1, 3, 4 ] +// +// Limitaions: +// Cannot handle the following types: Pointers, Func +// Assumes we are compairing structs generated from JSON, YAML, or TOML. +func DeepContains(container, containee interface{}) bool { + if container == nil || containee == nil { + return container == containee + } + v1 := reflect.ValueOf(container) + v2 := reflect.ValueOf(containee) + + return deepContains(v1, v2, 0) +} + +func deepContains(v1, v2 reflect.Value, depth int) bool { + if depth > 200 { + panic("deep Contains depth exceeded, likely a circular reference") + } + if !v1.IsValid() || !v2.IsValid() { + return v1.IsValid() == v2.IsValid() + } + + switch v1.Kind() { + case reflect.Array, reflect.Slice: + // check for subset matches in arrays + return arrayLikeContains(v1, v2, depth+1) + case reflect.Map: + return mapContains(v1, v2, depth+1) + case reflect.Interface: + return deepContains(v1.Elem(), v2, depth+1) + case reflect.Ptr, reflect.Struct, reflect.Func: + panic(fmt.Sprintf("unimplmemented comparison for type: %s", v1.Kind().String())) + default: // assume it is a atomic datatype + return reflect.DeepEqual(v1, v2) + } +} + +func mapContains(v1, v2 reflect.Value, depth int) bool { + t2 := v2.Kind() + if t2 == reflect.Interface { + return mapContains(v1, v2.Elem(), depth+1) + } else if t2 == reflect.Map { + result := true + for _, k := range v2.MapKeys() { + k2Val := v2.MapIndex(k) + k1Val := v1.MapIndex(k) + if !k1Val.IsValid() || !reflect.DeepEqual(k1Val.Interface(), k2Val.Interface()) { + result = false + break + } + } + if result { + return true + } + } + for _, k := range v1.MapKeys() { + kVal := v1.MapIndex(k) + if deepContains(kVal, v2, depth+1) { + return true + } + } + return false +} + +func arrayLikeContains(v1, v2 reflect.Value, depth int) bool { + t2 := v2.Kind() + if t2 == reflect.Interface { + return mapContains(v1, v2.Elem(), depth+1) + } else if t2 == reflect.Array || t2 == reflect.Slice { + v1Index := 0 + v2Index := 0 + for v1Index < v1.Len() && v2Index < v2.Len() { + if reflect.DeepEqual(v1.Index(v1Index).Interface(), v2.Index(v2Index).Interface()) { + v2Index++ + } + v1Index++ + } + if v2Index == v2.Len() { + return true + } + } + for i := 0; i < v1.Len(); i++ { + if deepContains(v1.Index(i), v2, depth+1) { + return true + } + } + return false +} diff --git a/testhelpers/comparehelpers/deep_compare_test.go b/testhelpers/comparehelpers/deep_compare_test.go new file mode 100644 index 000000000..543936964 --- /dev/null +++ b/testhelpers/comparehelpers/deep_compare_test.go @@ -0,0 +1,153 @@ +package comparehelpers_test + +import ( + "encoding/json" + "testing" + + "github.com/buildpacks/pack/testhelpers" + "github.com/buildpacks/pack/testhelpers/comparehelpers" + + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" +) + +func TestDeepContains(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "Builder Writer", testDeepContains, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testDeepContains(t *testing.T, when spec.G, it spec.S) { + var ( + assert = testhelpers.NewAssertionManager(t) + ) + when("DeepContains", func() { + var ( + containerJSON string + container interface{} + ) + when("Searching for array containment", func() { + it.Before(func() { + containerJSON = `[ + { + "Name": "Platypus", + "Order": "Monotremata", + "Info": [ + { + "Population": 5000, + "Habitat": ["splish-spash", "waters"] + }, + { + "Geography" : "Moon" + }, + { + "Discography": "My records are all platynum" + } + ] + }, + { + "Name": "Quoll", + "Order": "Dasyuromorphia", + "Info": [] + } +]` + + assert.Succeeds(json.Unmarshal([]byte(containerJSON), &container)) + }) + when("subarray is contained", func() { + it("return true", func() { + containedJSON := `[{ "Geography":"Moon" }, {"Discography": "My records are all platynum"}]` + + var contained interface{} + assert.Succeeds(json.Unmarshal([]byte(containedJSON), &contained)) + + out := comparehelpers.DeepContains(container, contained) + assert.Equal(out, true) + }) + }) + when("subarray is not contained", func() { + it("returns false", func() { + containedJSON := `[{ "Geography":"Moon" }, {"Discography": "Splish-splash Cash III"}]` + + var contained interface{} + assert.Succeeds(json.Unmarshal([]byte(containedJSON), &contained)) + + out := comparehelpers.DeepContains(container, contained) + assert.Equal(out, false) + }) + }) + }) + when("Searching for map containment", func() { + var ( + containerJSON string + container interface{} + ) + it.Before(func() { + containerJSON = `[ + { + "Name": "Platypus", + "Order": "Monotremata", + "Info": [ + { + "Population": 5000, + "Size": "smol", + "Habitat": ["shallow", "waters"] + }, + { + "Geography" : "Moon" + }, + { + "Discography": "My records are all platynum" + } + ] + }, + { + "Name": "Quoll", + "Order": "Dasyuromorphia", + "Info": [] + } +]` + assert.Succeeds(json.Unmarshal([]byte(containerJSON), &container)) + }) + when("map is contained", func() { + it("returns true", func() { + containedJSON := `{"Population": 5000, "Size": "smol"}` + var contained interface{} + assert.Succeeds(json.Unmarshal([]byte(containedJSON), &contained)) + + out := comparehelpers.DeepContains(container, contained) + assert.Equal(out, true) + }) + }) + when("map is not contained", func() { + it("returns false", func() { + containedJSON := `{"Order": "Nemotode"}` + var contained interface{} + assert.Succeeds(json.Unmarshal([]byte(containedJSON), &contained)) + + out := comparehelpers.DeepContains(container, contained) + assert.Equal(out, false) + }) + }) + }) + }) + when("json is not contained", func() { + it("return false", func() { + containerJSON := `[ + {"Name": "Platypus", "Order": "Monotremata"}, + {"Name": "Quoll", "Order": "Dasyuromorphia"} +]` + var container interface{} + assert.Succeeds(json.Unmarshal([]byte(containerJSON), &container)) + + containedJSON := `[{"Name": "Notapus", "Order": "Monotremata"}]` + + var contained interface{} + assert.Succeeds(json.Unmarshal([]byte(containedJSON), &contained)) + + out := comparehelpers.DeepContains(container, contained) + assert.Equal(out, false) + }) + }) +}