From 9f88a272a5a4eb1f6eb63befc7f2cde96b533cd0 Mon Sep 17 00:00:00 2001 From: Nofre Date: Wed, 14 May 2025 23:18:34 +0200 Subject: [PATCH 1/9] feat: added yaml config loader, simple yaml as example --- cmd/root.go | 1 - cmd/run.go | 129 +++++++++++++++----------------------- examples/simple.yaml | 41 ++++++++++++ go.mod | 5 +- go.sum | 6 +- internal/config/config.go | 8 +++ internal/test/config.go | 19 ++++++ internal/yaml/loader.go | 20 ++++++ 8 files changed, 142 insertions(+), 87 deletions(-) create mode 100644 examples/simple.yaml create mode 100644 internal/config/config.go create mode 100644 internal/test/config.go create mode 100644 internal/yaml/loader.go diff --git a/cmd/root.go b/cmd/root.go index 8984010..5f77738 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,5 +13,4 @@ func Execute() { func init() { rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(runCmd) } diff --git a/cmd/run.go b/cmd/run.go index c58d62e..9f097c9 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -2,107 +2,78 @@ package cmd import ( "fmt" - "os" - "time" + "github.com/apiqube/cli/internal/config" + "github.com/apiqube/cli/internal/yaml" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" - - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) +func init() { + rootCmd.AddCommand(runCmd) +} + var runCmd = &cobra.Command{ - Use: "run", - Short: "Run test suite with interactive CLI", + Use: "run [test file]", + Short: "Run test scenarios with provided configuration", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - RunInteractiveTestUI() - }, -} + testFile := args[0] -type testCase struct { - Name string - Status string -} + tea.Printf("Configuration file path: %s\n", testFile)() -type model struct { - tests []testCase - progress progress.Model - index int - quitting bool -} + cfg, err := yaml.LoadConfig[config.Config](testFile) + if err != nil { + fmt.Println("Error loading config:", err) + return + } -func initialModel() model { - return model{ - tests: []testCase{ - {"Register User", "pending"}, - {"Login User", "pending"}, - {"Create Resource", "pending"}, - {"Delete Resource", "pending"}, - }, - progress: progress.New(progress.WithDefaultGradient()), - } -} + p := tea.NewProgram(testModel{config: cfg}) -func (m model) Init() tea.Cmd { - return tea.Batch( - tea.Tick(time.Second*3, func(t time.Time) tea.Msg { - return tickMsg{} - }), - ) + if _, err := p.Run(); err != nil { + fmt.Println("Error starting program:", err) + } + }, } -type tickMsg struct{} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tickMsg: - if m.index >= len(m.tests) { - m.quitting = true - return m, tea.Quit - } +type testModel struct { + config *config.Config + cursor int +} - m.tests[m.index].Status = "✓ passed" - m.index++ - return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg { - return tickMsg{} - }) +func (m testModel) Init() tea.Cmd { + return nil +} +func (m testModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch message := msg.(type) { case tea.KeyMsg: - if msg.String() == "q" { - m.quitting = true + switch message.String() { + case "ctrl+c": + case "q": return m, tea.Quit } } - return m, nil } -func (m model) View() string { - if m.quitting { - return lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("10")). - Render("\n✅ All tests complete!\n\n") - } - - s := "🧪 Running test cases:\n\n" - - for i, t := range m.tests { - style := lipgloss.NewStyle() - if i == m.index { - style = style.Bold(true).Foreground(lipgloss.Color("12")) +func (m testModel) View() string { + var out string + out += fmt.Sprintf("Test Manager - Version %s\n", m.config.Version) + out += "Press 'q' to quit.\n\n" + + for _, test := range m.config.Tests { + out += fmt.Sprintf("Test: %s\n", test.Name) + out += fmt.Sprintf("Description: %s\n", test.Description) + if len(test.Flags) > 0 { + out += "Flags: " + for _, flag := range test.Flags { + out += flag + " " + } + out += "\n" } - s += style.Render(fmt.Sprintf("• %s [%s]", t.Name, t.Status)) + "\n" + out += "\n" } - s += "\nPress 'q' to quit at any time.\n" - return s -} - -func RunInteractiveTestUI() { - if _, err := tea.NewProgram(initialModel()).Run(); err != nil { - fmt.Println("Error running UI:", err) - os.Exit(1) - } + return out } diff --git a/examples/simple.yaml b/examples/simple.yaml new file mode 100644 index 0000000..58cb13f --- /dev/null +++ b/examples/simple.yaml @@ -0,0 +1,41 @@ +version: "1" + +tests: + - name: "User Registration" + description: "Test the registration of a new user" + type: "http" + method: "POST" + url: "https://api.example.com/register" + headers: + Content-Type: "application/json" + body: + username: "testuser" + password: "testpass" + email: "test@example.com" + expected_response: + status_code: 201 + body: + message: "User registered successfully" + flags: + - "verbose" + - "debug" + + - name: "User Login" + description: "Test user login functionality" + type: "http" + method: "POST" + url: "https://api.example.com/login" + headers: + Content-Type: "application/json" + body: + username: "testuser" + password: "testpass" + expected_response: + status_code: 200 + body: + message: "Login successful" + flags: + - "verbose" + use_case: + - "normal_flow" + - "edge_case_invalid_credentials" diff --git a/go.mod b/go.mod index 4307c8c..b975f33 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +3,15 @@ module github.com/apiqube/cli go 1.24.2 require ( - github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 - github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.9.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/go.sum b/go.sum index 11a8c07..61a937b 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,9 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= @@ -55,5 +51,7 @@ golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..7f306a1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,8 @@ +package config + +import "github.com/apiqube/cli/internal/test" + +type Config struct { + Version string `yaml:"version"` + Tests []test.Case `yaml:"tests"` +} diff --git a/internal/test/config.go b/internal/test/config.go new file mode 100644 index 0000000..6c1f36e --- /dev/null +++ b/internal/test/config.go @@ -0,0 +1,19 @@ +package test + +type Case struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Type string `yaml:"type"` + Method string `yaml:"method"` + URL string `yaml:"url"` + Headers map[string]string `yaml:"headers"` + Body map[string]interface{} `yaml:"body"` + ExpectedResponse ExpectedResponse `yaml:"expected_response"` + Flags []string `yaml:"flags"` + UseCase []string `yaml:"use_case"` +} + +type ExpectedResponse struct { + StatusCode int `yaml:"status_code"` + Body map[string]interface{} `yaml:"body"` +} diff --git a/internal/yaml/loader.go b/internal/yaml/loader.go new file mode 100644 index 0000000..3feb852 --- /dev/null +++ b/internal/yaml/loader.go @@ -0,0 +1,20 @@ +package yaml + +import ( + "gopkg.in/yaml.v3" + "os" +) + +func LoadConfig[T any](filePath string) (*T, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var t T + if err = yaml.Unmarshal(data, &t); err != nil { + return nil, err + } + + return &t, nil +} From c6eab55c2dd96f2cf166ba83a3b890d6a02fcc9d Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 00:58:59 +0200 Subject: [PATCH 2/9] chore: added manifests --- examples/simple.yaml | 41 ---------------------------- internal/config/config.go | 8 ------ internal/menifests/interface.go | 17 ++++++++++++ internal/menifests/load_test_http.go | 40 +++++++++++++++++++++++++++ internal/menifests/server.go | 10 +++++++ internal/menifests/service.go | 26 ++++++++++++++++++ internal/menifests/test_http.go | 36 ++++++++++++++++++++++++ internal/test/config.go | 19 ------------- internal/yaml/loader.go | 20 -------------- 9 files changed, 129 insertions(+), 88 deletions(-) delete mode 100644 examples/simple.yaml delete mode 100644 internal/config/config.go create mode 100644 internal/menifests/interface.go create mode 100644 internal/menifests/load_test_http.go create mode 100644 internal/menifests/server.go create mode 100644 internal/menifests/service.go create mode 100644 internal/menifests/test_http.go delete mode 100644 internal/test/config.go delete mode 100644 internal/yaml/loader.go diff --git a/examples/simple.yaml b/examples/simple.yaml deleted file mode 100644 index 58cb13f..0000000 --- a/examples/simple.yaml +++ /dev/null @@ -1,41 +0,0 @@ -version: "1" - -tests: - - name: "User Registration" - description: "Test the registration of a new user" - type: "http" - method: "POST" - url: "https://api.example.com/register" - headers: - Content-Type: "application/json" - body: - username: "testuser" - password: "testpass" - email: "test@example.com" - expected_response: - status_code: 201 - body: - message: "User registered successfully" - flags: - - "verbose" - - "debug" - - - name: "User Login" - description: "Test user login functionality" - type: "http" - method: "POST" - url: "https://api.example.com/login" - headers: - Content-Type: "application/json" - body: - username: "testuser" - password: "testpass" - expected_response: - status_code: 200 - body: - message: "Login successful" - flags: - - "verbose" - use_case: - - "normal_flow" - - "edge_case_invalid_credentials" diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 7f306a1..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,8 +0,0 @@ -package config - -import "github.com/apiqube/cli/internal/test" - -type Config struct { - Version string `yaml:"version"` - Tests []test.Case `yaml:"tests"` -} diff --git a/internal/menifests/interface.go b/internal/menifests/interface.go new file mode 100644 index 0000000..2934e08 --- /dev/null +++ b/internal/menifests/interface.go @@ -0,0 +1,17 @@ +package menifests + +type Manifest interface { + GetVersion() string + GetKind() string + GetName() string + Hash() string +} + +type RawManifest struct { + Kind string `yaml:"kind"` +} + +type Metadata struct { + Name string `yaml:"name"` + Group string `yaml:"group,omitempty"` +} diff --git a/internal/menifests/load_test_http.go b/internal/menifests/load_test_http.go new file mode 100644 index 0000000..e4287ec --- /dev/null +++ b/internal/menifests/load_test_http.go @@ -0,0 +1,40 @@ +package menifests + +import "time" + +type LoadTestHttp struct { + Kind string `yaml:"kind"` + Metadata Metadata `yaml:"metadata"` + + Spec struct { + Cases []LoadTestHttpCase `yaml:"cases"` + } `yaml:"spec"` + + Depends []string `yaml:"depends,omitempty"` +} + +type LoadTestHttpCase struct { + Method string `yaml:"method"` + Endpoint string `yaml:"endpoint,omitempty"` + Url string `yaml:"url,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body map[string]interface{} `yaml:"body,omitempty"` + + Expected *LoadTestHttpCaseExpect `yaml:"expected,omitempty"` + Extract *LoadTestHttpExtractRule `yaml:"extract,omitempty"` + + Timeout time.Duration `yaml:"timeout,omitempty"` + Async bool `yaml:"async,omitempty"` + Repeats int `yaml:"repeats,omitempty"` +} + +type LoadTestHttpCaseExpect struct { + Code int `yaml:"code"` + Message string `yaml:"message,omitempty"` + Data map[string]interface{} `yaml:"data,omitempty"` +} + +type LoadTestHttpExtractRule struct { + Path string `yaml:"path"` + Value string `yaml:"value"` +} diff --git a/internal/menifests/server.go b/internal/menifests/server.go new file mode 100644 index 0000000..d3e0843 --- /dev/null +++ b/internal/menifests/server.go @@ -0,0 +1,10 @@ +package menifests + +type Server struct { + Kind string `yaml:"kind"` + Metadata Metadata `yaml:"metadata"` + Spec struct { + BaseUrl string `yaml:"baseUrl"` + Headers map[string]string `yaml:"headers"` + } `yaml:"spec"` +} diff --git a/internal/menifests/service.go b/internal/menifests/service.go new file mode 100644 index 0000000..4613fd2 --- /dev/null +++ b/internal/menifests/service.go @@ -0,0 +1,26 @@ +package menifests + +type Service struct { + Kind string `yaml:"kind"` + Metadata Metadata `yaml:"metadata"` + Spec struct { + Containers []Container `yaml:"containers"` + } `yaml:"spec,flow"` +} + +type Container struct { + Name string `yaml:"name"` + ContainerName string `yaml:"container_name"` + Dockerfile string `yaml:"dockerfile"` + Image string `yaml:"image"` + Ports []string `yaml:"ports,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Command string `yaml:"command"` + Depends *ContainerDepend `yaml:"depends,omitempty"` + Replicas int `yaml:"replicas"` + HealthPath string `yaml:"health_path"` +} + +type ContainerDepend struct { + Depends []string `yaml:"depends,omitempty"` +} diff --git a/internal/menifests/test_http.go b/internal/menifests/test_http.go new file mode 100644 index 0000000..a04ebd6 --- /dev/null +++ b/internal/menifests/test_http.go @@ -0,0 +1,36 @@ +package menifests + +import "time" + +type TestHttp struct { + Kind string `yaml:"kind"` + Metadata Metadata `yaml:"metadata"` + Spec struct { + Cases []TestHttpCase `yaml:"cases,flow"` + } `yaml:"spec"` + Depends []string `yaml:"depends,omitempty"` +} + +type TestHttpCase struct { + Method string `yaml:"method"` + Endpoint string `yaml:"endpoint,omitempty"` + Url string `yaml:"url,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body map[string]interface{} `yaml:"body,omitempty"` + Expected *TestHttpCaseExpect `yaml:"expected,omitempty"` + Extract *TestHttpExtractRule `yaml:"extract,omitempty"` + Timeout time.Duration `yaml:"timeout,omitempty"` + Async bool `yaml:"async,omitempty"` + Repeats int `yaml:"repeats,omitempty"` +} + +type TestHttpCaseExpect struct { + Code int `yaml:"code"` + Message string `yaml:"message,omitempty"` + Data map[string]interface{} `yaml:"data,omitempty"` +} + +type TestHttpExtractRule struct { + Path string `yaml:"path,omitempty"` + Value string `yaml:"value,omitempty"` +} diff --git a/internal/test/config.go b/internal/test/config.go deleted file mode 100644 index 6c1f36e..0000000 --- a/internal/test/config.go +++ /dev/null @@ -1,19 +0,0 @@ -package test - -type Case struct { - Name string `yaml:"name"` - Description string `yaml:"description"` - Type string `yaml:"type"` - Method string `yaml:"method"` - URL string `yaml:"url"` - Headers map[string]string `yaml:"headers"` - Body map[string]interface{} `yaml:"body"` - ExpectedResponse ExpectedResponse `yaml:"expected_response"` - Flags []string `yaml:"flags"` - UseCase []string `yaml:"use_case"` -} - -type ExpectedResponse struct { - StatusCode int `yaml:"status_code"` - Body map[string]interface{} `yaml:"body"` -} diff --git a/internal/yaml/loader.go b/internal/yaml/loader.go deleted file mode 100644 index 3feb852..0000000 --- a/internal/yaml/loader.go +++ /dev/null @@ -1,20 +0,0 @@ -package yaml - -import ( - "gopkg.in/yaml.v3" - "os" -) - -func LoadConfig[T any](filePath string) (*T, error) { - data, err := os.ReadFile(filePath) - if err != nil { - return nil, err - } - - var t T - if err = yaml.Unmarshal(data, &t); err != nil { - return nil, err - } - - return &t, nil -} From e8ec696d916d044d0980731b6256229881725f64 Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 03:06:26 +0200 Subject: [PATCH 3/9] chore(manifests): manifests structures updated to better reading, added validate tags --- go.mod | 1 + go.sum | 2 + internal/manifests/base.go | 7 +++ internal/manifests/interface.go | 25 +++++++++++ internal/manifests/load/http.go | 65 +++++++++++++++++++++++++++ internal/manifests/server/server.go | 40 +++++++++++++++++ internal/manifests/service/service.go | 55 +++++++++++++++++++++++ internal/manifests/tests/http.go | 65 +++++++++++++++++++++++++++ internal/manifests/validate.go | 11 +++++ internal/menifests/interface.go | 17 ------- internal/menifests/load_test_http.go | 40 ----------------- internal/menifests/server.go | 10 ----- internal/menifests/service.go | 26 ----------- internal/menifests/test_http.go | 36 --------------- 14 files changed, 271 insertions(+), 129 deletions(-) create mode 100644 internal/manifests/base.go create mode 100644 internal/manifests/interface.go create mode 100644 internal/manifests/load/http.go create mode 100644 internal/manifests/server/server.go create mode 100644 internal/manifests/service/service.go create mode 100644 internal/manifests/tests/http.go create mode 100644 internal/manifests/validate.go delete mode 100644 internal/menifests/interface.go delete mode 100644 internal/menifests/load_test_http.go delete mode 100644 internal/menifests/server.go delete mode 100644 internal/menifests/service.go delete mode 100644 internal/menifests/test_http.go diff --git a/go.mod b/go.mod index b975f33..ffdcd5c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect diff --git a/go.sum b/go.sum index 61a937b..8f7db3f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= diff --git a/internal/manifests/base.go b/internal/manifests/base.go new file mode 100644 index 0000000..287ae9f --- /dev/null +++ b/internal/manifests/base.go @@ -0,0 +1,7 @@ +package manifests + +type BaseManifest struct { + Kind string `yaml:"kind" valid:"required,alpha,in(Server|Service|TestHttp|TestLoad)"` + Metadata `yaml:"metadata,inline"` + DependsOn []string `yaml:"dependsOn,omitempty"` +} diff --git a/internal/manifests/interface.go b/internal/manifests/interface.go new file mode 100644 index 0000000..d4b34e8 --- /dev/null +++ b/internal/manifests/interface.go @@ -0,0 +1,25 @@ +package manifests + +const ( + DefaultNamespace = "default" +) + +type Manifest interface { + GetKind() string + GetName() string + GetNamespace() string + GetDependsOn() []string +} + +type Defaultable[T Manifest] interface { + Default() T +} + +type RawManifest struct { + Kind string `yaml:"kind"` +} + +type Metadata struct { + Name string `yaml:"name" valid:"required,alpha"` + Namespace string `yaml:"namespace" valid:"required,alpha"` +} diff --git a/internal/manifests/load/http.go b/internal/manifests/load/http.go new file mode 100644 index 0000000..f29e2e5 --- /dev/null +++ b/internal/manifests/load/http.go @@ -0,0 +1,65 @@ +package load + +import ( + "github.com/apiqube/cli/internal/manifests" + "time" +) + +var _ manifests.Manifest = (*Http)(nil) +var _ manifests.Defaultable[*Http] = (*Http)(nil) + +type Http struct { + manifests.BaseManifest `yaml:",inline"` + + Spec struct { + Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` + } `yaml:"spec" valid:"required"` +} + +type HttpCase struct { + Method string `yaml:"method" valid:"required,uppercase,in(GET|POST|PUT|DELETE)"` + Endpoint string `yaml:"endpoint,omitempty"` + Url string `yaml:"url,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body map[string]interface{} `yaml:"body,omitempty"` + + Expected *HttpExpect `yaml:"expected,omitempty"` + Extract *HttpExtractRule `yaml:"extract,omitempty"` + + Timeout time.Duration `yaml:"timeout,omitempty"` + Async bool `yaml:"async,omitempty"` + Repeats int `yaml:"repeats,omitempty"` +} + +type HttpExpect struct { + Code int `yaml:"code" valid:"required,range(0|599)"` + Message string `yaml:"message,omitempty"` + Data map[string]interface{} `yaml:"data,omitempty"` +} + +type HttpExtractRule struct { + Path string `yaml:"path,omitempty"` + Value string `yaml:"value,omitempty"` +} + +func (h *Http) GetKind() string { + return h.Kind +} + +func (h *Http) GetName() string { + return h.Metadata.Name +} + +func (h *Http) GetNamespace() string { + return h.Metadata.Namespace +} + +func (h *Http) GetDependsOn() []string { + return h.DependsOn +} + +func (h *Http) Default() *Http { + h.Namespace = manifests.DefaultNamespace + + return h +} diff --git a/internal/manifests/server/server.go b/internal/manifests/server/server.go new file mode 100644 index 0000000..7be33c0 --- /dev/null +++ b/internal/manifests/server/server.go @@ -0,0 +1,40 @@ +package server + +import "github.com/apiqube/cli/internal/manifests" + +var _ manifests.Manifest = (*Server)(nil) +var _ manifests.Defaultable[*Server] = (*Server)(nil) + +type Server struct { + manifests.BaseManifest `yaml:",inline"` + + Spec struct { + BaseUrl string `yaml:"baseUrl" valid:"required,url"` + Headers map[string]string `yaml:"headers,omitempty"` + } `yaml:"spec" valid:"required"` +} + +func (s *Server) GetKind() string { + return s.Kind +} + +func (s *Server) GetName() string { + return s.Name +} + +func (s *Server) GetNamespace() string { + return s.Namespace +} + +func (s *Server) GetDependsOn() []string { + return s.DependsOn +} + +func (s *Server) Default() *Server { + s.Namespace = manifests.DefaultNamespace + s.Spec.Headers = map[string]string{ + "Content-Type": "application/json", + } + + return s +} diff --git a/internal/manifests/service/service.go b/internal/manifests/service/service.go new file mode 100644 index 0000000..ffc4dc5 --- /dev/null +++ b/internal/manifests/service/service.go @@ -0,0 +1,55 @@ +package service + +import "github.com/apiqube/cli/internal/manifests" + +var _ manifests.Manifest = (*Service)(nil) +var _ manifests.Defaultable[*Service] = (*Service)(nil) + +type Service struct { + manifests.BaseManifest `yaml:",inline"` + + Spec struct { + Containers []Container `yaml:"containers" valid:"required,length(1|50)"` + } `yaml:"spec" valid:"required"` + + DependsOn []string `yaml:"dependsOn,omitempty"` +} + +type Container struct { + Name string `yaml:"name" valid:"required"` + ContainerName string `yaml:"container_name,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"` + Depends *ContainerDepend `yaml:"depends,omitempty"` + Replicas int `yaml:"replicas,omitempty" valid:"length(0|25)"` + HealthPath string `yaml:"health_path,omitempty"` +} + +type ContainerDepend struct { + Depends []string `yaml:"depends,omitempty" valid:"required,length(1|25)"` +} + +func (s *Service) GetKind() string { + return s.Kind +} + +func (s *Service) GetName() string { + return s.Name +} + +func (s *Service) GetNamespace() string { + return s.Namespace +} + +func (s *Service) GetDependsOn() []string { + return s.DependsOn +} + +func (s *Service) Default() *Service { + s.Namespace = manifests.DefaultNamespace + + return s +} diff --git a/internal/manifests/tests/http.go b/internal/manifests/tests/http.go new file mode 100644 index 0000000..d13242f --- /dev/null +++ b/internal/manifests/tests/http.go @@ -0,0 +1,65 @@ +package tests + +import ( + "github.com/apiqube/cli/internal/manifests" + "time" +) + +var _ manifests.Manifest = (*Http)(nil) +var _ manifests.Defaultable[*Http] = (*Http)(nil) + +type Http struct { + manifests.BaseManifest `yaml:",inline"` + + Spec struct { + Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` + } `yaml:"spec" valid:"required"` + + DependsOn []string `yaml:"dependsOn,omitempty"` +} + +type HttpCase struct { + Method string `yaml:"method" valid:"required,uppercase,in(GET|POST|PUT|DELETE)"` + Endpoint string `yaml:"endpoint,omitempty"` + Url string `yaml:"url,omitempty"` + Headers map[string]string `yaml:"headers,omitempty"` + Body map[string]interface{} `yaml:"body,omitempty"` + Expected *HttpExpect `yaml:"expected,omitempty"` + Extract *HttpExtractRule `yaml:"extract,omitempty"` + Timeout time.Duration `yaml:"timeout,omitempty"` + Async bool `yaml:"async,omitempty"` + Repeats int `yaml:"repeats,omitempty"` +} + +type HttpExpect struct { + Code int `yaml:"code" valid:"required,range(0|599)"` + Message string `yaml:"message,omitempty"` + Data map[string]interface{} `yaml:"data,omitempty"` +} + +type HttpExtractRule struct { + Path string `yaml:"path,omitempty"` + Value string `yaml:"value,omitempty"` +} + +func (h *Http) GetKind() string { + return h.Kind +} + +func (h *Http) GetName() string { + return h.Name +} + +func (h *Http) GetNamespace() string { + return h.Namespace +} + +func (h *Http) GetDependsOn() []string { + return h.DependsOn +} + +func (h *Http) Default() *Http { + h.Namespace = manifests.DefaultNamespace + + return h +} diff --git a/internal/manifests/validate.go b/internal/manifests/validate.go new file mode 100644 index 0000000..e4eae33 --- /dev/null +++ b/internal/manifests/validate.go @@ -0,0 +1,11 @@ +package manifests + +import "github.com/asaskevich/govalidator" + +func ValidateManifest(manifest any) error { + if ok, err := govalidator.ValidateStruct(manifest); !ok && err != nil { + return err + } + + return nil +} diff --git a/internal/menifests/interface.go b/internal/menifests/interface.go deleted file mode 100644 index 2934e08..0000000 --- a/internal/menifests/interface.go +++ /dev/null @@ -1,17 +0,0 @@ -package menifests - -type Manifest interface { - GetVersion() string - GetKind() string - GetName() string - Hash() string -} - -type RawManifest struct { - Kind string `yaml:"kind"` -} - -type Metadata struct { - Name string `yaml:"name"` - Group string `yaml:"group,omitempty"` -} diff --git a/internal/menifests/load_test_http.go b/internal/menifests/load_test_http.go deleted file mode 100644 index e4287ec..0000000 --- a/internal/menifests/load_test_http.go +++ /dev/null @@ -1,40 +0,0 @@ -package menifests - -import "time" - -type LoadTestHttp struct { - Kind string `yaml:"kind"` - Metadata Metadata `yaml:"metadata"` - - Spec struct { - Cases []LoadTestHttpCase `yaml:"cases"` - } `yaml:"spec"` - - Depends []string `yaml:"depends,omitempty"` -} - -type LoadTestHttpCase struct { - Method string `yaml:"method"` - Endpoint string `yaml:"endpoint,omitempty"` - Url string `yaml:"url,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - Body map[string]interface{} `yaml:"body,omitempty"` - - Expected *LoadTestHttpCaseExpect `yaml:"expected,omitempty"` - Extract *LoadTestHttpExtractRule `yaml:"extract,omitempty"` - - Timeout time.Duration `yaml:"timeout,omitempty"` - Async bool `yaml:"async,omitempty"` - Repeats int `yaml:"repeats,omitempty"` -} - -type LoadTestHttpCaseExpect struct { - Code int `yaml:"code"` - Message string `yaml:"message,omitempty"` - Data map[string]interface{} `yaml:"data,omitempty"` -} - -type LoadTestHttpExtractRule struct { - Path string `yaml:"path"` - Value string `yaml:"value"` -} diff --git a/internal/menifests/server.go b/internal/menifests/server.go deleted file mode 100644 index d3e0843..0000000 --- a/internal/menifests/server.go +++ /dev/null @@ -1,10 +0,0 @@ -package menifests - -type Server struct { - Kind string `yaml:"kind"` - Metadata Metadata `yaml:"metadata"` - Spec struct { - BaseUrl string `yaml:"baseUrl"` - Headers map[string]string `yaml:"headers"` - } `yaml:"spec"` -} diff --git a/internal/menifests/service.go b/internal/menifests/service.go deleted file mode 100644 index 4613fd2..0000000 --- a/internal/menifests/service.go +++ /dev/null @@ -1,26 +0,0 @@ -package menifests - -type Service struct { - Kind string `yaml:"kind"` - Metadata Metadata `yaml:"metadata"` - Spec struct { - Containers []Container `yaml:"containers"` - } `yaml:"spec,flow"` -} - -type Container struct { - Name string `yaml:"name"` - ContainerName string `yaml:"container_name"` - Dockerfile string `yaml:"dockerfile"` - Image string `yaml:"image"` - Ports []string `yaml:"ports,omitempty"` - Env map[string]string `yaml:"env,omitempty"` - Command string `yaml:"command"` - Depends *ContainerDepend `yaml:"depends,omitempty"` - Replicas int `yaml:"replicas"` - HealthPath string `yaml:"health_path"` -} - -type ContainerDepend struct { - Depends []string `yaml:"depends,omitempty"` -} diff --git a/internal/menifests/test_http.go b/internal/menifests/test_http.go deleted file mode 100644 index a04ebd6..0000000 --- a/internal/menifests/test_http.go +++ /dev/null @@ -1,36 +0,0 @@ -package menifests - -import "time" - -type TestHttp struct { - Kind string `yaml:"kind"` - Metadata Metadata `yaml:"metadata"` - Spec struct { - Cases []TestHttpCase `yaml:"cases,flow"` - } `yaml:"spec"` - Depends []string `yaml:"depends,omitempty"` -} - -type TestHttpCase struct { - Method string `yaml:"method"` - Endpoint string `yaml:"endpoint,omitempty"` - Url string `yaml:"url,omitempty"` - Headers map[string]string `yaml:"headers,omitempty"` - Body map[string]interface{} `yaml:"body,omitempty"` - Expected *TestHttpCaseExpect `yaml:"expected,omitempty"` - Extract *TestHttpExtractRule `yaml:"extract,omitempty"` - Timeout time.Duration `yaml:"timeout,omitempty"` - Async bool `yaml:"async,omitempty"` - Repeats int `yaml:"repeats,omitempty"` -} - -type TestHttpCaseExpect struct { - Code int `yaml:"code"` - Message string `yaml:"message,omitempty"` - Data map[string]interface{} `yaml:"data,omitempty"` -} - -type TestHttpExtractRule struct { - Path string `yaml:"path,omitempty"` - Value string `yaml:"value,omitempty"` -} From ecfe9735e17b076ebac4b2737959e811e4e8c377 Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 03:33:50 +0200 Subject: [PATCH 4/9] chore(yaml): adjusted manifests bases, added yaml load and parser scripts --- internal/manifests/base.go | 3 +- internal/manifests/interface.go | 9 ++--- internal/yaml/load.go | 41 +++++++++++++++++++++++ internal/yaml/parse.go | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 internal/yaml/load.go create mode 100644 internal/yaml/parse.go diff --git a/internal/manifests/base.go b/internal/manifests/base.go index 287ae9f..394b061 100644 --- a/internal/manifests/base.go +++ b/internal/manifests/base.go @@ -1,7 +1,8 @@ package manifests type BaseManifest struct { - Kind string `yaml:"kind" valid:"required,alpha,in(Server|Service|TestHttp|TestLoad)"` + Version uint8 `yaml:"version" valid:"required,numeric"` + Kind string `yaml:"kind" valid:"required,alpha,in(Server|Service|HttpTest|HttpLoadTest)"` Metadata `yaml:"metadata,inline"` DependsOn []string `yaml:"dependsOn,omitempty"` } diff --git a/internal/manifests/interface.go b/internal/manifests/interface.go index d4b34e8..d74a862 100644 --- a/internal/manifests/interface.go +++ b/internal/manifests/interface.go @@ -2,6 +2,11 @@ package manifests const ( DefaultNamespace = "default" + + ServerManifestKind = "Server" + ServiceManifestKind = "Service" + HttpTestManifestKind = "HttpTest" + HttpLoadTestManifestKind = "HttpLoadTest" ) type Manifest interface { @@ -15,10 +20,6 @@ type Defaultable[T Manifest] interface { Default() T } -type RawManifest struct { - Kind string `yaml:"kind"` -} - type Metadata struct { Name string `yaml:"name" valid:"required,alpha"` Namespace string `yaml:"namespace" valid:"required,alpha"` diff --git a/internal/yaml/load.go b/internal/yaml/load.go new file mode 100644 index 0000000..866c869 --- /dev/null +++ b/internal/yaml/load.go @@ -0,0 +1,41 @@ +package yaml + +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" + "os" + "path/filepath" + "strings" +) + +func LoadManifestsFromDir(dir string) ([]manifests.Manifest, error) { + var mans []manifests.Manifest + + files, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var manifest manifests.Manifest + var content []byte + + for _, file := range files { + if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) { + continue + } + + content, err = os.ReadFile(filepath.Join(dir, file.Name())) + if err != nil { + return nil, err + } + + manifest, err = ParseManifest(content) + if err != nil { + return nil, fmt.Errorf("in file %s: %w", file.Name(), err) + } + + mans = append(mans, manifest) + } + + return mans, nil +} diff --git a/internal/yaml/parse.go b/internal/yaml/parse.go new file mode 100644 index 0000000..c4c8b0c --- /dev/null +++ b/internal/yaml/parse.go @@ -0,0 +1,58 @@ +package yaml + +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/load" + "github.com/apiqube/cli/internal/manifests/server" + "github.com/apiqube/cli/internal/manifests/service" + "github.com/apiqube/cli/internal/manifests/tests" + "gopkg.in/yaml.v3" +) + +type RawManifest struct { + Kind string `yaml:"kind"` +} + +func ParseManifest(data []byte) (manifests.Manifest, error) { + var raw RawManifest + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to read kind: %w", err) + } + + switch raw.Kind { + case manifests.ServerManifestKind: + var m server.Server + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + + return m.Default(), nil + case manifests.ServiceManifestKind: + var m service.Service + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + + return m.Default(), nil + + case manifests.HttpTestManifestKind: + var m tests.Http + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + + return m.Default(), nil + case manifests.HttpLoadTestManifestKind: + var m load.Http + + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, err + } + + return m.Default(), nil + + default: + return nil, fmt.Errorf("unsupported kind: %s", raw.Kind) + } +} From bdae100215ed515f475ec03a91fcf3d2e574272a Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 03:34:16 +0200 Subject: [PATCH 5/9] chore(cmd): run command removed but added apply command --- cmd/apply.go | 41 +++++++++++++++++++++++++++ cmd/run.go | 79 ---------------------------------------------------- 2 files changed, 41 insertions(+), 79 deletions(-) create mode 100644 cmd/apply.go delete mode 100644 cmd/run.go diff --git a/cmd/apply.go b/cmd/apply.go new file mode 100644 index 0000000..3484fa9 --- /dev/null +++ b/cmd/apply.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "github.com/apiqube/cli/internal/yaml" + "github.com/spf13/cobra" +) + +func init() { + applyCmd.Flags().StringP("file", "f", "", "Path to manifest file") + rootCmd.AddCommand(applyCmd) +} + +var applyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply resources from manifest file", + RunE: func(cmd *cobra.Command, args []string) error { + file, err := cmd.Flags().GetString("file") + if err != nil { + return err + } + if file == "" { + return fmt.Errorf("no manifest file provided (use -f or --file)") + } + + fmt.Println("Applying manifest from:", file) + + manifests, err := yaml.LoadManifestsFromDir(file) + if err != nil { + return err + } + + for i, manifest := range manifests { + fmt.Printf("%d\nKind: %s\nName: %s\n Namespace: %s\n", + i, manifest.GetKind(), manifest.GetName(), manifest.GetNamespace(), + ) + } + + return nil + }, +} diff --git a/cmd/run.go b/cmd/run.go deleted file mode 100644 index 9f097c9..0000000 --- a/cmd/run.go +++ /dev/null @@ -1,79 +0,0 @@ -package cmd - -import ( - "fmt" - "github.com/apiqube/cli/internal/config" - "github.com/apiqube/cli/internal/yaml" - tea "github.com/charmbracelet/bubbletea" - - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(runCmd) -} - -var runCmd = &cobra.Command{ - Use: "run [test file]", - Short: "Run test scenarios with provided configuration", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - testFile := args[0] - - tea.Printf("Configuration file path: %s\n", testFile)() - - cfg, err := yaml.LoadConfig[config.Config](testFile) - if err != nil { - fmt.Println("Error loading config:", err) - return - } - - p := tea.NewProgram(testModel{config: cfg}) - - if _, err := p.Run(); err != nil { - fmt.Println("Error starting program:", err) - } - }, -} - -type testModel struct { - config *config.Config - cursor int -} - -func (m testModel) Init() tea.Cmd { - return nil -} - -func (m testModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch message := msg.(type) { - case tea.KeyMsg: - switch message.String() { - case "ctrl+c": - case "q": - return m, tea.Quit - } - } - return m, nil -} - -func (m testModel) View() string { - var out string - out += fmt.Sprintf("Test Manager - Version %s\n", m.config.Version) - out += "Press 'q' to quit.\n\n" - - for _, test := range m.config.Tests { - out += fmt.Sprintf("Test: %s\n", test.Name) - out += fmt.Sprintf("Description: %s\n", test.Description) - if len(test.Flags) > 0 { - out += "Flags: " - for _, flag := range test.Flags { - out += flag + " " - } - out += "\n" - } - out += "\n" - } - - return out -} From 77056242e99a0a5c15b9f294000dbd11770d4253 Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 04:02:20 +0200 Subject: [PATCH 6/9] chore: some fixing and adjustments in parsing logic, added on testing time yaml marshaling script --- cmd/apply.go | 10 +++++-- examples/simple/combined.yaml | 9 ++++++ examples/simple/server.yaml | 12 ++++++++ go.mod | 24 ++------------- go.sum | 45 ---------------------------- internal/manifests/base.go | 2 +- internal/yaml/{load.go => loader.go} | 2 +- internal/yaml/parse.go | 22 ++++++++------ internal/yaml/saver.go | 38 +++++++++++++++++++++++ 9 files changed, 83 insertions(+), 81 deletions(-) create mode 100644 examples/simple/combined.yaml create mode 100644 examples/simple/server.yaml rename internal/yaml/{load.go => loader.go} (82%) create mode 100644 internal/yaml/saver.go diff --git a/cmd/apply.go b/cmd/apply.go index 3484fa9..9f76f7e 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -7,7 +7,7 @@ import ( ) func init() { - applyCmd.Flags().StringP("file", "f", "", "Path to manifest file") + applyCmd.Flags().StringP("file", "f", ".", "Path to manifest file") rootCmd.AddCommand(applyCmd) } @@ -31,11 +31,15 @@ var applyCmd = &cobra.Command{ } for i, manifest := range manifests { - fmt.Printf("%d\nKind: %s\nName: %s\n Namespace: %s\n", - i, manifest.GetKind(), manifest.GetName(), manifest.GetNamespace(), + fmt.Printf("%d\nKind: %s\nName: %s\nNamespace: %s\n", + i+1, manifest.GetKind(), manifest.GetName(), manifest.GetNamespace(), ) } + if err := yaml.SaveManifests(file, manifests...); err != nil { + return err + } + return nil }, } diff --git a/examples/simple/combined.yaml b/examples/simple/combined.yaml new file mode 100644 index 0000000..7105c89 --- /dev/null +++ b/examples/simple/combined.yaml @@ -0,0 +1,9 @@ +version: 1 +kind: Server +metadata: + name: simple-server + namespace: simple-server-space +spec: + baseUrl: http://localhost:8080 + headers: + Content-Type: application/json diff --git a/examples/simple/server.yaml b/examples/simple/server.yaml new file mode 100644 index 0000000..7b3caa0 --- /dev/null +++ b/examples/simple/server.yaml @@ -0,0 +1,12 @@ +version: 1 + +kind: Server + +metadata: + name: simple-server + namespace: simple-server-space + +spec: + baseUrl: "http://localhost:8080" + headers: + Content-Type: application/json \ No newline at end of file diff --git a/go.mod b/go.mod index ffdcd5c..ac60359 100644 --- a/go.mod +++ b/go.mod @@ -1,34 +1,14 @@ module github.com/apiqube/cli -go 1.24.2 +go 1.24.3 require ( - github.com/charmbracelet/bubbletea v1.3.5 + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 8f7db3f..b7853cc 100644 --- a/go.sum +++ b/go.sum @@ -1,58 +1,13 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= -github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -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/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/manifests/base.go b/internal/manifests/base.go index 394b061..377a8c1 100644 --- a/internal/manifests/base.go +++ b/internal/manifests/base.go @@ -3,6 +3,6 @@ package manifests type BaseManifest struct { Version uint8 `yaml:"version" valid:"required,numeric"` Kind string `yaml:"kind" valid:"required,alpha,in(Server|Service|HttpTest|HttpLoadTest)"` - Metadata `yaml:"metadata,inline"` + Metadata `yaml:"metadata"` DependsOn []string `yaml:"dependsOn,omitempty"` } diff --git a/internal/yaml/load.go b/internal/yaml/loader.go similarity index 82% rename from internal/yaml/load.go rename to internal/yaml/loader.go index 866c869..38d81cd 100644 --- a/internal/yaml/load.go +++ b/internal/yaml/loader.go @@ -20,7 +20,7 @@ func LoadManifestsFromDir(dir string) ([]manifests.Manifest, error) { var content []byte for _, file := range files { - if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) { + if file.IsDir() || file.Name() == "combined.yaml" || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) { continue } diff --git a/internal/yaml/parse.go b/internal/yaml/parse.go index c4c8b0c..21bd2cd 100644 --- a/internal/yaml/parse.go +++ b/internal/yaml/parse.go @@ -20,39 +20,43 @@ func ParseManifest(data []byte) (manifests.Manifest, error) { return nil, fmt.Errorf("failed to read kind: %w", err) } + var manifest manifests.Manifest + switch raw.Kind { case manifests.ServerManifestKind: var m server.Server - if err := yaml.Unmarshal(data, &m); err != nil { + + if err := yaml.Unmarshal(data, m.Default()); err != nil { return nil, err } - return m.Default(), nil + manifest = &m case manifests.ServiceManifestKind: var m service.Service - if err := yaml.Unmarshal(data, &m); err != nil { + if err := yaml.Unmarshal(data, m.Default()); err != nil { return nil, err } - return m.Default(), nil - + manifest = &m case manifests.HttpTestManifestKind: var m tests.Http - if err := yaml.Unmarshal(data, &m); err != nil { + if err := yaml.Unmarshal(data, m.Default()); err != nil { return nil, err } - return m.Default(), nil + manifest = &m case manifests.HttpLoadTestManifestKind: var m load.Http - if err := yaml.Unmarshal(data, &m); err != nil { + if err := yaml.Unmarshal(data, m.Default()); err != nil { return nil, err } - return m.Default(), nil + manifest = &m default: return nil, fmt.Errorf("unsupported kind: %s", raw.Kind) } + + return manifest, nil } diff --git a/internal/yaml/saver.go b/internal/yaml/saver.go new file mode 100644 index 0000000..abd2597 --- /dev/null +++ b/internal/yaml/saver.go @@ -0,0 +1,38 @@ +package yaml + +import ( + "bytes" + "fmt" + "github.com/apiqube/cli/internal/manifests" + "gopkg.in/yaml.v3" + "os" + "path/filepath" +) + +func SaveManifests(dir string, manifests ...manifests.Manifest) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + + var buf bytes.Buffer + + for i, manifest := range manifests { + data, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to marshal manifest %d: %w", i, err) + } + + if i > 0 { + buf.WriteString("---\n") + } + + buf.Write(data) + } + + outputPath := filepath.Join(dir, "combined.yaml") + if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} From 8f68e7c54488450e2692b56bc7db105b6b945964 Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 05:47:11 +0200 Subject: [PATCH 7/9] feat(manifests): added examples yaml manifest, generated and tested, added login building executions plans --- cmd/apply.go | 34 ++++-- examples/execution-plan.json | 8 ++ examples/simple/combined.yaml | 101 +++++++++++++++++- examples/simple/http_load_test.yaml | 27 +++++ examples/simple/http_test.yaml | 45 ++++++++ examples/simple/server.yaml | 1 - examples/simple/service.yaml | 37 +++++++ internal/manifests/depends/dependencies.go | 39 +++++++ internal/manifests/depends/plan.go | 42 ++++++++ internal/manifests/depends/sort.go | 59 ++++++++++ internal/manifests/interface.go | 6 +- internal/manifests/{ => kinds}/base.go | 7 +- internal/manifests/{ => kinds}/load/http.go | 24 +++-- .../manifests/{ => kinds}/server/server.go | 12 ++- .../manifests/{ => kinds}/service/service.go | 18 ++-- internal/manifests/{ => kinds}/tests/http.go | 14 ++- internal/yaml/parse.go | 8 +- 17 files changed, 443 insertions(+), 39 deletions(-) create mode 100644 examples/execution-plan.json create mode 100644 examples/simple/http_load_test.yaml create mode 100644 examples/simple/http_test.yaml create mode 100644 examples/simple/service.yaml create mode 100644 internal/manifests/depends/dependencies.go create mode 100644 internal/manifests/depends/plan.go create mode 100644 internal/manifests/depends/sort.go rename internal/manifests/{ => kinds}/base.go (63%) rename internal/manifests/{ => kinds}/load/http.go (65%) rename internal/manifests/{ => kinds}/server/server.go (74%) rename internal/manifests/{ => kinds}/service/service.go (76%) rename internal/manifests/{ => kinds}/tests/http.go (80%) diff --git a/cmd/apply.go b/cmd/apply.go index 9f76f7e..c2d013d 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "github.com/apiqube/cli/internal/manifests/depends" "github.com/apiqube/cli/internal/yaml" "github.com/spf13/cobra" ) @@ -25,18 +26,39 @@ var applyCmd = &cobra.Command{ fmt.Println("Applying manifest from:", file) - manifests, err := yaml.LoadManifestsFromDir(file) + mans, err := yaml.LoadManifestsFromDir(file) if err != nil { return err } - for i, manifest := range manifests { - fmt.Printf("%d\nKind: %s\nName: %s\nNamespace: %s\n", - i+1, manifest.GetKind(), manifest.GetName(), manifest.GetNamespace(), - ) + if err = depends.GeneratePlan("./examples", mans); err != nil { + return err + } + + graph, _, err := depends.BuildDependencyGraph(mans) + if err != nil { + return err + } + + order, err := depends.TopoSort(graph) + if err != nil { + return err + } + + sortedMans, err := depends.SortManifestsByExecutionOrder(mans, order) + if err != nil { + return err + } + + for _, m := range sortedMans { + fmt.Printf("ManifestID: %s\n", m.GetID()) + } + + if err = depends.SaveExecutionPlan("./examples", order); err != nil { + return err } - if err := yaml.SaveManifests(file, manifests...); err != nil { + if err := yaml.SaveManifests(file, mans...); err != nil { return err } diff --git a/examples/execution-plan.json b/examples/execution-plan.json new file mode 100644 index 0000000..f9f0800 --- /dev/null +++ b/examples/execution-plan.json @@ -0,0 +1,8 @@ +{ + "order": [ + "default.Server.simple-server", + "default.Service.simple-service", + "default.HttpTest.simple-http-test", + "default.HttpLoadTest.simple-http-load-test" + ] +} \ No newline at end of file diff --git a/examples/simple/combined.yaml b/examples/simple/combined.yaml index 7105c89..6c36e69 100644 --- a/examples/simple/combined.yaml +++ b/examples/simple/combined.yaml @@ -1,9 +1,108 @@ version: 1 +kind: HttpLoadTest +metadata: + name: simple-http-load-test + namespace: default +dependsOn: + - default.HttpTest.simple-http-test +spec: + cases: + - name: user-login + method: GET + endpoint: /login + body: + email: example_email + password: example_password + expected: + code: 200 + - name: user-fetch + method: GET + endpoint: /users/{id} + expected: + code: 404 + message: User not found +--- +version: 1 +kind: HttpTest +metadata: + name: simple-http-test + namespace: default +dependsOn: + - default.Service.simple-service +spec: + server: simple-server + cases: + - name: user-register + method: POST + endpoint: /register + headers: + Authorization: some_jwt_token + type: some_data + body: + email: example_email + password: example_password + username: example_username + expected: + code: 201 + message: User successfully registered + timeout: 1s + async: true + repeats: 20 + - name: user-login + method: GET + endpoint: /login + body: + email: example_email + password: example_password + expected: + code: 200 + - name: user-fetch + method: GET + endpoint: /users/{id} + expected: + code: 404 + message: User not found +--- +version: 1 kind: Server metadata: name: simple-server - namespace: simple-server-space + namespace: default spec: baseUrl: http://localhost:8080 headers: Content-Type: application/json +--- +version: 1 +kind: Service +metadata: + name: simple-service + namespace: default +dependsOn: + - default.Server.simple-server +spec: + containers: + - name: users-service + containerName: users + dockerfile: some_path + image: some_path + ports: + - 8080:8080 + - 50051:50051 + env: + clean: clean-command + db_url: postgres:5432 + run: run-command + replicas: 3 + healthPath: /health + - name: auth-service + containerName: auth + dockerfile: some_path + image: some_path + ports: + - 8081:8081 + - 50052:50052 + env: + db_url: postgres:5432 + replicas: 6 + healthPath: /health diff --git a/examples/simple/http_load_test.yaml b/examples/simple/http_load_test.yaml new file mode 100644 index 0000000..390f6dc --- /dev/null +++ b/examples/simple/http_load_test.yaml @@ -0,0 +1,27 @@ +version: 1 + +kind: HttpLoadTest + +metadata: + name: simple-http-load-test + +spec: + cases: + - name: user-login + method: GET + endpoint: /login + body: + email: "example_email" + password: "example_password" + expected: + code: 200 + + - name: user-fetch + method: GET + endpoint: /users/{id} + expected: + code: 404 + message: "User not found" + +dependsOn: + - default.HttpTest.simple-http-test \ No newline at end of file diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml new file mode 100644 index 0000000..caa961d --- /dev/null +++ b/examples/simple/http_test.yaml @@ -0,0 +1,45 @@ +version: 1 + +kind: HttpTest + +metadata: + name: simple-http-test + +spec: + server: simple-server + cases: + - name: user-register + method: POST + endpoint: /register + headers: + type: some_data + Authorization: some_jwt_token + body: + username: "example_username" + email: "example_email" + password: "example_password" + expected: + code: 201 + message: "User successfully registered" + timeout: 1s + async: true + repeats: 20 + + - name: user-login + method: GET + endpoint: /login + body: + email: "example_email" + 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 diff --git a/examples/simple/server.yaml b/examples/simple/server.yaml index 7b3caa0..9084ef8 100644 --- a/examples/simple/server.yaml +++ b/examples/simple/server.yaml @@ -4,7 +4,6 @@ kind: Server metadata: name: simple-server - namespace: simple-server-space spec: baseUrl: "http://localhost:8080" diff --git a/examples/simple/service.yaml b/examples/simple/service.yaml new file mode 100644 index 0000000..c681984 --- /dev/null +++ b/examples/simple/service.yaml @@ -0,0 +1,37 @@ +version: 1 + +kind: Service + +metadata: + name: simple-service + +spec: + containers: + - name: users-service + containerName: users + dockerfile: some_path + image: some_path + ports: + - 8080:8080 + - 50051:50051 + env: + run: run-command + clean: clean-command + db_url: postgres:5432 + replicas: 3 + healthPath: /health + + - name: auth-service + containerName: auth + dockerfile: some_path + image: some_path + ports: + - 8081:8081 + - 50052:50052 + env: + db_url: postgres:5432 + replicas: 6 + healthPath: /health + +dependsOn: + - default.Server.simple-server \ No newline at end of file diff --git a/internal/manifests/depends/dependencies.go b/internal/manifests/depends/dependencies.go new file mode 100644 index 0000000..99c12d3 --- /dev/null +++ b/internal/manifests/depends/dependencies.go @@ -0,0 +1,39 @@ +package depends + +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" +) + +type Node struct { + ID string + Manifest manifests.Manifest + Depends []string +} + +func BuildDependencyGraph(mans []manifests.Manifest) (map[string][]string, map[string]manifests.Manifest, error) { + graph := make(map[string][]string) + idToManifest := make(map[string]manifests.Manifest) + + for _, m := range mans { + id := m.GetID() + idToManifest[id] = m + graph[id] = []string{} + } + + for id, manifest := range idToManifest { + for _, dep := range manifest.GetDependsOn() { + if dep == id { + return nil, nil, fmt.Errorf("manifest %s cannot depend on itself", id) + } + + if _, ok := idToManifest[dep]; !ok { + return nil, nil, fmt.Errorf("manifest %s depends on unknown manifest %s", id, dep) + } + + graph[id] = append(graph[id], dep) + } + } + + return graph, idToManifest, nil +} diff --git a/internal/manifests/depends/plan.go b/internal/manifests/depends/plan.go new file mode 100644 index 0000000..cb728c7 --- /dev/null +++ b/internal/manifests/depends/plan.go @@ -0,0 +1,42 @@ +package depends + +import ( + "encoding/json" + "fmt" + "github.com/apiqube/cli/internal/manifests" + "os" + "path/filepath" +) + +type ExecutionPlan struct { + Order []string `json:"order"` +} + +func GeneratePlan(outPath string, manifests []manifests.Manifest) error { + graph, _, err := BuildDependencyGraph(manifests) + if err != nil { + return err + } + + order, err := TopoSort(graph) + if err != nil { + return err + } + + return SaveExecutionPlan(outPath, order) +} + +func SaveExecutionPlan(path string, order []string) error { + plan := ExecutionPlan{Order: order} + + data, err := json.MarshalIndent(plan, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal plan: %w", err) + } + + if err = os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + + return os.WriteFile(filepath.Join(path, "execution-plan.json"), data, 0644) +} diff --git a/internal/manifests/depends/sort.go b/internal/manifests/depends/sort.go new file mode 100644 index 0000000..77c699e --- /dev/null +++ b/internal/manifests/depends/sort.go @@ -0,0 +1,59 @@ +package depends + +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" +) + +func TopoSort(graph map[string][]string) ([]string, error) { + visited := make(map[string]bool) + temp := make(map[string]bool) + var result []string + + var visit func(string) error + visit = func(n string) error { + if temp[n] { + return fmt.Errorf("circular dependency detected at %s", n) + } + if !visited[n] { + temp[n] = true + for _, dep := range graph[n] { + if err := visit(dep); err != nil { + return err + } + } + visited[n] = true + temp[n] = false + result = append(result, n) + } + return nil + } + + for node := range graph { + if err := visit(node); err != nil { + return nil, err + } + } + + return result, nil +} + +func SortManifestsByExecutionOrder(mans []manifests.Manifest, order []string) ([]manifests.Manifest, error) { + idMap := make(map[string]manifests.Manifest) + for _, m := range mans { + idMap[m.GetID()] = m + } + + sorted := make([]manifests.Manifest, 0, len(order)) + + for _, id := range order { + m, ok := idMap[id] + if !ok { + return nil, fmt.Errorf("manifest %s not found in loaded manifests", id) + } + + sorted = append(sorted, m) + } + + return sorted, nil +} diff --git a/internal/manifests/interface.go b/internal/manifests/interface.go index d74a862..ef01d3a 100644 --- a/internal/manifests/interface.go +++ b/internal/manifests/interface.go @@ -10,6 +10,7 @@ const ( ) type Manifest interface { + GetID() string GetKind() string GetName() string GetNamespace() string @@ -19,8 +20,3 @@ type Manifest interface { type Defaultable[T Manifest] interface { Default() T } - -type Metadata struct { - Name string `yaml:"name" valid:"required,alpha"` - Namespace string `yaml:"namespace" valid:"required,alpha"` -} diff --git a/internal/manifests/base.go b/internal/manifests/kinds/base.go similarity index 63% rename from internal/manifests/base.go rename to internal/manifests/kinds/base.go index 377a8c1..5ca9937 100644 --- a/internal/manifests/base.go +++ b/internal/manifests/kinds/base.go @@ -1,4 +1,9 @@ -package manifests +package kinds + +type Metadata struct { + Name string `yaml:"name" valid:"required,alpha"` + Namespace string `yaml:"namespace" valid:"required,alpha"` +} type BaseManifest struct { Version uint8 `yaml:"version" valid:"required,numeric"` diff --git a/internal/manifests/load/http.go b/internal/manifests/kinds/load/http.go similarity index 65% rename from internal/manifests/load/http.go rename to internal/manifests/kinds/load/http.go index f29e2e5..a374038 100644 --- a/internal/manifests/load/http.go +++ b/internal/manifests/kinds/load/http.go @@ -1,7 +1,9 @@ package load import ( + "fmt" "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" "time" ) @@ -9,26 +11,26 @@ var _ manifests.Manifest = (*Http)(nil) var _ manifests.Defaultable[*Http] = (*Http)(nil) type Http struct { - manifests.BaseManifest `yaml:",inline"` + kinds.BaseManifest `yaml:",inline"` Spec struct { - Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` + Server string `yaml:"server,omitempty"` + Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` } `yaml:"spec" valid:"required"` } type HttpCase struct { + Name string `yaml:"name" valid:"required"` Method string `yaml:"method" valid:"required,uppercase,in(GET|POST|PUT|DELETE)"` Endpoint string `yaml:"endpoint,omitempty"` Url string `yaml:"url,omitempty"` Headers map[string]string `yaml:"headers,omitempty"` Body map[string]interface{} `yaml:"body,omitempty"` - - Expected *HttpExpect `yaml:"expected,omitempty"` - Extract *HttpExtractRule `yaml:"extract,omitempty"` - - Timeout time.Duration `yaml:"timeout,omitempty"` - Async bool `yaml:"async,omitempty"` - Repeats int `yaml:"repeats,omitempty"` + Expected *HttpExpect `yaml:"expected,omitempty"` + Extract *HttpExtractRule `yaml:"extract,omitempty"` + Timeout time.Duration `yaml:"timeout,omitempty"` + Async bool `yaml:"async,omitempty"` + Repeats int `yaml:"repeats,omitempty"` } type HttpExpect struct { @@ -42,6 +44,10 @@ type HttpExtractRule struct { Value string `yaml:"value,omitempty"` } +func (h *Http) GetID() string { + return fmt.Sprintf("%s.%s.%s", h.Namespace, h.Kind, h.Name) +} + func (h *Http) GetKind() string { return h.Kind } diff --git a/internal/manifests/server/server.go b/internal/manifests/kinds/server/server.go similarity index 74% rename from internal/manifests/server/server.go rename to internal/manifests/kinds/server/server.go index 7be33c0..b5332e3 100644 --- a/internal/manifests/server/server.go +++ b/internal/manifests/kinds/server/server.go @@ -1,12 +1,16 @@ package server -import "github.com/apiqube/cli/internal/manifests" +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) var _ manifests.Manifest = (*Server)(nil) var _ manifests.Defaultable[*Server] = (*Server)(nil) type Server struct { - manifests.BaseManifest `yaml:",inline"` + kinds.BaseManifest `yaml:",inline"` Spec struct { BaseUrl string `yaml:"baseUrl" valid:"required,url"` @@ -14,6 +18,10 @@ type Server struct { } `yaml:"spec" valid:"required"` } +func (s *Server) GetID() string { + return fmt.Sprintf("%s.%s.%s", s.Namespace, s.Kind, s.Name) +} + func (s *Server) GetKind() string { return s.Kind } diff --git a/internal/manifests/service/service.go b/internal/manifests/kinds/service/service.go similarity index 76% rename from internal/manifests/service/service.go rename to internal/manifests/kinds/service/service.go index ffc4dc5..e929b56 100644 --- a/internal/manifests/service/service.go +++ b/internal/manifests/kinds/service/service.go @@ -1,23 +1,25 @@ package service -import "github.com/apiqube/cli/internal/manifests" +import ( + "fmt" + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) var _ manifests.Manifest = (*Service)(nil) var _ manifests.Defaultable[*Service] = (*Service)(nil) type Service struct { - manifests.BaseManifest `yaml:",inline"` + kinds.BaseManifest `yaml:",inline"` Spec struct { Containers []Container `yaml:"containers" valid:"required,length(1|50)"` } `yaml:"spec" valid:"required"` - - DependsOn []string `yaml:"dependsOn,omitempty"` } type Container struct { Name string `yaml:"name" valid:"required"` - ContainerName string `yaml:"container_name,omitempty"` + ContainerName string `yaml:"containerName,omitempty"` Dockerfile string `yaml:"dockerfile,omitempty"` Image string `yaml:"image,omitempty"` Ports []string `yaml:"ports,omitempty"` @@ -25,13 +27,17 @@ type Container struct { Command string `yaml:"command,omitempty"` Depends *ContainerDepend `yaml:"depends,omitempty"` Replicas int `yaml:"replicas,omitempty" valid:"length(0|25)"` - HealthPath string `yaml:"health_path,omitempty"` + HealthPath string `yaml:"healthPath,omitempty"` } type ContainerDepend struct { Depends []string `yaml:"depends,omitempty" valid:"required,length(1|25)"` } +func (s *Service) GetID() string { + return fmt.Sprintf("%s.%s.%s", s.Namespace, s.Kind, s.Name) +} + func (s *Service) GetKind() string { return s.Kind } diff --git a/internal/manifests/tests/http.go b/internal/manifests/kinds/tests/http.go similarity index 80% rename from internal/manifests/tests/http.go rename to internal/manifests/kinds/tests/http.go index d13242f..4114d48 100644 --- a/internal/manifests/tests/http.go +++ b/internal/manifests/kinds/tests/http.go @@ -1,7 +1,9 @@ package tests import ( + "fmt" "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" "time" ) @@ -9,16 +11,16 @@ var _ manifests.Manifest = (*Http)(nil) var _ manifests.Defaultable[*Http] = (*Http)(nil) type Http struct { - manifests.BaseManifest `yaml:",inline"` + kinds.BaseManifest `yaml:",inline"` Spec struct { - Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` + Server string `yaml:"server,omitempty"` + Cases []HttpCase `yaml:"cases" valid:"required,length(1|100)"` } `yaml:"spec" valid:"required"` - - DependsOn []string `yaml:"dependsOn,omitempty"` } type HttpCase struct { + Name string `yaml:"name" valid:"required"` Method string `yaml:"method" valid:"required,uppercase,in(GET|POST|PUT|DELETE)"` Endpoint string `yaml:"endpoint,omitempty"` Url string `yaml:"url,omitempty"` @@ -42,6 +44,10 @@ type HttpExtractRule struct { Value string `yaml:"value,omitempty"` } +func (h *Http) GetID() string { + return fmt.Sprintf("%s.%s.%s", h.Namespace, h.Kind, h.Name) +} + func (h *Http) GetKind() string { return h.Kind } diff --git a/internal/yaml/parse.go b/internal/yaml/parse.go index 21bd2cd..f1c3401 100644 --- a/internal/yaml/parse.go +++ b/internal/yaml/parse.go @@ -3,10 +3,10 @@ package yaml import ( "fmt" "github.com/apiqube/cli/internal/manifests" - "github.com/apiqube/cli/internal/manifests/load" - "github.com/apiqube/cli/internal/manifests/server" - "github.com/apiqube/cli/internal/manifests/service" - "github.com/apiqube/cli/internal/manifests/tests" + "github.com/apiqube/cli/internal/manifests/kinds/load" + "github.com/apiqube/cli/internal/manifests/kinds/server" + "github.com/apiqube/cli/internal/manifests/kinds/service" + "github.com/apiqube/cli/internal/manifests/kinds/tests" "gopkg.in/yaml.v3" ) From 6c937d10261c104559b8a793d1b5afd67295632b Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 10:15:39 +0200 Subject: [PATCH 8/9] feat(ui): added ui bubletea for project --- cmd/apply.go | 52 +-- examples/simple/combined.yaml | 112 +++---- examples/{ => simple}/execution-plan.json | 0 go.mod | 23 ++ go.sum | 51 +++ internal/ui/console.go | 45 +++ internal/ui/elements.go | 388 ++++++++++++++++++++++ internal/ui/model.go | 97 ++++++ internal/ui/styles.go | 120 +++++++ internal/ui/ui.go | 152 +++++++++ 10 files changed, 960 insertions(+), 80 deletions(-) rename examples/{ => simple}/execution-plan.json (100%) create mode 100644 internal/ui/console.go create mode 100644 internal/ui/elements.go create mode 100644 internal/ui/model.go create mode 100644 internal/ui/styles.go create mode 100644 internal/ui/ui.go diff --git a/cmd/apply.go b/cmd/apply.go index c2d013d..ac98cae 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -1,10 +1,12 @@ package cmd import ( - "fmt" "github.com/apiqube/cli/internal/manifests/depends" + "github.com/apiqube/cli/internal/ui" "github.com/apiqube/cli/internal/yaml" "github.com/spf13/cobra" + "slices" + "time" ) func init() { @@ -16,52 +18,54 @@ var applyCmd = &cobra.Command{ Use: "apply", Short: "Apply resources from manifest file", RunE: func(cmd *cobra.Command, args []string) error { + ui.Init() + defer func() { + time.Sleep(time.Millisecond * 100) + ui.Stop() + }() + file, err := cmd.Flags().GetString("file") if err != nil { + ui.Errorf("Failed to parse --file: %s", err.Error()) return err } - if file == "" { - return fmt.Errorf("no manifest file provided (use -f or --file)") - } - fmt.Println("Applying manifest from:", file) + ui.Printf("Applying manifests from: %s", file) + ui.Spinner(true, "Loading manifests") mans, err := yaml.LoadManifestsFromDir(file) if err != nil { + ui.Errorf("Failed to load manifests: %s", err.Error()) return err } - if err = depends.GeneratePlan("./examples", mans); err != nil { - return err - } + ui.Spinner(false) + ui.Printf("Loaded %d manifests", len(mans)) - graph, _, err := depends.BuildDependencyGraph(mans) - if err != nil { - return err + slices.Reverse(mans) + for i, man := range mans { + ui.Printf("#%d ID: %s", i+1, man.GetID()) } - order, err := depends.TopoSort(graph) - if err != nil { - return err - } + ui.Spinner(true, "Generating execution plan") - sortedMans, err := depends.SortManifestsByExecutionOrder(mans, order) - if err != nil { + if err = depends.GeneratePlan("./examples/simple", mans); err != nil { + ui.Errorf("Failed to generate plan: %s", err.Error()) return err } - for _, m := range sortedMans { - fmt.Printf("ManifestID: %s\n", m.GetID()) - } - - if err = depends.SaveExecutionPlan("./examples", order); err != nil { - return err - } + ui.Spinner(false) + ui.Print("Execution plan generated successfully") + ui.Spinner(true, "Saving manifests...") if err := yaml.SaveManifests(file, mans...); err != nil { + ui.Error("Failed to save manifests: " + err.Error()) return err } + ui.Spinner(false) + ui.Println("Manifests applied successfully") + return nil }, } diff --git a/examples/simple/combined.yaml b/examples/simple/combined.yaml index 6c36e69..57ebc16 100644 --- a/examples/simple/combined.yaml +++ b/examples/simple/combined.yaml @@ -1,26 +1,46 @@ version: 1 -kind: HttpLoadTest +kind: Service metadata: - name: simple-http-load-test + name: simple-service namespace: default dependsOn: - - default.HttpTest.simple-http-test + - default.Server.simple-server spec: - cases: - - name: user-login - method: GET - endpoint: /login - body: - email: example_email - password: example_password - expected: - code: 200 - - name: user-fetch - method: GET - endpoint: /users/{id} - expected: - code: 404 - message: User not found + containers: + - name: users-service + containerName: users + dockerfile: some_path + image: some_path + ports: + - 8080:8080 + - 50051:50051 + env: + clean: clean-command + db_url: postgres:5432 + run: run-command + replicas: 3 + healthPath: /health + - name: auth-service + containerName: auth + dockerfile: some_path + image: some_path + ports: + - 8081:8081 + - 50052:50052 + env: + db_url: postgres:5432 + replicas: 6 + healthPath: /health +--- +version: 1 +kind: Server +metadata: + name: simple-server + namespace: default +spec: + baseUrl: http://localhost:8080 + headers: + Content-Type: application/json --- version: 1 kind: HttpTest @@ -64,45 +84,25 @@ spec: message: User not found --- version: 1 -kind: Server -metadata: - name: simple-server - namespace: default -spec: - baseUrl: http://localhost:8080 - headers: - Content-Type: application/json ---- -version: 1 -kind: Service +kind: HttpLoadTest metadata: - name: simple-service + name: simple-http-load-test namespace: default dependsOn: - - default.Server.simple-server + - default.HttpTest.simple-http-test spec: - containers: - - name: users-service - containerName: users - dockerfile: some_path - image: some_path - ports: - - 8080:8080 - - 50051:50051 - env: - clean: clean-command - db_url: postgres:5432 - run: run-command - replicas: 3 - healthPath: /health - - name: auth-service - containerName: auth - dockerfile: some_path - image: some_path - ports: - - 8081:8081 - - 50052:50052 - env: - db_url: postgres:5432 - replicas: 6 - healthPath: /health + cases: + - name: user-login + method: GET + endpoint: /login + body: + email: example_email + password: example_password + expected: + code: 200 + - name: user-fetch + method: GET + endpoint: /users/{id} + expected: + code: 404 + message: User not found diff --git a/examples/execution-plan.json b/examples/simple/execution-plan.json similarity index 100% rename from examples/execution-plan.json rename to examples/simple/execution-plan.json diff --git a/go.mod b/go.mod index ac60359..015e2be 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,34 @@ go 1.24.3 require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.5 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index b7853cc..627fc1f 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,64 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= +github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/ui/console.go b/internal/ui/console.go new file mode 100644 index 0000000..940c594 --- /dev/null +++ b/internal/ui/console.go @@ -0,0 +1,45 @@ +package ui + +func Print(a ...interface{}) { + printStyled(TypeLog, a...) +} + +func Printf(format string, a ...interface{}) { + printStyledf(TypeLog, format, a...) +} + +func Println(a ...interface{}) { + printStyledln(TypeLog, a...) +} + +func Success(a ...interface{}) { + printStyled(TypeSuccess, a...) +} + +func Successf(format string, a ...interface{}) { + printStyledf(TypeSuccess, format, a...) +} + +func Error(a ...interface{}) { + printStyled(TypeError, a...) +} + +func Errorf(format string, a ...interface{}) { + printStyledf(TypeError, format, a...) +} + +func Warning(a ...interface{}) { + printStyled(TypeWarning, a...) +} + +func Warningf(format string, a ...interface{}) { + printStyledf(TypeWarning, format, a...) +} + +func Info(a ...interface{}) { + printStyled(TypeInfo, a...) +} + +func Infof(format string, a ...interface{}) { + printStyledf(TypeInfo, format, a...) +} diff --git a/internal/ui/elements.go b/internal/ui/elements.go new file mode 100644 index 0000000..4d8fa8f --- /dev/null +++ b/internal/ui/elements.go @@ -0,0 +1,388 @@ +package ui + +import ( + "fmt" + "github.com/charmbracelet/lipgloss" + "strings" + "time" +) + +func Progress(percent float64, text ...string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + textStr := "" + if len(text) > 0 { + textStr = strings.Join(text, " ") + } + + if percent >= 100 { + m.removeLastProgress() + return + } + + m.updateOrAddProgress(percent, textStr) + }) +} + +func Loader(show bool, text ...string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + textStr := "" + if len(text) > 0 { + textStr = strings.Join(text, " ") + } + + if !show { + m.removeLastLoader() + return + } + + m.updateOrAddLoader(textStr) + }) +} + +func Snippet(code string) { + if IsEnabled() { + instance.queueUpdate(func(m *uiModel) { + m.content = append(m.content, message{ + text: code, + style: snippetStyle, + }) + trimContent(m) + }) + } +} + +func PackageManager(action, pkg, status string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + m.elements = append(m.elements, Element{ + elementType: TypePackage, + action: action, + packageName: pkg, + status: status, + }) + }) +} + +func RealtimeMsg(content string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + m.elements = append(m.elements, Element{ + elementType: TypeRealtime, + content: content, + }) + }) +} + +func Spinner(show bool, text ...string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + textStr := "" + if len(text) > 0 { + textStr = strings.Join(text, " ") + } + + if !show { + m.removeLastSpinner() + return + } + + m.updateOrAddSpinner(textStr) + }) +} + +func Stopwatch(start bool, name ...string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + nameStr := "" + if len(name) > 0 { + nameStr = strings.Join(name, " ") + } + + if !start { + m.removeLastStopwatch() + return + } + + m.updateOrAddStopwatch(nameStr) + }) +} + +func Table(headers []string, data [][]string) { + if !IsEnabled() { + return + } + + instance.queueUpdate(func(m *uiModel) { + m.elements = append(m.elements, Element{ + elementType: TypeTable, + tableHeaders: headers, + tableData: data, + }) + }) +} + +func (m *uiModel) removeLastProgress() { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeProgress { + m.elements = append(m.elements[:i], m.elements[i+1:]...) + return + } + } +} + +func (m *uiModel) removeLastLoader() { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeLoader { + m.elements = append(m.elements[:i], m.elements[i+1:]...) + return + } + } +} + +func (m *uiModel) updateOrAddProgress(percent float64, text string) { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeProgress { + m.elements[i].progress = percent + m.elements[i].progressText = text + return + } + } + + m.elements = append(m.elements, Element{ + elementType: TypeProgress, + progress: percent, + progressText: text, + }) +} + +func (m *uiModel) updateOrAddLoader(text string) { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeLoader { + m.elements[i].showLoader = true + m.elements[i].loaderText = text + return + } + } + + m.elements = append(m.elements, Element{ + elementType: TypeLoader, + showLoader: true, + loaderText: text, + }) +} + +func renderPackage(header, body, status string) string { + actionStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + pkgStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + statusStyle := lipgloss.NewStyle() + + switch status { + case "done": + statusStyle = statusStyle.Foreground(lipgloss.Color("10")) + case "error": + statusStyle = statusStyle.Foreground(lipgloss.Color("9")).Bold(true) + case "working": + statusStyle = statusStyle.Foreground(lipgloss.Color("214")) + default: + statusStyle = statusStyle.Foreground(lipgloss.Color("240")) + } + + return fmt.Sprintf("%s %s %s", + actionStyle.Render(header), + pkgStyle.Render(body), + statusStyle.Render(status), + ) +} + +func renderRealtime(content string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("45")). + Render("↳ " + content) +} + +func renderSpinner(index int, text string) string { + if text == "" { + text = "Processing" + } + return spinnerStyle. + Render(fmt.Sprintf("%s %s", spinners[index], text)) +} + +func renderStopwatch(startTime time.Time, name string) string { + duration := time.Since(startTime).Round(time.Second) + timeStr := fmt.Sprintf("%02d:%02d:%02d", + int(duration.Hours()), + int(duration.Minutes())%60, + int(duration.Seconds())%60) + + text := timeStr + if name != "" { + text = name + ": " + timeStr + } + + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("51")). + Render("⏱ " + text) +} + +func renderTable(headers []string, data [][]string) string { + if len(headers) == 0 || len(data) == 0 { + return "" + } + + colWidths := make([]int, len(headers)) + for i, h := range headers { + colWidths[i] = len(h) + } + + for _, row := range data { + for i, cell := range row { + if len(cell) > colWidths[i] { + colWidths[i] = len(cell) + } + } + } + + var sb strings.Builder + headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + cellStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + + for i, h := range headers { + sb.WriteString(headerStyle.Render(fmt.Sprintf("%-*s", colWidths[i]+2, h))) + } + sb.WriteString("\n") + + for _, w := range colWidths { + sb.WriteString(strings.Repeat("─", w+2)) + } + sb.WriteString("\n") + + for _, row := range data { + for i, cell := range row { + sb.WriteString(cellStyle.Render(fmt.Sprintf("%-*s", colWidths[i]+2, cell))) + } + sb.WriteString("\n") + } + + return sb.String() +} + +func renderProgressBar(percent float64, text string) string { + const width = 30 + filled := int(percent / 100 * width) + unfilled := width - filled + + percentStr := fmt.Sprintf("%3.0f%%", percent) + bar := strings.Repeat("█", filled) + strings.Repeat("░", unfilled) + + if text == "" { + text = fmt.Sprintf("%.1f%%", percent) + } + + percentPart := progressTextStyle.Render(" " + percentStr + " ") + barPart := progressBarStyle.Render(bar) + textPart := progressTextStyle.Render(" " + text) + + return textPart + barPart + percentPart +} + +func renderLoader(text string) string { + if text == "" { + text = "Processing..." + } + return loaderStyle.Render("↻ " + text) +} + +func (m *uiModel) replaceLastProgress(percent float64, text string) { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeProgress { + m.elements[i].progress = percent + m.elements[i].progressText = text + return + } + } + + m.elements = append(m.elements, Element{ + elementType: TypeProgress, + progress: percent, + progressText: text, + }) +} + +func renderSnippet(code string) string { + return snippetStyle.Render(code) +} + +func (m *uiModel) removeLastSpinner() { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeSpinner { + m.elements = append(m.elements[:i], m.elements[i+1:]...) + return + } + } +} + +func (m *uiModel) updateOrAddSpinner(text string) { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeSpinner { + m.elements[i].showSpinner = true + m.elements[i].spinnerText = text + return + } + } + + m.elements = append(m.elements, Element{ + elementType: TypeSpinner, + showSpinner: true, + spinnerText: text, + }) +} + +func (m *uiModel) removeLastStopwatch() { + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeStopwatch { + m.elements = append(m.elements[:i], m.elements[i+1:]...) + return + } + } +} + +func (m *uiModel) updateOrAddStopwatch(name string) { + now := time.Now() + for i := len(m.elements) - 1; i >= 0; i-- { + if m.elements[i].elementType == TypeStopwatch { + if name != "" && m.elements[i].content != name { + continue + } + m.elements[i].startTime = now + m.elements[i].content = name + return + } + } + + m.elements = append(m.elements, Element{ + elementType: TypeStopwatch, + startTime: now, + content: name, + }) +} diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..8668ed5 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,97 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "strings" + "sync" + "time" +) + +type uiModel struct { + elements []Element + content []message + mu sync.Mutex + spinnerIndex int +} + +type message struct { + text string + style lipgloss.Style + timestamp time.Time +} + +type updateFunc func(*uiModel) + +type forceRefresh struct{} + +func (m *uiModel) Init() tea.Cmd { + return nil +} + +func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case tea.KeyMsg: + return m, tea.Quit + case forceRefresh: + return m, nil + } + return m, nil +} + +func (m *uiModel) View() string { + m.mu.Lock() + defer m.mu.Unlock() + + var sb strings.Builder + + m.spinnerIndex = (m.spinnerIndex + 1) % len(spinners) + + for _, msg := range m.content { + sb.WriteString(timestampStyle.Render(msg.timestamp.Format("15:04:05")) + " " + msg.style.Render(msg.text)) + sb.WriteString("\n") + } + + for _, elem := range m.elements { + switch elem.elementType { + case TypeProgress: + sb.WriteString(renderProgressBar(elem.progress, elem.progressText)) + sb.WriteString("\n\n") + + case TypeLoader: + if elem.showLoader { + sb.WriteString(renderLoader(elem.loaderText)) + sb.WriteString("\n\n") + } + + case TypePackage: + sb.WriteString(renderPackage(elem.action, elem.packageName, elem.status)) + sb.WriteString("\n") + + case TypeRealtime: + sb.WriteString(renderRealtime(elem.content)) + sb.WriteString("\n") + + case TypeSpinner: + if elem.showSpinner { + sb.WriteString(renderSpinner(m.spinnerIndex, elem.spinnerText)) + sb.WriteString("\n") + } + + case TypeStopwatch: + sb.WriteString(renderStopwatch(elem.startTime, elem.content)) + sb.WriteString("\n") + + case TypeTable: + sb.WriteString(renderTable(elem.tableHeaders, elem.tableData)) + sb.WriteString("\n") + + case TypeSnippet: + sb.WriteString(renderSnippet(elem.content)) + sb.WriteString("\n\n") + default: + } + } + + return sb.String() +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..6174a6f --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,120 @@ +package ui + +import ( + "fmt" + "github.com/charmbracelet/lipgloss" + "time" +) + +var ( + timestampStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#949494")) + + logStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e4e4e4")) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#5fd700")) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff0000")). + Bold(true) + + warningStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff8700")). + Bold(true) + + infoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00afff")). + Bold(false) + + snippetStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("230")). + Background(lipgloss.Color("236")). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("59")) + + progressBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("57")). + Background(lipgloss.Color("236")) + + progressTextStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("255")) + + loaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("212")). + Bold(true) + + spinnerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff0087")) + + packageActionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("99")). + Bold(true) + + packageNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("231")) +) + +func getStyle(t MessageType) lipgloss.Style { + switch t { + case TypeSuccess: + return successStyle + case TypeError: + return errorStyle + case TypeWarning: + return warningStyle + case TypeInfo: + return infoStyle + case TypeSnippet: + return snippetStyle + default: + return logStyle + } +} + +func formatWithTimestamp(msg string) string { + now := time.Now().Format("15:04:05") + return timestampStyle.Render(now) + " " + msg +} + +func printStyled(t MessageType, a ...interface{}) { + if IsEnabled() { + instance.queueUpdate(func(m *uiModel) { + m.content = append(m.content, message{ + text: fmt.Sprint(a...), + style: getStyle(t), + timestamp: time.Now(), + }) + trimContent(m) + }) + } +} + +func printStyledf(t MessageType, format string, a ...interface{}) { + if IsEnabled() { + instance.queueUpdate(func(m *uiModel) { + m.content = append(m.content, message{ + text: fmt.Sprintf(format, a...), + style: getStyle(t), + timestamp: time.Now(), + }) + trimContent(m) + }) + } +} + +func printStyledln(t MessageType, a ...interface{}) { + if IsEnabled() { + instance.queueUpdate(func(m *uiModel) { + m.content = append(m.content, message{ + text: fmt.Sprintln(a...), + style: getStyle(t), + timestamp: time.Now(), + }) + trimContent(m) + }) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..03f20ed --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,152 @@ +package ui + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "sync" + "time" +) + +type MessageType int + +const ( + TypeLog MessageType = iota + TypeProgress + TypeLoader + TypeSuccess + TypeError + TypeWarning + TypeInfo + TypeSnippet + TypePackage + TypeRealtime + TypeSpinner + TypeStopwatch + TypeTable +) + +type Element struct { + elementType MessageType + content string + progress float64 + progressText string + showLoader bool + loaderText string + showSpinner bool + spinnerText string + startTime time.Time + tableData [][]string + tableHeaders []string + status string + packageName string + action string +} + +var ( + instance *UI + once sync.Once + spinners = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +) + +type UI struct { + program *tea.Program + model *uiModel + enabled bool + updates chan updateFunc + closeCh chan struct{} + wg sync.WaitGroup + initialized bool + ready chan struct{} + keepOutput bool +} + +func Init() { + once.Do(func() { + model := &uiModel{} + ui := &UI{ + model: model, + enabled: true, + updates: make(chan updateFunc, 100), + closeCh: make(chan struct{}), + ready: make(chan struct{}), + } + + go func() { + ui.program = tea.NewProgram( + model, + tea.WithoutSignalHandler(), + tea.WithInput(nil), + ) + close(ui.ready) + if _, err := ui.program.Run(); err != nil { + fmt.Println("UI error:", err) + } + }() + + ui.wg.Add(1) + go ui.processUpdates() + + instance = ui + instance.initialized = true + }) +} + +func Stop() { + if instance != nil && instance.initialized { + close(instance.closeCh) + instance.wg.Wait() + instance.enabled = false + instance.initialized = false + instance = nil + } +} + +func IsEnabled() bool { + return instance != nil && instance.enabled +} + +func (ui *UI) queueUpdate(fn updateFunc) { + select { + case ui.updates <- fn: + default: + } +} + +func (ui *UI) processUpdates() { + defer ui.wg.Done() + + <-ui.ready + + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case update := <-ui.updates: + ui.model.mu.Lock() + update(ui.model) + ui.model.mu.Unlock() + ui.requestRender() + + case <-ticker.C: + ui.requestRender() + + case <-ui.closeCh: + return + } + } +} + +func (ui *UI) requestRender() { + if ui.program != nil { + go func() { + ui.program.Send(forceRefresh{}) + }() + } +} + +func trimContent(m *uiModel) { + if len(m.content) > 20 { + m.content = m.content[len(m.content)-20:] + } +} From 2e9f18c7a78a21963347b66f0266d51574825d92 Mon Sep 17 00:00:00 2001 From: Nofre Date: Thu, 15 May 2025 10:18:43 +0200 Subject: [PATCH 9/9] chore: tiding, fmt, linting --- cmd/apply.go | 5 +++-- go.mod | 3 --- go.sum | 6 ------ internal/manifests/depends/dependencies.go | 1 + internal/manifests/depends/plan.go | 7 ++++--- internal/manifests/depends/sort.go | 1 + internal/manifests/kinds/load/http.go | 13 ++++++++----- internal/manifests/kinds/server/server.go | 7 +++++-- internal/manifests/kinds/service/service.go | 7 +++++-- internal/manifests/kinds/tests/http.go | 9 ++++++--- internal/ui/elements.go | 19 ++----------------- internal/ui/model.go | 5 +++-- internal/ui/styles.go | 15 ++------------- internal/ui/ui.go | 4 ++-- internal/yaml/loader.go | 3 ++- internal/yaml/parse.go | 1 + internal/yaml/saver.go | 9 +++++---- 17 files changed, 50 insertions(+), 65 deletions(-) diff --git a/cmd/apply.go b/cmd/apply.go index ac98cae..038fb61 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -1,12 +1,13 @@ package cmd import ( + "slices" + "time" + "github.com/apiqube/cli/internal/manifests/depends" "github.com/apiqube/cli/internal/ui" "github.com/apiqube/cli/internal/yaml" "github.com/spf13/cobra" - "slices" - "time" ) func init() { diff --git a/go.mod b/go.mod index 015e2be..c6e8442 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.24.3 require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 - github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.5 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.9.1 @@ -12,10 +11,8 @@ require ( ) require ( - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/go.sum b/go.sum index 627fc1f..8f7db3f 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,11 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= diff --git a/internal/manifests/depends/dependencies.go b/internal/manifests/depends/dependencies.go index 99c12d3..1eff1e1 100644 --- a/internal/manifests/depends/dependencies.go +++ b/internal/manifests/depends/dependencies.go @@ -2,6 +2,7 @@ package depends import ( "fmt" + "github.com/apiqube/cli/internal/manifests" ) diff --git a/internal/manifests/depends/plan.go b/internal/manifests/depends/plan.go index cb728c7..05283c1 100644 --- a/internal/manifests/depends/plan.go +++ b/internal/manifests/depends/plan.go @@ -3,9 +3,10 @@ package depends import ( "encoding/json" "fmt" - "github.com/apiqube/cli/internal/manifests" "os" "path/filepath" + + "github.com/apiqube/cli/internal/manifests" ) type ExecutionPlan struct { @@ -34,9 +35,9 @@ func SaveExecutionPlan(path string, order []string) error { return fmt.Errorf("failed to marshal plan: %w", err) } - if err = os.MkdirAll(path, 0755); err != nil { + if err = os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("failed to create dir: %w", err) } - return os.WriteFile(filepath.Join(path, "execution-plan.json"), data, 0644) + return os.WriteFile(filepath.Join(path, "execution-plan.json"), data, 0o644) } diff --git a/internal/manifests/depends/sort.go b/internal/manifests/depends/sort.go index 77c699e..8733062 100644 --- a/internal/manifests/depends/sort.go +++ b/internal/manifests/depends/sort.go @@ -2,6 +2,7 @@ package depends import ( "fmt" + "github.com/apiqube/cli/internal/manifests" ) diff --git a/internal/manifests/kinds/load/http.go b/internal/manifests/kinds/load/http.go index a374038..3825656 100644 --- a/internal/manifests/kinds/load/http.go +++ b/internal/manifests/kinds/load/http.go @@ -2,13 +2,16 @@ package load import ( "fmt" + "time" + "github.com/apiqube/cli/internal/manifests" "github.com/apiqube/cli/internal/manifests/kinds" - "time" ) -var _ manifests.Manifest = (*Http)(nil) -var _ manifests.Defaultable[*Http] = (*Http)(nil) +var ( + _ manifests.Manifest = (*Http)(nil) + _ manifests.Defaultable[*Http] = (*Http)(nil) +) type Http struct { kinds.BaseManifest `yaml:",inline"` @@ -53,11 +56,11 @@ func (h *Http) GetKind() string { } func (h *Http) GetName() string { - return h.Metadata.Name + return h.Name } func (h *Http) GetNamespace() string { - return h.Metadata.Namespace + return h.Namespace } func (h *Http) GetDependsOn() []string { diff --git a/internal/manifests/kinds/server/server.go b/internal/manifests/kinds/server/server.go index b5332e3..75da5e7 100644 --- a/internal/manifests/kinds/server/server.go +++ b/internal/manifests/kinds/server/server.go @@ -2,12 +2,15 @@ package server import ( "fmt" + "github.com/apiqube/cli/internal/manifests" "github.com/apiqube/cli/internal/manifests/kinds" ) -var _ manifests.Manifest = (*Server)(nil) -var _ manifests.Defaultable[*Server] = (*Server)(nil) +var ( + _ manifests.Manifest = (*Server)(nil) + _ manifests.Defaultable[*Server] = (*Server)(nil) +) type Server struct { kinds.BaseManifest `yaml:",inline"` diff --git a/internal/manifests/kinds/service/service.go b/internal/manifests/kinds/service/service.go index e929b56..0678362 100644 --- a/internal/manifests/kinds/service/service.go +++ b/internal/manifests/kinds/service/service.go @@ -2,12 +2,15 @@ package service import ( "fmt" + "github.com/apiqube/cli/internal/manifests" "github.com/apiqube/cli/internal/manifests/kinds" ) -var _ manifests.Manifest = (*Service)(nil) -var _ manifests.Defaultable[*Service] = (*Service)(nil) +var ( + _ manifests.Manifest = (*Service)(nil) + _ manifests.Defaultable[*Service] = (*Service)(nil) +) type Service struct { kinds.BaseManifest `yaml:",inline"` diff --git a/internal/manifests/kinds/tests/http.go b/internal/manifests/kinds/tests/http.go index 4114d48..47888f1 100644 --- a/internal/manifests/kinds/tests/http.go +++ b/internal/manifests/kinds/tests/http.go @@ -2,13 +2,16 @@ package tests import ( "fmt" + "time" + "github.com/apiqube/cli/internal/manifests" "github.com/apiqube/cli/internal/manifests/kinds" - "time" ) -var _ manifests.Manifest = (*Http)(nil) -var _ manifests.Defaultable[*Http] = (*Http)(nil) +var ( + _ manifests.Manifest = (*Http)(nil) + _ manifests.Defaultable[*Http] = (*Http)(nil) +) type Http struct { kinds.BaseManifest `yaml:",inline"` diff --git a/internal/ui/elements.go b/internal/ui/elements.go index 4d8fa8f..77a45a1 100644 --- a/internal/ui/elements.go +++ b/internal/ui/elements.go @@ -2,9 +2,10 @@ package ui import ( "fmt" - "github.com/charmbracelet/lipgloss" "strings" "time" + + "github.com/charmbracelet/lipgloss" ) func Progress(percent float64, text ...string) { @@ -313,22 +314,6 @@ func renderLoader(text string) string { return loaderStyle.Render("↻ " + text) } -func (m *uiModel) replaceLastProgress(percent float64, text string) { - for i := len(m.elements) - 1; i >= 0; i-- { - if m.elements[i].elementType == TypeProgress { - m.elements[i].progress = percent - m.elements[i].progressText = text - return - } - } - - m.elements = append(m.elements, Element{ - elementType: TypeProgress, - progress: percent, - progressText: text, - }) -} - func renderSnippet(code string) string { return snippetStyle.Render(code) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 8668ed5..8864c8d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1,11 +1,12 @@ package ui import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" "strings" "sync" "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type uiModel struct { diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 6174a6f..6932e8e 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -2,8 +2,9 @@ package ui import ( "fmt" - "github.com/charmbracelet/lipgloss" "time" + + "github.com/charmbracelet/lipgloss" ) var ( @@ -49,13 +50,6 @@ var ( spinnerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#ff0087")) - - packageActionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("99")). - Bold(true) - - packageNameStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("231")) ) func getStyle(t MessageType) lipgloss.Style { @@ -75,11 +69,6 @@ func getStyle(t MessageType) lipgloss.Style { } } -func formatWithTimestamp(msg string) string { - now := time.Now().Format("15:04:05") - return timestampStyle.Render(now) + " " + msg -} - func printStyled(t MessageType, a ...interface{}) { if IsEnabled() { instance.queueUpdate(func(m *uiModel) { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 03f20ed..09c0874 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2,9 +2,10 @@ package ui import ( "fmt" - tea "github.com/charmbracelet/bubbletea" "sync" "time" + + tea "github.com/charmbracelet/bubbletea" ) type MessageType int @@ -57,7 +58,6 @@ type UI struct { wg sync.WaitGroup initialized bool ready chan struct{} - keepOutput bool } func Init() { diff --git a/internal/yaml/loader.go b/internal/yaml/loader.go index 38d81cd..8dc7edd 100644 --- a/internal/yaml/loader.go +++ b/internal/yaml/loader.go @@ -2,10 +2,11 @@ package yaml import ( "fmt" - "github.com/apiqube/cli/internal/manifests" "os" "path/filepath" "strings" + + "github.com/apiqube/cli/internal/manifests" ) func LoadManifestsFromDir(dir string) ([]manifests.Manifest, error) { diff --git a/internal/yaml/parse.go b/internal/yaml/parse.go index f1c3401..deef425 100644 --- a/internal/yaml/parse.go +++ b/internal/yaml/parse.go @@ -2,6 +2,7 @@ package yaml import ( "fmt" + "github.com/apiqube/cli/internal/manifests" "github.com/apiqube/cli/internal/manifests/kinds/load" "github.com/apiqube/cli/internal/manifests/kinds/server" diff --git a/internal/yaml/saver.go b/internal/yaml/saver.go index abd2597..1026c33 100644 --- a/internal/yaml/saver.go +++ b/internal/yaml/saver.go @@ -3,14 +3,15 @@ package yaml import ( "bytes" "fmt" - "github.com/apiqube/cli/internal/manifests" - "gopkg.in/yaml.v3" "os" "path/filepath" + + "github.com/apiqube/cli/internal/manifests" + "gopkg.in/yaml.v3" ) func SaveManifests(dir string, manifests ...manifests.Manifest) error { - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create dir: %w", err) } @@ -30,7 +31,7 @@ func SaveManifests(dir string, manifests ...manifests.Manifest) error { } outputPath := filepath.Join(dir, "combined.yaml") - if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil { + if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil { return fmt.Errorf("failed to write file: %w", err) }