From 8e6a5071ae72e1d02f561f48392209ce0ab4b11a Mon Sep 17 00:00:00 2001 From: metron2 Date: Fri, 11 Jun 2021 18:33:09 -0400 Subject: [PATCH] bosh take-out --- cmd/cmd.go | 4 + cmd/opts/opts.go | 22 ++- cmd/opts/opts_test.go | 54 ++++++ cmd/take_out.go | 77 ++++++++ cmd/take_out_test.go | 256 +++++++++++++++++++++++++++ takeout/interfaces.go | 36 ++++ takeout/opentry.go | 7 + takeout/takeoutfakes/takeoutfakes.go | 26 +++ takeout/takeoutrelease.go | 154 ++++++++++++++++ 9 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 cmd/take_out.go create mode 100644 cmd/take_out_test.go create mode 100644 takeout/interfaces.go create mode 100644 takeout/opentry.go create mode 100644 takeout/takeoutfakes/takeoutfakes.go create mode 100644 takeout/takeoutrelease.go diff --git a/cmd/cmd.go b/cmd/cmd.go index d99b1cf17..9e60a47f0 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "github.com/cloudfoundry/bosh-cli/takeout" "path/filepath" "github.com/cppforlife/go-patch/patch" @@ -155,6 +156,9 @@ func (c Cmd) Execute() (cmdErr error) { case *DeleteDeploymentOpts: return NewDeleteDeploymentCmd(deps.UI, c.deployment()).Run(*opts) + case *TakeOutOpts: + return NewTakeOutCmd(deps.UI, takeout.RealUtensils{}).Run(*opts) + case *ReleasesOpts: return NewReleasesCmd(deps.UI, c.director()).Run() diff --git a/cmd/opts/opts.go b/cmd/opts/opts.go index 2300ceed6..8b45ca6e3 100644 --- a/cmd/opts/opts.go +++ b/cmd/opts/opts.go @@ -90,6 +90,7 @@ type BoshOpts struct { Manifest ManifestOpts `command:"manifest" alias:"man" description:"Show deployment manifest"` Interpolate InterpolateOpts `command:"interpolate" alias:"int" description:"Interpolates variables into a manifest"` + TakeOut TakeOutOpts `command:"take-out" description:"prepares dependencies for offline use"` // Events Events EventsOpts `command:"events" description:"List events"` @@ -281,7 +282,7 @@ type TaskOpts struct { Event bool `long:"event" description:"Track event log"` CPI bool `long:"cpi" description:"Track CPI log"` Debug bool `long:"debug" description:"Track debug log"` - Result bool `long:"result" description:"Track result log"` + Result bool `longTake:"result" description:"Track result log"` All bool `long:"all" short:"a" description:"Include all task types (ssh, logs, vms, etc)"` Deployment string @@ -358,6 +359,25 @@ type InterpolateArgs struct { Manifest FileBytesArg `positional-arg-name:"PATH" description:"Path to a template that will be interpolated"` } +type TakeOutOpts struct { + Args TakeOutArgs `positional-args:"true" required:"true"` + MirrorPrefix string `long:"mirror-prefix" short:"m" description:"Mirror prefix" optional:"true" default:"file:"` + StemcellType string `long:"stemcell-type" short:"t" description:"Stemcell type" optional:"true" default:"vsphere-esxi"` + + VarFlags + OpsFlags + + VarErrors bool `long:"var-errs" description:"Expect all variables to be found, otherwise error"` + VarErrorsUnused bool `long:"var-errs-unused" description:"Expect all variables to be used, otherwise error"` + + cmd +} + +type TakeOutArgs struct { + Name string `positional-arg-name:"NAME" description:"file name of ops file"` + Manifest FileBytesArg `positional-arg-name:"PATH" description:"Path to a template for take_out"` +} + // Config type ConfigOpts struct { diff --git a/cmd/opts/opts_test.go b/cmd/opts/opts_test.go index 82b005c2a..4ffbb50cd 100644 --- a/cmd/opts/opts_test.go +++ b/cmd/opts/opts_test.go @@ -1448,6 +1448,60 @@ var _ = Describe("Opts", func() { }) }) + Describe("TakeOutOpts", func() { + var opts TakeOutOpts + + It("has Args", func() { + Expect(getStructTagForName("Args", &opts)).To(Equal(`positional-args:"true" required:"true"`)) + }) + + Describe("MirrorPrefix", func() { + It("contains desired values", func() { + Expect(getStructTagForName("MirrorPrefix", &opts)).To(Equal( + `long:"mirror-prefix" short:"m" description:"Mirror prefix" optional:"true" default:"file:"`, + )) + }) + }) + + It("has VarErrors", func() { + Expect(getStructTagForName("VarErrors", &opts)).To(Equal( + `long:"var-errs" description:"Expect all variables to be found, otherwise error"`, + )) + }) + + It("has VarErrorsUnused", func() { + Expect(getStructTagForName("VarErrorsUnused", &opts)).To(Equal( + `long:"var-errs-unused" description:"Expect all variables to be used, otherwise error"`, + )) + }) + + }) + + Describe("TakeOutArgs", func() { + var opts *TakeOutArgs + + BeforeEach(func() { + opts = &TakeOutArgs{} + }) + + Describe("Name", func() { + It("contains desired values", func() { + Expect(getStructTagForName("Name", opts)).To(Equal( + `positional-arg-name:"NAME" description:"file name of ops file"`, + )) + }) + }) + + Describe("Manifest", func() { + It("contains desired values", func() { + Expect(getStructTagForName("Manifest", opts)).To(Equal( + `positional-arg-name:"PATH" description:"Path to a template for take_out"`, + )) + }) + }) + + }) + Describe("UpdateCloudConfigOpts", func() { var opts *UpdateCloudConfigOpts diff --git a/cmd/take_out.go b/cmd/take_out.go new file mode 100644 index 000000000..e5e4fbdf6 --- /dev/null +++ b/cmd/take_out.go @@ -0,0 +1,77 @@ +package cmd + +import ( + . "github.com/cloudfoundry/bosh-cli/cmd/opts" + boshtpl "github.com/cloudfoundry/bosh-cli/director/template" + "github.com/cloudfoundry/bosh-cli/takeout" + boshui "github.com/cloudfoundry/bosh-cli/ui" + bosherr "github.com/cloudfoundry/bosh-utils/errors" + "gopkg.in/yaml.v2" + "os" +) + +type TakeOutCmd struct { + ui boshui.UI + to takeout.Utensils +} + +func NewTakeOutCmd(ui boshui.UI, d takeout.Utensils) TakeOutCmd { + return TakeOutCmd{ui: ui, to: d} +} + +func (c TakeOutCmd) Run(opts TakeOutOpts) error { + tpl := boshtpl.NewTemplate(opts.Args.Manifest.Bytes) + + bytes, err := tpl.Evaluate(opts.VarFlags.AsVariables(), opts.OpsFlags.AsOp(), boshtpl.EvaluateOpts{}) + if err != nil { + return bosherr.WrapErrorf(err, "Evaluating manifest") + } + if _, err := os.Stat(opts.Args.Name); os.IsExist(err) { + return bosherr.WrapErrorf(err, "Takeout op name exists") + } + deployment, err := c.to.ParseDeployment(bytes) + + if err != nil { + return bosherr.WrapError(err, "Problem parsing deployment") + } + c.ui.PrintLinef("Processing releases for offline use") + var releaseChanges []takeout.OpEntry + for _, r := range deployment.Releases { + if r.URL == "" { + c.ui.PrintLinef("Release does not have a URL for take_out; Name: %s / %s", r.Name, r.Version) + return bosherr.WrapErrorf(nil, "Provide an opsfile that has a URL or removes this release") // TODO + } else { + o, err := c.to.TakeOutRelease(r, c.ui, opts.MirrorPrefix) + if err != nil { + return err + } + releaseChanges = append(releaseChanges, o) + } + } + for _, s := range deployment.Stemcells { + err := c.to.TakeOutStemcell(s, c.ui, opts.StemcellType) + if err != nil { + + } + } + + y, _ := yaml.Marshal(releaseChanges) + c.ui.PrintLinef("Writing take_out operation to file: " + opts.Args.Name) + takeoutOp, err := os.Create(opts.Args.Name) + if err != nil { + return err + } + if takeoutOp != nil { + defer func() { + if ferr := takeoutOp.Close(); ferr != nil { + err = ferr + } + }() + } + _, err = takeoutOp.WriteString("---\n") + _, err = takeoutOp.WriteString(string(y)) + if err != nil { + return err + } + return nil +} diff --git a/cmd/take_out_test.go b/cmd/take_out_test.go new file mode 100644 index 000000000..a6153a36a --- /dev/null +++ b/cmd/take_out_test.go @@ -0,0 +1,256 @@ +package cmd_test + +import ( + fakesys "github.com/cloudfoundry/bosh-utils/system/fakes" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + . "github.com/cloudfoundry/bosh-cli/cmd" + fakeout "github.com/cloudfoundry/bosh-cli/takeout/takeoutfakes" + fakeui "github.com/cloudfoundry/bosh-cli/ui/fakes" +) + +// bosh take_out cf-6-offline-sources.yml manifest.yml -o use-compiled-release.yml +var _ = Describe("TakeOutCmd", func() { + var ( + ui *fakeui.FakeUI + command TakeOutCmd + fs *fakesys.FakeFileSystem + ) + + BeforeEach(func() { + ui = &fakeui.FakeUI{} + + fs = fakesys.NewFakeFileSystem() + + command = NewTakeOutCmd(ui, fakeout.FakeUtensils{}) + }) + + Describe("Run", func() { + var ( + opts TakeOutOpts + ) + + BeforeEach(func() { + file, err := fs.TempFile("take-out") + if err != nil { + panic(err) + } + + opts = TakeOutOpts{ + Args: TakeOutArgs{ + Name: file.Name(), + Manifest: FileBytesArg{Bytes: []byte("name: dep")}, + }, + } + + }) + + act := func() error { return command.Run(opts) } + + It("manifest", func() { + err := act() + Expect(err).ToNot(HaveOccurred()) + + //Expect(deployment.UpdateCallCount()).To(Equal(1)) + + //bytes, updateOpts := deployment.UpdateArgsForCall(0) + //Expect(bytes).To(Equal([]byte("name: dep\n"))) + //Expect(updateOpts).To(Equal(boshdir.UpdateOpts{})) + }) + // + // It("deploys manifest allowing to recreate, recreate persistent disks, fix, and skip drain", func() { + // opts.RecreatePersistentDisks = true + // opts.Recreate = true + // opts.Fix = true + // opts.SkipDrain = boshdir.SkipDrains{boshdir.SkipDrain{All: true}} + // + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // + // Expect(deployment.UpdateCallCount()).To(Equal(1)) + // + // bytes, updateOpts := deployment.UpdateArgsForCall(0) + // Expect(bytes).To(Equal([]byte("name: dep\n"))) + // Expect(updateOpts).To(Equal(boshdir.UpdateOpts{ + // RecreatePersistentDisks: true, + // Recreate: true, + // Fix: true, + // SkipDrain: boshdir.SkipDrains{boshdir.SkipDrain{All: true}}, + // })) + // }) + // + // It("deploys manifest allowing to dry_run", func() { + // opts.DryRun = true + // + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // + // Expect(deployment.UpdateCallCount()).To(Equal(1)) + // + // bytes, updateOpts := deployment.UpdateArgsForCall(0) + // Expect(bytes).To(Equal([]byte("name: dep\n"))) + // Expect(updateOpts).To(Equal(boshdir.UpdateOpts{ + // DryRun: true, + // })) + // }) + // + // It("deploys templated manifest", func() { + // opts.Args.Manifest = FileBytesArg{ + // Bytes: []byte("name: dep\nname1: ((name1))\nname2: ((name2))\n"), + // } + // + // opts.VarKVs = []boshtpl.VarKV{ + // {Name: "name1", Value: "val1-from-kv"}, + // } + // + // opts.VarsFiles = []boshtpl.VarsFileArg{ + // {Vars: boshtpl.StaticVariables(map[string]interface{}{"name1": "val1-from-file"})}, + // {Vars: boshtpl.StaticVariables(map[string]interface{}{"name2": "val2-from-file"})}, + // } + // + // opts.OpsFiles = []OpsFileArg{ + // { + // Ops: patch.Ops([]patch.Op{ + // patch.ReplaceOp{Path: patch.MustNewPointerFromString("/xyz?"), Value: "val"}, + // }), + // }, + // } + // + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // + // Expect(deployment.UpdateCallCount()).To(Equal(1)) + // + // bytes, _ := deployment.UpdateArgsForCall(0) + // Expect(bytes).To(Equal([]byte("name: dep\nname1: val1-from-kv\nname2: val2-from-file\nxyz: val\n"))) + // }) + // + // It("does not deploy if name specified in the manifest does not match deployment's name", func() { + // opts.Args.Manifest = FileBytesArg{ + // Bytes: []byte("name: other-name"), + // } + // + // err := act() + // Expect(err).To(HaveOccurred()) + // Expect(err.Error()).To(Equal( + // "Expected manifest to specify deployment name 'dep' but was 'other-name'")) + // + // Expect(deployment.UpdateCallCount()).To(Equal(0)) + // }) + // + // It("uploads releases provided in the manifest after manifest has been interpolated", func() { + // opts.Args.Manifest = FileBytesArg{ + // Bytes: []byte("name: dep\nbefore-upload-manifest: ((key))"), + // } + // + // opts.VarKVs = []boshtpl.VarKV{ + // {Name: "key", Value: "key-val"}, + // } + // + // releaseUploader.UploadReleasesReturns([]byte("after-upload-manifest"), nil) + // + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // + // bytes := releaseUploader.UploadReleasesArgsForCall(0) + // Expect(bytes).To(Equal([]byte("before-upload-manifest: key-val\nname: dep\n"))) + // + // Expect(deployment.UpdateCallCount()).To(Equal(1)) + // + // bytes, _ = deployment.UpdateArgsForCall(0) + // Expect(bytes).To(Equal([]byte("after-upload-manifest"))) + // }) + // + // It("returns error and does not deploy if uploading releases fails", func() { + // opts.Args.Manifest = FileBytesArg{ + // Bytes: []byte(` + //name: dep + //releases: + //- name: capi + // sha1: capi-sha1 + // url: https://capi-url + // version: 1+capi + //`), + // } + // + // releaseUploader.UploadReleasesReturns(nil, errors.New("fake-err")) + // + // err := act() + // Expect(err).To(HaveOccurred()) + // Expect(err.Error()).To(ContainSubstring("fake-err")) + // + // Expect(deployment.UpdateCallCount()).To(Equal(0)) + // }) + // + // It("uploads releases but does not deploy if confirmation is rejected", func() { + // opts.Args.Manifest = FileBytesArg{ + // Bytes: []byte(` + //name: dep + //releases: + //- name: capi + // sha1: capi-sha1 + // url: /capi-url + // version: create + //`), + // } + // + // ui.AskedConfirmationErr = errors.New("stop") + // + // err := act() + // Expect(err).To(HaveOccurred()) + // Expect(err.Error()).To(ContainSubstring("stop")) + // + // Expect(releaseUploader.UploadReleasesCallCount()).To(Equal(1)) + // Expect(deployment.UpdateCallCount()).To(Equal(0)) + // }) + // + // It("returns an error if diffing failed", func() { + // deployment.DiffReturns(boshdir.DeploymentDiff{}, errors.New("Fetching diff result")) + // + // err := act() + // Expect(err).To(HaveOccurred()) + // }) + // + // It("gets the diff from the deployment", func() { + // diff := [][]interface{}{ + // []interface{}{"some line that stayed", nil}, + // []interface{}{"some line that was added", "added"}, + // []interface{}{"some line that was removed", "removed"}, + // } + // + // expectedDiff := boshdir.NewDeploymentDiff(diff, nil) + // deployment.DiffReturns(expectedDiff, nil) + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // Expect(deployment.DiffCallCount()).To(Equal(1)) + // Expect(ui.Said).To(ContainElement(" some line that stayed\n")) + // Expect(ui.Said).To(ContainElement("+ some line that was added\n")) + // Expect(ui.Said).To(ContainElement("- some line that was removed\n")) + // }) + // + // It("deploys manifest with diff context", func() { + // context := map[string]interface{}{ + // "cloud_config_id": 2, + // "runtime_config_id": 3, + // } + // expectedDiff := boshdir.NewDeploymentDiff(nil, context) + // + // deployment.DiffReturns(expectedDiff, nil) + // err := act() + // Expect(err).ToNot(HaveOccurred()) + // Expect(deployment.DiffCallCount()).To(Equal(1)) + // + // _, updateOptions := deployment.UpdateArgsForCall(0) + // Expect(updateOptions.Diff).To(Equal(expectedDiff)) + // }) + // + // It("returns error if deploying failed", func() { + // deployment.UpdateReturns(errors.New("fake-err")) + // + // err := act() + // Expect(err).To(HaveOccurred()) + // Expect(err.Error()).To(ContainSubstring("fake-err")) + // }) + }) +}) diff --git a/takeout/interfaces.go b/takeout/interfaces.go new file mode 100644 index 000000000..f2d3a684b --- /dev/null +++ b/takeout/interfaces.go @@ -0,0 +1,36 @@ +package takeout + +import ( + boshdir "github.com/cloudfoundry/bosh-cli/director" + boshui "github.com/cloudfoundry/bosh-cli/ui" +) + +type Utensils interface { + DeploymentReader + ReleaseDownloader + OpFileGenerator + StemcellDownloader +} +type Manifest struct { + Name string + + Releases []boshdir.ManifestRelease + Stemcells []boshdir.ManifestReleaseStemcell +} + +type DeploymentReader interface { + ParseDeployment(bytes []byte) (Manifest, error) +} + +type ReleaseDownloader interface { + RetrieveRelease(r boshdir.ManifestRelease, ui boshui.UI, localFileName string) (err error) +} + +type StemcellDownloader interface { + TakeOutStemcell(s boshdir.ManifestReleaseStemcell, ui boshui.UI, stemCellType string) (err error) + RetrieveStemcell(ui boshui.UI, s boshdir.ManifestReleaseStemcell, localFileName string, stemCellType string) (err error) +} + +type OpFileGenerator interface { + TakeOutRelease(r boshdir.ManifestRelease, ui boshui.UI, mirrorPrefix string) (entry OpEntry, err error) +} diff --git a/takeout/opentry.go b/takeout/opentry.go new file mode 100644 index 000000000..34160a19b --- /dev/null +++ b/takeout/opentry.go @@ -0,0 +1,7 @@ +package takeout + +type OpEntry struct { + Type string + Path string + Value string +} diff --git a/takeout/takeoutfakes/takeoutfakes.go b/takeout/takeoutfakes/takeoutfakes.go new file mode 100644 index 000000000..f7a4ef8c9 --- /dev/null +++ b/takeout/takeoutfakes/takeoutfakes.go @@ -0,0 +1,26 @@ +package takeoutfakes + +import ( + "errors" + "fmt" + boshdir "github.com/cloudfoundry/bosh-cli/director" + "github.com/cloudfoundry/bosh-cli/takeout" + boshui "github.com/cloudfoundry/bosh-cli/ui" +) + +type FakeUtensils struct { + takeout.RealUtensils + RetrieveMap map[boshdir.ManifestRelease]string +} + +func (c FakeUtensils) Reset() { + c.RetrieveMap = make(map[boshdir.ManifestRelease]string) +} + +func (c FakeUtensils) RetrieveRelease(r boshdir.ManifestRelease, ui boshui.UI, localFileName string) (err error) { + if val, ok := c.RetrieveMap[r]; ok { + return errors.New(fmt.Sprintf("Release already in download map: %s", val)) + } + c.RetrieveMap[r] = localFileName + return +} diff --git a/takeout/takeoutrelease.go b/takeout/takeoutrelease.go new file mode 100644 index 000000000..5e7d5d8ef --- /dev/null +++ b/takeout/takeoutrelease.go @@ -0,0 +1,154 @@ +package takeout + +import ( + "crypto/sha1" + "fmt" + boshdir "github.com/cloudfoundry/bosh-cli/director" + boshui "github.com/cloudfoundry/bosh-cli/ui" + bosherr "github.com/cloudfoundry/bosh-utils/errors" + "gopkg.in/yaml.v2" + "io" + "net/http" + "os" + "regexp" +) + +var BadChar = regexp.MustCompile("[?=\"]") + +type RealUtensils struct { +} + +func (c RealUtensils) RetrieveRelease(r boshdir.ManifestRelease, ui boshui.UI, localFileName string) (err error) { + ui.PrintLinef("Downloading release: %s / %s -> %s", r.Name, r.Version, localFileName) + + tempFileName := localFileName + ".download" + + + resp, err := http.Get(r.URL) + + if resp != nil { + defer func() { + if ferr := resp.Body.Close(); ferr != nil { + err = ferr + } + }() + } + if err != nil { + return err + } + + // Create the file + out, err := os.Create(tempFileName) + if out != nil { + defer func() { + if ferr := out.Close(); ferr != nil { + err = ferr + } + }() + } + if err != nil { + return err + } + + // Write the body to file + hash := sha1.New() + _, err = io.Copy(out, io.TeeReader(resp.Body, hash)) + actualSha1 := fmt.Sprintf("%x", hash.Sum(nil)) + if err != nil { + return err + } + if len(r.SHA1) == 40 { + if actualSha1 != r.SHA1 { + return bosherr.Errorf("sha1 mismatch %s (a:%s, e:%s)", localFileName, actualSha1, r.SHA1) + } + } + err = os.Rename(tempFileName, localFileName) + if err != nil { + return err + } + return nil +} + +func (c RealUtensils) TakeOutStemcell(s boshdir.ManifestReleaseStemcell, ui boshui.UI, stemCellType string) (err error) { + + localFileName := fmt.Sprintf("bosh-%s-%s-go_agent-stemcell_v%s.tgz", stemCellType, s.OS, s.Version) + + if _, err := os.Stat(localFileName); os.IsNotExist(err) { + + err := c.RetrieveStemcell(ui, s, localFileName, stemCellType) + if err != nil { + return err + } + } else { + ui.PrintLinef("Stemcell present: %s", localFileName) + } + return +} + +func (c RealUtensils) RetrieveStemcell(ui boshui.UI, s boshdir.ManifestReleaseStemcell, localFileName string, stemCellType string) (err error) { + ui.PrintLinef("Downloading stemcell: %s / %s -> %s", s.OS, s.Version, localFileName) + url := fmt.Sprintf("https://bosh.io/d/stemcells/bosh-%s-%s-go_agent?v=%s", stemCellType, s.OS, s.Version) + ui.PrintLinef("Trying %s", url) + resp, err := http.Get(url) + if resp != nil { + defer func() { + if ferr := resp.Body.Close(); ferr != nil { + err = ferr + } + }() + } + if err != nil { + return err + } + // Create the file + out, err := os.Create(localFileName) + if err != nil { + return err + } + defer func() { + if ferr := out.Close(); ferr != nil { + err = ferr + } + }() + // Write the body to file + hash := sha1.New() + _, err = io.Copy(out, io.TeeReader(resp.Body, hash)) + actualSha1 := fmt.Sprintf("%x", hash.Sum(nil)) + if err != nil { + return err + } + ui.PrintLinef("Stemcell %s SHA1:%s", localFileName, actualSha1) + return err +} + +func (c RealUtensils) TakeOutRelease(r boshdir.ManifestRelease, ui boshui.UI, mirrorPrefix string) (entry OpEntry, err error) { + + // generate a local file name that's safe + localFileName := BadChar.ReplaceAllString(fmt.Sprintf("%s_v%s.tgz", r.Name, r.Version), "_") + + if _, err := os.Stat(localFileName); os.IsNotExist(err) { + err = c.RetrieveRelease(r, ui, localFileName) + if err != nil { + return OpEntry{}, err + } + } else { + ui.PrintLinef("Release present: %s / %s -> %s", r.Name, r.Version, localFileName) + } + if len(r.Name) > 0 { + path := fmt.Sprintf("/releases/name=%s/url", r.Name) + localFile := fmt.Sprintf("%s%s", mirrorPrefix, localFileName) + entry = OpEntry{Type: "replace", Path: path, Value: localFile} + } + return entry, err +} + +func (c RealUtensils) ParseDeployment(bytes []byte) (Manifest, error) { + var deployment Manifest + + err := yaml.Unmarshal(bytes, &deployment) + if err != nil { + return deployment, err + } + + return deployment, nil +}