From c8cc7b2448ac45c3d6eeff5f68479dd5b18f6d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20L=2E=20Hansen?= Date: Thu, 27 Apr 2023 12:32:48 +0800 Subject: [PATCH] Workflow commands (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes a simple framework for supporting workflow commands. Fully implements `::add-mask::`, `::debug::`, and `::stop-commands::`. Addresses #148 Co-authored-by: Jason Song Reviewed-on: https://gitea.com/gitea/act_runner/pulls/149 Reviewed-by: Jason Song Co-authored-by: Søren L. Hansen Co-committed-by: Søren L. Hansen --- internal/pkg/report/reporter.go | 88 +++++++++++++++- internal/pkg/report/reporter_test.go | 148 +++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 internal/pkg/report/reporter_test.go diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index 3a1a75c..10aa7e6 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -6,6 +6,7 @@ package report import ( "context" "fmt" + "regexp" "strings" "sync" "time" @@ -31,10 +32,14 @@ type Reporter struct { logOffset int logRows []*runnerv1.LogRow logReplacer *strings.Replacer + oldnew []string state *runnerv1.TaskState stateMu sync.RWMutex outputs sync.Map + + debugOutputEnabled bool + stopCommandEndToken string } func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task) *Reporter { @@ -46,15 +51,22 @@ func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.C oldnew = append(oldnew, v, "***") } - return &Reporter{ + rv := &Reporter{ ctx: ctx, cancel: cancel, client: client, + oldnew: oldnew, logReplacer: strings.NewReplacer(oldnew...), state: &runnerv1.TaskState{ Id: task.Id, }, } + + if task.Secrets["ACTIONS_STEP_DEBUG"] == "true" { + rv.debugOutputEnabled = true + } + + return rv } func (r *Reporter) ResetSteps(l int) { @@ -71,6 +83,13 @@ func (r *Reporter) Levels() []log.Level { return log.AllLevels } +func appendIfNotNil[T any](s []*T, v *T) []*T { + if v != nil { + return append(s, v) + } + return s +} + func (r *Reporter) Fire(entry *log.Entry) error { r.stateMu.Lock() defer r.stateMu.Unlock() @@ -97,7 +116,7 @@ func (r *Reporter) Fire(entry *log.Entry) error { } } if !r.duringSteps() { - r.logRows = append(r.logRows, r.parseLogRow(entry)) + r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } return nil } @@ -110,7 +129,7 @@ func (r *Reporter) Fire(entry *log.Entry) error { } if step == nil { if !r.duringSteps() { - r.logRows = append(r.logRows, r.parseLogRow(entry)) + r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } return nil } @@ -124,10 +143,10 @@ func (r *Reporter) Fire(entry *log.Entry) error { step.LogIndex = int64(r.logOffset + len(r.logRows)) } step.LogLength++ - r.logRows = append(r.logRows, r.parseLogRow(entry)) + r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } } else if !r.duringSteps() { - r.logRows = append(r.logRows, r.parseLogRow(entry)) + r.logRows = appendIfNotNil(r.logRows, r.parseLogRow(entry)) } if v, ok := entry.Data["stepResult"]; ok { if stepResult, ok := r.parseResult(v); ok { @@ -338,11 +357,70 @@ func (r *Reporter) parseResult(result interface{}) (runnerv1.Result, bool) { return ret, ok } +var cmdRegex = regexp.MustCompile(`^::([^ :]+)( .*)?::(.*)$`) + +func (r *Reporter) handleCommand(originalContent, command, parameters, value string) *string { + if r.stopCommandEndToken != "" && command != r.stopCommandEndToken { + return &originalContent + } + + switch command { + case "add-mask": + r.addMask(value) + return nil + case "debug": + if r.debugOutputEnabled { + return &value + } + return nil + + case "notice": + // Not implemented yet, so just return the original content. + return &originalContent + case "warning": + // Not implemented yet, so just return the original content. + return &originalContent + case "error": + // Not implemented yet, so just return the original content. + return &originalContent + case "group": + // Returning the original content, because I think the frontend + // will use it when rendering the output. + return &originalContent + case "endgroup": + // Ditto + return &originalContent + case "stop-commands": + r.stopCommandEndToken = value + return nil + case r.stopCommandEndToken: + r.stopCommandEndToken = "" + return nil + } + return &originalContent +} + func (r *Reporter) parseLogRow(entry *log.Entry) *runnerv1.LogRow { content := strings.TrimRightFunc(entry.Message, func(r rune) bool { return r == '\r' || r == '\n' }) + + matches := cmdRegex.FindStringSubmatch(content) + if matches != nil { + if output := r.handleCommand(content, matches[1], matches[2], matches[3]); output != nil { + content = *output + } else { + return nil + } + } + content = r.logReplacer.Replace(content) + return &runnerv1.LogRow{ Time: timestamppb.New(entry.Time), Content: content, } } + +func (r *Reporter) addMask(msg string) { + r.oldnew = append(r.oldnew, msg, "***") + r.logReplacer = strings.NewReplacer(r.oldnew...) +} diff --git a/internal/pkg/report/reporter_test.go b/internal/pkg/report/reporter_test.go new file mode 100644 index 0000000..6682a33 --- /dev/null +++ b/internal/pkg/report/reporter_test.go @@ -0,0 +1,148 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package report + +import ( + "strings" + "testing" + + log "github.com/sirupsen/logrus" + "gotest.tools/v3/assert" +) + +func TestReporter_parseLogRow(t *testing.T) { + tests := []struct { + name string + debugOutputEnabled bool + args []string + want []string + }{ + { + "No command", false, + []string{"Hello, world!"}, + []string{"Hello, world!"}, + }, + { + "Add-mask", false, + []string{ + "foo mysecret bar", + "::add-mask::mysecret", + "foo mysecret bar", + }, + []string{ + "foo mysecret bar", + "", + "foo *** bar", + }, + }, + { + "Debug enabled", true, + []string{ + "::debug::GitHub Actions runtime token access controls", + }, + []string{ + "GitHub Actions runtime token access controls", + }, + }, + { + "Debug not enabled", false, + []string{ + "::debug::GitHub Actions runtime token access controls", + }, + []string{ + "", + }, + }, + { + "notice", false, + []string{ + "::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + []string{ + "::notice file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + }, + { + "warning", false, + []string{ + "::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + []string{ + "::warning file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + }, + { + "error", false, + []string{ + "::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + []string{ + "::error file=file.name,line=42,endLine=48,title=Cool Title::Gosh, that's not going to work", + }, + }, + { + "group", false, + []string{ + "::group::", + "::endgroup::", + }, + []string{ + "::group::", + "::endgroup::", + }, + }, + { + "stop-commands", false, + []string{ + "::add-mask::foo", + "::stop-commands::myverycoolstoptoken", + "::add-mask::bar", + "::debug::Stuff", + "myverycoolstoptoken", + "::add-mask::baz", + "::myverycoolstoptoken::", + "::add-mask::wibble", + "foo bar baz wibble", + }, + []string{ + "", + "", + "::add-mask::bar", + "::debug::Stuff", + "myverycoolstoptoken", + "::add-mask::baz", + "", + "", + "*** bar baz ***", + }, + }, + { + "unknown command", false, + []string{ + "::set-mask::foo", + }, + []string{ + "::set-mask::foo", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Reporter{ + logReplacer: strings.NewReplacer(), + debugOutputEnabled: tt.debugOutputEnabled, + } + for idx, arg := range tt.args { + rv := r.parseLogRow(&log.Entry{Message: arg}) + got := "" + + if rv != nil { + got = rv.Content + } + + assert.Equal(t, tt.want[idx], got) + } + }) + } +}