Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate a mixin feed based on a directory #279

Merged
merged 5 commits into from
Apr 23, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions cmd/porter/mixins.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"github.com/deislabs/porter/pkg/mixin"
"github.com/deislabs/porter/pkg/mixin/feed"
"github.com/deislabs/porter/pkg/porter"
"github.com/deislabs/porter/pkg/printer"
"github.com/spf13/cobra"
Expand All @@ -12,13 +13,14 @@ func buildMixinsCommand(p *porter.Porter) *cobra.Command {
Use: "mixins",
Aliases: []string{"mixin"},
Short: "Mixin commands",
}
cmd.Annotations = map[string]string{
"group": "resource",
Annotations: map[string]string{
"group": "resource",
},
}

cmd.AddCommand(buildMixinsListCommand(p))
cmd.AddCommand(BuildMixinInstallCommand(p))
cmd.AddCommand(buildMixinsFeedCommand(p))

return cmd
}
Expand Down Expand Up @@ -70,3 +72,74 @@ func BuildMixinInstallCommand(p *porter.Porter) *cobra.Command {

return cmd
}

func buildMixinsFeedCommand(p *porter.Porter) *cobra.Command {
cmd := &cobra.Command{
Use: "feed",
Aliases: []string{"feeds"},
Short: "Feed commands",
Annotations: map[string]string{
"group": "resource",
},
}

cmd.AddCommand(BuildMixinFeedGenerateCommand(p))
cmd.AddCommand(BuildMixinFeedTemplateCommand(p))

return cmd
}

func BuildMixinFeedGenerateCommand(p *porter.Porter) *cobra.Command {
opts := feed.GenerateOptions{}
cmd := &cobra.Command{
Use: "generate",
Short: "Generate an atom feed from the mixins in a directory",
Long: `Generate an atom feed from the mixins in a directory.

A template is required, providing values for text properties such as the author name, base URLs and other values that cannot be inferred from the mixin file names. You can make a default template by running 'porter mixins feed template'.

The file names of the mixins must follow the naming conventions required of published mixins:

VERSION/MIXIN-GOOS-GOARCH[FILE_EXT]

More than one mixin may be present in the directory, and the directories may be nested a few levels deep, as long as the file path ends with the above naming convention, porter will find and match it. Below is an example directory structure that porter can list to generate a feed:

bin/
└── v1.2.3/
├── mymixin-darwin-amd64
├── mymixin-linux-amd64
└── mymixin-windows-amd64.exe

See https://porter.sh/mixin-distribution more details.
`,
Example: ` porter mixin feed generate
porter mixin feed generate --dir bin --file bin/atom.xml --template porter-atom-template.xml`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return opts.Validate(p.Context)
},
RunE: func(cmd *cobra.Command, args []string) error {
return p.GenerateMixinFeed(opts)
},
}

cmd.Flags().StringVarP(&opts.SearchDirectory, "dir", "d", "",
"The directory to search for mixin versions to publish in the feed. Defaults to the current directory.")
cmd.Flags().StringVarP(&opts.AtomFile, "file", "f", "atom.xml",
"The path of the atom feed output by this command.")
cmd.Flags().StringVarP(&opts.TemplateFile, "template", "t", "atom-template.xml",
"The template atom file used to populate the text fields in the generated feed.")

return cmd
}

func BuildMixinFeedTemplateCommand(p *porter.Porter) *cobra.Command {
cmd := &cobra.Command{
Use: "template",
Short: "Create an atom feed template",
Long: "Create an atom feed template in the current directory",
RunE: func(cmd *cobra.Command, args []string) error {
return p.CreateMixinFeedTemplate()
},
}
return cmd
}
4 changes: 4 additions & 0 deletions pkg/mixin/feed/feed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package feed

type MixinFeed struct {
}
168 changes: 168 additions & 0 deletions pkg/mixin/feed/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package feed

import (
"fmt"
"os"
"regexp"
"sort"
"time"

"github.com/cbroglie/mustache"
"github.com/deislabs/porter/pkg/context"
"github.com/pkg/errors"
)

