-
-
Notifications
You must be signed in to change notification settings - Fork 109
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implement: atmos list workflows (#941)
* 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
1 parent
e0b941b
commit 4cd79f1
Showing
19 changed files
with
743 additions
and
22 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.