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 vendor #994

Open
wants to merge 33 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8a849f7
feat: add vendor list command and functionality
Cerebrovinny Feb 1, 2025
13a0665
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 1, 2025
0a99336
update tests
Cerebrovinny Feb 1, 2025
968ee5c
refactor: update vendor manifest structure and file permissions format
Cerebrovinny Feb 1, 2025
c7c97a2
[autofix.ci] apply automated fixes
autofix-ci[bot] Feb 1, 2025
7b2e9dc
feat: add version field to vendor list output
Cerebrovinny Feb 2, 2025
359802a
fix: handle error when restoring working directory in vendor test
Cerebrovinny Feb 2, 2025
32eb1e8
feat: add TSV format support and default delimiters for list vendor
Cerebrovinny Feb 2, 2025
9524836
vendor update test fixes
Cerebrovinny Feb 2, 2025
97561cb
refactor: improve vendor list functionality and template processing
Cerebrovinny Feb 2, 2025
e8bf78d
feat: set relative file path for vendor sources
Cerebrovinny Feb 2, 2025
d0721ab
fix: handle absolute vendor base paths in list vendor command
Cerebrovinny Feb 2, 2025
e58b895
Merge branch 'main' into DEV-2806
Cerebrovinny Feb 4, 2025
0f83c6b
Add documentation for 'atmos list vendor' command
Cerebrovinny Feb 4, 2025
162ee0a
docs: update vendor command output format options and examples
Cerebrovinny Feb 4, 2025
44141e6
fix: correct row index condition in vendor list styling
Cerebrovinny Feb 4, 2025
0555127
Update website/docs/cli/commands/list/list-vendor.mdx
Cerebrovinny Feb 4, 2025
118effd
refactor(list-vendor): simplify output columns and update documentation
Cerebrovinny Feb 4, 2025
38c6ad9
fix: update vendor command links in documentation
Cerebrovinny Feb 4, 2025
26ded60
docs: add vendor list columns configuration documentation
Cerebrovinny Feb 4, 2025
9484970
Add recursion depth limit to vendor imports processing
Cerebrovinny Feb 4, 2025
cf6adf5
Update website/docs/cli/commands/list/list-vendor.mdx
Cerebrovinny Feb 4, 2025
e4be660
Update website/docs/cli/commands/list/list-vendor.mdx
Cerebrovinny Feb 4, 2025
705f5dc
docs: add format option to vendor list command configuration
Cerebrovinny Feb 4, 2025
8eb7dae
Fix broken link to list-components command documentation
Cerebrovinny Feb 4, 2025
aaf323c
Merge branch 'main' into DEV-2806
Cerebrovinny Feb 4, 2025
65f697d
Fix broken link to 'list components' command documentation
Cerebrovinny Feb 4, 2025
a572d0a
feat: add template-based column configuration for vendor list command
Cerebrovinny Feb 5, 2025
cccede3
docs: add template variable documentation for vendor list columns
Cerebrovinny Feb 5, 2025
4c5e0ef
feat: add atmos-specific fields to vendor manifest data
Cerebrovinny Feb 5, 2025
f98652b
Merge branch 'main' into DEV-2806
osterman Feb 6, 2025
59f0048
Remove unused vendor list configuration
Cerebrovinny Feb 7, 2025
9cd9f28
Merge branch 'main' into DEV-2806
Cerebrovinny Feb 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions cmd/list_vendor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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"
)

