Skip to content
Merged
15 changes: 13 additions & 2 deletions cmd/list/flag_wrappers.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,20 @@ func WithFormatFlag(options *[]flags.Option) {
defer perf.Track(nil, "list.WithFormatFlag")()

*options = append(*options,
flags.WithStringFlag("format", "f", "", "Output format: table, json, yaml, csv, tsv, tree"),
flags.WithStringFlag("format", "f", "", "Output format: table, json, yaml, csv, tsv, tree, matrix"),
flags.WithEnvVars("format", "ATMOS_LIST_FORMAT"),
flags.WithValidValues("format", "table", "json", "yaml", "csv", "tsv", "tree"),
flags.WithValidValues("format", "table", "json", "yaml", "csv", "tsv", "tree", "matrix"),
)
}

// WithOutputFileFlag adds output file flag for writing results in key=value format (for $GITHUB_OUTPUT).
// Used by: instances (with --format=matrix).
func WithOutputFileFlag(options *[]flags.Option) {
defer perf.Track(nil, "list.WithOutputFileFlag")()

*options = append(*options,
flags.WithStringFlag("output-file", "o", "", "Write output to file in key=value format (for $GITHUB_OUTPUT)"),
flags.WithEnvVars("output-file", "ATMOS_LIST_OUTPUT_FILE"),
)
}

Expand Down
4 changes: 4 additions & 0 deletions cmd/list/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type InstancesOptions struct {
Sort string
Upload bool
Provenance bool
OutputFile string
}

// instancesCmd lists atmos instances.
Expand Down Expand Up @@ -65,6 +66,7 @@ var instancesCmd = &cobra.Command{
Sort: v.GetString("sort"),
Upload: v.GetBool("upload"),
Provenance: v.GetBool("provenance"),
OutputFile: v.GetString("output-file"),
}

return executeListInstancesCmd(cmd, args, opts)
Expand Down Expand Up @@ -120,6 +122,7 @@ func init() {
WithSortFlag,
WithUploadFlag,
WithProvenanceFlag,
WithOutputFileFlag,
)

// Register flags.
Expand Down Expand Up @@ -173,5 +176,6 @@ func executeListInstancesCmd(cmd *cobra.Command, args []string, opts *InstancesO
Delimiter: opts.Delimiter,
Query: opts.Query,
AuthManager: authManager,
OutputFile: opts.OutputFile,
})
}
27 changes: 27 additions & 0 deletions cmd/list/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func TestListInstancesOptions(t *testing.T) {
Stack: "prod-*",
Query: ".component",
Upload: false,
OutputFile: "",
}

assert.Equal(t, "json", opts.Format)
Expand All @@ -94,6 +95,18 @@ func TestListInstancesOptions(t *testing.T) {
assert.Equal(t, "prod-*", opts.Stack)
assert.Equal(t, ".component", opts.Query)
assert.False(t, opts.Upload)
assert.Empty(t, opts.OutputFile)
}

// TestListInstancesOptions_MatrixFormat tests matrix format with output-file option.
func TestListInstancesOptions_MatrixFormat(t *testing.T) {
opts := &InstancesOptions{
Format: "matrix",
OutputFile: "/tmp/github_output",
}

assert.Equal(t, "matrix", opts.Format)
assert.Equal(t, "/tmp/github_output", opts.OutputFile)
}

// TestListInstancesOptions_Upload tests the upload flag behavior.
Expand Down Expand Up @@ -270,3 +283,17 @@ func TestInstancesParserInit(t *testing.T) {
// Note: If the flag is not found, that's not necessarily an error - it may be registered
// lazily or through a different mechanism. The important test is that the parser exists.
}

// TestInstancesOutputFileFlag tests that the output-file flag is registered.
func TestInstancesOutputFileFlag(t *testing.T) {
// The output-file flag should be registered on the instances command.
outputFileFlag := instancesCmd.Flags().Lookup("output-file")
if outputFileFlag == nil {
outputFileFlag = instancesCmd.PersistentFlags().Lookup("output-file")
}

assert.NotNil(t, outputFileFlag, "output-file flag should be registered on instances command")
if outputFileFlag != nil {
assert.Equal(t, "", outputFileFlag.DefValue, "output-file flag default should be empty")
}
}
76 changes: 14 additions & 62 deletions internal/exec/describe_affected.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package exec

