From fc1101ecb6f3285d3b24bdd22e4ad13341a0dae3 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:00:53 -0700 Subject: [PATCH] add "bake.New" function to split a into defaults based tile source --- pkg/bake/defaults.go | 17 +++ pkg/bake/defaults_test.go | 82 +++++++++++ pkg/bake/new.go | 200 +++++++++++++++++++++++++++ pkg/bake/new_internal_test.go | 79 +++++++++++ pkg/bake/new_test.go | 45 ++++++ pkg/bake/testdata/tile-0.1.3.pivotal | Bin 0 -> 9439 bytes 6 files changed, 423 insertions(+) create mode 100644 pkg/bake/defaults.go create mode 100644 pkg/bake/defaults_test.go create mode 100644 pkg/bake/new.go create mode 100644 pkg/bake/new_internal_test.go create mode 100644 pkg/bake/new_test.go create mode 100644 pkg/bake/testdata/tile-0.1.3.pivotal diff --git a/pkg/bake/defaults.go b/pkg/bake/defaults.go new file mode 100644 index 00000000..1617d428 --- /dev/null +++ b/pkg/bake/defaults.go @@ -0,0 +1,17 @@ +package bake + +const ( + DefaultFilepathIconImage = "icon.png" + DefaultFilepathKilnfile = "Kilnfile" + DefaultFilepathKilnfileLock = DefaultFilepathKilnfile + ".lock" + DefaultFilepathBaseYML = "base.yml" + + DefaultDirectoryReleases = "releases" + DefaultDirectoryForms = "forms" + DefaultDirectoryInstanceGroups = "instance_groups" + DefaultDirectoryJobs = "jobs" + DefaultDirectoryMigrations = "migrations" + DefaultDirectoryProperties = "properties" + DefaultDirectoryRuntimeConfigs = "runtime_configs" + DefaultDirectoryBOSHVariables = "bosh_variables" +) diff --git a/pkg/bake/defaults_test.go b/pkg/bake/defaults_test.go new file mode 100644 index 00000000..d6c05581 --- /dev/null +++ b/pkg/bake/defaults_test.go @@ -0,0 +1,82 @@ +package bake_test + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pivotal-cf/kiln/internal/commands" + "github.com/pivotal-cf/kiln/pkg/bake" +) + +func TestBakeOptions(t *testing.T) { + options := reflect.TypeOf(commands.BakeOptions{}) + + for _, tt := range []struct { + Constant string + LongFlag string + }{ + { + Constant: bake.DefaultFilepathIconImage, + LongFlag: "icon", + }, + { + Constant: bake.DefaultFilepathKilnfile, + LongFlag: "Kilnfile", + }, + { + Constant: bake.DefaultFilepathBaseYML, + LongFlag: "metadata", + }, + { + Constant: bake.DefaultDirectoryReleases, + LongFlag: "releases-directory", + }, + { + Constant: bake.DefaultDirectoryForms, + LongFlag: "forms-directory", + }, + { + Constant: bake.DefaultDirectoryInstanceGroups, + LongFlag: "instance-groups-directory", + }, + { + Constant: bake.DefaultDirectoryJobs, + LongFlag: "jobs-directory", + }, + { + Constant: bake.DefaultDirectoryMigrations, + LongFlag: "migrations-directory", + }, + { + Constant: bake.DefaultDirectoryProperties, + LongFlag: "properties-directory", + }, + { + Constant: bake.DefaultDirectoryRuntimeConfigs, + LongFlag: "runtime-configs-directory", + }, + { + Constant: bake.DefaultDirectoryBOSHVariables, + LongFlag: "bosh-variables-directory", + }, + } { + t.Run(tt.Constant, func(t *testing.T) { + field, found := fieldByTag(options, "long", "icon") + require.True(t, found) + require.Equal(t, field.Tag.Get("default"), bake.DefaultFilepathIconImage) + }) + } +} + +func fieldByTag(tp reflect.Type, tagName, tagValue string) (reflect.StructField, bool) { + for i := 0; i < tp.NumField(); i++ { + field := tp.Field(i) + value, ok := field.Tag.Lookup(tagName) + if ok && value == tagValue { + return field, true + } + } + return reflect.StructField{}, false +} diff --git a/pkg/bake/new.go b/pkg/bake/new.go new file mode 100644 index 00000000..2c6f20f5 --- /dev/null +++ b/pkg/bake/new.go @@ -0,0 +1,200 @@ +package bake + +import ( + "archive/zip" + "encoding/base64" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/crhntr/yamlutil/yamlnode" + "gopkg.in/yaml.v3" + + "github.com/pivotal-cf/kiln/pkg/cargo" + "github.com/pivotal-cf/kiln/pkg/tile" +) + +const ( + fileMode = 0644 + dirMode = 0744 + + gitKeepFilename = ".gitkeep" + defaultGitIgnore = + /* language=gitignore */ ` +*.pivotal +releases/*.tgz +releases/*.tar.gz +*.out +` +) + +func New(outputDirectory, tilePath string, spec cargo.Kilnfile) error { + f, openErr := os.Open(tilePath) + info, statErr := os.Stat(tilePath) + if err := errors.Join(statErr, openErr); err != nil { + return err + } + defer closeAndIgnoreError(f) + return newFromReader(outputDirectory, f, info.Size(), spec) +} + +func newFromReader(outputDirectory string, r io.ReaderAt, size int64, spec cargo.Kilnfile) error { + zr, err := zip.NewReader(r, size) + if err != nil { + return err + } + return newFromFS(outputDirectory, zr, spec) +} + +func newFromFS(outputDirectory string, dir fs.FS, spec cargo.Kilnfile) error { + productTemplateBuffer, err := tile.ReadMetadataFromFS(dir) + if err != nil { + return err + } + productTemplate, err := newFromProductTemplate(outputDirectory, productTemplateBuffer) + if err != nil { + return err + } + if err := extractMigrations(outputDirectory, dir); err != nil { + return err + } + releaseLocks, err := extractReleases(outputDirectory, productTemplate, dir) + if err != nil { + return err + } + if err := newKilnfiles(outputDirectory, spec, releaseLocks); err != nil { + return err + } + baseYML, err := yaml.Marshal(productTemplate) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outputDirectory, DefaultFilepathBaseYML), baseYML, fileMode); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outputDirectory, ".gitignore"), []byte(defaultGitIgnore), fileMode); err != nil { + return err + } + return nil +} + +func newKilnfiles(outputDirectory string, spec cargo.Kilnfile, releaseTarballs []cargo.BOSHReleaseTarball) error { + var lock cargo.KilnfileLock + for _, tarball := range releaseTarballs { + lock.Releases = append(lock.Releases, cargo.BOSHReleaseTarballLock{ + Name: tarball.Manifest.Name, + Version: tarball.Manifest.Version, + SHA1: tarball.SHA1, + }) + } + slices.SortFunc(lock.Releases, func(a, b cargo.BOSHReleaseTarballLock) int { + return strings.Compare(a.Name, b.Name) + }) + spec.Releases = spec.Releases[:0] + for _, lock := range spec.Releases { + spec.Releases = append(spec.Releases, cargo.BOSHReleaseTarballSpecification{ + Name: lock.Name, + Version: lock.Version, + DeGlazeBehavior: cargo.LockPatch, + FloatAlways: false, + }) + } + kilnfileLock, err := yaml.Marshal(lock) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(outputDirectory, DefaultFilepathKilnfileLock), kilnfileLock, fileMode) +} + +func newFromProductTemplate(outputDirectory string, productTemplate []byte) (*yaml.Node, error) { + var productTemplateNode yaml.Node + if err := yaml.Unmarshal(productTemplate, &productTemplateNode); err != nil { + return &productTemplateNode, fmt.Errorf("failed to parse product template: %w", err) + } + return &productTemplateNode, errors.Join(writeIconPNG(outputDirectory, &productTemplateNode)) +} + +func extractMigrations(outputDirectory string, dir fs.FS) error { + migrations, err := fs.Glob(dir, "migrations/*.js") + if err != nil { + return err + } + for _, migration := range migrations { + outPath := filepath.Join(outputDirectory, filepath.FromSlash(migration)) + if err := copyFile(outPath, dir, migration); err != nil { + return err + + } + } + return nil +} + +func extractReleases(outputDirectory string, productTemplate *yaml.Node, dir fs.FS) ([]cargo.BOSHReleaseTarball, error) { + releases, err := fs.Glob(dir, "releases/*.tgz") + if err != nil { + return nil, err + } + var tarballs []cargo.BOSHReleaseTarball + + if err := os.MkdirAll(filepath.Join(outputDirectory, DefaultDirectoryReleases), dirMode); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(outputDirectory, DefaultDirectoryReleases, gitKeepFilename), nil, fileMode); err != nil { + return nil, err + } + for _, release := range releases { + outPath := filepath.Join(outputDirectory, filepath.FromSlash(release)) + if err := copyFile(outPath, dir, release); err != nil { + return nil, err + } + releaseTarball, err := cargo.OpenBOSHReleaseTarball(outPath) + if err != nil { + return nil, err + } + tarballs = append(tarballs, releaseTarball) + } + releasesNode, found := yamlnode.LookupKey(productTemplate, "releases") + if !found { + return nil, err + } + var releasesList []string + for _, tarball := range tarballs { + releasesList = append(releasesList, fmt.Sprintf("{{ release %q }}", tarball.Manifest.Name)) + } + return tarballs, releasesNode.Encode(&releasesList) +} + +func copyFile(out string, dir fs.FS, p string) error { + srcFile, err := dir.Open(p) + if err != nil { + return err + } + defer closeAndIgnoreError(srcFile) + + dstFile, err := os.Create(out) + if err != nil { + return err + } + defer closeAndIgnoreError(dstFile) + + _, err = io.Copy(dstFile, srcFile) + return err +} + +func writeIconPNG(outputDirectory string, productTemplate *yaml.Node) error { + iconImageNode, found := yamlnode.LookupKey(productTemplate, "icon_image") + if !found { + return fmt.Errorf("icon_image not found in product template") + } + iconImage, err := base64.StdEncoding.DecodeString(strings.TrimSpace(iconImageNode.Value)) + if err != nil { + return fmt.Errorf("failed to decode icon_image: %w", err) + } + iconImageNode.Value = `{{ icon }}` + return os.WriteFile(filepath.Join(outputDirectory, DefaultFilepathIconImage), iconImage, fileMode) +} diff --git a/pkg/bake/new_internal_test.go b/pkg/bake/new_internal_test.go new file mode 100644 index 00000000..efd9a7c1 --- /dev/null +++ b/pkg/bake/new_internal_test.go @@ -0,0 +1,79 @@ +package bake + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/pivotal-cf/kiln/pkg/cargo" +) + +func Test_shatterFromFS(t *testing.T) { + t.Run("missing product template", func(t *testing.T) { + zip := os.DirFS(t.TempDir()) + output := t.TempDir() + err := newFromFS(output, zip, cargo.Kilnfile{}) + require.ErrorContains(t, err, "metadata file not found") + }) +} + +func Test_shatterProductTemplate(t *testing.T) { + t.Run("when the product template is valid", func(t *testing.T) { + const productTemplateYAML = `}:` + output := t.TempDir() + _, err := newFromProductTemplate(output, []byte(productTemplateYAML)) + require.ErrorContains(t, err, "failed to parse product template") + }) +} + +func Test_writeIconPNG(t *testing.T) { + t.Run("when the field is valid base64", func(t *testing.T) { + const productTemplateYAML = + /* language=yaml */ `--- +icon_image: cmVsYXRpbmcgdG8gb3Igb2YgdGhlIG5hdHVyZSBvZiBhbiBpY29u +` + output := t.TempDir() + productTemplate := parseProductTemplateNode(t, productTemplateYAML) + err := writeIconPNG(output, productTemplate) + require.NoError(t, err) + + expOutput := filepath.Join(output, DefaultFilepathIconImage) + require.FileExists(t, expOutput) + buf, err := os.ReadFile(expOutput) + require.NoError(t, err) + require.Equal(t, "relating to or of the nature of an icon", string(buf), + "it gets written to the file") + }) + + t.Run("missing icon", func(t *testing.T) { + const productTemplateYAML = + /* language=yaml */ `--- +ping: pong +` + output := t.TempDir() + productTemplate := parseProductTemplateNode(t, productTemplateYAML) + err := writeIconPNG(output, productTemplate) + require.ErrorContains(t, err, "icon_image not found in product template") + }) + + t.Run("not base64", func(t *testing.T) { + const productTemplateYAML = + /* language=yaml */ `--- +icon_image: $ +` + output := t.TempDir() + productTemplate := parseProductTemplateNode(t, productTemplateYAML) + err := writeIconPNG(output, productTemplate) + require.ErrorContains(t, err, "failed to decode icon_image") + }) +} + +func parseProductTemplateNode(t *testing.T, productTemplateYAML string) *yaml.Node { + t.Helper() + var productTemplate yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(productTemplateYAML), &productTemplate)) + return &productTemplate +} diff --git a/pkg/bake/new_test.go b/pkg/bake/new_test.go new file mode 100644 index 00000000..bcce80cf --- /dev/null +++ b/pkg/bake/new_test.go @@ -0,0 +1,45 @@ +package bake_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/pivotal-cf/kiln/pkg/bake" + "github.com/pivotal-cf/kiln/pkg/cargo" +) + +func TestNew(t *testing.T) { + outputDirectory := t.TempDir() + + err := bake.New(outputDirectory, filepath.FromSlash("testdata/tile-0.1.3.pivotal"), cargo.Kilnfile{}) + require.NoError(t, err) + + iconFileContents, _ := os.ReadFile(filepath.Join(outputDirectory, bake.DefaultFilepathIconImage)) + require.Equal(t, "some icon\n", string(iconFileContents), "it writes the icon image") + + baseYML, _ := os.ReadFile(filepath.Join(outputDirectory, bake.DefaultFilepathBaseYML)) + var productTemplate yaml.Node + require.NoError(t, yaml.Unmarshal(baseYML, &productTemplate)) + + // do kiln bake in outputDirectory and see if it outputs the same tile +} + +func TestNew_bad_inputs(t *testing.T) { + t.Run("not a zip file", func(t *testing.T) { + outputDirectory := t.TempDir() + const notAZipFile = "new_test.go" + err := bake.New(outputDirectory, notAZipFile, cargo.Kilnfile{}) + require.ErrorContains(t, err, "zip") + }) + + t.Run("file does not exist", func(t *testing.T) { + outputDirectory := t.TempDir() + + err := bake.New(outputDirectory, filepath.FromSlash("testdata/banana.pivotal"), cargo.Kilnfile{}) + require.ErrorContains(t, err, "no such file") + }) +} diff --git a/pkg/bake/testdata/tile-0.1.3.pivotal b/pkg/bake/testdata/tile-0.1.3.pivotal new file mode 100644 index 0000000000000000000000000000000000000000..5d75019e3aac1fc59475c6947e46ab5ea2122a48 GIT binary patch literal 9439 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 literal 0 HcmV?d00001