// listVendorCmd lists atmos vendor configurations
var listVendorCmd = &cobra.Command{
Use: "vendor",
Short: "List all vendor configurations",
Long: "List vendor configurations in a tabular way, including component and vendor manifests",
Example: "atmos list vendor\n" +
"atmos list vendor --format json\n" +
"atmos list vendor --format csv # Uses comma (,) as delimiter\n" +
"atmos list vendor --format tsv # Uses tab (\\t) as delimiter\n" +
"atmos list vendor --format csv --delimiter ';' # Custom delimiter",
Run: func(cmd *cobra.Command, args []string) {
flags := cmd.Flags()

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
}

// Set appropriate default delimiter based on format
if formatFlag == l.FormatCSV && delimiterFlag == l.DefaultTSVDelimiter {
delimiterFlag = l.DefaultCSVDelimiter
}

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.FilterAndListVendors(atmosConfig.Vendor.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() {
listVendorCmd.PersistentFlags().String("format", "", "Output format (table, json, csv, tsv)")
listVendorCmd.PersistentFlags().String("delimiter", "\t", "Delimiter for csv/tsv output (default: tab for tsv, comma for csv)")
listCmd.AddCommand(listVendorCmd)
}
19 changes: 15 additions & 4 deletions internal/exec/vendor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,14 @@ func ExecuteAtmosVendorInternal(
}

// Process imports and return all sources from all the imports and from `vendor.yaml`
sources, _, err := processVendorImports(
sources, _, err := ProcessVendorImports(
atmosConfig,
vendorConfigFileName,
atmosVendorSpec.Imports,
atmosVendorSpec.Sources,
[]string{vendorConfigFileName},
0,
50,
)
if err != nil {
return err
Expand Down Expand Up @@ -435,14 +437,22 @@ func ExecuteAtmosVendorInternal(
return nil
}

// processVendorImports processes all imports recursively and returns a list of sources
func processVendorImports(
// ProcessVendorImports processes all imports recursively and returns a list of sources
// maxDepth limits the recursion depth to prevent stack overflow from deeply nested imports
func ProcessVendorImports(
atmosConfig schema.AtmosConfiguration,
vendorConfigFile string,
imports []string,
sources []schema.AtmosVendorSource,
allImports []string,
depth int,
maxDepth int,
) ([]schema.AtmosVendorSource, []string, error) {
// Prevent stack overflow from deeply nested imports
if depth > maxDepth {
return nil, nil, fmt.Errorf("maximum import depth of %d exceeded in vendor config file '%s'", maxDepth, vendorConfigFile)
}

var mergedSources []schema.AtmosVendorSource

for _, imp := range imports {
Expand All @@ -468,7 +478,8 @@ func processVendorImports(
return nil, nil, fmt.Errorf("either 'spec.sources' or 'spec.imports' (or both) must be defined in the vendor config file '%s'", imp)
}

mergedSources, allImports, err = processVendorImports(atmosConfig, imp, vendorConfig.Spec.Imports, mergedSources, allImports)
// Pass depth + 1 to track recursion depth
mergedSources, allImports, err = ProcessVendorImports(atmosConfig, imp, vendorConfig.Spec.Imports, mergedSources, allImports, depth+1, maxDepth)
if err != nil {
return nil, nil, err
}
Expand Down
254 changes: 254 additions & 0 deletions pkg/list/list_vendor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
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"
)

const (
DefaultCSVDelimiter = ","
DefaultTSVDelimiter = "\t"
)

// processVendorFile processes a vendor configuration file and returns vendor information
Cerebrovinny marked this conversation as resolved.
Show resolved Hide resolved
func processVendorFile(filePath string, atmosConfig schema.AtmosConfiguration) ([]schema.AtmosVendorSource, error) {
// Use the existing vendoring logic to process the file and its imports
vendorConfig, _, _, err := exec.ReadAndProcessVendorConfigFile(atmosConfig, filePath, false)
if err != nil {
return nil, fmt.Errorf("error processing vendor file %s: %w", filePath, err)
}

// Process all sources from the main config and its imports
mergedSources, _, err := exec.ProcessVendorImports(atmosConfig, filePath, vendorConfig.Spec.Imports, vendorConfig.Spec.Sources, []string{}, 0, 10)
if err != nil {
return nil, fmt.Errorf("error processing vendor imports: %w", err)
}

// Get vendor base path
var vendorBasePath string
if utils.IsPathAbsolute(atmosConfig.Vendor.BasePath) {
vendorBasePath = atmosConfig.Vendor.BasePath
} else {
vendorBasePath = filepath.Join(atmosConfig.BasePath, atmosConfig.Vendor.BasePath)
}

// Get relative path from vendor base path's parent directory
relPath, err := filepath.Rel(filepath.Dir(vendorBasePath), filePath)
if err != nil {
return nil, fmt.Errorf("error getting relative path for vendor file: %w", err)
}

// Process templates in sources and targets
for i := range mergedSources {
// Set the File field with the relative path, preserving the vendor directory
mergedSources[i].File = filepath.ToSlash(relPath)

// Process templates in the target path
if len(mergedSources[i].Targets) > 0 {
processedTarget, err := exec.ProcessTmpl(
"target",
mergedSources[i].Targets[0],
map[string]string{
"Component": mergedSources[i].Component,
"Version": mergedSources[i].Version,
},
false,
)
if err != nil {
return nil, fmt.Errorf("error processing target template: %w", err)
}
mergedSources[i].Targets[0] = processedTarget
}

// Process templates in the source URI
processedSource, err := exec.ProcessTmpl(
"source",
mergedSources[i].Source,
map[string]string{
"Component": mergedSources[i].Component,
"Version": mergedSources[i].Version,
},
false,
)
if err != nil {
return nil, fmt.Errorf("error processing source template: %w", err)
}
mergedSources[i].Source = processedSource
}

return mergedSources, nil
}

// FilterAndListVendors lists vendor configurations based on the provided configuration
func FilterAndListVendors(listConfig schema.ListConfig, format string, delimiter string) (string, error) {
if err := ValidateFormat(format); err != nil {
return "", err
}

// Set default delimiters based on format
if format == FormatCSV && delimiter == DefaultTSVDelimiter {
delimiter = DefaultCSVDelimiter
}

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

// Initialize Atmos config
configAndStacksInfo := schema.ConfigAndStacksInfo{}
atmosConfig, err := config.InitCliConfig(configAndStacksInfo, true)
if err != nil {
return "", fmt.Errorf("error initializing CLI config: %w", err)
}

// Define default columns if not specified in config
header := []string{"Component", "Version", "Folder", "Source"}
if len(listConfig.Columns) > 0 {
header = make([]string, len(listConfig.Columns))
for i, col := range listConfig.Columns {
header[i] = col.Name
}
}

// Get vendor path
var vendorPath string
if utils.IsPathAbsolute(atmosConfig.Vendor.BasePath) {
vendorPath = atmosConfig.Vendor.BasePath
} else {
vendorPath = filepath.Join(atmosConfig.BasePath, atmosConfig.Vendor.BasePath)
}

// Check if vendor path exists
fileInfo, err := os.Stat(vendorPath)
if err != nil {
return "", fmt.Errorf("the vendor path '%s' does not exist. Review 'vendor.base_path' in 'atmos.yaml'", vendorPath)
}

var files []string
if fileInfo.IsDir() {
// If it's a directory, get all YAML files
files, err = utils.GetAllYamlFilesInDir(vendorPath)
if err != nil {
return "", fmt.Errorf("error reading the directory '%s' defined in 'vendor.base_path' in 'atmos.yaml': %v",
atmosConfig.Vendor.BasePath, err)
}
// Convert relative paths to absolute paths
for i, f := range files {
files[i] = filepath.Join(vendorPath, f)
}
} else {
// If it's a file, just use that file
files = []string{vendorPath}
}

// Process all vendor files
var allVendors []schema.AtmosVendorSource
for _, f := range files {
vendors, err := processVendorFile(f, atmosConfig)
if err != nil {
return "", err
}
allVendors = append(allVendors, vendors...)
}

// Convert vendor info to rows based on header columns
var rows [][]string
for _, vendor := range allVendors {
row := make([]string, len(header))
for i, col := range header {
switch col {
case "Component":
row[i] = vendor.Component
case "Version":
row[i] = vendor.Version
case "Folder":
if len(vendor.Targets) > 0 {
row[i] = filepath.Join(atmosConfig.BasePath, vendor.Targets[0])
}
case "Source":
row[i] = vendor.Source
case "Type":
row[i] = "Vendor Manifest"
case "Manifest":
row[i] = vendor.File
}
}
rows = append(rows, row)
Cerebrovinny marked this conversation as resolved.
Show resolved Hide resolved
}

// Sort rows for consistent output
sort.Slice(rows, func(i, j int) bool {
// Compare each column in order until we find a difference
for col := 0; col < len(rows[i]); col++ {
if rows[i][col] != rows[j][col] {
return rows[i][col] < rows[j][col]
}
}
return false // rows are identical
})

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

// Handle different output formats
switch format {
case FormatJSON:
jsonBytes, err := json.MarshalIndent(allVendors, "", " ")
if err != nil {
return "", fmt.Errorf("error formatting JSON output: %w", err)
}
return string(jsonBytes), nil

case FormatCSV, FormatTSV:
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 == -1 {
return style.Inherit(theme.Styles.CommandName).Align(lipgloss.Center)
}
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
Loading