type GenerateOptions struct {
SearchDirectory string
AtomFile string
TemplateFile string
}

func (o *GenerateOptions) Validate(c *context.Context) error {
err := o.ValidateSearchDirectory(c)
if err != nil {
return err
}

return o.ValidateTemplateFile(c)
}

func (o *GenerateOptions) ValidateSearchDirectory(cxt *context.Context) error {
if o.SearchDirectory == "" {
wd, err := os.Getwd()
if err != nil {
return errors.Wrap(err, "could not get current working directory")
}

o.SearchDirectory = wd
}

if _, err := cxt.FileSystem.Stat(o.SearchDirectory); err != nil {
return errors.Wrapf(err, "invalid --directory %s", o.SearchDirectory)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should we print invalid --dir or invalid -d to represent the flag strings supported? Or perhaps just invalid directory for a more general error msg?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, yes thanks it should be --dir

}

return nil
}

func (o *GenerateOptions) ValidateTemplateFile(cxt *context.Context) error {
if _, err := cxt.FileSystem.Stat(o.TemplateFile); err != nil {
return errors.Wrapf(err, "invalid --template %s", o.TemplateFile)
}

return nil
}

type mixinFileset struct {
Mixin string
Version string
Files []mixinFile
}

func (f *mixinFileset) Updated() string {
return toAtomTimestamp(f.GetLastUpdated())
}

func (f *mixinFileset) GetLastUpdated() time.Time {
var max time.Time
for _, f := range f.Files {
if f.Updated.After(max) {
max = f.Updated
}
}
return max
}

type mixinEntries []*mixinFileset

func (e mixinEntries) Len() int {
return len(e)
}

func (e mixinEntries) Swap(i, j int) {
e[i], e[j] = e[j], e[i]
}

func (e mixinEntries) Less(i, j int) bool {
return e[i].GetLastUpdated().Before(e[j].GetLastUpdated())
}

type mixinFile struct {
File string
Updated time.Time
}

type mixinFeed map[string]map[string]*mixinFileset

func Generate(opts GenerateOptions, cxt *context.Context) error {
feedTmpl, err := cxt.FileSystem.ReadFile(opts.TemplateFile)
if err != nil {
return errors.Wrapf(err, "error reading template file at %s", opts.TemplateFile)
}

mixinRegex := regexp.MustCompile(`(.*/)?(.+)/([a-z]+)-([a-z0-9]+)-([a-z0-9]+)(\.exe)?`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to restrict some of these regex fields to only the currently supported values? (For instance, OS can be only one of {linux, windows, darwin}, arch only amd64, etc.)

I can also understand wanting to keep it unrestricted with a vision towards supporting other variants...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally didn't want to be in the business of keeping up to date with our build matrix but honestly, it is not going to change often. So we just need to be aware that this is here. I'll update it and leave a note in the makefile's build matrix that this should be updated too when it's changed.


feed := mixinFeed{}
found := 0
err = cxt.FileSystem.Walk(opts.SearchDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

matches := mixinRegex.FindStringSubmatch(path)
if len(matches) > 0 {
version := matches[2]
mixin := matches[3]
filename := info.Name()

versions, ok := feed[mixin]
if !ok {
versions = map[string]*mixinFileset{}
feed[mixin] = versions
}

fileset, ok := versions[version]
if !ok {
fileset = &mixinFileset{
Mixin: mixin,
Version: version,
}
versions[version] = fileset
found++
}
fileset.Files = append(fileset.Files, mixinFile{File: filename, Updated: info.ModTime()})
}

return nil
})
if err != nil {
return err
}

tmplData := map[string]interface{}{}
mixins := make([]string, 0, len(feed))
entries := make(mixinEntries, 0, found)
for m, versions := range feed {
mixins = append(mixins, m)
for _, fileset := range versions {
entries = append(entries, fileset)
}
}
sort.Sort(sort.Reverse(entries))

tmplData["Mixins"] = mixins
tmplData["Entries"] = entries
tmplData["Updated"] = entries[0].Updated()

atomXml, err := mustache.Render(string(feedTmpl), tmplData)
err = cxt.FileSystem.WriteFile(opts.AtomFile, []byte(atomXml), 0644)
if err != nil {
return errors.Wrapf(err, "could not write feed to %s", opts.AtomFile)
}

fmt.Fprintf(cxt.Out, "wrote feed to %s\n", opts.AtomFile)
return nil
}

