Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement: atmos list workflows #941

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
55 changes: 55 additions & 0 deletions cmd/list_workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cmd

import (
"fmt"

"github.com/fatih/color"
"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, showing their associated files and workflow names for easy reference.",
Example: "atmos list workflows\n" +
"atmos list workflows -f <file>",
Run: func(cmd *cobra.Command, args []string) {
// Check Atmos configuration
checkAtmosConfig()

flags := cmd.Flags()

fileFlag, err := flags.GetString("file")
if err != nil {
u.PrintMessageInColor(fmt.Sprintf("Error getting the 'file' flag: %v", err), color.New(color.FgRed))
return
}
Cerebrovinny marked this conversation as resolved.
Show resolved Hide resolved

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)
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)")
listCmd.AddCommand(listWorkflowsCmd)
}
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
125 changes: 125 additions & 0 deletions pkg/list/list_workflows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package list

import (
"fmt"
"os"
"sort"
"strings"

"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/ui/theme"
"github.com/samber/lo"

"github.com/cloudposse/atmos/pkg/schema"
"gopkg.in/yaml.v3"
)

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

// Filters and lists workflows based on the given file
func FilterAndListWorkflows(fileFlag string, listConfig schema.ListConfig) (string, error) {
header := []string{"File", "Workflow", "Description"}

var rows [][]string

// If a specific file is provided, validate and load it
if 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 {
// Use example data for empty fileFlag
manifest := schema.WorkflowManifest{
Name: "example",
Workflows: schema.WorkflowConfig{
"test-1": schema.WorkflowDefinition{
Description: "Test workflow",
Steps: []schema.WorkflowStep{
{Command: "echo Command 1", Name: "step1", Type: "shell"},
{Command: "echo Command 2", Name: "step2", Type: "shell"},
{Command: "echo Command 3", Name: "step3", Type: "shell"},
{Command: "echo Command 4", Type: "shell"},
},
},
},
}

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, "\t")
})
sort.Slice(rows, func(i, j int) bool {
return strings.Join(rows[i], "\t") < strings.Join(rows[j], "\t")
})
Comment on lines +85 to +90
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's support an optional --delimiter flag, that defaults to \t


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

// See if TTY is attached
if !exec.CheckTTYSupport() {
// Degrade it to tabular output
var output strings.Builder
output.WriteString(strings.Join(header, "\t") + "\n")
for _, row := range rows {
output.WriteString(strings.Join(row, "\t") + "\n")
}
return output.String(), nil
}

// Create a styled table
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)
}
if row%2 == 0 {
return style.Inherit(theme.Styles.GrayText)
}
return style.Inherit(theme.Styles.Description)
}).
Headers(header...).
Rows(rows...)

return t.String() + "\n", nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use probably the OS new line characters

}
144 changes: 144 additions & 0 deletions pkg/list/list_workflows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package list

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"

"github.com/cloudposse/atmos/pkg/schema"
)

func TestListWorkflows(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "workflow_test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

emptyWorkflowFile := filepath.Join(tmpDir, "empty.yaml")
emptyWorkflow := schema.WorkflowManifest{
Name: "empty",
Workflows: schema.WorkflowConfig{},
}
emptyWorkflowBytes, err := yaml.Marshal(emptyWorkflow)
require.NoError(t, err)
err = os.WriteFile(emptyWorkflowFile, emptyWorkflowBytes, 0644)
require.NoError(t, err)

tests := []struct {
name string
fileFlag string
config schema.ListConfig
wantErr bool
contains []string
notContains []string
}{
{
name: "happy path - default config",
fileFlag: "",
config: schema.ListConfig{
Columns: []schema.ListColumnConfig{
{Name: "File", Value: "{{ .workflow_file }}"},
{Name: "Workflow", Value: "{{ .workflow_name }}"},
{Name: "Description", Value: "{{ .workflow_description }}"},
},
},
wantErr: false,
contains: []string{
"File", "Workflow", "Description",
"example", "test-1", "Test workflow",
},
},
{
name: "empty workflows",
fileFlag: emptyWorkflowFile,
config: schema.ListConfig{
Columns: []schema.ListColumnConfig{
{Name: "File", Value: "{{ .workflow_file }}"},
{Name: "Workflow", Value: "{{ .workflow_name }}"},
{Name: "Description", Value: "{{ .workflow_description }}"},
},
},
wantErr: false,
contains: []string{"No workflows found"},
},
{
name: "invalid file path",
fileFlag: "/invalid/path/workflows.yaml",
config: schema.ListConfig{},
wantErr: true,
notContains: []string{"File", "Workflow", "Description"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, err := FilterAndListWorkflows(tt.fileFlag, tt.config)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)

// Verify expected content is present
for _, expected := range tt.contains {
assert.Contains(t, output, expected)
}

// Verify unexpected content is not present
for _, unexpected := range tt.notContains {
assert.NotContains(t, output, unexpected)
}

// Verify the order of headers
if tt.name == "happy path - default config" {
assert.True(t, strings.Index(output, "File") < strings.Index(output, "Workflow"))
assert.True(t, strings.Index(output, "Workflow") < strings.Index(output, "Description"))
}
})
}
}

func TestListWorkflowsWithFile(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "workflow_test")
require.NoError(t, err)
defer os.RemoveAll(tmpDir)

// Create a test workflow file
testWorkflowFile := filepath.Join(tmpDir, "test.yaml")
testWorkflow := schema.WorkflowManifest{
Name: "example",
Workflows: schema.WorkflowConfig{
"test-1": schema.WorkflowDefinition{
Description: "Test workflow",
Steps: []schema.WorkflowStep{
{Command: "echo Command 1", Name: "step1", Type: "shell"},
},
},
},
}
testWorkflowBytes, err := yaml.Marshal(testWorkflow)
require.NoError(t, err)
err = os.WriteFile(testWorkflowFile, testWorkflowBytes, 0644)
require.NoError(t, err)

listConfig := schema.ListConfig{
Columns: []schema.ListColumnConfig{
{Name: "File", Value: "{{ .workflow_file }}"},
{Name: "Workflow", Value: "{{ .workflow_name }}"},
{Name: "Description", Value: "{{ .workflow_description }}"},
},
}

output, err := FilterAndListWorkflows(testWorkflowFile, listConfig)
assert.NoError(t, err)
assert.Contains(t, output, "File")
assert.Contains(t, output, "Workflow")
assert.Contains(t, output, "Description")
assert.Contains(t, output, "example")
assert.Contains(t, output, "test-1")
assert.Contains(t, output, "Test workflow")
}
13 changes: 12 additions & 1 deletion pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ type Stacks struct {
}

type Workflows struct {
BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"`
BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"`
List ListConfig `yaml:"list" json:"list" mapstructure:"list"`
}

type Logs struct {
Expand Down Expand Up @@ -691,3 +692,13 @@ type MarkdownStyle struct {
type ChromaStyle struct {
Color string `yaml:"color,omitempty" json:"color,omitempty" mapstructure:"color"`
}

type ListConfig struct {
Format string `yaml:"format" json:"format" mapstructure:"format"`
Columns []ListColumnConfig `yaml:"columns" json:"columns" mapstructure:"columns"`
}

type ListColumnConfig struct {
Name string `yaml:"name" json:"name" mapstructure:"name"`
Value string `yaml:"value" json:"value" mapstructure:"value"`
}
Loading