diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 8a3a37d5a..42c831e08 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -29,6 +29,7 @@ type PackClient interface { NewBuildpack(context.Context, client.NewBuildpackOptions) error PackageBuildpack(ctx context.Context, opts client.PackageBuildpackOptions) error PackageExtension(ctx context.Context, opts client.PackageBuildpackOptions) error + NewExtension(ctx context.Context, options client.NewExtensionOptions) error Build(context.Context, client.BuildOptions) error RegisterBuildpack(context.Context, client.RegisterBuildpackOptions) error YankBuildpack(client.YankBuildpackOptions) error diff --git a/internal/commands/extension.go b/internal/commands/extension.go index bc4ab3647..22cf86e32 100644 --- a/internal/commands/extension.go +++ b/internal/commands/extension.go @@ -19,7 +19,7 @@ func NewExtensionCommand(logger logging.Logger, cfg config.Config, client PackCl // client and packageConfigReader to be passed later on cmd.AddCommand(ExtensionPackage(logger, cfg, client, packageConfigReader)) // client to be passed later on - cmd.AddCommand(ExtensionNew(logger)) + cmd.AddCommand(ExtensionNew(logger, client)) cmd.AddCommand(ExtensionPull(logger, cfg, client)) cmd.AddCommand(ExtensionRegister(logger, cfg, client)) cmd.AddCommand(ExtensionYank(logger, cfg, client)) diff --git a/internal/commands/extension_new.go b/internal/commands/extension_new.go index d91e424cb..5e64bed4c 100644 --- a/internal/commands/extension_new.go +++ b/internal/commands/extension_new.go @@ -1,8 +1,19 @@ package commands import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "github.com/spf13/cobra" + "github.com/buildpacks/pack/internal/style" + "github.com/buildpacks/pack/internal/target" + "github.com/buildpacks/pack/pkg/client" + "github.com/buildpacks/pack/pkg/dist" "github.com/buildpacks/pack/pkg/logging" ) @@ -11,25 +22,78 @@ type ExtensionNewFlags struct { API string Path string Stacks []string + Targets []string Version string } // extensioncreator type to be added here and argument also to be added in the function +type ExtensionCreator interface { + NewExtension(ctx context.Context, options client.NewExtensionOptions) error +} // ExtensionNew generates the scaffolding of an extension -func ExtensionNew(logger logging.Logger) *cobra.Command { +func ExtensionNew(logger logging.Logger, creator ExtensionCreator) *cobra.Command { + var flags ExtensionNewFlags cmd := &cobra.Command{ Use: "new ", Short: "Creates basic scaffolding of an extension", Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), Example: "pack extension new ", RunE: logError(logger, func(cmd *cobra.Command, args []string) error { - // logic will go here + id := args[0] + idParts := strings.Split(id, "/") + dirName := idParts[len(idParts)-1] + + var path string + if len(flags.Path) == 0 { + cwd, err := os.Getwd() + if err != nil { + return err + } + path = filepath.Join(cwd, dirName) + } else { + path = flags.Path + } + + _, err := os.Stat(path) + if !os.IsNotExist(err) { + return fmt.Errorf("directory %s exists", style.Symbol(path)) + } + + var targets []dist.Target + if len(flags.Targets) == 0 && len(flags.Stacks) == 0 { + targets = []dist.Target{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }} + } else { + if targets, err = target.ParseTargets(flags.Targets, logger); err != nil { + return err + } + } + + if err := creator.NewExtension(cmd.Context(), client.NewExtensionOptions{ + API: flags.API, + ID: id, + Path: path, + Targets: targets, + Version: flags.Version, + }); err != nil { + return err + } + + logger.Infof("Successfully created %s", style.Symbol(id)) return nil }), } - - // flags will go here + cmd.Flags().StringVarP(&flags.API, "api", "a", "0.9", "Buildpack API compatibility of the generated extension") + cmd.Flags().StringVarP(&flags.Path, "path", "p", "", "Path to generate the extension") + cmd.Flags().StringVarP(&flags.Version, "version", "V", "1.0.0", "Version of the generated extension") + cmd.Flags().StringSliceVarP(&flags.Targets, "targets", "t", nil, + `A list of platforms to target; these recorded in extension.toml. One can provide targets in the format [os][/arch][/arch-variant]:[distroname@osversion];[distroname@osversion] + - Example: '--targets "linux/amd64" --targets "linux/arm64"' + - Example (distribution version): '--targets "windows/amd64:windows-nano@10.0.19041.1415"' + - Example (architecture with distributed versions): '--targets "linux/arm/v6:ubuntu@14.04" --targets "linux/arm/v6:ubuntu@16.04"' `) AddHelpFlag(cmd, "new") return cmd diff --git a/internal/commands/extension_new_test.go b/internal/commands/extension_new_test.go new file mode 100644 index 000000000..3f6a94c92 --- /dev/null +++ b/internal/commands/extension_new_test.go @@ -0,0 +1,180 @@ +package commands_test + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/buildpacks/pack/pkg/client" + "github.com/buildpacks/pack/pkg/dist" + "github.com/buildpacks/pack/pkg/logging" + + "github.com/golang/mock/gomock" + "github.com/heroku/color" + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/spf13/cobra" + + "github.com/buildpacks/pack/internal/commands" + "github.com/buildpacks/pack/internal/commands/testmocks" + h "github.com/buildpacks/pack/testhelpers" +) + +func TestExtensionNewCommand(t *testing.T) { + color.Disable(true) + defer color.Disable(false) + spec.Run(t, "ExtensionNewCommand", testExtensionNewCommand, spec.Parallel(), spec.Report(report.Terminal{})) +} + +func testExtensionNewCommand(t *testing.T, when spec.G, it spec.S) { + var ( + command *cobra.Command + logger *logging.LogWithWriters + outBuf bytes.Buffer + mockController *gomock.Controller + mockClient *testmocks.MockPackClient + tmpDir string + ) + targets := []dist.Target{{ + OS: runtime.GOOS, + Arch: runtime.GOARCH, + }} + + it.Before(func() { + var err error + tmpDir, err = os.MkdirTemp("", "build-test") + h.AssertNil(t, err) + + logger = logging.NewLogWithWriters(&outBuf, &outBuf) + mockController = gomock.NewController(t) + mockClient = testmocks.NewMockPackClient(mockController) + + command = commands.ExtensionNew(logger, mockClient) + }) + + it.After(func() { + os.RemoveAll(tmpDir) + }) + + when("ExtensionNew#Execute", func() { + it("uses the args to generate artifacts", func() { + mockClient.EXPECT().NewExtension(gomock.Any(), client.NewExtensionOptions{ + API: "0.9", + ID: "example/some-cnb", + Path: filepath.Join(tmpDir, "some-cnb"), + Version: "1.0.0", + Targets: targets, + }).Return(nil).MaxTimes(1) + + path := filepath.Join(tmpDir, "some-cnb") + command.SetArgs([]string{"--path", path, "example/some-cnb"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + + it("stops if the directory already exists", func() { + err := os.MkdirAll(tmpDir, 0600) + h.AssertNil(t, err) + + command.SetArgs([]string{"--path", tmpDir, "example/some-cnb"}) + err = command.Execute() + h.AssertNotNil(t, err) + h.AssertContains(t, outBuf.String(), "ERROR: directory") + }) + + when("target flag is specified, ", func() { + it("it uses target to generate artifacts", func() { + mockClient.EXPECT().NewExtension(gomock.Any(), client.NewExtensionOptions{ + API: "0.9", + ID: "example/targets", + Path: filepath.Join(tmpDir, "targets"), + Version: "1.0.0", + Targets: []dist.Target{{ + OS: "linux", + Arch: "arm", + ArchVariant: "v6", + Distributions: []dist.Distribution{{ + Name: "ubuntu", + Versions: []string{"14.04", "16.04"}, + }}, + }}, + }).Return(nil).MaxTimes(1) + + path := filepath.Join(tmpDir, "targets") + command.SetArgs([]string{"--path", path, "example/targets", "--targets", "linux/arm/v6:ubuntu@14.04@16.04"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + it("it should show error when invalid [os]/[arch] passed", func() { + mockClient.EXPECT().NewExtension(gomock.Any(), client.NewExtensionOptions{ + API: "0.9", + ID: "example/targets", + Path: filepath.Join(tmpDir, "targets"), + Version: "1.0.0", + Targets: []dist.Target{{ + OS: "os", + Arch: "arm", + ArchVariant: "v6", + Distributions: []dist.Distribution{{ + Name: "ubuntu", + Versions: []string{"14.04", "16.04"}, + }}, + }}, + }).Return(nil).MaxTimes(1) + + path := filepath.Join(tmpDir, "targets") + command.SetArgs([]string{"--path", path, "example/targets", "--targets", "os/arm/v6:ubuntu@14.04@16.04"}) + + err := command.Execute() + h.AssertNotNil(t, err) + }) + when("it should", func() { + it("support format [os][/arch][/variant]:[name@version@version2];[some-name@version@version2]", func() { + mockClient.EXPECT().NewExtension(gomock.Any(), client.NewExtensionOptions{ + API: "0.9", + ID: "example/targets", + Path: filepath.Join(tmpDir, "targets"), + Version: "1.0.0", + Targets: []dist.Target{ + { + OS: "linux", + Arch: "arm", + ArchVariant: "v6", + Distributions: []dist.Distribution{ + { + Name: "ubuntu", + Versions: []string{"14.04", "16.04"}, + }, + { + Name: "debian", + Versions: []string{"8.10", "10.9"}, + }, + }, + }, + { + OS: "windows", + Arch: "amd64", + Distributions: []dist.Distribution{ + { + Name: "windows-nano", + Versions: []string{"10.0.19041.1415"}, + }, + }, + }, + }, + }).Return(nil).MaxTimes(1) + + path := filepath.Join(tmpDir, "targets") + command.SetArgs([]string{"--path", path, "example/targets", "--targets", "linux/arm/v6:ubuntu@14.04@16.04;debian@8.10@10.9", "-t", "windows/amd64:windows-nano@10.0.19041.1415"}) + + err := command.Execute() + h.AssertNil(t, err) + }) + }) + }) + }) +} diff --git a/internal/commands/testmocks/mock_pack_client.go b/internal/commands/testmocks/mock_pack_client.go index 981704c09..acf38d2ba 100644 --- a/internal/commands/testmocks/mock_pack_client.go +++ b/internal/commands/testmocks/mock_pack_client.go @@ -227,6 +227,20 @@ func (mr *MockPackClientMockRecorder) NewBuildpack(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewBuildpack", reflect.TypeOf((*MockPackClient)(nil).NewBuildpack), arg0, arg1) } +// NewExtension mocks base method. +func (m *MockPackClient) NewExtension(arg0 context.Context, arg1 client.NewExtensionOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewExtension", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// NewExtension indicates an expected call of NewExtension. +func (mr *MockPackClientMockRecorder) NewExtension(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewExtension", reflect.TypeOf((*MockPackClient)(nil).NewExtension), arg0, arg1) +} + // PackageBuildpack mocks base method. func (m *MockPackClient) PackageBuildpack(arg0 context.Context, arg1 client.PackageBuildpackOptions) error { m.ctrl.T.Helper() diff --git a/pkg/client/new_extension.go b/pkg/client/new_extension.go new file mode 100644 index 000000000..5a90cc72d --- /dev/null +++ b/pkg/client/new_extension.go @@ -0,0 +1,118 @@ +package client + +import ( + "context" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" + + "github.com/buildpacks/lifecycle/api" + + "github.com/buildpacks/pack/internal/style" + "github.com/buildpacks/pack/pkg/dist" +) + +var ( + bashBinGenerate = `#!/usr/bin/env bash + +set -eo pipefail + +# 1. GET ARGS +output_dir=$CNB_OUTPUT_DIR + +# 2. GENERATE build.Dockerfile +cat >> "${output_dir}/build.Dockerfile" <> "${output_dir}/run.Dockerfile" < 0 { // nolint + return nil // Order extension or stack extension, no validation required + } else if e.WithLinuxBuild && os == DefaultTargetOSLinux && arch == DefaultTargetArch { + return nil + } else if e.WithWindowsBuild && os == DefaultTargetOSWindows && arch == DefaultTargetArch { + return nil + } + } + for _, target := range e.Targets() { + if target.OS == os { + if target.Arch == "" || arch == "" || target.Arch == arch { + if len(target.Distributions) == 0 || distroName == "" || distroVersion == "" { + return nil + } + for _, distro := range target.Distributions { + if distro.Name == distroName { + if len(distro.Versions) == 0 { + return nil + } + for _, version := range distro.Versions { + if version == distroVersion { + return nil + } + } + } + } + } + } + } + type osDistribution struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + } + type target struct { + OS string `json:"os"` + Arch string `json:"arch"` + Distribution osDistribution `json:"distribution"` + } + return fmt.Errorf( + "unable to satisfy target os/arch constraints; build image: %s, extension %s: %s", + toJSONMaybe(target{ + OS: os, + Arch: arch, + Distribution: osDistribution{Name: distroName, Version: distroVersion}, + }), + style.Symbol(e.Info().FullName()), + toJSONMaybe(e.Targets()), + ) } func (e *ExtensionDescriptor) EscapedID() string { @@ -44,5 +98,5 @@ func (e *ExtensionDescriptor) Stacks() []Stack { } func (e *ExtensionDescriptor) Targets() []Target { - return nil + return e.WithTargets } diff --git a/pkg/dist/extension_descriptor_test.go b/pkg/dist/extension_descriptor_test.go index 22e92c81b..d0ed3d5db 100644 --- a/pkg/dist/extension_descriptor_test.go +++ b/pkg/dist/extension_descriptor_test.go @@ -29,6 +29,86 @@ func testExtensionDescriptor(t *testing.T, when spec.G, it spec.S) { }) }) + when("validating against run image target", func() { + it("succeeds with no distribution", func() { + ext := dist.ExtensionDescriptor{ + WithInfo: dist.ModuleInfo{ + ID: "some.extension.id", + Version: "some.extension.version", + }, + WithTargets: []dist.Target{{ + OS: "fake-os", + Arch: "fake-arch", + }}, + } + + h.AssertNil(t, ext.EnsureTargetSupport("fake-os", "fake-arch", "fake-distro", "0.0")) + }) + + it("succeeds with no target and bin/build.exe", func() { + ext := dist.ExtensionDescriptor{ + WithInfo: dist.ModuleInfo{ + ID: "some.extension.id", + Version: "some.extension.version", + }, + WithWindowsBuild: true, + } + + h.AssertNil(t, ext.EnsureTargetSupport("windows", "amd64", "fake-distro", "0.0")) + }) + + it("succeeds with no target and bin/build", func() { + ext := dist.ExtensionDescriptor{ + WithInfo: dist.ModuleInfo{ + ID: "some.extension.id", + Version: "some.extension.version", + }, + WithLinuxBuild: true, + } + + h.AssertNil(t, ext.EnsureTargetSupport("linux", "amd64", "fake-distro", "0.0")) + }) + + it("succeeds with distribution", func() { + ext := dist.ExtensionDescriptor{ + WithInfo: dist.ModuleInfo{ + ID: "some.extension.id", + Version: "some.extension.version", + }, + WithTargets: []dist.Target{{ + OS: "fake-os", + Arch: "fake-arch", + Distributions: []dist.Distribution{ + { + Name: "fake-distro", + Versions: []string{"0.1"}, + }, + { + Name: "another-distro", + Versions: []string{"0.22"}, + }, + }, + }}, + } + + h.AssertNil(t, ext.EnsureTargetSupport("fake-os", "fake-arch", "fake-distro", "0.1")) + }) + + it("succeeds with missing arch", func() { + ext := dist.ExtensionDescriptor{ + WithInfo: dist.ModuleInfo{ + ID: "some.extension.id", + Version: "some.extension.version", + }, + WithTargets: []dist.Target{{ + OS: "fake-os", + }}, + } + + h.AssertNil(t, ext.EnsureTargetSupport("fake-os", "fake-arch", "fake-distro", "0.1")) + }) + }) + when("#Kind", func() { it("returns 'extension'", func() { extDesc := dist.ExtensionDescriptor{}