diff --git a/cmd/apply.go b/cmd/apply.go new file mode 100644 index 0000000..038fb61 --- /dev/null +++ b/cmd/apply.go @@ -0,0 +1,72 @@ +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" +) + +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 { + 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 + } + + 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 + } + + ui.Spinner(false) + ui.Printf("Loaded %d manifests", len(mans)) + + slices.Reverse(mans) + for i, man := range mans { + ui.Printf("#%d ID: %s", i+1, man.GetID()) + } + + ui.Spinner(true, "Generating execution plan") + + if err = depends.GeneratePlan("./examples/simple", mans); err != nil { + ui.Errorf("Failed to generate plan: %s", err.Error()) + 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/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 deleted file mode 100644 index c58d62e..0000000 --- a/cmd/run.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var runCmd = &cobra.Command{ - Use: "run", - Short: "Run test suite with interactive CLI", - Run: func(cmd *cobra.Command, args []string) { - RunInteractiveTestUI() - }, -} - -type testCase struct { - Name string - Status string -} - -type model struct { - tests []testCase - progress progress.Model - index int - quitting bool -} - -func initialModel() model { - return model{ - tests: []testCase{ - {"Register User", "pending"}, - {"Login User", "pending"}, - {"Create Resource", "pending"}, - {"Delete Resource", "pending"}, - }, - progress: progress.New(progress.WithDefaultGradient()), - } -} - -func (m model) Init() tea.Cmd { - return tea.Batch( - tea.Tick(time.Second*3, func(t time.Time) tea.Msg { - return tickMsg{} - }), - ) -} - -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 - } - - m.tests[m.index].Status = "✓ passed" - m.index++ - return m, tea.Tick(700*time.Millisecond, func(t time.Time) tea.Msg { - return tickMsg{} - }) - - case tea.KeyMsg: - if msg.String() == "q" { - m.quitting = true - 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")) - } - s += style.Render(fmt.Sprintf("• %s [%s]", t.Name, t.Status)) + "\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) - } -} diff --git a/examples/simple/combined.yaml b/examples/simple/combined.yaml new file mode 100644 index 0000000..57ebc16 --- /dev/null +++ b/examples/simple/combined.yaml @@ -0,0 +1,108 @@ +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 +--- +version: 1 +kind: Server +metadata: + name: simple-server + namespace: default +spec: + baseUrl: http://localhost:8080 + headers: + Content-Type: application/json +--- +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: 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 diff --git a/examples/simple/execution-plan.json b/examples/simple/execution-plan.json new file mode 100644 index 0000000..f9f0800 --- /dev/null +++ b/examples/simple/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/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 new file mode 100644 index 0000000..9084ef8 --- /dev/null +++ b/examples/simple/server.yaml @@ -0,0 +1,11 @@ +version: 1 + +kind: Server + +metadata: + name: simple-server + +spec: + baseUrl: "http://localhost:8080" + headers: + Content-Type: application/json \ No newline at end of file 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/go.mod b/go.mod index 4307c8c..c6e8442 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,18 @@ module github.com/apiqube/cli -go 1.24.2 +go 1.24.3 require ( - github.com/charmbracelet/bubbles v0.21.0 + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 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/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..8f7db3f 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +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/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 +53,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/manifests/depends/dependencies.go b/internal/manifests/depends/dependencies.go new file mode 100644 index 0000000..1eff1e1 --- /dev/null +++ b/internal/manifests/depends/dependencies.go @@ -0,0 +1,40 @@ +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..05283c1 --- /dev/null +++ b/internal/manifests/depends/plan.go @@ -0,0 +1,43 @@ +package depends + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/apiqube/cli/internal/manifests" +) + +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, 0o755); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + + 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 new file mode 100644 index 0000000..8733062 --- /dev/null +++ b/internal/manifests/depends/sort.go @@ -0,0 +1,60 @@ +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 new file mode 100644 index 0000000..ef01d3a --- /dev/null +++ b/internal/manifests/interface.go @@ -0,0 +1,22 @@ +package manifests + +const ( + DefaultNamespace = "default" + + ServerManifestKind = "Server" + ServiceManifestKind = "Service" + HttpTestManifestKind = "HttpTest" + HttpLoadTestManifestKind = "HttpLoadTest" +) + +type Manifest interface { + GetID() string + GetKind() string + GetName() string + GetNamespace() string + GetDependsOn() []string +} + +type Defaultable[T Manifest] interface { + Default() T +} diff --git a/internal/manifests/kinds/base.go b/internal/manifests/kinds/base.go new file mode 100644 index 0000000..5ca9937 --- /dev/null +++ b/internal/manifests/kinds/base.go @@ -0,0 +1,13 @@ +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"` + Kind string `yaml:"kind" valid:"required,alpha,in(Server|Service|HttpTest|HttpLoadTest)"` + Metadata `yaml:"metadata"` + DependsOn []string `yaml:"dependsOn,omitempty"` +} diff --git a/internal/manifests/kinds/load/http.go b/internal/manifests/kinds/load/http.go new file mode 100644 index 0000000..3825656 --- /dev/null +++ b/internal/manifests/kinds/load/http.go @@ -0,0 +1,74 @@ +package load + +import ( + "fmt" + "time" + + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) + +var ( + _ manifests.Manifest = (*Http)(nil) + _ manifests.Defaultable[*Http] = (*Http)(nil) +) + +type Http struct { + kinds.BaseManifest `yaml:",inline"` + + Spec struct { + 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"` +} + +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) GetID() string { + return fmt.Sprintf("%s.%s.%s", h.Namespace, h.Kind, h.Name) +} + +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/kinds/server/server.go b/internal/manifests/kinds/server/server.go new file mode 100644 index 0000000..75da5e7 --- /dev/null +++ b/internal/manifests/kinds/server/server.go @@ -0,0 +1,51 @@ +package server + +import ( + "fmt" + + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) + +var ( + _ manifests.Manifest = (*Server)(nil) + _ manifests.Defaultable[*Server] = (*Server)(nil) +) + +type Server struct { + kinds.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) GetID() string { + return fmt.Sprintf("%s.%s.%s", s.Namespace, s.Kind, s.Name) +} + +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/kinds/service/service.go b/internal/manifests/kinds/service/service.go new file mode 100644 index 0000000..0678362 --- /dev/null +++ b/internal/manifests/kinds/service/service.go @@ -0,0 +1,64 @@ +package service + +import ( + "fmt" + + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) + +var ( + _ manifests.Manifest = (*Service)(nil) + _ manifests.Defaultable[*Service] = (*Service)(nil) +) + +type Service struct { + kinds.BaseManifest `yaml:",inline"` + + Spec struct { + Containers []Container `yaml:"containers" valid:"required,length(1|50)"` + } `yaml:"spec" valid:"required"` +} + +type Container struct { + Name string `yaml:"name" valid:"required"` + ContainerName string `yaml:"containerName,omitempty"` + Dockerfile string `yaml:"dockerfile,omitempty"` + Image string `yaml:"image,omitempty"` + Ports []string `yaml:"ports,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + Command string `yaml:"command,omitempty"` + Depends *ContainerDepend `yaml:"depends,omitempty"` + Replicas int `yaml:"replicas,omitempty" valid:"length(0|25)"` + 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 +} + +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/kinds/tests/http.go b/internal/manifests/kinds/tests/http.go new file mode 100644 index 0000000..47888f1 --- /dev/null +++ b/internal/manifests/kinds/tests/http.go @@ -0,0 +1,74 @@ +package tests + +import ( + "fmt" + "time" + + "github.com/apiqube/cli/internal/manifests" + "github.com/apiqube/cli/internal/manifests/kinds" +) + +var ( + _ manifests.Manifest = (*Http)(nil) + _ manifests.Defaultable[*Http] = (*Http)(nil) +) + +type Http struct { + kinds.BaseManifest `yaml:",inline"` + + Spec struct { + 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"` +} + +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) GetID() string { + return fmt.Sprintf("%s.%s.%s", h.Namespace, h.Kind, h.Name) +} + +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/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..77a45a1 --- /dev/null +++ b/internal/ui/elements.go @@ -0,0 +1,373 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +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 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..8864c8d --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,98 @@ +package ui + +import ( + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +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..6932e8e --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,109 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/lipgloss" +) + +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")) +) + +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 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..09c0874 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,152 @@ +package ui + +import ( + "fmt" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +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{} +} + +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:] + } +} diff --git a/internal/yaml/loader.go b/internal/yaml/loader.go new file mode 100644 index 0000000..8dc7edd --- /dev/null +++ b/internal/yaml/loader.go @@ -0,0 +1,42 @@ +package yaml + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/apiqube/cli/internal/manifests" +) + +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() || file.Name() == "combined.yaml" || (!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..deef425 --- /dev/null +++ b/internal/yaml/parse.go @@ -0,0 +1,63 @@ +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" + "github.com/apiqube/cli/internal/manifests/kinds/service" + "github.com/apiqube/cli/internal/manifests/kinds/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) + } + + var manifest manifests.Manifest + + switch raw.Kind { + case manifests.ServerManifestKind: + var m server.Server + + if err := yaml.Unmarshal(data, m.Default()); err != nil { + return nil, err + } + + manifest = &m + case manifests.ServiceManifestKind: + var m service.Service + if err := yaml.Unmarshal(data, m.Default()); err != nil { + return nil, err + } + + manifest = &m + case manifests.HttpTestManifestKind: + var m tests.Http + if err := yaml.Unmarshal(data, m.Default()); err != nil { + return nil, err + } + + manifest = &m + case manifests.HttpLoadTestManifestKind: + var m load.Http + + if err := yaml.Unmarshal(data, m.Default()); err != nil { + return nil, err + } + + 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..1026c33 --- /dev/null +++ b/internal/yaml/saver.go @@ -0,0 +1,39 @@ +package yaml + +import ( + "bytes" + "fmt" + "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, 0o755); 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(), 0o644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +}