diff --git a/tools/updater/cmd/main.go b/tools/updater/cmd/main.go index 849fc4a8f..e41d75875 100644 --- a/tools/updater/cmd/main.go +++ b/tools/updater/cmd/main.go @@ -5,15 +5,13 @@ package main import ( "encoding/json" "fmt" + "io" "os" + "path" "strings" - "cuelang.org/go/cue" - "cuelang.org/go/cue/cuecontext" - "cuelang.org/go/cue/format" "github.com/alecthomas/kong" "github.com/input-output-hk/catalyst-ci/tools/updater/pkg" - ch "github.com/mheers/cue-helper/pkg/value" "github.com/spf13/afero" ) @@ -22,6 +20,12 @@ var cli struct { Update updateCmd `cmd:"" help:"Overrides a target path in a CUE file with the given value."` } +type updateCmd struct { + Bundle updateBundleCmd `cmd:"" help:"Overrides a target path in a Timoni bundle values field with the given value."` + Deployments updateDeploymentsCmd `cmd:"" help:"Performs a mass update on Timoni bundle files using given input data."` + File updateFileCmd `cmd:"" help:"Overrides a target path in a CUE file with the given value."` +} + type scanCmd struct { Path string `arg:"" help:"The path to scan for deployment files." type:"existingdir" required:"true"` Template []string `short:"t" help:"A key/value pair used to override constant values in deployment configurations."` @@ -53,36 +57,105 @@ func (c *scanCmd) Run() error { return nil } -type updateCmd struct { - BundleFile string `type:"existingfile" short:"b" help:"Path to the Timoni bundle file to modify." required:"true"` - Path string `arg:"" help:"A dot separated path to the value to override (must already exist)."` - Value string `arg:"" help:"The value to override the value at the path with."` +type updateBundleCmd struct { + BundleFile string `type:"existingfile" short:"f" help:"Path to the bundle file to update." required:"true"` + Instance string `short:"i" help:"The instance to update." required:"true"` + InPlace bool `help:"Update the file in place."` + Path string `arg:"" help:"A dot separated path to the value to update (must already exist)."` + Value string `arg:"" help:"The value to updates the value at the path with."` } -func (c *updateCmd) Run() error { - cuectx := cuecontext.New() - v, err := pkg.ReadFile(cuectx, c.BundleFile, afero.NewOsFs()) +func (c *updateBundleCmd) Run() error { + path := fmt.Sprintf("bundle.instances.%s.values.%s", c.Instance, c.Path) + + src, err := pkg.UpdateFile(c.BundleFile, path, c.Value, afero.NewOsFs()) if err != nil { return err } - if !v.LookupPath(cue.ParsePath(c.Path)).Exists() { - return fmt.Errorf("path %q does not exist", c.Path) + if c.InPlace { + if err := os.WriteFile(c.BundleFile, src, 0644); err != nil { //nolint:gosec + return fmt.Errorf("failed to write file %q: %v", c.BundleFile, err) + } + } else { + fmt.Print(string(src)) } - v, err = ch.Replace(v, c.Path, c.Value) - if err != nil { - return err + return nil +} + +type updateDeploymentsCmd struct { + RootDir string `arg:"" type:"existingdir" help:"The root directory where Timoni bundle files are located." required:"true"` + Environment string `short:"e" help:"The environment to update." required:"true"` + Input string `short:"i" help:"The path to the input data file (can be passed via stdin)."` +} + +func (c *updateDeploymentsCmd) Run() error { + var data []byte + + _, err := os.Stat(c.Input) + if os.IsNotExist(err) { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read input data: %v", err) + } + } else { + data, err = os.ReadFile(c.Input) + if err != nil { + return fmt.Errorf("failed to read input data from %s: %v", c.Input, err) + } + } + + overrides := []pkg.OverrideConfig{} + if err := json.Unmarshal(data, &overrides); err != nil { + return fmt.Errorf("failed to parse input data: %v", err) + } + + for _, override := range overrides { + bundlePath := path.Join(c.RootDir, c.Environment, override.App, "bundle.cue") + if _, err := os.Stat(bundlePath); os.IsNotExist(err) { + return fmt.Errorf("bundle file %q does not exist", bundlePath) + } + + var path string + if override.Instance == "" { + path = fmt.Sprintf("bundle.instances.%s.values.%s", override.App, override.Path) + } else { + path = fmt.Sprintf("bundle.instances.%s.values.%s", override.Instance, override.Path) + } + + src, err := pkg.UpdateFile(bundlePath, path, override.Value, afero.NewOsFs()) + if err != nil { + return fmt.Errorf("failed to update bundle file %q: %v", bundlePath, err) + } + + if err := os.WriteFile(bundlePath, src, 0644); err != nil { //nolint:gosec + return fmt.Errorf("failed to write bundle file %q: %v", bundlePath, err) + } } - node := v.Syntax(cue.Final(), cue.Concrete(true), cue.Docs(true)) - src, err := format.Node(node) + return nil +} + +type updateFileCmd struct { + File string `type:"existingfile" short:"f" help:"Path to the CUE file to update." required:"true"` + InPlace bool `help:"Update the file in place."` + Path string `arg:"" help:"A dot separated path to the value to update (must already exist)."` + Value string `arg:"" help:"The value to updates the value at the path with."` +} + +func (c *updateFileCmd) Run() error { + src, err := pkg.UpdateFile(c.File, c.Path, c.Value, afero.NewOsFs()) if err != nil { return err } - if err := os.WriteFile(c.BundleFile, src, 0644); err != nil { //nolint:gosec - return fmt.Errorf("failed to write file %q: %v", c.BundleFile, err) + if c.InPlace { + if err := os.WriteFile(c.File, src, 0644); err != nil { //nolint:gosec + return fmt.Errorf("failed to write file %q: %v", c.File, err) + } + } else { + fmt.Print(string(src)) } return nil diff --git a/tools/updater/pkg/cue.go b/tools/updater/pkg/cue.go index 29d2eaf3f..109d80cea 100644 --- a/tools/updater/pkg/cue.go +++ b/tools/updater/pkg/cue.go @@ -6,12 +6,15 @@ import ( "fmt" "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/format" + ch "github.com/mheers/cue-helper/pkg/value" "github.com/spf13/afero" ) // ReadFile reads a CUE file and returns a cue.Value. -func ReadFile(ctx *cue.Context, path string, os afero.Fs) (cue.Value, error) { - contents, err := afero.ReadFile(os, path) +func ReadFile(ctx *cue.Context, path string, fs afero.Fs) (cue.Value, error) { + contents, err := afero.ReadFile(fs, path) if err != nil { return cue.Value{}, fmt.Errorf("failed to read file %q: %w", path, err) } @@ -23,3 +26,28 @@ func ReadFile(ctx *cue.Context, path string, os afero.Fs) (cue.Value, error) { return v, nil } + +// UpdateFile updates a CUE file at the given path with the given value and returns the updated file contents. +func UpdateFile(filePath, path string, value interface{}, fs afero.Fs) ([]byte, error) { + v, err := ReadFile(cuecontext.New(), filePath, fs) + if err != nil { + return nil, err + } + + if !v.LookupPath(cue.ParsePath(path)).Exists() { + return nil, fmt.Errorf("path %q does not exist", path) + } + + v, err = ch.Replace(v, path, value) + if err != nil { + return nil, fmt.Errorf("failed to replace value at path %q: %w", path, err) + } + + node := v.Syntax(cue.Final(), cue.Concrete(true), cue.Docs(true)) + src, err := format.Node(node) + if err != nil { + return nil, fmt.Errorf("failed to format CUE file: %w", err) + } + + return src, nil +} diff --git a/tools/updater/pkg/cue_test.go b/tools/updater/pkg/cue_test.go index 738596f00..3dc8c615e 100644 --- a/tools/updater/pkg/cue_test.go +++ b/tools/updater/pkg/cue_test.go @@ -3,6 +3,7 @@ package pkg_test // cspell: words afero cuelang cuecontext import ( + "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" "github.com/input-output-hk/catalyst-ci/tools/updater/pkg" . "github.com/onsi/ginkgo/v2" @@ -12,19 +13,19 @@ import ( var _ = Describe("Cue", func() { Describe("ReadFile", func() { - var os afero.Fs + var fs afero.Fs BeforeEach(func() { - os = afero.NewMemMapFs() + fs = afero.NewMemMapFs() }) When("the file exists", func() { It("returns a cue.Value", func() { - err := afero.WriteFile(os, "foo.cue", []byte("foo: 1"), 0644) + err := afero.WriteFile(fs, "foo.cue", []byte("foo: 1"), 0644) Expect(err).ToNot(HaveOccurred()) ctx := cuecontext.New() - v, err := pkg.ReadFile(ctx, "foo.cue", os) + v, err := pkg.ReadFile(ctx, "foo.cue", fs) Expect(err).ToNot(HaveOccurred()) Expect(v).ToNot(BeNil()) }) @@ -34,9 +35,51 @@ var _ = Describe("Cue", func() { It("returns an error", func() { ctx := cuecontext.New() - _, err := pkg.ReadFile(ctx, "foo.cue", os) + _, err := pkg.ReadFile(ctx, "foo.cue", fs) Expect(err).To(HaveOccurred()) }) }) }) + + Describe("UpdateFile", func() { + var fs afero.Fs + + When("updating a file", func() { + BeforeEach(func() { + fs = afero.NewMemMapFs() + }) + + When("the file exists", func() { + BeforeEach(func() { + err := afero.WriteFile(fs, "foo.cue", []byte("foo: 1"), 0644) + Expect(err).ToNot(HaveOccurred()) + }) + + When("the path exists", func() { + It("updates the file", func() { + src, err := pkg.UpdateFile("foo.cue", "foo", 2, fs) + Expect(err).ToNot(HaveOccurred()) + + ctx := cuecontext.New() + v := ctx.CompileBytes(src) + Expect(v.LookupPath(cue.ParsePath("foo")).Int64()).To(Equal(int64(2))) + }) + }) + + When("the path does not exist", func() { + It("returns an error", func() { + _, err := pkg.UpdateFile("foo.cue", "bar", 2, fs) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + When("the file does not exist", func() { + It("returns an error", func() { + _, err := pkg.UpdateFile("foo.cue", "foo", 2, fs) + Expect(err).To(HaveOccurred()) + }) + }) + }) + }) }) diff --git a/tools/updater/pkg/deployment.go b/tools/updater/pkg/deployment.go index e7fdb9da9..f7a7b4bb4 100644 --- a/tools/updater/pkg/deployment.go +++ b/tools/updater/pkg/deployment.go @@ -18,9 +18,10 @@ type DeploymentFile struct { // OverrideConfig represents configuration for overriding a value in a CUE file. type OverrideConfig struct { - App string `json:"app" yaml:"app"` - Path string `json:"path" yaml:"path"` - Value string `json:"value" yaml:"value"` + App string `json:"app" yaml:"app"` + Instance string `json:"instance" yaml:"instance"` + Path string `json:"path" yaml:"path"` + Value string `json:"value" yaml:"value"` } // ScanForDeploymentFiles scans a directory for deployment configuration files.