From d1f5da30ae1956e23c54c57be66defd60081be96 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 08:07:20 +0200 Subject: [PATCH 1/8] ci(ci): ci git action updated --- .github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b93e92..62ad67f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,12 +47,8 @@ jobs: COMMIT=$(git rev-parse --short HEAD) DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') - go build -ldflags="\ - -X github.com/apiqube/cli/cmd/cli.version=$TAG \ - -X github.com/apiqube/cli/cmd/cli.commit=$COMMIT \ - -X github.com/apiqube/cli/cmd/cli.date=$DATE" \ - -o qube ./cmd/qube - + go build -ldflags="-X github.com/apiqube/cli/cmd/cli.version=$TAG -X github.com/apiqube/cli/cmd/cli.commit=$COMMIT -X github.com/apiqube/cli/cmd/cli.date=$DATE" -o qube ./cmd/qube + ./qube version mkdir -p bin mv qube bin/ From 9b0ded7a5ed3f4368c4859ef1f730e1a769f22f6 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 09:20:34 +0200 Subject: [PATCH 2/8] feat(manifests): added cmd and func logic for editing manifests via os editor --- cmd/cli/edit/edit.go | 139 +++++++++++++++++++++++++++ cmd/cli/root.go | 2 + internal/core/manifests/edit/edit.go | 96 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 cmd/cli/edit/edit.go create mode 100644 internal/core/manifests/edit/edit.go diff --git a/cmd/cli/edit/edit.go b/cmd/cli/edit/edit.go new file mode 100644 index 0000000..6cf8061 --- /dev/null +++ b/cmd/cli/edit/edit.go @@ -0,0 +1,139 @@ +package edit + +import ( + "errors" + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/edit" + "github.com/apiqube/cli/internal/core/manifests/hash" + "github.com/apiqube/cli/internal/core/manifests/loader" + "github.com/apiqube/cli/internal/core/store" + uicli "github.com/apiqube/cli/ui/cli" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "edit", + Short: "Edit already saved manifests", + SilenceErrors: true, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + opts, err := parseOptions(cmd) + if err != nil { + uicli.Error(err.Error()) + return + } + + var mans []manifests.Manifest + var man, result manifests.Manifest + + uicli.Info("Looking for manifest...") + + query := store.NewQuery() + queryFlag := false + + if opts.flagsSet["id"] { + mans, err = store.Load(store.LoadOptions{IDs: []string{opts.manifestID}}) + } else if opts.flagsSet["name"] { + query.WithExactName(opts.name) + queryFlag = true + } else if opts.flagsSet["hash"] { + query.WithHashPrefix(opts.hashPrefix) + queryFlag = true + } + + if queryFlag { + mans, err = store.Search(store.NewQuery()) + } + + if err != nil { + uicli.Errorf("Failed to load manifest: %s", err.Error()) + return + } else if len(mans) == 0 { + uicli.Info("No manifests found matching the criteria") + return + } + + man = mans[0] + + uicli.Successf("Manifest %s was founded", man.GetID()) + uicli.Infof("Loading %s manifest in editing context", man.GetID()) + + if result, err = edit.Edit(man); err != nil { + if errors.Is(err, edit.ErrFileNotEdited) { + uicli.Infof("Manifest file %s was not edited", man.GetID()) + return + } + + uicli.Errorf("Failed to edit manifest: %s", err.Error()) + return + } + + uicli.Info("Preparing manifest for saving") + + if content, err := loader.NormalizeYAML(result); err != nil { + uicli.Errorf("Failed to normalize manifest: %s", err.Error()) + return + } else { + if hash, err := hash.CalculateHashWithContent(content); err != nil { + uicli.Errorf("Failed to calculate hash: %s", err.Error()) + return + } else { + result.GetMeta().SetHash(hash) + } + } + + uicli.Infof("Saving %s manifest in storage", man.GetID()) + + if err = store.Save(man); err != nil { + uicli.Errorf("Failed to save manifest: %s", err.Error()) + return + } + + uicli.Successf("Manifest %s successfully saved", man.GetID()) + }, +} + +func init() { + Cmd.Flags().StringP("id", "i", "", "Search and edit manifest by ID") + Cmd.Flags().StringP("name", "n", "", "Search and edit manifest by name") + Cmd.Flags().StringP("hash", "H", "", "Search and edit manifest by hash") +} + +type options struct { + manifestID string + name string + hashPrefix string + + flagsSet map[string]bool +} + +func parseOptions(cmd *cobra.Command) (*options, error) { + opts := &options{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("id") { + opts.manifestID, _ = cmd.Flags().GetString("id") + } + if markFlag("name") { + opts.name, _ = cmd.Flags().GetString("name") + } + if markFlag("hash") { + opts.hashPrefix, _ = cmd.Flags().GetString("hash") + } + + if opts.flagsSet["id"] && (opts.flagsSet["name"] || opts.flagsSet["hash"]) { + return nil, fmt.Errorf("id/name and hash flags cannot be used together") + } + + return opts, nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 279cd06..230597b 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -2,6 +2,7 @@ package cli import ( "context" + "github.com/apiqube/cli/cmd/cli/edit" "os" "os/signal" "syscall" @@ -44,6 +45,7 @@ func Execute() { generator.Cmd, rollback.Cmd, search.Cmd, + edit.Cmd, ) cobra.CheckErr(rootCmd.Execute()) diff --git a/internal/core/manifests/edit/edit.go b/internal/core/manifests/edit/edit.go new file mode 100644 index 0000000..ecb903b --- /dev/null +++ b/internal/core/manifests/edit/edit.go @@ -0,0 +1,96 @@ +package edit + +import ( + "errors" + "fmt" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/parsing" + "gopkg.in/yaml.v3" + "os" + "os/exec" + "runtime" +) + +var ErrFileNotEdited = errors.New("file was not edited") + +func Edit(manifest manifests.Manifest) (manifests.Manifest, error) { + tmpFile, _ := os.CreateTemp("", fmt.Sprintf("%s.*.yaml", manifest.GetID())) + defer func() { + _ = os.Remove(tmpFile.Name()) + }() + + var data []byte + var err error + + if data, err = yaml.Marshal(manifest); err != nil { + return manifest, fmt.Errorf("error marshalling manifest: %s", err.Error()) + } + + if _, err = tmpFile.Write(data); err != nil { + return manifest, fmt.Errorf("error writing manifest data to temp file: %s", err.Error()) + } + + if err = tmpFile.Close(); err != nil { + return manifest, fmt.Errorf("error closing temp file: %s", err.Error()) + } + + if err = editManifestFile(tmpFile.Name()); err != nil { + if errors.Is(err, ErrFileNotEdited) { + return manifest, err + } + + return manifest, fmt.Errorf("error editing manifest: %s", err.Error()) + } + + var updatedData []byte + if updatedData, err = os.ReadFile(tmpFile.Name()); err != nil { + return manifest, fmt.Errorf("error reading updated manifest: %s", err.Error()) + } + + var result manifests.Manifest + + if result, err = parsing.ParseManifestAsYAML(updatedData); err != nil { + return manifest, err + } + + return result, nil +} + +func editManifestFile(path string) error { + fileInfo, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to access file: %w", err) + } + if fileInfo.IsDir() { + return fmt.Errorf("path is a directory, not a file") + } + + editor := os.Getenv("EDITOR") + if editor == "" { + if runtime.GOOS == "windows" { + editor = "notepad" + } else { + editor = "vi" + } + } + + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err = cmd.Run(); err != nil { + return fmt.Errorf("editor failed: %w", err) + } + + newInfo, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to verify edited file: %w", err) + } + + if fileInfo.ModTime() == newInfo.ModTime() { + return ErrFileNotEdited + } + + return nil +} From 9893bc0101f128578d70abea6203d37cc21dfc85 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 09:24:13 +0200 Subject: [PATCH 3/8] chore(manifests): updated time checking for manifest's editing via os editor, task file command adjusted --- Taskfile.yml | 4 ++-- cmd/cli/edit/edit.go | 1 + cmd/cli/root.go | 3 ++- internal/core/manifests/edit/edit.go | 9 +++++---- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 9b37a98..64be8c1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,12 +40,12 @@ tasks: cmds: - reflex -r '\.go$$' -s -- sh -c "task build && task run" - go-fmt: + fmt: desc: ๐Ÿงน Cleaning all go code cmds: - gofumpt -l -w . - go-lint: + lint: desc: ๐Ÿš€ Command for linting code cmds: - golangci-lint run ./... \ No newline at end of file diff --git a/cmd/cli/edit/edit.go b/cmd/cli/edit/edit.go index 6cf8061..bedaaef 100644 --- a/cmd/cli/edit/edit.go +++ b/cmd/cli/edit/edit.go @@ -3,6 +3,7 @@ package edit import ( "errors" "fmt" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/edit" "github.com/apiqube/cli/internal/core/manifests/hash" diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 230597b..0d34fad 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -2,11 +2,12 @@ package cli import ( "context" - "github.com/apiqube/cli/cmd/cli/edit" "os" "os/signal" "syscall" + "github.com/apiqube/cli/cmd/cli/edit" + "github.com/apiqube/cli/ui/cli" "github.com/apiqube/cli/cmd/cli/apply" diff --git a/internal/core/manifests/edit/edit.go b/internal/core/manifests/edit/edit.go index ecb903b..f3769b3 100644 --- a/internal/core/manifests/edit/edit.go +++ b/internal/core/manifests/edit/edit.go @@ -3,12 +3,13 @@ package edit import ( "errors" "fmt" - "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/parsing" - "gopkg.in/yaml.v3" "os" "os/exec" "runtime" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/parsing" + "gopkg.in/yaml.v3" ) var ErrFileNotEdited = errors.New("file was not edited") @@ -88,7 +89,7 @@ func editManifestFile(path string) error { return fmt.Errorf("failed to verify edited file: %w", err) } - if fileInfo.ModTime() == newInfo.ModTime() { + if fileInfo.ModTime().Equal(newInfo.ModTime()) { return ErrFileNotEdited } From 86228a6d98fed7fc1edfba2a1621cda0e49e4362 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 10:25:34 +0200 Subject: [PATCH 4/8] refactor(manifests): manifests action refactored, splitted on IO and side operations, adjustments in search cmd, write operation (now IO) replaced --- cmd/cli/apply/apply.go | 4 +- cmd/cli/check/check.go | 5 +- cmd/cli/edit/edit.go | 13 +- cmd/cli/search/results.go | 140 ++---------------- .../{core => }/collections/priority_queue.go | 0 .../loader/loader.go => io/load.go} | 53 +------ internal/core/io/write.go | 130 ++++++++++++++++ .../core/manifests/{index => kinds}/index.go | 2 +- internal/core/manifests/kinds/plan/plan.go | 31 ++-- .../core/manifests/kinds/servers/server.go | 31 ++-- .../core/manifests/kinds/services/service.go | 33 ++--- .../core/manifests/kinds/tests/api/http.go | 32 ++-- .../core/manifests/kinds/tests/load/http.go | 34 ++--- .../core/manifests/kinds/values/values.go | 31 ++-- .../core/manifests/{hash => utils}/hash.go | 4 +- .../depends/dependencies.go | 2 +- internal/core/runner/plan/manager.go | 8 +- internal/core/store/db.go | 19 ++- internal/core/store/index.go | 30 ++-- internal/core/store/query.go | 35 ++--- .../manifests/edit => operations}/edit.go | 61 +++++++- internal/operations/interfaces.go | 28 ++++ internal/operations/normileze.go | 62 ++++++++ .../manifests/parsing => operations}/parse.go | 49 +++--- 24 files changed, 469 insertions(+), 368 deletions(-) rename internal/{core => }/collections/priority_queue.go (100%) rename internal/core/{manifests/loader/loader.go => io/load.go} (73%) create mode 100644 internal/core/io/write.go rename internal/core/manifests/{index => kinds}/index.go (97%) rename internal/core/manifests/{hash => utils}/hash.go (88%) rename internal/core/{manifests => runner}/depends/dependencies.go (97%) rename internal/{core/manifests/edit => operations}/edit.go (56%) create mode 100644 internal/operations/interfaces.go create mode 100644 internal/operations/normileze.go rename internal/{core/manifests/parsing => operations}/parse.go (76%) diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go index 2cf4638..cfd67f7 100644 --- a/cmd/cli/apply/apply.go +++ b/cmd/cli/apply/apply.go @@ -1,8 +1,8 @@ package apply import ( + "github.com/apiqube/cli/internal/core/io" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/loader" "github.com/apiqube/cli/internal/core/store" "github.com/apiqube/cli/ui/cli" "github.com/spf13/cobra" @@ -26,7 +26,7 @@ var Cmd = &cobra.Command{ cli.Infof("Loading manifests from: %s", file) - loadedMans, cachedMans, err := loader.LoadManifests(file) + loadedMans, cachedMans, err := io.LoadManifests(file) if err != nil { cli.Errorf("Failed to load manifests: %s", err.Error()) return diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index be45204..6a8cf9b 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -4,11 +4,12 @@ import ( "fmt" "strings" + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/ui/cli" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" - "github.com/apiqube/cli/internal/core/manifests/loader" runner "github.com/apiqube/cli/internal/core/runner/plan" "github.com/apiqube/cli/internal/core/store" "github.com/spf13/cobra" @@ -120,7 +121,7 @@ func loadManifests(opts *checkPlanOptions) ([]manifests.Manifest, error) { }) case opts.flagsSet["file"]: - loadedMans, _, err := loader.LoadManifests(opts.file) + loadedMans, _, err := io.LoadManifests(opts.file) if err == nil { cli.Infof("Manifests from provided path %s loaded", opts.file) } diff --git a/cmd/cli/edit/edit.go b/cmd/cli/edit/edit.go index bedaaef..82095f3 100644 --- a/cmd/cli/edit/edit.go +++ b/cmd/cli/edit/edit.go @@ -5,10 +5,9 @@ import ( "fmt" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/edit" - "github.com/apiqube/cli/internal/core/manifests/hash" - "github.com/apiqube/cli/internal/core/manifests/loader" + "github.com/apiqube/cli/internal/core/manifests/utils" "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/internal/operations" uicli "github.com/apiqube/cli/ui/cli" "github.com/spf13/cobra" ) @@ -60,8 +59,8 @@ var Cmd = &cobra.Command{ uicli.Successf("Manifest %s was founded", man.GetID()) uicli.Infof("Loading %s manifest in editing context", man.GetID()) - if result, err = edit.Edit(man); err != nil { - if errors.Is(err, edit.ErrFileNotEdited) { + if result, err = operations.Edit(man); err != nil { + if errors.Is(err, operations.ErrFileNotEdited) { uicli.Infof("Manifest file %s was not edited", man.GetID()) return } @@ -72,11 +71,11 @@ var Cmd = &cobra.Command{ uicli.Info("Preparing manifest for saving") - if content, err := loader.NormalizeYAML(result); err != nil { + if content, err := operations.NormalizeYAML(result); err != nil { uicli.Errorf("Failed to normalize manifest: %s", err.Error()) return } else { - if hash, err := hash.CalculateHashWithContent(content); err != nil { + if hash, err := utils.CalculateContentHash(content); err != nil { uicli.Errorf("Failed to calculate hash: %s", err.Error()) return } else { diff --git a/cmd/cli/search/results.go b/cmd/cli/search/results.go index 934d5bd..cfe8ebd 100644 --- a/cmd/cli/search/results.go +++ b/cmd/cli/search/results.go @@ -1,16 +1,15 @@ package search import ( - "encoding/json" "fmt" - "os" - "path/filepath" "sort" "strings" + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/internal/operations" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/ui/cli" - "gopkg.in/yaml.v3" ) func sortManifests(manifests []manifests.Manifest, fields []string) { @@ -119,131 +118,22 @@ func handleSearchResults(manifests []manifests.Manifest, opts *Options) error { } if opts.output { - if err := outputResults(manifests, opts); err != nil { - return fmt.Errorf("output failed: %w", err) - } - } else { - displayResults(manifests) - } - - return nil -} - -func outputResults(manifests []manifests.Manifest, opts *Options) error { - if err := ensureOutputDirectory(opts.outputPath); err != nil { - return err - } - - if opts.outputMode == "combined" { - return writeCombinedOutput(manifests, opts) - } - return writeSeparateOutputs(manifests, opts) -} - -func writeCombinedOutput(manifests []manifests.Manifest, opts *Options) error { - filename := filepath.Join(opts.outputPath, fmt.Sprintf("manifests.%s", opts.outputFormat)) - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - defer func() { - _ = file.Close() - }() - - switch opts.outputFormat { - case "yaml": - return writeCombinedYAML(file, manifests) - case "json": - return writeCombinedJSON(file, manifests) - default: - return fmt.Errorf("unsupported format: %s", opts.outputFormat) - } -} - -func writeCombinedYAML(file *os.File, manifests []manifests.Manifest) error { - encoder := yaml.NewEncoder(file) - for _, m := range manifests { - if err := encoder.Encode(m); err != nil { - return fmt.Errorf("YAML encoding failed: %w", err) - } - if _, err := file.WriteString("---\n"); err != nil { - return fmt.Errorf("failed to write YAML separator: %w", err) - } - } - return nil -} - -func writeCombinedJSON(file *os.File, manifests []manifests.Manifest) error { - if _, err := file.WriteString("[\n"); err != nil { - return err - } - - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - - for i, m := range manifests { - if i > 0 { - if _, err := file.WriteString(",\n"); err != nil { - return err - } - } - if err := encoder.Encode(m); err != nil { - return err + var parseFormat operations.ParseFormat + switch opts.outputFormat { + case "json": + parseFormat = operations.JSONFormat + default: + parseFormat = operations.YAMLFormat } - } - _, err := file.WriteString("\n]") - return err -} - -func ensureOutputDirectory(path string) error { - if path == "" { - path = "." - } - - if _, err := os.Stat(path); os.IsNotExist(err) { - cli.Infof("Creating output directory: %s", path) - if err = os.MkdirAll(path, 0o755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) + if opts.outputMode == "combined" { + return io.WriteCombined(opts.outputPath, parseFormat, manifests...) + } else { + return io.WriteSeparate(opts.outputPath, parseFormat, manifests...) } + } else { + displayResults(manifests) } - return nil -} - -func writeSeparateOutputs(manifests []manifests.Manifest, opts *Options) error { - for _, m := range manifests { - filename := filepath.Join(opts.outputPath, fmt.Sprintf("%s.%s", m.GetID(), opts.outputFormat)) - if err := writeSingleManifest(filename, m, opts.outputFormat); err != nil { - return fmt.Errorf("failed to write manifest %s: %w", m.GetID(), err) - } - } - cli.Successf("Successfully wrote %d manifests to %s", len(manifests), opts.outputPath) - return nil -} - -func writeSingleManifest(filename string, manifest manifests.Manifest, format string) error { - file, err := os.Create(filename) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer func() { - _ = file.Close() - }() - switch strings.ToLower(format) { - case "yaml": - encoder := yaml.NewEncoder(file) - if err := encoder.Encode(manifest); err != nil { - return fmt.Errorf("yaml encoding failed: %w", err) - } - case "json": - encoder := json.NewEncoder(file) - encoder.SetIndent("", " ") - if err := encoder.Encode(manifest); err != nil { - return fmt.Errorf("json encoding failed: %w", err) - } - default: - return fmt.Errorf("unsupported format: %s", format) - } return nil } diff --git a/internal/core/collections/priority_queue.go b/internal/collections/priority_queue.go similarity index 100% rename from internal/core/collections/priority_queue.go rename to internal/collections/priority_queue.go diff --git a/internal/core/manifests/loader/loader.go b/internal/core/io/load.go similarity index 73% rename from internal/core/manifests/loader/loader.go rename to internal/core/io/load.go index 4e6fb4c..421233c 100644 --- a/internal/core/manifests/loader/loader.go +++ b/internal/core/io/load.go @@ -1,20 +1,16 @@ -package loader +package io import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" - "github.com/apiqube/cli/ui/cli" - - "gopkg.in/yaml.v3" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/operations" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/hash" - "github.com/apiqube/cli/internal/core/manifests/parsing" "github.com/apiqube/cli/internal/core/store" ) @@ -67,7 +63,7 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif return nil, nil, fmt.Errorf("error reading file %s: %w", filePath, err) } - parsedManifests, err := parsing.ParseManifestsAsYAML(content) + parsedManifests, err := operations.ParseBatchAsYAML(content) if err != nil { return nil, nil, fmt.Errorf("in file %s: %w", filepath.Base(filePath), err) } @@ -79,12 +75,12 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif manifestID := m.GetID() var normalized []byte - normalized, err = NormalizeYAML(m) + normalized, err = operations.NormalizeYAML(m) if err != nil { return nil, nil, fmt.Errorf("failed to normalize manifest %s: %w", manifestID, err) } var manifestHash string - manifestHash, err = hash.CalculateHashWithContent(normalized) + manifestHash, err = utils.CalculateContentHash(normalized) if err != nil { return nil, nil, fmt.Errorf("failed to calculate hash for manifest %s: %w", manifestID, err) } @@ -109,7 +105,6 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif } if _, exists := manifestsSet[manifestID]; exists { - cli.Warningf("Duplicate manifest ID: %s (from %s)", manifestID, filepath.Base(filePath)) continue } @@ -126,42 +121,6 @@ func processFile(filePath string, manifestsSet map[string]struct{}) (new []manif return newManifests, cachedManifests, nil } -func NormalizeYAML(m manifests.Manifest) ([]byte, error) { - data, err := yaml.Marshal(m) - if err != nil { - return nil, err - } - - var raw map[string]interface{} - if err = yaml.Unmarshal(data, &raw); err != nil { - return nil, err - } - - sorted := sortMapKeys(raw) - - return yaml.Marshal(sorted) -} - -func sortMapKeys(m map[string]interface{}) map[string]interface{} { - res := make(map[string]interface{}) - keys := make([]string, 0, len(m)) - - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - if nested, ok := m[k].(map[string]interface{}); ok { - res[k] = sortMapKeys(nested) - } else { - res[k] = m[k] - } - } - - return res -} - func isNotFoundError(err error) bool { return err != nil && (strings.Contains(err.Error(), "no matching manifest found") || strings.Contains(err.Error(), "not found")) diff --git a/internal/core/io/write.go b/internal/core/io/write.go new file mode 100644 index 0000000..5de9278 --- /dev/null +++ b/internal/core/io/write.go @@ -0,0 +1,130 @@ +package io + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/operations" + "gopkg.in/yaml.v3" +) + +func WriteCombined(path string, format operations.ParseFormat, mans ...manifests.Manifest) error { + if err := ensureOutputDirectory(path); err != nil { + return err + } + + filename := filepath.Join(path, fmt.Sprintf("manifests.%s", format.String())) + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer func() { + _ = file.Close() + }() + + switch format { + case operations.JSONFormat: + return writeCombinedJSON(file, mans) + default: + return writeCombinedYAML(file, mans) + } +} + +func WriteSeparate(path string, format operations.ParseFormat, mans ...manifests.Manifest) error { + if err := ensureOutputDirectory(path); err != nil { + return err + } + + for _, m := range mans { + filename := filepath.Join(path, fmt.Sprintf("%s.%s", m.GetID(), format.String())) + if err := writeSingleManifest(filename, format, m); err != nil { + return fmt.Errorf("failed to write manifest %s: %w", m.GetID(), err) + } + } + + return nil +} + +func writeCombinedYAML(file *os.File, manifests []manifests.Manifest) error { + encoder := yaml.NewEncoder(file) + + for _, m := range manifests { + if err := encoder.Encode(m); err != nil { + return fmt.Errorf("YAML encoding failed: %w", err) + } + if _, err := file.WriteString("---\n"); err != nil { + return fmt.Errorf("failed to write YAML separator: %w", err) + } + } + + return nil +} + +func writeCombinedJSON(file *os.File, manifests []manifests.Manifest) error { + if _, err := file.WriteString("[\n"); err != nil { + return fmt.Errorf("failed to write manifests in file: %w", err) + } + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + + for i, m := range manifests { + if i > 0 { + if _, err := file.WriteString(",\n"); err != nil { + return fmt.Errorf("failed to write manifests in file: %w", err) + } + } + if err := encoder.Encode(m); err != nil { + return fmt.Errorf("JSON encoding failed: %w", err) + } + } + + if _, err := file.WriteString("\n]"); err != nil { + return fmt.Errorf("failed to write manifests in file: %w", err) + } + + return nil +} + +func writeSingleManifest(filename string, format operations.ParseFormat, manifest manifests.Manifest) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer func() { + _ = file.Close() + }() + + switch format { + case operations.JSONFormat: + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err = encoder.Encode(manifest); err != nil { + return fmt.Errorf("json encoding failed: %w", err) + } + default: + encoder := yaml.NewEncoder(file) + if err = encoder.Encode(manifest); err != nil { + return fmt.Errorf("yaml encoding failed: %w", err) + } + } + + return nil +} + +func ensureOutputDirectory(path string) error { + if path == "" { + path = "." + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + if err = os.MkdirAll(path, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + } + + return nil +} diff --git a/internal/core/manifests/index/index.go b/internal/core/manifests/kinds/index.go similarity index 97% rename from internal/core/manifests/index/index.go rename to internal/core/manifests/kinds/index.go index f4e5853..45a3a7c 100644 --- a/internal/core/manifests/index/index.go +++ b/internal/core/manifests/kinds/index.go @@ -1,4 +1,4 @@ -package index +package kinds const ( ID = "id" diff --git a/internal/core/manifests/kinds/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go index db7e2f1..11b6577 100644 --- a/internal/core/manifests/kinds/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/index" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -70,21 +69,21 @@ func (p *Plan) GetNamespace() string { func (p *Plan) Index() any { return map[string]any{ - index.ID: p.GetID(), - index.Version: float64(p.Version), - index.Kind: p.Kind, - index.Name: p.Name, - index.Namespace: p.Namespace, - - index.MetaHash: p.Meta.Hash, - index.MetaVersion: float64(p.Meta.Version), - index.MetaIsCurrent: p.Meta.IsCurrent, - index.MetaCreatedAt: p.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: p.Meta.CreatedBy, - index.MetaUpdatedAt: p.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: p.Meta.UpdatedBy, - index.MetaUsedBy: p.Meta.UsedBy, - index.MetaLastApplied: p.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.ID: p.GetID(), + kinds.Version: float64(p.Version), + kinds.Kind: p.Kind, + kinds.Name: p.Name, + kinds.Namespace: p.Namespace, + + kinds.MetaHash: p.Meta.Hash, + kinds.MetaVersion: float64(p.Meta.Version), + kinds.MetaIsCurrent: p.Meta.IsCurrent, + kinds.MetaCreatedAt: p.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: p.Meta.CreatedBy, + kinds.MetaUpdatedAt: p.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: p.Meta.UpdatedBy, + kinds.MetaUsedBy: p.Meta.UsedBy, + kinds.MetaLastApplied: p.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/kinds/servers/server.go b/internal/core/manifests/kinds/servers/server.go index 050f47f..122e1f3 100644 --- a/internal/core/manifests/kinds/servers/server.go +++ b/internal/core/manifests/kinds/servers/server.go @@ -4,7 +4,6 @@ import ( "time" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/index" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -43,21 +42,21 @@ func (s *Server) GetNamespace() string { func (s *Server) Index() any { return map[string]any{ - index.ID: s.GetID(), - index.Version: float64(s.Version), - index.Kind: s.Kind, - index.Name: s.Name, - index.Namespace: s.Namespace, - - index.MetaHash: s.Meta.Hash, - index.MetaVersion: float64(s.Meta.Version), - index.MetaIsCurrent: s.Meta.IsCurrent, - index.MetaCreatedAt: s.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: s.Meta.CreatedBy, - index.MetaUpdatedAt: s.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: s.Meta.UpdatedBy, - index.MetaUsedBy: s.Meta.UsedBy, - index.MetaLastApplied: s.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.ID: s.GetID(), + kinds.Version: float64(s.Version), + kinds.Kind: s.Kind, + kinds.Name: s.Name, + kinds.Namespace: s.Namespace, + + kinds.MetaHash: s.Meta.Hash, + kinds.MetaVersion: float64(s.Meta.Version), + kinds.MetaIsCurrent: s.Meta.IsCurrent, + kinds.MetaCreatedAt: s.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: s.Meta.CreatedBy, + kinds.MetaUpdatedAt: s.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: s.Meta.UpdatedBy, + kinds.MetaUsedBy: s.Meta.UsedBy, + kinds.MetaLastApplied: s.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/kinds/services/service.go b/internal/core/manifests/kinds/services/service.go index 7b0943a..5a64def 100644 --- a/internal/core/manifests/kinds/services/service.go +++ b/internal/core/manifests/kinds/services/service.go @@ -4,7 +4,6 @@ import ( "time" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/index" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -48,22 +47,22 @@ func (s *Service) GetDependsOn() []string { func (s *Service) Index() any { return map[string]any{ - index.ID: s.GetID(), - index.Version: float64(s.Version), - index.Kind: s.Kind, - index.Name: s.Name, - index.Namespace: s.Namespace, - index.DependsOn: s.DependsOn, - - index.MetaHash: s.Meta.Hash, - index.MetaVersion: float64(s.Meta.Version), - index.MetaIsCurrent: s.Meta.IsCurrent, - index.MetaCreatedAt: s.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: s.Meta.CreatedBy, - index.MetaUpdatedAt: s.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: s.Meta.UpdatedBy, - index.MetaUsedBy: s.Meta.UsedBy, - index.MetaLastApplied: s.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.ID: s.GetID(), + kinds.Version: float64(s.Version), + kinds.Kind: s.Kind, + kinds.Name: s.Name, + kinds.Namespace: s.Namespace, + kinds.DependsOn: s.DependsOn, + + kinds.MetaHash: s.Meta.Hash, + kinds.MetaVersion: float64(s.Meta.Version), + kinds.MetaIsCurrent: s.Meta.IsCurrent, + kinds.MetaCreatedAt: s.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: s.Meta.CreatedBy, + kinds.MetaUpdatedAt: s.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: s.Meta.UpdatedBy, + kinds.MetaUsedBy: s.Meta.UsedBy, + kinds.MetaLastApplied: s.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/kinds/tests/api/http.go b/internal/core/manifests/kinds/tests/api/http.go index a59fabe..28890d4 100644 --- a/internal/core/manifests/kinds/tests/api/http.go +++ b/internal/core/manifests/kinds/tests/api/http.go @@ -4,8 +4,6 @@ import ( "fmt" "time" - "github.com/apiqube/cli/internal/core/manifests/index" - "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/manifests" @@ -53,21 +51,21 @@ func (h *Http) GetNamespace() string { func (h *Http) Index() any { return map[string]any{ - index.Version: float64(h.Version), - index.Kind: h.Kind, - index.Name: h.Name, - index.Namespace: h.Namespace, - index.DependsOn: h.DependsOn, - - index.MetaHash: h.Meta.Hash, - index.MetaVersion: float64(h.Meta.Version), - index.MetaIsCurrent: h.Meta.IsCurrent, - index.MetaCreatedAt: h.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: h.Meta.CreatedBy, - index.MetaUpdatedAt: h.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: h.Meta.UpdatedBy, - index.MetaUsedBy: h.Meta.UsedBy, - index.MetaLastApplied: h.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.Version: float64(h.Version), + kinds.Kind: h.Kind, + kinds.Name: h.Name, + kinds.Namespace: h.Namespace, + kinds.DependsOn: h.DependsOn, + + kinds.MetaHash: h.Meta.Hash, + kinds.MetaVersion: float64(h.Meta.Version), + kinds.MetaIsCurrent: h.Meta.IsCurrent, + kinds.MetaCreatedAt: h.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: h.Meta.CreatedBy, + kinds.MetaUpdatedAt: h.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: h.Meta.UpdatedBy, + kinds.MetaUsedBy: h.Meta.UsedBy, + kinds.MetaLastApplied: h.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/kinds/tests/load/http.go b/internal/core/manifests/kinds/tests/load/http.go index 87a87e3..24fddd0 100644 --- a/internal/core/manifests/kinds/tests/load/http.go +++ b/internal/core/manifests/kinds/tests/load/http.go @@ -3,8 +3,6 @@ package load import ( "time" - "github.com/apiqube/cli/internal/core/manifests/index" - "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" @@ -76,22 +74,22 @@ func (h *Http) GetNamespace() string { func (h *Http) Index() any { return map[string]any{ - index.ID: h.GetID(), - index.Version: float64(h.Version), - index.Kind: h.Kind, - index.Name: h.Name, - index.Namespace: h.Namespace, - index.DependsOn: h.DependsOn, - - index.MetaHash: h.Meta.Hash, - index.MetaVersion: float64(h.Meta.Version), - index.MetaIsCurrent: h.Meta.IsCurrent, - index.MetaCreatedAt: h.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: h.Meta.CreatedBy, - index.MetaUpdatedAt: h.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: h.Meta.UpdatedBy, - index.MetaUsedBy: h.Meta.UsedBy, - index.MetaLastApplied: h.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.ID: h.GetID(), + kinds.Version: float64(h.Version), + kinds.Kind: h.Kind, + kinds.Name: h.Name, + kinds.Namespace: h.Namespace, + kinds.DependsOn: h.DependsOn, + + kinds.MetaHash: h.Meta.Hash, + kinds.MetaVersion: float64(h.Meta.Version), + kinds.MetaIsCurrent: h.Meta.IsCurrent, + kinds.MetaCreatedAt: h.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: h.Meta.CreatedBy, + kinds.MetaUpdatedAt: h.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: h.Meta.UpdatedBy, + kinds.MetaUsedBy: h.Meta.UsedBy, + kinds.MetaLastApplied: h.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/kinds/values/values.go b/internal/core/manifests/kinds/values/values.go index 4b70f11..3c91cfb 100644 --- a/internal/core/manifests/kinds/values/values.go +++ b/internal/core/manifests/kinds/values/values.go @@ -4,7 +4,6 @@ import ( "time" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/index" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -42,21 +41,21 @@ func (v *Values) GetNamespace() string { func (v *Values) Index() any { return map[string]any{ - index.ID: v.GetID(), - index.Version: float64(v.Version), - index.Kind: v.Kind, - index.Name: v.Name, - index.Namespace: v.Namespace, - - index.MetaHash: v.Meta.Hash, - index.MetaVersion: float64(v.Meta.Version), - index.MetaIsCurrent: v.Meta.IsCurrent, - index.MetaCreatedAt: v.Meta.CreatedAt.Format(time.RFC3339Nano), - index.MetaCreatedBy: v.Meta.CreatedBy, - index.MetaUpdatedAt: v.Meta.UpdatedAt.Format(time.RFC3339Nano), - index.MetaUpdatedBy: v.Meta.UpdatedBy, - index.MetaUsedBy: v.Meta.UsedBy, - index.MetaLastApplied: v.Meta.LastApplied.Format(time.RFC3339Nano), + kinds.ID: v.GetID(), + kinds.Version: float64(v.Version), + kinds.Kind: v.Kind, + kinds.Name: v.Name, + kinds.Namespace: v.Namespace, + + kinds.MetaHash: v.Meta.Hash, + kinds.MetaVersion: float64(v.Meta.Version), + kinds.MetaIsCurrent: v.Meta.IsCurrent, + kinds.MetaCreatedAt: v.Meta.CreatedAt.Format(time.RFC3339Nano), + kinds.MetaCreatedBy: v.Meta.CreatedBy, + kinds.MetaUpdatedAt: v.Meta.UpdatedAt.Format(time.RFC3339Nano), + kinds.MetaUpdatedBy: v.Meta.UpdatedBy, + kinds.MetaUsedBy: v.Meta.UsedBy, + kinds.MetaLastApplied: v.Meta.LastApplied.Format(time.RFC3339Nano), } } diff --git a/internal/core/manifests/hash/hash.go b/internal/core/manifests/utils/hash.go similarity index 88% rename from internal/core/manifests/hash/hash.go rename to internal/core/manifests/utils/hash.go index 75c1446..842f7db 100644 --- a/internal/core/manifests/hash/hash.go +++ b/internal/core/manifests/utils/hash.go @@ -1,4 +1,4 @@ -package hash +package utils import ( "crypto/sha256" @@ -26,7 +26,7 @@ func CalculateHashWithPath(filePath string, content []byte) (string, error) { return hash, nil } -func CalculateHashWithContent(content []byte) (string, error) { +func CalculateContentHash(content []byte) (string, error) { hasher := sha256.New() hasher.Write(content) hash := hex.EncodeToString(hasher.Sum(nil)) diff --git a/internal/core/manifests/depends/dependencies.go b/internal/core/runner/depends/dependencies.go similarity index 97% rename from internal/core/manifests/depends/dependencies.go rename to internal/core/runner/depends/dependencies.go index 017d8a1..af15fd2 100644 --- a/internal/core/manifests/depends/dependencies.go +++ b/internal/core/runner/depends/dependencies.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/apiqube/cli/internal/core/collections" + "github.com/apiqube/cli/internal/collections" "github.com/apiqube/cli/internal/core/manifests" ) diff --git a/internal/core/runner/plan/manager.go b/internal/core/runner/plan/manager.go index de751c8..cccdf2b 100644 --- a/internal/core/runner/plan/manager.go +++ b/internal/core/runner/plan/manager.go @@ -5,11 +5,11 @@ import ( "sort" "strings" + "github.com/apiqube/cli/internal/operations" + "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/hash" "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" - "github.com/apiqube/cli/internal/core/manifests/loader" "github.com/apiqube/cli/internal/core/manifests/utils" ) @@ -123,12 +123,12 @@ func (g *basicManager) Generate() (*plan.Plan, error) { newPlan.Spec.Stages = stages - planData, err := loader.NormalizeYAML(&newPlan) + planData, err := operations.NormalizeYAML(&newPlan) if err != nil { return nil, fmt.Errorf("fail while generating plan hash: %v", err) } - planHash, err := hash.CalculateHashWithContent(planData) + planHash, err := utils.CalculateContentHash(planData) if err != nil { return nil, fmt.Errorf("fail while calculation plan hash: %v", err) } diff --git a/internal/core/store/db.go b/internal/core/store/db.go index d9ed059..2e5d039 100644 --- a/internal/core/store/db.go +++ b/internal/core/store/db.go @@ -12,14 +12,13 @@ import ( "strings" "time" + "github.com/apiqube/cli/internal/operations" + "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/utils" - "github.com/apiqube/cli/internal/core/manifests/index" - "github.com/adrg/xdg" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/parsing" "github.com/blevesearch/bleve/v2" "github.com/dgraph-io/badger/v4" ) @@ -235,7 +234,7 @@ func (s *Storage) loadByHash(hash string) (manifests.Manifest, error) { var manifest manifests.Manifest hashQuery := bleve.NewTermQuery(hash) - hashQuery.SetField(index.MetaHash) + hashQuery.SetField(kinds.MetaHash) searchResult, err := s.index.Search(bleve.NewSearchRequest(hashQuery)) if err != nil { @@ -270,7 +269,7 @@ func (s *Storage) loadByHash(hash string) (manifests.Manifest, error) { return manifestItem.Value(func(data []byte) error { var parseErr error - manifest, parseErr = parsing.ParseManifest(parsing.JSONMethod, data) + manifest, parseErr = operations.Parse(operations.JSONFormat, data) return parseErr }) }) @@ -295,7 +294,7 @@ func (s *Storage) loadVersion(id string, version int) (manifests.Manifest, error return err } return item.Value(func(data []byte) error { - m, err = parsing.ParseManifestAsJSON(data) + m, err = operations.Parse(operations.JSONFormat, data) return err }) }) @@ -327,7 +326,7 @@ func (s *Storage) loadLatest() ([]manifests.Manifest, error) { var m manifests.Manifest err = versionedItem.Value(func(data []byte) error { - m, err = parsing.ParseManifestAsJSON(data) + m, err = operations.Parse(operations.JSONFormat, data) if err != nil { return fmt.Errorf("failed to parse manifest %s: %w", versionedKey, err) } @@ -389,7 +388,7 @@ func (s *Storage) loadBulk(opts LoadOptions) ([]manifests.Manifest, error) { var manifest manifests.Manifest if err = item.Value(func(data []byte) error { - manifest, err = parsing.ParseManifestAsJSON(data) + manifest, err = operations.Parse(operations.JSONFormat, data) return err }); err != nil { errs = errors.Join(errs, fmt.Errorf("parsing failed for %q: %w", id, err)) @@ -444,7 +443,7 @@ func (s *Storage) parseSearchResults(searchResults *bleve.SearchResult) ([]manif var m manifests.Manifest err = manifestItem.Value(func(data []byte) error { - m, err = parsing.ParseManifest(parsing.JSONMethod, data) + m, err = operations.Parse(operations.JSONFormat, data) if err != nil { return err } @@ -553,7 +552,7 @@ func (s *Storage) resetVersions(txn *badger.Txn, id string) error { return fmt.Errorf("failed to copy manifest data: %w", err) } - manifest, err := parsing.ParseManifest(parsing.JSONMethod, manifestData) + manifest, err := operations.Parse(operations.JSONFormat, manifestData) if err != nil { return fmt.Errorf("parsing failed: %w", err) } diff --git a/internal/core/store/index.go b/internal/core/store/index.go index 7792215..9999d70 100644 --- a/internal/core/store/index.go +++ b/internal/core/store/index.go @@ -1,7 +1,7 @@ package store import ( - "github.com/apiqube/cli/internal/core/manifests/index" + "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/mapping" ) @@ -15,17 +15,17 @@ func buildBleveMapping() *mapping.IndexMappingImpl { idMapping := bleve.NewTextFieldMapping() idMapping.Analyzer = "keyword" idMapping.Store = true - manifestMapping.AddFieldMappingsAt(index.ID, idMapping) + manifestMapping.AddFieldMappingsAt(kinds.ID, idMapping) - manifestMapping.AddFieldMappingsAt(index.Version, bleve.NewNumericFieldMapping()) - manifestMapping.AddFieldMappingsAt(index.MetaVersion, bleve.NewNumericFieldMapping()) + manifestMapping.AddFieldMappingsAt(kinds.Version, bleve.NewNumericFieldMapping()) + manifestMapping.AddFieldMappingsAt(kinds.MetaVersion, bleve.NewNumericFieldMapping()) exactMatchFields := []string{ - index.Kind, - index.DependsOn, - index.MetaCreatedBy, - index.MetaUpdatedBy, - index.MetaUsedBy, + kinds.Kind, + kinds.DependsOn, + kinds.MetaCreatedBy, + kinds.MetaUpdatedBy, + kinds.MetaUsedBy, } for _, field := range exactMatchFields { @@ -36,16 +36,16 @@ func buildBleveMapping() *mapping.IndexMappingImpl { nameMapping := bleve.NewTextFieldMapping() nameMapping.Analyzer = "keyword" - manifestMapping.AddFieldMappingsAt(index.Name, nameMapping) + manifestMapping.AddFieldMappingsAt(kinds.Name, nameMapping) namespaceMapping := bleve.NewTextFieldMapping() namespaceMapping.Analyzer = "keyword" - manifestMapping.AddFieldMappingsAt(index.Namespace, namespaceMapping) + manifestMapping.AddFieldMappingsAt(kinds.Namespace, namespaceMapping) hashMapping := bleve.NewTextFieldMapping() hashMapping.Analyzer = "keyword" hashMapping.Store = true - manifestMapping.AddFieldMappingsAt(index.MetaHash, hashMapping) + manifestMapping.AddFieldMappingsAt(kinds.MetaHash, hashMapping) dateTimeFieldMapping := bleve.NewTextFieldMapping() dateTimeFieldMapping.Analyzer = "keyword" @@ -54,9 +54,9 @@ func buildBleveMapping() *mapping.IndexMappingImpl { dateTimeFieldMapping.IncludeInAll = false dateFields := []string{ - index.MetaCreatedAt, - index.MetaUpdatedAt, - index.MetaLastApplied, + kinds.MetaCreatedAt, + kinds.MetaUpdatedAt, + kinds.MetaLastApplied, } for _, field := range dateFields { diff --git a/internal/core/store/query.go b/internal/core/store/query.go index 84113c3..1638439 100644 --- a/internal/core/store/query.go +++ b/internal/core/store/query.go @@ -3,7 +3,8 @@ package store import ( "time" - "github.com/apiqube/cli/internal/core/manifests/index" + "github.com/apiqube/cli/internal/core/manifests/kinds" + "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/search/query" ) @@ -56,35 +57,35 @@ func (q *manifestQuery) WithAll() Query { func (q *manifestQuery) WithExactName(name string) Query { termQuery := bleve.NewTermQuery(name) - termQuery.SetField(index.Name) + termQuery.SetField(kinds.Name) q.addQuery(termQuery) return q } func (q *manifestQuery) WithWildcardName(pattern string) Query { wildcardQuery := bleve.NewWildcardQuery(pattern) - wildcardQuery.SetField(index.Name) + wildcardQuery.SetField(kinds.Name) q.addQuery(wildcardQuery) return q } func (q *manifestQuery) WithRegexName(regex string) Query { regexpQuery := bleve.NewRegexpQuery(regex) - regexpQuery.SetField(index.Name) + regexpQuery.SetField(kinds.Name) q.addQuery(regexpQuery) return q } func (q *manifestQuery) WithKind(kind string) Query { termQuery := bleve.NewTermQuery(kind) - termQuery.SetField(index.Kind) + termQuery.SetField(kinds.Kind) q.addQuery(termQuery) return q } func (q *manifestQuery) WithNamespace(namespace string) Query { termQuery := bleve.NewTermQuery(namespace) - termQuery.SetField(index.Namespace) + termQuery.SetField(kinds.Namespace) q.addQuery(termQuery) return q } @@ -92,27 +93,27 @@ func (q *manifestQuery) WithNamespace(namespace string) Query { func (q *manifestQuery) WithVersion(version int) Query { val := float64(version) numericQuery := bleve.NewNumericRangeQuery(&val, nil) - numericQuery.SetField(index.MetaVersion) + numericQuery.SetField(kinds.MetaVersion) return q } func (q *manifestQuery) WithCreatedBy(by string) Query { termQuery := bleve.NewTermQuery(by) - termQuery.SetField(index.MetaCreatedBy) + termQuery.SetField(kinds.MetaCreatedBy) q.addQuery(termQuery) return q } func (q *manifestQuery) WithUsedBy(by string) Query { termQuery := bleve.NewTermQuery(by) - termQuery.SetField(index.MetaUsedBy) + termQuery.SetField(kinds.MetaUsedBy) q.addQuery(termQuery) return q } func (q *manifestQuery) WithHashPrefix(prefix string) Query { prefixQuery := bleve.NewPrefixQuery(prefix) - prefixQuery.SetField(index.MetaHash) + prefixQuery.SetField(kinds.MetaHash) q.addQuery(prefixQuery) return q } @@ -121,7 +122,7 @@ func (q *manifestQuery) WithDependencies(deps []string) Query { disjunctionQuery := bleve.NewDisjunctionQuery() for _, dep := range deps { termQuery := bleve.NewTermQuery(dep) - termQuery.SetField(index.DependsOn) + termQuery.SetField(kinds.DependsOn) disjunctionQuery.AddQuery(termQuery) } q.addQuery(disjunctionQuery) @@ -132,7 +133,7 @@ func (q *manifestQuery) WithAllDependencies(deps []string) Query { conjunctionQuery := bleve.NewConjunctionQuery() for _, dep := range deps { termQuery := bleve.NewTermQuery(dep) - termQuery.SetField(index.DependsOn) + termQuery.SetField(kinds.DependsOn) conjunctionQuery.AddQuery(termQuery) } q.addQuery(conjunctionQuery) @@ -141,35 +142,35 @@ func (q *manifestQuery) WithAllDependencies(deps []string) Query { func (q *manifestQuery) WithCreatedAfter(t time.Time) Query { dateQuery := bleve.NewTermRangeQuery(t.Format(time.RFC3339Nano), "") - dateQuery.SetField(index.MetaCreatedAt) + dateQuery.SetField(kinds.MetaCreatedAt) q.addQuery(dateQuery) return q } func (q *manifestQuery) WithCreatedBefore(t time.Time) Query { dateQuery := bleve.NewTermRangeQuery("", t.Format(time.RFC3339Nano)) - dateQuery.SetField(index.MetaCreatedAt) + dateQuery.SetField(kinds.MetaCreatedAt) q.addQuery(dateQuery) return q } func (q *manifestQuery) WithUpdatedAfter(t time.Time) Query { dateQuery := bleve.NewTermRangeQuery(t.Format(time.RFC3339Nano), "") - dateQuery.SetField(index.MetaUpdatedAt) + dateQuery.SetField(kinds.MetaUpdatedAt) q.addQuery(dateQuery) return q } func (q *manifestQuery) WithUpdatedBefore(t time.Time) Query { dateQuery := bleve.NewTermRangeQuery("", t.Format(time.RFC3339Nano)) - dateQuery.SetField(index.MetaUpdatedAt) + dateQuery.SetField(kinds.MetaUpdatedAt) q.addQuery(dateQuery) return q } func (q *manifestQuery) WithLastApplied(t time.Time) Query { dateQuery := bleve.NewTermRangeQuery("", t.Format(time.RFC3339Nano)) - dateQuery.SetField(index.MetaLastApplied) + dateQuery.SetField(kinds.MetaLastApplied) q.addQuery(dateQuery) return q } diff --git a/internal/core/manifests/edit/edit.go b/internal/operations/edit.go similarity index 56% rename from internal/core/manifests/edit/edit.go rename to internal/operations/edit.go index f3769b3..06531c3 100644 --- a/internal/core/manifests/edit/edit.go +++ b/internal/operations/edit.go @@ -1,6 +1,7 @@ -package edit +package operations import ( + "encoding/json" "errors" "fmt" "os" @@ -8,13 +9,24 @@ import ( "runtime" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/parsing" "gopkg.in/yaml.v3" ) var ErrFileNotEdited = errors.New("file was not edited") +func EditFormat(format ParseFormat, manifest manifests.Manifest) (manifests.Manifest, error) { + if format == JSONFormat { + return editAsJson(manifest) + } else { + return editAsYaml(manifest) + } +} + func Edit(manifest manifests.Manifest) (manifests.Manifest, error) { + return editAsYaml(manifest) +} + +func editAsYaml(manifest manifests.Manifest) (manifests.Manifest, error) { tmpFile, _ := os.CreateTemp("", fmt.Sprintf("%s.*.yaml", manifest.GetID())) defer func() { _ = os.Remove(tmpFile.Name()) @@ -50,7 +62,50 @@ func Edit(manifest manifests.Manifest) (manifests.Manifest, error) { var result manifests.Manifest - if result, err = parsing.ParseManifestAsYAML(updatedData); err != nil { + if result, err = Parse(YAMLFormat, updatedData); err != nil { + return manifest, err + } + + return result, nil +} + +func editAsJson(manifest manifests.Manifest) (manifests.Manifest, error) { + tmpFile, _ := os.CreateTemp("", fmt.Sprintf("%s.*.json", manifest.GetID())) + defer func() { + _ = os.Remove(tmpFile.Name()) + }() + + var data []byte + var err error + + if data, err = json.Marshal(manifest); err != nil { + return manifest, fmt.Errorf("error marshalling manifest: %s", err.Error()) + } + + if _, err = tmpFile.Write(data); err != nil { + return manifest, fmt.Errorf("error writing manifest data to temp file: %s", err.Error()) + } + + if err = tmpFile.Close(); err != nil { + return manifest, fmt.Errorf("error closing temp file: %s", err.Error()) + } + + if err = editManifestFile(tmpFile.Name()); err != nil { + if errors.Is(err, ErrFileNotEdited) { + return manifest, err + } + + return manifest, fmt.Errorf("error editing manifest: %s", err.Error()) + } + + var updatedData []byte + if updatedData, err = os.ReadFile(tmpFile.Name()); err != nil { + return manifest, fmt.Errorf("error reading updated manifest: %s", err.Error()) + } + + var result manifests.Manifest + + if result, err = Parse(JSONFormat, updatedData); err != nil { return manifest, err } diff --git a/internal/operations/interfaces.go b/internal/operations/interfaces.go new file mode 100644 index 0000000..72ceaea --- /dev/null +++ b/internal/operations/interfaces.go @@ -0,0 +1,28 @@ +package operations + +import "github.com/apiqube/cli/internal/core/manifests" + +type ParseFormat uint8 + +const ( + JSONFormat ParseFormat = iota + 1 + YAMLFormat +) + +func (f ParseFormat) String() string { + switch f { + case JSONFormat: + return "json" + default: + return "yaml" + } +} + +type Parser interface { + Parse(format ParseFormat, data []byte) (manifests.Manifest, error) + ParseBatch(format ParseFormat, data []byte) ([]manifests.Manifest, error) +} + +type Editor interface { + Edit(format ParseFormat, manifest manifests.Manifest) (manifests.Manifest, error) +} diff --git a/internal/operations/normileze.go b/internal/operations/normileze.go new file mode 100644 index 0000000..70b7428 --- /dev/null +++ b/internal/operations/normileze.go @@ -0,0 +1,62 @@ +package operations + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/apiqube/cli/internal/core/manifests" + "gopkg.in/yaml.v3" +) + +func NormalizeYAML(m manifests.Manifest) ([]byte, error) { + data, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed normilize manifest: %v", err) + } + + var raw map[string]interface{} + if err = yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed normilize manifest: %v", err) + } + + sorted := sortMapKeys(raw) + + return yaml.Marshal(sorted) +} + +func NormalizeJSON(m manifests.Manifest) ([]byte, error) { + data, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed normilize manifest: %v", err) + } + + var raw map[string]interface{} + if err = json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed normilize manifest: %v", err) + } + + sorted := sortMapKeys(raw) + + return json.Marshal(sorted) +} + +func sortMapKeys(m map[string]interface{}) map[string]interface{} { + res := make(map[string]interface{}) + keys := make([]string, 0, len(m)) + + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + if nested, ok := m[k].(map[string]interface{}); ok { + res[k] = sortMapKeys(nested) + } else { + res[k] = m[k] + } + } + + return res +} diff --git a/internal/core/manifests/parsing/parse.go b/internal/operations/parse.go similarity index 76% rename from internal/core/manifests/parsing/parse.go rename to internal/operations/parse.go index 7744221..017e0c7 100644 --- a/internal/core/manifests/parsing/parse.go +++ b/internal/operations/parse.go @@ -1,4 +1,4 @@ -package parsing +package operations import ( "bytes" @@ -19,24 +19,17 @@ import ( "gopkg.in/yaml.v3" ) -type ParseMethod uint8 - -const ( - JSONMethod ParseMethod = iota + 1 - YAMLMethod -) - -type RawManifest struct { +type rawManifest struct { Kind string `yaml:"kind" json:"kind"` } -func ParseManifestsAsYAML(data []byte) ([]manifests.Manifest, error) { +func ParseBatchAsYAML(data []byte) ([]manifests.Manifest, error) { docs := bytes.Split(data, []byte("\n---")) var results []manifests.Manifest var rErr error for _, doc := range docs { - manifest, err := ParseManifest(YAMLMethod, doc) + manifest, err := Parse(YAMLFormat, doc) if err != nil { rErr = errors.Join(rErr, err) continue @@ -48,7 +41,7 @@ func ParseManifestsAsYAML(data []byte) ([]manifests.Manifest, error) { return results, rErr } -func ParseManifestsAsJSON(data []byte) ([]manifests.Manifest, error) { +func ParseBatchAsJSON(data []byte) ([]manifests.Manifest, error) { docs := bytes.Split(data, []byte("\n\n")) if bytes.HasPrefix(bytes.TrimSpace(data), []byte("[")) { @@ -60,7 +53,7 @@ func ParseManifestsAsJSON(data []byte) ([]manifests.Manifest, error) { var results []manifests.Manifest var rErr error for _, rawDoc := range rawManifests { - manifest, err := ParseManifest(JSONMethod, rawDoc) + manifest, err := Parse(JSONFormat, rawDoc) if err != nil { rErr = errors.Join(rErr, err) continue @@ -79,7 +72,7 @@ func ParseManifestsAsJSON(data []byte) ([]manifests.Manifest, error) { continue } - manifest, err := ParseManifest(JSONMethod, doc) + manifest, err := Parse(JSONFormat, doc) if err != nil { rErr = errors.Join(rErr, err) continue @@ -94,21 +87,13 @@ func ParseManifestsAsJSON(data []byte) ([]manifests.Manifest, error) { return results, rErr } -func ParseManifestAsYAML(data []byte) (manifests.Manifest, error) { - return ParseManifest(YAMLMethod, data) -} - -func ParseManifestAsJSON(data []byte) (manifests.Manifest, error) { - return ParseManifest(JSONMethod, data) -} - -func ParseManifest(parseMethod ParseMethod, data []byte) (manifests.Manifest, error) { +func Parse(format ParseFormat, data []byte) (manifests.Manifest, error) { data = bytes.TrimSpace(data) if len(data) == 0 { return nil, fmt.Errorf("provided data not looks li ke a valid manifest") } - var raw RawManifest + var raw rawManifest if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("failed to recognize manifest kind: %w", err) } @@ -135,23 +120,23 @@ func ParseManifest(parseMethod ParseMethod, data []byte) (manifests.Manifest, er def.Default() } - if prep, ok := manifest.(manifests.Prepare); ok { - prep.Prepare() - } - var err error - switch parseMethod { - case JSONMethod: + switch format { + case JSONFormat: err = json.Unmarshal(data, manifest) - case YAMLMethod: + case YAMLFormat: err = yaml.Unmarshal(data, manifest) default: - return nil, fmt.Errorf("unknown parse method: %d", parseMethod) + return nil, fmt.Errorf("unknown parse method: %d", format) } if err != nil { return nil, fmt.Errorf("failed to parse manifest: %w", err) } + if prep, ok := manifest.(manifests.Prepare); ok { + prep.Prepare() + } + return manifest, nil } From 99e44606b8027ab0521168e3123901ea3491f73d Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 15:18:39 +0200 Subject: [PATCH 5/8] feat(manifests): added validations by validate tags, wrote validators and validators funcs, updates in kinds --- cmd/cli/apply/apply.go | 93 ++++++++++++-- cmd/cli/check/check.go | 2 +- cmd/cli/search/search.go | 2 +- examples/combined/combined.yaml | 17 ++- examples/plan/plan.yaml | 2 +- examples/services/http_load_test.yaml | 6 +- examples/services/service.yaml | 5 - examples/simple/http_test.yaml | 2 +- go.mod | 6 + go.sum | 14 +++ internal/core/manifests/interface.go | 4 + internal/core/manifests/kinds/base.go | 10 +- internal/core/manifests/kinds/helpers.go | 9 -- internal/core/manifests/kinds/plan/base.go | 12 ++ internal/core/manifests/kinds/plan/plan.go | 48 ++++---- .../core/manifests/kinds/servers/server.go | 14 ++- .../core/manifests/kinds/services/base.go | 20 ++-- .../core/manifests/kinds/services/service.go | 14 ++- .../core/manifests/kinds/tests/api/http.go | 14 +-- internal/core/manifests/kinds/tests/base.go | 48 ++++---- .../core/manifests/kinds/tests/load/http.go | 51 ++++---- .../core/manifests/kinds/values/values.go | 12 +- internal/core/manifests/utils/id_helpers.go | 4 + internal/core/runner/depends/dependencies.go | 2 +- internal/core/runner/plan/manager.go | 5 +- internal/core/store/db.go | 2 +- internal/core/store/index.go | 2 +- internal/validate/cli.go | 106 ++++++++++++++++ internal/validate/interfaces.go | 19 +++ internal/validate/validator.go | 113 ++++++++++++++++++ internal/validate/validators_funcs.go | 47 ++++++++ ui/cli/console.go | 32 ++--- ui/cli/independ.go | 26 +++- 33 files changed, 578 insertions(+), 185 deletions(-) delete mode 100644 internal/core/manifests/kinds/helpers.go create mode 100644 internal/core/manifests/kinds/plan/base.go create mode 100644 internal/validate/cli.go create mode 100644 internal/validate/interfaces.go create mode 100644 internal/validate/validator.go create mode 100644 internal/validate/validators_funcs.go diff --git a/cmd/cli/apply/apply.go b/cmd/cli/apply/apply.go index cfd67f7..2eb508b 100644 --- a/cmd/cli/apply/apply.go +++ b/cmd/cli/apply/apply.go @@ -1,11 +1,18 @@ package apply import ( + "errors" + "fmt" + "os" + "strings" + "github.com/apiqube/cli/internal/core/io" "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/internal/validate" "github.com/apiqube/cli/ui/cli" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func init() { @@ -14,43 +21,103 @@ func init() { var Cmd = &cobra.Command{ Use: "apply", - Short: "Apply resources from manifest file", + Short: "Apply resources from manifest files", + Long: "Apply configuration from YAML manifests with validation and version control", SilenceErrors: true, SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { file, err := cmd.Flags().GetString("file") if err != nil { - cli.Errorf("Failed to parse --file: %s", err.Error()) + cli.Errorf("Failed to parse input file flag: %v", err) return } cli.Infof("Loading manifests from: %s", file) - loadedMans, cachedMans, err := io.LoadManifests(file) if err != nil { - cli.Errorf("Failed to load manifests: %s", err.Error()) + cli.Errorf("Critical load error:\n%s", formatLoadError(err, file)) + return + } + + cli.Info("Validating manifests...") + validator := validate.NewManifestValidator(validate.NewValidator(), cli.Instance()) + + validator.Validate(loadedMans...) + + validMans := validator.Valid() + if len(validMans) == 0 { + cli.Warning("No valid manifests to apply") return } - printManifestsLoadResult(loadedMans, cachedMans) + printManifestsLoadResult(validMans, cachedMans) - if err := store.Save(loadedMans...); err != nil { - cli.Infof("Failed to save manifests: %s", err.Error()) + cli.Infof("Saving %d manifests to storage...", len(validMans)) + if err := store.Save(validMans...); err != nil { + cli.Errorf("Storage error: -\n%s", err.Error()) return } - cli.Success("Manifests applied successfully") + printPostApplySummary(validMans) + cli.Successf("Successfully applied %d manifests", len(validMans)) }, } +func formatLoadError(err error, file string) string { + if os.IsNotExist(err) { + return fmt.Sprintf("File not found: \n%s- Please check the path and try again", file) + } + var yamlErr *yaml.TypeError + if errors.As(err, &yamlErr) { + return fmt.Sprintf("YAML syntax error:\n%s", indentYAMLError(yamlErr)) + } + + return err.Error() +} + func printManifestsLoadResult(newMans, cachedMans []manifests.Manifest) { - for _, m := range newMans { - cli.Infof("New manifest added: %s (h: %s...)", m.GetID(), cli.ShortHash(m.GetMeta().GetHash())) + if len(newMans) > 0 { + var builder strings.Builder + + for _, m := range newMans { + builder.WriteString(fmt.Sprintf("\n- %s %s", + m.GetID(), + fmt.Sprintf("(h: %s)", cli.ShortHash(m.GetMeta().GetHash())), + )) + } + + cli.Infof("New manifests detected: %s", builder.String()) } - for _, m := range cachedMans { - cli.Infof("Manifest %s unchanged (h: %s...) - using cached version", m.GetID(), cli.ShortHash(m.GetMeta().GetHash())) + if len(cachedMans) > 0 { + var builder strings.Builder + + for _, m := range cachedMans { + builder.WriteString(fmt.Sprintf("\n- %s %s", + m.GetID(), + fmt.Sprintf("(h: %s)", cli.ShortHash(m.GetMeta().GetHash())), + )) + + cli.Infof("Using cached manifest: %s", builder.String()) + } } +} + +func printPostApplySummary(mans []manifests.Manifest) { + stats := make(map[string]int) + for _, m := range mans { + stats[m.GetKind()]++ + } + + var builder strings.Builder + + for kind, count := range stats { + builder.WriteString(fmt.Sprintf("\n- %s: %d", kind, count)) + } + + cli.Infof("Applied manifests by kind: %s", builder.String()) +} - cli.Infof("Loaded new manifests\nNew: %d\nCached: %d", len(newMans), len(cachedMans)) +func indentYAMLError(err *yaml.TypeError) string { + return " " + strings.Join(err.Errors, "\n ") } diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index 6a8cf9b..a22cf43 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -70,7 +70,7 @@ var cmdAllCheck = &cobra.Command{ func init() { cmdManifestCheck.Flags().String("id", "", "Full manifest ID to check (namespace.kind.name)") - cmdManifestCheck.Flags().String("kind", "", "kind of manifest (e.g., HttpTest, Server, Values)") + cmdManifestCheck.Flags().String("kind", "", "kind of manifest (e.g., HttpTest, Target, Values)") cmdManifestCheck.Flags().String("name", "", "name of manifest") cmdManifestCheck.Flags().String("namespace", "", "namespace of manifest") cmdManifestCheck.Flags().String("file", "", "Path to manifest file to check") diff --git a/cmd/cli/search/search.go b/cmd/cli/search/search.go index dbbe506..4d7d198 100644 --- a/cmd/cli/search/search.go +++ b/cmd/cli/search/search.go @@ -30,7 +30,7 @@ time ranges, and output formatting`, } if len(manifests) == 0 { - cli.Warning("No manifests found matching the criteria") + cli.Info("No manifests found matching the criteria") return nil } diff --git a/examples/combined/combined.yaml b/examples/combined/combined.yaml index 57ebc16..ce48e12 100644 --- a/examples/combined/combined.yaml +++ b/examples/combined/combined.yaml @@ -4,13 +4,12 @@ metadata: name: simple-service namespace: default dependsOn: - - default.Server.simple-server + - combined.Server.simple-server spec: containers: - name: users-service containerName: users dockerfile: some_path - image: some_path ports: - 8080:8080 - 50051:50051 @@ -22,7 +21,6 @@ spec: healthPath: /health - name: auth-service containerName: auth - dockerfile: some_path image: some_path ports: - 8081:8081 @@ -36,7 +34,7 @@ version: 1 kind: Server metadata: name: simple-server - namespace: default + namespace: combined spec: baseUrl: http://localhost:8080 headers: @@ -46,11 +44,11 @@ version: 1 kind: HttpTest metadata: name: simple-http-test - namespace: default + namespace: combined dependsOn: - - default.Service.simple-service + - combined.Service.simple-service spec: - server: simple-server + target: simple-server cases: - name: user-register method: POST @@ -87,10 +85,11 @@ version: 1 kind: HttpLoadTest metadata: name: simple-http-load-test - namespace: default + namespace: combined dependsOn: - - default.HttpTest.simple-http-test + - combined.HttpTest.simple-http-test spec: + target: simple-service.users-service cases: - name: user-login method: GET diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml index f75d5e2..90ff29f 100644 --- a/examples/plan/plan.yaml +++ b/examples/plan/plan.yaml @@ -5,7 +5,6 @@ metadata: spec: stages: - - name: "Preparation..." description: "First stage with loading values to context" manifests: @@ -16,5 +15,6 @@ spec: - default.Server.simple-server - name: "Testing APIs" + manifests: - default.HttpTest.simple-http-test \ No newline at end of file diff --git a/examples/services/http_load_test.yaml b/examples/services/http_load_test.yaml index 05a261f..c4d620f 100644 --- a/examples/services/http_load_test.yaml +++ b/examples/services/http_load_test.yaml @@ -1,11 +1,9 @@ version: 1 - kind: HttpLoadTest - metadata: name: simple-http-load-test - spec: + target: simple-service cases: - name: user-login method: GET @@ -15,13 +13,11 @@ spec: password: "example_password" expected: code: 200 - - name: user-fetch method: GET endpoint: /users/{id} expected: code: 404 message: "User not found" - dependsOn: - default.Service.simple-service \ No newline at end of file diff --git a/examples/services/service.yaml b/examples/services/service.yaml index 3bfffbe..0c5fd58 100644 --- a/examples/services/service.yaml +++ b/examples/services/service.yaml @@ -1,7 +1,5 @@ version: 1 - kind: Service - metadata: name: simple-service @@ -10,7 +8,6 @@ spec: - name: users-service containerName: users dockerfile: some_path - image: some_path ports: - 8080:8080 env: @@ -21,7 +18,6 @@ spec: healthPath: /health - name: auth-service containerName: auth - dockerfile: some_path image: some_path ports: - 8081:8081 @@ -32,7 +28,6 @@ spec: healthPath: /health - name: lobby-service containerName: lobby - dockerfile: some_path image: some_path ports: - 8081:8081 diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml index d8b60d4..7cdf594 100644 --- a/examples/simple/http_test.yaml +++ b/examples/simple/http_test.yaml @@ -3,7 +3,7 @@ kind: HttpTest metadata: name: simple-http-test spec: - server: simple-server + target: simple-server cases: - name: user-register method: POST diff --git a/go.mod b/go.mod index 99da1ae..22aea62 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/blevesearch/bleve/v2 v2.5.1 github.com/charmbracelet/lipgloss v1.1.0 github.com/dgraph-io/badger/v4 v4.7.0 + github.com/go-playground/validator/v10 v10.26.0 github.com/google/uuid v1.6.0 github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.9.1 @@ -47,8 +48,11 @@ require ( github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/golang/protobuf v1.5.0 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -57,6 +61,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -79,6 +84,7 @@ require ( go.opentelemetry.io/otel/trace v1.35.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index dc7a197..a59dcee 100644 --- a/go.sum +++ b/go.sum @@ -91,11 +91,21 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= @@ -133,6 +143,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -210,6 +222,8 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/internal/core/manifests/interface.go b/internal/core/manifests/interface.go index e201a91..13ecf48 100644 --- a/internal/core/manifests/interface.go +++ b/internal/core/manifests/interface.go @@ -8,6 +8,10 @@ const ( DefaultNamespace = "default" ) +const ( + V1Version = "v1" +) + const ( PlanManifestKind = "Plan" ValuesManifestLind = "Values" diff --git a/internal/core/manifests/kinds/base.go b/internal/core/manifests/kinds/base.go index a9cd96f..d8aa41c 100644 --- a/internal/core/manifests/kinds/base.go +++ b/internal/core/manifests/kinds/base.go @@ -1,16 +1,16 @@ package kinds type Metadata struct { - Name string `yaml:"name" json:"name" valid:"required,alpha"` - Namespace string `yaml:"namespace" json:"namespace" valid:"required,alpha"` + Name string `yaml:"name" json:"name" validate:"required,min=3"` + Namespace string `yaml:"namespace" json:"namespace"` } type BaseManifest struct { - Version uint8 `yaml:"version" json:"version" valid:"required,numeric"` - Kind string `yaml:"kind" json:"kind" valid:"required,alpha,in(Server|Service|HttpTest|HttpLoadTest)"` + Version string `yaml:"version" json:"version" validate:"required,eq=v1"` + Kind string `yaml:"kind" json:"kind" validate:"required,oneof=Plan Values Server Service HttpTest HttpLoadTest"` Metadata `yaml:"metadata" json:"metadata"` } type Dependencies struct { - DependsOn []string `yaml:"dependsOn" json:"dependsOn"` + DependsOn []string `yaml:"dependsOn" json:"dependsOn" validate:"omitempty,min=1,max=25"` } diff --git a/internal/core/manifests/kinds/helpers.go b/internal/core/manifests/kinds/helpers.go deleted file mode 100644 index 8d6ea09..0000000 --- a/internal/core/manifests/kinds/helpers.go +++ /dev/null @@ -1,9 +0,0 @@ -package kinds - -import ( - "fmt" -) - -func FormManifestID(namespace, kind, name string) string { - return fmt.Sprintf("%s.%s.%s", namespace, kind, name) -} diff --git a/internal/core/manifests/kinds/plan/base.go b/internal/core/manifests/kinds/plan/base.go new file mode 100644 index 0000000..88b8f72 --- /dev/null +++ b/internal/core/manifests/kinds/plan/base.go @@ -0,0 +1,12 @@ +package plan + +type StageMode string + +const ( + Strict StageMode = "strict" + Parallel StageMode = "parallel" +) + +func (s StageMode) String() string { + return string(s) +} diff --git a/internal/core/manifests/kinds/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go index 11b6577..f1eca86 100644 --- a/internal/core/manifests/kinds/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -2,7 +2,6 @@ package plan import ( "fmt" - "strings" "time" "github.com/apiqube/cli/internal/core/manifests/utils" @@ -19,40 +18,40 @@ var ( ) type Plan struct { - kinds.BaseManifest `yaml:",inline" json:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - Stages []Stage `yaml:"stages" json:"stages"` - Hooks *Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty"` - } `yaml:"spec" json:"spec"` + Stages []Stage `yaml:"stages" json:"stages" validate:"required,min=1,dive"` + Hooks *Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty" validate:"omitempty,dive"` + } `yaml:"spec" json:"spec" validate:"required"` - Meta *kinds.Meta `yaml:",inline" json:"meta"` + Meta *kinds.Meta `yaml:"-" json:"meta"` } type Stage struct { - Name string `yaml:"name" json:"name"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - Manifests []string `yaml:"manifests" json:"manifests"` + Name string `yaml:"name" json:"name" validate:"required,min=3,max=50"` + Description string `yaml:"description,omitempty" json:"description,omitempty" validate:"omitempty,max=255"` + Manifests []string `yaml:"manifests" json:"manifests" validate:"required,min=1,dive"` Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"` - Params map[string]any `yaml:"params,omitempty" json:"params,omitempty"` - Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // (strict|parallel) - Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty"` + Params map[string]any `yaml:"params,omitempty" json:"params,omitempty" validate:"omitempty"` + Mode string `yaml:"mode,omitempty" json:"mode,omitempty" validate:"omitempty,oneof=strict parallel"` // (strict|parallel) + Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty" validate:"omitempty,dive"` } type Hooks struct { - BeforeStart []Action `yaml:"beforeStart,omitempty" json:"beforeStart,omitempty"` - AfterFinish []Action `yaml:"afterFinish,omitempty" json:"afterFinish,omitempty"` - OnSuccess []Action `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty"` - OnFailure []Action `yaml:"onFailure,omitempty" json:"onFailure,omitempty"` + BeforeStart []Action `yaml:"beforeStart,omitempty" json:"beforeStart,omitempty" validate:"omitempty,dive"` + AfterFinish []Action `yaml:"afterFinish,omitempty" json:"afterFinish,omitempty" validate:"omitempty,dive"` + OnSuccess []Action `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty" validate:"omitempty,dive"` + OnFailure []Action `yaml:"onFailure,omitempty" json:"onFailure,omitempty" validate:"omitempty,dive"` } type Action struct { - Type string `yaml:"type" json:"type"` // eg log/save/skip/fail/exec/notify - Params map[string]any `yaml:"params" json:"params"` + Type string `yaml:"type" json:"type" validate:"required,oneof=log save skip fail exec notify"` // eg log/save/skip/fail/exec/notify + Params map[string]any `yaml:"params" json:"params" validate:"required"` } func (p *Plan) GetID() string { - return kinds.FormManifestID(p.Namespace, p.Kind, p.Name) + return utils.FormManifestID(p.Namespace, p.Kind, p.Name) } func (p *Plan) GetKind() string { @@ -70,7 +69,7 @@ func (p *Plan) GetNamespace() string { func (p *Plan) Index() any { return map[string]any{ kinds.ID: p.GetID(), - kinds.Version: float64(p.Version), + kinds.Version: p.Version, kinds.Kind: p.Kind, kinds.Name: p.Name, kinds.Namespace: p.Namespace, @@ -92,8 +91,8 @@ func (p *Plan) GetMeta() manifests.Meta { } func (p *Plan) Default() { - if p.Version <= 0 { - p.Version = 1 + if p.Version != manifests.V1Version { + p.Version = manifests.V1Version } if p.Name == "" { @@ -128,13 +127,12 @@ func (p *Plan) Prepare() { for i, stage := range p.Spec.Stages { if stage.Mode == "" { - stage.Mode = "lite" + stage.Mode = Strict.String() } for j, m := range stage.Manifests { namespace, kind, name := utils.ParseManifestID(m) - m = strings.Join([]string{namespace, kind, name}, ".") - stage.Manifests[j] = m + stage.Manifests[j] = utils.FormManifestID(namespace, kind, name) } p.Spec.Stages[i] = stage diff --git a/internal/core/manifests/kinds/servers/server.go b/internal/core/manifests/kinds/servers/server.go index 122e1f3..09e209c 100644 --- a/internal/core/manifests/kinds/servers/server.go +++ b/internal/core/manifests/kinds/servers/server.go @@ -3,6 +3,8 @@ package servers import ( "time" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -14,18 +16,18 @@ var ( ) type Server struct { - kinds.BaseManifest `yaml:",inline" json:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - BaseUrl string `yaml:"baseUrl" json:"baseUrl" valid:"required,url"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers" valid:"-"` - } `yaml:"spec" json:"spec" valid:"required"` + BaseUrl string `yaml:"baseUrl" json:"baseUrl" validate:"required,url"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers"` + } `yaml:"spec" json:"spec" validate:"required"` Meta *kinds.Meta `yaml:"-" json:"meta"` } func (s *Server) GetID() string { - return kinds.FormManifestID(s.Namespace, s.Kind, s.Name) + return utils.FormManifestID(s.Namespace, s.Kind, s.Name) } func (s *Server) GetKind() string { @@ -43,7 +45,7 @@ func (s *Server) GetNamespace() string { func (s *Server) Index() any { return map[string]any{ kinds.ID: s.GetID(), - kinds.Version: float64(s.Version), + kinds.Version: s.Version, kinds.Kind: s.Kind, kinds.Name: s.Name, kinds.Namespace: s.Namespace, diff --git a/internal/core/manifests/kinds/services/base.go b/internal/core/manifests/kinds/services/base.go index bfbe70b..77ae590 100644 --- a/internal/core/manifests/kinds/services/base.go +++ b/internal/core/manifests/kinds/services/base.go @@ -3,14 +3,14 @@ package services import "github.com/apiqube/cli/internal/core/manifests/kinds" type Container struct { - Name string `yaml:"name" valid:"required"` - ContainerName string `yaml:"containerName,omitempty"` - Dockerfile string `yaml:"dockerfile,omitempty"` - Image string `yaml:"image,omitempty"` - Ports []string `yaml:"ports,omitempty"` - Env map[string]string `yaml:"env,omitempty"` - Command string `yaml:"command,omitempty"` - Replicas int `yaml:"replicas,omitempty" valid:"length(0|25)"` - HealthPath string `yaml:"healthPath,omitempty"` - kinds.Dependencies `yaml:",inline,omitempty" json:"dependencies,omitempty"` + Name string `yaml:"name" json:"name" validate:"required,min=5,max=25"` + ContainerName string `yaml:"containerName,omitempty" json:"containerName" validate:"omitempty,min=3,max=25"` + Dockerfile string `yaml:"dockerfile,omitempty" json:"dockerfile" validate:"excluded_with=Image"` + Image string `yaml:"image,omitempty" json:"image" validate:"excluded_with=Dockerfile"` + Ports []string `yaml:"ports,omitempty" json:"ports"` + Env map[string]string `yaml:"env,omitempty" json:"env" validate:"omitempty,max=100,dive"` + Command string `yaml:"command,omitempty" json:"command" validate:"omitempty,min=1,max=25"` + Replicas int `yaml:"replicas,omitempty" json:"replicas" validate:"omitempty,min=1,max=25"` + HealthPath string `yaml:"healthPath,omitempty" json:"healthPath"` + kinds.Dependencies `yaml:",inline,omitempty" json:"dependencies,omitempty" validate:"omitempty"` } diff --git a/internal/core/manifests/kinds/services/service.go b/internal/core/manifests/kinds/services/service.go index 5a64def..d930497 100644 --- a/internal/core/manifests/kinds/services/service.go +++ b/internal/core/manifests/kinds/services/service.go @@ -3,6 +3,8 @@ package services import ( "time" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -15,18 +17,18 @@ var ( ) type Service struct { - kinds.BaseManifest `yaml:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - Containers []Container `yaml:"containers" valid:"required,length(1|50)"` - } `yaml:"spec" valid:"required"` + Containers []Container `yaml:"containers" validate:"required,min=1,max=25,dive"` + } `yaml:"spec" validate:"required"` - kinds.Dependencies `yaml:",inline" json:",inline"` + kinds.Dependencies `yaml:",inline" json:",inline" validate:"omitempty"` Meta *kinds.Meta `yaml:"-" json:"meta"` } func (s *Service) GetID() string { - return kinds.FormManifestID(s.Namespace, s.Kind, s.Name) + return utils.FormManifestID(s.Namespace, s.Kind, s.Name) } func (s *Service) GetKind() string { @@ -48,7 +50,7 @@ func (s *Service) GetDependsOn() []string { func (s *Service) Index() any { return map[string]any{ kinds.ID: s.GetID(), - kinds.Version: float64(s.Version), + kinds.Version: s.Version, kinds.Kind: s.Kind, kinds.Name: s.Name, kinds.Namespace: s.Namespace, diff --git a/internal/core/manifests/kinds/tests/api/http.go b/internal/core/manifests/kinds/tests/api/http.go index 28890d4..9fa6095 100644 --- a/internal/core/manifests/kinds/tests/api/http.go +++ b/internal/core/manifests/kinds/tests/api/http.go @@ -18,19 +18,19 @@ var ( ) type Http struct { - kinds.BaseManifest `yaml:",inline" json:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - Target string `yaml:"target,omitempty" json:"target,omitempty"` - Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)" json:"cases"` - } `yaml:"spec" json:"spec" valid:"required"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []HttpCase `yaml:"cases" json:"cases" valid:"required,min=1,max=100,dive"` + } `yaml:"spec" json:"spec" validate:"required"` - kinds.Dependencies `yaml:",inline" json:",inline"` + kinds.Dependencies `yaml:",inline" json:",inline" validate:"omitempty"` Meta *kinds.Meta `yaml:"-" json:"meta"` } type HttpCase struct { - tests.HttpCase `yaml:",inline" json:",inline"` + tests.HttpCase `yaml:",inline" json:",inline" validate:"required"` } func (h *Http) GetID() string { @@ -51,7 +51,7 @@ func (h *Http) GetNamespace() string { func (h *Http) Index() any { return map[string]any{ - kinds.Version: float64(h.Version), + kinds.Version: h.Version, kinds.Kind: h.Kind, kinds.Name: h.Name, kinds.Namespace: h.Namespace, diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index 0373db5..e55532f 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -5,41 +5,41 @@ import ( ) type HttpCase struct { - Name string `yaml:"name" json:"name" valid:"required"` - Method string `yaml:"method" json:"method" valid:"required,uppercase,in(GET|POST|PUT|DELETE)"` - Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` - Url string `yaml:"url,omitempty" json:"url,omitempty"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - Body map[string]any `yaml:"body,omitempty" json:"body,omitempty"` - Assert *Assert `yaml:"assert,omitempty" json:"assert,omitempty"` - Save *Save `yaml:"save,omitempty" json:"save,omitempty"` - Pass []Pass `yaml:"pass,omitempty" json:"pass,omitempty"` - Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` - Parallel bool `yaml:"async,omitempty" json:"async,omitempty"` + Name string `yaml:"name" json:"name" validate:"required,min=3,max=128"` + Method string `yaml:"method" json:"method" valid:"required,uppercase,oneof=GET POST PUT PATCH DELETE"` + Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty" validate:"omitempty"` + Url string `yaml:"url,omitempty" json:"url,omitempty" validate:"omitempty,url"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,min=1,max=100"` + Body map[string]any `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,min=1,max=100"` + Assert *Assert `yaml:"assert,omitempty" json:"assert,omitempty" validate:"omitempty"` + Save *Save `yaml:"save,omitempty" json:"save,omitempty" validate:"omitempty"` + Pass []Pass `yaml:"pass,omitempty" json:"pass,omitempty" validate:"omitempty,min=1,max=25,dive"` + Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"omitempty,duration"` + Parallel bool `yaml:"async,omitempty" json:"async,omitempty" validate:"omitempty,boolean"` } type Assert struct { - Assertions []AssertElement `yaml:",inline,omitempty" json:",inline,omitempty"` + Assertions []AssertElement `yaml:",inline,omitempty" json:",inline,omitempty" validate:"required,min=1,max=50,dive"` } type AssertElement struct { - Target string `yaml:"target,omitempty" json:"target,omitempty"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,min=3,max=128"` Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` - Contains string `yaml:"contains,omitempty" json:"contains,omitempty"` - Exists bool `yaml:"exists,omitempty" json:"exists,omitempty"` - Template string `yaml:"template,omitempty" json:"template,omitempty"` + Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` + Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` + Template string `yaml:"template,omitempty" json:"template,omitempty" validate:"omitempty,min=1,max=100,contains_template"` } type Save struct { - Json map[string]string `yaml:"json,omitempty" json:"json,omitempty"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` - Status bool `yaml:"status,omitempty" json:"status,omitempty"` - Body bool `yaml:"body,omitempty" json:"body,omitempty"` - All bool `yaml:"all,omitempty" json:"all,omitempty"` - Group string `yaml:"group,omitempty" json:"group,omitempty"` + Json map[string]string `yaml:"json,omitempty" json:"json,omitempty" validate:"omitempty,dive,keys,endkeys,json"` + Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,dive,keys,endkeys"` + Status bool `yaml:"status,omitempty" json:"status,omitempty" validate:"omitempty,boolean"` + Body bool `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,boolean"` + All bool `yaml:"all,omitempty" json:"all,omitempty" validate:"omitempty,boolean"` + Group string `yaml:"group,omitempty" json:"group,omitempty" validate:"omitempty,min=1,max=100"` } type Pass struct { - From string `yaml:"from" json:"from"` - Map map[string]string `yaml:"map,omitempty" json:"map,omitempty"` + From string `yaml:"from" json:"from" validate:"required,min=1,max=100"` + Map map[string]string `yaml:"map,omitempty" json:"map,omitempty" validate:"omitempty,min=1,max=100,keys,endkeys"` } diff --git a/internal/core/manifests/kinds/tests/load/http.go b/internal/core/manifests/kinds/tests/load/http.go index 24fddd0..0231183 100644 --- a/internal/core/manifests/kinds/tests/load/http.go +++ b/internal/core/manifests/kinds/tests/load/http.go @@ -3,6 +3,8 @@ package load import ( "time" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/kinds/tests" @@ -16,48 +18,49 @@ var ( ) type Http struct { - kinds.BaseManifest `yaml:",inline" json:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - Server string `yaml:"server,omitempty" json:"server,omitempty"` - Cases []HttpCase `yaml:"cases" json:"cases" valid:"required,length(1|100)"` - } `yaml:"spec" json:"spec" valid:"required"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` + Cases []HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` + } `yaml:"spec" json:"spec" validate:"required"` - kinds.Dependencies `yaml:",inline" json:",inline"` + kinds.Dependencies `yaml:",inline" json:",inline" validate:"omitempty"` Meta *kinds.Meta `yaml:"-" json:"meta"` } type HttpCase struct { - tests.HttpCase `yaml:",inline" json:",inline" valid:"in(constant|ramp|wave|step)"` - - Type string `yaml:"type,omitempty" json:"type,omitempty"` - Repeats int `yaml:"repeats,omitempty" json:"repeats,omitempty"` - Agents int `yaml:"agents,omitempty" json:"agents,omitempty"` - RPS int `yaml:"rps,omitempty" json:"rps,omitempty"` - Ramp *RampConfig `yaml:"ramp,omitempty" json:"ramp,omitempty"` - Wave *WaveConfig `yaml:"wave,omitempty" json:"wave,omitempty"` - Step *StepConfig `yaml:"step,omitempty" json:"step,omitempty"` - Duration time.Duration `yaml:"duration,omitempty" json:"duration,omitempty"` - SaveEvery int `yaml:"saveEvery,omitempty" json:"saveEvery,omitempty"` + tests.HttpCase `yaml:",inline" json:",inline" validate:"required"` + + Type string `yaml:"type,omitempty" json:"type,omitempty" validate:"omitempty,oneof=wave ramp step"` + Repeats int `yaml:"repeats,omitempty" json:"repeats,omitempty" validate:"omitempty,min=1,max=1000"` + Agents int `yaml:"agents,omitempty" json:"agents,omitempty" validate:"omitempty,min=1,max=1000"` + RPS int `yaml:"rps,omitempty" json:"rps,omitempty" validate:"omitempty,min=1,max=100000"` + Ramp *RampConfig `yaml:"ramp,omitempty" json:"ramp,omitempty" validate:"omitempty"` + Wave *WaveConfig `yaml:"wave,omitempty" json:"wave,omitempty" validate:"omitempty"` + Step *StepConfig `yaml:"step,omitempty" json:"step,omitempty" validate:"omitempty"` + Duration time.Duration `yaml:"duration,omitempty" json:"duration,omitempty" validate:"omitempty,duration"` + SaveEvery int `yaml:"saveEvery,omitempty" json:"saveEvery,omitempty" validate:"omitempty,min=1,max=1000"` } type WaveConfig struct { - Low int `yaml:"low,omitempty" json:"low,omitempty"` - High int `yaml:"high,omitempty" json:"high,omitempty"` - Delta int `yaml:"delta,omitempty" json:"delta,omitempty"` + Low int `yaml:"low,omitempty" json:"low,omitempty" validate:"required,min=1,max=1000"` + High int `yaml:"high,omitempty" json:"high,omitempty" validate:"required,min=1,max=100000"` + Delta int `yaml:"delta,omitempty" json:"delta,omitempty" validate:"omitempty,min=1,max=100000"` } type RampConfig struct { - Start int `yaml:"start,omitempty" json:"start,omitempty"` - End int `yaml:"end,omitempty" json:"end,omitempty"` + Start int `yaml:"start,omitempty" json:"start,omitempty" validate:"required,min=1,max=1000"` + End int `yaml:"end,omitempty" json:"end,omitempty" validate:"required,min=1,max=100000"` + Delta int `yaml:"delta,omitempty" json:"delta,omitempty" validate:"omitempty,min=1,max=100000"` } type StepConfig struct { - Pause time.Duration `yaml:"pause,omitempty" json:"pause,omitempty"` + Pause time.Duration `yaml:"pause,omitempty" json:"pause,omitempty" validate:"required,duration"` } func (h *Http) GetID() string { - return kinds.FormManifestID(h.Namespace, h.Kind, h.Name) + return utils.FormManifestID(h.Namespace, h.Kind, h.Name) } func (h *Http) GetKind() string { @@ -75,7 +78,7 @@ func (h *Http) GetNamespace() string { func (h *Http) Index() any { return map[string]any{ kinds.ID: h.GetID(), - kinds.Version: float64(h.Version), + kinds.Version: h.Version, kinds.Kind: h.Kind, kinds.Name: h.Name, kinds.Namespace: h.Namespace, diff --git a/internal/core/manifests/kinds/values/values.go b/internal/core/manifests/kinds/values/values.go index 3c91cfb..81311f8 100644 --- a/internal/core/manifests/kinds/values/values.go +++ b/internal/core/manifests/kinds/values/values.go @@ -3,6 +3,8 @@ package values import ( "time" + "github.com/apiqube/cli/internal/core/manifests/utils" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/manifests/kinds" ) @@ -14,17 +16,17 @@ var ( ) type Values struct { - kinds.BaseManifest `yaml:",inline" json:",inline"` + kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - Data map[string]any `yaml:",inline" json:",inline"` - } `yaml:"spec" valid:"required"` + Data map[string]any `yaml:",inline" json:",inline" validate:"required,min=1,dive"` + } `yaml:"spec" validate:"required"` Meta *kinds.Meta `yaml:"-" json:"meta"` } func (v *Values) GetID() string { - return kinds.FormManifestID(v.Namespace, v.Kind, v.Name) + return utils.FormManifestID(v.Namespace, v.Kind, v.Name) } func (v *Values) GetKind() string { @@ -42,7 +44,7 @@ func (v *Values) GetNamespace() string { func (v *Values) Index() any { return map[string]any{ kinds.ID: v.GetID(), - kinds.Version: float64(v.Version), + kinds.Version: v.Version, kinds.Kind: v.Kind, kinds.Name: v.Name, kinds.Namespace: v.Namespace, diff --git a/internal/core/manifests/utils/id_helpers.go b/internal/core/manifests/utils/id_helpers.go index 78e4be4..ae829b6 100644 --- a/internal/core/manifests/utils/id_helpers.go +++ b/internal/core/manifests/utils/id_helpers.go @@ -9,6 +9,10 @@ import ( "github.com/apiqube/cli/internal/core/manifests" ) +func FormManifestID(namespace, kind, name string) string { + return fmt.Sprintf("%s.%s.%s", namespace, kind, name) +} + func ParseManifestID(id string) (namespace, kind, name string) { parts := strings.Split(id, ".") diff --git a/internal/core/runner/depends/dependencies.go b/internal/core/runner/depends/dependencies.go index af15fd2..b4a7bee 100644 --- a/internal/core/runner/depends/dependencies.go +++ b/internal/core/runner/depends/dependencies.go @@ -13,7 +13,7 @@ import ( var priorityOrder = map[string]int{ "Values": 100, "ConfigMap": 90, - "Server": 50, + "Target": 50, "Service": 30, } diff --git a/internal/core/runner/plan/manager.go b/internal/core/runner/plan/manager.go index cccdf2b..7f9d9b8 100644 --- a/internal/core/runner/plan/manager.go +++ b/internal/core/runner/plan/manager.go @@ -8,14 +8,13 @@ import ( "github.com/apiqube/cli/internal/operations" "github.com/apiqube/cli/internal/core/manifests" - "github.com/apiqube/cli/internal/core/manifests/kinds" "github.com/apiqube/cli/internal/core/manifests/kinds/plan" "github.com/apiqube/cli/internal/core/manifests/utils" ) var kindPriority = map[string]int{ "Values": 0, - "Server": 10, + "Target": 10, "Service": 20, "HttpTest": 30, "HttpLoadTest": 40, @@ -46,7 +45,7 @@ func (g *basicManager) CheckPlan(pln *plan.Plan) error { return err } - id = kinds.FormManifestID(namespace, kind, name) + id = utils.FormManifestID(namespace, kind, name) stage.Manifests[j] = id if seen[id] { diff --git a/internal/core/store/db.go b/internal/core/store/db.go index 2e5d039..da68b22 100644 --- a/internal/core/store/db.go +++ b/internal/core/store/db.go @@ -359,7 +359,7 @@ func (s *Storage) loadBulk(opts LoadOptions) ([]manifests.Manifest, error) { return err } - id = kinds.FormManifestID(namespace, kind, name) + id = utils.FormManifestID(namespace, kind, name) item, err := txn.Get(genLatestKey(id)) if err != nil { diff --git a/internal/core/store/index.go b/internal/core/store/index.go index 9999d70..19134b2 100644 --- a/internal/core/store/index.go +++ b/internal/core/store/index.go @@ -17,10 +17,10 @@ func buildBleveMapping() *mapping.IndexMappingImpl { idMapping.Store = true manifestMapping.AddFieldMappingsAt(kinds.ID, idMapping) - manifestMapping.AddFieldMappingsAt(kinds.Version, bleve.NewNumericFieldMapping()) manifestMapping.AddFieldMappingsAt(kinds.MetaVersion, bleve.NewNumericFieldMapping()) exactMatchFields := []string{ + kinds.Version, kinds.Kind, kinds.DependsOn, kinds.MetaCreatedBy, diff --git a/internal/validate/cli.go b/internal/validate/cli.go new file mode 100644 index 0000000..dc31f61 --- /dev/null +++ b/internal/validate/cli.go @@ -0,0 +1,106 @@ +package validate + +import ( + "errors" + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/ui" +) + +var _ ManifestsValidator = (*ManifestValidator)(nil) + +type ManifestValidator struct { + validator Validator + ui ui.UI + valid, inValid []manifests.Manifest +} + +func NewManifestValidator(validator Validator, ui ui.UI) *ManifestValidator { + return &ManifestValidator{ + validator: validator, + ui: ui, + } +} + +func (v *ManifestValidator) Validate(manifests ...manifests.Manifest) bool { + hasErrors := false + errorBuilder := &ManifestErrorBuilder{ui: v.ui} + + for i, man := range manifests { + errorBuilder.StartManifest(i+1, man.GetID()) + + if err := v.validator.Validate(man); err != nil { + hasErrors = true + var vErr *ValidationError + if errors.As(err, &vErr) { + errorBuilder.AddValidationErrors(vErr.Details) + } else { + errorBuilder.AddGenericError(err) + } + + v.inValid = append(v.inValid, man) + } else { + v.valid = append(v.valid, man) + } + + errorBuilder.FinishManifest() + } + + return !hasErrors +} + +func (v *ManifestValidator) Valid() []manifests.Manifest { + return v.valid +} + +func (v *ManifestValidator) Invalid() []manifests.Manifest { + return v.inValid +} + +type ManifestErrorBuilder struct { + ui ui.UI + manifestID string + position int + hasErrors bool +} + +func (b *ManifestErrorBuilder) StartManifest(position int, id string) { + b.position = position + b.manifestID = id + b.hasErrors = false +} + +func (b *ManifestErrorBuilder) AddValidationErrors(details []ValidationErrorDetail) { + b.hasErrors = true + + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Manifest #%d - %s has validation errors:", b.position, b.manifestID)) + + for _, detail := range details { + builder.WriteString(fmt.Sprintf("\n- %-10s %s", detail.Field+":", detail.Message)) + } + + b.ui.Log(ui.TypeError, builder.String()) +} + +func (b *ManifestErrorBuilder) AddGenericError(err error) { + b.hasErrors = true + b.ui.Logf(ui.TypeError, "Manifest #%d - %s Error: %s", + b.position, + b.manifestID, + err.Error(), + ) +} + +func (b *ManifestErrorBuilder) FinishManifest() { + if !b.hasErrors { + b.ui.Logf(ui.TypeSuccess, "Manifest #%d - %s %s", + b.position, + b.manifestID, + "โœ“ Valid", + ) + } +} diff --git a/internal/validate/interfaces.go b/internal/validate/interfaces.go new file mode 100644 index 0000000..56dd2e2 --- /dev/null +++ b/internal/validate/interfaces.go @@ -0,0 +1,19 @@ +package validate + +import "github.com/apiqube/cli/internal/core/manifests" + +type ValidatorErrorBuilder interface { + AddError(detail ValidationErrorDetail) + Build() error +} + +type Validator interface { + Validate(manifest manifests.Manifest) error +} + +type ManifestsValidator interface { + Validate(manifest ...manifests.Manifest) bool + + Valid() []manifests.Manifest + Invalid() []manifests.Manifest +} diff --git a/internal/validate/validator.go b/internal/validate/validator.go new file mode 100644 index 0000000..f4168a5 --- /dev/null +++ b/internal/validate/validator.go @@ -0,0 +1,113 @@ +package validate + +import ( + "errors" + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/go-playground/validator/v10" +) + +func NewValidator() Validator { + v := validator.New(validator.WithRequiredStructEnabled()) + + for kind, validFunc := range manifestKinsValidationFuncs { + _ = v.RegisterValidation(kind, validFunc) + } + + for name, validFunc := range validationFuncs { + _ = v.RegisterValidation(name, validFunc) + } + + return &baseValidator{ + validate: v, + } +} + +type baseValidator struct { + validate *validator.Validate +} + +func (b *baseValidator) Validate(manifest manifests.Manifest) error { + if err := b.validate.Struct(manifest); err != nil { + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) { + errBuilder := &ValidationErrorBuilder{ + Errors: make([]ValidationErrorDetail, 0, len(validationErrors)), + } + + for _, fieldErr := range validationErrors { + errBuilder.AddError(ValidationErrorDetail{ + Field: fieldErr.Field(), + Tag: fieldErr.Tag(), + Value: fieldErr.Value(), + Message: buildErrorMessage(fieldErr), + }) + } + + return errBuilder.Build() + } + + return fmt.Errorf("manifest validation error: %w", err) + } + return nil +} + +type ValidationErrorBuilder struct { + Errors []ValidationErrorDetail +} + +type ValidationError struct { + Details []ValidationErrorDetail +} + +type ValidationErrorDetail struct { + Field string + Tag string + Value any + Message string +} + +func (b *ValidationErrorBuilder) AddError(detail ValidationErrorDetail) { + b.Errors = append(b.Errors, detail) +} + +func (b *ValidationErrorBuilder) Build() error { + if len(b.Errors) == 0 { + return nil + } + return &ValidationError{Details: b.Errors} +} + +func (e *ValidationError) Error() string { + var sb strings.Builder + sb.WriteString("validation failed:\n") + for i, detail := range e.Details { + sb.WriteString(fmt.Sprintf("%d) %s\n", i+1, detail.Message)) + } + return sb.String() +} + +func buildErrorMessage(fieldErr validator.FieldError) string { + fieldName := fieldErr.Field() + + switch fieldErr.Tag() { + case "required": + return fmt.Sprintf("field '%s' is required", fieldName) + case "min": + return fmt.Sprintf("field '%s' must be at least %s", fieldName, fieldErr.Param()) + case "max": + return fmt.Sprintf("field '%s' must be at most %s", fieldName, fieldErr.Param()) + case "email": + return fmt.Sprintf("field '%s' must be a valid email", fieldName) + case "oneof": + return fmt.Sprintf("field '%s' must contain only one of: %s", fieldName, strings.Join(strings.Split(strings.TrimSpace(fieldErr.Param()), " "), " | ")) + case "excluded_with": + return fmt.Sprintf("field '%s' must be exclusive between: %s and %s", fieldName, fieldName, strings.Join(strings.Split(strings.TrimSpace(fieldErr.Param()), " "), " | ")) + case "eq": + return fmt.Sprintf("field '%s' must be equal to %s", fieldName, fieldErr.Param()) + default: + return fmt.Sprintf("field '%s' failed validation '%s'", fieldName, fieldErr.Tag()) + } +} diff --git a/internal/validate/validators_funcs.go b/internal/validate/validators_funcs.go new file mode 100644 index 0000000..b44b197 --- /dev/null +++ b/internal/validate/validators_funcs.go @@ -0,0 +1,47 @@ +package validate + +import ( + "strings" + "time" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/go-playground/validator/v10" +) + +var ( + validationFuncs = map[string]func(fl validator.FieldLevel) bool{ + "duration": func(fl validator.FieldLevel) bool { + _, err := time.ParseDuration(fl.Field().String()) + return err == nil + }, + "contains_template": func(fl validator.FieldLevel) bool { + value := fl.Field().String() + return strings.Contains(value, "{") && strings.Contains(value, "}") + }, + } + + manifestKinsValidationFuncs = map[string]func(fl validator.FieldLevel) bool{ + manifests.PlanManifestKind: planValidationFunc, + } +) + +func planValidationFunc(fl validator.FieldLevel) bool { + params, ok := fl.Field().Interface().(map[string]any) + if !ok { + return false + } + + actionType := fl.Parent().FieldByName("Type").String() + + switch actionType { + case "exec": + if _, ok = params["command"]; !ok { + return false + } + case "notify": + if _, ok = params["target"]; !ok { + return false + } + } + return true +} diff --git a/ui/cli/console.go b/ui/cli/console.go index d5b28ac..dfcbd8a 100644 --- a/ui/cli/console.go +++ b/ui/cli/console.go @@ -7,97 +7,97 @@ import ( ) func Print(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeLog, msg) } } func Printf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeLog, format, a...) } } func Println(a ...any) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeLog, fmt.Sprintln(a...)) } } func Debug(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeDebug, msg) } } func Debugf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeDebug, format, a...) } } func Info(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeInfo, msg) } } func Infof(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeInfo, format, a...) } } func Warning(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeWarning, msg) } } func Warningf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeWarning, format, a...) } } func Error(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeError, msg) } } func Errorf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeError, format, a...) } } func Fatal(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeFatal, msg) } } func Fatalf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeFatal, format, a...) } } func Success(msg string) { - if inEnabled() { + if isEnabled() { instance.Log(ui.TypeSuccess, msg) } } func Successf(format string, a ...any) { - if inEnabled() { + if isEnabled() { instance.Logf(ui.TypeSuccess, format, a...) } } func Done(msg string) { - if inEnabled() { + if isEnabled() { instance.Done(msg) } } diff --git a/ui/cli/independ.go b/ui/cli/independ.go index fd78b98..8564e68 100644 --- a/ui/cli/independ.go +++ b/ui/cli/independ.go @@ -26,18 +26,28 @@ func Stop() { } } -func inEnabled() bool { - return instance != nil && enabled +func Instance() *UI { + if isEnabled() { + return instance + } + + Init() + + if isEnabled() { + return instance + } + + return NewUI() } func Table(headers []string, rows [][]string) { - if inEnabled() { + if isEnabled() { instance.Table(headers, rows) } } func Progress() ui.ProgressReporter { - if inEnabled() { + if isEnabled() { return instance.Progress() } @@ -45,7 +55,7 @@ func Progress() ui.ProgressReporter { } func Spinner() ui.SpinnerReporter { - if inEnabled() { + if isEnabled() { return instance.Spinner() } @@ -53,8 +63,12 @@ func Spinner() ui.SpinnerReporter { } func Snippet() ui.SnippetReporter { - if inEnabled() { + if isEnabled() { return instance.Snippet() } return &snippetReporter{} } + +func isEnabled() bool { + return instance != nil && enabled +} From ed3e6559532a9de0302e6f19d7af6acf1e32fcb6 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 15:43:17 +0200 Subject: [PATCH 6/8] chore(manifests): yaml manifests from example folder updated --- examples/combined/combined.yaml | 8 ++++---- examples/plan/plan.yaml | 6 +----- examples/services/http_load_test.yaml | 2 +- examples/services/service.yaml | 2 +- examples/simple/http_test.yaml | 2 +- examples/simple/server.yaml | 5 +---- examples/simple/values.yaml | 4 +--- 7 files changed, 10 insertions(+), 19 deletions(-) diff --git a/examples/combined/combined.yaml b/examples/combined/combined.yaml index ce48e12..8a2f9da 100644 --- a/examples/combined/combined.yaml +++ b/examples/combined/combined.yaml @@ -1,4 +1,4 @@ -version: 1 +version: v1 kind: Service metadata: name: simple-service @@ -30,7 +30,7 @@ spec: replicas: 6 healthPath: /health --- -version: 1 +version: v1 kind: Server metadata: name: simple-server @@ -40,7 +40,7 @@ spec: headers: Content-Type: application/json --- -version: 1 +version: v1 kind: HttpTest metadata: name: simple-http-test @@ -81,7 +81,7 @@ spec: code: 404 message: User not found --- -version: 1 +version: v1 kind: HttpLoadTest metadata: name: simple-http-load-test diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml index 90ff29f..0db4b95 100644 --- a/examples/plan/plan.yaml +++ b/examples/plan/plan.yaml @@ -1,20 +1,16 @@ -version: 1 +version: v1 kind: Plan metadata: name: plan-example - spec: stages: - name: "Preparation..." description: "First stage with loading values to context" manifests: - Values.simple-value - - name: "Starting and checking server" manifests: - default.Server.simple-server - - name: "Testing APIs" - manifests: - default.HttpTest.simple-http-test \ No newline at end of file diff --git a/examples/services/http_load_test.yaml b/examples/services/http_load_test.yaml index c4d620f..93a0310 100644 --- a/examples/services/http_load_test.yaml +++ b/examples/services/http_load_test.yaml @@ -1,4 +1,4 @@ -version: 1 +version: v1 kind: HttpLoadTest metadata: name: simple-http-load-test diff --git a/examples/services/service.yaml b/examples/services/service.yaml index 0c5fd58..674f401 100644 --- a/examples/services/service.yaml +++ b/examples/services/service.yaml @@ -1,4 +1,4 @@ -version: 1 +version: v1 kind: Service metadata: name: simple-service diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml index 7cdf594..a19b77d 100644 --- a/examples/simple/http_test.yaml +++ b/examples/simple/http_test.yaml @@ -1,4 +1,4 @@ -version: 1 +version: v1 kind: HttpTest metadata: name: simple-http-test diff --git a/examples/simple/server.yaml b/examples/simple/server.yaml index 0f60e8d..c4412d0 100644 --- a/examples/simple/server.yaml +++ b/examples/simple/server.yaml @@ -1,10 +1,7 @@ -version: 1 - +version: v1 kind: Server - metadata: name: simple-server - spec: baseUrl: "http://localhost:8081" headers: diff --git a/examples/simple/values.yaml b/examples/simple/values.yaml index 6a594c3..54455ab 100644 --- a/examples/simple/values.yaml +++ b/examples/simple/values.yaml @@ -1,9 +1,7 @@ -version: 1 - +version: v1 kind: Values metadata: name: simple-value - spec: users: username: ["Max", "Carl", "John", "Alex", "Uli"] From e550879fdd086845034f9241e8659880a89efd34 Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 17:50:16 +0200 Subject: [PATCH 7/8] feat(cmd): realized generate cmd for generating execution plan with provided filters --- cmd/cli/check/check.go | 21 ++- cmd/cli/generator/generate.go | 223 +++++++++++++++++++++++++- examples/combined/combined.yaml | 2 +- examples/services/http_load_test.yaml | 5 +- examples/services/service.yaml | 2 +- examples/simple/values.yaml | 2 +- internal/core/runner/plan/manager.go | 7 +- 7 files changed, 240 insertions(+), 22 deletions(-) diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index a22cf43..1e578b7 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -31,32 +31,34 @@ var cmdManifestCheck = &cobra.Command{ var cmdPlanCheck = &cobra.Command{ Use: "plan", Short: "Validate a plan manifest", - RunE: func(cmd *cobra.Command, args []string) error { + Run: func(cmd *cobra.Command, args []string) { opts, err := parseCheckPlanFlags(cmd, args) if err != nil { - return uiErrorf("Failed to parse provided values: %v", err) + cli.Errorf("Failed to parse provided values: %v", err) + return } if err := validateCheckPlanOptions(opts); err != nil { - return uiErrorf("%s", err.Error()) + cli.Errorf("%s", err.Error()) + return } loadedManifests, err := loadManifests(opts) if err != nil { - return uiErrorf("Failed to load manifests: %v", err) + cli.Errorf("Failed to load manifests: %v", err) + return } planManifest, err := extractPlanManifest(loadedManifests) if err != nil { - return uiErrorf("Failed to check plan manifest: %v", err) + cli.Errorf("Failed to check plan manifest: %v", err) } if err := validatePlan(planManifest); err != nil { - return uiErrorf("Failed to check plan: %v", err) + cli.Errorf("Failed to check plan: %v", err) } cli.Successf("Successfully checked plan manifest") - return nil }, } @@ -98,11 +100,6 @@ type ( } ) -func uiErrorf(format string, args ...interface{}) error { - cli.Errorf(format, args...) - return nil -} - func validateCheckPlanOptions(opts *checkPlanOptions) error { if !opts.flagsSet["id"] && !opts.flagsSet["name"] && diff --git a/cmd/cli/generator/generate.go b/cmd/cli/generator/generate.go index 81a5d37..0a08b14 100644 --- a/cmd/cli/generator/generate.go +++ b/cmd/cli/generator/generate.go @@ -1,15 +1,232 @@ package generator import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/internal/core/manifests" + runner "github.com/apiqube/cli/internal/core/runner/plan" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/internal/operations" + "github.com/apiqube/cli/ui/cli" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) var Cmd = &cobra.Command{ Use: "generate", - Short: "Generate manifests with provided flags", + Short: "Generate execution plan from loaded manifests", SilenceErrors: true, SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - return nil + Run: func(cmd *cobra.Command, args []string) { + opts, err := parseOptions(cmd) + if err != nil { + cli.Errorf("Failed to parse provided values: %v", err) + return + } + + cli.Info("Loading manifests...") + loadedManifests, err := loadManifests(opts) + if err != nil { + cli.Errorf("Failed to load manifests: %v", err) + return + } + + cli.Infof("Loaded %d manifests", len(loadedManifests)) + cli.Info("Generating plan...") + + manager := runner.NewPlanManagerBuilder(). + WithManifests(loadedManifests...).Build() + + planManifest, err := manager.Generate() + if err != nil { + cli.Errorf("Failed to generate plan: %v", err) + return + } + + cli.Successf("Plan successfully generated") + + if err = savePlan(opts, planManifest); err != nil { + cli.Errorf("Failed to save plan: %v", err) + return + } + + cli.Successf("Generated plan successfully saved") }, } + +func init() { + Cmd.Flags().StringArrayP("names", "n", []string{}, "Names of manifests to generate (comma separated)") + Cmd.Flags().StringP("namespace", "s", "", "Namespace of manifests to generate") + Cmd.Flags().StringArrayP("ids", "i", []string{}, "IDs of manifests to generate (comma separated)") + Cmd.Flags().StringArrayP("hashes", "H", []string{}, "Hash prefixes for manifests (min 5 chars each)") + Cmd.Flags().StringP("file", "f", ".", "Path to manifest directory (default: current)") + Cmd.Flags().Bool("save", false, "Save generated plan to file") + Cmd.Flags().Bool("print", false, "Print generated plan to stdout") + Cmd.Flags().BoolP("output", "o", false, "Make output after generating") + Cmd.Flags().String("output-path", "", "Output path to save the plan (default: current directory)") + Cmd.Flags().String("output-format", "yaml", "Output format (yaml|json)") +} + +type options struct { + names []string + namespace string + ids []string + hashes []string + + file string + + save bool + print bool + output bool + outputPath string + outputFormat string + + flagsSet map[string]bool +} + +func parseOptions(cmd *cobra.Command) (*options, error) { + opts := &options{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("names") { + opts.names, _ = cmd.Flags().GetStringArray("names") + } + if markFlag("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + } + if markFlag("ids") { + opts.ids, _ = cmd.Flags().GetStringArray("ids") + } + if markFlag("hashes") { + opts.hashes, _ = cmd.Flags().GetStringArray("hashes") + } + + if markFlag("file") { + opts.file, _ = cmd.Flags().GetString("file") + } + + if markFlag("save") { + opts.save, _ = cmd.Flags().GetBool("save") + } + if markFlag("print") { + opts.print, _ = cmd.Flags().GetBool("print") + } + + if markFlag("output") { + opts.output, _ = cmd.Flags().GetBool("output") + } + if markFlag("output-path") { + opts.outputPath, _ = cmd.Flags().GetString("output-path") + } + if markFlag("output-format") { + opts.outputFormat, _ = cmd.Flags().GetString("output-format") + } + + exclusiveFlags := []string{"names", "namespace", "ids", "hashes", "file"} + + var usedFlags []string + for _, flag := range exclusiveFlags { + if opts.flagsSet[flag] { + usedFlags = append(usedFlags, "--"+flag) + } + } + + if len(usedFlags) > 1 { + return nil, fmt.Errorf( + "conflicting filters: %s\n"+ + "these filters cannot be used together, please use only one", + strings.Join(usedFlags, " and "), + ) + } + + if err := validateOptions(opts); err != nil { + return nil, err + } + + return opts, nil +} + +func validateOptions(opts *options) error { + if !opts.flagsSet["names"] && + !opts.flagsSet["namespace"] && + !opts.flagsSet["ids"] && + !opts.flagsSet["hashes"] && + !opts.flagsSet["file"] { + return fmt.Errorf("at least one generate filter must be specified") + } + return nil +} + +func loadManifests(opts *options) ([]manifests.Manifest, error) { + switch { + case opts.flagsSet["ids"]: + return store.Load(store.LoadOptions{ + IDs: opts.ids, + }) + + case opts.flagsSet["file"]: + loadedMans, cachedMans, err := io.LoadManifests(opts.file) + if err == nil { + cli.Infof("Manifests from provided path %s loaded", opts.file) + } + + loadedMans = append(loadedMans, cachedMans...) + return loadedMans, err + + default: + query := store.NewQuery() + if opts.flagsSet["names"] { + for _, name := range opts.names { + query.WithExactName(name) + } + } + + if opts.flagsSet["hashes"] { + for _, hash := range opts.hashes { + query.WithHashPrefix(hash) + } + } + + if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + } + + return store.Search(query) + } +} + +func savePlan(opts *options, plan manifests.Manifest) error { + if opts.save { + if err := store.Save(plan); err != nil { + return err + } + } else if opts.output { + var parseFormat operations.ParseFormat + switch opts.outputFormat { + case "json": + parseFormat = operations.JSONFormat + default: + parseFormat = operations.YAMLFormat + } + + return io.WriteSeparate(opts.outputPath, parseFormat, plan) + } else { + if data, err := yaml.Marshal(plan); err == nil { + cli.Snippet().View(plan.GetID(), data) + } else { + return fmt.Errorf("failed to marshal plan to yaml: %v", err) + } + } + return nil +} diff --git a/examples/combined/combined.yaml b/examples/combined/combined.yaml index 8a2f9da..4f5ae27 100644 --- a/examples/combined/combined.yaml +++ b/examples/combined/combined.yaml @@ -2,7 +2,7 @@ version: v1 kind: Service metadata: name: simple-service - namespace: default + namespace: combined dependsOn: - combined.Server.simple-server spec: diff --git a/examples/services/http_load_test.yaml b/examples/services/http_load_test.yaml index 93a0310..37f4080 100644 --- a/examples/services/http_load_test.yaml +++ b/examples/services/http_load_test.yaml @@ -2,6 +2,7 @@ version: v1 kind: HttpLoadTest metadata: name: simple-http-load-test + namespace: infrastructure spec: target: simple-service cases: @@ -18,6 +19,4 @@ spec: endpoint: /users/{id} expected: code: 404 - message: "User not found" -dependsOn: - - default.Service.simple-service \ No newline at end of file + message: "User not found" \ No newline at end of file diff --git a/examples/services/service.yaml b/examples/services/service.yaml index 674f401..d150074 100644 --- a/examples/services/service.yaml +++ b/examples/services/service.yaml @@ -2,7 +2,7 @@ version: v1 kind: Service metadata: name: simple-service - + namespace: infrastructure spec: containers: - name: users-service diff --git a/examples/simple/values.yaml b/examples/simple/values.yaml index 54455ab..9f28b99 100644 --- a/examples/simple/values.yaml +++ b/examples/simple/values.yaml @@ -1,7 +1,7 @@ version: v1 kind: Values metadata: - name: simple-value + name: simple-values spec: users: username: ["Max", "Carl", "John", "Alex", "Uli"] diff --git a/internal/core/runner/plan/manager.go b/internal/core/runner/plan/manager.go index 7f9d9b8..97cedae 100644 --- a/internal/core/runner/plan/manager.go +++ b/internal/core/runner/plan/manager.go @@ -14,7 +14,7 @@ import ( var kindPriority = map[string]int{ "Values": 0, - "Target": 10, + "Server": 10, "Service": 20, "HttpTest": 30, "HttpLoadTest": 40, @@ -96,6 +96,11 @@ func (g *basicManager) Generate() (*plan.Plan, error) { graph := newDepGraph() for id, m := range g.manifests { + if m.GetKind() == manifests.PlanManifestKind { + delete(g.manifests, id) + continue + } + graph.addNode(id) if depend, ok := m.(manifests.Dependencies); ok { From b2078c549ad1ef6356f53c23911776ee09b7feaf Mon Sep 17 00:00:00 2001 From: Nofre Date: Tue, 20 May 2025 17:53:29 +0200 Subject: [PATCH 8/8] fix(cmd): fixed version cmd puddings in outputs --- cmd/cli/version.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/cli/version.go b/cmd/cli/version.go index fb72845..95d68a3 100644 --- a/cmd/cli/version.go +++ b/cmd/cli/version.go @@ -21,11 +21,11 @@ var versionCmd = &cobra.Command{ data := fmt.Sprintf("Qube CLI\nVersion: %s", version) if commit != "" { - data += fmt.Sprintf("Commit: %s\n", commit) + data += fmt.Sprintf("\nCommit: %s\n", commit) } if date != "" { - data += fmt.Sprintf("Date: %s\n", date) + data += fmt.Sprintf("\nDate: %s\n", date) } fmt.Println(data)