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 00000000..5d75019e Binary files /dev/null and b/pkg/bake/testdata/tile-0.1.3.pivotal differ