diff --git a/go.mod b/go.mod index eb0307b0..b47078c1 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.6 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0 + github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 github.com/hashicorp/go-multierror v1.1.1 @@ -18,6 +19,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.14.0 github.com/stretchr/testify v1.8.1 + golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 golang.org/x/term v0.3.0 ) @@ -50,7 +52,6 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect golang.org/x/crypto v0.4.0 // indirect - golang.org/x/exp v0.0.0-20221217163422-3c43f8badb15 // indirect golang.org/x/sys v0.3.0 // indirect golang.org/x/text v0.5.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 06af14c6..16c27a47 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0 h1:3IpsaL5PJepA/5WEhUBn7GmXS2uT4GZ0n5N/iQV6JhQ= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.20.0/go.mod h1:++gnwUI5HaG31oDMaUXrNNjsXfmLn/zWyMmy/5GaFSA= +github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= +github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= diff --git a/pkg/cmd/package/nuget/create/create.go b/pkg/cmd/package/nuget/create/create.go new file mode 100644 index 00000000..599b56da --- /dev/null +++ b/pkg/cmd/package/nuget/create/create.go @@ -0,0 +1,316 @@ +package create + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + pack "github.com/OctopusDeploy/cli/pkg/cmd/package/support" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/surveyext" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/spf13/cobra" +) + +const ( + FlagAuthor = "author" + FlagTitle = "title" + FlagDescription = "description" + FlagReleaseNotes = "releaseNotes" + FlagReleaseNotesFile = "releaseNotesFile" +) + +type NuPkgCreateFlags struct { + Author *flag.Flag[[]string] // this need to be multiple and default to current user + Title *flag.Flag[string] + Description *flag.Flag[string] + ReleaseNotes *flag.Flag[string] + ReleaseNotesFile *flag.Flag[string] +} + +type NuPkgCreateOptions struct { + *NuPkgCreateFlags + *pack.PackageCreateOptions +} + +func NewNuPkgCreateFlags() *NuPkgCreateFlags { + return &NuPkgCreateFlags{ + Author: flag.New[[]string](FlagAuthor, false), + Title: flag.New[string](FlagTitle, false), + Description: flag.New[string](FlagDescription, false), + ReleaseNotes: flag.New[string](FlagReleaseNotes, false), + ReleaseNotesFile: flag.New[string](FlagReleaseNotesFile, false), + } +} + +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := NewNuPkgCreateFlags() + packFlags := pack.NewPackageCreateFlags() + + cmd := &cobra.Command{ + Use: "create", + Short: "Create nuget", + Long: "Create nuget package", + Example: heredoc.Docf(` + $ %[1]s package nuget create --id SomePackage --version 1.0.0 + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, _ []string) error { + opts := &NuPkgCreateOptions{ + NuPkgCreateFlags: createFlags, + PackageCreateOptions: pack.NewPackageCreateOptions(f, packFlags, cmd), + } + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&packFlags.Id.Value, packFlags.Id.Name, "", "The ID of the package") + flags.StringVarP(&packFlags.Version.Value, packFlags.Version.Name, "v", "", "The version of the package, must be a valid SemVer.") + flags.StringVar(&packFlags.BasePath.Value, packFlags.BasePath.Name, "", "Root folder containing the contents to zip.") + flags.StringVar(&packFlags.OutFolder.Value, packFlags.OutFolder.Name, "", "Folder into which the zip file will be written.") + flags.StringSliceVar(&packFlags.Include.Value, packFlags.Include.Name, []string{}, "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\".") + flags.BoolVar(&packFlags.Verbose.Value, packFlags.Verbose.Name, false, "Verbose output.") + flags.BoolVar(&packFlags.Overwrite.Value, packFlags.Overwrite.Name, false, "Allow an existing package file of the same ID/version to be overwritten.") + flags.StringSliceVar(&createFlags.Author.Value, createFlags.Author.Name, []string{}, "Add author/s to the package metadata.") + flags.StringVar(&createFlags.Title.Value, createFlags.Title.Name, "", "The title of the package.") + flags.StringVar(&createFlags.Description.Value, createFlags.Description.Name, "", "A description of the package, defaults to \"A deployment package created from files on disk.\".") + flags.StringVar(&createFlags.ReleaseNotes.Value, createFlags.ReleaseNotes.Name, "", "Release notes for this version of the package.") + flags.StringVar(&createFlags.ReleaseNotesFile.Value, createFlags.ReleaseNotesFile.Name, "", "A file containing release notes for this version of the package.") + flags.SortFlags = false + + return cmd +} + +func createRun(opts *NuPkgCreateOptions) error { + if !opts.NoPrompt { + if err := pack.PackageCreatePromptMissing(opts.PackageCreateOptions); err != nil { + return err + } + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.Id.Value == "" { + return errors.New("must supply a package ID") + } + + err := applyDefaultsToUnspecifiedPackageOptions(opts) + if err != nil { + return err + } + + nuspecFilePath := "" + if shouldGenerateNuSpec(opts) { + defer func() { + if nuspecFilePath != "" { + err := os.Remove(nuspecFilePath) + if err != nil { + panic(err) + } + } + }() + nuspecFilePath, err = GenerateNuSpec(opts) + if err != nil { + return err + } + opts.Include.Value = append(opts.Include.Value, opts.Id.Value+".nuspec") + } + + pack.VerboseOut(opts.Writer, opts.Verbose.Value, "Packing \"%s\" version \"%s\"...\n", opts.Id.Value, opts.Version.Value) + outFilePath := pack.BuildOutFileName("nupkg", opts.Id.Value, opts.Version.Value) + + err = pack.BuildPackage(opts.PackageCreateOptions, outFilePath) + if err != nil { + return err + } + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd( + opts.CmdPath, + opts.Author, + opts.Title, + opts.Description, + opts.ReleaseNotes, + opts.ReleaseNotesFile, + opts.Id, + opts.Version, + opts.BasePath, + opts.OutFolder, + opts.Include, + opts.Verbose, + opts.Overwrite, + ) + fmt.Fprintf(opts.Writer, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func PromptMissing(opts *NuPkgCreateOptions) error { + if len(opts.Author.Value) == 0 { + for { + author := "" + if err := opts.Ask(&survey.Input{ + Message: "Author (leave blank to continue)", + Help: "Add an author to the package metadata.", + }, &author); err != nil { + return err + } + if strings.TrimSpace(author) == "" { + break + } + opts.Author.Value = append(opts.Author.Value, author) + } + } + + if len(opts.Author.Value) > 0 { + if opts.Title.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Nuspec title", + Help: "The title to include in the Nuspec file.", + }, &opts.Title.Value); err != nil { + return err + } + } + + if opts.Description.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Nuspec description", + Help: "The description to include in the Nuspec file.", + Default: "A deployment package created from files on disk.", + }, &opts.Description.Value); err != nil { + return err + } + } + + if opts.ReleaseNotes.Value == "" { + if err := opts.Ask(&surveyext.OctoEditor{ + Editor: &survey.Editor{ + Message: "Nuspec release notes", + Help: "The release notes to include in the Nuspec file.", + }, + Optional: true, + }, &opts.ReleaseNotes.Value); err != nil { + return err + } + } + + if opts.ReleaseNotesFile.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Nuspec release notes file", + Help: "A path to a release notes file whose contents will be included in the Nuspec file's release notes.", + }, &opts.ReleaseNotesFile.Value); err != nil { + return err + } + } + } + + return nil +} + +func applyDefaultsToUnspecifiedPackageOptions(opts *NuPkgCreateOptions) error { + if opts.Version.Value == "" { + opts.Version.Value = pack.BuildTimestampSemVer(time.Now()) + } + + if opts.BasePath.Value == "" { + opts.BasePath.Value = "." + } + + if opts.OutFolder.Value == "" { + opts.OutFolder.Value = "." + } + + if len(opts.Include.Value) == 0 { + opts.Include.Value = append(opts.Include.Value, "**") + } + + if len(opts.Author.Value) > 0 { + if opts.Description.Value == "" { + opts.Description.Value = "A deployment package created from files on disk." + } + } + + return nil +} + +func getReleaseNotesFromFile(filePath string) (string, error) { + _, err := os.Stat(filePath) + if err != nil { + return "", err + } + + notes, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + + return string(notes), nil +} + +func shouldGenerateNuSpec(opts *NuPkgCreateOptions) bool { + return opts.Description.Value != "" || + opts.Title.Value != "" || + opts.ReleaseNotes.Value != "" || + opts.ReleaseNotesFile.Value != "" || + !util.Empty(opts.Author.Value) +} + +func GenerateNuSpec(opts *NuPkgCreateOptions) (string, error) { + + if opts.Description.Value == "" { + return "", errors.New("description is required when generating nuspec metadata") + } + if len(opts.Author.Value) == 0 { + return "", errors.New("at least one author is required when generating nuspec metadata") + } + + releaseNotes := opts.ReleaseNotes.Value + if opts.ReleaseNotesFile.Value != "" { + if releaseNotes != "" { + return "", errors.New(`cannot specify both "Nuspec release notes" and "Nuspec release notes file"`) + } + + notes, err := getReleaseNotesFromFile(opts.ReleaseNotesFile.Value) + releaseNotes = notes + if err != nil { + return "", err + } + } + + filePath := filepath.Join(opts.BasePath.Value, opts.Id.Value+".nuspec") + + var sb strings.Builder + sb.WriteString(`` + "\n") + sb.WriteString(`` + "\n") + sb.WriteString(" \n") + sb.WriteString(" " + opts.Id.Value + "\n") + sb.WriteString(" " + opts.Version.Value + "\n") + sb.WriteString(" " + opts.Description.Value + "\n") + sb.WriteString(" " + strings.Join(opts.Author.Value, ",") + "\n") + if releaseNotes != "" { + sb.WriteString(" " + releaseNotes + "\n") + } + sb.WriteString(" \n") + sb.WriteString("\n") + + file, err := os.Create(filePath) + if err != nil { + return "", err + } + + _, err = file.WriteString(sb.String()) + if err != nil { + return "", err + } + + return filePath, file.Close() +} diff --git a/pkg/cmd/package/nuget/nuget.go b/pkg/cmd/package/nuget/nuget.go new file mode 100644 index 00000000..8827517b --- /dev/null +++ b/pkg/cmd/package/nuget/nuget.go @@ -0,0 +1,22 @@ +package nuget + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdNugetCreate "github.com/OctopusDeploy/cli/pkg/cmd/package/nuget/create" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdPackageNuget(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "nuget ", + Short: "Package as NuPkg", + Long: "Package as NuPkg for Octopus Deploy", + Example: heredoc.Docf("$ %s package nuget create", constants.ExecutableName), + } + + cmd.AddCommand(cmdNugetCreate.NewCmdCreate(f)) + + return cmd +} diff --git a/pkg/cmd/package/package.go b/pkg/cmd/package/package.go index f089be0e..b43761d0 100644 --- a/pkg/cmd/package/package.go +++ b/pkg/cmd/package/package.go @@ -2,10 +2,11 @@ package _package import ( "fmt" - cmdList "github.com/OctopusDeploy/cli/pkg/cmd/package/list" + cmdNuget "github.com/OctopusDeploy/cli/pkg/cmd/package/nuget" cmdUpload "github.com/OctopusDeploy/cli/pkg/cmd/package/upload" cmdVersions "github.com/OctopusDeploy/cli/pkg/cmd/package/versions" + cmdZip "github.com/OctopusDeploy/cli/pkg/cmd/package/zip" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -26,5 +27,7 @@ func NewCmdPackage(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdUpload.NewCmdUpload(f)) cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdVersions.NewCmdVersions(f)) + cmd.AddCommand(cmdNuget.NewCmdPackageNuget(f)) + cmd.AddCommand(cmdZip.NewCmdPackageZip(f)) return cmd } diff --git a/pkg/cmd/package/support/pack.go b/pkg/cmd/package/support/pack.go new file mode 100644 index 00000000..cd323b38 --- /dev/null +++ b/pkg/cmd/package/support/pack.go @@ -0,0 +1,270 @@ +package support + +import ( + "archive/zip" + "errors" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/bmatcuk/doublestar/v4" + "github.com/spf13/cobra" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + FlagId = "id" + FlagVersion = "version" + FlagBasePath = "base-path" + FlagOutFolder = "out-folder" + FlagInclude = "include" + FlagVerbose = "verbose" + FlagOverwrite = "overwrite" +) + +type PackageCreateFlags struct { + Id *flag.Flag[string] + Version *flag.Flag[string] + BasePath *flag.Flag[string] + OutFolder *flag.Flag[string] + Include *flag.Flag[[]string] + Verbose *flag.Flag[bool] + Overwrite *flag.Flag[bool] +} + +type PackageCreateOptions struct { + *PackageCreateFlags + Writer io.Writer + Ask question.Asker + NoPrompt bool + CmdPath string +} + +func NewPackageCreateFlags() *PackageCreateFlags { + return &PackageCreateFlags{ + Id: flag.New[string](FlagId, false), + Version: flag.New[string](FlagVersion, false), + BasePath: flag.New[string](FlagBasePath, false), + OutFolder: flag.New[string](FlagOutFolder, false), + Include: flag.New[[]string](FlagInclude, false), + Verbose: flag.New[bool](FlagVerbose, false), + Overwrite: flag.New[bool](FlagOverwrite, false), + } +} + +func NewPackageCreateOptions(f factory.Factory, flags *PackageCreateFlags, cmd *cobra.Command) *PackageCreateOptions { + return &PackageCreateOptions{ + PackageCreateFlags: flags, + Writer: cmd.OutOrStdout(), + Ask: f.Ask, + NoPrompt: !f.IsPromptEnabled(), + CmdPath: cmd.CommandPath(), + } +} + +func PackageCreatePromptMissing(opts *PackageCreateOptions) error { + if opts.Id.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Package ID", + Help: "The ID of the package.", + }, &opts.Id.Value, survey.WithValidator(survey.ComposeValidators( + survey.Required, + survey.MaxLength(200), + survey.MinLength(1), + ))); err != nil { + return err + } + } + + if opts.Version.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Version", + Help: "The version of the package, must be a valid SemVer.", + }, &opts.Version.Value); err != nil { + return err + } + } + + if opts.BasePath.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Base Path", + Help: "Root folder containing the contents to zip; defaults to current directory.", + Default: ".", + }, &opts.BasePath.Value); err != nil { + return err + } + } + + if opts.OutFolder.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Out Folder", + Help: "Folder into which the zip file will be written; defaults to the current directory.", + Default: ".", + }, &opts.OutFolder.Value); err != nil { + return err + } + } + + if len(opts.Include.Value) == 0 { + for { + var pattern string + if err := opts.Ask(&survey.Input{ + Message: "Include patterns", + Help: "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\" no patterns provided.", + }, &pattern); err != nil { + return err + } + if pattern == "" { + break + } + opts.Include.Value = append(opts.Include.Value, pattern) + } + } + + if !opts.Verbose.Value { + err := opts.Ask(&survey.Confirm{ + Message: "Verbose", + Default: true, + }, &opts.Verbose.Value) + if err != nil { + return err + } + } + + if !opts.Overwrite.Value { + err := opts.Ask(&survey.Confirm{ + Message: "Overwrite", + Default: true, + }, &opts.Overwrite.Value) + if err != nil { + return err + } + } + + return nil +} + +func VerboseOut(out io.Writer, isVerbose bool, messageTemplate string, messageArgs ...any) { + if isVerbose { + fmt.Fprintf(out, messageTemplate, messageArgs...) + } +} + +func BuildTimestampSemVer(dateTime time.Time) string { + endSegment := dateTime.Hour()*10000 + dateTime.Minute()*100 + dateTime.Second() + return fmt.Sprintf("%d.%d.%d.%d", dateTime.Year(), dateTime.Month(), dateTime.Day(), endSegment) +} + +func BuildOutFileName(packageType, id, version string) string { + return fmt.Sprintf("%s.%s.%s", id, version, packageType) +} + +func BuildPackage(opts *PackageCreateOptions, outFileName string) error { + outFilePath := filepath.Join(opts.OutFolder.Value, outFileName) + outPath, err := filepath.Abs(opts.OutFolder.Value) + if err != nil { + return err + } + + _, err = os.Stat(outFilePath) + if !opts.Overwrite.Value && err == nil { + return fmt.Errorf("package with name '%s' already exists ...aborting", outFileName) + } + + VerboseOut(opts.Writer, opts.Verbose.Value, "Saving \"%s\" to \"%s\"...\nAdding files from \"%s\" matching pattern/s \"%s\"\n", outPath, outFileName, outPath, strings.Join(opts.Include.Value, ", ")) + + filePaths, err := getDistinctPatternMatches(opts.BasePath.Value, opts.Include.Value) + if err != nil { + return err + } + + if len(filePaths) == 0 { + return errors.New("no files identified to package") + } + + return buildArchive(opts.Writer, outFilePath, opts.BasePath.Value, filePaths, opts.Verbose.Value) +} + +func buildArchive(out io.Writer, outFilePath string, basePath string, filesToArchive []string, isVerbose bool) error { + _, outFile := filepath.Split(outFilePath) + zipFile, err := os.Create(outFilePath) + if err != nil { + return err + } + defer zipFile.Close() + + writer := zip.NewWriter(zipFile) + defer writer.Close() + + for _, path := range filesToArchive { + if path == outFile || path == "." { + continue + } + + fullPath := filepath.Join(basePath, path) + fileInfo, err := os.Stat(fullPath) + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(fileInfo) + if err != nil { + return err + } + + header.Method = zip.Deflate + header.Name = path + if fileInfo.IsDir() { + header.Name += "/" + } + + headerWriter, err := writer.CreateHeader(header) + if err != nil { + return err + } + + if fileInfo.IsDir() { + continue + } + + f, err := os.Open(fullPath) + if err != nil { + return err + } + + _, err = io.Copy(headerWriter, f) + if err == nil { + VerboseOut(out, isVerbose, "Added file: %s\n", path) + } else { + return err + } + + err = f.Close() + if err != nil { + return err + } + } + + return nil +} + +func getDistinctPatternMatches(basePath string, patterns []string) ([]string, error) { + fileSys := os.DirFS(filepath.Clean(basePath)) + var filePaths []string + + for _, pattern := range patterns { + paths, err := doublestar.Glob(fileSys, filepath.ToSlash(pattern)) + if err != nil { + return nil, err + } + filePaths = append(filePaths, paths...) + } + + return util.SliceDistinct(filePaths), nil +} diff --git a/pkg/cmd/package/support/pack_test.go b/pkg/cmd/package/support/pack_test.go new file mode 100644 index 00000000..e7e70068 --- /dev/null +++ b/pkg/cmd/package/support/pack_test.go @@ -0,0 +1,69 @@ +package support + +import ( + "errors" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/stretchr/testify/assert" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestVerboseOut_WithVerboseEnabled(t *testing.T) { + result := testutil.CaptureConsoleOutput(func() { + VerboseOut(os.Stdout, true, "This %s a %s... %d", "is", "test", 123) + }) + assert.Equal(t, "This is a test... 123", result) +} + +func TestVerboseOut_WithVerboseDisabled(t *testing.T) { + result := testutil.CaptureConsoleOutput(func() { + VerboseOut(os.Stdout, false, "This %s a %s... %d", "is", "test", 123) + }) + assert.Equal(t, "", result) +} + +func TestBuildTimestampSemVer(t *testing.T) { + knownTime := time.Date(2000, time.January, 1, 1, 1, 1, 0, time.UTC) + assert.Equal(t, "2000.1.1.10101", BuildTimestampSemVer(knownTime)) +} + +func TestBuildOutFileName(t *testing.T) { + result := BuildOutFileName("zip", "SomePackage", "1.0.1") + assert.Equal(t, "SomePackage.1.0.1.zip", result) +} + +func TestPanicImmediately(t *testing.T) { + basePath := setupForArchive(t) + if runtime.GOOS == "windows" { // See line 63 + defer t.Cleanup(func() { + cleanUpTemp(basePath) + }) + } + + newPath := filepath.Join(basePath, "test.txt") + _, err := os.Stat(newPath) + assert.Nil(t, err) +} + +func setupForArchive(t *testing.T) string { + dir := filepath.ToSlash(t.TempDir()) + _, err := os.Create(dir + "/test.txt") + if err != nil { + panic(err) + } + + return dir +} + +// TODO Test and potentially remove manual clean-up when go version >= 1.20.0 +// cleanUpTemp is a temporary solution for windows to https://github.com/golang/go/issues/51442. +func cleanUpTemp(tempDir string) { + err := errors.New("init not nil") + for err != nil { + time.Sleep(time.Millisecond * 10) + err = os.RemoveAll(tempDir) + } +} diff --git a/pkg/cmd/package/zip/create/create.go b/pkg/cmd/package/zip/create/create.go new file mode 100644 index 00000000..afd8bf85 --- /dev/null +++ b/pkg/cmd/package/zip/create/create.go @@ -0,0 +1,93 @@ +package create + +import ( + "errors" + "fmt" + "time" + + "github.com/MakeNowJust/heredoc/v2" + pack "github.com/OctopusDeploy/cli/pkg/cmd/package/support" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/spf13/cobra" +) + +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := pack.NewPackageCreateFlags() + + cmd := &cobra.Command{ + Use: "create", + Short: "Create zip", + Long: "Create zip package", + Example: heredoc.Docf(` + $ %[1]s package zip create --id SomePackage --version 1.0.0 + `, constants.ExecutableName), + RunE: func(cmd *cobra.Command, args []string) error { + opts := pack.NewPackageCreateOptions(f, createFlags, cmd) + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&createFlags.Id.Value, createFlags.Id.Name, "", "The ID of the package.") + flags.StringVarP(&createFlags.Version.Value, createFlags.Version.Name, "v", "", "The version of the package, must be a valid SemVer.") + flags.StringVar(&createFlags.BasePath.Value, createFlags.BasePath.Name, "", "Root folder containing the contents to zip.") + flags.StringVar(&createFlags.OutFolder.Value, createFlags.OutFolder.Name, "", "Folder into which the zip file will be written.") + flags.StringSliceVar(&createFlags.Include.Value, createFlags.Include.Name, []string{}, "Add a file pattern to include, relative to the base path e.g. /bin/*.dll; defaults to \"**\".") + flags.BoolVar(&createFlags.Verbose.Value, createFlags.Verbose.Name, false, "Verbose output.") + flags.BoolVar(&createFlags.Overwrite.Value, createFlags.Overwrite.Name, false, "Allow an existing package file of the same ID/version to be overwritten.") + flags.SortFlags = false + + return cmd +} + +func createRun(opts *pack.PackageCreateOptions) error { + if !opts.NoPrompt { + if err := pack.PackageCreatePromptMissing(opts); err != nil { + return err + } + } + + if opts.Id.Value == "" { + return errors.New("must supply a package ID") + } + applyDefaultsToUnspecifiedOptions(opts) + + pack.VerboseOut(opts.Writer, opts.Verbose.Value, "Packing \"%s\" version \"%s\"...\n", opts.Id.Value, opts.Version.Value) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd( + opts.CmdPath, + opts.Id, + opts.Version, + opts.BasePath, + opts.OutFolder, + opts.Include, + opts.Verbose, + opts.Overwrite, + ) + fmt.Fprintf(opts.Writer, "\nAutomation Command: %s\n", autoCmd) + } + + outFilePath := pack.BuildOutFileName("zip", opts.Id.Value, opts.Version.Value) + return pack.BuildPackage(opts, outFilePath) +} + +func applyDefaultsToUnspecifiedOptions(opts *pack.PackageCreateOptions) { + if opts.Version.Value == "" { + opts.Version.Value = pack.BuildTimestampSemVer(time.Now()) + } + + if opts.BasePath.Value == "" { + opts.BasePath.Value = "." + } + + if opts.OutFolder.Value == "" { + opts.OutFolder.Value = "." + } + + if len(opts.Include.Value) == 0 { + opts.Include.Value = append(opts.Include.Value, "**") + } +} diff --git a/pkg/cmd/package/zip/zip.go b/pkg/cmd/package/zip/zip.go new file mode 100644 index 00000000..c55e20e7 --- /dev/null +++ b/pkg/cmd/package/zip/zip.go @@ -0,0 +1,22 @@ +package zip + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdZipCreate "github.com/OctopusDeploy/cli/pkg/cmd/package/zip/create" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdPackageZip(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "zip ", + Short: "Package as zip", + Long: "Package as zip for Octopus Deploy", + Example: heredoc.Docf("$ %s package zip create", constants.ExecutableName), + } + + cmd.AddCommand(cmdZipCreate.NewCmdCreate(f)) + + return cmd +} diff --git a/pkg/cmd/target/azure-web-app/create/create.go b/pkg/cmd/target/azure-web-app/create/create.go index 7618b159..302e5473 100644 --- a/pkg/cmd/target/azure-web-app/create/create.go +++ b/pkg/cmd/target/azure-web-app/create/create.go @@ -150,7 +150,7 @@ func createRun(opts *CreateOptions) error { endpoint.WebAppName = opts.WebApp.Value endpoint.ResourceGroupName = opts.ResourceGroup.Value endpoint.WebAppSlotName = opts.Slot.Value - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) err = shared.ConfigureTenant(deploymentTarget, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions) if err != nil { diff --git a/pkg/cmd/target/cloud-region/create/create.go b/pkg/cmd/target/cloud-region/create/create.go index 7ab72eda..e358ecd0 100644 --- a/pkg/cmd/target/cloud-region/create/create.go +++ b/pkg/cmd/target/cloud-region/create/create.go @@ -111,7 +111,7 @@ func createRun(opts *CreateOptions) error { endpoint.DefaultWorkerPoolID = workerPoolId } - target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value)) + target := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) err = shared.ConfigureTenant(target, opts.CreateTargetTenantFlags, opts.CreateTargetTenantOptions) if err != nil { return err diff --git a/pkg/cmd/target/kubernetes/create/create.go b/pkg/cmd/target/kubernetes/create/create.go index 7eec5ae1..fee93fe6 100644 --- a/pkg/cmd/target/kubernetes/create/create.go +++ b/pkg/cmd/target/kubernetes/create/create.go @@ -446,7 +446,7 @@ func (opts *CreateOptions) Commit() error { endpoint.Authentication = auth } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) machinePolicy, err := machinescommon.FindDefaultMachinePolicy(opts.GetAllMachinePoliciesCallback) if err != nil { diff --git a/pkg/cmd/target/listening-tentacle/create/create.go b/pkg/cmd/target/listening-tentacle/create/create.go index f06f0e2f..3745fec1 100644 --- a/pkg/cmd/target/listening-tentacle/create/create.go +++ b/pkg/cmd/target/listening-tentacle/create/create.go @@ -131,7 +131,7 @@ func createRun(opts *CreateOptions) error { endpoint.ProxyID = proxy.GetID() } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value) if err != nil { return err diff --git a/pkg/cmd/target/shared/role_test.go b/pkg/cmd/target/shared/role_test.go index 1b8f0a36..01c842ac 100644 --- a/pkg/cmd/target/shared/role_test.go +++ b/pkg/cmd/target/shared/role_test.go @@ -3,11 +3,22 @@ package shared_test import ( "github.com/OctopusDeploy/cli/pkg/cmd" "github.com/OctopusDeploy/cli/pkg/cmd/target/shared" + "github.com/OctopusDeploy/cli/pkg/util" "github.com/OctopusDeploy/cli/test/testutil" "github.com/stretchr/testify/assert" "testing" ) +func TestDistinctRoles_EmptyList(t *testing.T) { + result := util.SliceDistinct([]string{}) + assert.Empty(t, result) +} + +func TestDistinctRoles_DuplicateValues(t *testing.T) { + result := util.SliceDistinct([]string{"a", "b", "a"}) + assert.Equal(t, []string{"a", "b"}, result) +} + func TestPromptRoles_FlagsSupplied(t *testing.T) { pa := []*testutil.PA{} diff --git a/pkg/cmd/target/ssh/create/create.go b/pkg/cmd/target/ssh/create/create.go index a13c84c0..9ea153d4 100644 --- a/pkg/cmd/target/ssh/create/create.go +++ b/pkg/cmd/target/ssh/create/create.go @@ -141,7 +141,7 @@ func createRun(opts *CreateOptions) error { endpoint.ProxyID = proxy.GetID() } - deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.DistinctStrings(opts.Roles.Value)) + deploymentTarget := machines.NewDeploymentTarget(opts.Name.Value, endpoint, environmentIds, util.SliceDistinct(opts.Roles.Value)) machinePolicy, err := machinescommon.FindMachinePolicy(opts.GetAllMachinePoliciesCallback, opts.MachinePolicy.Value) if err != nil { return err diff --git a/pkg/cmd/tenant/list/list.go b/pkg/cmd/tenant/list/list.go index cccac7b5..b5e0cbc1 100644 --- a/pkg/cmd/tenant/list/list.go +++ b/pkg/cmd/tenant/list/list.go @@ -112,7 +112,7 @@ func getEnvironmentMap(client *client.Client, tenants []*tenants.Tenant) (map[st } } - environmentIds = util.DistinctStrings(environmentIds) + environmentIds = util.SliceDistinct(environmentIds) environmentMap := make(map[string]string) queryResult, err := client.Environments.Get(environments.EnvironmentsQuery{IDs: environmentIds}) @@ -140,7 +140,7 @@ func getProjectMap(client *client.Client, tenants []*tenants.Tenant) (map[string projectIds = append(projectIds, p) } } - projectIds = util.DistinctStrings(projectIds) + projectIds = util.SliceDistinct(projectIds) projectMap := make(map[string]string) queryResult, err := client.Projects.Get(projects.ProjectsQuery{IDs: projectIds}) diff --git a/pkg/util/util.go b/pkg/util/util.go index e8b4133f..96d3c40a 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -165,15 +165,14 @@ func Any[T any](items []T) bool { return !Empty(items) } -func DistinctStrings(items []string) []string { - itemsMap := make(map[string]bool) - result := []string{} - for _, r := range items { - if _, ok := itemsMap[r]; !ok { - itemsMap[r] = true - result = append(result, r) +func SliceDistinct[T comparable](slice []T) []T { + inResult := make(map[T]bool) + var result []T + for _, str := range slice { + if _, ok := inResult[str]; !ok { + inResult[str] = true + result = append(result, str) } } - return result } diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index 272f640e..5a33ae29 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -390,12 +390,16 @@ func TestEmpty_SomeItems(t *testing.T) { assert.False(t, util.Empty([]string{"value"})) } -func TestDistinctStrings_EmptyList(t *testing.T) { - result := util.DistinctStrings([]string{}) - assert.Empty(t, result) +func TestDistinct_Empty(t *testing.T) { + assert.Equal(t, []string(nil), util.SliceDistinct([]string{})) } -func TestDistinctStrings_DuplicateValues(t *testing.T) { - result := util.DistinctStrings([]string{"a", "b", "a"}) - assert.Equal(t, []string{"a", "b"}, result) +func TestDistinct_WithoutDuplicateItems(t *testing.T) { + items := []string{"foo", "bar", "baz"} + assert.Equal(t, items, util.SliceDistinct(items)) +} + +func TestDistinct_WithDuplicateItems(t *testing.T) { + items := []string{"foo", "bar", "foo", "baz", "bar"} + assert.Equal(t, []string{"foo", "bar", "baz"}, util.SliceDistinct(items)) } diff --git a/test/testutil/testutil.go b/test/testutil/testutil.go index 581709a1..640e254e 100644 --- a/test/testutil/testutil.go +++ b/test/testutil/testutil.go @@ -1,10 +1,12 @@ package testutil import ( + "bytes" "encoding/json" "errors" "io" "net/http" + "os" "runtime/debug" "testing" ) @@ -105,3 +107,32 @@ func ParseJsonStrict[T any](input io.Reader) (T, error) { decoder.DisallowUnknownFields() return parsedStdout, decoder.Decode(&parsedStdout) } + +// CaptureConsoleOutput borrows from `github.com/zenizh/go-capturer`. This implementation captures both stdout +// and stderr. Consider adding the go-capturer module if more granularity is needed +func CaptureConsoleOutput(f func()) string { + r, w, err := os.Pipe() + if err != nil { + panic(err) + } + + stdout := os.Stdout + os.Stdout = w + defer func() { + os.Stdout = stdout + }() + + stderr := os.Stderr + os.Stderr = w + defer func() { + os.Stderr = stderr + }() + + f() + w.Close() + + var buf bytes.Buffer + io.Copy(&buf, r) + + return buf.String() +}