Refactor control export to use ExportManager. Closes #2515

This commit is contained in:
kaidaguerre
2022-10-12 16:25:31 +01:00
committed by GitHub
parent c7f32a36da
commit 397c951fe2
25 changed files with 603 additions and 674 deletions

View File

@@ -1,15 +1,11 @@
package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"golang.org/x/exp/maps"
"io"
"os"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
@@ -18,7 +14,6 @@ import (
"github.com/turbot/steampipe/pkg/cmdconfig"
"github.com/turbot/steampipe/pkg/constants"
"github.com/turbot/steampipe/pkg/contexthelpers"
"github.com/turbot/steampipe/pkg/control"
"github.com/turbot/steampipe/pkg/control/controldisplay"
"github.com/turbot/steampipe/pkg/control/controlexecute"
"github.com/turbot/steampipe/pkg/control/controlstatus"
@@ -129,9 +124,6 @@ func runCheckCmd(cmd *cobra.Command, args []string) {
workspace := initData.Workspace
client := initData.Client
failures := 0
var exportErrors []error
exportErrorsLock := &sync.Mutex{}
exportWaitGroup := &sync.WaitGroup{}
var durations []time.Duration
shouldShare := viper.IsSet(constants.ArgShare)
@@ -145,7 +137,7 @@ func runCheckCmd(cmd *cobra.Command, args []string) {
}
// treat each arg as a separate execution
for _, arg := range args {
for _, targetName := range args {
if utils.IsContextCancelled(ctx) {
durations = append(durations, 0)
// skip over this arg, since the execution was cancelled
@@ -153,12 +145,8 @@ func runCheckCmd(cmd *cobra.Command, args []string) {
continue
}
// get the export formats for this argument
exportTargets, err := getExportTargets(arg)
error_helpers.FailOnError(err)
// create the execution tree
executionTree, err := controlexecute.NewExecutionTree(ctx, workspace, client, arg)
executionTree, err := controlexecute.NewExecutionTree(ctx, workspace, client, targetName)
error_helpers.FailOnError(err)
// execute controls synchronously (execute returns the number of failures)
@@ -166,16 +154,9 @@ func runCheckCmd(cmd *cobra.Command, args []string) {
err = displayControlResults(ctx, executionTree)
error_helpers.FailOnError(err)
if len(exportTargets) > 0 {
d := &control.ExportData{
ExecutionTree: executionTree,
Targets: exportTargets,
ErrorsLock: exportErrorsLock,
Errors: exportErrors,
WaitGroup: exportWaitGroup,
}
exportCheckResult(ctx, d)
}
exportArgs := viper.GetStringSlice(constants.ArgExport)
err = initData.ExportManager.DoExport(ctx, targetName, executionTree, exportArgs)
error_helpers.FailOnError(err)
// if the share args are set, create a snapshot and share it
if generateSnapshot {
@@ -185,13 +166,6 @@ func runCheckCmd(cmd *cobra.Command, args []string) {
durations = append(durations, executionTree.EndTime.Sub(executionTree.StartTime))
}
// wait for exports to complete
exportWaitGroup.Wait()
if len(exportErrors) > 0 {
error_helpers.ShowError(ctx, error_helpers.CombineErrors(exportErrors...))
}
if shouldPrintTiming() {
printTiming(args, durations)
}
@@ -241,6 +215,10 @@ func initialiseCheck(ctx context.Context) *initialisation.InitData {
return initData
}
if len(viper.GetStringSlice(constants.ArgExport)) > 0 {
registerCheckExporters(initData)
}
// control specific init
if !w.ModfileExists() {
initData.Result.Error = workspace.ErrorNoModDefinition
@@ -269,6 +247,15 @@ func initialiseCheck(ctx context.Context) *initialisation.InitData {
return initData
}
// register exporters for each of the supported check formats
func registerCheckExporters(initData *initialisation.InitData) {
exporters, err := controldisplay.GetExporters()
error_helpers.FailOnErrorWithMessage(err, "failed to load exporters")
// register all exporters
initData.RegisterExporters(exporters...)
}
func initialiseCheckColorScheme() error {
theme := viper.GetString(constants.ArgTheme)
if !viper.GetBool(constants.ConfigKeyIsTerminalTTY) {
@@ -306,17 +293,6 @@ func shouldPrintTiming() bool {
(outputFormat == constants.OutputFormatText || outputFormat == constants.OutputFormatBrief)
}
func exportCheckResult(ctx context.Context, d *control.ExportData) {
d.WaitGroup.Add(1)
go func() {
err := exportControlResults(ctx, d.ExecutionTree, d.Targets)
if len(err) > 0 {
d.AddErrors(err)
}
d.WaitGroup.Done()
}()
}
func displayControlResults(ctx context.Context, executionTree *controlexecute.ExecutionTree) error {
output := viper.GetString(constants.ArgOutput)
formatter, err := parseOutputArg(output)
@@ -328,141 +304,10 @@ func displayControlResults(ctx context.Context, executionTree *controlexecute.Ex
if err != nil {
return err
}
// tactical solution to prettify the json output
if output == constants.OutputFormatJSON {
reader, err = prettifyJsonFromReader(reader)
if err != nil {
return err
}
}
_, err = io.Copy(os.Stdout, reader)
return err
}
func exportControlResults(ctx context.Context, executionTree *controlexecute.ExecutionTree, targets []*controldisplay.CheckExportTarget) []error {
errors := []error{}
for _, target := range targets {
if utils.IsContextCancelled(ctx) {
// set the error
errors = append(errors, ctx.Err())
// and skip forward
continue
}
dataToExport, err := target.Formatter.Format(ctx, executionTree)
if err != nil {
errors = append(errors, err)
continue
}
if utils.IsContextCancelled(ctx) {
errors = append(errors, ctx.Err())
continue
}
// create the output file
destination, err := os.Create(target.File)
if err != nil {
errors = append(errors, err)
continue
}
// tactical solution to prettify the json output
if target.Formatter.Name() == constants.OutputFormatJSON {
dataToExport, err = prettifyJsonFromReader(dataToExport)
if err != nil {
errors = append(errors, err)
continue
}
}
_, err = io.Copy(destination, dataToExport)
if err != nil {
errors = append(errors, err)
continue
}
destination.Close()
}
return errors
}
func prettifyJsonFromReader(dataToExport io.Reader) (io.Reader, error) {
b, err := io.ReadAll(dataToExport)
if err != nil {
return nil, err
}
var prettyJSON bytes.Buffer
err = json.Indent(&prettyJSON, b, "", " ")
if err != nil {
return nil, err
}
dataToExport = &prettyJSON
return dataToExport, nil
}
func getExportTargets(executionName string) ([]*controldisplay.CheckExportTarget, error) {
var targets = make(map[string]*controldisplay.CheckExportTarget)
var targetErrors []error
exports := viper.GetStringSlice(constants.ArgExport)
for _, export := range exports {
export = strings.TrimSpace(export)
if len(export) == 0 {
// if this is an empty string, ignore
continue
}
newTarget, err := getExportTarget(executionName, export)
if err != nil {
targetErrors = append(targetErrors, err)
continue
}
if newTarget == nil {
targetErrors = append(targetErrors, fmt.Errorf("formatter satisfying '%s' not found", export))
continue
}
// add to map if not already there
if _, ok := targets[newTarget.File]; !ok {
targets[newTarget.File] = newTarget
}
}
// convert target map into array
targetList := maps.Values(targets)
return targetList, error_helpers.CombineErrors(targetErrors...)
}
// getExportTarget parses the flag value, finds a matching formatter and returns an export target
func getExportTarget(executionName string, export string) (*controldisplay.CheckExportTarget, error) {
formatResolver, err := controldisplay.NewFormatResolver()
if err != nil {
return nil, err
}
var fileName string
formatter, err := formatResolver.GetFormatter(export)
if err != nil {
// as we are resolving the format by extension, the arg will be the filename so return it
formatter, err = formatResolver.GetFormatterByExtension(export)
if err != nil {
return nil, err
}
if formatter == nil {
return nil, nil
}
// use the export arg as the filename
fileName = export
}
if fileName == "" {
// we need to generate a filename
fileName = generateDefaultExportFileName(formatter, executionName)
}
target := controldisplay.NewCheckExportTarget(formatter, fileName)
return target, nil
}
// parseOutputArg parses the --output flag value and returns the Formatter that can format the data
func parseOutputArg(arg string) (formatter controldisplay.Formatter, err error) {
formatResolver, err := controldisplay.NewFormatResolver()
@@ -472,9 +317,3 @@ func parseOutputArg(arg string) (formatter controldisplay.Formatter, err error)
return formatResolver.GetFormatter(arg)
}
func generateDefaultExportFileName(formatter controldisplay.Formatter, executionName string) string {
now := time.Now()
timeFormatted := fmt.Sprintf("%d%02d%02d-%02d%02d%02d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
return fmt.Sprintf("%s-%s%s", executionName, timeFormatted, formatter.FileExtension())
}

View File

@@ -265,8 +265,8 @@ func getInitData(ctx context.Context, w *workspace.Workspace) *initialisation.In
return initData
}
func dashboardExporters() []*export.SnapshotExporter {
return []*export.SnapshotExporter{&export.SnapshotExporter{}}
func dashboardExporters() []export.Exporter {
return []export.Exporter{&export.SnapshotExporter{}}
}
func runSingleDashboard(ctx context.Context, targetName string, inputs map[string]interface{}) error {
@@ -303,7 +303,8 @@ func runSingleDashboard(ctx context.Context, targetName string, inputs map[strin
error_helpers.FailOnErrorWithMessage(err, "failed to upload snapshot")
// export the result (if needed)
err = exportSnapshot(initData.ExportResolver, targetName, snap)
exportArgs := viper.GetStringSlice(constants.ArgExport)
err = initData.ExportManager.DoExport(ctx, targetName, snap, exportArgs)
error_helpers.FailOnErrorWithMessage(err, "failed to export snapshot")
return nil

View File

@@ -21,7 +21,6 @@ import (
"github.com/turbot/steampipe/pkg/dashboard/dashboardtypes"
"github.com/turbot/steampipe/pkg/display"
"github.com/turbot/steampipe/pkg/error_helpers"
"github.com/turbot/steampipe/pkg/export"
"github.com/turbot/steampipe/pkg/interactive"
"github.com/turbot/steampipe/pkg/query"
"github.com/turbot/steampipe/pkg/query/queryexecute"
@@ -212,40 +211,14 @@ func executeSnapshotQuery(initData *query.InitData, w *workspace.Workspace, ctx
err = uploadSnapshot(snap)
// export the result if necessary
err = exportSnapshot(initData.ExportResolver, targetName, snap)
exportArgs := viper.GetStringSlice(constants.ArgExport)
err = initData.ExportManager.DoExport(ctx, targetName, snap, exportArgs)
error_helpers.FailOnErrorWithMessage(err, "failed to export snapshot")
}
}
return 0
}
func exportSnapshot(exportResolver *export.Resolver, targetName string, snap *dashboardtypes.SteampipeSnapshot) error {
exports := viper.GetStringSlice(constants.ArgExport)
if len(exports) == 0 {
return nil
}
// get the short name for the target
parsedResource, err := modconfig.ParseResourceName(targetName)
if err != nil {
return err
}
shortName := parsedResource.Name
targets, err := exportResolver.ResolveTargetsFromArgs(exports, shortName)
if err != nil {
return err
}
var errors []error
for _, target := range targets {
if err := target.Export(snap); err != nil {
errors = append(errors, err)
}
}
return error_helpers.CombineErrors(errors...)
}
func snapshotToQueryResult(snap *dashboardtypes.SteampipeSnapshot, name string) (*queryresult.Result, error) {
// find chart nde - we expect only 1
parsedName, err := modconfig.ParseResourceName(name)

View File

@@ -1,13 +0,0 @@
package controldisplay
type CheckExportTarget struct {
Formatter Formatter
File string
}
func NewCheckExportTarget(formatter Formatter, file string) *CheckExportTarget {
return &CheckExportTarget{
Formatter: formatter,
File: file,
}
}

View File

@@ -0,0 +1,43 @@
package controldisplay
import (
"context"
"fmt"
"github.com/turbot/steampipe/pkg/control/controlexecute"
"github.com/turbot/steampipe/pkg/export"
)
type ControlExporter struct {
formatter Formatter
}
func NewControlExporter(formatter Formatter) *ControlExporter {
return &ControlExporter{formatter}
}
func (e *ControlExporter) Export(ctx context.Context, input export.ExportSourceData, destPath string) error {
// input must be control execution tree
tree, ok := input.(*controlexecute.ExecutionTree)
if !ok {
return fmt.Errorf("ControlExporter input must be *controlexecute.ExecutionTree")
}
res, err := e.formatter.Format(ctx, tree)
if err != nil {
return err
}
return export.Write(destPath, res)
}
func (e *ControlExporter) FileExtension() string {
return e.formatter.FileExtension()
}
func (e *ControlExporter) Name() string {
return e.formatter.Name()
}
func (e *ControlExporter) Alias() string {
return e.formatter.Alias()
}

View File

@@ -3,15 +3,17 @@ package controldisplay
import (
"fmt"
"github.com/turbot/steampipe/pkg/constants"
"github.com/turbot/steampipe/pkg/export"
"github.com/turbot/steampipe/pkg/filepaths"
"os"
"path/filepath"
"strings"
)
type FormatResolver struct {
templates []*OutputTemplate
outputFormatters map[string]Formatter
templates []*OutputTemplate
formatterByName map[string]Formatter
// array of unique formatters used for export
exportFormatters []Formatter
}
func NewFormatResolver() (*FormatResolver, error) {
@@ -19,107 +21,72 @@ func NewFormatResolver() (*FormatResolver, error) {
if err != nil {
return nil, err
}
var outputFormatters = map[string]Formatter{
constants.OutputFormatNone: &NullFormatter{},
constants.OutputFormatText: &TextFormatter{},
constants.OutputFormatBrief: &TextFormatter{},
constants.OutputFormatSnapshot: &SnapshotFormatter{},
formatters := []Formatter{
&NullFormatter{},
&TextFormatter{},
&SnapshotFormatter{},
}
return &FormatResolver{templates: templates, outputFormatters: outputFormatters}, nil
res := &FormatResolver{
formatterByName: make(map[string]Formatter),
}
for _, f := range formatters {
if err := res.registerFormatter(f); err != nil {
return nil, err
}
}
for _, t := range templates {
f, err := NewTemplateFormatter(t)
if err != nil {
return nil, err
}
if err := res.registerFormatter(f); err != nil {
return nil, err
}
}
return res, nil
}
func (r *FormatResolver) GetFormatter(arg string) (Formatter, error) {
if formatter, found := r.outputFormatters[arg]; found {
if formatter, found := r.formatterByName[arg]; found {
return formatter, nil
}
// otherwise look for a template
templateFormat, err := r.resolveOutputTemplate(arg)
if err != nil {
return nil, err
}
return NewTemplateFormatter(templateFormat)
return nil, fmt.Errorf("could not resolve formatter for %s", arg)
}
func (r *FormatResolver) GetFormatterByExtension(filename string) (Formatter, error) {
// so we failed to exactly match an existing format or template name
// instead, treat the arg as a filename and try to infer the formatter from the extension
func (r *FormatResolver) registerFormatter(f Formatter) error {
name := f.Name()
extension := filepath.Ext(filename)
// first try the defined formatters
for _, formatter := range r.outputFormatters {
if formatter.FileExtension() == extension {
return formatter, nil
if _, ok := r.formatterByName[name]; ok {
return fmt.Errorf("failed to register output formatter - duplicate format name %s", name)
}
r.formatterByName[name] = f
// if the formatter has an alias, also register by alias
if alias := f.Alias(); alias != "" {
if _, ok := r.formatterByName[alias]; ok {
return fmt.Errorf("failed to register output formatter - duplicate format name %s", alias)
}
r.formatterByName[alias] = f
}
// try to find the target template by the given filename
matchedTemplate, err := r.findTemplateByExtension(filename)
if err != nil {
return nil, err
// add to exportFormatters list (exclude 'None')
if f.Name() != constants.OutputFormatNone {
r.exportFormatters = append(r.exportFormatters, f)
}
return NewTemplateFormatter(matchedTemplate)
return nil
}
// resolveOutputTemplate accepts the export argument and resolves the template to use.
// If an exact match to the available templates is not found, and if 'allowFilenameEvaluation' is true
// then the 'export' value is parsed as a filename and the suffix is used to match to available templates
// returns
// - the export template to use
// - the path of the file to write to
// - error (if any)
func (r *FormatResolver) resolveOutputTemplate(export string) (format *OutputTemplate, err error) {
// try an exact match
for _, t := range r.templates {
if t.FormatName == export || t.FormatFullName == export {
return t, nil
}
func (r *FormatResolver) controlExporters() []export.Exporter {
res := make([]export.Exporter, len(r.exportFormatters))
for i, formatter := range r.exportFormatters {
res[i] = NewControlExporter(formatter)
}
return nil, fmt.Errorf("template %s not found", export)
}
func (r *FormatResolver) findTemplateByExtension(filename string) (format *OutputTemplate, err error) {
// does the filename end with this exact format?
for _, t := range r.templates {
if strings.HasSuffix(filename, t.FormatFullName) {
return t, nil
}
}
extension := filepath.Ext(filename)
if len(extension) == 0 {
// we don't have anything to work with
return nil, fmt.Errorf("template %s not found", filename)
}
var matchingTemplates []*OutputTemplate
// does the given extension match with one of the template extension?
for _, t := range r.templates {
if strings.HasSuffix(t.OutputExtension, extension) {
matchingTemplates = append(matchingTemplates, t)
}
}
if len(matchingTemplates) > 1 {
matchNames := []string{}
// find out if any of them has preference
for _, match := range matchingTemplates {
if match.DefaultTemplateForExtension {
return match, nil
}
matchNames = append(matchNames, match.FormatName)
}
// there's ambiguity - we have more than one matching templates based on extension
return nil, fmt.Errorf("ambiguous templates found: %v", matchNames)
}
if len(matchingTemplates) == 1 {
return matchingTemplates[0], nil
}
return nil, fmt.Errorf("template %s not found", filename)
return res
}
func loadAvailableTemplates() ([]*OutputTemplate, error) {

View File

@@ -10,4 +10,11 @@ type Formatter interface {
Format(ctx context.Context, tree *controlexecute.ExecutionTree) (io.Reader, error)
FileExtension() string
Name() string
Alias() string
}
type FormatterBase struct{}
func (*FormatterBase) Alias() string {
return ""
}

View File

@@ -2,6 +2,7 @@ package controldisplay
import (
"context"
"github.com/turbot/steampipe/pkg/constants"
"github.com/turbot/steampipe/pkg/control/controlexecute"
"io"
"strings"
@@ -9,7 +10,9 @@ import (
// NullFormatter is to be used when no output is expected. It always returns a `io.Reader` which
// reads an empty string
type NullFormatter struct{}
type NullFormatter struct {
FormatterBase
}
func (j *NullFormatter) Format(ctx context.Context, tree *controlexecute.ExecutionTree) (io.Reader, error) {
return strings.NewReader(""), nil
@@ -21,6 +24,5 @@ func (j *NullFormatter) FileExtension() string {
}
func (j *NullFormatter) Name() string {
// will not be called
return ""
return constants.OutputFormatNone
}

View File

@@ -11,7 +11,9 @@ import (
"github.com/turbot/steampipe/pkg/control/controlexecute"
)
type SnapshotFormatter struct{}
type SnapshotFormatter struct {
FormatterBase
}
func (f *SnapshotFormatter) Format(_ context.Context, tree *controlexecute.ExecutionTree) (io.Reader, error) {
snapshot, err := executionTreeToSnapshot(tree)
@@ -36,3 +38,7 @@ func (f *SnapshotFormatter) FileExtension() string {
func (f SnapshotFormatter) Name() string {
return constants.OutputFormatSnapshot
}
func (f *SnapshotFormatter) Alias() string {
return ""
}

View File

@@ -2,7 +2,7 @@ package controldisplay
import (
"context"
"fmt"
"github.com/turbot/steampipe/pkg/utils"
"io"
"os"
"text/template"
@@ -13,20 +13,6 @@ import (
"github.com/turbot/steampipe/pkg/version"
)
type TemplateRenderConfig struct {
RenderHeader bool
}
type TemplateRenderConstants struct {
SteampipeVersion string
WorkingDir string
}
type TemplateRenderContext struct {
Constants TemplateRenderConstants
Config TemplateRenderConfig
Data *controlexecute.ExecutionTree
}
// TemplateFormatter implements the 'Formatter' interface and exposes a generic template based output mechanism
// for 'check' execution trees
type TemplateFormatter struct {
@@ -34,6 +20,22 @@ type TemplateFormatter struct {
exportFormat *OutputTemplate
}
func NewTemplateFormatter(input *OutputTemplate) (*TemplateFormatter, error) {
templateFuncs := templateFuncs()
// add a stub "render_context" function
// this will be overwritten before we execute the template
// if we don't put this here, then templates which use this
// won't parse and will throw Error: template: ****: function "render_context" not defined
templateFuncs["render_context"] = func() TemplateRenderContext { return TemplateRenderContext{} }
t := template.Must(template.New("outlet").
Funcs(templateFuncs).
ParseFS(os.DirFS(input.TemplatePath), "*"))
return &TemplateFormatter{exportFormat: input, template: t}, nil
}
func (tf TemplateFormatter) Format(ctx context.Context, tree *controlexecute.ExecutionTree) (io.Reader, error) {
reader, writer := io.Pipe()
go func() {
@@ -70,35 +72,30 @@ func (tf TemplateFormatter) Format(ctx context.Context, tree *controlexecute.Exe
writer.Close()
}
}()
// tactical - for json, prettify the output
if tf.shouldPrettify(){
return utils.PrettifyJsonFromReader(reader)
}
return reader, nil
}
func (tf TemplateFormatter) FileExtension() string {
// if the extension is the same as the format name, return just the extension
if tf.exportFormat.DefaultTemplateForExtension {
return tf.exportFormat.OutputExtension
} else {
// otherwise return the fullname
return fmt.Sprintf(".%s", tf.exportFormat.FormatFullName)
}
return tf.exportFormat.FileExtension
}
func (tf TemplateFormatter) Name() string {
return tf.exportFormat.FormatName
}
func NewTemplateFormatter(input *OutputTemplate) (*TemplateFormatter, error) {
templateFuncs := templateFuncs()
// add a stub "render_context" function
// this will be overwritten before we execute the template
// if we don't put this here, then templates which use this
// won't parse and will throw Error: template: ****: function "render_context" not defined
templateFuncs["render_context"] = func() TemplateRenderContext { return TemplateRenderContext{} }
t := template.Must(template.New("outlet").
Funcs(templateFuncs).
ParseFS(os.DirFS(input.TemplatePath), "*"))
return &TemplateFormatter{exportFormat: input, template: t}, nil
func (tf TemplateFormatter) Alias() string {
if tf.exportFormat.FormatFullName != tf.exportFormat.FormatName {
return tf.exportFormat.FormatFullName
}
return ""
}
func (tf TemplateFormatter) shouldPrettify() bool {
return tf.Name() == constants.OutputFormatJSON
}

View File

@@ -1,192 +1,183 @@
package controldisplay
import (
"log"
"os"
"path/filepath"
"testing"
"github.com/otiai10/copy"
"github.com/turbot/steampipe/pkg/filepaths"
)
type resolveOutputTemplateTestCase struct {
input string // --export <val>
expected interface{}
}
// TODO KAI change this test to GetFormatter and GetFormatterByExtension
var resolveOutputTemplateTestCases = map[string]resolveOutputTemplateTestCase{
"html": {
input: "html",
expected: OutputTemplate{
FormatFullName: "html.html",
OutputExtension: ".html",
},
},
"nunit3": {
input: "nunit3",
expected: OutputTemplate{
FormatFullName: "nunit3.xml",
OutputExtension: ".xml",
},
},
"markdown": {
input: "md",
expected: OutputTemplate{
FormatFullName: "md.md",
OutputExtension: ".md",
},
},
"nunit3.xml": {
input: "nunit3.xml",
expected: OutputTemplate{
FormatFullName: "nunit3.xml",
OutputExtension: ".xml",
},
},
"markdown.md": {
input: "markdown.md",
expected: "ERROR",
//OutputTemplate{
// FormatFullName: "md.md",
// OutputExtension: ".md",
//},
},
// "txt.dat": {
// input: "txt.dat",
// expected: OutputTemplate{
// FormatFullName: "txt.dat",
// OutputExtension: ".dat",
// },
// },
// "custom.txt": {
// input: "custom.txt",
// expected: OutputTemplate{
// FormatFullName: "custom.txt",
// OutputExtension: ".txt",
// },
// },
"foo.xml": {
input: "foo.xml",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "nunit3.xml",
// OutputExtension: ".xml",
//},
},
"brief.html": {
input: "brief.html",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "html.html",
// OutputExtension: ".html",
//},
},
"output.html": {
input: "output.html",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "html.html",
// OutputExtension: ".html",
//},
},
"output.md": {
input: "output.md",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "md.md",
// OutputExtension: ".md",
//},
},
// "output.txt": {
// input: "output.txt",
// expected: OutputTemplate{
// FormatFullName: "custom.txt",
// OutputExtension: ".txt",
// },
// },
// "output.dat": {
// input: "output.dat",
// expected: OutputTemplate{
// FormatFullName: "txt.dat",
// OutputExtension: ".dat",
// },
// },
"output.brief.html": {
input: "output.brief.html",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "html.html",
// OutputExtension: ".html",
//},
},
"output.nunit3.xml": {
input: "output.nunit3.xml",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "nunit3.xml",
// OutputExtension: ".xml",
//},
},
"output.foo.xml": {
input: "output.foo.xml",
expected: "ERROR",
// OutputTemplate{
// FormatFullName: "nunit3.xml",
// OutputExtension: ".xml",
//},
},
}
func setup() {
filepaths.SteampipeDir = "~/.steampipe"
source, err := filepath.Abs("templates")
if err != nil {
log.Fatal(err)
}
dest, err := filepath.Abs("~/.steampipe/check/templates")
if err != nil {
log.Fatal(err)
}
err = copy.Copy(source, dest)
if err != nil {
log.Fatal(err)
}
}
func teardown() {
os.RemoveAll("~/.steampipe/check/templates")
}
func TestResolveOutputTemplate(t *testing.T) {
setup()
resolver, _ := NewFormatResolver()
for name, test := range resolveOutputTemplateTestCases {
outputTemplate, err := resolver.resolveOutputTemplate(test.input)
if err != nil {
if test.expected != "ERROR" {
t.Errorf("Test: '%s'' FAILED with unexpected error: %v", name, err)
}
continue
}
if test.expected == "ERROR" {
t.Errorf("Test: '%s'' FAILED - expected error", name)
continue
}
expectedFormat := test.expected.(OutputTemplate)
if !FormatEqual(outputTemplate, &expectedFormat) {
t.Errorf("Test: '%s'' FAILED : expected:\n%s\n\ngot:\n%s", name, expectedFormat.FormatName, outputTemplate)
}
}
teardown()
}
func FormatEqual(l, r *OutputTemplate) bool {
return (l.FormatFullName == r.FormatFullName)
}
//
//type resolveOutputTemplateTestCase struct {
// input string // --export <val>
// expected interface{}
//}
//
//// TODO KAI change this test to GetFormatter and getFormatterByExtension
//
//var resolveOutputTemplateTestCases = map[string]resolveOutputTemplateTestCase{
// "html": {
// input: "html",
// expected: OutputTemplate{
// FormatFullName: "html.html",
// FileExtension: ".html",
// },
// },
// "nunit3": {
// input: "nunit3",
// expected: OutputTemplate{
// FormatFullName: "nunit3.xml",
// FileExtension: ".xml",
// },
// },
// "markdown": {
// input: "md",
// expected: OutputTemplate{
// FormatFullName: "md.md",
// FileExtension: ".md",
// },
// },
//
// "nunit3.xml": {
// input: "nunit3.xml",
// expected: OutputTemplate{
// FormatFullName: "nunit3.xml",
// FileExtension: ".xml",
// },
// },
// "markdown.md": {
// input: "markdown.md",
// expected: "ERROR",
// //OutputTemplate{
// // FormatFullName: "md.md",
// // OutputExtension: ".md",
// //},
// },
// // "txt.dat": {
// // input: "txt.dat",
// // expected: OutputTemplate{
// // FormatFullName: "txt.dat",
// // OutputExtension: ".dat",
// // },
// // },
// // "custom.txt": {
// // input: "custom.txt",
// // expected: OutputTemplate{
// // FormatFullName: "custom.txt",
// // OutputExtension: ".txt",
// // },
// // },
// "foo.xml": {
// input: "foo.xml",
//
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "nunit3.xml",
// // OutputExtension: ".xml",
// //},
// },
// "brief.html": {
// input: "brief.html",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "html.html",
// // OutputExtension: ".html",
// //},
// },
// "output.html": {
// input: "output.html",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "html.html",
// // OutputExtension: ".html",
// //},
// },
// "output.md": {
// input: "output.md",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "md.md",
// // OutputExtension: ".md",
// //},
// },
// // "output.txt": {
// // input: "output.txt",
// // expected: OutputTemplate{
// // FormatFullName: "custom.txt",
// // OutputExtension: ".txt",
// // },
// // },
// // "output.dat": {
// // input: "output.dat",
// // expected: OutputTemplate{
// // FormatFullName: "txt.dat",
// // OutputExtension: ".dat",
// // },
// // },
// "output.brief.html": {
// input: "output.brief.html",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "html.html",
// // OutputExtension: ".html",
// //},
// },
// "output.nunit3.xml": {
// input: "output.nunit3.xml",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "nunit3.xml",
// // OutputExtension: ".xml",
// //},
// },
// "output.foo.xml": {
// input: "output.foo.xml",
// expected: "ERROR",
// // OutputTemplate{
// // FormatFullName: "nunit3.xml",
// // OutputExtension: ".xml",
// //},
// },
//}
//
//func setup() {
// filepaths.SteampipeDir = "~/.steampipe"
// source, err := filepath.Abs("templates")
// if err != nil {
// log.Fatal(err)
// }
//
// dest, err := filepath.Abs("~/.steampipe/check/templates")
// if err != nil {
// log.Fatal(err)
// }
//
// err = copy.Copy(source, dest)
// if err != nil {
// log.Fatal(err)
// }
//}
//
//func teardown() {
// os.RemoveAll("~/.steampipe/check/templates")
//}
//
//func TestResolveOutputTemplate(t *testing.T) {
// setup()
// resolver, _ := NewFormatResolver()
// for name, test := range resolveOutputTemplateTestCases {
// outputTemplate, err := resolver.resolveOutputTemplate(test.input)
// if err != nil {
// if test.expected != "ERROR" {
// t.Errorf("Test: '%s'' FAILED with unexpected error: %v", name, err)
// }
// continue
// }
// if test.expected == "ERROR" {
// t.Errorf("Test: '%s'' FAILED - expected error", name)
// continue
// }
// expectedFormat := test.expected.(OutputTemplate)
// if !FormatEqual(outputTemplate, &expectedFormat) {
// t.Errorf("Test: '%s'' FAILED : expected:\n%s\n\ngot:\n%s", name, expectedFormat.FormatName, outputTemplate)
// }
// }
// teardown()
//}
//
//func FormatEqual(l, r *OutputTemplate) bool {
// return (l.FormatFullName == r.FormatFullName)
//}

View File

@@ -14,7 +14,9 @@ import (
const MaxColumns = 200
type TextFormatter struct{}
type TextFormatter struct {
FormatterBase
}
func (tf *TextFormatter) Format(ctx context.Context, tree *controlexecute.ExecutionTree) (io.Reader, error) {
renderer := NewTableRenderer(tree)

View File

@@ -0,0 +1,15 @@
package controldisplay
import (
"github.com/turbot/steampipe/pkg/export"
)
// GetExporters returns an array of ControlExporters corresponding to the available output formats
func GetExporters() ([]export.Exporter, error) {
formatResolver, err := NewFormatResolver()
if err != nil {
return nil, err
}
exporters := formatResolver.controlExporters()
return exporters, nil
}

View File

@@ -7,33 +7,26 @@ import (
)
type OutputTemplate struct {
TemplatePath string
FormatName string
OutputExtension string
FormatFullName string
DefaultTemplateForExtension bool
TemplatePath string
FormatName string
FileExtension string
FormatFullName string
}
func NewOutputTemplate(directory string) *OutputTemplate {
func NewOutputTemplate(directoryPath string) *OutputTemplate {
format := new(OutputTemplate)
format.TemplatePath = directory
format.TemplatePath = directoryPath
directory = filepath.Base(directory)
directoryName := filepath.Base(directoryPath)
// does the directory name include an extension?
ext := filepath.Ext(directoryName)
format.FormatFullName = directoryName
format.FormatName = strings.TrimSuffix(directoryName, ext)
format.FileExtension = fmt.Sprintf(".%s", directoryName)
// try splitting by a .(dot)
lastDotIndex := strings.LastIndex(directory, ".")
if lastDotIndex == -1 {
format.OutputExtension = fmt.Sprintf(".%s", directory)
format.FormatName = directory
format.DefaultTemplateForExtension = true
} else {
format.OutputExtension = filepath.Ext(directory)
format.FormatName = strings.TrimSuffix(directory, filepath.Ext(directory))
}
format.FormatFullName = fmt.Sprintf("%s%s", format.FormatName, format.OutputExtension)
return format
}
func (ft *OutputTemplate) String() string {
return fmt.Sprintf("( %s %s %s %s )", ft.TemplatePath, ft.FormatName, ft.OutputExtension, ft.FormatFullName)
return fmt.Sprintf("( %s %s %s %s )", ft.TemplatePath, ft.FormatName, ft.FileExtension, ft.FormatFullName)
}

View File

@@ -0,0 +1,18 @@
package controldisplay
import "github.com/turbot/steampipe/pkg/control/controlexecute"
type TemplateRenderConfig struct {
RenderHeader bool
}
type TemplateRenderConstants struct {
SteampipeVersion string
WorkingDir string
}
type TemplateRenderContext struct {
Constants TemplateRenderConstants
Config TemplateRenderConfig
Data *controlexecute.ExecutionTree
}

View File

@@ -1,22 +0,0 @@
package control
import (
"sync"
"github.com/turbot/steampipe/pkg/control/controldisplay"
"github.com/turbot/steampipe/pkg/control/controlexecute"
)
type ExportData struct {
ExecutionTree *controlexecute.ExecutionTree
Targets []*controldisplay.CheckExportTarget
ErrorsLock *sync.Mutex
Errors []error
WaitGroup *sync.WaitGroup
}
func (e *ExportData) AddErrors(err []error) {
e.ErrorsLock.Lock()
e.Errors = append(e.Errors, err...)
e.ErrorsLock.Unlock()
}

View File

@@ -1,12 +1,21 @@
package export
import "context"
// ExportSourceData is an interface implemented by all types which can be used as an input to an exporter
type ExportSourceData interface {
IsExportSourceData()
}
type Exporter interface {
Export(input ExportSourceData, destPath string) error
Export(ctx context.Context, input ExportSourceData, destPath string) error
FileExtension() string
Name() string
Alias() string
}
type ExporterBase struct{}
func (*ExporterBase) Alias() string {
return ""
}

View File

@@ -4,17 +4,16 @@ import (
"fmt"
"io"
"os"
"strings"
"time"
)
func generateDefaultExportFileName(exporter Exporter, executionName string) string {
func GenerateDefaultExportFileName(exporter Exporter, executionName string) string {
now := time.Now()
timeFormatted := fmt.Sprintf("%d%02d%02d-%02d%02d%02d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
return fmt.Sprintf("%s-%s%s", executionName, timeFormatted, exporter.FileExtension())
}
func writeExport(filePath string, exportData *strings.Reader) error {
func Write(filePath string, exportData io.Reader) error {
// create the output file
destination, err := os.Create(filePath)
if err != nil {

150
pkg/export/manager.go Normal file
View File

@@ -0,0 +1,150 @@
package export
import (
"context"
"fmt"
"github.com/turbot/steampipe/pkg/error_helpers"
"golang.org/x/exp/maps"
"path"
"strings"
)
type Manager struct {
registeredExporters map[string]Exporter
registeredExtensions map[string]Exporter
}
func NewManager() *Manager {
return &Manager{
registeredExporters: make(map[string]Exporter),
registeredExtensions: make(map[string]Exporter),
}
}
func (m *Manager) Register(exporter Exporter) error {
name := exporter.Name()
if _, ok := m.registeredExporters[name]; ok {
return fmt.Errorf("failed to register exporter - duplicate name %s", name)
}
m.registeredExporters[exporter.Name()] = exporter
// if the exporter has an alias, also register by alias
if alias := exporter.Alias(); alias != "" {
if _, ok := m.registeredExporters[alias]; ok {
return fmt.Errorf("failed to register exporter - duplicate name %s", name)
}
m.registeredExporters[alias] = exporter
}
// now register extension
ext := exporter.FileExtension()
m.registerExporterByExtension(exporter, ext)
// if the extension has multiple segments, try to register for the short version as well
if shortExtension := path.Ext(ext); shortExtension != ext {
m.registerExporterByExtension(exporter, shortExtension)
}
return nil
}
func (m *Manager) registerExporterByExtension(exporter Exporter, ext string) {
// do we already have an exporter registered for this extension?
if existing, ok := m.registeredExtensions[ext]; ok {
// check if either the existing or new template is the default for extension
existingIsDefaultForExt := isDefaultExporterForExtension(existing)
newIsDefaultForExt := isDefaultExporterForExtension(exporter)
// if NEITHER are default for the extension, there is a clash which cannot be resolved -
// we must remove the existing key
if !newIsDefaultForExt && !existingIsDefaultForExt {
delete(m.registeredExtensions, ext)
}
// if existing is default and new isn't, nothing to do
if existingIsDefaultForExt {
return
}
// to get here, new must be default exporter for extension
// (it is impossible for both to be default as that implies duplicate exporter names)
// fall through to...
}
// register the extension
m.registeredExtensions[ext] = exporter
}
// an exporter is the 'default for extension' if the exporter name is the same as the extension name
// i.e. json exporter would be the default for the `.json` extension
func isDefaultExporterForExtension(existing Exporter) bool {
return strings.TrimPrefix(existing.FileExtension(), ".") == existing.Name()
}
func (m *Manager) resolveTargetsFromArgs(exportArgs []string, executionName string) ([]*Target, error) {
var targets = make(map[string]*Target)
var targetErrors []error
for _, export := range exportArgs {
export = strings.TrimSpace(export)
if len(export) == 0 {
// if this is an empty string, ignore
continue
}
t, err := m.getExportTarget(export, executionName)
if err != nil {
targetErrors = append(targetErrors, err)
continue
}
// add to map if not already there
if _, ok := targets[t.filePath]; !ok {
targets[t.filePath] = t
}
}
// convert target map into array
targetList := maps.Values(targets)
return targetList, error_helpers.CombineErrors(targetErrors...)
}
func (m *Manager) getExportTarget(export, executionName string) (*Target, error) {
if e, ok := m.registeredExporters[export]; ok {
t := &Target{
exporter: e,
filePath: GenerateDefaultExportFileName(e, executionName),
}
return t, nil
}
// now try by extension
ext := path.Ext(export)
if e, ok := m.registeredExtensions[ext]; ok {
t := &Target{
exporter: e,
filePath: export,
}
return t, nil
}
return nil, fmt.Errorf("formatter satisfying '%s' not found", export)
}
func (m *Manager) DoExport(ctx context.Context, targetName string, source ExportSourceData, exports []string) error {
if len(exports) == 0 {
return nil
}
targets, err := m.resolveTargetsFromArgs(exports, targetName)
if err != nil {
return err
}
var errors []error
for _, target := range targets {
if err := target.Export(ctx, source); err != nil {
errors = append(errors, err)
}
}
return error_helpers.CombineErrors(errors...)
}

View File

@@ -1,74 +0,0 @@
package export
import (
"fmt"
"github.com/turbot/steampipe/pkg/error_helpers"
"golang.org/x/exp/maps"
"path"
"strings"
)
type Resolver struct {
registeredExporters map[string]Exporter
registeredExtensions map[string]Exporter
}
func NewResolver() *Resolver {
return &Resolver{
registeredExporters: make(map[string]Exporter),
registeredExtensions: make(map[string]Exporter),
}
}
func (r *Resolver) Register(exporter Exporter) {
r.registeredExporters[exporter.Name()] = exporter
r.registeredExporters[exporter.FileExtension()] = exporter
}
func (r *Resolver) ResolveTargetsFromArgs(exportArgs []string, executionName string) ([]*Target, error) {
var targets = make(map[string]*Target)
var targetErrors []error
for _, export := range exportArgs {
export = strings.TrimSpace(export)
if len(export) == 0 {
// if this is an empty string, ignore
continue
}
t, err := r.getExportTarget(export, executionName)
if err != nil {
targetErrors = append(targetErrors, err)
continue
}
// add to map if not already there
if _, ok := targets[t.filePath]; !ok {
targets[t.filePath] = t
}
}
// convert target map into array
targetList := maps.Values(targets)
return targetList, error_helpers.CombineErrors(targetErrors...)
}
func (r *Resolver) getExportTarget(export, executionName string) (*Target, error) {
if e, ok := r.registeredExporters[export]; ok {
t := &Target{
exporter: e,
filePath: generateDefaultExportFileName(e, executionName),
}
return t, nil
}
if e, ok := r.registeredExtensions[path.Ext(export)]; ok {
t := &Target{
exporter: e,
filePath: export,
}
return t, nil
}
return nil, fmt.Errorf("formatter satisfying '%s' not found", export)
}

View File

@@ -1,6 +1,7 @@
package export
import (
"context"
"encoding/json"
"fmt"
"github.com/turbot/steampipe/pkg/constants"
@@ -9,9 +10,10 @@ import (
)
type SnapshotExporter struct {
ExporterBase
}
func (e *SnapshotExporter) Export(input ExportSourceData, filePath string) error {
func (e *SnapshotExporter) Export(_ context.Context, input ExportSourceData, filePath string) error {
snapshot, ok := input.(*dashboardtypes.SteampipeSnapshot)
if !ok {
@@ -24,7 +26,7 @@ func (e *SnapshotExporter) Export(input ExportSourceData, filePath string) error
res := strings.NewReader(fmt.Sprintf("%s\n", string(snapshotStr)))
return writeExport(filePath, res)
return Write(filePath, res)
}
func (e *SnapshotExporter) FileExtension() string {

View File

@@ -1,10 +1,12 @@
package export
import "context"
type Target struct {
exporter Exporter
filePath string
}
func (t *Target) Export(input ExportSourceData) error {
return t.exporter.Export(input, t.filePath)
func (t *Target) Export(ctx context.Context, input ExportSourceData) error {
return t.exporter.Export(ctx, input, t.filePath)
}

View File

@@ -29,7 +29,7 @@ type InitData struct {
PreparedStatementSource *modconfig.ResourceMaps
ShutdownTelemetry func()
ExportResolver *export.Resolver
ExportManager *export.Manager
}
func NewErrorInitData(err error) *InitData {
@@ -40,17 +40,17 @@ func NewErrorInitData(err error) *InitData {
func NewInitData(w *workspace.Workspace) *InitData {
i := &InitData{
Workspace: w,
Result: &db_common.InitResult{},
ExportResolver: export.NewResolver(),
Workspace: w,
Result: &db_common.InitResult{},
ExportManager: export.NewManager(),
}
return i
}
func (i *InitData) RegisterExporters(exporters ...*export.SnapshotExporter) *InitData {
func (i *InitData) RegisterExporters(exporters ...export.Exporter) *InitData {
for _, e := range exporters {
i.ExportResolver.Register(e)
i.ExportManager.Register(e)
}
return i

View File

@@ -35,8 +35,8 @@ func NewInitData(ctx context.Context, w *workspace.Workspace, args []string) *In
return i
}
func queryExporters() []*export.SnapshotExporter {
return []*export.SnapshotExporter {&export.SnapshotExporter{}}
func queryExporters() []export.Exporter {
return []export.Exporter{&export.SnapshotExporter{}}
}
func (i *InitData) Cancel() {

22
pkg/utils/json.go Normal file
View File

@@ -0,0 +1,22 @@
package utils
import (
"bytes"
"encoding/json"
"io"
)
func PrettifyJsonFromReader(dataToExport io.Reader) (io.Reader, error) {
b, err := io.ReadAll(dataToExport)
if err != nil {
return nil, err
}
var prettyJSON bytes.Buffer
err = json.Indent(&prettyJSON, b, "", " ")
if err != nil {
return nil, err
}
dataToExport = &prettyJSON
return dataToExport, nil
}