import (
"encoding/json"
"errors"
"fmt"
"os"

"github.com/go-git/go-git/v5/plumbing"
giturl "github.com/kubescape/go-git-url"
Expand All @@ -16,8 +14,8 @@ import (
"github.com/cloudposse/atmos/pkg/auth"
"github.com/cloudposse/atmos/pkg/ci"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/matrix"
"github.com/cloudposse/atmos/pkg/pager"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/pro"
Expand Down Expand Up @@ -362,7 +360,13 @@ func (d *describeAffectedExec) Execute(a *DescribeAffectedCmdArgs) error {
func (d *describeAffectedExec) view(a *DescribeAffectedCmdArgs, repoUrl string, headHead, baseHead *plumbing.Reference, affected []schema.Affected) error {
// Handle matrix format specially - it bypasses the normal view flow.
if a.Format == "matrix" {
return writeMatrixOutput(affected, a.GithubOutputFile)
entries := convertAffectedToMatrix(affected)
return matrix.WriteOutput(entries, a.GithubOutputFile)
}

// Reject --output-file for non-matrix formats — it would be silently ignored.
if a.GithubOutputFile != "" {
return fmt.Errorf("%w: --output-file is only supported with --format=matrix", errUtils.ErrInvalidFlag)
}

if a.Query == "" {
Expand Down Expand Up @@ -527,69 +531,17 @@ func viewConfig(v *viewConfigProps) error {
return nil
}

// MatrixOutput represents the GitHub Actions matrix strategy format.
type MatrixOutput struct {
Include []MatrixEntry `json:"include"`
}

// MatrixEntry represents a single entry in the matrix include array.
type MatrixEntry struct {
Stack string `json:"stack"`
Component string `json:"component"`
ComponentPath string `json:"component_path"`
ComponentType string `json:"component_type"`
}

// convertAffectedToMatrix converts the affected list to GitHub Actions matrix format.
func convertAffectedToMatrix(affected []schema.Affected) MatrixOutput {
matrix := MatrixOutput{
Include: make([]MatrixEntry, 0, len(affected)),
}
// convertAffectedToMatrix converts the affected list to matrix entries.
func convertAffectedToMatrix(affected []schema.Affected) []matrix.Entry {
entries := make([]matrix.Entry, 0, len(affected))
for i := range affected {
a := &affected[i]
entry := MatrixEntry{
entries = append(entries, matrix.Entry{
Stack: a.Stack,
Component: a.Component,
ComponentPath: a.ComponentPath,
ComponentType: a.ComponentType,
}
matrix.Include = append(matrix.Include, entry)
}
return matrix
}

// writeMatrixOutput writes the matrix output to stdout or a file.
// If outputFile is specified (for $GITHUB_OUTPUT), writes in key=value format.
// Otherwise, writes JSON to stdout.
func writeMatrixOutput(affected []schema.Affected, outputFile string) error {
matrix := convertAffectedToMatrix(affected)
matrixJSON, err := json.Marshal(matrix)
if err != nil {
return fmt.Errorf("failed to marshal matrix output: %w", err)
})
}

if outputFile != "" {
// Write to file in key=value format for $GITHUB_OUTPUT.
// Using 0644 permissions - matrix contains only stack/component names, not secrets.
f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, defaultFilePermissions)
if err != nil {
return fmt.Errorf("failed to open output file %s: %w", outputFile, err)
}
defer f.Close()

// Write matrix=<json> format.
if _, err := fmt.Fprintf(f, "matrix=%s\n", string(matrixJSON)); err != nil {
return fmt.Errorf("failed to write to output file %s: %w", outputFile, err)
}
// Also write count for convenience.
if _, err := fmt.Fprintf(f, "affected_count=%d\n", len(affected)); err != nil {
return fmt.Errorf("failed to write count to output file %s: %w", outputFile, err)
}
log.Debug("Wrote matrix output to file", "file", outputFile, "count", len(affected))
return nil
}

// Write to stdout.
_ = data.Writeln(string(matrixJSON))
return nil
return entries
}
121 changes: 42 additions & 79 deletions internal/exec/describe_affected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
iolib "github.com/cloudposse/atmos/pkg/io"
"github.com/cloudposse/atmos/pkg/matrix"
"github.com/cloudposse/atmos/pkg/pager"
"github.com/cloudposse/atmos/pkg/pro/dtos"
"github.com/cloudposse/atmos/pkg/schema"
Expand Down Expand Up @@ -1561,12 +1562,12 @@ func TestDescribeAffectedDeletedComponentFiltering(t *testing.T) {
assert.Equal(t, 1, deletedCount, "with stack filter ue1-staging, should find only 1 deleted component")
}

// TestConvertAffectedToMatrix tests converting affected components to GitHub Actions matrix format.
// TestConvertAffectedToMatrix tests converting affected components to matrix entries.
func TestConvertAffectedToMatrix(t *testing.T) {
t.Run("empty affected list", func(t *testing.T) {
matrix := convertAffectedToMatrix([]schema.Affected{})
assert.NotNil(t, matrix.Include)
assert.Empty(t, matrix.Include)
entries := convertAffectedToMatrix([]schema.Affected{})
assert.NotNil(t, entries)
assert.Empty(t, entries)
})

t.Run("single affected", func(t *testing.T) {
Expand All @@ -1578,12 +1579,12 @@ func TestConvertAffectedToMatrix(t *testing.T) {
ComponentType: "terraform",
},
}
matrix := convertAffectedToMatrix(affected)
require.Len(t, matrix.Include, 1)
assert.Equal(t, "ue1-dev", matrix.Include[0].Stack)
assert.Equal(t, "vpc", matrix.Include[0].Component)
assert.Equal(t, filepath.Join("components", "terraform", "vpc"), matrix.Include[0].ComponentPath)
assert.Equal(t, "terraform", matrix.Include[0].ComponentType)
entries := convertAffectedToMatrix(affected)
require.Len(t, entries, 1)
assert.Equal(t, "ue1-dev", entries[0].Stack)
assert.Equal(t, "vpc", entries[0].Component)
assert.Equal(t, filepath.Join("components", "terraform", "vpc"), entries[0].ComponentPath)
assert.Equal(t, "terraform", entries[0].ComponentType)
})

t.Run("multiple affected", func(t *testing.T) {
Expand All @@ -1601,78 +1602,22 @@ func TestConvertAffectedToMatrix(t *testing.T) {
ComponentType: "terraform",
},
}
matrix := convertAffectedToMatrix(affected)
require.Len(t, matrix.Include, 2)
assert.Equal(t, "ue1-dev", matrix.Include[0].Stack)
assert.Equal(t, "eks", matrix.Include[1].Component)
})
}

// TestWriteMatrixOutput_File tests writing matrix output to a file.
func TestWriteMatrixOutput_File(t *testing.T) {
t.Run("writes matrix and count to file", func(t *testing.T) {
outputFile := filepath.Join(t.TempDir(), "github_output")
affected := []schema.Affected{
{
Stack: "ue1-dev",
Component: "vpc",
ComponentPath: filepath.Join("components", "terraform", "vpc"),
ComponentType: "terraform",
},
}
err := writeMatrixOutput(affected, outputFile)
require.NoError(t, err)

content, err := os.ReadFile(outputFile)
require.NoError(t, err)

lines := strings.Split(strings.TrimSpace(string(content)), "\n")
require.Len(t, lines, 2)
assert.True(t, strings.HasPrefix(lines[0], "matrix="))
assert.Equal(t, "affected_count=1", lines[1])

// Verify JSON is valid.
matrixJSON := strings.TrimPrefix(lines[0], "matrix=")
var matrix MatrixOutput
err = json.Unmarshal([]byte(matrixJSON), &matrix)
require.NoError(t, err)
require.Len(t, matrix.Include, 1)
assert.Equal(t, "vpc", matrix.Include[0].Component)
})

t.Run("empty affected writes empty include", func(t *testing.T) {
outputFile := filepath.Join(t.TempDir(), "github_output")
err := writeMatrixOutput([]schema.Affected{}, outputFile)
require.NoError(t, err)

content, err := os.ReadFile(outputFile)
require.NoError(t, err)
assert.Contains(t, string(content), `"include":[]`)
assert.Contains(t, string(content), "affected_count=0")
})

t.Run("file open error", func(t *testing.T) {
err := writeMatrixOutput([]schema.Affected{}, filepath.Join(t.TempDir(), "nonexistent", "file"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open output file")
})
}

// TestWriteMatrixOutput_Stdout tests writing matrix output to stdout.
func TestWriteMatrixOutput_Stdout(t *testing.T) {
ioCtx, err := iolib.NewContext()
require.NoError(t, err)
data.InitWriter(ioCtx)

err = writeMatrixOutput([]schema.Affected{
{
entries := convertAffectedToMatrix(affected)
require.Len(t, entries, 2)
// Assert full first and last entries by value to catch regressions that drop or corrupt fields.
assert.Equal(t, matrix.Entry{
Stack: "ue1-dev",
Component: "vpc",
ComponentPath: filepath.Join("components", "terraform", "vpc"),
ComponentType: "terraform",
},
}, "")
assert.NoError(t, err)
}, entries[0])
assert.Equal(t, matrix.Entry{
Stack: "ue1-staging",
Component: "eks",
ComponentPath: filepath.Join("components", "terraform", "eks"),
ComponentType: "terraform",
}, entries[1])
})
}

// TestResolveBaseFromCI tests CI base auto-detection.
Expand Down Expand Up @@ -1933,10 +1878,28 @@ func TestExecute_MatrixFormat(t *testing.T) {
content, err := os.ReadFile(outputFile)
require.NoError(t, err)
assert.Contains(t, string(content), "matrix=")
assert.Contains(t, string(content), "affected_count=1")
assert.Contains(t, string(content), "count=1")
})
}

// TestView_OutputFileRejectsNonMatrix tests that --output-file is rejected for non-matrix formats.
func TestView_OutputFileRejectsNonMatrix(t *testing.T) {
d := describeAffectedExec{atmosConfig: &schema.AtmosConfiguration{}}

err := d.view(
&DescribeAffectedCmdArgs{
Format: "json",
GithubOutputFile: "/tmp/some-file",
CLIConfig: &schema.AtmosConfiguration{},
},
"", nil, nil, []schema.Affected{},
)

require.Error(t, err)
assert.ErrorIs(t, err, errUtils.ErrInvalidFlag)
assert.Contains(t, err.Error(), "--output-file is only supported with --format=matrix")
}

// TestDescribeAffectedDeletedComponentWithDependents tests that deleted components
// don't crash when IncludeDependents is enabled. Deleted components don't exist in
// HEAD, so attempting to resolve their dependents causes "invalid component" errors.
Expand Down
Loading
Loading