func toAtomTimestamp(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
94 changes: 94 additions & 0 deletions pkg/mixin/feed/generate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package feed

import (
"io/ioutil"
"sort"
"testing"
"time"

"github.com/stretchr/testify/assert"

"github.com/deislabs/porter/pkg/context"
"github.com/stretchr/testify/require"
)

func TestGenerate(t *testing.T) {
tc := context.NewTestContext(t)
tc.AddTestFile("testdata/atom-template.xml", "template.xml")

tc.FileSystem.Create("bin/v1.2.3/helm-darwin-amd64")
tc.FileSystem.Create("bin/v1.2.3/helm-linux-amd64")
tc.FileSystem.Create("bin/v1.2.3/helm-windows-amd64.exe")

// Force the up3 timestamps to stay the same for each test run
up3, _ := time.Parse("2006-Jan-02", "2013-Feb-03")
tc.FileSystem.Chtimes("bin/v1.2.3/helm-darwin-amd64", up3, up3)
tc.FileSystem.Chtimes("bin/v1.2.3/helm-linux-amd64", up3, up3)
tc.FileSystem.Chtimes("bin/v1.2.3/helm-windows-amd64.exe", up3, up3)

tc.FileSystem.Create("bin/v1.2.4/helm-darwin-amd64")
tc.FileSystem.Create("bin/v1.2.4/helm-linux-amd64")
tc.FileSystem.Create("bin/v1.2.4/helm-windows-amd64.exe")

up4, _ := time.Parse("2006-Jan-02", "2013-Feb-04")
tc.FileSystem.Chtimes("bin/v1.2.4/helm-darwin-amd64", up4, up4)
tc.FileSystem.Chtimes("bin/v1.2.4/helm-linux-amd64", up4, up4)
tc.FileSystem.Chtimes("bin/v1.2.4/helm-windows-amd64.exe", up4, up4)

tc.FileSystem.Create("bin/v1.2.3/exec-darwin-amd64")
tc.FileSystem.Create("bin/v1.2.3/exec-linux-amd64")
tc.FileSystem.Create("bin/v1.2.3/exec-windows-amd64.exe")

up2, _ := time.Parse("2006-Jan-02", "2013-Feb-02")
tc.FileSystem.Chtimes("bin/v1.2.3/exec-darwin-amd64", up2, up2)
tc.FileSystem.Chtimes("bin/v1.2.3/exec-linux-amd64", up2, up2)
tc.FileSystem.Chtimes("bin/v1.2.3/exec-windows-amd64.exe", up2, up2)

opts := GenerateOptions{
AtomFile: "atom.xml",
SearchDirectory: "bin",
TemplateFile: "template.xml",
}
err := Generate(opts, tc.Context)
require.NoError(t, err)

b, err := tc.FileSystem.ReadFile("atom.xml")
require.NoError(t, err)
gotXml := string(b)

b, err = ioutil.ReadFile("testdata/atom.xml")
require.NoError(t, err)
wantXml := string(b)

assert.Equal(t, wantXml, gotXml)
}

func TestMixinEntries_Sort(t *testing.T) {
up2, _ := time.Parse("2006-Jan-02", "2013-Feb-02")
up3, _ := time.Parse("2006-Jan-02", "2013-Feb-03")
up4, _ := time.Parse("2006-Jan-02", "2013-Feb-04")

entries := mixinEntries{
{
Files: []mixinFile{
{Updated: up3},
},
},
{
Files: []mixinFile{
{Updated: up2},
},
},
{
Files: []mixinFile{
{Updated: up4},
},
},
}

sort.Sort(sort.Reverse(entries))

assert.Equal(t, up4, entries[0].Files[0].Updated)
assert.Equal(t, up3, entries[1].Files[0].Updated)
assert.Equal(t, up2, entries[2].Files[0].Updated)
}
Loading