diff --git a/cmd/fluxctl/error.go b/cmd/fluxctl/error.go index bfbe274fa..3fbcff505 100644 --- a/cmd/fluxctl/error.go +++ b/cmd/fluxctl/error.go @@ -29,3 +29,4 @@ func checkExactlyOne(optsDescription string, supplied ...bool) error { } var errorWantedNoArgs = newUsageError("expected no (non-flag) arguments") +var errorInvalidOutputFormat = newUsageError("invalid output format specified") diff --git a/cmd/fluxctl/format.go b/cmd/fluxctl/format.go index 13d5bcdb8..49c3fb9f2 100644 --- a/cmd/fluxctl/format.go +++ b/cmd/fluxctl/format.go @@ -2,10 +2,19 @@ package main import ( "bytes" + "encoding/json" "fmt" + "io" "os" "strings" "text/tabwriter" + "time" + + "github.com/pkg/errors" + + "github.com/fluxcd/flux/pkg/registry" + + v6 "github.com/fluxcd/flux/pkg/api/v6" "github.com/spf13/cobra" ) @@ -14,6 +23,13 @@ type outputOpts struct { verbosity int } +const ( + outputFormatJson = "json" + outputFormatTab = "tab" +) + +var validOutputFormats = []string{outputFormatJson, outputFormatTab} + func AddOutputFlags(cmd *cobra.Command, opts *outputOpts) { cmd.Flags().CountVarP(&opts.verbosity, "verbose", "v", "include skipped (and ignored, with -vv) workloads in output") } @@ -29,3 +45,144 @@ func makeExample(examples ...string) string { } return strings.TrimSuffix(buf.String(), "\n") } + +func outputFormatIsValid(format string) bool { + for _, f := range validOutputFormats { + if f == format { + return true + } + } + return false +} + +// outputImagesJson sends the provided ImageStatus info to the io.Writer in JSON formatting, honoring limits in opts +func outputImagesJson(images []v6.ImageStatus, out io.Writer, opts *imageListOpts) error { + if opts.limit < 0 { + return errors.New("opts.limit cannot be less than 0") + } + var sliceLimit int + + // Truncate the Available container images to honor the lesser of + // opts.limit or the total number of Available + for i := 0; i < len(images); i++ { + containerImages := images[i] + for i := 0; i < len(containerImages.Containers); i++ { + if opts.limit != 0 { + available := containerImages.Containers[i].Available + if len(available) < opts.limit { + sliceLimit = len(available) + } else { + sliceLimit = opts.limit + } + containerImages.Containers[i].Available = containerImages.Containers[i].Available[:sliceLimit] + } + } + } + + e := json.NewEncoder(out) + if err := e.Encode(images); err != nil { + return err + } + return nil +} + +// outputImagesTab sends the provided ImageStatus info to os.Stdout in tab formatting, honoring limits in opts +func outputImagesTab(images []v6.ImageStatus, opts *imageListOpts) { + out := newTabwriter() + + if !opts.noHeaders { + fmt.Fprintln(out, "WORKLOAD\tCONTAINER\tIMAGE\tCREATED") + } + + for _, image := range images { + if len(image.Containers) == 0 { + fmt.Fprintf(out, "%s\t\t\t\n", image.ID) + continue + } + + imageName := image.ID.String() + for _, container := range image.Containers { + var lineCount int + containerName := container.Name + reg, repo, currentTag := container.Current.ID.Components() + if reg != "" { + reg += "/" + } + if len(container.Available) == 0 { + availableErr := container.AvailableError + if availableErr == "" { + availableErr = registry.ErrNoImageData.Error() + } + fmt.Fprintf(out, "%s\t%s\t%s%s\t%s\n", imageName, containerName, reg, repo, availableErr) + } else { + fmt.Fprintf(out, "%s\t%s\t%s%s\t\n", imageName, containerName, reg, repo) + } + foundRunning := false + for _, available := range container.Available { + running := "| " + _, _, tag := available.ID.Components() + if currentTag == tag { + running = "'->" + foundRunning = true + } else if foundRunning { + running = " " + } + + lineCount++ + var printEllipsis, printLine bool + if opts.limit <= 0 || lineCount <= opts.limit { + printEllipsis, printLine = false, true + } else if container.Current.ID == available.ID { + printEllipsis, printLine = lineCount > (opts.limit+1), true + } + if printEllipsis { + fmt.Fprintf(out, "\t\t%s (%d image(s) omitted)\t\n", ":", lineCount-opts.limit-1) + } + if printLine { + createdAt := "" + if !available.CreatedAt.IsZero() { + createdAt = available.CreatedAt.Format(time.RFC822) + } + fmt.Fprintf(out, "\t\t%s %s\t%s\n", running, tag, createdAt) + } + } + if !foundRunning { + running := "'->" + if currentTag == "" { + currentTag = "(untagged)" + } + fmt.Fprintf(out, "\t\t%s %s\t%s\n", running, currentTag, "?") + + } + imageName = "" + } + } + out.Flush() +} + +// outputWorkloadsJson sends the provided Workload data to the io.Writer as JSON +func outputWorkloadsJson(workloads []v6.ControllerStatus, out io.Writer) error { + encoder := json.NewEncoder(out) + return encoder.Encode(workloads) +} + +// outputWorkloadsTab sends the provided Workload data to STDOUT, formatted with tabs for CLI +func outputWorkloadsTab(workloads []v6.ControllerStatus, opts *workloadListOpts) { + w := newTabwriter() + if !opts.noHeaders { + fmt.Fprintf(w, "WORKLOAD\tCONTAINER\tIMAGE\tRELEASE\tPOLICY\n") + } + + for _, workload := range workloads { + if len(workload.Containers) > 0 { + c := workload.Containers[0] + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", workload.ID, c.Name, c.Current.ID, workload.Status, policies(workload)) + for _, c := range workload.Containers[1:] { + fmt.Fprintf(w, "\t%s\t%s\t\t\n", c.Name, c.Current.ID) + } + } else { + fmt.Fprintf(w, "%s\t\t\t%s\t%s\n", workload.ID, workload.Status, policies(workload)) + } + } + w.Flush() +} diff --git a/cmd/fluxctl/list_images_cmd.go b/cmd/fluxctl/list_images_cmd.go index a59fda2e4..b3bfa1866 100644 --- a/cmd/fluxctl/list_images_cmd.go +++ b/cmd/fluxctl/list_images_cmd.go @@ -2,25 +2,24 @@ package main import ( "context" - "fmt" + "os" "sort" - "time" "github.com/spf13/cobra" v10 "github.com/fluxcd/flux/pkg/api/v10" v6 "github.com/fluxcd/flux/pkg/api/v6" - "github.com/fluxcd/flux/pkg/registry" "github.com/fluxcd/flux/pkg/resource" "github.com/fluxcd/flux/pkg/update" ) type imageListOpts struct { *rootOpts - namespace string - workload string - limit int - noHeaders bool + namespace string + workload string + limit int + noHeaders bool + outputFormat string // Deprecated controller string @@ -41,6 +40,7 @@ func (opts *imageListOpts) Command() *cobra.Command { cmd.Flags().StringVarP(&opts.workload, "workload", "w", "", "Show images for this workload") cmd.Flags().IntVarP(&opts.limit, "limit", "l", 10, "Number of images to show (0 for all)") cmd.Flags().BoolVar(&opts.noHeaders, "no-headers", false, "Don't print headers (default print headers)") + cmd.Flags().StringVarP(&opts.outputFormat, "output-format", "o", "tab", "Output format (tab or json)") // Deprecated cmd.Flags().StringVarP(&opts.controller, "controller", "c", "", "Show images for this controller") @@ -53,6 +53,11 @@ func (opts *imageListOpts) RunE(cmd *cobra.Command, args []string) error { if len(args) != 0 { return errorWantedNoArgs } + + if !outputFormatIsValid(opts.outputFormat) { + return errorInvalidOutputFormat + } + ns := getKubeConfigContextNamespaceOrDefault(opts.namespace, "default", opts.Context) imageOpts := v10.ListImagesOptions{ Spec: update.ResourceSpecAll, @@ -61,7 +66,7 @@ func (opts *imageListOpts) RunE(cmd *cobra.Command, args []string) error { // Backwards compatibility with --controller until we remove it switch { case opts.workload != "" && opts.controller != "": - return newUsageError("can't specify both the controller and workload") + return newUsageError("can't specify both the controller and image") case opts.controller != "": opts.workload = opts.controller } @@ -76,83 +81,20 @@ func (opts *imageListOpts) RunE(cmd *cobra.Command, args []string) error { ctx := context.Background() - workloads, err := opts.API.ListImagesWithOptions(ctx, imageOpts) + images, err := opts.API.ListImagesWithOptions(ctx, imageOpts) if err != nil { return err } - sort.Sort(imageStatusByName(workloads)) - - out := newTabwriter() + sort.Sort(imageStatusByName(images)) - if !opts.noHeaders { - fmt.Fprintln(out, "WORKLOAD\tCONTAINER\tIMAGE\tCREATED") + switch opts.outputFormat { + case outputFormatJson: + return outputImagesJson(images, os.Stdout, opts) + default: + outputImagesTab(images, opts) } - for _, workload := range workloads { - if len(workload.Containers) == 0 { - fmt.Fprintf(out, "%s\t\t\t\n", workload.ID) - continue - } - - workloadName := workload.ID.String() - for _, container := range workload.Containers { - var lineCount int - containerName := container.Name - reg, repo, currentTag := container.Current.ID.Components() - if reg != "" { - reg += "/" - } - if len(container.Available) == 0 { - availableErr := container.AvailableError - if availableErr == "" { - availableErr = registry.ErrNoImageData.Error() - } - fmt.Fprintf(out, "%s\t%s\t%s%s\t%s\n", workloadName, containerName, reg, repo, availableErr) - } else { - fmt.Fprintf(out, "%s\t%s\t%s%s\t\n", workloadName, containerName, reg, repo) - } - foundRunning := false - for _, available := range container.Available { - running := "| " - _, _, tag := available.ID.Components() - if currentTag == tag { - running = "'->" - foundRunning = true - } else if foundRunning { - running = " " - } - - lineCount++ - var printEllipsis, printLine bool - if opts.limit <= 0 || lineCount <= opts.limit { - printEllipsis, printLine = false, true - } else if container.Current.ID == available.ID { - printEllipsis, printLine = lineCount > (opts.limit+1), true - } - if printEllipsis { - fmt.Fprintf(out, "\t\t%s (%d image(s) omitted)\t\n", ":", lineCount-opts.limit-1) - } - if printLine { - createdAt := "" - if !available.CreatedAt.IsZero() { - createdAt = available.CreatedAt.Format(time.RFC822) - } - fmt.Fprintf(out, "\t\t%s %s\t%s\n", running, tag, createdAt) - } - } - if !foundRunning { - running := "'->" - if currentTag == "" { - currentTag = "(untagged)" - } - fmt.Fprintf(out, "\t\t%s %s\t%s\n", running, currentTag, "?") - - } - workloadName = "" - } - } - out.Flush() return nil } diff --git a/cmd/fluxctl/list_images_cmd_test.go b/cmd/fluxctl/list_images_cmd_test.go new file mode 100644 index 000000000..0ab42e3ea --- /dev/null +++ b/cmd/fluxctl/list_images_cmd_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fluxcd/flux/pkg/image" + "github.com/fluxcd/flux/pkg/update" + + v6 "github.com/fluxcd/flux/pkg/api/v6" +) + +func Test_outputImagesJson(t *testing.T) { + opts := &imageListOpts{limit: 10} // 10 is the default from the flag + + t.Run("sends JSON to the io.Writer", func(t *testing.T) { + buf := &bytes.Buffer{} + images := testImages(opts.limit) + err := outputImagesJson(images, buf, opts) + require.NoError(t, err) + unmarshallTarget := &[]v6.ImageStatus{} + err = json.Unmarshal(buf.Bytes(), unmarshallTarget) + require.NoError(t, err) + }) + + t.Run("respects provided limit on Available container images", func(t *testing.T) { + buf := &bytes.Buffer{} + images := testImages(100) + _ = outputImagesJson(images, buf, opts) + + unmarshallTarget := &[]v6.ImageStatus{} + _ = json.Unmarshal(buf.Bytes(), unmarshallTarget) + + imageSlice := *unmarshallTarget + availableListSize := len(imageSlice[0].Containers[0].Available) + + assert.Equal(t, opts.limit, availableListSize) + }) + + t.Run("provides all when limit is 0", func(t *testing.T) { + buf := &bytes.Buffer{} + opts := &imageListOpts{limit: 0} // 0 means all + count := 100 + images := testImages(count) + _ = outputImagesJson(images, buf, opts) + + unmarshallTarget := &[]v6.ImageStatus{} + _ = json.Unmarshal(buf.Bytes(), unmarshallTarget) + + imageSlice := *unmarshallTarget + availableListSize := len(imageSlice[0].Containers[0].Available) + + assert.Equal(t, count, availableListSize) + }) + + t.Run("returns an error on a bad limit", func(t *testing.T) { + badLimitOpts := &imageListOpts{limit: -1} + buf := &bytes.Buffer{} + images := testImages(10) + err := outputImagesJson(images, buf, badLimitOpts) + assert.Error(t, err) + }) +} + +// testImages returns a single-member collection of ImageStatus objects with +// an optional number of Available images on the only Container +func testImages(availableCount int) []v6.ImageStatus { + containerWithAvailable := []v6.Container{{Name: "TestContainer"}} + + images := []v6.ImageStatus{{Containers: containerWithAvailable}} + available := update.SortedImageInfos{} + + for i := 0; i < availableCount; i++ { + digest := fmt.Sprintf("abc123%d", i) + imageID := fmt.Sprintf("deadbeef%d", i) + testImage := image.Info{ + ID: image.Ref{}, + Digest: digest, + ImageID: imageID, + Labels: image.Labels{}, + CreatedAt: time.Time{}, + LastFetched: time.Time{}, + } + + available = append(available, testImage) + } + + images[0].Containers[0].Available = available + + return images +} diff --git a/cmd/fluxctl/list_workloads_cmd.go b/cmd/fluxctl/list_workloads_cmd.go index fda7ac0a2..b26696b30 100644 --- a/cmd/fluxctl/list_workloads_cmd.go +++ b/cmd/fluxctl/list_workloads_cmd.go @@ -2,7 +2,7 @@ package main import ( "context" - "fmt" + "os" "sort" "strings" @@ -18,6 +18,7 @@ type workloadListOpts struct { allNamespaces bool containerName string noHeaders bool + outputFormat string } func newWorkloadList(parent *rootOpts) *workloadListOpts { @@ -36,6 +37,7 @@ func (opts *workloadListOpts) Command() *cobra.Command { cmd.Flags().BoolVarP(&opts.allNamespaces, "all-namespaces", "a", false, "Query across all namespaces") cmd.Flags().StringVarP(&opts.containerName, "container", "c", "", "Filter workloads by container name") cmd.Flags().BoolVar(&opts.noHeaders, "no-headers", false, "Don't print headers (default print headers)") + cmd.Flags().StringVarP(&opts.outputFormat, "output-format", "o", "tab", "Output format (tab or json)") return cmd } @@ -44,6 +46,10 @@ func (opts *workloadListOpts) RunE(cmd *cobra.Command, args []string) error { return errorWantedNoArgs } + if !outputFormatIsValid(opts.outputFormat) { + return errorInvalidOutputFormat + } + var ns string if opts.allNamespaces { ns = "" @@ -64,23 +70,13 @@ func (opts *workloadListOpts) RunE(cmd *cobra.Command, args []string) error { sort.Sort(workloadStatusByName(workloads)) - w := newTabwriter() - if !opts.noHeaders { - fmt.Fprintf(w, "WORKLOAD\tCONTAINER\tIMAGE\tRELEASE\tPOLICY\n") + switch opts.outputFormat { + case outputFormatJson: + outputWorkloadsJson(workloads, os.Stdout) + default: + outputWorkloadsTab(workloads, opts) } - for _, workload := range workloads { - if len(workload.Containers) > 0 { - c := workload.Containers[0] - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", workload.ID, c.Name, c.Current.ID, workload.Status, policies(workload)) - for _, c := range workload.Containers[1:] { - fmt.Fprintf(w, "\t%s\t%s\t\t\n", c.Name, c.Current.ID) - } - } else { - fmt.Fprintf(w, "%s\t\t\t%s\t%s\n", workload.ID, workload.Status, policies(workload)) - } - } - w.Flush() return nil } @@ -115,8 +111,8 @@ func policies(s v6.ControllerStatus) string { // Extract workloads having its container name equal to containerName func filterByContainerName(workloads []v6.ControllerStatus, containerName string) (filteredWorkloads []v6.ControllerStatus) { - for _, workload := range workloads { - if len(workload.Containers) > 0 { + for _, workload := range workloads { + if len(workload.Containers) > 0 { for _, c := range workload.Containers { if c.Name == containerName { filteredWorkloads = append(filteredWorkloads, workload) @@ -124,6 +120,6 @@ func filterByContainerName(workloads []v6.ControllerStatus, containerName string } } } - } - return + } + return } diff --git a/cmd/fluxctl/list_workloads_cmd_test.go b/cmd/fluxctl/list_workloads_cmd_test.go new file mode 100644 index 000000000..09a9eb7f5 --- /dev/null +++ b/cmd/fluxctl/list_workloads_cmd_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fluxcd/flux/pkg/cluster" + + "github.com/fluxcd/flux/pkg/resource" + + v6 "github.com/fluxcd/flux/pkg/api/v6" +) + +func Test_outputWorkloadsJson(t *testing.T) { + buf := &bytes.Buffer{} + + t.Run("sends JSON to the io.Writer", func(t *testing.T) { + workloads := testWorkloads(5) + err := outputWorkloadsJson(workloads, buf) + require.NoError(t, err) + unmarshallTarget := &[]v6.ControllerStatus{} + err = json.Unmarshal(buf.Bytes(), unmarshallTarget) + require.NoError(t, err) + }) +} + +func testWorkloads(workloadCount int) []v6.ControllerStatus { + workloads := []v6.ControllerStatus{} + for i := 0; i < workloadCount; i++ { + name := fmt.Sprintf("mah-app-%d", i) + id := resource.MakeID("applications", "deployment", name) + + cs := v6.ControllerStatus{ + ID: id, + Containers: nil, + ReadOnly: "", + Status: "ready", + Rollout: cluster.RolloutStatus{}, + SyncError: "", + Antecedent: resource.ID{}, + Labels: nil, + Automated: false, + Locked: false, + Ignore: false, + Policies: nil, + } + + workloads = append(workloads, cs) + + } + return workloads +}