Skip to content

Commit

Permalink
Implement: atmos list workflows (#941)
Browse files Browse the repository at this point in the history
* fix: suppress config error when help is requested

* feat: add list configuration to workflows schema

* feat: add list workflows command to display Atmos workflows

* feat: add workflow listing functionality with table output support

* Add tests for workflow listing functionality

* fix: add newline at the end of workflow list output

* refactor: use CheckTTYSupport for terminal detection

* refactor: use theme styles for workflow list table formatting

* feat(workflows): add file loading and parsing functionality for workflow manifests

* test(list): enhance workflow listing test coverage with temporary files

* feat(workflows): add JSON and CSV output formats for workflow listing

* fixes log level

* update cli tests

* fix: use OS-specific line endings in workflow list output

* feat: add OS-specific line ending support and format validation tests

* feat: add format validation for workflow list output

* feat: add file path validation and handle nil workflows in manifest

* refactor: move line ending logic to utils package

* Add test snapshots for log level validation and CLI command output

* Enable snapshots and add diff validation for log level tests

* feat: add GetLineEnding function to handle platform-specific line endings

* refactor: use runtime.GOOS to detect Windows platform for line endings

* update copy

* fix expected diff

* feat: implement workflow discovery from configured workflow directory

* Remove alternating row styles in workflow list

---------

Co-authored-by: Andriy Knysh <[email protected]>
  • Loading branch information
Cerebrovinny and aknysh authored Jan 25, 2025
1 parent e0b941b commit 4cd79f1
Show file tree
Hide file tree
Showing 19 changed files with 743 additions and 22 deletions.
67 changes: 67 additions & 0 deletions cmd/list_workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/cloudposse/atmos/pkg/config"
l "github.com/cloudposse/atmos/pkg/list"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui/theme"
u "github.com/cloudposse/atmos/pkg/utils"
)

// listWorkflowsCmd lists atmos workflows
var listWorkflowsCmd = &cobra.Command{
Use: "workflows",
Short: "List all Atmos workflows",
Long: "List Atmos workflows, with options to filter results by specific files.",
Example: "atmos list workflows\n" +
"atmos list workflows -f <file>\n" +
"atmos list workflows --format json\n" +
"atmos list workflows --format csv --delimiter ','",
Run: func(cmd *cobra.Command, args []string) {
flags := cmd.Flags()

fileFlag, err := flags.GetString("file")
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error getting the 'file' flag: %v", err), theme.Colors.Error)
return
}

formatFlag, err := flags.GetString("format")
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error getting the 'format' flag: %v", err), theme.Colors.Error)
return
}

delimiterFlag, err := flags.GetString("delimiter")
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error getting the 'delimiter' flag: %v", err), theme.Colors.Error)
return
}

configAndStacksInfo := schema.ConfigAndStacksInfo{}
atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true)
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error initializing CLI config: %v", err), theme.Colors.Error)
return
}

output, err := l.FilterAndListWorkflows(fileFlag, atmosConfig.Workflows.List, formatFlag, delimiterFlag)
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error: %v"+"\n", err), theme.Colors.Warning)
return
}

u.PrintMessageInColor(output, theme.Colors.Success)
},
}

