github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/scout/cmd/validate.go (about)

     1  // Copyright (c) 2022, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package scoutcmd
     6  
     7  import (
     8  	"bytes"
     9  	"context"
    10  	"encoding/json"
    11  	"fmt"
    12  	"os"
    13  	"sort"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	"github.com/choria-io/go-choria/client/discovery"
    19  	"github.com/choria-io/go-choria/client/scoutclient"
    20  	"github.com/choria-io/go-choria/inter"
    21  	iu "github.com/choria-io/go-choria/internal/util"
    22  	scoutagent "github.com/choria-io/go-choria/scout/agent/scout"
    23  	"github.com/goss-org/goss"
    24  	gossoutputs "github.com/goss-org/goss/outputs"
    25  	"github.com/goss-org/goss/resource"
    26  	gossutil "github.com/goss-org/goss/util"
    27  	"github.com/sirupsen/logrus"
    28  	xtablewriter "github.com/xlab/tablewriter"
    29  )
    30  
    31  type ValidateCommandOptions struct {
    32  	Variables     []byte
    33  	NodeVarsFile  string
    34  	Rules         []byte
    35  	NodeRulesFile string
    36  	Display       string
    37  	Table         bool
    38  	Verbose       bool
    39  	Json          bool
    40  	Color         bool
    41  	Local         bool
    42  }
    43  
    44  type ValidateCommand struct {
    45  	sopts *discovery.StandardOptions
    46  	log   *logrus.Entry
    47  	fw    inter.Framework
    48  	opts  *ValidateCommandOptions
    49  }
    50  
    51  func NewValidateCommand(sopts *discovery.StandardOptions, fw inter.Framework, opts *ValidateCommandOptions, log *logrus.Entry) (*ValidateCommand, error) {
    52  	return &ValidateCommand{
    53  		sopts: sopts,
    54  		log:   log,
    55  		fw:    fw,
    56  		opts:  opts,
    57  	}, nil
    58  }
    59  
    60  func (v *ValidateCommand) renderTableResult(table *xtablewriter.Table, vr *scoutagent.GossValidateResponse, reqOk bool, sender string, statusMsg string) bool {
    61  	fail := v.fw.Colorize("red", "X")
    62  	ok := v.fw.Colorize("green", "✓")
    63  	skip := v.fw.Colorize("yellow", "?")
    64  	errm := v.fw.Colorize("red", "!")
    65  
    66  	should := false
    67  
    68  	if !reqOk {
    69  		table.AddRow(fail, sender, "", "", statusMsg)
    70  		return true
    71  	}
    72  
    73  	if vr.Failures > 0 || vr.Tests == 0 {
    74  		should = true
    75  		table.AddRow(fail, sender, "", "", vr.Summary)
    76  	} else {
    77  		should = true
    78  		table.AddRow(ok, sender, "", "", vr.Summary)
    79  	}
    80  
    81  	sort.Slice(vr.Results, func(i, j int) bool {
    82  		return !vr.Results[i].Successful || vr.Results[i].Err != nil
    83  	})
    84  
    85  	if v.opts.Display == "none" {
    86  		return should
    87  	}
    88  
    89  	for _, res := range vr.Results {
    90  		should = true
    91  
    92  		if res.Err != nil {
    93  			table.AddRow(errm, "", res.ResourceType, res.ResourceId, res.Err.Error())
    94  			continue
    95  		}
    96  
    97  		switch {
    98  		case res.Result == resource.SKIP && v.opts.Display != "ok":
    99  			table.AddRow(skip, "", res.ResourceType, res.ResourceId, fmt.Sprintf("%s: skipped", res.Property))
   100  		case res.Result == resource.SUCCESS && v.opts.Display != "failed":
   101  			table.AddRow(ok, "", res.ResourceType, res.ResourceId, res.SummaryLineCompact)
   102  		case res.Result == resource.FAIL && v.opts.Display != "ok":
   103  			table.AddRow(fail, "", res.ResourceType, res.ResourceId, res.SummaryLineCompact)
   104  		}
   105  	}
   106  
   107  	return should
   108  }
   109  
   110  func (v *ValidateCommand) renderTextResult(vr *scoutagent.GossValidateResponse, reqOk bool, sender string, statusMsg string) {
   111  	if !reqOk {
   112  		fmt.Printf("%s: %s\n\n", sender, v.fw.Colorize("red", statusMsg))
   113  		return
   114  	}
   115  
   116  	if vr.Failures > 0 || vr.Tests == 0 {
   117  		fmt.Printf("%s: %s\n\n", sender, v.fw.Colorize("red", vr.Summary))
   118  	} else {
   119  		fmt.Printf("%s: %s\n\n", sender, v.fw.Colorize("green", vr.Summary))
   120  	}
   121  
   122  	sort.Slice(vr.Results, func(i, j int) bool {
   123  		return !vr.Results[i].Successful
   124  	})
   125  
   126  	if v.opts.Display == "none" {
   127  		fmt.Println()
   128  		return
   129  	}
   130  
   131  	lb := false
   132  	for i, res := range vr.Results {
   133  		switch {
   134  		case res.Result == resource.SKIP && v.opts.Display != "ok":
   135  			if lb {
   136  				fmt.Println()
   137  			}
   138  			fmt.Printf("   %s %s\n", v.fw.Colorize("yellow", "?"), res.SummaryLineCompact)
   139  			lb = false
   140  		case res.Result == resource.FAIL && v.opts.Display != "ok":
   141  			if i != 0 {
   142  				fmt.Println()
   143  			}
   144  			lb = true
   145  			msg := fmt.Sprintf("%s %s", v.fw.Colorize("red", "X"), res.SummaryLine)
   146  			fmt.Printf("%s\n", iu.ParagraphPadding(msg, 3))
   147  		case res.Result == resource.SUCCESS && v.opts.Display != "failed":
   148  			if lb {
   149  				fmt.Println()
   150  			}
   151  
   152  			fmt.Printf("   %s %s\n", v.fw.Colorize("green", "✓"), res.SummaryLineCompact)
   153  
   154  			lb = false
   155  		}
   156  	}
   157  
   158  	fmt.Println()
   159  }
   160  
   161  func (v *ValidateCommand) localValidate() error {
   162  	var err error
   163  	var out bytes.Buffer
   164  	var table *xtablewriter.Table
   165  	var shouldRenderTable bool
   166  
   167  	rules, err := os.CreateTemp("", "choria-gossfile-*.yaml")
   168  	if err != nil {
   169  		return err
   170  	}
   171  	defer os.Remove(rules.Name())
   172  	defer rules.Close()
   173  
   174  	_, err = rules.Write(v.opts.Rules)
   175  	if err != nil {
   176  		return err
   177  	}
   178  	rules.Close()
   179  
   180  	opts := []gossutil.ConfigOption{
   181  		gossutil.WithMaxConcurrency(1),
   182  		gossutil.WithResultWriter(&out),
   183  		gossutil.WithSpecFile(rules.Name()),
   184  	}
   185  
   186  	if len(v.opts.Variables) > 0 {
   187  		opts = append(opts, gossutil.WithVarsBytes(v.opts.Variables))
   188  	}
   189  
   190  	cfg, err := gossutil.NewConfig(opts...)
   191  	if err != nil {
   192  		return err
   193  	}
   194  
   195  	_, err = goss.Validate(cfg)
   196  	if err != nil {
   197  		return err
   198  	}
   199  
   200  	res := &gossoutputs.StructuredOutput{}
   201  	err = json.Unmarshal(out.Bytes(), res)
   202  	if err != nil {
   203  		return err
   204  	}
   205  
   206  	resp := &scoutagent.GossValidateResponse{Results: []gossoutputs.StructuredTestResult{}}
   207  
   208  	var errors int
   209  	for _, r := range res.Results {
   210  		switch {
   211  		case r.Err != nil:
   212  			errors++
   213  		case r.Result == resource.SKIP:
   214  			resp.Skipped++
   215  		}
   216  	}
   217  
   218  	resp.Results = res.Results
   219  	resp.Summary = res.SummaryLine
   220  	resp.Failures = res.Summary.Failed + errors
   221  	resp.Runtime = res.Summary.TotalDuration.Seconds()
   222  	resp.Success = res.Summary.TestCount - res.Summary.Failed - resp.Skipped
   223  	resp.Tests = res.Summary.TestCount
   224  
   225  	if v.opts.Table {
   226  		table = iu.NewUTF8TableWithTitle("Goss check results", "", "Node", "Resource", "ID", "State")
   227  	}
   228  
   229  	if v.opts.Table {
   230  		shouldRenderTable = v.renderTableResult(table, resp, true, "localhost", "OK")
   231  	} else {
   232  		v.renderTextResult(resp, true, "localhost", "OK")
   233  	}
   234  
   235  	if v.opts.Table && shouldRenderTable {
   236  		fmt.Println(table.Render())
   237  	}
   238  
   239  	return nil
   240  }
   241  
   242  func (v *ValidateCommand) Run(ctx context.Context, wg *sync.WaitGroup) error {
   243  	defer wg.Done()
   244  
   245  	if v.opts.NodeRulesFile == "" && len(v.opts.Rules) == 0 {
   246  		return fmt.Errorf("neither local validation rules nor a remote file were supplied")
   247  	}
   248  	if v.opts.NodeRulesFile != "" && len(v.opts.Rules) > 0 {
   249  		return fmt.Errorf("both local validation rules and a remote rules file were supplied")
   250  	}
   251  	if len(v.opts.Variables) > 0 && v.opts.NodeVarsFile != "" {
   252  		return fmt.Errorf("both local variables and a remote variables file were supplied")
   253  	}
   254  
   255  	if v.opts.Local {
   256  		return v.localValidate()
   257  	}
   258  
   259  	sc, err := scoutClient(v.fw, v.sopts, v.log)
   260  	if err != nil {
   261  		return err
   262  	}
   263  
   264  	action := sc.GossValidate()
   265  	if v.opts.NodeRulesFile != "" {
   266  		action.File(v.opts.NodeRulesFile)
   267  	} else if len(v.opts.Rules) > 0 {
   268  		action.YamlRules(string(v.opts.Rules))
   269  	} else {
   270  		return fmt.Errorf("no rules or rules file specified")
   271  	}
   272  
   273  	if len(v.opts.Variables) > 0 {
   274  		action.YamlVars(string(v.opts.Variables))
   275  	} else if v.opts.NodeVarsFile != "" {
   276  		action.Vars(v.opts.NodeVarsFile)
   277  	}
   278  
   279  	start := time.Now()
   280  	result, err := action.Do(ctx)
   281  	if err != nil {
   282  		return err
   283  	}
   284  	runTime := time.Since(start)
   285  
   286  	if v.opts.Json {
   287  		return result.RenderResults(os.Stdout, scoutclient.JSONFormat, scoutclient.DisplayDDL, v.opts.Verbose, false, v.opts.Color, v.log)
   288  	}
   289  
   290  	if result.Stats().ResponsesCount() == 0 {
   291  		return fmt.Errorf("no responses received")
   292  	}
   293  
   294  	count := 0
   295  	failed := 0
   296  	success := 0
   297  	skipped := 0
   298  	nodes := 0
   299  	shouldRenderTable := false
   300  
   301  	var table *xtablewriter.Table
   302  	if v.opts.Table {
   303  		table = iu.NewUTF8TableWithTitle("Goss check results", "", "Node", "Resource", "ID", "State")
   304  	}
   305  
   306  	result.EachOutput(func(r *scoutclient.GossValidateOutput) {
   307  		vr := &scoutagent.GossValidateResponse{}
   308  		err = r.ParseGossValidateOutput(vr)
   309  		if err != nil {
   310  			v.log.Errorf("Could not parse output from %s: %s", r.ResultDetails().Sender(), err)
   311  			return
   312  		}
   313  
   314  		nodes++
   315  		count += vr.Tests
   316  		failed += vr.Failures
   317  		success += vr.Success
   318  		skipped += vr.Skipped
   319  		if !r.ResultDetails().OK() {
   320  			failed++
   321  		}
   322  
   323  		switch v.opts.Display {
   324  		case "none":
   325  			return
   326  		case "all":
   327  		case "ok":
   328  			// skip on not ok
   329  			if !r.ResultDetails().OK() || vr.Tests == 0 || vr.Failures > 0 || vr.Skipped > 0 {
   330  				return
   331  			}
   332  		case "failed":
   333  			// skip all ok
   334  			if r.ResultDetails().OK() && vr.Tests > 0 && vr.Failures == 0 && vr.Skipped == 0 {
   335  				return
   336  			}
   337  		}
   338  
   339  		if v.opts.Table {
   340  			shouldRenderTable = v.renderTableResult(table, vr, r.ResultDetails().OK(), r.ResultDetails().Sender(), r.ResultDetails().StatusMessage())
   341  		} else {
   342  			v.renderTextResult(vr, r.ResultDetails().OK(), r.ResultDetails().Sender(), r.ResultDetails().StatusMessage())
   343  		}
   344  	})
   345  
   346  	if v.opts.Table && shouldRenderTable {
   347  		fmt.Println(table.Render())
   348  	}
   349  
   350  	parts := []string{
   351  		fmt.Sprintf("Nodes: %d", nodes),
   352  	}
   353  	if failed > 0 {
   354  		parts = append(parts, v.fw.Colorize("red", fmt.Sprintf("Failed: %d", failed)))
   355  	} else {
   356  		parts = append(parts, v.fw.Colorize("green", fmt.Sprintf("Failed: %d", failed)))
   357  	}
   358  	if skipped > 0 {
   359  		parts = append(parts, v.fw.Colorize("yellow", fmt.Sprintf("Skipped: %d", skipped)))
   360  	} else {
   361  		parts = append(parts, v.fw.Colorize("green", fmt.Sprintf("Skipped: %d", skipped)))
   362  	}
   363  	if success > 0 {
   364  		parts = append(parts, v.fw.Colorize("green", fmt.Sprintf("Success: %d", success)))
   365  	} else {
   366  		parts = append(parts, v.fw.Colorize("red", fmt.Sprintf("Success: %d", success)))
   367  	}
   368  	parts = append(parts, fmt.Sprintf("Duration: %v", runTime.Round(time.Millisecond)))
   369  
   370  	fmt.Printf("%s\n", strings.Join(parts, ", "))
   371  
   372  	if v.opts.Verbose {
   373  		return result.RenderResults(os.Stdout, scoutclient.TXTFooter, scoutclient.DisplayDDL, v.opts.Verbose, false, v.opts.Color, v.log)
   374  	}
   375  
   376  	return nil
   377  }