From 17ebb1e87db6b8d474bfa6380c686e556947fad1 Mon Sep 17 00:00:00 2001 From: "bsf-sync-bot[bot]" <180526808+bsf-sync-bot[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 05:59:37 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'pkg/'=20wi?= =?UTF-8?q?th=20remote=20'pkg/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/helper/helper.go | 25 +++ pkg/helper/helper_test.go | 36 ++++ pkg/helper/hide_secrets.go | 47 +++++ pkg/helper/hide_secrets_test.go | 112 +++++++++++ pkg/logger/common.go | 15 +- pkg/logger/logger.go | 7 +- pkg/repository/registry/registry.go | 9 +- pkg/repository/types/azblob/repository.go | 3 +- pkg/repository/types/curl/repository.go | 7 +- pkg/result/engine.go | 23 ++- pkg/result/engine_test.go | 13 +- pkg/result/testdata/result.golden | 3 +- pkg/result/v1/result.go | 3 + pkg/schema/schema.go | 12 +- pkg/v2/config/config.go | 2 +- pkg/v2/config/validate.go | 14 +- pkg/v2/config/validate_test.go | 8 +- pkg/v2/executor/autopilotcheck.go | 13 +- pkg/v2/executor/autopilotcheck_test.go | 4 +- pkg/v2/executor/executor.go | 8 +- pkg/v2/executor/finalize.go | 3 +- pkg/v2/model/error.go | 20 ++ pkg/v2/orchestrator/orchestrator.go | 9 +- pkg/v2/replacer/replacer.go | 129 +++++++++---- pkg/v2/replacer/replacer_test.go | 34 ++-- pkg/v2/repository/registry/registry.go | 3 +- pkg/v2/result/creator.go | 16 +- pkg/v2/result/creator_create_test.go | 11 +- pkg/v2/result/creator_test.go | 3 +- pkg/v2/result/result.go | 3 + pkg/v2/runner/runner.go | 43 ----- pkg/v2/runner/runner_test.go | 139 -------------- pkg/v2/runner/subprocess.go | 121 +++++++++--- pkg/v2/runner/subprocess_test.go | 219 +++++++++++++++++++--- 34 files changed, 768 insertions(+), 349 deletions(-) create mode 100644 pkg/v2/model/error.go delete mode 100644 pkg/v2/runner/runner_test.go diff --git a/pkg/helper/helper.go b/pkg/helper/helper.go index 3da4266..49d14e9 100644 --- a/pkg/helper/helper.go +++ b/pkg/helper/helper.go @@ -1,9 +1,12 @@ package helper import ( + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" + "regexp" "strings" ) @@ -87,3 +90,25 @@ func CreateSymlinks(src string, dst string, names []string) error { } return nil } + +type HashFields struct { + Chapter string + Requirement string + Check string + Criterion string + Justification string +} + +func GenerateCheckResultIdHash(hashFields HashFields) string { + input := hashFields.Chapter + hashFields.Requirement + hashFields.Check + hashFields.Criterion + hashFields.Justification + + re := regexp.MustCompile(`\s+`) + input = re.ReplaceAllString(input, "") + + hash := sha256.New() + hash.Write([]byte(input)) + hashBytes := hash.Sum(nil) + hashString := hex.EncodeToString(hashBytes) + + return hashString +} diff --git a/pkg/helper/helper_test.go b/pkg/helper/helper_test.go index ea288be..12b833a 100644 --- a/pkg/helper/helper_test.go +++ b/pkg/helper/helper_test.go @@ -469,3 +469,39 @@ func TestCreateSymlinks(t *testing.T) { } }) } + +func TestGenerateCheckResultIdHash(t *testing.T) { + cases := []struct { + name string + fields HashFields + want string + }{{ + name: "should return a hash of the fields as the current api implementation", + fields: HashFields{ + Chapter: "1", + Requirement: "1", + Check: "1", + Criterion: "This is a test criterion", + Justification: "The criterion was not met", + }, + want: "562a22e934fbcad59b1de359cbd914ea810e57b7d04f2c0487e39a89ffaa05dd", + }, + { + name: "should support whitespaces in all fields", + fields: HashFields{ + Chapter: "Chapter 1", + Requirement: "Requirement 1", + Check: "Check 1", + Criterion: "This is a test criterion", + Justification: "The criterion was not met", + }, + want: "9319a093d48e7488ef34cd74ccfe5e2f23a00b32eede2ba30d39676f2029a528", + }} + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got := GenerateCheckResultIdHash(tt.fields) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/helper/hide_secrets.go b/pkg/helper/hide_secrets.go index 89728de..0a4ebe7 100644 --- a/pkg/helper/hide_secrets.go +++ b/pkg/helper/hide_secrets.go @@ -74,3 +74,50 @@ func HideSecretsInString(content string, secrets map[string]string) string { } return content } + +func HideSecretsInArrayOfLines(lines []string, secrets map[string]string) []string { + simpleSecrets, multilineSecrets := make(map[string]string), make(map[string][]string) + for secretName, secretValue := range secrets { + secretLines := strings.Split(secretValue, "\n") + if len(secretLines) > 1 { + multilineSecrets[secretName] = secretLines + } else { + simpleSecrets[secretName] = secretValue + } + } + lines = HideSecretsInArrayOfStrings(lines, simpleSecrets) + + for secretName, secretLines := range multilineSecrets { + start, mid, end := secretLines[0], secretLines[1:len(secretLines)-1], secretLines[len(secretLines)-1] + for startIndex := 0; startIndex <= len(lines)-len(secretLines); startIndex++ { + prefix, startOk := strings.CutSuffix(lines[startIndex], start) + if !startOk { + continue + } + endIndex := startIndex + 1 + midOk := true + for _, midLine := range mid { + if midLine != lines[endIndex] { + midOk = false + break + } + endIndex++ + } + if !midOk { + continue + } + suffix, endOk := strings.CutPrefix(lines[endIndex], end) + if !endOk { + continue + } + + lines[startIndex] = fmt.Sprintf("%s***%s***", prefix, secretName) + lines[endIndex] = suffix + for i := startIndex + 1; i < endIndex; i++ { + lines[i] = "" + } + startIndex = endIndex + } + } + return lines +} diff --git a/pkg/helper/hide_secrets_test.go b/pkg/helper/hide_secrets_test.go index c18df9a..b818729 100644 --- a/pkg/helper/hide_secrets_test.go +++ b/pkg/helper/hide_secrets_test.go @@ -312,3 +312,115 @@ func TestHideSecretsInString(t *testing.T) { }) } } + +func TestHideSecretsInArrayOfLines(t *testing.T) { + testCases := map[string]struct { + secrets map[string]string + content []string + want []string + }{ + "should mask simple secrets appearing in multiple lines": { + secrets: map[string]string{ + "password": "qux", + }, + content: []string{ + "foo qux bar", + "bar qux foo", + }, + want: []string{ + "foo ***password*** bar", + "bar ***password*** foo", + }, + }, + "should mask secrets going over two lines": { + secrets: map[string]string{ + "password": "foo\nbar", + }, + content: []string{ + "Password: foo", + "bar <-Password ends here", + }, + want: []string{ + "Password: ***password***", + " <-Password ends here", + }, + }, + "should mask simple and multiline secrets": { + secrets: map[string]string{ + "password": "foo\nbar", + "dogword": "woof", + }, + content: []string{ + "Password: foo", + "bar. woof!", + }, + want: []string{ + "Password: ***password***", + ". ***dogword***!", + }, + }, + "should mask secrets going over three lines": { + secrets: map[string]string{ + "password": "foo\nbar\nbaz", + }, + content: []string{ + "Password: foo", + "bar", + "baz <-Password ends here", + }, + want: []string{ + "Password: ***password***", + "", + " <-Password ends here", + }, + }, + "should mask secrets going over four lines": { + secrets: map[string]string{ + "password": "foo\nbar\nbaz\nqux", + }, + content: []string{ + "Password: foo", + "bar", + "baz", + "qux <-Password ends here", + }, + want: []string{ + "Password: ***password***", + "", + "", + " <-Password ends here", + }, + }, + "should not fail if secret has more lines than content": { + secrets: map[string]string{ + "password": "foo\nbar\nbaz\nqux", + }, + content: []string{ + "Just one line", + }, + want: []string{ + "Just one line", + }, + }, + "should not fail if secret ends in newline": { + secrets: map[string]string{ + "password": "foo\n", + }, + content: []string{ + "foo", + "", + }, + want: []string{ + "***password***", + "", + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := HideSecretsInArrayOfLines(tc.content, tc.secrets) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/logger/common.go b/pkg/logger/common.go index 3eb703b..994c156 100644 --- a/pkg/logger/common.go +++ b/pkg/logger/common.go @@ -43,6 +43,11 @@ func NewCommon(s ...Settings) *Common { consoleLogging := zapcore.Lock(zapcore.AddSync(os.Stdout)) var core zapcore.Core + var cores []zapcore.Core + if !settings.DisableConsoleLogging { + cores = append(cores, zapcore.NewCore(consoleEncoder, consoleLogging, level)) + } + if settings.File != "" { jsonEncoderConfig := zap.NewProductionEncoderConfig() jsonEncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder @@ -54,12 +59,12 @@ func NewCommon(s ...Settings) *Common { MaxBackups: 3, MaxAge: 28, // days })) - core = zapcore.NewTee( - zapcore.NewCore(consoleEncoder, consoleLogging, level), - zapcore.NewCore(jsonEncoder, fileLogging, level), - ) + cores = append(cores, zapcore.NewCore(jsonEncoder, fileLogging, level)) + } + if len(cores) > 1 { + core = zapcore.NewTee(cores...) } else { - core = zapcore.NewCore(consoleEncoder, consoleLogging, level) + core = cores[0] } return &Common{ Log{ diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 95d41d5..0100cd4 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -15,9 +15,10 @@ func init() { } type Settings struct { - Level string - File string - Secrets map[string]string + Level string + File string + Secrets map[string]string + DisableConsoleLogging bool } type Log struct { diff --git a/pkg/repository/registry/registry.go b/pkg/repository/registry/registry.go index ec63ece..a993e22 100644 --- a/pkg/repository/registry/registry.go +++ b/pkg/repository/registry/registry.go @@ -6,6 +6,7 @@ import ( "github.com/B-S-F/onyx/pkg/logger" "github.com/B-S-F/onyx/pkg/repository" "github.com/B-S-F/onyx/pkg/repository/app" + "github.com/B-S-F/onyx/pkg/v2/model" ) type Registry struct { @@ -56,18 +57,18 @@ func (r *Registry) tryInstallFromAllRepositories(appReferences *app.Reference) e foundApps = append(foundApps, app) } if len(foundApps) == 0 { - err := fmt.Errorf("app %s could not be downloaded from any repository:", appReferences) + err := fmt.Errorf("app %s could not be downloaded from any repository", appReferences) for _, installationError := range installationErrors { err = fmt.Errorf("%w\n\t%v", err, installationError) } - return err + return model.NewUserErr(err, "failed to download app") } if len(foundApps) > 1 { repositoryNames := make([]string, 0, len(foundApps)) for _, app := range foundApps { repositoryNames = append(repositoryNames, app.Reference().Repository) } - return fmt.Errorf("app %s found in multiple repositories %v", appReferences, repositoryNames) + return model.NewUserErr(fmt.Errorf("app %s found in multiple repositories %v", appReferences, repositoryNames), "ambiguous app") } r.repositoryApps[appReferences.String()] = foundApps[0] return nil @@ -77,7 +78,7 @@ func (r *Registry) installFromRepository(appReference *app.Reference) error { r.logger.Debugf("Installing app %s from repository %s", appReference, appReference.Repository) repository, ok := r.repositories[appReference.Repository] if !ok { - return fmt.Errorf("repository %s not found", appReference.Repository) + return model.NewUserErr(fmt.Errorf("repository %s not found", appReference.Repository), "invalid app repository") } app, err := repository.InstallApp(appReference) diff --git a/pkg/repository/types/azblob/repository.go b/pkg/repository/types/azblob/repository.go index 7f0f9f3..cc65fa9 100644 --- a/pkg/repository/types/azblob/repository.go +++ b/pkg/repository/types/azblob/repository.go @@ -11,6 +11,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" "github.com/B-S-F/onyx/pkg/repository" "github.com/B-S-F/onyx/pkg/repository/app" + "github.com/B-S-F/onyx/pkg/v2/model" ) type Repository struct { @@ -58,7 +59,7 @@ func NewRepository(name string, installationPath string, config map[string]inter func (r *Repository) InstallApp(appReference *app.Reference) (app.App, error) { appPath, err := r.getAppPath(appReference.Name, appReference.Version) if err != nil { - return nil, fmt.Errorf("failed to install app %s: %w", appReference, err) + return nil, model.NewUserErr(fmt.Errorf("failed to install app %s: %w", appReference, err), "invalid app path") } ouputPath := app.InstallationPath(r.InstallationPath, r.RepoName, appReference.Name, appReference.Version) diff --git a/pkg/repository/types/curl/repository.go b/pkg/repository/types/curl/repository.go index 50956bb..7acedbb 100644 --- a/pkg/repository/types/curl/repository.go +++ b/pkg/repository/types/curl/repository.go @@ -11,6 +11,7 @@ import ( "github.com/B-S-F/onyx/pkg/repository" "github.com/B-S-F/onyx/pkg/repository/app" + "github.com/B-S-F/onyx/pkg/v2/model" ) const DOWNLOAD_TIMEOUT = 30 * time.Second @@ -43,7 +44,7 @@ func (r *Repository) InstallApp(appReference *app.Reference) (app.App, error) { url, err := r.getAppURL(appReference.Name, appReference.Version) if err != nil { - return nil, fmt.Errorf("failed to install app %s: %w", appReference, err) + return nil, err } outputPath := app.InstallationPath(r.InstallationPath, r.RepoName, appReference.Name, appReference.Version) @@ -70,10 +71,10 @@ func (r *Repository) InstallApp(appReference *app.Reference) (app.App, error) { func (r *Repository) getAppURL(appName, appVersion string) (*url.URL, error) { configUrl := r.Config.URL if configUrl == "" { - return nil, fmt.Errorf("repository URL is empty") + return nil, model.NewUserErr(fmt.Errorf("repository URL is empty"), "invalid repository URL") } if !strings.Contains(configUrl, "{name}") || !strings.Contains(configUrl, "{version}") { - return nil, fmt.Errorf("repository URL does not contain {name} or {version}") + return nil, model.NewUserErr(fmt.Errorf("repository URL does not contain {name} or {version}"), "invalid repository URL") } configUrl = strings.ReplaceAll(configUrl, "{name}", appName) configUrl = strings.ReplaceAll(configUrl, "{version}", appVersion) diff --git a/pkg/result/engine.go b/pkg/result/engine.go index 3c668eb..3a5c38d 100644 --- a/pkg/result/engine.go +++ b/pkg/result/engine.go @@ -169,13 +169,27 @@ func getCheck(checks map[string]*v1.Check, requestedCheck *configuration.Check) return check } -func (res *DefaultResultEngine) createCheckResult(output *executor.Output) v1.CheckResult { +type Mapping struct { + Chapter string + Requirement string + Check string +} + +func (res *DefaultResultEngine) createCheckResult(output *executor.Output, mapping Mapping) v1.CheckResult { result := v1.CheckResult{} result.Autopilot = output.Name result.Status = output.Status result.Reason = output.Reason for _, r := range output.Results { + hashFields := helper.HashFields{ + Chapter: mapping.Chapter, + Requirement: mapping.Requirement, + Check: mapping.Check, + Criterion: r.Criterion, + Justification: r.Justification, + } result.Results = append(result.Results, v1.AutopilotResult{ + Hash: helper.GenerateCheckResultIdHash(hashFields), Criterion: common.MultilineString(r.Criterion), Fulfilled: r.Fulfilled, Justification: common.MultilineString(r.Justification), @@ -200,7 +214,12 @@ func (res *DefaultResultEngine) addItemResult(item *item.Result) { res.logger.Debug("Search for ", zap.Any("check", item.Config.Check)) check := getCheck(requirement.Checks, &item.Config.Check) res.logger.Debug("Search for ", zap.Any("autopilot", item.Config.Autopilot)) - check.Evaluation = res.createCheckResult(item.Output) + mapping := Mapping{ + Chapter: item.Config.Chapter.Id, + Requirement: item.Config.Requirement.Id, + Check: item.Config.Check.Id, + } + check.Evaluation = res.createCheckResult(item.Output, mapping) check.Type = item.Output.ExecutionType res.Result.Statistics.CountChecks++ if item.Output.ExecutionType == Automation { diff --git a/pkg/result/engine_test.go b/pkg/result/engine_test.go index 7fac489..d896eda 100644 --- a/pkg/result/engine_test.go +++ b/pkg/result/engine_test.go @@ -192,6 +192,7 @@ func TestCreateResult(t *testing.T) { Reason: "reason", Results: []v1.AutopilotResult{ { + Hash: "7ae1f095d63607b1228ffe92919e7a287ae61ca5b49d8c0fff5272a0ec06062f", Criterion: "finding criteria", Fulfilled: false, Justification: "finding reason", @@ -404,6 +405,7 @@ func TestAddItemResult(t *testing.T) { Status: "GREEN", Results: []v1.AutopilotResult{ { + Hash: "7ae1f095d63607b1228ffe92919e7a287ae61ca5b49d8c0fff5272a0ec06062f", Criterion: "finding criteria", Fulfilled: false, Justification: "finding reason", @@ -477,6 +479,7 @@ func TestAddItemResult(t *testing.T) { Reason: "Overall Reason", Results: []v1.AutopilotResult{ { + Hash: "7ae1f095d63607b1228ffe92919e7a287ae61ca5b49d8c0fff5272a0ec06062f", Criterion: "finding criteria", Fulfilled: false, Justification: "finding reason", @@ -673,6 +676,7 @@ func TestAddItemResult(t *testing.T) { Status: "GRE\nEN", Results: []v1.AutopilotResult{ { + Hash: "b2127a418aa20cd4072a9fcf1b1ef59459322a9dadcc8ddcf77c12b3b9da4f4f", Criterion: "finding\ncriteria 1", Fulfilled: false, Justification: "finding\nreason 1", @@ -681,6 +685,7 @@ func TestAddItemResult(t *testing.T) { }, }, { + Hash: "d04e77076b2bcb3307546df1021d0747a0e6d22cf8ed68cbe0a50001a955903a", Criterion: "finding\tcriteria 2", Fulfilled: false, Justification: "finding\treason 2", @@ -754,15 +759,21 @@ func TestCreateCheckResult(t *testing.T) { ExitCode: 0, EvidencePath: "root/tmp/", } + mapping := Mapping{ + Chapter: "1", + Requirement: "1", + Check: "1", + } // act - result := res.createCheckResult(output) + result := res.createCheckResult(output, mapping) // assert assert.Equal(t, "Test Autopilot", result.Autopilot) assert.Equal(t, "GREEN", result.Status) assert.Equal(t, "Test Reason", result.Reason) assert.Len(t, result.Results, 1) + assert.Equal(t, "8875d5897f0ff743f670454dc36b22d21a86b82b1658b15a6c2c7986dec5fa2b", result.Results[0].Hash) assert.Equal(t, common.MultilineString("Test Criterion"), result.Results[0].Criterion) assert.True(t, result.Results[0].Fulfilled) assert.Equal(t, common.MultilineString("Test Justification"), result.Results[0].Justification) diff --git a/pkg/result/testdata/result.golden b/pkg/result/testdata/result.golden index 9d0de56..c747673 100644 --- a/pkg/result/testdata/result.golden +++ b/pkg/result/testdata/result.golden @@ -34,7 +34,8 @@ chapters: status: GREEN reason: reason results: - - criterion: finding criteria + - hash: 7ae1f095d63607b1228ffe92919e7a287ae61ca5b49d8c0fff5272a0ec06062f + criterion: finding criteria fulfilled: false justification: finding reason outputs: diff --git a/pkg/result/v1/result.go b/pkg/result/v1/result.go index e92b67b..2436e49 100644 --- a/pkg/result/v1/result.go +++ b/pkg/result/v1/result.go @@ -51,6 +51,9 @@ type ExecutionInformation struct { // Contains one of potentially many results reported by an autopilot type AutopilotResult struct { + // Unique identifier for the Result, created from the Chapter, Requirement, Check, Criterion, and Justification. + // Example "9319a093d48e7488ef34cd74ccfe5e2f23a00b32eede2ba30d39676f2029a528" + Hash string `yaml:"hash" json:"hash" jsonschema:"required"` // Criterion that was evaluated by the autopilot // Example "My Criterion" Criterion common.MultilineString `yaml:"criterion" json:"criterion" jsonschema:"required"` diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 5904b02..f1ecb71 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -3,9 +3,11 @@ package schema import ( "encoding/json" "fmt" + "strings" "github.com/B-S-F/onyx/pkg/logger" "github.com/B-S-F/onyx/pkg/replacer" + "github.com/B-S-F/onyx/pkg/v2/model" "github.com/invopop/jsonschema" "github.com/invopop/yaml" "github.com/pkg/errors" @@ -53,12 +55,11 @@ func (s *Schema) Validate(yamlData []byte) error { return errors.Wrapf(err, "error validating data: %s", err) } if !result.Valid() { - errorMsg := "config data does not match schema" - s.logger.Error(fmt.Sprintf("%s:", errorMsg)) + var validationErrs []string for _, desc := range result.Errors() { - s.logger.Error(fmt.Sprintf(" - %s", desc)) + validationErrs = append(validationErrs, fmt.Sprintf(" - %s", desc)) } - return errors.New(errorMsg) + return model.NewUserErr(errors.New(fmt.Sprintf("\n%s", strings.Join(validationErrs, "\n"))), "config data does not match schema") } // validate replace patterns patterns := replacer.FindAllReplacePatterns(string(yamlData)) @@ -69,8 +70,7 @@ func (s *Schema) Validate(yamlData []byte) error { s.logger.Warnf("deprecated pattern '%s' found. Valid patterns are: ${{ secrets. }}, ${{ vars. }}, and ${{ env. }}", pattern) } else { errorMsg := fmt.Sprintf("invalid pattern '%s' found. Valid patterns are: ${{ secrets. }}, ${{ vars. }} and ${{ env. }}", pattern) - s.logger.Error(errorMsg) - return errors.New(errorMsg) + return model.NewUserErr(errors.New(errorMsg), "config contains invalid replace pattern") } } } diff --git a/pkg/v2/config/config.go b/pkg/v2/config/config.go index 9ca86cd..1aa84b8 100644 --- a/pkg/v2/config/config.go +++ b/pkg/v2/config/config.go @@ -138,7 +138,7 @@ type Evaluate struct { // Example // # do evaluation of SharePoint metadata here // # do evaluation of PDF signature data here - Run string `yaml:"run" json:"run" jsonschema:"required"` + Run string `yaml:"run" json:"run" jsonschema:"required,minLength=1"` } type Finalize struct { diff --git a/pkg/v2/config/validate.go b/pkg/v2/config/validate.go index 6464d5d..d19dd99 100644 --- a/pkg/v2/config/validate.go +++ b/pkg/v2/config/validate.go @@ -1,9 +1,11 @@ package config import ( + "fmt" "regexp" "github.com/B-S-F/onyx/pkg/logger" + "github.com/B-S-F/onyx/pkg/v2/model" "github.com/pkg/errors" ) @@ -18,7 +20,7 @@ func Validate(config interface{}) error { continue } if err := validateID(step.ID, idMap); err != nil { - return errors.Wrap(err, "invalid step id "+step.ID) + return err } } } @@ -27,7 +29,7 @@ func Validate(config interface{}) error { for _, step := range autopilot.Steps { for _, depends := range step.Depends { if !idMap[depends] { - return errors.Errorf("missing dependency %s", depends) + return model.NewUserErr(errors.Errorf("missing dependency %s", depends), "config validation failed") } } } @@ -44,9 +46,9 @@ func Validate(config interface{}) error { // validate checks for _, chap := range cfg.Chapters { for _, req := range chap.Requirements { - for _, check := range req.Checks { + for checkID, check := range req.Checks { if check.isAutomation() && check.isManual() { - return errors.Errorf("checks can't have both manual and automated checks") + return model.NewUserErr(errors.Errorf("invalid check '%s': checks can't have both manual and automated checks", checkID), "config validation failed") } } } @@ -63,11 +65,11 @@ func validateID(id string, existingIDs map[string]bool) error { } if !isValidIDPattern.MatchString(id) { - return errors.New("ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed.") + return model.NewUserErr(fmt.Errorf("invalid step id '%s': ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed.", id), "config validation failed") } if _, exists := existingIDs[id]; exists { - return errors.New("ID must be unique. This ID already exists.") + return model.NewUserErr(fmt.Errorf("invalid step id '%s': ID must be unique. This ID already exists.", id), "config validation failed") } existingIDs[id] = true diff --git a/pkg/v2/config/validate_test.go b/pkg/v2/config/validate_test.go index 005b7e5..f0e5716 100644 --- a/pkg/v2/config/validate_test.go +++ b/pkg/v2/config/validate_test.go @@ -21,7 +21,7 @@ func TestCustomValidationForV2(t *testing.T) { "autopilots": {Steps: []Step{{ID: "invalidid$"}}}, }, }, - want: errors.New("invalid step id invalidid$: ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), + want: errors.New("config validation failed: invalid step id 'invalidid$': ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), }, "invalid-id-when-contains-umlaut": { input: &Config{ @@ -29,7 +29,7 @@ func TestCustomValidationForV2(t *testing.T) { "autopilots": {Steps: []Step{{ID: "invalididÄ"}}}, }, }, - want: errors.New("invalid step id invalididÄ: ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), + want: errors.New("config validation failed: invalid step id 'invalididÄ': ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), }, "valid-name": { input: &Config{ @@ -53,7 +53,7 @@ func TestCustomValidationForV2(t *testing.T) { "autopilots": {Steps: []Step{{Depends: []string{"fetch1"}}}}, }, }, - want: errors.New("missing dependency fetch1"), + want: errors.New("config validation failed: missing dependency fetch1"), }, "invalid-check": { input: &Config{ @@ -80,7 +80,7 @@ func TestCustomValidationForV2(t *testing.T) { }, }, }, - want: errors.New("checks can't have both manual and automated checks"), + want: errors.New("config validation failed: invalid check 'check1': checks can't have both manual and automated checks"), }, "valid-check": { input: &Config{ diff --git a/pkg/v2/executor/autopilotcheck.go b/pkg/v2/executor/autopilotcheck.go index f792ed3..9243788 100644 --- a/pkg/v2/executor/autopilotcheck.go +++ b/pkg/v2/executor/autopilotcheck.go @@ -27,6 +27,7 @@ type AutopilotExecutor struct { logger *logger.Autopilot timeout time.Duration runner *runner.Subprocess + userLogger logger.Logger } type stepDirs struct { @@ -41,7 +42,7 @@ type evaluateResult struct { results []model.Result } -func NewAutopilotExecutor(wdUtils workdir.Utilizer, rootWorkDir string, strict bool, logger *logger.Autopilot, timeout time.Duration) *AutopilotExecutor { +func NewAutopilotExecutor(wdUtils workdir.Utilizer, rootWorkDir string, strict bool, logger *logger.Autopilot, timeout time.Duration, userLogger logger.Logger) *AutopilotExecutor { return &AutopilotExecutor{ wdUtils: wdUtils, rootWorkDir: rootWorkDir, @@ -49,11 +50,12 @@ func NewAutopilotExecutor(wdUtils workdir.Utilizer, rootWorkDir string, strict b logger: logger, timeout: timeout, runner: runner.NewSubprocess(logger), + userLogger: userLogger, } } func (a *AutopilotExecutor) ExecuteAutopilotCheck(item *model.AutopilotCheck, env, secrets map[string]string) (*model.AutopilotResult, error) { - if result := checkErrors(item, a.logger); result != nil { + if result := checkErrors(item, a.logger, a.userLogger); result != nil { return result, nil } @@ -117,6 +119,7 @@ func (a *AutopilotExecutor) ExecuteAutopilotCheck(item *model.AutopilotCheck, en if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("failed to run autopilot '%s' step '%s'", item.Autopilot.Name, step.ID)) } + // get step result and log output stepResult := parseStepResult(runnerOutput, step.ID, stepDirs, inputDirs) if err := writeLogs(stepDirs.stepDir, a.wdUtils, stepResult.Logs); err != nil { @@ -191,7 +194,7 @@ func (a *AutopilotExecutor) ExecuteAutopilotCheck(item *model.AutopilotCheck, en return autopilotResult, nil } -func checkErrors(item *model.AutopilotCheck, logger *logger.Autopilot) *model.AutopilotResult { +func checkErrors(item *model.AutopilotCheck, logger *logger.Autopilot, userLogger logger.Logger) *model.AutopilotResult { if len(item.ValidationErrs) > 0 { msg := fmt.Sprintf("autopilot '%s' has the following validation errors and won't be executed: %s", item.Autopilot.Name, errs.Join(item.ValidationErrs...).Error()) output := &model.AutopilotResult{ @@ -202,7 +205,9 @@ func checkErrors(item *model.AutopilotCheck, logger *logger.Autopilot) *model.Au }, Name: item.Autopilot.Name, } - logger.Error(msg) + logger.Warn(msg) + userLogger.Error(msg) + return output } return nil diff --git a/pkg/v2/executor/autopilotcheck_test.go b/pkg/v2/executor/autopilotcheck_test.go index 6122b26..25a60f9 100644 --- a/pkg/v2/executor/autopilotcheck_test.go +++ b/pkg/v2/executor/autopilotcheck_test.go @@ -273,7 +273,7 @@ func TestAutopilotExecuteIntegration(t *testing.T) { env := map[string]string{} // act - autopilotExecutor := NewAutopilotExecutor(wdUtils, tmpDir, tc.strict, logger, timeout) + autopilotExecutor := NewAutopilotExecutor(wdUtils, tmpDir, tc.strict, logger, timeout, nopLogger) actual, err := autopilotExecutor.ExecuteAutopilotCheck(tc.check, env, secrets) expected := tc.want(tmpDir) @@ -393,7 +393,7 @@ func TestAutopilotExecuteDirectoryStructure(t *testing.T) { env := map[string]string{} // act - autopilotExecutor := NewAutopilotExecutor(wdUtils, tmpDir, tc.strict, logger, timeout) + autopilotExecutor := NewAutopilotExecutor(wdUtils, tmpDir, tc.strict, logger, timeout, nopLogger) actual, err := autopilotExecutor.ExecuteAutopilotCheck(tc.check, env, secrets) // assert diff --git a/pkg/v2/executor/executor.go b/pkg/v2/executor/executor.go index 25baee7..c5f4ded 100644 --- a/pkg/v2/executor/executor.go +++ b/pkg/v2/executor/executor.go @@ -18,6 +18,10 @@ func StartRunner(workDir string, run string, env, secrets map[string]string, log WorkDir: workDir, } out, err := scriptRunner.Execute(&input, timeout) - logger.Debug("output", zap.Any("output", out), zap.Error(err)) - return out, err + if err != nil { + return nil, err + } + + logger.Debug("output", zap.Any("output", out)) + return out, nil } diff --git a/pkg/v2/executor/finalize.go b/pkg/v2/executor/finalize.go index 13aa66d..4d50f29 100644 --- a/pkg/v2/executor/finalize.go +++ b/pkg/v2/executor/finalize.go @@ -34,7 +34,7 @@ func NewFinalizeExecutor(wdUtils workdir.Utilizer, rootWorkDir string, logger *l func (f *FinalizeExecutor) Execute(item *model.Finalize, env, secrets map[string]string) (*model.FinalizeResult, error) { err := overWriteConfigFiles(f.wdUtils, item.Configs, f.rootWorkDir) if err != nil { - return nil, errors.Wrap(err, "failed to create config files") + return nil, err } specialEnv := map[string]string{"result_path": f.rootWorkDir} runtimeEnv := helper.MergeMaps(env, item.Env, specialEnv) @@ -42,6 +42,7 @@ func (f *FinalizeExecutor) Execute(item *model.Finalize, env, secrets map[string if err != nil { return nil, errors.Wrap(err, "failed to run finalize") } + result := &model.FinalizeResult{ Logs: runnerOutput.Logs, ExitCode: runnerOutput.ExitCode, diff --git a/pkg/v2/model/error.go b/pkg/v2/model/error.go new file mode 100644 index 0000000..5381f1f --- /dev/null +++ b/pkg/v2/model/error.go @@ -0,0 +1,20 @@ +package model + +import "fmt" + +type UserError struct { + err error + reason string +} + +func NewUserErr(err error, reason string) UserError { + return UserError{err: err, reason: reason} +} + +func (u UserError) Error() string { + return fmt.Sprintf("%s: %s", u.reason, u.err.Error()) +} + +func (u UserError) Reason() string { + return u.reason +} diff --git a/pkg/v2/orchestrator/orchestrator.go b/pkg/v2/orchestrator/orchestrator.go index a969629..bef460d 100644 --- a/pkg/v2/orchestrator/orchestrator.go +++ b/pkg/v2/orchestrator/orchestrator.go @@ -22,10 +22,11 @@ type Orchestrator struct { strict bool timeout time.Duration logger logger.Logger + userLogger logger.Logger } -func New(rootWorkDir string, strict bool, timeout time.Duration, logger logger.Logger) *Orchestrator { - return &Orchestrator{rootWorkDir: rootWorkDir, timeout: timeout, logger: logger, strict: strict} +func New(rootWorkDir string, strict bool, timeout time.Duration, logger logger.Logger, userLogger logger.Logger) *Orchestrator { + return &Orchestrator{rootWorkDir: rootWorkDir, timeout: timeout, logger: logger, strict: strict, userLogger: userLogger} } type manualExec struct { @@ -173,7 +174,9 @@ func (o *Orchestrator) runAutopilots(autopilots []model.AutopilotCheck, env, sec o.rootWorkDir, o.strict, logger, - o.timeout) + o.timeout, + o.userLogger, + ) logger.Info(fmt.Sprintf("[[ CHAPTER: %s REQUIREMENT: %s CHECK: %s ]]", strings.ToUpper(autopilot.Chapter.Id), strings.ToUpper(autopilot.Requirement.Id), strings.ToUpper(autopilot.Check.Id))) diff --git a/pkg/v2/replacer/replacer.go b/pkg/v2/replacer/replacer.go index 801e189..2d7b875 100644 --- a/pkg/v2/replacer/replacer.go +++ b/pkg/v2/replacer/replacer.go @@ -15,10 +15,11 @@ var PatternVariableType = []string{"vars", "secrets", "env"} var DeprecatedVariableType = []string{"var", "secret", "envs"} type Runner struct { - ep *model.ExecutionPlan - variables *map[string]string - replacer replacer.Replacer - logger logger.Logger + ep *model.ExecutionPlan + variables *map[string]string + replacer replacer.Replacer + logger logger.Logger + userLogger logger.Logger } type Scope int @@ -33,34 +34,34 @@ func (s Scope) String() string { return [...]string{"Initial", "ConfigValues"}[s] } -func New(ep *model.ExecutionPlan, vars *map[string]string, p ...replacer.Pattern) *Runner { +func New(ep *model.ExecutionPlan, vars *map[string]string, userLogger logger.Logger, p ...replacer.Pattern) *Runner { r := replacer.NewReplacerImpl(p) variables := helper.MergeMaps(ep.DefaultVars, *vars) return &Runner{ - ep: ep, - variables: &variables, - replacer: r, - logger: logger.Get(), + ep: ep, + variables: &variables, + replacer: r, + logger: logger.Get(), + userLogger: userLogger, } } -func Run(ep *model.ExecutionPlan, vars, secrets map[string]string, scope Scope) error { +func Run(ep *model.ExecutionPlan, vars, secrets map[string]string, scope Scope, userLogger logger.Logger) { possibleTypes := PatternVariableType[:] possibleTypes = append(possibleTypes, DeprecatedVariableType...) for _, varType := range possibleTypes { switch varType { case "vars", "var": - r := New(ep, &vars, replacer.NewPattern(varType, PatternStart, PatternEnd)) + r := New(ep, &vars, userLogger, replacer.NewPattern(varType, PatternStart, PatternEnd)) r.replace("vars", scope) case "secrets", "secret": - r := New(ep, &secrets, replacer.NewPattern(varType, PatternStart, PatternEnd)) + r := New(ep, &secrets, userLogger, replacer.NewPattern(varType, PatternStart, PatternEnd)) r.replace("secrets", scope) case "env", "envs": - r := New(ep, &ep.Env, replacer.NewPattern(varType, PatternStart, PatternEnd)) + r := New(ep, &ep.Env, userLogger, replacer.NewPattern(varType, PatternStart, PatternEnd)) r.replace("env", scope) } } - return nil } func (r *Runner) replace(varType string, scope Scope) { @@ -82,20 +83,28 @@ func (r *Runner) replaceInitialExecutionPlan(varType string) { } if e := r.replacer.Env(&r.ep.Env, variablesList); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in global Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in global Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Metadata if e := r.replacer.Struct(&r.ep.Metadata, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Metadata: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Metadata: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Header if e := r.replacer.Struct(&r.ep.Header, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Header: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Header: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // Replace Repositories for i := range r.ep.Repositories { if e := r.replacer.Struct(&r.ep.Repositories[i], *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Repository: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Repository: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } @@ -115,7 +124,9 @@ func (r *Runner) replaceInitialExecutionPlan(varType string) { func (r *Runner) replaceManualItem(item *model.ManualCheck, varType string) { r.replaceCommonItem(&item.Item, varType) if e := r.replacer.Struct(&item.Manual, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing variables in Manual: %s", e).Error()) + err := fmt.Errorf("error replacing variables in Manual: %s", e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } @@ -133,16 +144,22 @@ func (r *Runner) replaceAutopilotItem(item *model.AutopilotCheck, varType string } for _, appRef := range item.AppReferences { if e := r.replacer.Struct(appRef, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in AppReference: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in AppReference: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } // replace Env if e := r.replacer.Env(&item.CheckEnv, itemEnvList); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Autopilot.Env if e := r.replacer.Env(&item.Autopilot.Env, autopilotEnvList); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Autopilot.Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Autopilot.Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } var autopilotEnv map[string]string if varType == "env" { @@ -154,7 +171,9 @@ func (r *Runner) replaceAutopilotItem(item *model.AutopilotCheck, varType string autopilot := &item.Autopilot autopilotName, e := r.replacer.String(autopilot.Name, autopilotEnv) if e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Autopilot: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Autopilot: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } autopilot.Name = autopilotName var stepEnvList []map[string]string @@ -169,7 +188,9 @@ func (r *Runner) replaceAutopilotItem(item *model.AutopilotCheck, varType string step := &autopilot.Steps[i][j] // replace Step.Env if e := r.replacer.Env(&step.Env, stepEnvList); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Step.Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Step.Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } var stepEnv map[string]string if varType == "env" { @@ -180,17 +201,23 @@ func (r *Runner) replaceAutopilotItem(item *model.AutopilotCheck, varType string } // replace Step if e := r.replacer.Struct(step, stepEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Step: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Step: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Step.Config keys if e := r.replaceKeys(varType, &step.Configs, autopilotEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Step.Config keys: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Step.Config keys: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } } // replace Evaluate.Env if e := r.replacer.Env(&autopilot.Evaluate.Env, stepEnvList); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Evaluate.Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Evaluate.Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Evaluate var evaluateEnv map[string]string @@ -201,25 +228,35 @@ func (r *Runner) replaceAutopilotItem(item *model.AutopilotCheck, varType string evaluateEnv = autopilotEnv } if e := r.replacer.Struct(&autopilot.Evaluate, evaluateEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Evaluate: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Evaluate: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } // replace Evaluate.Config keys if e := r.replaceKeys(varType, &autopilot.Evaluate.Configs, evaluateEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Evaluate.Config keys: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Evaluate.Config keys: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } func (r *Runner) replaceCommonItem(item *model.Item, varType string) { if e := r.replacer.Struct(&item.Chapter, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Chapter: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Chapter: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } if e := r.replacer.Struct(&item.Requirement, *r.variables); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Requirement: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Requirement: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } checkEnv := buildEnvironment(*r.variables) if e := r.replacer.Struct(&item.Check, checkEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Check: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Check: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } @@ -228,7 +265,9 @@ func (r *Runner) replaceFinalizeItem(item *model.Finalize, varType string) { finalizeEnvList := buildEnvironmentList(*r.variables) // replace Finalize.Env if e := r.replacer.Env(&item.Env, finalizeEnvList); e != nil { - r.logger.Error(fmt.Errorf("error replacing variables in Finalize.Env: %w", e).Error()) + err := fmt.Errorf("error replacing variables in Finalize.Env: %w", e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } var finalizeEnv map[string]string if varType == "env" { @@ -238,17 +277,23 @@ func (r *Runner) replaceFinalizeItem(item *model.Finalize, varType string) { } // replace Finalize if e := r.replacer.Map(&item.Env, finalizeEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing variables in Finalize: %w", e).Error()) + err := fmt.Errorf("error replacing variables in Finalize: %w", e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } run, e := r.replacer.String(item.Run, finalizeEnv) if e != nil { - r.logger.Error(fmt.Errorf("error replacing variables in Finalize: %w", e).Error()) + err := fmt.Errorf("error replacing variables in Finalize: %w", e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } item.Run = run // replace Config keys in Finalize if e := r.replaceKeys(varType, &item.Configs, finalizeEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Config keys: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Config keys: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } @@ -278,7 +323,9 @@ func (r *Runner) replaceConfigValues(varType string) { stepEnv = autopilotEnv } if e := r.replaceConfig(varType, &step.Configs, stepEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Step.Env: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Step.Env: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } } @@ -291,7 +338,9 @@ func (r *Runner) replaceConfigValues(varType string) { evaluateEnv = autopilotEnv } if e := r.replaceConfig(varType, &autopilotItem.Autopilot.Evaluate.Configs, evaluateEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Config: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Config: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } @@ -305,7 +354,9 @@ func (r *Runner) replaceConfigValues(varType string) { // replace Config values in Finalize if e := r.replaceConfig(varType, &r.ep.Finalize.Configs, finalizeEnv); e != nil { - r.logger.Error(fmt.Errorf("error replacing '%s' in Finalize.Config: %w", varType, e).Error()) + err := fmt.Errorf("error replacing '%s' in Finalize.Config: %w", varType, e) + r.logger.Warn(err.Error()) + r.userLogger.Error(err.Error()) } } } diff --git a/pkg/v2/replacer/replacer_test.go b/pkg/v2/replacer/replacer_test.go index e738788..dd09a37 100644 --- a/pkg/v2/replacer/replacer_test.go +++ b/pkg/v2/replacer/replacer_test.go @@ -4,8 +4,10 @@ import ( "testing" config "github.com/B-S-F/onyx/pkg/configuration" + "github.com/B-S-F/onyx/pkg/logger" "github.com/B-S-F/onyx/pkg/v2/model" "github.com/stretchr/testify/assert" + "go.uber.org/zap" ) var varsContent = map[string]string{ @@ -39,31 +41,29 @@ var secretsContent = map[string]string{ "GITHUB_PASSWORD": "github_password", } +var nopLogger = &logger.Log{ + Logger: zap.NewNop(), +} + func TestReplaceRun(t *testing.T) { executionPlan := simpleExecPlan() - err := Run( + Run( executionPlan, varsContent, secretsContent, Initial, + nopLogger, ) - if err != nil { - t.Errorf("Error running replacer: %v", err) - } - - err = Run( + Run( executionPlan, varsContent, secretsContent, ConfigValues, + nopLogger, ) - if err != nil { - t.Errorf("Error running replacer: %v", err) - } - // metadata assert.Equal(t, "2", executionPlan.Metadata.Version, "metadata should be equal") // header @@ -241,28 +241,22 @@ func TestReplaceRunWithoutFinalizer(t *testing.T) { executionPlan := simpleExecPlan() executionPlan.Finalize = nil - err := Run( + Run( executionPlan, varsContent, secretsContent, Initial, + nopLogger, ) - if err != nil { - t.Errorf("Error running replacer: %v", err) - } - - err = Run( + Run( executionPlan, varsContent, secretsContent, ConfigValues, + nopLogger, ) - if err != nil { - t.Errorf("Error running replacer: %v", err) - } - // metadata assert.Equal(t, "2", executionPlan.Metadata.Version, "metadata should be equal") // header diff --git a/pkg/v2/repository/registry/registry.go b/pkg/v2/repository/registry/registry.go index 2be488b..856d293 100644 --- a/pkg/v2/repository/registry/registry.go +++ b/pkg/v2/repository/registry/registry.go @@ -5,7 +5,6 @@ import ( "github.com/B-S-F/onyx/pkg/repository/registry" "github.com/B-S-F/onyx/pkg/v2/model" "github.com/B-S-F/onyx/pkg/v2/repository/app" - "github.com/pkg/errors" ) func Initialize(ep *model.ExecutionPlan, repositories []repository.Repository) (*registry.Registry, error) { @@ -14,7 +13,7 @@ func Initialize(ep *model.ExecutionPlan, repositories []repository.Repository) ( for _, appReference := range appReferences { err := appRegistry.Install(appReference) if err != nil { - return nil, errors.Wrap(err, "error adding app to registry") + return nil, err } } return appRegistry, nil diff --git a/pkg/v2/result/creator.go b/pkg/v2/result/creator.go index 5b7a791..ddba2c6 100644 --- a/pkg/v2/result/creator.go +++ b/pkg/v2/result/creator.go @@ -100,7 +100,7 @@ func (c *Creator) AppendFinalizeResult(res *Result, finalizeResult model.Finaliz logs, err := c.marshalLogs(finalizeResult.Logs) if err != nil { - return errors.Wrap(err, "failed to json marshal log entries") + return err } res.Finalize = &Finalize{ @@ -119,7 +119,7 @@ func (c *Creator) marshalLogs(logs []model.LogEntry) ([]string, error) { for _, log := range logs { logLine, err := json.Marshal(log) if err != nil { - return nil, err + return nil, errors.Wrap(err, "failed to json marshal log entry") } result = append(result, string(logLine)) @@ -204,7 +204,15 @@ func (c *Creator) addAutopilotResult(chapters map[string]*Chapter, a model.Autop var evaluationResults []EvaluationResult for _, result := range a.Result.EvaluateResult.Results { + hashFields := helper.HashFields{ + Chapter: a.AutopilotCheck.Chapter.Id, + Requirement: a.AutopilotCheck.Requirement.Id, + Check: a.AutopilotCheck.Check.Id, + Criterion: result.Criterion, + Justification: result.Justification, + } evaluationResults = append(evaluationResults, EvaluationResult{ + Hash: helper.GenerateCheckResultIdHash(hashFields), Criterion: common.MultilineString(result.Criterion), Fulfilled: result.Fulfilled, Justification: common.MultilineString(result.Justification), @@ -214,7 +222,7 @@ func (c *Creator) addAutopilotResult(chapters map[string]*Chapter, a model.Autop evaluateLogs, err := c.marshalLogs(a.Result.EvaluateResult.Logs) if err != nil { - return errors.Wrap(err, "failed to json marshal log entries") + return err } requirement.Checks[a.AutopilotCheck.Check.Id] = &Check{ @@ -257,7 +265,7 @@ func (c *Creator) createSteps(stepResults []model.StepResult, stepsByID map[stri logs, err := c.marshalLogs(s.Logs) if err != nil { - return nil, errors.Wrap(err, "failed to json marshal log entries") + return nil, err } steps = append(steps, Step{ diff --git a/pkg/v2/result/creator_create_test.go b/pkg/v2/result/creator_create_test.go index 7aa2778..8efcb60 100644 --- a/pkg/v2/result/creator_create_test.go +++ b/pkg/v2/result/creator_create_test.go @@ -181,7 +181,11 @@ func TestCreator_Create(t *testing.T) { OverallStatus: "GREEN", Chapters: map[string]*Chapter{ "1": simpleAutomationChapter(), - "2": simpleAutomationChapter(), + "2": func() *Chapter { + c := simpleAutomationChapter() + c.Requirements["1"].Checks["1"].Evaluation.Results[0].Hash = "dc34b8e77c1e77c63c75a34e80d2f47a0b9fde6cb314d566cb7e3bff2a2b30a2" + return c + }(), }, Statistics: Statistics{CountChecks: 2, CountAutomatedChecks: 2, PercentageDone: 100, PercentageAutomated: 100}, }}, @@ -206,6 +210,7 @@ func TestCreator_Create(t *testing.T) { c.Requirements["1"].Status = "RED" c.Requirements["1"].Checks["1"].Evaluation.Status = "RED" c.Requirements["1"].Checks["1"].Evaluation.Reason = "is red" + c.Requirements["1"].Checks["1"].Evaluation.Results[0].Hash = "dc34b8e77c1e77c63c75a34e80d2f47a0b9fde6cb314d566cb7e3bff2a2b30a2" return c }(), }, @@ -232,6 +237,8 @@ func TestCreator_Create(t *testing.T) { c.Requirements["2"].Status = "RED" c.Requirements["2"].Checks["1"].Evaluation.Status = "RED" c.Requirements["2"].Checks["1"].Evaluation.Reason = "is red" + c.Requirements["2"].Checks["1"].Evaluation.Results[0].Hash = "67ad823ab37dd00780d8beb36274452e10b7e6579a5d318344321a34c375dd85" + return c }(), }, @@ -258,6 +265,7 @@ func TestCreator_Create(t *testing.T) { c.Requirements["1"].Status = "RED" c.Requirements["1"].Checks["2"].Evaluation.Status = "RED" c.Requirements["1"].Checks["2"].Evaluation.Reason = "is red" + c.Requirements["1"].Checks["2"].Evaluation.Results[0].Hash = "a3a15e7d746953df3d3a562a8be220cbc63727ea2eb059122e6cca00ed5d3923" return c }(), }, @@ -709,6 +717,7 @@ func simpleAutomationChapter() *Chapter { ConfigFiles: []string{"cfg.yaml"}, Results: []EvaluationResult{ { + Hash: "19d9338067c7b1aa3e17cc779bf90abe65b6e47da9eb7b798a48aff9ab9ea58b", Criterion: "criterion", Fulfilled: true, Justification: "justified", diff --git a/pkg/v2/result/creator_test.go b/pkg/v2/result/creator_test.go index 4240e50..97a61bb 100644 --- a/pkg/v2/result/creator_test.go +++ b/pkg/v2/result/creator_test.go @@ -234,7 +234,8 @@ chapters: status: GREEN reason: should be GREEN results: - - criterion: criterion + - hash: 19d9338067c7b1aa3e17cc779bf90abe65b6e47da9eb7b798a48aff9ab9ea58b + criterion: criterion fulfilled: true justification: justified configFiles: diff --git a/pkg/v2/result/result.go b/pkg/v2/result/result.go index 3b23eda..1cc694f 100644 --- a/pkg/v2/result/result.go +++ b/pkg/v2/result/result.go @@ -171,6 +171,9 @@ type Evaluation struct { // Contains one of potentially many results reported by an autopilot type EvaluationResult struct { + // Unique identifier for the Result, created from the Chapter, Requirement, Check, Criterion, and Justification. + // Example "9319a093d48e7488ef34cd74ccfe5e2f23a00b32eede2ba30d39676f2029a528" + Hash string `yaml:"hash" json:"hash" jsonschema:"required"` // Criterion that was evaluated by the autopilot // Example "My Criterion" Criterion common.MultilineString `yaml:"criterion" json:"criterion" jsonschema:"required"` diff --git a/pkg/v2/runner/runner.go b/pkg/v2/runner/runner.go index 69730e7..28c611b 100644 --- a/pkg/v2/runner/runner.go +++ b/pkg/v2/runner/runner.go @@ -1,8 +1,6 @@ package runner import ( - "encoding/json" - "strings" "time" "github.com/B-S-F/onyx/pkg/v2/model" @@ -28,47 +26,6 @@ type Output struct { ExitCode int } -func (o *Output) parseLogStrings(outStr, errStr string) error { - outLines := strings.Split(outStr, "\n") - for _, outLine := range outLines { - if len(outLine) == 0 { - continue - } - - if byteLine := []byte(outLine); json.Valid(byteLine) { - var jsonLine map[string]interface{} - decoder := json.NewDecoder(strings.NewReader(outLine)) - decoder.UseNumber() - _ = decoder.Decode(&jsonLine) - o.Logs = append(o.Logs, model.LogEntry{Source: stdOutSourceType, Json: jsonLine}) - o.JsonData = append(o.JsonData, jsonLine) - continue - } - - o.Logs = append(o.Logs, model.LogEntry{Source: stdOutSourceType, Text: outLine}) - } - - errLines := strings.Split(errStr, "\n") - for _, errLine := range errLines { - if len(errLine) == 0 { - continue - } - - if byteLine := []byte(errLine); json.Valid(byteLine) { - var jsonLine map[string]interface{} - decoder := json.NewDecoder(strings.NewReader(errLine)) - decoder.UseNumber() - _ = decoder.Decode(&jsonLine) - o.Logs = append(o.Logs, model.LogEntry{Source: stdErrSourceType, Json: jsonLine}) - continue - } - - o.Logs = append(o.Logs, model.LogEntry{Source: stdErrSourceType, Text: errLine}) - } - - return nil -} - type Runner interface { Execute(input *Input, timeout time.Duration) (*Output, error) } diff --git a/pkg/v2/runner/runner_test.go b/pkg/v2/runner/runner_test.go deleted file mode 100644 index 3e6134f..0000000 --- a/pkg/v2/runner/runner_test.go +++ /dev/null @@ -1,139 +0,0 @@ -//go:build unit -// +build unit - -package runner - -import ( - "encoding/json" - "testing" - - "github.com/B-S-F/onyx/pkg/v2/model" - "github.com/stretchr/testify/assert" -) - -func TestParseLogStrings(t *testing.T) { - testCases := map[string]struct { - outStr string - errStr string - want *Output - }{ - "should add to logs, error logs and output data": { - outStr: "{\"key1\": \"value1\"}\n{\"key2\": \"value2\"}", - errStr: "error message", - want: &Output{ - Logs: []model.LogEntry{ - {Source: "stdout", Json: map[string]interface{}{"key1": "value1"}}, - {Source: "stdout", Json: map[string]interface{}{"key2": "value2"}}, - {Source: "stderr", Text: "error message"}, - }, - JsonData: []map[string]interface{}{ - {"key1": "value1"}, - {"key2": "value2"}, - }, - }, - }, - "should add json line log to output data": { - outStr: "{\"key1\": \"value1\"}\n{\"key2\": \"value2\"}", - errStr: "", - want: &Output{ - Logs: []model.LogEntry{ - {Source: "stdout", Json: map[string]interface{}{"key1": "value1"}}, - {Source: "stdout", Json: map[string]interface{}{"key2": "value2"}}, - }, - JsonData: []map[string]interface{}{ - {"key1": "value1"}, - {"key2": "value2"}, - }, - }, - }, - "should decode numbers as json.Number": { - outStr: "{\"key1\": 1}\n{\"key2\": 2.0}\n{\"key3\": 201872326}\n{\"key4\": 201872326.0}\n{\"key5\": -201872326}\n{\"key6\": -201872326.1}\n{\"key7\": 0}", - errStr: "", - want: &Output{ - Logs: []model.LogEntry{ - {Source: "stdout", Json: map[string]interface{}{"key1": json.Number("1")}}, - {Source: "stdout", Json: map[string]interface{}{"key2": json.Number("2.0")}}, - {Source: "stdout", Json: map[string]interface{}{"key3": json.Number("201872326")}}, - {Source: "stdout", Json: map[string]interface{}{"key4": json.Number("201872326.0")}}, - {Source: "stdout", Json: map[string]interface{}{"key5": json.Number("-201872326")}}, - {Source: "stdout", Json: map[string]interface{}{"key6": json.Number("-201872326.1")}}, - {Source: "stdout", Json: map[string]interface{}{"key7": json.Number("0")}}, - }, - JsonData: []map[string]interface{}{ - {"key1": json.Number("1")}, - {"key2": json.Number("2.0")}, - {"key3": json.Number("201872326")}, - {"key4": json.Number("201872326.0")}, - {"key5": json.Number("-201872326")}, - {"key6": json.Number("-201872326.1")}, - {"key7": json.Number("0")}, - }, - }, - }, - "should treat dates as string": { - outStr: "{\"key1\": \"2021-01-01T00:00:00Z\"}", - errStr: "", - want: &Output{ - Logs: []model.LogEntry{{Source: "stdout", Json: map[string]interface{}{"key1": "2021-01-01T00:00:00Z"}}}, - JsonData: []map[string]interface{}{ - {"key1": "2021-01-01T00:00:00Z"}, - }, - }, - }, - "should add normal log to logs": { - outStr: "normal log", - errStr: "", - want: &Output{ - Logs: []model.LogEntry{{Source: "stdout", Text: "normal log"}}, - JsonData: nil, - }, - }, - "should add error log to logs with source stderr": { - outStr: "", - errStr: "error log", - want: &Output{ - Logs: []model.LogEntry{{Source: "stderr", Text: "error log"}}, - JsonData: nil, - }, - }, - "should add json error log to logs with source stderr": { - outStr: "", - errStr: "{\"context\":\"some-context\", \"errMsg\":\"err-msg\"}", - want: &Output{ - Logs: []model.LogEntry{{Source: "stderr", Json: map[string]interface{}{"context": "some-context", "errMsg": "err-msg"}}}, - JsonData: nil, - }, - }, - "should not add empty trailing log when newline is last character": { - outStr: "hello world\n", - errStr: "hello error world\n", - want: &Output{ - Logs: []model.LogEntry{ - {Source: "stdout", Text: "hello world"}, - {Source: "stderr", Text: "hello error world"}, - }, - JsonData: nil, - }, - }, - "should return empty output when input is empty": { - outStr: "", - errStr: "", - want: &Output{ - Logs: nil, - JsonData: nil, - }, - }, - } - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - // arrange - out := &Output{} - - // act - out.parseLogStrings(tc.outStr, tc.errStr) - - // assert - assert.Equal(t, tc.want, out) - }) - } -} diff --git a/pkg/v2/runner/subprocess.go b/pkg/v2/runner/subprocess.go index 188629e..307a12a 100644 --- a/pkg/v2/runner/subprocess.go +++ b/pkg/v2/runner/subprocess.go @@ -1,7 +1,9 @@ package runner import ( + "bytes" "context" + "encoding/json" "fmt" "os" "os/exec" @@ -11,6 +13,8 @@ import ( "github.com/B-S-F/onyx/pkg/helper" "github.com/B-S-F/onyx/pkg/logger" "github.com/B-S-F/onyx/pkg/v2/model" + "github.com/netflix/go-iomux" + errs "github.com/pkg/errors" "go.uber.org/zap" ) @@ -29,23 +33,110 @@ func (s *Subprocess) Execute(input *Input, timeout time.Duration) (*Output, erro defer cancel() // start command s.logger.Debug("Starting command", zap.String("cmd", input.Cmd), zap.Strings("args", input.Args)) - var outbuf, errbuf strings.Builder - cmd.Stdout = &outbuf - cmd.Stderr = &errbuf - exitCode := s.runCommand(cmd, ctx) - // parse output - out, err := s.parseOutput(input, exitCode, outbuf.String(), errbuf.String()) + mux := iomux.NewMuxUnixGram[string]() + defer mux.Close() + cmd.Stdout, _ = mux.Tag(stdOutSourceType) + cmd.Stderr, _ = mux.Tag(stdErrSourceType) + + out := &Output{WorkDir: input.WorkDir} + chunks, err := mux.ReadWhile(func() error { + out.ExitCode = s.runCommand(cmd, ctx) + return nil + }) if err != nil { - return nil, err + return nil, errs.Wrap(err, "Failed to read command response") } - if exitCode == 124 { + s.demuxLogs(input, out, chunks) + + if out.ExitCode == 124 { out.Logs = append(out.Logs, model.LogEntry{Source: stdErrSourceType, Text: fmt.Sprintf("Command timed out after %s", timeout)}) } return out, nil } +type streamParser struct { + buf []byte +} + +func (p *streamParser) parse(chunk []byte) []string { + lines := bytes.Split(chunk, []byte("\n")) + if len(p.buf) > 0 { + lines[0] = append(p.buf, lines[0]...) + } + lines, rest := lines[:len(lines)-1], lines[len(lines)-1] + p.buf = rest + + var entries []string + for _, line := range lines { + entries = append(entries, string(line)) + } + return entries +} + +func (p *streamParser) end() (bool, string) { + if len(p.buf) > 0 { + line := string(p.buf) + p.buf = []byte{} + return true, line + } + return false, "" +} + +func (s *Subprocess) demuxLogs(in *Input, out *Output, chunks []*iomux.TaggedData[string]) { + demuxed := map[string][]string{ + stdOutSourceType: {}, + stdErrSourceType: {}, + } + parsers := make(map[string]*streamParser) + order := []string{} + + for stream := range demuxed { + parsers[stream] = &streamParser{} + } + + for _, chunk := range chunks { + parser := parsers[chunk.Tag] + lines := parser.parse(chunk.Data) + demuxed[chunk.Tag] = append(demuxed[chunk.Tag], lines...) + for range lines { + order = append(order, chunk.Tag) + } + } + + for _, stream := range []string{stdOutSourceType, stdErrSourceType} { + if ok, leftover := parsers[stream].end(); ok { + demuxed[stream] = append(demuxed[stream], leftover) + order = append(order, stream) + } + } + + for stream, lines := range demuxed { + demuxed[stream] = helper.HideSecretsInArrayOfLines(lines, in.Secrets) + } + + for _, stream := range order { + line := demuxed[stream][0] + demuxed[stream] = demuxed[stream][1:] + if line == "" { + continue + } + entry := model.LogEntry{Source: stream} + if json.Valid([]byte(line)) { + decoder := json.NewDecoder(strings.NewReader(line)) + decoder.UseNumber() + _ = decoder.Decode(&entry.Json) + if stream == stdOutSourceType { + out.JsonData = append(out.JsonData, entry.Json) + } + } else { + entry.Text = line + } + out.Logs = append(out.Logs, entry) + } +} + func (s *Subprocess) initCommand(input *Input, timeout time.Duration) (*exec.Cmd, context.Context, context.CancelFunc) { // context with timeout if timeout <= 0 { @@ -82,17 +173,3 @@ func (s *Subprocess) runCommand(cmd *exec.Cmd, ctx context.Context) int { } return 0 } - -func (s *Subprocess) parseOutput(input *Input, exitCode int, stdout, stderr string) (*Output, error) { - out := &Output{} - out.WorkDir = input.WorkDir - out.ExitCode = exitCode - outStr := helper.HideSecretsInString(stdout, input.Secrets) - errStr := helper.HideSecretsInString(stderr, input.Secrets) - err := out.parseLogStrings(outStr, errStr) - if err != nil { - return nil, err - } - - return out, nil -} diff --git a/pkg/v2/runner/subprocess_test.go b/pkg/v2/runner/subprocess_test.go index 3fec341..1d537b2 100644 --- a/pkg/v2/runner/subprocess_test.go +++ b/pkg/v2/runner/subprocess_test.go @@ -4,13 +4,15 @@ package runner import ( - "bytes" + "context" + "encoding/json" "os" "testing" "time" "github.com/B-S-F/onyx/pkg/logger" "github.com/B-S-F/onyx/pkg/v2/model" + "github.com/netflix/go-iomux" "github.com/stretchr/testify/assert" "go.uber.org/zap" ) @@ -116,6 +118,52 @@ func TestExecute(t *testing.T) { WorkDir: tmpDir, }, }, + "should return standard error and standard output in the correct order": { + input: &Input{ + Cmd: "/bin/bash", + Args: []string{"-c", "echo hello world1; 1>&2 echo hello world2; echo hello world3; echo hello world4; 1>&2 echo hello world5; 1>&2 echo hello world6"}, + WorkDir: tmpDir, + }, + timeout: 10 * time.Minute, + want: &Output{ + Logs: []model.LogEntry{ + {Source: "stdout", Text: "hello world1"}, + {Source: "stderr", Text: "hello world2"}, + {Source: "stdout", Text: "hello world3"}, + {Source: "stdout", Text: "hello world4"}, + {Source: "stderr", Text: "hello world5"}, + {Source: "stderr", Text: "hello world6"}, + }, + ExitCode: 0, + WorkDir: tmpDir, + }, + }, + "should handle interleaved standard error and standard output": { + input: &Input{ + Cmd: "/bin/bash", + Args: []string{"-c", "echo -n hello; 1>&2 echo hello world; echo world;"}, + WorkDir: tmpDir, + }, + timeout: 10 * time.Minute, + want: &Output{ + Logs: []model.LogEntry{{Source: "stderr", Text: "hello world"}, {Source: "stdout", Text: "helloworld"}}, + ExitCode: 0, + WorkDir: tmpDir, + }, + }, + "should handle missing newline at the end of logs": { + input: &Input{ + Cmd: "/bin/bash", + Args: []string{"-c", "echo -n hello; 1>&2 echo -n hello world; echo -n world;"}, + WorkDir: tmpDir, + }, + timeout: 10 * time.Minute, + want: &Output{ + Logs: []model.LogEntry{{Source: "stdout", Text: "helloworld"}, {Source: "stderr", Text: "hello world"}}, + ExitCode: 0, + WorkDir: tmpDir, + }, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { @@ -260,51 +308,164 @@ func TestRunCommand(t *testing.T) { } } -func TestParseOutput(t *testing.T) { +func TestDemuxLogs(t *testing.T) { s := &Subprocess{ logger: nopLogger, } - workDir := "/tmp" - secrets := map[string]string{"secret": "value"} testCases := map[string]struct { - name string - input *Input - exitCode int - stdout bytes.Buffer - stderr bytes.Buffer - want *Output + outStr string + errStr string + want *Output }{ - "should return output without logs": { - input: &Input{WorkDir: workDir, Secrets: secrets}, - exitCode: 0, - stdout: bytes.Buffer{}, - stderr: bytes.Buffer{}, + "should add to logs, error logs and output data": { + outStr: "{\"key1\": \"value1\"}\n{\"key2\": \"value2\"}", + errStr: "error message", want: &Output{ - WorkDir: workDir, - ExitCode: 0, + Logs: []model.LogEntry{ + {Source: "stdout", Json: map[string]interface{}{"key1": "value1"}}, + {Source: "stdout", Json: map[string]interface{}{"key2": "value2"}}, + {Source: "stderr", Text: "error message"}, + }, + JsonData: []map[string]interface{}{ + {"key1": "value1"}, + {"key2": "value2"}, + }, }, }, - "should return output with logs and error logs": { - input: &Input{WorkDir: workDir, Secrets: secrets}, - exitCode: 2, - stdout: *bytes.NewBufferString("output"), - stderr: *bytes.NewBufferString("error"), + "should add json line log to output data": { + outStr: "{\"key1\": \"value1\"}\n{\"key2\": \"value2\"}", + errStr: "", want: &Output{ - WorkDir: workDir, - ExitCode: 2, - Logs: []model.LogEntry{{Source: "stdout", Text: "output"}, {Source: "stderr", Text: "error"}}, + Logs: []model.LogEntry{ + {Source: "stdout", Json: map[string]interface{}{"key1": "value1"}}, + {Source: "stdout", Json: map[string]interface{}{"key2": "value2"}}, + }, + JsonData: []map[string]interface{}{ + {"key1": "value1"}, + {"key2": "value2"}, + }, + }, + }, + "should decode numbers as json.Number": { + outStr: "{\"key1\": 1}\n{\"key2\": 2.0}\n{\"key3\": 201872326}\n{\"key4\": 201872326.0}\n{\"key5\": -201872326}\n{\"key6\": -201872326.1}\n{\"key7\": 0}", + errStr: "", + want: &Output{ + Logs: []model.LogEntry{ + {Source: "stdout", Json: map[string]interface{}{"key1": json.Number("1")}}, + {Source: "stdout", Json: map[string]interface{}{"key2": json.Number("2.0")}}, + {Source: "stdout", Json: map[string]interface{}{"key3": json.Number("201872326")}}, + {Source: "stdout", Json: map[string]interface{}{"key4": json.Number("201872326.0")}}, + {Source: "stdout", Json: map[string]interface{}{"key5": json.Number("-201872326")}}, + {Source: "stdout", Json: map[string]interface{}{"key6": json.Number("-201872326.1")}}, + {Source: "stdout", Json: map[string]interface{}{"key7": json.Number("0")}}, + }, + JsonData: []map[string]interface{}{ + {"key1": json.Number("1")}, + {"key2": json.Number("2.0")}, + {"key3": json.Number("201872326")}, + {"key4": json.Number("201872326.0")}, + {"key5": json.Number("-201872326")}, + {"key6": json.Number("-201872326.1")}, + {"key7": json.Number("0")}, + }, + }, + }, + "should treat dates as string": { + outStr: "{\"key1\": \"2021-01-01T00:00:00Z\"}", + errStr: "", + want: &Output{ + Logs: []model.LogEntry{{Source: "stdout", Json: map[string]interface{}{"key1": "2021-01-01T00:00:00Z"}}}, + JsonData: []map[string]interface{}{ + {"key1": "2021-01-01T00:00:00Z"}, + }, + }, + }, + "should add normal log to logs": { + outStr: "normal log", + errStr: "", + want: &Output{ + Logs: []model.LogEntry{{Source: "stdout", Text: "normal log"}}, + JsonData: nil, + }, + }, + "should add error log to logs with source stderr": { + outStr: "", + errStr: "error log", + want: &Output{ + Logs: []model.LogEntry{{Source: "stderr", Text: "error log"}}, + JsonData: nil, + }, + }, + "should add json error log to logs with source stderr": { + outStr: "", + errStr: "{\"context\":\"some-context\", \"errMsg\":\"err-msg\"}", + want: &Output{ + Logs: []model.LogEntry{{Source: "stderr", Json: map[string]interface{}{"context": "some-context", "errMsg": "err-msg"}}}, + JsonData: nil, + }, + }, + "should not add empty trailing log when newline is last character": { + outStr: "hello world\n", + errStr: "hello error world\n", + want: &Output{ + Logs: []model.LogEntry{ + {Source: "stdout", Text: "hello world"}, + {Source: "stderr", Text: "hello error world"}, + }, + JsonData: nil, + }, + }, + "should return empty output when input is empty": { + outStr: "", + errStr: "", + want: &Output{ + Logs: nil, + JsonData: nil, + }, + }, + "should skip empty log lines": { + outStr: "hello\n\nworld\n", + errStr: "hello\n\n\n\nerror\n\nworld\n", + want: &Output{ + Logs: []model.LogEntry{ + {Source: "stdout", Text: "hello"}, + {Source: "stdout", Text: "world"}, + {Source: "stderr", Text: "hello"}, + {Source: "stderr", Text: "error"}, + {Source: "stderr", Text: "world"}, + }, + JsonData: nil, }, }, } - for name, tc := range testCases { t.Run(name, func(t *testing.T) { + // arrange + out := &Output{} + mux := iomux.NewMuxUnixGram[string]() + stdout, _ := mux.Tag(stdOutSourceType) + stderr, _ := mux.Tag(stdErrSourceType) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + stdout.WriteString(tc.outStr) + stdout.Close() + stderr.WriteString(tc.errStr) + stderr.Close() + cancel() + }() + // act - result, err := s.parseOutput(tc.input, tc.exitCode, tc.stdout.String(), tc.stderr.String()) + chunks, err := mux.ReadUntil(ctx) + s.demuxLogs(&Input{}, out, chunks) + // assert - assert.NoError(t, err) - assert.Equal(t, tc.want, result) + if assert.NoError(t, err) { + assert.Equal(t, tc.want, out) + } + + // cleanup + mux.Close() }) } } From 921c4820c27e40c1dc973a73ee1b225f274968ec Mon Sep 17 00:00:00 2001 From: "bsf-sync-bot[bot]" <180526808+bsf-sync-bot[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 05:59:43 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'cmd/'=20wi?= =?UTF-8?q?th=20remote=20'cmd/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v1/configuration/qg-result.golden | 47 ++++++---- .../v2/configuration/qg-result.golden | 87 +++++++++++-------- cmd/cli/main.go | 9 ++ cmd/cli/schema/testdata/result-schema.golden | 5 ++ 4 files changed, 96 insertions(+), 52 deletions(-) diff --git a/cmd/cli/exec/testdata/v1/configuration/qg-result.golden b/cmd/cli/exec/testdata/v1/configuration/qg-result.golden index 451de37..86bfb4e 100644 --- a/cmd/cli/exec/testdata/v1/configuration/qg-result.golden +++ b/cmd/cli/exec/testdata/v1/configuration/qg-result.golden @@ -97,7 +97,8 @@ chapters: status: GREEN reason: Some reason results: - - criterion: I am a criterion + - hash: c3c3665989b7cbc8702070dc74ec0a1fde4d3ecbe117bd9f5b0316bd0647a1b6 + criterion: I am a criterion fulfilled: false justification: I am the justification execution: @@ -116,7 +117,8 @@ chapters: status: YELLOW reason: Some reason results: - - criterion: I am a criterion + - hash: 921f14ba4892f7a96d90dd8bb9e48f39cfe68f2c289fa7c0f4bbf373cdfb7b6c + criterion: I am a criterion fulfilled: false justification: I am the justification execution: @@ -135,7 +137,8 @@ chapters: status: RED reason: Some reason results: - - criterion: I am a criterion + - hash: bd5874ac53b2a37af048d428f9e67bb6d61ec22f330fdb1dc78de2a49b6c0b67 + criterion: I am a criterion fulfilled: false justification: I am the justification execution: @@ -154,7 +157,8 @@ chapters: status: ERROR reason: 'autopilot ''status-provider'' provided an invalid ''status'': ''UNKNOWN''' results: - - criterion: I am a criterion + - hash: a33315a95bdc2647644827686ae5ebe2e6aa9ed8b69bacba5b391d0564a2fea1 + criterion: I am a criterion fulfilled: false justification: I am the justification execution: @@ -173,7 +177,8 @@ chapters: status: ERROR reason: 'autopilot ''status-provider'' provided an invalid ''status'': ''''' results: - - criterion: I am a criterion + - hash: 4503209c10693291841e713e9df3abb07264bcd6888fb2b9231238ca4a0eaab6 + criterion: I am a criterion fulfilled: false justification: I am the justification execution: @@ -206,7 +211,8 @@ chapters: status: GREEN reason: This is a reason results: - - criterion: I am a criterion + - hash: 62b8a9617b91f48aa9b776f1872a067feaa5416fbe9d6600a19147219d4ec93c + criterion: I am a criterion fulfilled: false justification: I am the justification outputs: @@ -245,13 +251,16 @@ chapters: status: GREEN reason: This is a reason results: - - criterion: I am a criterion + - hash: df86b33dd05c64038169a39102f172264031677651be2969663a89e4d75447d4 + criterion: I am a criterion fulfilled: false justification: I am the reason - - criterion: I am a criterion 2 + - hash: 428b9015ffa9368b41ffb87a6b26e63e29db79f4b9ec2a959c747f47ac3f9472 + criterion: I am a criterion 2 fulfilled: false justification: I am another reason - - criterion: I am a criterion 3 + - hash: 4117a8eeac0f8f1a7fbe81fd1cc3777365741efa913b77b788dbd2afb72ec23a + criterion: I am a criterion 3 fulfilled: false justification: I am yet another reason metadata: @@ -275,7 +284,8 @@ chapters: status: RED reason: "" results: - - criterion: "criterion is \b \f \n \r \t \n \\ \" \\n" + - hash: 0f166d8c2b022147fb9ba3986cfa0a759f92e89f573ca0b450772fa7d45f8844 + criterion: "criterion is \b \f \n \r \t \n \\ \" \\n" fulfilled: true justification: "reason is \b \f \n \r \t \n \\ \" \\n" execution: @@ -295,7 +305,8 @@ chapters: reas on results: - - criterion: |- + - hash: 6a60816cd3f443e5a97f3ca9e856721b2b2761cb85d7e20986cdce27d7826258 + criterion: |- crit erion fulfilled: true @@ -325,7 +336,8 @@ chapters: status: GREEN reason: reason results: - - criterion: criterion + - hash: 99e8f388599eec7c92eceeb021c69d838a56745bde533464742bfebb1e0ac3c2 + criterion: criterion fulfilled: true justification: |- line1 @@ -663,7 +675,8 @@ chapters: status: GREEN reason: Repository apps was fetched results: - - criterion: Repository apps can be fetched + - hash: 683e6203a448914c6cdb62c2cb28fbf13cd1d29d3c75edb30e6b84c0aedf3b8b + criterion: Repository apps can be fetched fulfilled: true justification: This app is a repository app execution: @@ -682,7 +695,8 @@ chapters: status: GREEN reason: Repository apps was fetched results: - - criterion: Repository apps can be fetched + - hash: 21394d26803c1cfe7b2e837d0988f0c72d57f232ebf7df00fcfa12fc17f0ff87 + criterion: Repository apps can be fetched fulfilled: true justification: This app is a repository app execution: @@ -709,10 +723,12 @@ chapters: status: RED reason: test results: - - criterion: FFixed RTC ticket with ID 1588653 must be risk assessed + - hash: 70c10c9354636f7349de490760d7ef587b1e78e26439821e0fafe9481df64831 + criterion: FFixed RTC ticket with ID 1588653 must be risk assessed fulfilled: false justification: Please type the appropriate risk assessment for RTC Ticket with ID 1588653. metadata: + Defect Occurrence: "Always" boolean: "true" test-json: "{\"key\":\"value\"}" Id: "1588653" @@ -720,7 +736,6 @@ chapters: Summary: "[main] after EDLminidump SoC bootup stuck" Creation Date: "2022-11-08T09:51:00" Modified Date: "2023-06-09T14:05:00" - Defect Occurrence: "Always" execution: logs: - '{"result": {"criterion": "FFixed RTC ticket with ID 1588653 must be risk assessed", "fulfilled": false, "justification": "Please type the appropriate risk assessment for RTC Ticket with ID 1588653.", "metadata": {"Id": 1588653, "Filed Against": "Platform_General", "Summary": "[main] after EDLminidump SoC bootup stuck", "Creation Date": "2022-11-08T09:51:00", "Modified Date": "2023-06-09T14:05:00", "Defect Occurrence": "Always", "boolean": true, "test-json": {"key": "value"}}}}' diff --git a/cmd/cli/exec/testdata/v2/configuration/qg-result.golden b/cmd/cli/exec/testdata/v2/configuration/qg-result.golden index b7ca18d..a53701d 100644 --- a/cmd/cli/exec/testdata/v2/configuration/qg-result.golden +++ b/cmd/cli/exec/testdata/v2/configuration/qg-result.golden @@ -77,15 +77,15 @@ chapters: - fetch1 - fetch2 logs: - - "{\"source\":\"stdout\",\"text\":\"1_1_1\"}" - - "{\"source\":\"stdout\",\"text\":\"evidences/1_1_1/steps/transform2/files\"}" - - "{\"source\":\"stdout\",\"text\":\"evidences/1_1_1/steps/transform2/data.json\"}" - - "{\"source\":\"stdout\",\"text\":\"Removing ' from evidences/1_1_1/steps/fetch1/files' to sanitize for ls\"}" - - "{\"source\":\"stdout\",\"text\":\"Reading from evidences/1_1_1/steps/fetch1/files\"}" - - "{\"source\":\"stdout\",\"text\":\"fetch1.txt\"}" - - "{\"source\":\"stdout\",\"text\":\"Removing ' from 'evidences/1_1_1/steps/fetch2/files to sanitize for ls\"}" - - "{\"source\":\"stdout\",\"text\":\"Reading from evidences/1_1_1/steps/fetch2/files\"}" - - "{\"source\":\"stdout\",\"text\":\"fetch2.txt\"}" + - '{"source":"stdout","text":"1_1_1"}' + - '{"source":"stdout","text":"evidences/1_1_1/steps/transform2/files"}' + - '{"source":"stdout","text":"evidences/1_1_1/steps/transform2/data.json"}' + - '{"source":"stdout","text":"Removing '' from evidences/1_1_1/steps/fetch1/files'' to sanitize for ls"}' + - '{"source":"stdout","text":"Reading from evidences/1_1_1/steps/fetch1/files"}' + - '{"source":"stdout","text":"fetch1.txt"}' + - '{"source":"stdout","text":"Removing '' from ''evidences/1_1_1/steps/fetch2/files to sanitize for ls"}' + - '{"source":"stdout","text":"Reading from evidences/1_1_1/steps/fetch2/files"}' + - '{"source":"stdout","text":"fetch2.txt"}' configFiles: [] outputDir: evidences/1_1_1/steps/transform2/files resultFile: evidences/1_1_1/steps/transform2/data.json @@ -97,21 +97,22 @@ chapters: status: GREEN reason: This is a reason results: - - criterion: I am a criterion + - hash: f68d82660400fc15fdd64a7e60fee5a9ab40effbe7d177c50039d8442a438c8c + criterion: I am a criterion fulfilled: false justification: I am the justification logs: - - "{\"source\":\"stdout\",\"text\":\"evidences/1_1_1/steps/transform1/data.json':'evidences/1_1_1/steps/transform2/data.json\"}" - - "{\"source\":\"stdout\",\"text\":\"evidences/1_1_1/evaluation/result.json\"}" - - "{\"source\":\"stdout\",\"text\":\"Removing ' from evidences/1_1_1/steps/transform1/data.json' to sanitize for cat\"}" - - "{\"source\":\"stdout\",\"text\":\"Reading from evidences/1_1_1/steps/transform1/data.json\"}" - - "{\"source\":\"stdout\",\"text\":\"result2\"}" - - "{\"source\":\"stdout\",\"text\":\"Removing ' from 'evidences/1_1_1/steps/transform2/data.json to sanitize for cat\"}" - - "{\"source\":\"stdout\",\"text\":\"Reading from evidences/1_1_1/steps/transform2/data.json\"}" - - "{\"source\":\"stdout\",\"text\":\"result2\"}" - - "{\"source\":\"stdout\",\"json\":{\"status\":\"GREEN\"}}" - - "{\"source\":\"stdout\",\"json\":{\"reason\":\"This is a reason\"}}" - - "{\"source\":\"stdout\",\"json\":{\"result\":{\"criterion\":\"I am a criterion\",\"fulfilled\":false,\"justification\":\"I am the justification\"}}}" + - '{"source":"stdout","text":"evidences/1_1_1/steps/transform1/data.json'':''evidences/1_1_1/steps/transform2/data.json"}' + - '{"source":"stdout","text":"evidences/1_1_1/evaluation/result.json"}' + - '{"source":"stdout","text":"Removing '' from evidences/1_1_1/steps/transform1/data.json'' to sanitize for cat"}' + - '{"source":"stdout","text":"Reading from evidences/1_1_1/steps/transform1/data.json"}' + - '{"source":"stdout","text":"result2"}' + - '{"source":"stdout","text":"Removing '' from ''evidences/1_1_1/steps/transform2/data.json to sanitize for cat"}' + - '{"source":"stdout","text":"Reading from evidences/1_1_1/steps/transform2/data.json"}' + - '{"source":"stdout","text":"result2"}' + - '{"source":"stdout","json":{"status":"GREEN"}}' + - '{"source":"stdout","json":{"reason":"This is a reason"}}' + - '{"source":"stdout","json":{"result":{"criterion":"I am a criterion","fulfilled":false,"justification":"I am the justification"}}}' configFiles: - additional-config.yaml "2": @@ -191,7 +192,8 @@ chapters: status: GREEN reason: Some reason results: - - criterion: I am a criterion + - hash: 6cd07f489428ea6310abf447520102f4bfaabeff04388ac6d99353b5b2b81c26 + criterion: I am a criterion fulfilled: false justification: I am the justification logs: @@ -208,7 +210,8 @@ chapters: status: YELLOW reason: Some reason results: - - criterion: I am a criterion + - hash: 54b5b05f4b4f0e0569560c2c3e56489c7a0db808cad858f5a6bf32221171f092 + criterion: I am a criterion fulfilled: false justification: I am the justification logs: @@ -225,7 +228,8 @@ chapters: status: RED reason: Some reason results: - - criterion: I am a criterion + - hash: f1357057327bf9a408cd9f1de916e87a1071a393f56c21de8037e7f918276cb6 + criterion: I am a criterion fulfilled: false justification: I am the justification logs: @@ -242,7 +246,8 @@ chapters: status: ERROR reason: 'autopilot ''status-provider'' provided an invalid ''status'': ''UNKNOWN''' results: - - criterion: I am a criterion + - hash: 5d56c0842cdd4700c8e2de80154a3da9a0ecf3cb7c5b36c2433af6a0b71eb17a + criterion: I am a criterion fulfilled: false justification: I am the justification logs: @@ -259,7 +264,8 @@ chapters: status: ERROR reason: 'autopilot ''status-provider'' provided an invalid ''status'': ''''' results: - - criterion: I am a criterion + - hash: e34c4ba0df60f5c24dd705626a99b65cd78d800f1d058fd39144a0b64c471b06 + criterion: I am a criterion fulfilled: false justification: I am the justification logs: @@ -288,19 +294,22 @@ chapters: status: GREEN reason: This is a reason results: - - criterion: I am a criterion + - hash: 80c747e328ef8057a6edae59e9623d20477c8f461d417bdeb8cc40b2c7159d57 + criterion: I am a criterion fulfilled: false justification: I am the reason - - criterion: I am a criterion 2 + - hash: 69da4b57a997033bedc707c613a594e08fca6002c79fe88423750227145da901 + criterion: I am a criterion 2 fulfilled: false justification: I am another reason - - criterion: I am a criterion 3 + - hash: 2f391bf70bd76a26aee380152d91fb0954ea6fe8005c7d7c7d2a8f84a7dac4aa + criterion: I am a criterion 3 fulfilled: false justification: I am yet another reason metadata: + severity: "I am a severity" customer: "I am customer in metadata" package: "I am a package" - severity: "I am a severity" logs: - '{"source":"stdout","json":{"result":{"criterion":"I am a criterion","fulfilled":false,"justification":"I am the reason"}}}' - '{"source":"stdout","json":{"result":{"criterion":"I am a criterion 2","fulfilled":false,"justification":"I am another reason"}}}' @@ -316,7 +325,8 @@ chapters: status: RED reason: "" results: - - criterion: "criterion is \b \f \n \r \t \n \\ \" \\n" + - hash: 44a98ada74924c6239f0d6696e80e9a1d5e1e618711a4cf1df91b2d65eea205f + criterion: "criterion is \b \f \n \r \t \n \\ \" \\n" fulfilled: true justification: "reason is \b \f \n \r \t \n \\ \" \\n" logs: @@ -334,7 +344,8 @@ chapters: reas on results: - - criterion: |- + - hash: ccbfa89b5f6ff9ef52b44e3921c91b0f83b2129a7cf58d6c988f6c128b8bbe0c + criterion: |- crit erion fulfilled: true @@ -357,7 +368,8 @@ chapters: status: GREEN reason: reason results: - - criterion: criterion + - hash: 6d74470ef4ae78a5e786c910b817915594e598003cfade40c7b18a4c765d72a3 + criterion: criterion fulfilled: true justification: |- line1 @@ -657,7 +669,8 @@ chapters: status: GREEN reason: Repository apps was fetched results: - - criterion: Repository apps can be fetched + - hash: 709876674ac30045358a9533393fd333fe6cb0aeb31f72b08c95cdb67ac4633c + criterion: Repository apps can be fetched fulfilled: true justification: This app is a repository app logs: @@ -674,7 +687,8 @@ chapters: status: GREEN reason: Repository apps was fetched results: - - criterion: Repository apps can be fetched + - hash: 5c4e98d57c22f8217688c53c222e29b65991a1206ec549f7f2b240e453091ab0 + criterion: Repository apps can be fetched fulfilled: true justification: This app is a repository app logs: @@ -699,7 +713,8 @@ chapters: status: RED reason: test results: - - criterion: FFixed RTC ticket with ID 1588653 must be risk assessed + - hash: 95774b4ca2f998885b0847ee7cdf3146aa82293a7ef49e032131dd68fe57463d + criterion: FFixed RTC ticket with ID 1588653 must be risk assessed fulfilled: false justification: Please type the appropriate risk assessment for RTC Ticket with ID 1588653. metadata: diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 716b601..46a6902 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "strings" @@ -9,6 +10,7 @@ import ( "github.com/B-S-F/onyx/cmd/cli/schema" "github.com/B-S-F/onyx/pkg/helper" "github.com/B-S-F/onyx/pkg/logger" + "github.com/B-S-F/onyx/pkg/v2/model" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -50,6 +52,7 @@ func initFlags(cmd *cobra.Command) { cmd.AddCommand(exec.ExecCommand()) cmd.AddCommand(migrate.MigrateCommand()) cmd.AddCommand(schema.SchemaCommand()) + cmd.SilenceErrors = true } func Execute(cmd *cobra.Command) { @@ -64,6 +67,12 @@ func Execute(cmd *cobra.Command) { } } if err := cmd.Execute(); err != nil { + var userErr model.UserError + if errors.As(err, &userErr) { + log.Warn(userErr.Reason()) + os.Exit(1) + } + log.Error(err.Error()) os.Exit(1) } diff --git a/cmd/cli/schema/testdata/result-schema.golden b/cmd/cli/schema/testdata/result-schema.golden index d19d040..793b77e 100644 --- a/cmd/cli/schema/testdata/result-schema.golden +++ b/cmd/cli/schema/testdata/result-schema.golden @@ -5,6 +5,10 @@ "$defs": { "AutopilotResult": { "properties": { + "hash": { + "type": "string", + "description": "Unique identifier for the Result, created from the Chapter, Requirement, Check, Criterion, and Justification.\nExample \"9319a093d48e7488ef34cd74ccfe5e2f23a00b32eede2ba30d39676f2029a528\"" + }, "criterion": { "type": "string", "description": "Criterion that was evaluated by the autopilot\nExample \"My Criterion\"" @@ -25,6 +29,7 @@ "additionalProperties": false, "type": "object", "required": [ + "hash", "criterion", "fulfilled", "justification" From 6c7cc5e93e2f701cb729177c6247dbd4a0a85ba7 Mon Sep 17 00:00:00 2001 From: "bsf-sync-bot[bot]" <180526808+bsf-sync-bot[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 05:59:49 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'internal/'?= =?UTF-8?q?=20with=20remote=20'internal/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/onyx/common/common.go | 3 +- internal/onyx/exec/exec.go | 51 ++++-- internal/onyx/exec/exec_test.go | 171 +++++++++++++++----- internal/onyx/exec/initialize_repository.go | 3 +- 4 files changed, 173 insertions(+), 55 deletions(-) diff --git a/internal/onyx/common/common.go b/internal/onyx/common/common.go index 0a5c91c..24ff9bc 100644 --- a/internal/onyx/common/common.go +++ b/internal/onyx/common/common.go @@ -10,6 +10,7 @@ import ( v1 "github.com/B-S-F/onyx/pkg/configuration/versions/v1" "github.com/B-S-F/onyx/pkg/logger" v2 "github.com/B-S-F/onyx/pkg/v2/config" + "github.com/B-S-F/onyx/pkg/v2/model" "github.com/pkg/errors" yaml "gopkg.in/yaml.v3" ) @@ -43,7 +44,7 @@ func (c *ConfigCreatorImpl) New(version string, content []byte) (interface{}, er case "v2": return v2.New(content) default: - return nil, fmt.Errorf("version %s not supported", version) + return nil, model.NewUserErr(fmt.Errorf("version %s not supported", version), "invalid config file version") } } diff --git a/internal/onyx/exec/exec.go b/internal/onyx/exec/exec.go index 2fade91..d92236c 100644 --- a/internal/onyx/exec/exec.go +++ b/internal/onyx/exec/exec.go @@ -67,10 +67,11 @@ type exec struct { transformer []transformer.Transformer transformerV2 []transformerV2.Transformer logger logger.Logger + userLogger logger.Logger execParams parameter.ExecutionParameter } -func newExec(execParams parameter.ExecutionParameter) *exec { +func newExec(execParams parameter.ExecutionParameter, userLogger logger.Logger) *exec { itemEngine := item.NewEngine(ROOT_WORK_DIRECTORY, execParams.Strict, execParams.CheckTimeout) finalizeEngine := finalize.NewEngine(ROOT_WORK_DIRECTORY, execParams.CheckTimeout) resultEngine := result.NewDefaultEngine(ROOT_WORK_DIRECTORY) @@ -87,6 +88,7 @@ func newExec(execParams parameter.ExecutionParameter) *exec { finalizerEngine: finalizeEngine, transformer: transformer, logger: logger.Get(), + userLogger: userLogger, execParams: execParams, transformerV2: []transformerV2.Transformer{transformerV2.NewAutopilotSkipper(execParams), transformerV2.NewConfigsLoader(ROOT_WORK_DIRECTORY)}, } @@ -103,8 +105,15 @@ func Exec(execParams parameter.ExecutionParameter) error { Secrets: secrets, File: filepath.Join(ROOT_WORK_DIRECTORY, "onyx.log"), }) // this logger prevents secrets from being logged + + userLogger := logger.NewCommon(logger.Settings{ + Secrets: secrets, + File: filepath.Join(ROOT_WORK_DIRECTORY, "usererr.log"), + DisableConsoleLogging: true, + }) // this logger prevents secrets from being logged + logger.Set(defaultLogger) - e := newExec(execParams) + e := newExec(execParams, userLogger) err = e.prepareRootFolder(ROOT_WORK_DIRECTORY, execParams.InputFolder) if err != nil { return errors.Wrap(err, "error setting up root directory") @@ -114,13 +123,21 @@ func Exec(execParams parameter.ExecutionParameter) error { e.logger.Info("parsing config file") cfg, version, err := createConfig(configFile, e.configCreator) if err != nil { - return errors.Wrap(err, "error creating config") + var userErr model.UserError + if errors.As(err, &userErr) { + e.userLogger.Errorf("error creating config: %s", userErr.Error()) + } + return err } e.logger.Info("validating config file") err = validateSchema(e.schema, cfg, configFile) if err != nil { - return errors.Wrap(err, "error validating schema") + var userErr model.UserError + if errors.As(err, &userErr) { + e.userLogger.Errorf("schema validation of config file failed: %s", userErr.Error()) + } + return err } switch version { @@ -143,6 +160,10 @@ func Exec(execParams parameter.ExecutionParameter) error { ep, err := e.initPlanV2(configV2, vars, secrets) if err != nil { + var userErr model.UserError + if errors.As(err, &userErr) { + e.userLogger.Errorf("initialization of execution plan failed: %s", userErr.Error()) + } return err } @@ -181,7 +202,7 @@ func (e *exec) execPlanV1(ep *configuration.ExecutionPlan, vars map[string]strin func (e *exec) execPlanV2(ep *model.ExecutionPlan, secrets map[string]string) error { e.logger.Info("[ RUN EXECUTION PLAN ]") - orchestrator := orchestrator.New(ROOT_WORK_DIRECTORY, e.execParams.Strict, e.execParams.CheckTimeout, e.logger) + orchestrator := orchestrator.New(ROOT_WORK_DIRECTORY, e.execParams.Strict, e.execParams.CheckTimeout, e.logger, e.userLogger) runResult, err := orchestrator.Run(ep.ManualChecks, ep.AutopilotChecks, ep.Env, secrets) if err != nil { return errors.Wrap(err, "error executing execution plan") @@ -284,7 +305,7 @@ func (e *exec) initPlanV1(config configuration.Config, vars, secrets map[string] func (e *exec) initPlanV2(config *v2.Config, vars, secrets map[string]string) (*model.ExecutionPlan, error) { e.logger.Info("executing custom config validation") if err := v2.Validate(config); err != nil { - return nil, errors.Wrap(err, "custom config validation failed") + return nil, err } ep, err := config.CreateExecutionPlan() @@ -293,10 +314,7 @@ func (e *exec) initPlanV2(config *v2.Config, vars, secrets map[string]string) (* } e.logger.Info("replacing parameters in execution plan") - err = replacerV2.Run(ep, vars, secrets, replacerV2.Initial) - if err != nil { - return nil, errors.Wrap(err, "error replacing parameters in execution plan") - } + replacerV2.Run(ep, vars, secrets, replacerV2.Initial, e.userLogger) e.logger.Info("transform execution plan") for _, transformer := range e.transformerV2 { @@ -307,21 +325,22 @@ func (e *exec) initPlanV2(config *v2.Config, vars, secrets map[string]string) (* } e.logger.Info("replacing config file parameters in execution plan") - err = replacerV2.Run(ep, vars, secrets, replacerV2.ConfigValues) - if err != nil { - return nil, errors.Wrap(err, "error replacing config file parameters second time in execution plan") - } + replacerV2.Run(ep, vars, secrets, replacerV2.ConfigValues, e.userLogger) e.logger.Info("initializing repositories") repositories, err := initializeRepository(ep.Repositories) if err != nil { - return nil, errors.Wrap(err, "error parsing repositories") + var userErr model.UserError + if errors.As(err, &userErr) { + e.userLogger.Errorf("error parsing repositories: %s", userErr.Error()) + } + return nil, err } e.logger.Info("initializing app registry") registry, err := registryV2.Initialize(ep, repositories) if err != nil { - return nil, errors.Wrap(err, "error initializing app registry") + return nil, err } e.logger.Info(registry.Stats()) diff --git a/internal/onyx/exec/exec_test.go b/internal/onyx/exec/exec_test.go index 4bd8998..3f99c4e 100644 --- a/internal/onyx/exec/exec_test.go +++ b/internal/onyx/exec/exec_test.go @@ -5,6 +5,7 @@ package exec import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -394,7 +395,7 @@ func TestExecErrors(t *testing.T) { VarsName: ".vars", SecretsName: ".secrets", }, - want: errors.New("custom config validation failed: invalid step id invalÜdID: ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), + want: errors.New("config validation failed: invalid step id 'invalÜdID': ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), prep: func(t *testing.T, inputDir string) { qgFile := filepath.Join(inputDir, "qg-config.yaml") @@ -424,7 +425,7 @@ func TestExecErrors(t *testing.T) { VarsName: ".vars", SecretsName: ".secrets", }, - want: errors.New("error validating schema: config data does not match schema"), + want: errors.New("config data does not match schema"), prep: func(t *testing.T, inputDir string) { qgFile := filepath.Join(inputDir, "qg-config.yaml") @@ -452,7 +453,7 @@ header: VarsName: ".vars", SecretsName: ".secrets", }, - want: errors.New("error creating config: version v1337 not supported"), + want: errors.New("invalid config file version: version v1337 not supported"), prep: func(t *testing.T, inputDir string) { qgFile := filepath.Join(inputDir, "qg-config.yaml") @@ -567,6 +568,136 @@ header: } } +func TestExecUserErrors(t *testing.T) { + writeTestFiles := func(t *testing.T, inputDir string, cfg *config.Config) { + qgFile := filepath.Join(inputDir, "qg-config.yaml") + cfgContent, err := yaml.Marshal(cfg) + require.NoError(t, err) + + err = os.WriteFile(qgFile, cfgContent, 0644) + require.NoError(t, err) + + varsFile := filepath.Join(inputDir, ".vars") + err = os.WriteFile(varsFile, nil, 0644) + require.NoError(t, err) + + secretsFile := filepath.Join(inputDir, ".secrets") + err = os.WriteFile(secretsFile, nil, 0644) + require.NoError(t, err) + } + + tests := map[string]struct { + execParams parameter.ExecutionParameter + want error + prep func(t *testing.T, inputDir string) + }{ + "should_write_user_error_when_custom_validation_fails": { + execParams: parameter.ExecutionParameter{ + ConfigName: "qg-config.yaml", + VarsName: ".vars", + SecretsName: ".secrets", + }, + want: errors.New("config validation failed: invalid step id 'invalÜdID': ID contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed."), + prep: func(t *testing.T, inputDir string) { + cfg := simpleConfigV2() + a := cfg.Autopilots["checker"] + a.Steps = []config.Step{{ID: "invalÜdID"}} + cfg.Autopilots["checker"] = a + + writeTestFiles(t, inputDir, cfg) + }, + }, + "should_write_user_error_when_invalid_config_version_provided": { + execParams: parameter.ExecutionParameter{ + ConfigName: "qg-config.yaml", + VarsName: ".vars", + SecretsName: ".secrets", + }, + want: errors.New("invalid config file version: version v1337 not supported"), + prep: func(t *testing.T, inputDir string) { + cfg := simpleConfigV2() + cfg.Metadata.Version = "v1337" + + writeTestFiles(t, inputDir, cfg) + }, + }, + "should_write_user_error_when_invalid_repository": { + execParams: parameter.ExecutionParameter{ + ConfigName: "qg-config.yaml", + VarsName: ".vars", + SecretsName: ".secrets", + }, + want: errors.New("invalid repositories: error initializing repositories: [error creating repository test: failed to create config: missing 'url' in config]"), + prep: func(t *testing.T, inputDir string) { + cfg := simpleConfigV2() + cfg.Repositories = []config.Repository{ + {Name: "test", Type: "curl", Config: nil}, + } + + writeTestFiles(t, inputDir, cfg) + }, + }, + "should_write_user_error_when_fails_to_download_app": { + execParams: parameter.ExecutionParameter{ + ConfigName: "qg-config.yaml", + VarsName: ".vars", + SecretsName: ".secrets", + }, + want: errors.New("failed to download app: app mytestapp@1.0.0 could not be downloaded from any repository"), + prep: func(t *testing.T, inputDir string) { + cfg := simpleConfigV2() + a := cfg.Autopilots["checker"] + a.Apps = []string{"mytestapp@1.0.0"} + cfg.Autopilots["checker"] = a + + writeTestFiles(t, inputDir, cfg) + }, + }, + "should_write_user_error_when_schema_validation_fails": { + execParams: parameter.ExecutionParameter{ + ConfigName: "qg-config.yaml", + VarsName: ".vars", + SecretsName: ".secrets", + }, + want: errors.New("config data does not match schema:"), + prep: func(t *testing.T, inputDir string) { + cfg := simpleConfigV2() + a := cfg.Autopilots["checker"] + a.Evaluate.Run = "" + cfg.Autopilots["checker"] = a + + writeTestFiles(t, inputDir, cfg) + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tempDir := t.TempDir() + OverrideDirectoriesForTest(tempDir + "/exec") + + // t.TempDir default input dir + if tt.execParams.InputFolder == "" { + tt.execParams.InputFolder = tempDir + } + if tt.execParams.OutputFolder == "" { + tt.execParams.OutputFolder = tempDir + } + + tt.prep(t, tt.execParams.InputFolder) + + err := Exec(tt.execParams) + require.Equal(t, err != nil, tt.want != nil) + if tt.want != nil { + assert.ErrorContains(t, err, tt.want.Error()) + + userErrLog, err := os.ReadFile(fmt.Sprintf("%s/exec/evidences/usererr.log", tempDir)) + require.NoError(t, err) + assert.Contains(t, string(userErrLog), tt.want.Error()) + } + }) + } +} + func TestExecBackwardsCompatibilityQGConfigV1(t *testing.T) { tmpDir := t.TempDir() cfgFilepath := filepath.Join(tmpDir, "qg-config-v1.yaml") @@ -650,40 +781,6 @@ func TestExecBackwardsCompatibilityQGConfigV1(t *testing.T) { assert.Equal(t, result.Chapters["1"].Requirements["1"].Checks["1"].Evaluation.Outputs["output2"], "out2") } -func TestExecQGConfigV2(t *testing.T) { - // TODO - t.Skip("execution of qg-config v2 needs to be implemented") - - tmpDir := t.TempDir() - cfgFilepath := filepath.Join(tmpDir, "qg-config-v2.yaml") - - cfg, err := yaml.Marshal(simpleConfigV2()) - require.NoError(t, err) - - err = os.WriteFile(cfgFilepath, cfg, 0644) - require.NoError(t, err) - - varsFilepath := filepath.Join(tmpDir, ".vars") - err = os.WriteFile(varsFilepath, nil, 0644) - require.NoError(t, err) - - secretsFilepath := filepath.Join(tmpDir, ".secrets") - err = os.WriteFile(secretsFilepath, nil, 0644) - require.NoError(t, err) - - execParams := parameter.ExecutionParameter{ - ConfigName: "qg-config-v2.yaml", - InputFolder: tmpDir, - VarsName: ".vars", - SecretsName: ".secrets", - } - - err = Exec(execParams) - assert.NoError(t, err) - - // TODO: assert result -} - func simpleResultV1() *resultv1.Result { return &resultv1.Result{ Metadata: resultv1.Metadata{Version: "v1"}, diff --git a/internal/onyx/exec/initialize_repository.go b/internal/onyx/exec/initialize_repository.go index 5088d50..88828bc 100644 --- a/internal/onyx/exec/initialize_repository.go +++ b/internal/onyx/exec/initialize_repository.go @@ -7,6 +7,7 @@ import ( "github.com/B-S-F/onyx/pkg/repository" "github.com/B-S-F/onyx/pkg/repository/types/azblob" "github.com/B-S-F/onyx/pkg/repository/types/curl" + "github.com/B-S-F/onyx/pkg/v2/model" ) func initializeRepository(repositories []configuration.Repository) ([]repository.Repository, error) { @@ -25,7 +26,7 @@ func initializeRepository(repositories []configuration.Repository) ([]repository registryRepositories = append(registryRepositories, repository) } if len(parseErrs) > 0 { - return nil, fmt.Errorf("error initializing repositories: %v", parseErrs) + return nil, model.NewUserErr(fmt.Errorf("error initializing repositories: %v", parseErrs), "invalid repositories") } return registryRepositories, nil } From e9cf87b97f2e9241406551ee11b6ea5804447052 Mon Sep 17 00:00:00 2001 From: "bsf-sync-bot[bot]" <180526808+bsf-sync-bot[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 05:59:52 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'go.mod'=20?= =?UTF-8?q?with=20remote=20'go.mod'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index 88401d5..89a8f5d 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 github.com/chigopher/pathlib v0.19.1 github.com/invopop/yaml v0.3.1 + github.com/netflix/go-iomux v1.0.0 github.com/pkg/errors v0.9.1 github.com/spf13/afero v1.11.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1