func init() {
listWorkflowsCmd.PersistentFlags().StringP("file", "f", "", "Filter workflows by file (e.g., atmos list workflows -f workflow1)")
listWorkflowsCmd.PersistentFlags().String("format", "", "Output format (table, json, csv)")
listWorkflowsCmd.PersistentFlags().String("delimiter", "\t", "Delimiter for csv output")
listCmd.AddCommand(listWorkflowsCmd)
}
7 changes: 6 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ var RootCmd = &cobra.Command{
// Only validate the config, don't store it yet since commands may need to add more info
_, err := cfg.InitCliConfig(configAndStacksInfo, false)
if err != nil {
if !errors.Is(err, cfg.NotFound) {
if errors.Is(err, cfg.NotFound) {
// For help commands or when help flag is set, we don't want to show the error
if !isHelpRequested {
u.LogWarning(errorConfig, err.Error())
}
} else {
u.LogErrorAndExit(errorConfig, err)
}
}
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/templates/term/term_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io"
"os"

"github.com/cloudposse/atmos/internal/exec"
"github.com/mitchellh/go-wordwrap"
"golang.org/x/term"
)
Expand All @@ -30,7 +31,7 @@ func NewResponsiveWriter(w io.Writer) io.Writer {
return w
}

if !term.IsTerminal(int(file.Fd())) {
if !exec.CheckTTYSupport() {
return w
}

Expand Down
229 changes: 229 additions & 0 deletions pkg/list/list_workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package list

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui/theme"
"github.com/cloudposse/atmos/pkg/utils"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)

const (
FormatTable = "table"
FormatJSON = "json"
FormatCSV = "csv"
)

// ValidateFormat checks if the given format is supported
func ValidateFormat(format string) error {
if format == "" {
return nil
}
validFormats := []string{FormatTable, FormatJSON, FormatCSV}
for _, f := range validFormats {
if format == f {
return nil
}
}
return fmt.Errorf("invalid format '%s'. Supported formats are: %s", format, strings.Join(validFormats, ", "))
}

// Extracts workflows from a workflow manifest
func getWorkflowsFromManifest(manifest schema.WorkflowManifest) ([][]string, error) {
var rows [][]string
if manifest.Workflows == nil {
return rows, nil
}
for workflowName, workflow := range manifest.Workflows {
rows = append(rows, []string{
manifest.Name,
workflowName,
workflow.Description,
})
}
return rows, nil
}

// FilterAndListWorkflows filters and lists workflows based on the given file
func FilterAndListWorkflows(fileFlag string, listConfig schema.ListConfig, format string, delimiter string) (string, error) {
if err := ValidateFormat(format); err != nil {
return "", err
}

if format == "" && listConfig.Format != "" {
if err := ValidateFormat(listConfig.Format); err != nil {
return "", err
}
format = listConfig.Format
}

// Parse columns configuration
header := []string{"File", "Workflow", "Description"}

// Get all workflows from manifests
var rows [][]string

// If a specific file is provided, validate and load it
if fileFlag != "" {
// Validate file path
cleanPath := filepath.Clean(fileFlag)
if !utils.IsYaml(cleanPath) {
return "", fmt.Errorf("invalid workflow file extension: %s", fileFlag)
}
if _, err := os.Stat(fileFlag); os.IsNotExist(err) {
return "", fmt.Errorf("workflow file not found: %s", fileFlag)
}

// Read and parse the workflow file
data, err := os.ReadFile(fileFlag)
if err != nil {
return "", fmt.Errorf("error reading workflow file: %w", err)
}

var manifest schema.WorkflowManifest
if err := yaml.Unmarshal(data, &manifest); err != nil {
return "", fmt.Errorf("error parsing workflow file: %w", err)
}

manifestRows, err := getWorkflowsFromManifest(manifest)
if err != nil {
return "", fmt.Errorf("error processing manifest: %w", err)
}
rows = append(rows, manifestRows...)
} else {
configAndStacksInfo := schema.ConfigAndStacksInfo{}
atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true)
if err != nil {
return "", fmt.Errorf("error initializing CLI config: %w", err)
}

// Get the workflows directory
var workflowsDir string
if utils.IsPathAbsolute(atmosConfig.Workflows.BasePath) {
workflowsDir = atmosConfig.Workflows.BasePath
} else {
workflowsDir = filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath)
}

isDirectory, err := utils.IsDirectory(workflowsDir)
if err != nil || !isDirectory {
return "", fmt.Errorf("the workflow directory '%s' does not exist. Review 'workflows.base_path' in 'atmos.yaml'", workflowsDir)
}

files, err := utils.GetAllYamlFilesInDir(workflowsDir)
if err != nil {
return "", fmt.Errorf("error reading the directory '%s' defined in 'workflows.base_path' in 'atmos.yaml': %v",
atmosConfig.Workflows.BasePath, err)
}

for _, f := range files {
var workflowPath string
if utils.IsPathAbsolute(atmosConfig.Workflows.BasePath) {
workflowPath = filepath.Join(atmosConfig.Workflows.BasePath, f)
} else {
workflowPath = filepath.Join(atmosConfig.BasePath, atmosConfig.Workflows.BasePath, f)
}

fileContent, err := os.ReadFile(workflowPath)
if err != nil {
return "", err
}

var manifest schema.WorkflowManifest
if err := yaml.Unmarshal(fileContent, &manifest); err != nil {
return "", fmt.Errorf("error parsing the workflow manifest '%s': %v", f, err)
}

manifestRows, err := getWorkflowsFromManifest(manifest)
if err != nil {
return "", fmt.Errorf("error processing manifest: %w", err)
}
rows = append(rows, manifestRows...)
}
}

// Remove duplicates and sort
rows = lo.UniqBy(rows, func(row []string) string {
return strings.Join(row, delimiter)
})
sort.Slice(rows, func(i, j int) bool {
return strings.Join(rows[i], delimiter) < strings.Join(rows[j], delimiter)
})

if len(rows) == 0 {
return "No workflows found", nil
}

// Handle different output formats
switch format {
case "json":
// Convert to JSON format
type workflow struct {
File string `json:"file"`
Name string `json:"name"`
Description string `json:"description"`
}
var workflows []workflow
for _, row := range rows {
workflows = append(workflows, workflow{
File: row[0],
Name: row[1],
Description: row[2],
})
}
jsonBytes, err := json.MarshalIndent(workflows, "", " ")
if err != nil {
return "", fmt.Errorf("error formatting JSON output: %w", err)
}
return string(jsonBytes), nil

case "csv":
// Use the provided delimiter for CSV output
var output strings.Builder
output.WriteString(strings.Join(header, delimiter) + utils.GetLineEnding())
for _, row := range rows {
output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding())
}
return output.String(), nil

default:
// If format is empty or "table", use table format
if format == "" && exec.CheckTTYSupport() {
// Create a styled table for TTY
t := table.New().
Border(lipgloss.ThickBorder()).
BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorBorder))).
StyleFunc(func(row, col int) lipgloss.Style {
style := lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1)
if row == 0 {
return style.Inherit(theme.Styles.CommandName).Align(lipgloss.Center)
}
// Use consistent style for all rows
return style.Inherit(theme.Styles.Description)
}).
Headers(header...).
Rows(rows...)

return t.String() + utils.GetLineEnding(), nil
}

// Default to simple tabular format for non-TTY or when format is explicitly "table"
var output strings.Builder
output.WriteString(strings.Join(header, delimiter) + utils.GetLineEnding())
for _, row := range rows {
output.WriteString(strings.Join(row, delimiter) + utils.GetLineEnding())
}
return output.String(), nil
}
}
Loading

0 comments on commit 4cd79f1

Please sign in to comment.