diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..c4151e2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Go Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..48fdb11 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,115 @@ +run: + timeout: 5m + go: '1.21' + +linters-settings: + govet: + enable: + - shadow + revive: + min-confidence: 0.8 + gocyclo: + min-complexity: 15 + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 2 + misspell: + locale: US + lll: + line-length: 140 + goimports: + local-prefixes: github.com/ChristopherHX/github-act-runner + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + +linters: + enable: + - bodyclose + # - depguard # Disabled for now as it's too restrictive + - dogsled + - dupl + - errcheck + - copyloopvar # replaces exportloopref for Go 1.22+ + - exhaustive + - goconst + - gocritic + - gofmt + - goimports + - revive # replaces golint + - mnd # replaces gomnd + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - noctx + - nolintlint + - rowserrcheck + - staticcheck + - stylecheck + - typecheck + - unconvert + - unparam + - unused + - whitespace + +issues: + exclude-rules: + - path: _test\.go + linters: + - mnd + - gocritic + - gosec + - path: main\.go + linters: + - mnd + - path: protocol/ + linters: + - revive # Protocol has many generated-like structs + - text: "weak cryptographic primitive" + linters: + - gosec + - text: "Use of weak random number generator" + linters: + - gosec + - text: "at least one file in a package should have a package comment" + linters: + - stylecheck + - text: "should have a package comment" + linters: + - stylecheck + - text: "package-comments: should have a package comment" + linters: + - revive + - text: "exported function .* should have comment or be unexported" + linters: + - revive + - text: "exported method .* should have comment or be unexported" + linters: + - revive + - text: "exported type .* should have comment or be unexported" + linters: + - revive + - text: "exported var .* should have comment or be unexported" + linters: + - revive + + exclude-use-default: false + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/actionsdotnetactcompat/act_runner.go b/actionsdotnetactcompat/act_runner.go index 1099c78..8f00b06 100644 --- a/actionsdotnetactcompat/act_runner.go +++ b/actionsdotnetactcompat/act_runner.go @@ -9,8 +9,9 @@ type ActRunner struct { actionsrunner.WorkerRunnerEnvironment } -func (arunner *ActRunner) ExecWorker(run *actionsrunner.RunRunner, wc actionsrunner.WorkerContext, jobreq *protocol.AgentJobRequestMessage, src []byte) error { - if len(arunner.WorkerArgs) <= 0 { +func (arunner *ActRunner) ExecWorker(run *actionsrunner.RunRunner, wc actionsrunner.WorkerContext, + jobreq *protocol.AgentJobRequestMessage, src []byte) error { + if len(arunner.WorkerArgs) == 0 { ExecWorker(jobreq, wc) return nil } diff --git a/actionsdotnetactcompat/act_worker.go b/actionsdotnetactcompat/act_worker.go index f4ba8d2..70f0525 100644 --- a/actionsdotnetactcompat/act_worker.go +++ b/actionsdotnetactcompat/act_worker.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net/http" "os" "path/filepath" @@ -15,11 +16,6 @@ import ( "strings" "time" - "github.com/ChristopherHX/github-act-runner/actionsrunner" - rcommon "github.com/ChristopherHX/github-act-runner/common" - "github.com/ChristopherHX/github-act-runner/protocol" - "github.com/ChristopherHX/github-act-runner/protocol/launch" - "github.com/ChristopherHX/github-act-runner/protocol/logger" "github.com/google/uuid" "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common/git" @@ -29,6 +25,22 @@ import ( "github.com/rhysd/actionlint" "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" + + "github.com/ChristopherHX/github-act-runner/actionsrunner" + rcommon "github.com/ChristopherHX/github-act-runner/common" + "github.com/ChristopherHX/github-act-runner/protocol" + "github.com/ChristopherHX/github-act-runner/protocol/launch" + "github.com/ChristopherHX/github-act-runner/protocol/logger" +) + +const ( + // HTTP and redirects constants + maxRedirects = 10 + jobTimeout = 5 * time.Minute + // File permissions + directoryPermissions = 0777 + // Stage constants + mainStage = "Main" ) type ghaFormatter struct { @@ -99,7 +111,7 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) { if hasStepID { stepIDArray, _ := rawStepID.([]string) var prefix string - if hasStage && stage != "Main" { + if hasStage && stage != mainStage { prefix = stage.(string) + "-" } stepID = prefix + stepIDArray[0] @@ -122,11 +134,11 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) { f.logger.MoveNextExt(false) te := protocol.CreateTimelineEntry(f.logger.TimelineRecords.Value[0].ID, stepID, stage.(string)+" "+stepName.(string)) te.Order = f.logger.TimelineRecords.Value[f.logger.CurrentRecord-1].Order + 1 - f.logger.Insert(te) + f.logger.Insert(&te) if cur := f.logger.Current(); cur != nil { cur.Start() } - f.logger.Update() + _ = f.logger.Update() // Ignore logger update errors } else { for { next := f.logger.MoveNext() @@ -139,12 +151,12 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) { if cur := f.logger.Current(); cur != nil { cur.Start() } - f.logger.Update() + _ = f.logger.Update() // Ignore logger update errors } } if f.rqt.MaskHints != nil { for _, v := range f.rqt.MaskHints { - if strings.ToLower(v.Type) == "regex" { + if strings.EqualFold(v.Type, "regex") { r, _ := regexp.Compile(v.Value) entry.Message = r.ReplaceAllString(entry.Message, "***") } @@ -152,7 +164,8 @@ func (f *ghaFormatter) Format(entry *logrus.Entry) ([]byte, error) { } if f.rqt.Variables != nil { for _, v := range f.rqt.Variables { - if v.IsSecret && len(v.Value) > 0 && !strings.EqualFold(v.Value, "true") && !strings.EqualFold(v.Value, "false") && !strings.EqualFold(v.Value, "0") && !strings.EqualFold(v.Value, "1") { + if v.IsSecret && v.Value != "" && !strings.EqualFold(v.Value, "true") && + !strings.EqualFold(v.Value, "false") && !strings.EqualFold(v.Value, "0") && !strings.EqualFold(v.Value, "1") { entry.Message = strings.ReplaceAll(entry.Message, v.Value, "***") } } @@ -182,25 +195,25 @@ type JobLoggerFactory struct { } func (factory *JobLoggerFactory) WithJobLogger() *logrus.Logger { - logger := logrus.New() - logger.SetOutput(factory.Logger.Out) - logger.SetLevel(factory.Logger.Level) - logger.SetFormatter(factory.Logger.Formatter) - return logger + jobLogger := logrus.New() + jobLogger.SetOutput(factory.Logger.Out) + jobLogger.SetLevel(factory.Logger.Level) + jobLogger.SetFormatter(factory.Logger.Formatter) + return jobLogger } func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerContext) { jlogger := wc.Logger() jobExecCtx := wc.JobExecCtx() - logger := logrus.New() - logger.SetOutput(jlogger) + actLogger := logrus.New() + actLogger.SetOutput(jlogger) formatter := &ghaFormatter{ rqt: rqt, logger: jlogger, ctx: jobExecCtx, } - logger.SetFormatter(formatter) - logger.Println("Initialize translating the job request to nektos/act") + actLogger.SetFormatter(formatter) + actLogger.Println("Initialize translating the job request to nektos/act") vssConnection, vssConnectionData, _ := rqt.GetConnection("SystemVssConnection") if jlogger.Connection != nil { vssConnection.Client = jlogger.Connection.Client @@ -208,7 +221,7 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon } finishJob2 := func(result string, outputs *map[string]protocol.VariableValue) { jlogger.TimelineRecords.Value[0].Complete(result) - jlogger.Logger.Close() + _ = jlogger.Logger.Close() // Ignore logger close errors jlogger.Finish() wc.FinishJob(result, outputs) } @@ -248,20 +261,21 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon env["ACTIONS_RUNTIME_URL"] = vssConnection.TenantURL env["ACTIONS_RUNTIME_TOKEN"] = vssConnection.Token - if cacheUrl, ok := vssConnectionData["CacheServerUrl"]; ok && len(cacheUrl) > 0 { - env["ACTIONS_CACHE_URL"] = cacheUrl + if cacheURL, cacheOk := vssConnectionData["CacheServerUrl"]; cacheOk && cacheURL != "" { + env["ACTIONS_CACHE_URL"] = cacheURL } - if idTokenUrl, ok := vssConnectionData["GenerateIdTokenUrl"]; ok && len(idTokenUrl) > 0 { - env["ACTIONS_ID_TOKEN_REQUEST_URL"] = idTokenUrl + if idTokenURL, idTokenOk := vssConnectionData["GenerateIdTokenUrl"]; idTokenOk && idTokenURL != "" { + env["ACTIONS_ID_TOKEN_REQUEST_URL"] = idTokenURL env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"] = vssConnection.Token } - if resultsServiceUrl, ok := vssConnectionData["ResultsServiceUrl"]; ok && len(resultsServiceUrl) > 0 { - env["ACTIONS_RESULTS_URL"] = resultsServiceUrl + if resultsServiceURL, resultsOk := vssConnectionData["ResultsServiceUrl"]; resultsOk && resultsServiceURL != "" { + env["ACTIONS_RESULTS_URL"] = resultsServiceURL } - if pipelinesServiceUrl, ok := vssConnectionData["PipelinesServiceUrl"]; ok && len(pipelinesServiceUrl) > 0 { - env["ACTIONS_RUNTIME_URL"] = pipelinesServiceUrl + if pipelinesServiceURL, pipelinesOk := vssConnectionData["PipelinesServiceUrl"]; pipelinesOk && pipelinesServiceURL != "" { + env["ACTIONS_RUNTIME_URL"] = pipelinesServiceURL } - if uses_cache_service_v2, ok := rqt.Variables["actions_uses_cache_service_v2"]; ok && strings.EqualFold(uses_cache_service_v2.Value, "True") { + if usesCacheServiceV2, cacheV2Ok := rqt.Variables["actions_uses_cache_service_v2"]; cacheV2Ok && + strings.EqualFold(usesCacheServiceV2.Value, "True") { env["ACTIONS_CACHE_SERVICE_V2"] = "True" // bool.TrueString } @@ -275,9 +289,9 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon failInitJob(err.Error()) return } - actions_step_debug := false - if sd, ok := rqt.Variables["ACTIONS_STEP_DEBUG"]; ok && (sd.Value == "true" || sd.Value == "1") { - actions_step_debug = true + actionsStepDebug := false + if sd, debugOk := rqt.Variables["ACTIONS_STEP_DEBUG"]; debugOk && (sd.Value == "true" || sd.Value == "1") { + actionsStepDebug = true } rawContainer := yaml.Node{} if rqt.JobContainer != nil { @@ -294,12 +308,12 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon e, _ := json.Marshal(githubCtxMap["event"]) payload = string(e) } - unix_host_prefix := "unix://" + unixHostPrefix := "unix://" // derive from DOCKER_HOST or use custom value from DOCKER_HOST_MOUNT_PATH - if docker_host_mount_path, ok := os.LookupEnv("DOCKER_HOST_MOUNT_PATH"); ok { - runnerConfig.ContainerDaemonSocket = docker_host_mount_path - } else if docker_host, ok := os.LookupEnv("DOCKER_HOST"); ok && strings.HasPrefix(strings.ToLower(docker_host), unix_host_prefix) { - runnerConfig.ContainerDaemonSocket = docker_host[len(unix_host_prefix):] + if dockerHostMountPath, ok := os.LookupEnv("DOCKER_HOST_MOUNT_PATH"); ok { + runnerConfig.ContainerDaemonSocket = dockerHostMountPath + } else if dockerHost, ok := os.LookupEnv("DOCKER_HOST"); ok && strings.HasPrefix(strings.ToLower(dockerHost), unixHostPrefix) { + runnerConfig.ContainerDaemonSocket = dockerHost[len(unixHostPrefix):] } // Non customizable config runnerConfig.Secrets = secrets @@ -321,10 +335,10 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon runnerConfig.ForcePull = true runnerConfig.ForceRebuild = true // allow downloading actions like older actions/runner using credentials of the redirect url - downloadActionHttpClient := *vssConnection.HttpClient() - downloadActionHttpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("stopped after 10 redirects") + downloadActionHTTPClient := *vssConnection.HTTPClient() + downloadActionHTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) } if len(via) >= 1 && req.Host != via[0].Host { req.Header.Del("Authorization") @@ -340,22 +354,22 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon {NameWithOwner: strings.Join(actionurl, "/"), Ref: ngcei.Ref}, } actionDownloadInfo := &protocol.ActionDownloadInfoCollection{} - err := vssConnection.RequestWithContext(ctx, "27d7f831-88c1-4719-8ca1-6a061dad90eb", "6.0-preview", "POST", map[string]string{ + if requestErr := vssConnection.RequestWithContext(ctx, "27d7f831-88c1-4719-8ca1-6a061dad90eb", "6.0-preview", "POST", map[string]string{ "scopeIdentifier": rqt.Plan.ScopeIdentifier, "hubName": rqt.Plan.PlanType, "planId": rqt.Plan.PlanID, - }, nil, actionList, actionDownloadInfo) - if err != nil { - return err + }, nil, actionList, actionDownloadInfo); requestErr != nil { + return requestErr } for _, v := range actionDownloadInfo.Actions { token := runnerConfig.Token if v.Authentication != nil && v.Authentication.Token != "" { token = v.Authentication.Token } - err := downloadAndExtractAction(ctx, ngcei.Dir, actionurl[0], actionurl[1], v.ResolvedSha, v.TarballUrl, token, &downloadActionHttpClient) - if err != nil { - return err + downloadErr := downloadAndExtractAction(ctx, ngcei.Dir, actionurl[0], actionurl[1], + v.ResolvedSha, v.TarballURL, token, &downloadActionHTTPClient) + if downloadErr != nil { + return downloadErr } } return nil @@ -375,25 +389,26 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon } actionDownloadInfo := &launch.ActionDownloadInfoResponseCollection{} urlBuilder := protocol.VssConnection{TenantURL: launchEndpoint.Value} - url, err := urlBuilder.BuildURL("actions/build/{planId}/jobs/{jobId}/runnerresolve/actions", map[string]string{ + url, urlErr := urlBuilder.BuildURL("actions/build/{planId}/jobs/{jobId}/runnerresolve/actions", map[string]string{ "jobId": rqt.JobID, "planId": rqt.Plan.PlanID, }, nil) - if err != nil { - return err + if urlErr != nil { + return urlErr } - err = vssConnection.RequestWithContext2(ctx, "POST", url, "", actionList, actionDownloadInfo) - if err != nil { - return err + if requestErr := vssConnection.RequestWithContext2(ctx, "POST", url, "", actionList, actionDownloadInfo); requestErr != nil { + return requestErr } for _, v := range actionDownloadInfo.Actions { token := runnerConfig.Token if v.Authentication != nil && v.Authentication.Token != "" { token = v.Authentication.Token } - err := downloadAndExtractAction(ctx, ngcei.Dir, actionurl[0], actionurl[1], v.ResolvedSha, v.TarUrl, token, &downloadActionHttpClient) - if err != nil { - return err + downloadErr := downloadAndExtractAction( + ctx, ngcei.Dir, actionurl[0], actionurl[1], v.ResolvedSha, v.TarURL, token, &downloadActionHTTPClient, + ) + if downloadErr != nil { + return downloadErr } } return nil @@ -502,10 +517,10 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon rc.ExprEval = ee formatter.rc = rc - if actions_step_debug { - logger.SetLevel(logrus.DebugLevel) + if actionsStepDebug { + actLogger.SetLevel(logrus.DebugLevel) } else { - logger.SetLevel(logrus.InfoLevel) + actLogger.SetLevel(logrus.InfoLevel) } rc.StepResults = make(map[string]*model.StepResult) @@ -528,28 +543,38 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon if canEvaluateNow { rec.Name = eval.Interpolate(jobExecCtx, rec.Name) } - jlogger.Append(rec).Order = int32(i + len(steps) + 1) + // Check for integer overflow before conversion + order := i + len(steps) + 1 + if order > math.MaxInt32 { // int32 max value + order = math.MaxInt32 + } + jlogger.Append(&rec).Order = int32(order) //nolint:gosec // bounds checked above } - logrus.SetLevel(logger.GetLevel()) - logrus.SetFormatter(logger.Formatter) - logrus.SetOutput(logger.Out) + logrus.SetLevel(actLogger.GetLevel()) + logrus.SetFormatter(actLogger.Formatter) + logrus.SetOutput(actLogger.Out) cacheDir := rc.ActionCacheDir() - if err := os.MkdirAll(cacheDir, 0777); err != nil { - logger.Warn("github-act-runner is be unable to access \"" + cacheDir + "\". You might want set one of the following environment variables XDG_CACHE_HOME, HOME to a user read and writeable location. Details: " + err.Error()) + if mkdirErr := os.MkdirAll(cacheDir, directoryPermissions); mkdirErr != nil { + actLogger.Warn("github-act-runner is be unable to access \"" + cacheDir + "\". You might want set one of the " + + "following environment variables XDG_CACHE_HOME, HOME to a user read and writeable location. Details: " + mkdirErr.Error()) } - logger.Println("Starting nektos/act") + actLogger.Println("Starting nektos/act") select { case <-jobExecCtx.Done(): default: fcancelctx, fcancel := context.WithCancel(context.Background()) defer fcancel() - ctxError := common.WithJobErrorContainer(runner.WithJobLogger(runner.WithJobLoggerFactory(common.WithLogger(fcancelctx, logger), &JobLoggerFactory{Logger: logger}), "", "", runnerConfig, &rc.Masks, rc.Matrix)) + ctxError := common.WithJobErrorContainer( + runner.WithJobLogger( + runner.WithJobLoggerFactory( + common.WithLogger(fcancelctx, actLogger), &JobLoggerFactory{Logger: actLogger}), + "", "", runnerConfig, &rc.Masks, rc.Matrix)) go func() { select { case <-jobExecCtx.Done(): - <-time.After(5 * time.Minute) + <-time.After(jobTimeout) fcancel() case <-fcancelctx.Done(): } @@ -570,7 +595,7 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon var outputMap *map[string]protocol.VariableValue if err != nil { - logger.Logf(logrus.ErrorLevel, "%v", err.Error()) + actLogger.Logf(logrus.ErrorLevel, "%v", err.Error()) jobStatus = "Failed" } formatter.Flush() @@ -592,26 +617,29 @@ func ExecWorker(rqt *protocol.AgentJobRequestMessage, wc actionsrunner.WorkerCon finishJob2(jobStatus, outputMap) } -func downloadAndExtractAction(ctx context.Context, target string, owner string, name string, resolvedSha string, tarURL string, token string, httpClient *http.Client) (reterr error) { - logger := common.Logger(ctx) +func downloadAndExtractAction( + ctx context.Context, target, owner, name, resolvedSha, tarURL, token string, httpClient *http.Client, +) (reterr error) { + contextLogger := common.Logger(ctx) cachedTar := filepath.Join(target, "..", owner+"."+name+"."+resolvedSha+".tar") defer func() { if reterr != nil { - os.Remove(cachedTar) + _ = os.Remove(cachedTar) // Ignore cleanup errors } }() var tarstream io.Reader + //nolint:gosec // cachedTar is constructed from controlled inputs (owner, name, resolvedSha) if fr, err := os.Open(cachedTar); err == nil { tarstream = fr - defer fr.Close() - if logger != nil { - logger.Infof("Found cache for action %v/%v (sha:%v) from %v", owner, name, resolvedSha, cachedTar) + defer func() { _ = fr.Close() }() // Ignore file close errors + if contextLogger != nil { + contextLogger.Infof("Found cache for action %v/%v (sha:%v) from %v", owner, name, resolvedSha, cachedTar) } } else { - if logger != nil { - logger.Infof("Downloading action %v/%v (sha:%v) from %v", owner, name, resolvedSha, tarURL) + if contextLogger != nil { + contextLogger.Infof("Downloading action %v/%v (sha:%v) from %v", owner, name, resolvedSha, tarURL) } - req, err := http.NewRequestWithContext(ctx, "GET", tarURL, nil) + req, err := http.NewRequestWithContext(ctx, "GET", tarURL, http.NoBody) if err != nil { return err } @@ -624,27 +652,28 @@ func downloadAndExtractAction(ctx context.Context, target string, owner string, if err != nil { return err } - defer rsp.Body.Close() - if rsp.StatusCode != 200 { + defer func() { _ = rsp.Body.Close() }() // Ignore response body close errors + if rsp.StatusCode != http.StatusOK { buf := &bytes.Buffer{} - io.Copy(buf, rsp.Body) - return fmt.Errorf("Failed to download action from %v response %v", tarURL, buf.String()) + _, _ = io.Copy(buf, rsp.Body) // Ignore error copy for error message + return fmt.Errorf("failed to download action from %v response %v", tarURL, buf.String()) } if len(resolvedSha) == len("0000000000000000000000000000000000000000") { + //nolint:gosec // cachedTar is constructed from controlled inputs (owner, name, resolvedSha) fo, err := os.Create(cachedTar) if err != nil { return err } - defer fo.Close() - len, err := io.Copy(fo, rsp.Body) + defer func() { _ = fo.Close() }() // Ignore file close errors + bytesWritten, err := io.Copy(fo, rsp.Body) if err != nil { return err } - if rsp.ContentLength >= 0 && len != rsp.ContentLength { - return fmt.Errorf("failed to download tar expected %v, but copied %v", rsp.ContentLength, len) + if rsp.ContentLength >= 0 && bytesWritten != rsp.ContentLength { + return fmt.Errorf("failed to download tar expected %v, but copied %v", rsp.ContentLength, bytesWritten) } tarstream = fo - fo.Seek(0, 0) + _, _ = fo.Seek(0, 0) // Ignore seek errors } else { tarstream = rsp.Body } @@ -660,6 +689,6 @@ func extractTarGz(reader io.Reader, dir string) error { if err != nil { return err } - defer gzr.Close() + defer func() { _ = gzr.Close() }() // Ignore gzip reader close errors return filecollector.ExtractTar(gzr, dir) } diff --git a/actionsdotnetactcompat/defaults_converter.go b/actionsdotnetactcompat/defaults_converter.go index 590b66c..47a80d1 100644 --- a/actionsdotnetactcompat/defaults_converter.go +++ b/actionsdotnetactcompat/defaults_converter.go @@ -4,21 +4,23 @@ import ( "encoding/json" "fmt" - "github.com/ChristopherHX/github-act-runner/protocol" "github.com/nektos/act/pkg/model" + + "github.com/ChristopherHX/github-act-runner/protocol" ) func ConvertDefaults(jobDefaults []protocol.TemplateToken) (model.Defaults, error) { defaults := model.Defaults{} - if jobDefaults != nil { - for _, rawenv := range jobDefaults { - rawobj := rawenv.ToRawObject() - rawobj = toStringMap(rawobj) - b, err := json.Marshal(rawobj) - if err != nil { - return model.Defaults{}, fmt.Errorf("Failed to eval defaults") - } - json.Unmarshal(b, &defaults) + for _, rawenv := range jobDefaults { + rawobj := rawenv.ToRawObject() + rawobj = toStringMap(rawobj) + b, err := json.Marshal(rawobj) + if err != nil { + return model.Defaults{}, fmt.Errorf("failed to eval defaults") + } + err = json.Unmarshal(b, &defaults) + if err != nil { + fmt.Printf("failed to unmarshal job default: %v", err) } } return defaults, nil diff --git a/actionsdotnetactcompat/environment_converter.go b/actionsdotnetactcompat/environment_converter.go index f1ead30..900eae8 100644 --- a/actionsdotnetactcompat/environment_converter.go +++ b/actionsdotnetactcompat/environment_converter.go @@ -8,23 +8,21 @@ import ( func ConvertEnvironment(environmentVariables []protocol.TemplateToken) (map[string]string, error) { env := make(map[string]string) - if environmentVariables != nil { - for _, rawenv := range environmentVariables { - if tmpenv, ok := rawenv.ToRawObject().(map[interface{}]interface{}); ok { - for k, v := range tmpenv { - key, ok := k.(string) - if !ok { - return nil, fmt.Errorf("env key: act doesn't support non strings") - } - value, ok := v.(string) - if !ok { - return nil, fmt.Errorf("env value: act doesn't support non strings") - } - env[key] = value + for _, rawenv := range environmentVariables { + if tmpenv, ok := rawenv.ToRawObject().(map[interface{}]interface{}); ok { + for k, v := range tmpenv { + key, ok := k.(string) + if !ok { + return nil, fmt.Errorf("env key: act doesn't support non strings") } - } else { - return nil, fmt.Errorf("env: not a map") + value, ok := v.(string) + if !ok { + return nil, fmt.Errorf("env value: act doesn't support non strings") + } + env[key] = value } + } else { + return nil, fmt.Errorf("env: not a map") } } return env, nil diff --git a/actionsdotnetactcompat/services_converter.go b/actionsdotnetactcompat/services_converter.go index b3e5981..133c629 100644 --- a/actionsdotnetactcompat/services_converter.go +++ b/actionsdotnetactcompat/services_converter.go @@ -4,8 +4,9 @@ import ( "encoding/json" "fmt" - "github.com/ChristopherHX/github-act-runner/protocol" "github.com/nektos/act/pkg/model" + + "github.com/ChristopherHX/github-act-runner/protocol" ) func ConvertServiceContainer(jobServiceContainers *protocol.TemplateToken) (map[string]*model.ContainerSpec, error) { diff --git a/actionsdotnetactcompat/step_converter.go b/actionsdotnetactcompat/step_converter.go index 15ba45c..a606693 100644 --- a/actionsdotnetactcompat/step_converter.go +++ b/actionsdotnetactcompat/step_converter.go @@ -4,15 +4,22 @@ import ( "fmt" "strings" - "github.com/ChristopherHX/github-act-runner/protocol" "github.com/google/uuid" "github.com/nektos/act/pkg/model" "gopkg.in/yaml.v3" + + "github.com/ChristopherHX/github-act-runner/protocol" +) + +const ( + // Step reference types + containerRegistryType = "containerregistry" ) func ConvertSteps(jobSteps []protocol.ActionStep) ([]*model.Step, error) { steps := []*model.Step{} - for _, step := range jobSteps { + for i := range jobSteps { + step := &jobSteps[i] st := strings.ToLower(step.Reference.Type) inputs := make(map[interface{}]interface{}) if step.Inputs != nil { @@ -34,26 +41,28 @@ func ConvertSteps(jobSteps []protocol.ActionStep) ([]*model.Step, error) { continueOnError := "false" if step.ContinueOnError != nil { tmpcontinueOnError := step.ContinueOnError.ToRawObject() - if b, ok := tmpcontinueOnError.(bool); ok { - continueOnError = fmt.Sprint(b) - } else if s, ok := tmpcontinueOnError.(string); ok { - continueOnError = s - } else { + switch v := tmpcontinueOnError.(type) { + case bool: + continueOnError = fmt.Sprint(v) + case string: + continueOnError = v + default: return nil, fmt.Errorf("ContinueOnError: Failed to translate") } } var timeoutMinutes string if step.TimeoutInMinutes != nil { rawTimeout := step.TimeoutInMinutes.ToRawObject() - if b, ok := rawTimeout.(float64); ok { - timeoutMinutes = fmt.Sprint(b) - } else if s, ok := rawTimeout.(string); ok { - timeoutMinutes = s - } else { + switch v := rawTimeout.(type) { + case float64: + timeoutMinutes = fmt.Sprint(v) + case string: + timeoutMinutes = v + default: return nil, fmt.Errorf("TimeoutInMinutes: Failed to translate") } } - var displayName string = "" + var displayName string if step.DisplayNameToken != nil { rawDisplayName, ok := step.DisplayNameToken.ToRawObject().(string) if !ok { @@ -104,15 +113,15 @@ func ConvertSteps(jobSteps []protocol.ActionStep) ([]*model.Step, error) { } else { return nil, fmt.Errorf("missing script") } - case "containerregistry", "repository": + case containerRegistryType, "repository": uses := "" - if st == "containerregistry" { + if st == containerRegistryType { uses = "docker://" + step.Reference.Image - } else if strings.ToLower(step.Reference.RepositoryType) == "self" { + } else if strings.EqualFold(step.Reference.RepositoryType, "self") { uses = step.Reference.Path } else { uses = step.Reference.Name - if len(step.Reference.Path) > 0 { + if step.Reference.Path != "" { uses = uses + "/" + step.Reference.Path } uses = uses + "@" + step.Reference.Ref diff --git a/actionsrunner/runner.go b/actionsrunner/runner.go index b6af2b7..45eee25 100644 --- a/actionsrunner/runner.go +++ b/actionsrunner/runner.go @@ -16,11 +16,25 @@ import ( "sync" "time" + "github.com/sirupsen/logrus" + "github.com/ChristopherHX/github-act-runner/common" "github.com/ChristopherHX/github-act-runner/protocol" runservice "github.com/ChristopherHX/github-act-runner/protocol/run" "github.com/ChristopherHX/github-act-runner/runnerconfiguration" - "github.com/sirupsen/logrus" +) + +// Constants for timeouts and retry logic +const ( + maxRetryAttempts = 10 + + waitJobCompleteInterval = 1 * time.Second + sessionExpired = 5 * time.Minute + retryShort = 10 * time.Second + retryInterval = 30 * time.Second + renewJobInterval = 60 * time.Second + httpTimeout = 100 * time.Second + shortTimeout = 100 * time.Millisecond ) type RunRunner struct { @@ -41,13 +55,14 @@ type JobRun struct { type RunnerEnvironment interface { BasicLogger - ReadJson(fname string, obj interface{}) error - WriteJson(fname string, obj interface{}) error + ReadJSON(fname string, obj interface{}) error + WriteJSON(fname string, obj interface{}) error Remove(fname string) error ExecWorker(run *RunRunner, wc WorkerContext, jobreq *protocol.AgentJobRequestMessage, src []byte) error } -func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Context, corectx context.Context) error { +//nolint:revive // context-as-argument: API compatibility requirement - cannot change parameter order +func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx, corectx context.Context) error { settings := run.Settings for i := 0; i < len(settings.Instances); i++ { if err := settings.Instances[i].EnshurePKey(); err != nil { @@ -78,13 +93,13 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte select { case <-allJobsDone(): cancel() - case <-time.After(100 * time.Millisecond): + case <-time.After(shortTimeout): run.Once = true firstJobReceived = true } } }() - if len(settings.Instances) <= 0 { + if len(settings.Instances) == 0 { return fmt.Errorf("please configure the runner") } isEphemeral := len(settings.Instances) == 1 && settings.Instances[0].Agent.Ephemeral @@ -101,20 +116,22 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte } }() var sessions []*protocol.TaskAgentSession - if err := runnerenv.ReadJson("sessions.json", &sessions); err != nil && run.Trace { + if err := runnerenv.ReadJSON("sessions.json", &sessions); err != nil && run.Trace { runnerenv.Printf("sessions.json is corrupted or does not exist: %v\n", err.Error()) } { // Backward compatibility var session protocol.TaskAgentSession - if err := runnerenv.ReadJson("session.json", &session); err != nil { + if err := runnerenv.ReadJSON("session.json", &session); err != nil { if run.Trace { runnerenv.Printf("session.json is corrupted or does not exist: %v\n", err.Error()) } } else { sessions = append(sessions, &session) // Save new format - runnerenv.WriteJson("sessions.json", sessions) + if err := runnerenv.WriteJSON("sessions.json", sessions); err != nil { + runnerenv.Printf("Warning: Cannot write sessions.json: %v\n", err.Error()) + } // Cleanup old files if err := runnerenv.Remove("session.json"); err != nil { runnerenv.Printf("Warning: Cannot delete session.json: %v\n", err.Error()) @@ -127,7 +144,6 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte for { mu := &sync.Mutex{} joblisteningctx, cancelJobListening := context.WithCancel(ctx) - defer cancelJobListening() wg := new(sync.WaitGroup) wg.Add(len(settings.Instances)) deleteSessions := firstRun @@ -145,13 +161,14 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte }() customTransport := http.DefaultTransport.(*http.Transport).Clone() customTransport.MaxIdleConns = 1 - customTransport.IdleConnTimeout = 100 * time.Second + customTransport.IdleConnTimeout = httpTimeout if v, ok := common.LookupEnvBool("SKIP_TLS_CERT_VALIDATION"); ok && v { + //nolint:gosec // Intentionally allows insecure TLS when explicitly configured customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } vssConnection := &protocol.VssConnection{ Client: &http.Client{ - Timeout: 100 * time.Second, + Timeout: httpTimeout, Transport: customTransport, }, TenantURL: instance.Auth.TenantURL, @@ -161,7 +178,9 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte Trace: run.Trace, } jobrun := &JobRun{} - if runnerenv.ReadJson("jobrun.json", jobrun) == nil && ((jobrun.RegistrationURL == instance.RegistrationURL && jobrun.Name == instance.Agent.Name) || (len(settings.Instances) == 1)) { + if runnerenv.ReadJSON("jobrun.json", jobrun) == nil && + ((jobrun.RegistrationURL == instance.RegistrationURL && jobrun.Name == instance.Agent.Name) || + (len(settings.Instances) == 1)) { result := "Failed" finish := &protocol.JobEvent{ Name: "JobCompleted", @@ -177,30 +196,39 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte runnerenv.Printf("Finished previous stuck job with Status Failed\n") break } - if i < 10 { - runnerenv.Printf("Retry finishing the job in 10 seconds attempt %v of 10\n", i+1) - <-time.After(time.Second * 10) + if i < maxRetryAttempts { + runnerenv.Printf("Retry finishing the job in %d seconds attempt %v of %d\n", retryShort/time.Second, i+1, maxRetryAttempts) + <-time.After(retryShort) } else { break } } }() - runnerenv.Remove("jobrun.json") + _ = runnerenv.Remove("jobrun.json") // Ignore cleanup errors } mu.Lock() - var _session *protocol.AgentMessageConnection = nil + var _session *protocol.AgentMessageConnection for _, session := range sessions { if session.Agent.Name == instance.Agent.Name && session.Agent.Authorization.PublicKey == instance.Agent.Authorization.PublicKey { session, err := vssConnection.LoadSession(joblisteningctx, session) + if err != nil { + fmt.Printf("failed to load session: %v", err) + } if deleteSessions { - session.Delete(joblisteningctx) + err1 := session.Delete(joblisteningctx) + if err1 != nil { + fmt.Printf("failed to delete session: %v", err1) + } for i, _session := range sessions { if session.TaskAgentSession.SessionID == _session.SessionID { sessions[i] = sessions[len(sessions)-1] sessions = sessions[:len(sessions)-1] } } - _ = runnerenv.WriteJson("sessions.json", sessions) + err1 = runnerenv.WriteJSON("sessions.json", sessions) + if err1 != nil { + fmt.Printf("failed to save sessions.json: %v\n", err1) + } } else if err == nil { _session = session } @@ -225,7 +253,10 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte sessions = sessions[:len(sessions)-1] } } - runnerenv.WriteJson("sessions.json", sessions) + err := runnerenv.WriteJSON("sessions.json", sessions) + if err != nil { + fmt.Printf("failed to save sessions.json") + } session = nil mu.Unlock() } @@ -244,26 +275,30 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte return 0 default: } - if session == nil || time.Now().After(lastSuccess.Add(5*time.Minute)) { + if session == nil || time.Now().After(lastSuccess.Add(sessionExpired)) { deleteSession() session2, err := vssConnection.CreateSession(joblisteningctx) if err != nil { - if strings.Contains(err.Error(), "invalid_client") || strings.Contains(err.Error(), "invalid_grant") || strings.Contains(err.Error(), "TaskAgentNotFoundException") || /* runner.server only */ strings.Contains(err.Error(), "Not Found") { - runnerenv.Printf("Fatal: It looks like this runner has been removed from GitHub, Failed to recreate Session for %v ( %v ): %v\n", instance.Agent.Name, instance.RegistrationURL, err.Error()) + if strings.Contains(err.Error(), "invalid_client") || strings.Contains(err.Error(), "invalid_grant") || + strings.Contains(err.Error(), "TaskAgentNotFoundException") || /* runner.server only */ + strings.Contains(err.Error(), "Not Found") { + runnerenv.Printf("Fatal: It looks like this runner has been removed from GitHub, Failed to recreate Session for %v ( %v ): %v\n", + instance.Agent.Name, instance.RegistrationURL, err.Error()) return 1 } - runnerenv.Printf("Failed to recreate Session for %v ( %v ), waiting 30 sec before retry: %v\n", instance.Agent.Name, instance.RegistrationURL, err.Error()) + runnerenv.Printf("Failed to recreate Session for %v ( %v ), waiting %d sec before retry: %v\n", + instance.Agent.Name, instance.RegistrationURL, retryInterval/time.Second, err.Error()) select { case <-joblisteningctx.Done(): return 0 - case <-time.After(30 * time.Second): + case <-time.After(retryInterval): } continue } else if session2 != nil { session = session2 mu.Lock() sessions = append(sessions, session.TaskAgentSession) - err := runnerenv.WriteJson("sessions.json", sessions) + err := runnerenv.WriteJSON("sessions.json", sessions) if err != nil { runnerenv.Printf("error: %v\n", err) } else { @@ -271,11 +306,11 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte } mu.Unlock() } else { - runnerenv.Printf("Failed to recreate Session, waiting 30 sec before retry\n") + runnerenv.Printf("Failed to recreate Session, waiting %d sec before retry\n", retryInterval/time.Second) select { case <-joblisteningctx.Done(): return 0 - case <-time.After(30 * time.Second): + case <-time.After(retryInterval): } continue } @@ -288,7 +323,7 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte "runnerVersion": "3.0.0", "status": session.Status, }, nil, message) - //TODO lastMessageId= + // TODO lastMessageId= if err != nil { if errors.Is(err, context.Canceled) { return 0 @@ -301,20 +336,20 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte runnerenv.Printf("Failed to get message, GitHub has rejected our authorization, recreate Session earlier: %v\n", err.Error()) session = nil continue - } else { - runnerenv.Printf("Failed to get message, waiting 10 sec before retry: %v\n", err.Error()) - select { - case <-joblisteningctx.Done(): - return 0 - case <-time.After(10 * time.Second): - } + } + runnerenv.Printf("Failed to get message, waiting %d sec before retry: %v\n", retryShort/time.Second, err.Error()) + select { + case <-joblisteningctx.Done(): + return 0 + case <-time.After(retryShort): } } else { lastSuccess = time.Now() } } else { lastSuccess = time.Now() - if firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || strings.EqualFold(message.MessageType, "RunnerJobRequest")) { + if firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || + strings.EqualFold(message.MessageType, "RunnerJobRequest")) { // It seems run once isn't supported by the backend, do the same as the official runner // Skip deleting the job message and cancel earlier runnerenv.Printf("Received a second job, but running in run once mode abort\n") @@ -334,9 +369,11 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte } } if success && message.FetchBrokerIfNeeded(xctx, session) == nil { - if strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || strings.EqualFold(message.MessageType, "RunnerJobRequest") { + if strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || + strings.EqualFold(message.MessageType, "RunnerJobRequest") { cancelJobListening() - for message != nil && !firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || strings.EqualFold(message.MessageType, "RunnerJobRequest")) { + for message != nil && !firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || + strings.EqualFold(message.MessageType, "RunnerJobRequest")) { if run.Once { firstJobReceived = true } @@ -353,10 +390,15 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte var err error message, err = session.GetNextMessage(jobExecCtx) if !errors.Is(err, context.Canceled) && message != nil { - if firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || strings.EqualFold(message.MessageType, "RunnerJobRequest")) { - runnerenv.Printf("Skip deleting the duplicated job request, we hope that the actions service reschedules your job to a different runner\n") + if firstJobReceived && (strings.EqualFold(message.MessageType, "PipelineAgentJobRequest") || + strings.EqualFold(message.MessageType, "RunnerJobRequest")) { + runnerenv.Printf("Skip deleting the duplicated job request, " + + "we hope that the actions service reschedules your job to a different runner\n") } else { - session.DeleteMessage(joblisteningctx, message) + err = session.DeleteMessage(joblisteningctx, message) + if err != nil { + runnerenv.Printf("Failed to delete message: %v\n", err) + } } if strings.EqualFold(message.MessageType, "JobCancellation") && cancelJob != nil { message = nil @@ -383,24 +425,27 @@ func (run *RunRunner) Run(runnerenv RunnerEnvironment, listenerctx context.Conte }(instance) } wg.Wait() + cancelJobListening() // Explicit cleanup to avoid resource leak if fatalFailure { return fmt.Errorf("fatal error, see log") } select { case <-allJobsDone(): if run.Once { + cancelJobListening() // Cleanup before return return nil } case <-ctx.Done(): + cancelJobListening() // Cleanup before return return nil } } } type RunnerJobRequestRef struct { - Id string `json:"id"` - RunnerRequestId string `json:"runner_request_id"` - RunServiceUrl string `json:"run_service_url"` + ID string `json:"id"` + RunnerRequestID string `json:"runner_request_id"` + RunServiceURL string `json:"run_service_url"` } type plainTextFormatter struct { @@ -410,7 +455,10 @@ func (f *plainTextFormatter) Format(entry *logrus.Entry) ([]byte, error) { return []byte(entry.Time.UTC().Format(protocol.TimestampOutputFormat) + " " + entry.Message + "\n"), nil } -func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *protocol.VssConnection, run *RunRunner, cancel context.CancelFunc, cancelJob context.CancelFunc, finishJob context.CancelFunc, jobExecCtx context.Context, jobctx context.Context, session *protocol.AgentMessageConnection, message protocol.TaskAgentMessage, instance *runnerconfiguration.RunnerInstance) { +//nolint:revive // context-as-argument: Legacy function signature for compatibility +func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *protocol.VssConnection, run *RunRunner, + cancel context.CancelFunc, cancelJob context.CancelFunc, finishJob context.CancelFunc, jobExecCtx context.Context, jobctx context.Context, + session *protocol.AgentMessageConnection, message protocol.TaskAgentMessage, instance *runnerconfiguration.RunnerInstance) { go func() { plogger := &PrefixConsoleLogger{ Parent: runnerenv, @@ -434,39 +482,46 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro plogger.Printf("%v\n", string(src)) } jobreq := &protocol.AgentJobRequestMessage{} - var runServiceUrl string - { - if strings.EqualFold(message.MessageType, "RunnerJobRequest") { - plogger.Printf("Warning: TaskAgentMessage.MessageType is %v, which has not been properly tested due to missing access to test servers of the new protocol before rollout. Please report any failures to https://github.com/ChristopherHX/github-act-runner/issues.\n", message.MessageType) - rjrr := &RunnerJobRequestRef{} - json.Unmarshal(src, rjrr) - for retries := 0; retries < 5; retries++ { - var err error - if len(rjrr.RunServiceUrl) == 0 { - err = vssConnection.RequestWithContext(jobctx, "25adab70-1379-4186-be8e-b643061ebe3a", "6.0-preview", "GET", map[string]string{ - "messageId": rjrr.RunnerRequestId, - }, map[string]string{}, nil, &src) - } else { - copy := *vssConnection - vssConnection = © - runServiceUrl = rjrr.RunServiceUrl - acquirejobUrl, _ := url.Parse(runServiceUrl) - acquirejobUrl.Path = path.Join(acquirejobUrl.Path, "acquirejob") - vssConnection.TenantURL = runServiceUrl - payload := &runservice.AcquireJobRequest{ - StreamID: rjrr.RunnerRequestId, - JobMessageID: rjrr.RunnerRequestId, - } - err = vssConnection.RequestWithContext2(jobctx, "POST", acquirejobUrl.String(), "", payload, &src) + var runServiceURL string + if strings.EqualFold(message.MessageType, "RunnerJobRequest") { + plogger.Printf("Warning: TaskAgentMessage.MessageType is %v, which has not been properly tested "+ + "due to missing access to test servers of the new protocol before rollout. "+ + "Please report any failures to https://github.com/ChristopherHX/github-act-runner/issues.\n", + message.MessageType) + rjrr := &RunnerJobRequestRef{} + if unmarshalErr := json.Unmarshal(src, rjrr); unmarshalErr != nil { + fmt.Printf("fail to unmashal job request: %v", unmarshalErr) + } + for retries := 0; retries < 5; retries++ { + var requestErr error + if rjrr.RunServiceURL == "" { + requestErr = vssConnection.RequestWithContext(jobctx, "25adab70-1379-4186-be8e-b643061ebe3a", "6.0-preview", "GET", map[string]string{ + "messageId": rjrr.RunnerRequestID, + }, map[string]string{}, nil, &src) + } else { + connectionCopy := *vssConnection + vssConnection = &connectionCopy + runServiceURL = rjrr.RunServiceURL + acquirejobURL, _ := url.Parse(runServiceURL) + acquirejobURL.Path = path.Join(acquirejobURL.Path, "acquirejob") + vssConnection.TenantURL = runServiceURL + payload := &runservice.AcquireJobRequest{ + StreamID: rjrr.RunnerRequestID, + JobMessageID: rjrr.RunnerRequestID, } - if err == nil { - json.Unmarshal(src, jobreq) - break + requestErr = vssConnection.RequestWithContext2(jobctx, "POST", acquirejobURL.String(), "", payload, &src) + } + if requestErr == nil { + if unmarshalErr := json.Unmarshal(src, jobreq); unmarshalErr != nil { + fmt.Printf("fail to unmarshall job request: %v", unmarshalErr) } - <-time.After(time.Second * 5 * time.Duration(retries+1)) + break } - } else { - json.Unmarshal(src, jobreq) + <-time.After(time.Second * 5 * time.Duration(retries+1)) + } + } else { + if unmarshalErr := json.Unmarshal(src, jobreq); unmarshalErr != nil { + fmt.Printf("fail to unmarshall job request: %v", unmarshalErr) } } jobrun := &JobRun{ @@ -475,49 +530,46 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro Plan: jobreq.Plan, RegistrationURL: instance.RegistrationURL, Name: instance.Agent.Name, - RunServiceURL: runServiceUrl, + RunServiceURL: runServiceURL, } - { - // TODO multi repository runners can receive multiple job requests at the same time and this protection doesn't work there - if err := runnerenv.WriteJson("jobrun.json", jobrun); err != nil { - plogger.Printf("INFO: Failed to create jobrun.json: %v\n", err) - } + // TODO multi repository runners can receive multiple job requests at the same time and this protection doesn't work there + if writeErr := runnerenv.WriteJSON("jobrun.json", jobrun); writeErr != nil { + plogger.Printf("INFO: Failed to create jobrun.json: %v\n", writeErr) } con := *vssConnection go func() { for { - var err error - if runServiceUrl != "" { + var renewErr error + if runServiceURL != "" { jobVssConnection, _, _ := jobreq.GetConnection("SystemVssConnection") jobVssConnection.Trace = con.Trace - renewjobUrl, _ := url.Parse(runServiceUrl) - renewjobUrl.Path = path.Join(renewjobUrl.Path, "renewjob") - jobVssConnection.TenantURL = runServiceUrl + renewjobURL, _ := url.Parse(runServiceURL) + renewjobURL.Path = path.Join(renewjobURL.Path, "renewjob") + jobVssConnection.TenantURL = runServiceURL payload := &runservice.RenewJobRequest{ PlanID: jobreq.Plan.PlanID, JobID: jobreq.JobID, } resp := &runservice.RenewJobResponse{} - err = jobVssConnection.RequestWithContext2(jobctx, "POST", renewjobUrl.String(), "", payload, &resp) + renewErr = jobVssConnection.RequestWithContext2(jobctx, "POST", renewjobURL.String(), "", payload, &resp) } else { - err = con.RequestWithContext(jobctx, "fc825784-c92a-4299-9221-998a02d1b54f", "5.1-preview", "PATCH", map[string]string{ + renewErr = con.RequestWithContext(jobctx, "fc825784-c92a-4299-9221-998a02d1b54f", "5.1-preview", "PATCH", map[string]string{ "poolId": fmt.Sprint(instance.PoolID), "requestId": fmt.Sprint(jobreq.RequestID), }, map[string]string{ "lockToken": "00000000-0000-0000-0000-000000000000", }, &protocol.RenewAgent{RequestID: jobreq.RequestID}, nil) } - if err != nil { - if errors.Is(err, context.Canceled) { + if renewErr != nil { + if errors.Is(renewErr, context.Canceled) { return - } else { - plogger.Printf("Failed to renew job: %v\n", err.Error()) } + plogger.Printf("Failed to renew job: %v\n", renewErr.Error()) } select { case <-jobctx.Done(): return - case <-time.After(60 * time.Second): + case <-time.After(renewJobInterval): } } }() @@ -530,8 +582,9 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro } wc.Init() jlogger := wc.Logger() - defer jlogger.Logger.Close() - setupJobEntry := jlogger.Append(protocol.CreateTimelineEntry(jobreq.JobID, "__setup_worker", "Set up Worker")) + defer func() { _ = jlogger.Logger.Close() }() // Ignore logger close errors + timelineEntry := protocol.CreateTimelineEntry(jobreq.JobID, "__setup_worker", "Set up Worker") + setupJobEntry := jlogger.Append(&timelineEntry) setupJobEntry.Order = 0 setupJobEntry.Start() jlogger.MoveNext() @@ -540,7 +593,7 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro if err := recover(); err != nil { wc.FailInitJob("Worker panicked", "The worker panicked with message: "+fmt.Sprint(err)+"\n"+string(debug.Stack())) } - runnerenv.Remove("jobrun.json") + _ = runnerenv.Remove("jobrun.json") // Ignore cleanup errors }() logger := logrus.New() @@ -548,11 +601,11 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro logger.SetFormatter(&plainTextFormatter{}) logger.SetLevel(logrus.DebugLevel) - jlogger.Update() + _ = jlogger.Update() // Ignore logger update errors logger.Log(logrus.InfoLevel, "Runner Name: "+instance.Agent.Name) logger.Log(logrus.InfoLevel, "Runner OSDescription: github-act-runner "+runtime.GOOS+"/"+runtime.GOARCH) - if len(run.Version) > 0 { + if run.Version != "" { logger.Log(logrus.InfoLevel, "Runner Version: "+run.Version) } @@ -564,7 +617,7 @@ func runJob(runnerenv RunnerEnvironment, joblock *sync.Mutex, vssConnection *pro select { case <-waitContext.Done(): return - case <-time.After(1 * time.Second): + case <-time.After(waitJobCompleteInterval): logger.Log(logrus.InfoLevel, "Waiting for runner to complete active job") } } diff --git a/actionsrunner/worker_context.go b/actionsrunner/worker_context.go index c9fd131..e0526cd 100644 --- a/actionsrunner/worker_context.go +++ b/actionsrunner/worker_context.go @@ -12,6 +12,12 @@ import ( "github.com/ChristopherHX/github-act-runner/protocol/run" ) +// Constants for worker context retry logic +const ( + workerMaxRetryAttempts = 10 + workerRetry = 10 * time.Second +) + type WorkerContext interface { FinishJob(result string, outputs *map[string]protocol.VariableValue) FailInitJob(title string, message string) @@ -28,6 +34,9 @@ type DefaultWorkerContext struct { RunnerLogger BasicLogger } +// FinishJob completes the job execution with the given result and outputs +// +//nolint:gocritic // ptrToRefParam: API compatibility requirement - changing pointer to value would be breaking change func (wc *DefaultWorkerContext) FinishJob(result string, outputs *map[string]protocol.VariableValue) { if strings.EqualFold(wc.Message().MessageType, "RunnerJobRequest") { payload := &run.CompleteJobRequest{ @@ -50,24 +59,25 @@ func (wc *DefaultWorkerContext) FinishJob(result string, outputs *map[string]pro } payload.Annotations = annotations } else if rec != nil { - stepResults = append(stepResults, run.TimeLineRecordToStepResult(*rec)) + stepResults = append(stepResults, run.TimeLineRecordToStepResult(rec)) } } payload.StepResults = stepResults } - completejobUrl, _ := url.Parse(wc.VssConnection.TenantURL) - completejobUrl.Path = path.Join(completejobUrl.Path, "completejob") + completejobURL, _ := url.Parse(wc.VssConnection.TenantURL) + completejobURL.Path = path.Join(completejobURL.Path, "completejob") for i := 0; ; i++ { - if err := wc.VssConnection.RequestWithContext2(context.Background(), "POST", completejobUrl.String(), "", payload, nil); err != nil { + if err := wc.VssConnection.RequestWithContext2(context.Background(), "POST", completejobURL.String(), "", payload, nil); err != nil { wc.RunnerLogger.Printf("Failed to finish Job '%v' with Status %v: %v\n", wc.Message().JobDisplayName, result, err.Error()) } else { wc.RunnerLogger.Printf("Finished Job '%v' with Status %v\n", wc.Message().JobDisplayName, result) break } - if i < 10 { - wc.RunnerLogger.Printf("Retry finishing '%v' in 10 seconds attempt %v of 10\n", wc.Message().JobDisplayName, i+1) - <-time.After(time.Second * 10) + if i < workerMaxRetryAttempts { + wc.RunnerLogger.Printf("Retry finishing '%v' in %d seconds attempt %v of %d\n", + wc.Message().JobDisplayName, workerRetry/time.Second, i+1, workerMaxRetryAttempts) + <-time.After(workerRetry) } else { break } @@ -88,20 +98,22 @@ func (wc *DefaultWorkerContext) FinishJob(result string, outputs *map[string]pro wc.RunnerLogger.Printf("Finished Job '%v' with Status %v\n", wc.Message().JobDisplayName, result) break } - if i < 10 { - wc.RunnerLogger.Printf("Retry finishing '%v' in 10 seconds attempt %v of 10\n", wc.Message().JobDisplayName, i+1) - <-time.After(time.Second * 10) + if i < workerMaxRetryAttempts { + wc.RunnerLogger.Printf("Retry finishing '%v' in %d seconds attempt %v of %d\n", + wc.Message().JobDisplayName, workerRetry/time.Second, i+1, workerMaxRetryAttempts) + <-time.After(workerRetry) } else { break } } } -func (wc *DefaultWorkerContext) FailInitJob(title string, message string) { +func (wc *DefaultWorkerContext) FailInitJob(title, message string) { if wc.Logger().Current() != nil { wc.Logger().Current().Complete("Failed") } - e := wc.Logger().Append(protocol.CreateTimelineEntry(wc.Message().JobID, "__fatal", title)) + timelineEntry := protocol.CreateTimelineEntry(wc.Message().JobID, "__fatal", title) + e := wc.Logger().Append(&timelineEntry) e.Start() if wc.Logger().Current() != e { for { @@ -114,7 +126,7 @@ func (wc *DefaultWorkerContext) FailInitJob(title string, message string) { } wc.Logger().Log(message) e.Complete("Failed") - wc.Logger().Logger.Close() + _ = wc.Logger().Logger.Close() // Ignore logger close errors wc.Logger().MoveNext() wc.Logger().TimelineRecords.Value[0].Complete("Failed") wc.Logger().Finish() @@ -163,7 +175,7 @@ func (wc *DefaultWorkerContext) Init() { LiveLogger: &logger.WebsocketLiveloggerWithFallback{ JobRequest: jobreq, Connection: jobVssConnection, - FeedStreamUrl: vssConnectionData["FeedStreamUrl"], + FeedStreamURL: vssConnectionData["FeedStreamUrl"], ForceWebsock: true, }, } @@ -177,11 +189,12 @@ func (wc *DefaultWorkerContext) Init() { LiveLogger: &logger.WebsocketLiveloggerWithFallback{ JobRequest: jobreq, Connection: jobVssConnection, - FeedStreamUrl: vssConnectionData["FeedStreamUrl"], + FeedStreamURL: vssConnectionData["FeedStreamUrl"], }, } } - jobEntry := wc.Logger().Append(protocol.CreateTimelineEntry("", wc.Message().JobName, wc.Message().JobDisplayName)) + timelineEntry := protocol.CreateTimelineEntry("", wc.Message().JobName, wc.Message().JobDisplayName) + jobEntry := wc.Logger().Append(&timelineEntry) jobEntry.ID = wc.Message().JobID jobEntry.Type = "Job" jobEntry.Order = 0 diff --git a/actionsrunner/worker_runner_environment.go b/actionsrunner/worker_runner_environment.go index e33b40a..bedb3ff 100644 --- a/actionsrunner/worker_runner_environment.go +++ b/actionsrunner/worker_runner_environment.go @@ -5,27 +5,35 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "os" "os/exec" "github.com/ChristopherHX/github-act-runner/protocol" ) +const ( + // Binary protocol constants + messageIDSize = 4 + cancelRequestCmd = 2 + // File permissions + filePermissions = 0664 +) + type WorkerRunnerEnvironment struct { WorkerArgs []string } -func (arunner *WorkerRunnerEnvironment) WriteJson(path string, value interface{}) error { +func (arunner *WorkerRunnerEnvironment) WriteJSON(path string, value interface{}) error { b, err := json.MarshalIndent(value, "", " ") if err != nil { return err } - return ioutil.WriteFile(path, b, 0777) + return os.WriteFile(path, b, filePermissions) } -func (arunner *WorkerRunnerEnvironment) ReadJson(path string, value interface{}) error { - cont, err := ioutil.ReadFile(path) +func (arunner *WorkerRunnerEnvironment) ReadJSON(path string, value interface{}) error { + //nolint:gosec // Path is provided by worker configuration, not user input + cont, err := os.ReadFile(path) if err != nil { return err } @@ -40,12 +48,15 @@ func (arunner *WorkerRunnerEnvironment) Printf(format string, a ...interface{}) fmt.Printf(format, a...) } -func (arunner *WorkerRunnerEnvironment) ExecWorker(run *RunRunner, wc WorkerContext, jobreq *protocol.AgentJobRequestMessage, src []byte) error { +func (arunner *WorkerRunnerEnvironment) ExecWorker( + _ *RunRunner, wc WorkerContext, _ *protocol.AgentJobRequestMessage, src []byte, +) error { jlogger := wc.Logger() jobExecCtx := wc.JobExecCtx() - if len(arunner.WorkerArgs) <= 0 { + if len(arunner.WorkerArgs) == 0 { return fmt.Errorf("missing WorkerArgs to execute an external worker") } + //nolint:gosec // WorkerArgs are configured by the administrator, not user input worker := exec.Command(arunner.WorkerArgs[0], arunner.WorkerArgs[1:]...) in, err := worker.StdinPipe() if err != nil { @@ -63,31 +74,55 @@ func (arunner *WorkerRunnerEnvironment) ExecWorker(run *RunRunner, wc WorkerCont if err != nil { return err } - jlogger.Logger.Close() + _ = jlogger.Logger.Close() // Ignore logger close errors jlogger.Current().Complete("Succeeded") jlogger.MoveNext() - mid := make([]byte, 4) + mid := make([]byte, messageIDSize) binary.BigEndian.PutUint32(mid, 1) // NewJobRequest - in.Write(mid) - binary.BigEndian.PutUint32(mid, uint32(len(src))) - in.Write(mid) - in.Write(src) + _, err = in.Write(mid) + if err != nil { + fmt.Printf("failed to write new job: %s", err) + } + binary.BigEndian.PutUint32(mid, uint32(len(src))) //nolint:gosec + _, err = in.Write(mid) + if err != nil { + fmt.Printf("failed to write new job: %s", err) + } + _, err = in.Write(src) + if err != nil { + fmt.Printf("failed to write new job: %s", err) + } done := make(chan struct{}) defer close(done) go func() { select { case <-jobExecCtx.Done(): - binary.BigEndian.PutUint32(mid, 2) // CancelRequest - in.Write(mid) - binary.BigEndian.PutUint32(mid, uint32(len(src))) - in.Write(mid) - in.Write(src) + binary.BigEndian.PutUint32(mid, cancelRequestCmd) // CancelRequest + _, err = in.Write(mid) + if err != nil { + fmt.Printf("failed to write cancel job: %s", err) + } + binary.BigEndian.PutUint32(mid, uint32(len(src))) //nolint:gosec + _, err = in.Write(mid) + if err != nil { + fmt.Printf("failed to write cancel job length: %s", err) + } + _, err = in.Write(src) + if err != nil { + fmt.Printf("failed to write cancel job body: %s", err) + } case <-done: } }() - io.Copy(os.Stdout, out) - io.Copy(os.Stdout, er) - worker.Wait() + _, err = io.Copy(os.Stdout, out) + if err != nil { + fmt.Printf("failed to copy out to stdout: %s", err) + } + _, err = io.Copy(os.Stdout, er) + if err != nil { + fmt.Printf("failed to copy err to stdout: %s", err) + } + _ = worker.Wait() // Ignore wait errors, checked with ProcessState if exitcode := worker.ProcessState.ExitCode(); exitcode != 0 { return fmt.Errorf("failed to execute worker: %v", exitcode) } diff --git a/common/envUtils.go b/common/envUtils.go index 8507bfd..c64e1f6 100644 --- a/common/envUtils.go +++ b/common/envUtils.go @@ -5,7 +5,7 @@ import ( "strings" ) -func LookupEnvBool(name string) (bool, bool) { +func LookupEnvBool(name string) (value, found bool) { if v, ok := os.LookupEnv(name); ok { if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "Y") || strings.EqualFold(v, "Yes") { return true, true diff --git a/common/io.go b/common/io.go index 7e4d96c..a784566 100644 --- a/common/io.go +++ b/common/io.go @@ -2,19 +2,25 @@ package common import ( "encoding/json" - "io/ioutil" + "os" ) -func WriteJson(path string, value interface{}) error { +const ( + // File permissions + filePermissions = 0664 +) + +func WriteJSON(path string, value interface{}) error { b, err := json.MarshalIndent(value, "", " ") if err != nil { return err } - return ioutil.WriteFile(path, b, 0777) + return os.WriteFile(path, b, filePermissions) } -func ReadJson(path string, value interface{}) error { - cont, err := ioutil.ReadFile(path) +func ReadJSON(path string, value interface{}) error { + //nolint:gosec // Path is provided by application configuration, not user input + cont, err := os.ReadFile(path) if err != nil { return err } diff --git a/main.go b/main.go index af1d429..ac3ed43 100644 --- a/main.go +++ b/main.go @@ -9,26 +9,33 @@ import ( "errors" "fmt" "io" - "io/ioutil" "os" "os/signal" "runtime" "strings" "syscall" + "github.com/joho/godotenv" + "github.com/kardianos/service" + "github.com/nektos/act/pkg/container" + "github.com/ChristopherHX/github-act-runner/actionsdotnetactcompat" "github.com/ChristopherHX/github-act-runner/actionsrunner" "github.com/ChristopherHX/github-act-runner/common" "github.com/ChristopherHX/github-act-runner/protocol" "github.com/ChristopherHX/github-act-runner/runnerconfiguration" runnerCompat "github.com/ChristopherHX/github-act-runner/runnerconfiguration/compat" - "github.com/joho/godotenv" - "github.com/kardianos/service" - "github.com/nektos/act/pkg/container" "github.com/spf13/cobra" ) +const ( + // Binary protocol constants + bufferSize = 4 + // File permissions + logFilePermissions = 0777 +) + type RunRunner struct { Once bool Terminal bool @@ -49,13 +56,13 @@ func readLegacyInstance(settings *runnerconfiguration.RunnerSettings, instance * taskAgent := &protocol.TaskAgent{} var key *rsa.PrivateKey req := &protocol.GitHubAuthResult{} - err := common.ReadJson("agent.json", taskAgent) + err := common.ReadJSON("agent.json", taskAgent) if err != nil { return 1 } { - cont, err := ioutil.ReadFile("cred.pkcs1") - if err != nil { + cont, readErr := os.ReadFile("cred.pkcs1") + if readErr != nil { return 1 } key, err = x509.ParsePKCS1PrivateKey(cont) @@ -63,7 +70,7 @@ func readLegacyInstance(settings *runnerconfiguration.RunnerSettings, instance * return 1 } } - err = common.ReadJson("auth.json", req) + err = common.ReadJSON("auth.json", req) if err != nil { return 1 } @@ -78,17 +85,15 @@ func readLegacyInstance(settings *runnerconfiguration.RunnerSettings, instance * func loadConfiguration() (*runnerconfiguration.RunnerSettings, error) { settings := &runnerconfiguration.RunnerSettings{} { - err := common.ReadJson("settings.json", settings) + err := common.ReadJSON("settings.json", settings) if err != nil { if errors.Is(err, os.ErrNotExist) { // Backward compat <= 0.0.3 // fmt.Printf("The runner needs to be configured first: %v\n", err.Error()) // return 1 settings.PoolID = 1 - } else { - if err != nil { - return nil, err - } + } else if err != nil { + return nil, err } } } @@ -134,7 +139,7 @@ func (run *RunRunner) Run() int { return run.RunWithContext(listenerctx, ctx) } -func (run *RunRunner) RunWithContext(listenerctx context.Context, ctx context.Context) int { +func (run *RunRunner) RunWithContext(listenerctx, ctx context.Context) int { var settings *runnerconfiguration.RunnerSettings var err error if run.JITConfig != "" { @@ -150,7 +155,7 @@ func (run *RunRunner) RunWithContext(listenerctx context.Context, ctx context.Co for _, kv := range os.Environ() { if strings.HasPrefix(kv, "ACTIONS_RUNNER_INPUT_") { k, _, _ := strings.Cut(kv, "=") - os.Unsetenv(k) + _ = os.Unsetenv(k) // Ignore error for env cleanup } } runner := &actionsrunner.RunRunner{ @@ -171,15 +176,15 @@ func (run *RunRunner) RunWithContext(listenerctx context.Context, ctx context.Co return 0 } -var version string = "0.8.x-dev" +var version = "0.8.x-dev" type interactive struct { } -func (i *interactive) GetInput(prompt string, def string) string { +func (i *interactive) GetInput(prompt, def string) string { return GetInput(prompt, def) } -func (i *interactive) GetSelectInput(prompt string, options []string, def string) string { +func (i *interactive) GetSelectInput(_ string, options []string, def string) string { return RunnerGroupSurvey(def, options) } func (i *interactive) GetMultiSelectInput(prompt string, options []string) []string { @@ -225,12 +230,14 @@ func (svc *RunRunnerSvc) Start(s service.Service) error { } else { svc.wait <- nil } - s.Stop() + if err := s.Stop(); err != nil { + fmt.Printf("Failed to stop service: %v", err) + } }() return nil } -func (svc *RunRunnerSvc) Stop(s service.Service) error { +func (svc *RunRunnerSvc) Stop(service.Service) error { svc.stop() if err, ok := <-svc.wait; ok && err != nil { return err @@ -248,42 +255,43 @@ func main() { Use: "configure", Short: "Configure your self-hosted runner", Args: cobra.MaximumNArgs(0), - Run: func(cmd *cobra.Command, args []string) { + Run: func(_ *cobra.Command, _ []string) { config.ReadFromEnvironment() settings := &runnerconfiguration.RunnerSettings{} if !printJITConfig { settings, _ = loadConfiguration() } settings, err := config.Configure(settings, &interactive{}, nil) + if err != nil { + fmt.Printf("failed to configure: %v\n", err) + os.Exit(1) + } if printJITConfig { - var jitconfig string - if err == nil { - jitconfig, err = runnerCompat.ToJitRunnerConfig(settings.Instances[0]) - } - if err != nil { - fmt.Printf("failed to configure: %v\n", err) + jitconfig, jitErr := runnerCompat.ToJitRunnerConfig(settings.Instances[0]) + if jitErr != nil { + fmt.Printf("failed to configure: %v\n", jitErr) os.Exit(1) - } else { - fmt.Println(jitconfig) } + fmt.Println(jitconfig) } else { if settings != nil { - os.Remove("agent.json") - os.Remove("auth.json") - os.Remove("cred.pkcs1") + _ = os.Remove("agent.json") // Ignore error for cleanup + _ = os.Remove("auth.json") // Ignore error for cleanup + _ = os.Remove("cred.pkcs1") // Ignore error for cleanup if saveActionsRunnerConfig && len(settings.Instances) == 1 { - runnerCompat.FromRunnerInstance(settings.Instances[0], runnerCompat.DefaultConfigFileAccess{}) + _ = runnerCompat.FromRunnerInstance(settings.Instances[0], runnerCompat.DefaultConfigFileAccess{}) } else { - common.WriteJson("settings.json", settings) + if writeErr := common.WriteJSON("settings.json", settings); writeErr != nil { + fmt.Printf("Failed to write settings.json: %v", writeErr) + } } } if err != nil { fmt.Printf("failed to configure: %v\n", err) os.Exit(1) - } else { - fmt.Printf("success\n") - os.Exit(0) } + fmt.Printf("success\n") + os.Exit(0) } }, } @@ -293,24 +301,28 @@ func main() { cmdConfigure.Flags().StringVar(&config.Pat, "pat", "", "personal access token with access to your repository, organization or enterprise") cmdConfigure.Flags().StringSliceVarP(&config.Labels, "labels", "l", []string{}, "custom user labels for your new runner") cmdConfigure.Flags().StringVar(&config.Name, "name", "", "custom runner name") - cmdConfigure.Flags().BoolVar(&config.NoDefaultLabels, "no-default-labels", false, "do not automatically add the following system labels: self-hosted, "+runtime.GOOS+" and "+runtime.GOARCH) + cmdConfigure.Flags().BoolVar(&config.NoDefaultLabels, "no-default-labels", false, + "do not automatically add the following system labels: self-hosted, "+runtime.GOOS+" and "+runtime.GOARCH) cmdConfigure.Flags().StringSliceVar(&config.SystemLabels, "system-labels", []string{}, "custom system labels for your new runner") cmdConfigure.Flags().StringVar(&config.Token, "runnergroup", "", "name of the runner group to use will ask if more than one is available") cmdConfigure.Flags().BoolVar(&config.Unattended, "unattended", false, "suppress shell prompts during configure") cmdConfigure.Flags().BoolVar(&config.Trace, "trace", false, "trace http communication with the github action service") - cmdConfigure.Flags().BoolVar(&config.Ephemeral, "ephemeral", false, "configure a single use runner, runner deletes it's setting.json ( and the actions service should remove their registrations at the same time ) after executing one job ( implies '--once' on run ). This is not supported for multi runners.") + cmdConfigure.Flags().BoolVar(&config.Ephemeral, "ephemeral", false, + "configure a single use runner, runner deletes it's setting.json ( and the actions service should remove their "+ + "registrations at the same time ) after executing one job ( implies '--once' on run ). This is not supported for multi runners.") cmdConfigure.Flags().StringVar(&config.RunnerGuard, "runner-guard", "", "reject jobs and configure act (deprecated, code removed)") cmdConfigure.Flags().BoolVar(&config.Replace, "replace", false, "replace any existing runner with the same name") cmdConfigure.Flags().BoolVar(&config.DisableUpdate, "disableupdate", false, "actions/runner disable updates (has no effect)") cmdConfigure.Flags().BoolVar(&printJITConfig, "print-jitconfig", false, "print the runner configuration as jitconfig") - cmdConfigure.Flags().BoolVar(&saveActionsRunnerConfig, "save-actionsrunnerconfig", false, "use the format of actions/runner to save the configuration") + cmdConfigure.Flags().BoolVar(&saveActionsRunnerConfig, "save-actionsrunnerconfig", false, + "use the format of actions/runner to save the configuration") cmdConfigure.Flags().StringVar(&config.WorkFolder, "work", "_work", "actions/runner work folder (has no effect)") var cmdRun = &cobra.Command{ Use: "run", Short: "Run your self-hosted runner", Args: cobra.MaximumNArgs(0), - Run: func(cmd *cobra.Command, args []string) { + Run: func(_ *cobra.Command, _ []string) { os.Exit(run.Run()) }, } @@ -319,14 +331,15 @@ func main() { cmdRun.Flags().BoolVarP(&run.Terminal, "terminal", "t", true, "allocate a pty if possible") cmdRun.Flags().BoolVar(&run.Trace, "trace", false, "trace http communication with the github action service") cmdRun.Flags().StringSliceVar(&run.WorkerArgs, "worker-args", []string{}, "custom worker for your runner") - cmdRun.Flags().StringVarP(&run.JITConfig, "jitconfig", "", os.Getenv("ACTIONS_RUNNER_INPUT_JITCONFIG"), "read the runner configuration from the jitconfig") + cmdRun.Flags().StringVarP(&run.JITConfig, "jitconfig", "", os.Getenv("ACTIONS_RUNNER_INPUT_JITCONFIG"), + "read the runner configuration from the jitconfig") var jitConfig string local, _ := common.LookupEnvBool("ACTIONS_RUNNER_INPUT_LOCAL") var cmdRemove = &cobra.Command{ Use: "remove", Short: "Remove your self-hosted runner", Args: cobra.MaximumNArgs(0), - Run: func(cmd *cobra.Command, args []string) { + Run: func(_ *cobra.Command, _ []string) { remove.ReadFromEnvironment() var settings *runnerconfiguration.RunnerSettings var err error @@ -343,60 +356,75 @@ func main() { settings, err = remove.Remove(settings, &interactive{}, nil) } if (settings != nil || local) && jitConfig == "" { - os.Remove("agent.json") - os.Remove("auth.json") - os.Remove("cred.pkcs1") - os.Remove(".runner") - os.Remove(".credentials") - os.Remove(".credentials_rsaparams") + _ = os.Remove("agent.json") // Ignore error for cleanup + _ = os.Remove("auth.json") // Ignore error for cleanup + _ = os.Remove("cred.pkcs1") // Ignore error for cleanup + _ = os.Remove(".runner") // Ignore error for cleanup + _ = os.Remove(".credentials") // Ignore error for cleanup + _ = os.Remove(".credentials_rsaparams") // Ignore error for cleanup if !local && len(settings.Instances) > 0 { - common.WriteJson("settings.json", settings) + if writeErr := common.WriteJSON("settings.json", settings); writeErr != nil { + fmt.Printf("Failed to write settings.json: %v", writeErr) + } } else { - os.Remove("settings.json") + _ = os.Remove("settings.json") // Ignore error for cleanup } } if err != nil { fmt.Printf("failed to remove: %v\n", err) os.Exit(1) - } else { - fmt.Printf("success\n") - os.Exit(0) } + fmt.Printf("success\n") + os.Exit(0) }, } - cmdRemove.Flags().StringVar(&remove.URL, "url", "", "url of your repository, organization or enterprise ( required to unconfigure version <= 0.0.3 )") + cmdRemove.Flags().StringVar(&remove.URL, "url", "", + "url of your repository, organization or enterprise ( required to unconfigure version <= 0.0.3 )") cmdRemove.Flags().StringVar(&remove.Token, "token", "", "runner registration or remove token") cmdRemove.Flags().StringVar(&remove.Pat, "pat", "", "personal access token with access to your repository, organization or enterprise") cmdRemove.Flags().BoolVar(&remove.Unattended, "unattended", false, "suppress shell prompts during configure") cmdRemove.Flags().StringVar(&remove.Name, "name", "", "name of the runner to remove") cmdRemove.Flags().BoolVar(&remove.Trace, "trace", false, "trace http communication with the github action service") cmdRemove.Flags().BoolVar(&remove.Force, "force", false, "force remove the instance even if the service responds with an error") - cmdRemove.Flags().StringVarP(&jitConfig, "jitconfig", "", os.Getenv("ACTIONS_RUNNER_INPUT_JITCONFIG"), "read the runner configuration from the jitconfig, this doesn't replace token/pat") + cmdRemove.Flags().StringVarP(&jitConfig, "jitconfig", "", os.Getenv("ACTIONS_RUNNER_INPUT_JITCONFIG"), + "read the runner configuration from the jitconfig, this doesn't replace token/pat") cmdRemove.Flags().BoolVar(&local, "local", local, "only delete the configuration") var cmdWorker = &cobra.Command{ Use: "worker", Short: "Run as self-hosted runner worker, can be used to create ephemeral worker without exposing other job requests", Args: cobra.MaximumNArgs(0), - Run: func(cmd *cobra.Command, args []string) { + Run: func(_ *cobra.Command, _ []string) { ccontext, cancelccontext := context.WithCancel(context.Background()) go func() { execcontext, cancelExec := context.WithCancel(context.Background()) defer cancelExec() - buf := make([]byte, 4) + buf := make([]byte, bufferSize) for { - io.ReadFull(os.Stdin, buf) + _, err := io.ReadFull(os.Stdin, buf) // Ignore read errors for worker protocol + if err != nil { + fmt.Printf("io.ReadFull message type: %v", err) + } messageType := binary.BigEndian.Uint32(buf) - io.ReadFull(os.Stdin, buf) + _, err = io.ReadFull(os.Stdin, buf) // Ignore read errors for worker protocol + if err != nil { + fmt.Printf("io.ReadFull message length: %v", err) + } messageLength := binary.BigEndian.Uint32(buf) src := make([]byte, messageLength) - io.ReadFull(os.Stdin, src) + _, err = io.ReadFull(os.Stdin, src) // Ignore read errors for worker protocol + if err != nil { + fmt.Printf("io.ReadFull stdin: %v", err) + } switch messageType { case 1: jobreq := &protocol.AgentJobRequestMessage{} - json.Unmarshal(src, jobreq) + err = json.Unmarshal(src, jobreq) // Ignore unmarshal errors for worker protocol + if err != nil { + fmt.Printf("unmarshal job request: %v", err) + } go func() { defer cancelExec() defer cancelccontext() @@ -406,7 +434,8 @@ func main() { RunnerLogger: &actionsrunner.ConsoleLogger{}, } wc.Init() - wc.Logger().Append(protocol.CreateTimelineEntry(jobreq.JobID, "__setup", "Set up Job")).Start() + timelineEntry := protocol.CreateTimelineEntry(jobreq.JobID, "__setup", "Set up Job") + wc.Logger().Append(&timelineEntry).Start() wc.Logger().MoveNext() actionsdotnetactcompat.ExecWorker(jobreq, wc) }() @@ -430,20 +459,20 @@ func main() { svcRun := &cobra.Command{ Use: "run", Short: "Used as service entrypoint", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { err := os.Chdir(wd) if err != nil { return err } - stdOut, err := os.OpenFile("github-act-runner-log.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0777) + stdOut, err := os.OpenFile("github-act-runner-log.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, logFilePermissions) if err == nil { os.Stdout = stdOut - defer os.Stdout.Close() + defer func() { _ = os.Stdout.Close() }() // Ignore close error for stdout } - stdErr, err := os.OpenFile("github-act-runner-log-error.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0777) + stdErr, err := os.OpenFile("github-act-runner-log-error.txt", os.O_WRONLY|os.O_APPEND|os.O_CREATE, logFilePermissions) if err == nil { os.Stderr = stdErr - defer os.Stderr.Close() + defer func() { _ = os.Stderr.Close() }() // Ignore close error for stderr } err = godotenv.Overload(envFile) @@ -463,7 +492,7 @@ func main() { svcInstall := &cobra.Command{ Use: "install", Short: "Install the service may require admin privileges", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { svc, err := service.New(&RunRunnerSvc{}, getSvcConfig(wd, envFile)) if err != nil { @@ -473,14 +502,15 @@ func main() { if err != nil { return err } - fmt.Printf("Success\nConsider adding required env variables for your jobs like HOME or PATH to your '%s' godotenv file\nSee https://pkg.go.dev/github.com/joho/godotenv for the syntax\n", envFile) + fmt.Printf("Success\nConsider adding required env variables for your jobs like HOME or PATH to your '%s' godotenv file\n"+ + "See https://pkg.go.dev/github.com/joho/godotenv for the syntax\n", envFile) return nil }, } svcUninstall := &cobra.Command{ Use: "uninstall", Short: "Uninstall the service may require admin privileges", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { svc, err := service.New(&RunRunnerSvc{}, getSvcConfig(wd, envFile)) if err != nil { @@ -492,7 +522,7 @@ func main() { svcStart := &cobra.Command{ Use: "start", Short: "Start the service may require admin privileges", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { svc, err := service.New(&RunRunnerSvc{}, getSvcConfig(wd, envFile)) if err != nil { @@ -504,7 +534,7 @@ func main() { svcStop := &cobra.Command{ Use: "stop", Short: "Stop the service may require admin privileges", - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(_ *cobra.Command, _ []string) error { svc, err := service.New(&RunRunnerSvc{}, getSvcConfig(wd, envFile)) if err != nil { @@ -520,10 +550,13 @@ func main() { Version: version, } rootCmd.AddCommand(cmdConfigure, cmdRun, cmdRemove, cmdWorker, cmdSvc) - rootCmd.Execute() + err := rootCmd.Execute() + if err != nil { + fmt.Printf("rootCmd: %v", err) + } } -func getSvcConfig(wd string, envFile string) *service.Config { +func getSvcConfig(wd, envFile string) *service.Config { svcConfig := &service.Config{ Name: "github-act-runner", DisplayName: "GitHub Act Runner", diff --git a/protocol/action_download_info.go b/protocol/action_download_info.go index c9ea15c..a7bc53e 100644 --- a/protocol/action_download_info.go +++ b/protocol/action_download_info.go @@ -19,9 +19,9 @@ type ActionDownloadInfo struct { NameWithOwner string ResolvedNameWithOwner string ResolvedSha string - TarballUrl string + TarballURL string Ref string - ZipballUrl string + ZipballURL string } type ActionDownloadAuthentication struct { diff --git a/protocol/connection.go b/protocol/connection.go index 6d8dc81..5816a72 100644 --- a/protocol/connection.go +++ b/protocol/connection.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" "path" @@ -16,8 +15,22 @@ import ( "strings" "time" - "github.com/ChristopherHX/github-act-runner/common" "github.com/google/uuid" + + "github.com/ChristopherHX/github-act-runner/common" +) + +const ( + apiVersionSuffix = "; api-version=" + statusOnline = "Online" + + maxIdleConnections = 1 + maxRedirects = 10 + + requestTimeout = 1 * time.Minute + idleConnectionTimeout = 100 * time.Second + httpClientTimeout = 100 * time.Second + maxRetryTime = 600 * time.Second ) type VssConnection struct { @@ -32,14 +45,14 @@ type VssConnection struct { Trace bool } -func (vssConnection *VssConnection) BuildURL(relativePath string, ppath map[string]string, query map[string]string) (string, error) { +func (vssConnection *VssConnection) BuildURL(relativePath string, ppath, query map[string]string) (string, error) { url2, err := url.Parse(vssConnection.TenantURL) if err != nil { return "", err } - url := relativePath + urlPath := relativePath re := regexp.MustCompile(`/*\{[^\}]+\}`) - url = re.ReplaceAllStringFunc(url, func(s string) string { + urlPath = re.ReplaceAllStringFunc(urlPath, func(s string) string { start := strings.Index(s, "{") end := strings.Index(s, "}") if val, ok := ppath[s[start+1:end]]; ok { @@ -47,7 +60,7 @@ func (vssConnection *VssConnection) BuildURL(relativePath string, ppath map[stri } return "" }) - url2.Path = path.Join(url2.Path, url) + url2.Path = path.Join(url2.Path, urlPath) q := url2.Query() for p, v := range query { q.Add(p, v) @@ -56,20 +69,21 @@ func (vssConnection *VssConnection) BuildURL(relativePath string, ppath map[stri return url2.String(), nil } -func (vssConnection *VssConnection) HttpClient() *http.Client { +func (vssConnection *VssConnection) HTTPClient() *http.Client { if vssConnection.Client == nil { customTransport := http.DefaultTransport.(*http.Transport).Clone() - customTransport.MaxIdleConns = 1 - customTransport.IdleConnTimeout = 100 * time.Second + customTransport.MaxIdleConns = maxIdleConnections + customTransport.IdleConnTimeout = idleConnectionTimeout if v, ok := common.LookupEnvBool("SKIP_TLS_CERT_VALIDATION"); ok && v { + //nolint:gosec // Intentionally allows insecure TLS when explicitly configured customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } vssConnection.Client = &http.Client{ - Timeout: 100 * time.Second, + Timeout: httpClientTimeout, Transport: customTransport, CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("stopped after 10 redirects") + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) } return nil }, @@ -81,34 +95,38 @@ func (vssConnection *VssConnection) HttpClient() *http.Client { func (vssConnection *VssConnection) authorize() (*VssOAuthTokenResponse, error) { var authResponse *VssOAuthTokenResponse var err error - authResponse, err = vssConnection.TaskAgent.Authorize(vssConnection.HttpClient(), vssConnection.Key) + authResponse, err = vssConnection.TaskAgent.Authorize(vssConnection.HTTPClient(), vssConnection.Key) if err == nil { return authResponse, nil } return nil, err } -func (vssConnection *VssConnection) Request(serviceID string, protocol string, method string, urlParameter map[string]string, queryParameter map[string]string, requestBody interface{}, responseBody interface{}) error { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) +func (vssConnection *VssConnection) Request( + serviceID, protocol, method string, urlParameter, queryParameter map[string]string, requestBody, responseBody interface{}, +) error { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) defer cancel() return vssConnection.RequestWithContext(ctx, serviceID, protocol, method, urlParameter, queryParameter, requestBody, responseBody) } -func (vssConnection *VssConnection) GetServiceURL(ctx context.Context, serviceID string, urlParameter map[string]string, queryParameter map[string]string) (string, error) { +func (vssConnection *VssConnection) GetServiceURL( + ctx context.Context, serviceID string, urlParameter, queryParameter map[string]string, +) (string, error) { if vssConnection.connectionData == nil { for i := 1; ; { vssConnection.connectionData = vssConnection.GetConnectionData() if vssConnection.connectionData != nil { break } - maxtime := 60 * 10 - var dtime time.Duration = time.Duration(i) * time.Second - if i < maxtime { + maxtimeSeconds := int(maxRetryTime / time.Second) + dtime := time.Duration(i) * time.Second + if i < maxtimeSeconds { i *= 2 } else { - dtime = time.Duration(maxtime) * time.Second + dtime = maxRetryTime } - fmt.Printf("Retry retrieving connectiondata from the server in %v seconds\n", dtime) + fmt.Printf("Retry retrieving connectiondata from the server in %v seconds\n", dtime/time.Second) select { case <-ctx.Done(): return "", fmt.Errorf("aborted to get connectionData") @@ -129,12 +147,17 @@ func (vssConnection *VssConnection) GetServiceURL(ctx context.Context, serviceID return vssConnection.BuildURL(serv.RelativePath, urlParameter, queryParameter) } -func (vssConnection *VssConnection) RequestWithContext(ctx context.Context, serviceID string, protocol string, method string, urlParameter map[string]string, queryParameter map[string]string, requestBody interface{}, responseBody interface{}) error { - url, err := vssConnection.GetServiceURL(ctx, serviceID, urlParameter, queryParameter) +func (vssConnection *VssConnection) RequestWithContext( + ctx context.Context, + serviceID, protocol, method string, + urlParameter, queryParameter map[string]string, + requestBody, responseBody interface{}, +) error { + requestURL, err := vssConnection.GetServiceURL(ctx, serviceID, urlParameter, queryParameter) if err != nil { return err } - return vssConnection.RequestWithContext2(ctx, method, url, protocol, requestBody, responseBody) + return vssConnection.RequestWithContext2(ctx, method, requestURL, protocol, requestBody, responseBody) } func extractReader(body interface{}) (io.Reader, []string, error) { @@ -173,7 +196,7 @@ func setResponseBody(r io.Reader, body interface{}) error { } if bresponse, ok := body.(*[]byte); ok { var err error - *bresponse, err = ioutil.ReadAll(r) + *bresponse, err = io.ReadAll(r) if err != nil { return err } @@ -186,18 +209,20 @@ func setResponseBody(r io.Reader, body interface{}) error { return nil } -func (vssConnection *VssConnection) requestWithContextNoAuth(ctx context.Context, method string, requesturl string, apiversion string, requestBody interface{}, responseBody interface{}) (int, error) { +func (vssConnection *VssConnection) requestWithContextNoAuth( + ctx context.Context, method, requesturl, apiversion string, requestBody, responseBody interface{}, +) (int, error) { buf, reqContentType, err := extractReader(requestBody) if err != nil { return 0, err } - if len(apiversion) > 0 { + if apiversion != "" { // vssservice always needs a version, even if there is no content - if requrl, err := url.Parse(requesturl); err == nil { - query := requrl.Query() + if parsedURL, parseErr := url.Parse(requesturl); parseErr == nil { + query := parsedURL.Query() query.Set("api-version", apiversion) - requrl.RawQuery = query.Encode() - requesturl = requrl.String() + parsedURL.RawQuery = query.Encode() + requesturl = parsedURL.String() } } request, err := http.NewRequestWithContext(ctx, method, requesturl, buf) @@ -217,50 +242,58 @@ func (vssConnection *VssConnection) requestWithContextNoAuth(ctx context.Context header.Set(acceptHeader, "application/json") } } - if len(apiversion) > 0 { + if apiversion != "" { // vssservice does only accept contenttype in a single line if len(header[contentTypeHeader]) > 0 { - header[contentTypeHeader][0] += "; api-version=" + apiversion + header[contentTypeHeader][0] += apiVersionSuffix + apiversion } if len(header[acceptHeader]) > 0 { - header[acceptHeader][0] += "; api-version=" + apiversion + header[acceptHeader][0] += apiVersionSuffix + apiversion } header["X-VSS-E2EID"] = []string{uuid.NewString()} header["X-TFS-FedAuthRedirect"] = []string{"Suppress"} header["X-TFS-Session"] = []string{uuid.NewString()} } - if len(vssConnection.Token) > 0 { + if vssConnection.Token != "" { header["Authorization"] = []string{"bearer " + vssConnection.Token} - } else if len(vssConnection.AuthHeader) > 0 { + } else if vssConnection.AuthHeader != "" { header["Authorization"] = []string{vssConnection.AuthHeader} } if vssConnection.Trace { - fmt.Printf("Http %v Request started %v\nHeaders:\n%v\nBody: `%v`\n", method, requesturl, getHeadersAsString(request.Header), getBodyAsString(buf)) + fmt.Printf("Http %v Request started %v\nHeaders:\n%v\nBody: `%v`\n", + method, requesturl, getHeadersAsString(request.Header), getBodyAsString(buf)) } - response, err := vssConnection.HttpClient().Do(request) + response, err := vssConnection.HTTPClient().Do(request) if err != nil { return 0, err } if response == nil { return 0, fmt.Errorf("failed to send request response is nil") } - defer response.Body.Close() + defer func() { + if closeErr := response.Body.Close(); closeErr != nil { + fmt.Printf("Failed to close response body: %v", closeErr) + } + }() var rbytes []byte var responseReader io.Reader - failed := response.StatusCode < 200 || response.StatusCode >= 300 + failed := response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices readResponse := vssConnection.Trace || failed if responseBody != nil { responseReader = response.Body if readResponse { - rbytes, err = ioutil.ReadAll(response.Body) + rbytes, err = io.ReadAll(response.Body) responseReader = bytes.NewReader(rbytes) if err != nil { rbytes = []byte("no response: " + err.Error()) } } } - traceMessage := fmt.Sprintf("Http %v Request finished %v %v\nHeaders: \n%v\nBody: `%v`\n", method, response.StatusCode, requesturl, getHeadersAsString(response.Header), string(rbytes)) + traceMessage := fmt.Sprintf( + "Http %v Request finished %v %v\nHeaders: \n%v\nBody: `%v`\n", + method, response.StatusCode, requesturl, + getHeadersAsString(response.Header), string(rbytes)) if vssConnection.Trace { fmt.Print(traceMessage) } @@ -273,15 +306,17 @@ func (vssConnection *VssConnection) requestWithContextNoAuth(ctx context.Context return response.StatusCode, setResponseBody(responseReader, responseBody) } -func (vssConnection *VssConnection) RequestWithContext2(ctx context.Context, method string, url string, protocol string, requestBody interface{}, responseBody interface{}) error { - statusCode, err := vssConnection.requestWithContextNoAuth(ctx, method, url, protocol, requestBody, responseBody) +func (vssConnection *VssConnection) RequestWithContext2( + ctx context.Context, method, requestURL, protocol string, requestBody, responseBody interface{}, +) error { + statusCode, err := vssConnection.requestWithContextNoAuth(ctx, method, requestURL, protocol, requestBody, responseBody) if (statusCode == 401 || statusCode == 400) && vssConnection.TaskAgent != nil && vssConnection.Key != nil { - authResponse, err := vssConnection.authorize() - if err != nil { - return err + authResponse, authErr := vssConnection.authorize() + if authErr != nil { + return authErr } vssConnection.Token = authResponse.AccessToken - _, err = vssConnection.requestWithContextNoAuth(ctx, method, url, protocol, requestBody, responseBody) + _, err = vssConnection.requestWithContextNoAuth(ctx, method, requestURL, protocol, requestBody, responseBody) return err } return err @@ -289,7 +324,10 @@ func (vssConnection *VssConnection) RequestWithContext2(ctx context.Context, met func (vssConnection *VssConnection) GetAgentPools() (*TaskAgentPools, error) { _taskAgentPools := &TaskAgentPools{} - if err := vssConnection.Request("a8c47e17-4d56-4a56-92bb-de7ea7dc65be", "", "GET", map[string]string{}, map[string]string{}, nil, _taskAgentPools); err != nil { + err := vssConnection.Request( + "a8c47e17-4d56-4a56-92bb-de7ea7dc65be", "", "GET", + map[string]string{}, map[string]string{}, nil, _taskAgentPools) + if err != nil { return nil, err } return _taskAgentPools, nil @@ -312,7 +350,7 @@ func (vssConnection *VssConnection) CreateSession(ctx context.Context) (*AgentMe _ = con.Delete(ctx) return nil, err } - con.Status = "Online" + con.Status = statusOnline return con, nil } @@ -324,7 +362,7 @@ func (vssConnection *VssConnection) LoadSession(ctx context.Context, session *Ta _ = con.Delete(ctx) return nil, err } - con.Status = "Online" + con.Status = statusOnline return con, nil } @@ -378,7 +416,8 @@ func (vssConnection *VssConnection) FinishJob(e *JobEvent, plan *TaskOrchestrati }, map[string]string{}, e, nil) } -func (vssConnection *VssConnection) SendLogLines(plan *TaskOrchestrationPlanReference, timelineID string, lines *TimelineRecordFeedLinesWrapper) error { +func (vssConnection *VssConnection) SendLogLines( + plan *TaskOrchestrationPlanReference, timelineID string, lines *TimelineRecordFeedLinesWrapper) error { return vssConnection.Request("858983e4-19bd-4c5e-864c-507b59b58b12", "5.1-preview", "POST", map[string]string{ "scopeIdentifier": plan.ScopeIdentifier, "planId": plan.PlanID, diff --git a/protocol/connection_data.go b/protocol/connection_data.go index 077d5c6..d52bb60 100644 --- a/protocol/connection_data.go +++ b/protocol/connection_data.go @@ -28,18 +28,18 @@ type ConnectionData struct { } func (vssConnection *VssConnection) GetConnectionData() *ConnectionData { - url, err := url.Parse(vssConnection.TenantURL) + parsedURL, err := url.Parse(vssConnection.TenantURL) if err != nil { return nil } - url.Path = path.Join(url.Path, "_apis/connectionData") - q := url.Query() + parsedURL.Path = path.Join(parsedURL.Path, "_apis/connectionData") + q := parsedURL.Query() q.Add("connectOptions", "1") q.Add("lastChangeId", "-1") q.Add("lastChangeId64", "-1") - url.RawQuery = q.Encode() + parsedURL.RawQuery = q.Encode() connectionData := &ConnectionData{} - err = vssConnection.RequestWithContext2(context.Background(), "GET", url.String(), "1.0", nil, connectionData) + err = vssConnection.RequestWithContext2(context.Background(), "GET", parsedURL.String(), "1.0", nil, connectionData) if err != nil { return nil } diff --git a/protocol/launch/contract.go b/protocol/launch/contract.go index 60e2a39..05b03f0 100644 --- a/protocol/launch/contract.go +++ b/protocol/launch/contract.go @@ -15,9 +15,9 @@ type ActionDownloadInfoResponse struct { Name string `json:"name,omitempty"` ResolvedName string `json:"resolved_name,omitempty"` ResolvedSha string `json:"resolved_sha,omitempty"` - TarUrl string `json:"tar_url,omitempty"` + TarURL string `json:"tar_url,omitempty"` Version string `json:"version,omitempty"` - ZipUrl string `json:"zip_url,omitempty"` + ZipURL string `json:"zip_url,omitempty"` } type ActionDownloadAuthenticationResponse struct { diff --git a/protocol/logger/job_logger.go b/protocol/logger/job_logger.go index a611a74..f15fab9 100644 --- a/protocol/logger/job_logger.go +++ b/protocol/logger/job_logger.go @@ -13,10 +13,20 @@ import ( "sync" "time" - "github.com/ChristopherHX/github-act-runner/protocol" - "github.com/ChristopherHX/github-act-runner/protocol/results" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" + + "github.com/ChristopherHX/github-act-runner/protocol" + "github.com/ChristopherHX/github-act-runner/protocol/results" +) + +const ( + // Websocket connection timeouts + websocketDialTimeout = 5 * time.Minute // Connection establishment timeout + websocketMessageTimeout = 5 * time.Second // Individual message timeout + websocketPingSize = 64 // bytes + // Results upload timeout + resultsUploadTimeout = 5 * time.Minute ) type LiveLogger interface { @@ -41,7 +51,7 @@ type WebsocketLivelogger struct { JobRequest *protocol.AgentJobRequestMessage Connection *protocol.VssConnection ws *websocket.Conn - FeedStreamUrl string + FeedStreamURL string } func (logger *WebsocketLivelogger) Close() error { @@ -59,17 +69,18 @@ func (logger *WebsocketLivelogger) Connect() error { fmt.Printf("Failed to close old websocket connection %s\n", err.Error()) } if logger.Connection.Trace { - fmt.Printf("Try to connect to websocket %s\n", logger.FeedStreamUrl) + fmt.Printf("Try to connect to websocket %s\n", logger.FeedStreamURL) } re := regexp.MustCompile("(?i)^http(s?)://") - feedStreamUrl, err := url.Parse(re.ReplaceAllString(logger.FeedStreamUrl, "ws$1://")) + feedStreamURL, err := url.Parse(re.ReplaceAllString(logger.FeedStreamURL, "ws$1://")) if err != nil { return err } - ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + ctx, cancel := context.WithTimeout(context.Background(), websocketDialTimeout) defer cancel() - logger.ws, _, err = websocket.Dial(ctx, feedStreamUrl.String(), &websocket.DialOptions{ - HTTPClient: logger.Connection.HttpClient(), + //nolint:bodyclose // websocket.Dial doesn't return an HTTP response body to close + logger.ws, _, err = websocket.Dial(ctx, feedStreamURL.String(), &websocket.DialOptions{ + HTTPClient: logger.Connection.HTTPClient(), HTTPHeader: http.Header{ "Authorization": []string{"Bearer " + logger.Connection.Token}, "User-Agent": []string{"github-act-runner/1.0.0"}, @@ -79,7 +90,7 @@ func (logger *WebsocketLivelogger) Connect() error { } func (logger *WebsocketLivelogger) SendLog(lines *protocol.TimelineRecordFeedLinesWrapper) error { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + ctx, cancel := context.WithTimeout(context.Background(), websocketMessageTimeout) defer cancel() return wsjson.Write(ctx, logger.ws, lines) } @@ -88,12 +99,12 @@ type WebsocketLiveloggerWithFallback struct { JobRequest *protocol.AgentJobRequestMessage Connection *protocol.VssConnection currentLogger LiveLogger - FeedStreamUrl string + FeedStreamURL string ForceWebsock bool } func (logger *WebsocketLiveloggerWithFallback) InitializeVssLogger() { - logger.Close() + _ = logger.Close() // Ignore error for cleanup logger.currentLogger = &VssLiveLogger{ JobRequest: logger.JobRequest, Connection: logger.Connection, @@ -101,12 +112,12 @@ func (logger *WebsocketLiveloggerWithFallback) InitializeVssLogger() { } func (logger *WebsocketLiveloggerWithFallback) Initialize() { - logger.Close() - if len(logger.FeedStreamUrl) > 0 { + _ = logger.Close() // Ignore error for cleanup + if logger.FeedStreamURL != "" { wslogger := &WebsocketLivelogger{ JobRequest: logger.JobRequest, Connection: logger.Connection, - FeedStreamUrl: logger.FeedStreamUrl, + FeedStreamURL: logger.FeedStreamURL, } err := wslogger.Connect() if err == nil { @@ -229,7 +240,7 @@ func (logger *BufferedLiveLogger) Close() error { func (logger *BufferedLiveLogger) SendLog(wrapper *protocol.TimelineRecordFeedLinesWrapper) error { if logger.logchan == nil { - logchan := make(chan *protocol.TimelineRecordFeedLinesWrapper, 64) + logchan := make(chan *protocol.TimelineRecordFeedLinesWrapper, websocketPingSize) logger.logchan = logchan logfinished := make(chan struct{}) logger.logfinished = logfinished @@ -254,7 +265,7 @@ type JobLogger struct { Logger LiveLogger lineBuffer []byte IsResults bool - ChangeId int64 + ChangeID int64 CurrentJobLine int64 FirstBlock bool FirstJobBlock bool @@ -312,7 +323,12 @@ func (logger *JobLogger) MoveNextExt(startNextRecord bool) *protocol.TimelineRec func (logger *JobLogger) uploadBlock(cur *protocol.TimelineRecord, finalBlock bool) { if !logger.IsResults && finalBlock && logger.CurrentBuffer.Len() > 0 { - if logid, err := logger.Connection.UploadLogFile(logger.JobRequest.Timeline.ID, logger.JobRequest, logger.CurrentBuffer.String()); err == nil { + logid, err := logger.Connection.UploadLogFile( + logger.JobRequest.Timeline.ID, + logger.JobRequest, + logger.CurrentBuffer.String(), + ) + if err == nil { cur.Log = &protocol.TaskLogReference{ID: logid} } } @@ -320,9 +336,11 @@ func (logger *JobLogger) uploadBlock(cur *protocol.TimelineRecord, finalBlock bo rs := &results.ResultsService{ Connection: logger.ResultsConnection, } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), resultsUploadTimeout) defer cancel() - rs.UploadResultsStepLogAsync(ctx, logger.JobRequest.Plan.PlanID, logger.JobRequest.JobID, cur.ID, &logger.ResultsCurrentBuffer, int64(logger.ResultsCurrentBuffer.Len()), logger.FirstBlock, finalBlock, logger.CurrentLine) + _ = rs.UploadResultsStepLogAsync(ctx, logger.JobRequest.Plan.PlanID, logger.JobRequest.JobID, cur.ID, + &logger.ResultsCurrentBuffer, int64(logger.ResultsCurrentBuffer.Len()), logger.FirstBlock, finalBlock, + logger.CurrentLine) // Ignore upload error for async operation logger.FirstBlock = false logger.ResultsCurrentBuffer.Reset() } @@ -336,7 +354,12 @@ func (logger *JobLogger) Finish() { func (logger *JobLogger) uploadJobBlob(finalBlock bool) { if !logger.IsResults && finalBlock && logger.JobBuffer.Len() > 0 && len(logger.TimelineRecords.Value) > 0 { - if logid, err := logger.Connection.UploadLogFile(logger.JobRequest.Timeline.ID, logger.JobRequest, logger.JobBuffer.String()); err == nil { + logid, err := logger.Connection.UploadLogFile( + logger.JobRequest.Timeline.ID, + logger.JobRequest, + logger.JobBuffer.String(), + ) + if err == nil { logger.TimelineRecords.Value[0].Log = &protocol.TaskLogReference{ID: logid} _ = logger.update() } @@ -346,9 +369,11 @@ func (logger *JobLogger) uploadJobBlob(finalBlock bool) { rs := &results.ResultsService{ Connection: logger.ResultsConnection, } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), resultsUploadTimeout) defer cancel() - rs.UploadResultsJobLogAsync(ctx, logger.JobRequest.Plan.PlanID, logger.JobRequest.JobID, &logger.ResultsJobBuffer, int64(logger.ResultsJobBuffer.Len()), logger.FirstJobBlock, finalBlock, logger.CurrentJobLine) + _ = rs.UploadResultsJobLogAsync(ctx, logger.JobRequest.Plan.PlanID, logger.JobRequest.JobID, + &logger.ResultsJobBuffer, int64(logger.ResultsJobBuffer.Len()), logger.FirstJobBlock, finalBlock, + logger.CurrentJobLine) // Ignore upload error for async operation logger.FirstJobBlock = false logger.ResultsJobBuffer.Reset() } @@ -364,14 +389,14 @@ func (logger *JobLogger) Update() error { func (logger *JobLogger) update() error { var errResults, errVss error if logger.ResultsConnection != nil { - logger.ChangeId++ + logger.ChangeID++ updatereq := &results.StepsUpdateRequest{} - updatereq.ChangeOrder = logger.ChangeId + updatereq.ChangeOrder = logger.ChangeID updatereq.WorkflowRunBackendID = logger.JobRequest.Plan.PlanID updatereq.WorkflowJobRunBackendID = logger.TimelineRecords.Value[0].ID updatereq.Steps = make([]results.Step, len(logger.TimelineRecords.Value)-1) for i, rec := range logger.TimelineRecords.Value[1:] { - updatereq.Steps[i] = results.ConvertTimelineRecordToStep(*rec) + updatereq.Steps[i] = results.ConvertTimelineRecordToStep(rec) } rs := &results.ResultsService{ Connection: logger.ResultsConnection, @@ -387,26 +412,25 @@ func (logger *JobLogger) update() error { return errors.Join(errResults, errVss) } -func (logger *JobLogger) Append(record protocol.TimelineRecord) *protocol.TimelineRecord { +func (logger *JobLogger) Append(record *protocol.TimelineRecord) *protocol.TimelineRecord { logger.loggersync.Lock() defer logger.loggersync.Unlock() if l := len(logger.TimelineRecords.Value); l > 0 { record.Order = logger.TimelineRecords.Value[l-1].Order + 1 } - logger.TimelineRecords.Value = append(logger.TimelineRecords.Value, &record) + logger.TimelineRecords.Value = append(logger.TimelineRecords.Value, record) logger.TimelineRecords.Count = int64(len(logger.TimelineRecords.Value)) - return &record + return record } -func (logger *JobLogger) Insert(record protocol.TimelineRecord) *protocol.TimelineRecord { +func (logger *JobLogger) Insert(record *protocol.TimelineRecord) *protocol.TimelineRecord { logger.loggersync.Lock() defer logger.loggersync.Unlock() x := append(make([]*protocol.TimelineRecord, 0), logger.TimelineRecords.Value[:logger.CurrentRecord]...) - y := append(x, &record) - z := append(y, logger.TimelineRecords.Value[logger.CurrentRecord:]...) - logger.TimelineRecords.Value = z + x = append(x, record) + logger.TimelineRecords.Value = append(x, logger.TimelineRecords.Value[logger.CurrentRecord:]...) logger.TimelineRecords.Count = int64(len(logger.TimelineRecords.Value)) - return &record + return record } func (logger *JobLogger) Log(lines string) { @@ -453,7 +477,7 @@ func (logger *JobLogger) Log(lines string) { wrapper.Value[i] = wrapper.Value[i][length:] } } - logger.Logger.SendLog(wrapper) + _ = logger.Logger.SendLog(wrapper) // Ignore send error for logging logger.uploadBlock(cur, false) logger.uploadJobBlob(false) } diff --git a/protocol/logger/job_logger_test.go b/protocol/logger/job_logger_test.go index 4705487..5149d87 100644 --- a/protocol/logger/job_logger_test.go +++ b/protocol/logger/job_logger_test.go @@ -6,23 +6,32 @@ import ( "github.com/ChristopherHX/github-act-runner/protocol" ) +const ( + // Test timeline entry reference names + initRefName = "_init" + init3RefName = "_init3" +) + func TestJobLogger(t *testing.T) { logger := &JobLogger{ TimelineRecords: &protocol.TimelineRecordWrapper{}, } - logger.Append(protocol.CreateTimelineEntry("", "_init", "Init")).Start() - logger.Append(protocol.CreateTimelineEntry("", "_init3", "Init")).Start() - if logger.TimelineRecords.Value[0].RefName != "_init" { + entry1 := protocol.CreateTimelineEntry("", initRefName, "Init") + logger.Append(&entry1).Start() + entry2 := protocol.CreateTimelineEntry("", init3RefName, "Init") + logger.Append(&entry2).Start() + if logger.TimelineRecords.Value[0].RefName != initRefName { t.FailNow() } - if logger.TimelineRecords.Value[1].RefName != "_init3" { + if logger.TimelineRecords.Value[1].RefName != init3RefName { t.FailNow() } - logger.Insert(protocol.CreateTimelineEntry("", "_init0", "Init")).Start() - if logger.TimelineRecords.Value[1].RefName != "_init" { + entry3 := protocol.CreateTimelineEntry("", "_init0", "Init") + logger.Insert(&entry3).Start() + if logger.TimelineRecords.Value[1].RefName != initRefName { t.FailNow() } - if logger.TimelineRecords.Value[2].RefName != "_init3" { + if logger.TimelineRecords.Value[2].RefName != init3RefName { t.FailNow() } } diff --git a/protocol/pipeline_context_data.go b/protocol/pipeline_context_data.go index bea17ac..2b887c4 100644 --- a/protocol/pipeline_context_data.go +++ b/protocol/pipeline_context_data.go @@ -5,6 +5,15 @@ import ( "fmt" ) +const ( + // PipelineContextData types + PipelineContextString = 0 + PipelineContextArray = 1 + PipelineContextDictionary = 2 + PipelineContextBool = 3 + PipelineContextNumber = 4 +) + type DictionaryContextDataPair struct { Key string `json:"k"` Value PipelineContextData `json:"v"` @@ -24,19 +33,19 @@ func (ctx *PipelineContextData) UnmarshalJSON(data []byte) error { if ctx.BoolValue == nil { ctx = nil } else { - var typ int32 = 3 + var typ int32 = PipelineContextBool ctx.Type = &typ } return nil } else if json.Unmarshal(data, &ctx.NumberValue) == nil { ctx.BoolValue = nil - var typ int32 = 4 + var typ int32 = PipelineContextNumber ctx.Type = &typ return nil } else if json.Unmarshal(data, &ctx.StringValue) == nil { ctx.BoolValue = nil ctx.NumberValue = nil - var typ int32 + var typ int32 = PipelineContextString ctx.Type = &typ return nil } else { @@ -53,9 +62,9 @@ func (ctx PipelineContextData) ToRawObject() interface{} { return nil } switch *ctx.Type { - case 0: + case PipelineContextString: return *ctx.StringValue - case 1: + case PipelineContextArray: a := make([]interface{}, 0) if ctx.ArrayValue != nil { for _, v := range *ctx.ArrayValue { @@ -63,7 +72,7 @@ func (ctx PipelineContextData) ToRawObject() interface{} { } } return a - case 2: + case PipelineContextDictionary: m := make(map[string]interface{}) if ctx.DictionaryValue != nil { for _, v := range *ctx.DictionaryValue { @@ -71,66 +80,67 @@ func (ctx PipelineContextData) ToRawObject() interface{} { } } return m - case 3: + case PipelineContextBool: return *ctx.BoolValue - case 4: + case PipelineContextNumber: return *ctx.NumberValue } return nil } func ToPipelineContextDataWithError(data interface{}) (PipelineContextData, error) { - if b, ok := data.(bool); ok { - var typ int32 = 3 + switch v := data.(type) { + case bool: + var typ int32 = PipelineContextBool return PipelineContextData{ Type: &typ, - BoolValue: &b, + BoolValue: &v, }, nil - } else if n, ok := data.(float64); ok { - var typ int32 = 4 + case float64: + var typ int32 = PipelineContextNumber return PipelineContextData{ Type: &typ, - NumberValue: &n, + NumberValue: &v, }, nil - } else if s, ok := data.(string); ok { - var typ int32 + case string: + var typ int32 = PipelineContextString return PipelineContextData{ Type: &typ, - StringValue: &s, + StringValue: &v, }, nil - } else if a, ok := data.([]interface{}); ok { + case []interface{}: arr := []PipelineContextData{} - for _, v := range a { - e, err := ToPipelineContextDataWithError(v) + for _, elem := range v { + e, err := ToPipelineContextDataWithError(elem) if err != nil { return PipelineContextData{}, err } arr = append(arr, e) } - var typ int32 = 1 + var typ int32 = PipelineContextArray return PipelineContextData{ Type: &typ, ArrayValue: &arr, }, nil - } else if o, ok := data.(map[string]interface{}); ok { + case map[string]interface{}: obj := []DictionaryContextDataPair{} - for k, v := range o { - e, err := ToPipelineContextDataWithError(v) + for k, val := range v { + e, err := ToPipelineContextDataWithError(val) if err != nil { return PipelineContextData{}, err } obj = append(obj, DictionaryContextDataPair{Key: k, Value: e}) } - var typ int32 = 2 + var typ int32 = PipelineContextDictionary return PipelineContextData{ Type: &typ, DictionaryValue: &obj, }, nil - } - if data == nil { + case nil: return PipelineContextData{}, nil + default: + return PipelineContextData{}, fmt.Errorf("unknown type") } - return PipelineContextData{}, fmt.Errorf("unknown type") } func ToPipelineContextData(data interface{}) PipelineContextData { diff --git a/protocol/results/contracts.go b/protocol/results/contracts.go index 2be3711..d9eaef5 100644 --- a/protocol/results/contracts.go +++ b/protocol/results/contracts.go @@ -1,162 +1,162 @@ -package results - -import ( - "time" - - "github.com/ChristopherHX/github-act-runner/protocol" -) - -type GetSignedStepSummaryURLRequest struct { - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` - StepBackendId string `json:"step_backend_id,omitempty"` -} - -type GetSignedStepSummaryURLResponse struct { - SummaryUrl string `json:"summary_url,omitempty"` - SoftSizeLimit int64 `json:"soft_size_limit,omitempty"` - BlobStorageType string `json:"blob_storage_type,omitempty"` -} - -type StepSummaryMetadataCreate struct { - StepBackendId string `json:"step_backend_id,omitempty"` - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - Size int64 `json:"size,omitempty"` - UploadedAt string `json:"uploaded_at,omitempty"` -} - -type GetSignedJobLogsURLRequest struct { - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` -} - -type GetSignedJobLogsURLResponse struct { - LogsUrl string `json:"logs_url,omitempty"` - BlobStorageType string `json:"blob_storage_type,omitempty"` -} - -type GetSignedStepLogsURLRequest struct { - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` - StepBackendId string `json:"step_backend_id,omitempty"` -} - -type GetSignedStepLogsURLResponse struct { - LogsUrl string `json:"logs_url,omitempty"` - BlobStorageType string `json:"blob_storage_type,omitempty"` - // SoftSizeLimit int64 `json:"soft_size_limit,omitempty"` // a string in the backend? -} - -type JobLogsMetadataCreate struct { - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - UploadedAt string `json:"uploaded_at,omitempty"` - LineCount int64 `json:"line_count,omitempty"` -} - -type StepLogsMetadataCreate struct { - WorkflowRunBackendId string `json:"workflow_run_backend_id,omitempty"` - WorkflowJobRunBackendId string `json:"workflow_job_run_backend_id,omitempty"` - StepBackendId string `json:"step_backend_id,omitempty"` - UploadedAt string `json:"uploaded_at,omitempty"` - LineCount int64 `json:"line_count,omitempty"` -} - -type CreateMetadataResponse struct { - Ok bool `json:"ok,omitempty"` -} - -type StepsUpdateRequest struct { - Steps []Step `json:"steps"` - ChangeOrder int64 `json:"change_order"` - WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` - WorkflowRunBackendID string `json:"workflow_run_backend_id"` -} - -type Step struct { - ExternalID string `json:"external_id"` - Number int32 `json:"number"` - Name string `json:"name"` - Status Status `json:"status"` - StartedAt string `json:"started_at,omitempty"` - CompletedAt string `json:"completed_at,omitempty"` - Conclusion Conclusion `json:"conclusion"` -} - -type Status int - -const ( - StatusUnknown Status = iota - StatusInProgress - StatusPending - StatusCompleted -) - -type Conclusion int - -const ( - ConclusionUnknown Conclusion = 0 - ConclusionSuccess Conclusion = 2 - ConclusionFailure Conclusion = 3 - ConclusionCancelled Conclusion = 4 - ConclusionSkipped Conclusion = 7 -) - -func ConvertTimelineRecordToStep(r protocol.TimelineRecord) Step { - return Step{ - ExternalID: r.ID, - Number: r.Order, - Name: r.Name, - Status: ConvertStateToStatus(r.State), - StartedAt: ConvertTimestamp(&r.StartTime), - CompletedAt: ConvertTimestamp(r.FinishTime), - Conclusion: ConvertResultToConclusion(r.Result), - } -} - -func ConvertTimestamp(s *string) string { - if s == nil || *s == "" { - return "" - } - if t, err := time.Parse(protocol.TimestampInputFormat, *s); err == nil { - return t.Format(TimestampOutputFormat) - } - return "" -} - -func ConvertStateToStatus(s string) Status { - switch s { - case "Completed": - return StatusCompleted - case "Pending": - return StatusPending - case "InProgress": - return StatusInProgress - default: - return StatusUnknown - } -} - -func ConvertResultToConclusion(s *string) Conclusion { - if s == nil { - return ConclusionUnknown - } - switch *s { - case "Succeeded": - return ConclusionSuccess - case "Skipped": - return ConclusionSkipped - case "Failed": - return ConclusionFailure - case "Canceled": - return ConclusionCancelled - default: - return ConclusionUnknown - } -} - -var ( - BlobStorageTypeAzureBlobStorage = "BLOB_STORAGE_TYPE_AZURE" - BlobStorageTypeUnspecified = "BLOB_STORAGE_TYPE_UNSPECIFIED" -) +package results + +import ( + "time" + + "github.com/ChristopherHX/github-act-runner/protocol" +) + +type GetSignedStepSummaryURLRequest struct { + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` + StepBackendID string `json:"step_backend_id,omitempty"` +} + +type GetSignedStepSummaryURLResponse struct { + SummaryURL string `json:"summary_url,omitempty"` + SoftSizeLimit int64 `json:"soft_size_limit,omitempty"` + BlobStorageType string `json:"blob_storage_type,omitempty"` +} + +type StepSummaryMetadataCreate struct { + StepBackendID string `json:"step_backend_id,omitempty"` + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + Size int64 `json:"size,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` +} + +type GetSignedJobLogsURLRequest struct { + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` +} + +type GetSignedJobLogsURLResponse struct { + LogsURL string `json:"logs_url,omitempty"` + BlobStorageType string `json:"blob_storage_type,omitempty"` +} + +type GetSignedStepLogsURLRequest struct { + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` + StepBackendID string `json:"step_backend_id,omitempty"` +} + +type GetSignedStepLogsURLResponse struct { + LogsURL string `json:"logs_url,omitempty"` + BlobStorageType string `json:"blob_storage_type,omitempty"` + // SoftSizeLimit int64 `json:"soft_size_limit,omitempty"` // a string in the backend? +} + +type JobLogsMetadataCreate struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` + LineCount int64 `json:"line_count,omitempty"` +} + +type StepLogsMetadataCreate struct { + WorkflowRunBackendID string `json:"workflow_run_backend_id,omitempty"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id,omitempty"` + StepBackendID string `json:"step_backend_id,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` + LineCount int64 `json:"line_count,omitempty"` +} + +type CreateMetadataResponse struct { + Ok bool `json:"ok,omitempty"` +} + +type StepsUpdateRequest struct { + Steps []Step `json:"steps"` + ChangeOrder int64 `json:"change_order"` + WorkflowJobRunBackendID string `json:"workflow_job_run_backend_id"` + WorkflowRunBackendID string `json:"workflow_run_backend_id"` +} + +type Step struct { + ExternalID string `json:"external_id"` + Number int32 `json:"number"` + Name string `json:"name"` + Status Status `json:"status"` + StartedAt string `json:"started_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + Conclusion Conclusion `json:"conclusion"` +} + +type Status int + +const ( + StatusUnknown Status = iota + StatusInProgress + StatusPending + StatusCompleted +) + +type Conclusion int + +const ( + ConclusionUnknown Conclusion = 0 + ConclusionSuccess Conclusion = 2 + ConclusionFailure Conclusion = 3 + ConclusionCancelled Conclusion = 4 + ConclusionSkipped Conclusion = 7 +) + +func ConvertTimelineRecordToStep(r *protocol.TimelineRecord) Step { + return Step{ + ExternalID: r.ID, + Number: r.Order, + Name: r.Name, + Status: ConvertStateToStatus(r.State), + StartedAt: ConvertTimestamp(&r.StartTime), + CompletedAt: ConvertTimestamp(r.FinishTime), + Conclusion: ConvertResultToConclusion(r.Result), + } +} + +func ConvertTimestamp(s *string) string { + if s == nil || *s == "" { + return "" + } + if t, err := time.Parse(protocol.TimestampInputFormat, *s); err == nil { + return t.Format(TimestampOutputFormat) + } + return "" +} + +func ConvertStateToStatus(s string) Status { + switch s { + case "Completed": + return StatusCompleted + case "Pending": + return StatusPending + case "InProgress": + return StatusInProgress + default: + return StatusUnknown + } +} + +func ConvertResultToConclusion(s *string) Conclusion { + if s == nil { + return ConclusionUnknown + } + switch *s { + case "Succeeded": + return ConclusionSuccess + case "Skipped": + return ConclusionSkipped + case "Failed": + return ConclusionFailure + case "Canceled": + return ConclusionCancelled + default: + return ConclusionUnknown + } +} + +var ( + BlobStorageTypeAzureBlobStorage = "BLOB_STORAGE_TYPE_AZURE" + BlobStorageTypeUnspecified = "BLOB_STORAGE_TYPE_UNSPECIFIED" +) diff --git a/protocol/results/service.go b/protocol/results/service.go index 333ecc2..0b94494 100644 --- a/protocol/results/service.go +++ b/protocol/results/service.go @@ -1,237 +1,259 @@ -package results - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "time" - - "github.com/ChristopherHX/github-act-runner/protocol" -) - -type ResultsService struct { - Connection *protocol.VssConnection -} - -func (rs *ResultsService) UploadBlockFileAsync(ctx context.Context, url string, blobStorageType string, fileContent io.Reader) error { - request, err := http.NewRequestWithContext(ctx, "PUT", url, fileContent) - if err != nil { - return err - } - if blobStorageType == BlobStorageTypeAzureBlobStorage { - request.Header.Set(AzureBlobTypeHeader, AzureBlockBlob) - } - response, err := rs.Connection.HttpClient().Do(request) - if err != nil { - return fmt.Errorf("failed to upload file, error %v", err.Error()) - } - if response.StatusCode >= 200 && response.StatusCode < 300 { - return nil - } - return fmt.Errorf("failed to upload file, status code: %v", response.StatusCode) -} - -func (rs *ResultsService) CreateAppendFileAsync(ctx context.Context, url string, blobStorageType string) error { - request, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBufferString("")) - if err != nil { - return err - } - if blobStorageType == BlobStorageTypeAzureBlobStorage { - request.Header.Set(AzureBlobTypeHeader, AzureAppendBlob) - request.Header.Set("Content-Length", "0") - } - response, err := rs.Connection.HttpClient().Do(request) - if err != nil { - return fmt.Errorf("failed to create append file, error %v", err.Error()) - } - if response.StatusCode >= 200 && response.StatusCode < 300 { - return nil - } - return fmt.Errorf("failed to create append file, status code: %v", response.StatusCode) -} - -func (rs *ResultsService) UploadAppendFileAsync(ctx context.Context, url string, blobStorageType string, fileContent io.Reader, finalize bool, fileSize int64) error { - comp := "&comp=appendblock" - if finalize { - comp = "&comp=appendblock&seal=true" - } - request, err := http.NewRequestWithContext(ctx, "PUT", url+comp, fileContent) - if err != nil { - return err - } - if blobStorageType == BlobStorageTypeAzureBlobStorage { - request.Header.Set(AzureBlobSealedHeader, fmt.Sprint(finalize)) - request.Header.Set("Content-Length", fmt.Sprint(fileSize)) - } - response, err := rs.Connection.HttpClient().Do(request) - if err != nil { - return fmt.Errorf("failed to upload append file, error %v", err.Error()) - } - if response.StatusCode >= 200 && response.StatusCode < 300 { - return nil - } - return fmt.Errorf("failed to upload append file, status code: %v", response.StatusCode) -} - -func (rs *ResultsService) UploadResultsStepSummaryAsync(ctx context.Context, planId string, jobId string, stepId string, fileContent io.Reader, fileSize int64) error { - req := &GetSignedStepSummaryURLRequest{ - WorkflowRunBackendId: planId, - WorkflowJobRunBackendId: jobId, - StepBackendId: stepId, - } - uploadUrlResponse := &GetSignedStepSummaryURLResponse{} - url, err := rs.Connection.BuildURL(GetStepSummarySignedBlobURL, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadUrlResponse); err != nil { - return err - } - if uploadUrlResponse.SummaryUrl == "" { - return fmt.Errorf("failed to get step log upload url") - } - if fileSize > uploadUrlResponse.SoftSizeLimit { - return fmt.Errorf("file size is larger than the upload url allows, file size: %v, upload url size: %v", fileSize, uploadUrlResponse.SoftSizeLimit) - } - err = rs.UploadBlockFileAsync(ctx, uploadUrlResponse.SummaryUrl, uploadUrlResponse.BlobStorageType, fileContent) - if err != nil { - return err - } - timestamp := time.Now().UTC().Format(TimestampOutputFormat) - mreq := &StepSummaryMetadataCreate{ - WorkflowJobRunBackendId: jobId, - WorkflowRunBackendId: planId, - StepBackendId: stepId, - UploadedAt: timestamp, - } - url, err = rs.Connection.BuildURL(CreateStepSummaryMetadata, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", mreq, nil); err != nil { - return err - } - return nil -} - -func (rs *ResultsService) UploadResultsStepLogAsync(ctx context.Context, planId string, jobId string, stepId string, fileContent io.Reader, fileSize int64, finalize bool, firstBlock bool, lineCount int64) error { - req := &GetSignedStepLogsURLRequest{ - WorkflowRunBackendId: planId, - WorkflowJobRunBackendId: jobId, - StepBackendId: stepId, - } - uploadUrlResponse := &GetSignedStepLogsURLResponse{} - url, err := rs.Connection.BuildURL(GetStepLogsSignedBlobURL, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadUrlResponse); err != nil { - return err - } - if uploadUrlResponse.LogsUrl == "" { - return fmt.Errorf("failed to get step log upload url") - } - if firstBlock { - err := rs.CreateAppendFileAsync(ctx, uploadUrlResponse.LogsUrl, uploadUrlResponse.BlobStorageType) - if err != nil { - return err - } - } - err = rs.UploadAppendFileAsync(ctx, uploadUrlResponse.LogsUrl, uploadUrlResponse.BlobStorageType, fileContent, finalize, fileSize) - if err != nil { - return err - } - if finalize { - timestamp := time.Now().UTC().Format(TimestampOutputFormat) - req := &StepLogsMetadataCreate{ - WorkflowJobRunBackendId: jobId, - WorkflowRunBackendId: planId, - StepBackendId: stepId, - UploadedAt: timestamp, - LineCount: lineCount, - } - url, err := rs.Connection.BuildURL(CreateStepLogsMetadata, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, nil); err != nil { - return err - } - } - return nil -} - -func (rs *ResultsService) UploadResultsJobLogAsync(ctx context.Context, planId string, jobId string, fileContent io.Reader, fileSize int64, finalize bool, firstBlock bool, lineCount int64) error { - req := &GetSignedJobLogsURLRequest{ - WorkflowRunBackendId: planId, - WorkflowJobRunBackendId: jobId, - } - uploadUrlResponse := &GetSignedJobLogsURLResponse{} - url, err := rs.Connection.BuildURL(GetJobLogsSignedBlobURL, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadUrlResponse); err != nil { - return err - } - if uploadUrlResponse.LogsUrl == "" { - return fmt.Errorf("failed to get step log upload url") - } - if firstBlock { - err := rs.CreateAppendFileAsync(ctx, uploadUrlResponse.LogsUrl, uploadUrlResponse.BlobStorageType) - if err != nil { - return err - } - } - err = rs.UploadAppendFileAsync(ctx, uploadUrlResponse.LogsUrl, uploadUrlResponse.BlobStorageType, fileContent, finalize, fileSize) - if err != nil { - return err - } - if finalize { - timestamp := time.Now().UTC().Format(TimestampOutputFormat) - req := &JobLogsMetadataCreate{ - WorkflowJobRunBackendId: jobId, - WorkflowRunBackendId: planId, - UploadedAt: timestamp, - LineCount: lineCount, - } - url, err := rs.Connection.BuildURL(CreateJobLogsMetadata, nil, nil) - if err != nil { - return err - } - if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, nil); err != nil { - return err - } - } - return nil -} - -func (rs *ResultsService) UpdateWorkflowStepsAsync(ctx context.Context, update *StepsUpdateRequest) error { - url, err := rs.Connection.BuildURL(WorkflowStepsUpdate, nil, nil) - if err != nil { - return err - } - return rs.Connection.RequestWithContext2(ctx, "POST", url, "", update, nil) -} - -var ( - TimestampInputFormat = "2006-01-02T15:04:05.999Z07:00" // allow to omit fractional seconds - TimestampOutputFormat = "2006-01-02T15:04:05.000Z07:00" // dotnet "yyyy-MM-dd'T'HH:mm:ss.fffK" - - ResultsReceiverTwirpEndpoint = "twirp/results.services.receiver.Receiver/" - GetStepSummarySignedBlobURL = ResultsReceiverTwirpEndpoint + "GetStepSummarySignedBlobURL" - CreateStepSummaryMetadata = ResultsReceiverTwirpEndpoint + "CreateStepSummaryMetadata" - GetStepLogsSignedBlobURL = ResultsReceiverTwirpEndpoint + "GetStepLogsSignedBlobURL" - CreateStepLogsMetadata = ResultsReceiverTwirpEndpoint + "CreateStepLogsMetadata" - GetJobLogsSignedBlobURL = ResultsReceiverTwirpEndpoint + "GetJobLogsSignedBlobURL" - CreateJobLogsMetadata = ResultsReceiverTwirpEndpoint + "CreateJobLogsMetadata" - ResultsProtoApiV1Endpoint = "twirp/github.actions.results.api.v1.WorkflowStepUpdateService/" - WorkflowStepsUpdate = ResultsProtoApiV1Endpoint + "WorkflowStepsUpdate" - - AzureBlobSealedHeader = "x-ms-blob-sealed" - AzureBlobTypeHeader = "x-ms-blob-type" - AzureBlockBlob = "BlockBlob" - AzureAppendBlob = "AppendBlob" -) +package results + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/ChristopherHX/github-act-runner/protocol" +) + +type ResultsService struct { + Connection *protocol.VssConnection +} + +func (rs *ResultsService) UploadBlockFileAsync(ctx context.Context, url, blobStorageType string, fileContent io.Reader) error { + request, err := http.NewRequestWithContext(ctx, "PUT", url, fileContent) + if err != nil { + return err + } + if blobStorageType == BlobStorageTypeAzureBlobStorage { + request.Header.Set(AzureBlobTypeHeader, AzureBlockBlob) + } + response, err := rs.Connection.HTTPClient().Do(request) + if err != nil { + return fmt.Errorf("failed to upload file, error %v", err.Error()) + } + defer func() { + _ = response.Body.Close() // Ignore error for body close + }() + + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + return fmt.Errorf("failed to upload file, status code: %v", response.StatusCode) +} + +func (rs *ResultsService) CreateAppendFileAsync(ctx context.Context, url, blobStorageType string) error { + request, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewBufferString("")) + if err != nil { + return err + } + if blobStorageType == BlobStorageTypeAzureBlobStorage { + request.Header.Set(AzureBlobTypeHeader, AzureAppendBlob) + request.Header.Set("Content-Length", "0") + } + response, err := rs.Connection.HTTPClient().Do(request) + if err != nil { + return fmt.Errorf("failed to create append file, error %v", err.Error()) + } + defer func() { + _ = response.Body.Close() // Ignore error for body close + }() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + return fmt.Errorf("failed to create append file, status code: %v", response.StatusCode) +} + +func (rs *ResultsService) UploadAppendFileAsync( + ctx context.Context, url, blobStorageType string, fileContent io.Reader, finalize bool, fileSize int64, +) error { + comp := "&comp=appendblock" + if finalize { + comp = "&comp=appendblock&seal=true" + } + request, err := http.NewRequestWithContext(ctx, "PUT", url+comp, fileContent) + if err != nil { + return err + } + if blobStorageType == BlobStorageTypeAzureBlobStorage { + request.Header.Set(AzureBlobSealedHeader, fmt.Sprint(finalize)) + request.Header.Set("Content-Length", fmt.Sprint(fileSize)) + } + response, err := rs.Connection.HTTPClient().Do(request) + if err != nil { + return fmt.Errorf("failed to upload append file, error %v", err.Error()) + } + defer func() { + _ = response.Body.Close() // Ignore error for body close + }() + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + return fmt.Errorf("failed to upload append file, status code: %v", response.StatusCode) +} + +func (rs *ResultsService) UploadResultsStepSummaryAsync( + ctx context.Context, planID, jobID, stepID string, fileContent io.Reader, fileSize int64, +) error { + req := &GetSignedStepSummaryURLRequest{ + WorkflowRunBackendID: planID, + WorkflowJobRunBackendID: jobID, + StepBackendID: stepID, + } + uploadURLResponse := &GetSignedStepSummaryURLResponse{} + url, err := rs.Connection.BuildURL(GetStepSummarySignedBlobURL, nil, nil) + if err != nil { + return err + } + if requestErr := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadURLResponse); requestErr != nil { + return requestErr + } + if uploadURLResponse.SummaryURL == "" { + return fmt.Errorf("failed to get step log upload url") + } + if fileSize > uploadURLResponse.SoftSizeLimit { + return fmt.Errorf( + "file size is larger than the upload url allows, file size: %v, upload url size: %v", + fileSize, + uploadURLResponse.SoftSizeLimit, + ) + } + err = rs.UploadBlockFileAsync(ctx, uploadURLResponse.SummaryURL, uploadURLResponse.BlobStorageType, fileContent) + if err != nil { + return err + } + timestamp := time.Now().UTC().Format(TimestampOutputFormat) + mreq := &StepSummaryMetadataCreate{ + WorkflowJobRunBackendID: jobID, + WorkflowRunBackendID: planID, + StepBackendID: stepID, + UploadedAt: timestamp, + } + url, err = rs.Connection.BuildURL(CreateStepSummaryMetadata, nil, nil) + if err != nil { + return err + } + if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", mreq, nil); err != nil { + return err + } + return nil +} + +func (rs *ResultsService) UploadResultsStepLogAsync( + ctx context.Context, planID, jobID, stepID string, fileContent io.Reader, fileSize int64, finalize, firstBlock bool, lineCount int64, +) error { + req := &GetSignedStepLogsURLRequest{ + WorkflowRunBackendID: planID, + WorkflowJobRunBackendID: jobID, + StepBackendID: stepID, + } + uploadURLResponse := &GetSignedStepLogsURLResponse{} + url, err := rs.Connection.BuildURL(GetStepLogsSignedBlobURL, nil, nil) + if err != nil { + return err + } + if requestErr := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadURLResponse); requestErr != nil { + return requestErr + } + if uploadURLResponse.LogsURL == "" { + return fmt.Errorf("failed to get step log upload url") + } + if firstBlock { + createErr := rs.CreateAppendFileAsync(ctx, uploadURLResponse.LogsURL, uploadURLResponse.BlobStorageType) + if createErr != nil { + return createErr + } + } + err = rs.UploadAppendFileAsync(ctx, uploadURLResponse.LogsURL, uploadURLResponse.BlobStorageType, fileContent, finalize, fileSize) + if err != nil { + return err + } + if finalize { + timestamp := time.Now().UTC().Format(TimestampOutputFormat) + req := &StepLogsMetadataCreate{ + WorkflowJobRunBackendID: jobID, + WorkflowRunBackendID: planID, + StepBackendID: stepID, + UploadedAt: timestamp, + LineCount: lineCount, + } + url, err := rs.Connection.BuildURL(CreateStepLogsMetadata, nil, nil) + if err != nil { + return err + } + if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, nil); err != nil { + return err + } + } + return nil +} + +func (rs *ResultsService) UploadResultsJobLogAsync( + ctx context.Context, planID, jobID string, fileContent io.Reader, fileSize int64, finalize, firstBlock bool, lineCount int64, +) error { + req := &GetSignedJobLogsURLRequest{ + WorkflowRunBackendID: planID, + WorkflowJobRunBackendID: jobID, + } + uploadURLResponse := &GetSignedJobLogsURLResponse{} + url, err := rs.Connection.BuildURL(GetJobLogsSignedBlobURL, nil, nil) + if err != nil { + return err + } + if requestErr := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, uploadURLResponse); requestErr != nil { + return requestErr + } + if uploadURLResponse.LogsURL == "" { + return fmt.Errorf("failed to get step log upload url") + } + if firstBlock { + createErr := rs.CreateAppendFileAsync(ctx, uploadURLResponse.LogsURL, uploadURLResponse.BlobStorageType) + if createErr != nil { + return createErr + } + } + err = rs.UploadAppendFileAsync(ctx, uploadURLResponse.LogsURL, uploadURLResponse.BlobStorageType, fileContent, finalize, fileSize) + if err != nil { + return err + } + if finalize { + timestamp := time.Now().UTC().Format(TimestampOutputFormat) + req := &JobLogsMetadataCreate{ + WorkflowJobRunBackendID: jobID, + WorkflowRunBackendID: planID, + UploadedAt: timestamp, + LineCount: lineCount, + } + url, err := rs.Connection.BuildURL(CreateJobLogsMetadata, nil, nil) + if err != nil { + return err + } + if err := rs.Connection.RequestWithContext2(ctx, "POST", url, "", req, nil); err != nil { + return err + } + } + return nil +} + +func (rs *ResultsService) UpdateWorkflowStepsAsync(ctx context.Context, update *StepsUpdateRequest) error { + url, err := rs.Connection.BuildURL(WorkflowStepsUpdate, nil, nil) + if err != nil { + return err + } + return rs.Connection.RequestWithContext2(ctx, "POST", url, "", update, nil) +} + +var ( + TimestampInputFormat = "2006-01-02T15:04:05.999Z07:00" // allow to omit fractional seconds + TimestampOutputFormat = "2006-01-02T15:04:05.000Z07:00" // dotnet "yyyy-MM-dd'T'HH:mm:ss.fffK" + + ResultsReceiverTwirpEndpoint = "twirp/results.services.receiver.Receiver/" + GetStepSummarySignedBlobURL = ResultsReceiverTwirpEndpoint + "GetStepSummarySignedBlobURL" + CreateStepSummaryMetadata = ResultsReceiverTwirpEndpoint + "CreateStepSummaryMetadata" + GetStepLogsSignedBlobURL = ResultsReceiverTwirpEndpoint + "GetStepLogsSignedBlobURL" + CreateStepLogsMetadata = ResultsReceiverTwirpEndpoint + "CreateStepLogsMetadata" + GetJobLogsSignedBlobURL = ResultsReceiverTwirpEndpoint + "GetJobLogsSignedBlobURL" + CreateJobLogsMetadata = ResultsReceiverTwirpEndpoint + "CreateJobLogsMetadata" + ResultsProtoAPIV1Endpoint = "twirp/github.actions.results.api.v1.WorkflowStepUpdateService/" + WorkflowStepsUpdate = ResultsProtoAPIV1Endpoint + "WorkflowStepsUpdate" + + AzureBlobSealedHeader = "x-ms-blob-sealed" + AzureBlobTypeHeader = "x-ms-blob-type" + AzureBlockBlob = "BlockBlob" + AzureAppendBlob = "AppendBlob" +) diff --git a/protocol/run/contracts.go b/protocol/run/contracts.go index 25047e9..7365fd5 100644 --- a/protocol/run/contracts.go +++ b/protocol/run/contracts.go @@ -51,7 +51,7 @@ func toLowerStringP(p *string) *string { return &ret } -func TimeLineRecordToStepResult(rec protocol.TimelineRecord) StepResult { +func TimeLineRecordToStepResult(rec *protocol.TimelineRecord) StepResult { annotations := make([]Annotation, len(rec.Issues)) for i, issue := range rec.Issues { annotations[i] = IssueToAnnotation(issue) diff --git a/protocol/runneradmin/contracts.go b/protocol/runneradmin/contracts.go index 833f997..e4f3a5f 100644 --- a/protocol/runneradmin/contracts.go +++ b/protocol/runneradmin/contracts.go @@ -1,19 +1,19 @@ package runneradmin type Authorization struct { - AuthorizationUrl string `json:"authorization_url"` - ServerUrl string `json:"server_url"` - ClientId string `json:"client_id"` + AuthorizationURL string `json:"authorization_url"` + ServerURL string `json:"server_url"` + ClientID string `json:"client_id"` } type Runner struct { Name string `json:"name"` - Id int32 `json:"id"` + ID int32 `json:"id"` Authorization Authorization `json:"authorization"` } type RunnerGroup struct { - Id int32 `json:"id,omitempty"` + ID int32 `json:"id,omitempty"` Name string `json:"name,omitempty"` IsDefault bool `json:"default,omitempty"` IsHosted bool `json:"is_hosted,omitempty"` diff --git a/protocol/session.go b/protocol/session.go index 3f5aef1..ef0dce2 100644 --- a/protocol/session.go +++ b/protocol/session.go @@ -8,7 +8,6 @@ import ( "crypto/rsa" "strings" - // nolint:gosec "crypto/sha1" "crypto/sha256" "encoding/base64" @@ -20,6 +19,11 @@ import ( "time" ) +const ( + // Message retry timeout + messageRetryTimeout = 10 * time.Second +) + type TaskAgentMessage struct { MessageID int64 MessageType string @@ -43,7 +47,9 @@ func (message *TaskAgentMessage) Decrypt(block cipher.Block) ([]byte, error) { cbcdec.CryptBlocks(src, src) maxlen := block.BlockSize() validlen := len(src) - if int(src[len(src)-1]) <= maxlen { // <= is needed if the message ends within a block boundary and maxlen=16 then we get 16 times char 16 appended, one whole extra block + // <= is needed if the message ends within a block boundary and maxlen=16 + // then we get 16 times char 16 appended, one whole extra block + if int(src[len(src)-1]) <= maxlen { ok := true for i := 2; i <= int(src[len(src)-1]); i++ { if src[len(src)-i] != src[len(src)-1] { @@ -64,7 +70,7 @@ func (message *TaskAgentMessage) Decrypt(block cipher.Block) ([]byte, error) { } type BrokerMigration struct { - BrokerBaseUrl string `json:"brokerBaseUrl"` + BrokerBaseURL string `json:"brokerBaseUrl"` } func (message *TaskAgentMessage) FetchBrokerIfNeeded(xctx context.Context, session *AgentMessageConnection) error { @@ -80,16 +86,16 @@ func (message *TaskAgentMessage) FetchBrokerIfNeeded(xctx context.Context, sessi return err } for retries := 0; retries < 5; retries++ { - copy := *vssConnection - vssConnection := © - vssConnection.TenantURL = rjrr.BrokerBaseUrl - furl, err := vssConnection.BuildURL("message", map[string]string{}, map[string]string{ + connCopy := *vssConnection + vssConnection := &connCopy + vssConnection.TenantURL = rjrr.BrokerBaseURL + furl, urlErr := vssConnection.BuildURL("message", map[string]string{}, map[string]string{ "sessionId": session.TaskAgentSession.SessionID, "runnerVersion": "3.0.0", "status": session.Status, }) - if err != nil { - return err + if urlErr != nil { + return urlErr } err = vssConnection.RequestWithContext2(xctx, "GET", furl, "", nil, &message) if err == nil || errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { @@ -129,7 +135,6 @@ func (session *TaskAgentSession) GetSessionKey(key *rsa.PrivateKey) (cipher.Bloc if session.UseFipsEncryption { h = sha256.New() } else { - // nolint:gosec // Needed for backward compatibility h = sha1.New() } sessionKey, err = rsa.DecryptOAEP(h, rand.Reader, key, sessionKey, []byte{}) @@ -181,7 +186,7 @@ func (session *AgentMessageConnection) GetNextMessage(ctx context.Context) (*Tas select { case <-ctx.Done(): return nil, context.Canceled - case <-time.After(10 * time.Second): + case <-time.After(messageRetryTimeout): } } } else { diff --git a/protocol/task_agent.go b/protocol/task_agent.go index f8270f1..1196b8c 100644 --- a/protocol/task_agent.go +++ b/protocol/task_agent.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "net/url" "time" @@ -14,6 +14,14 @@ import ( "github.com/google/uuid" ) +const ( + // JWT token expiration time + jwtExpiration = 5 * time.Minute + + // Error message prefix for authorization failures + authFailurePrefix = "Failed to Authorize: " +) + type TaskAgentPublicKey struct { Exponent string Modulus string @@ -62,7 +70,7 @@ func (taskAgent *TaskAgent) Authorize(c *http.Client, key interface{}) (*VssOAut Audience: taskAgent.Authorization.AuthorizationURL, NotBefore: now.Unix(), IssuedAt: now.Unix(), - ExpiresAt: now.Add(time.Minute * 5).Unix(), + ExpiresAt: now.Add(jwtExpiration).Unix(), }) stkn, err := token2.SignedString(key) if err != nil { @@ -74,20 +82,24 @@ func (taskAgent *TaskAgent) Authorize(c *http.Client, key interface{}) (*VssOAut data.Set("client_assertion", stkn) data.Set("grant_type", "client_credentials") + //nolint:noctx // Legacy function without context - would break API compatibility poolsreq, err := http.NewRequest("POST", taskAgent.Authorization.AuthorizationURL, bytes.NewBufferString(data.Encode())) if err != nil { - return nil, errors.New("Failed to Authorize: " + err.Error()) + return nil, errors.New(authFailurePrefix + err.Error()) } poolsreq.Header["Content-Type"] = []string{"application/x-www-form-urlencoded; charset=utf-8"} poolsreq.Header["Accept"] = []string{"application/json"} poolsresp, err := c.Do(poolsreq) if err != nil { - return nil, errors.New("Failed to Authorize: " + err.Error()) + return nil, errors.New(authFailurePrefix + err.Error()) } - defer poolsresp.Body.Close() - if poolsresp.StatusCode != 200 { - bytes, _ := ioutil.ReadAll(poolsresp.Body) - return nil, errors.New("Failed to Authorize, service responded with code " + fmt.Sprint(poolsresp.StatusCode) + ": " + string(bytes)) + defer func() { + _ = poolsresp.Body.Close() // Ignore close error + }() + if poolsresp.StatusCode != http.StatusOK { + responseBytes, _ := io.ReadAll(poolsresp.Body) + return nil, errors.New("Failed to Authorize, service responded with code " + fmt.Sprint(poolsresp.StatusCode) + + ": " + string(responseBytes)) } dec := json.NewDecoder(poolsresp.Body) if err := dec.Decode(tokenresp); err != nil { diff --git a/protocol/template_token.go b/protocol/template_token.go index 98a2a90..2f12515 100644 --- a/protocol/template_token.go +++ b/protocol/template_token.go @@ -3,12 +3,41 @@ package protocol import ( "encoding/json" "fmt" + "math" "regexp" "strings" "gopkg.in/yaml.v3" ) +const ( + // TemplateToken types + TokenTypeLiteral = 0 // Literal string + TokenTypeSequence = 1 // Array/sequence + TokenTypeMapping = 2 // Object/mapping + TokenTypeExpression = 3 // Expression to be evaluated + TokenTypeInsert = 4 // Insert directive + TokenTypeBool = 5 // Bool type + TokenTypeNumber = 6 // Number type + TokenTypeNull = 7 // Null + + // YAML mapping constants + yamlKeyValuePairs = 2 // YAML mapping nodes have key-value pairs + + // Map entry allocation multiplier (accounts for key-value pairs) + mapEntryMultiplier = 2 + // Expression parsing constants + expressionEndOffset = 2 // Length of "}}" + expressionStartOffset = 3 // Length of "${{" + 1 + + // Template token delimiters + templateOpenToken = "${{" + templateCloseToken = "}}" + + // Template directive names + insertDirective = "insert" +) + type MapEntry struct { Key *TemplateToken Value *TemplateToken @@ -146,7 +175,7 @@ func rewriteSubExpression(in string, forceFormat bool) (string, bool) { if exprEnd > -1 { formatOut += fmt.Sprintf("{%d}", len(results)) results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) - pos += exprEnd + 2 + pos += exprEnd + expressionEndOffset exprStart = -1 } else if strStart > -1 { pos += strStart + 1 @@ -157,7 +186,7 @@ func rewriteSubExpression(in string, forceFormat bool) (string, bool) { exprStart = strings.Index(in[pos:], "${{") if exprStart != -1 { formatOut += escapeFormatString(in[pos : pos+exprStart]) - exprStart = pos + exprStart + 3 + exprStart = pos + exprStart + expressionStartOffset pos = exprStart } else { formatOut += escapeFormatString(in[pos:]) @@ -180,15 +209,14 @@ func (converter *TemplateTokenConverter) FromRawObject(value interface{}) (*Temp if converter.AllowExpressions { // Resolve potential nested expressions and convert them to an expressions object if expr, ok := rewriteSubExpression(val, false); ok { - if expr == "insert" { - return &TemplateToken{Type: 4, Directive: &expr}, nil + if expr == insertDirective { + return &TemplateToken{Type: TokenTypeInsert, Directive: &expr}, nil } else { - return &TemplateToken{Type: 3, Expr: &expr}, nil - + return &TemplateToken{Type: TokenTypeExpression, Expr: &expr}, nil } } } - return &TemplateToken{Type: 0, Lit: &val}, nil + return &TemplateToken{Type: TokenTypeLiteral, Lit: &val}, nil case []interface{}: a := val seq := make([]*TemplateToken, len(a)) @@ -199,9 +227,9 @@ func (converter *TemplateTokenConverter) FromRawObject(value interface{}) (*Temp return nil, err } } - return &TemplateToken{Type: 1, Seq: &seq}, nil + return &TemplateToken{Type: TokenTypeSequence, Seq: &seq}, nil case map[string]interface{}: - _map := make([]MapEntry, 0, 2*len(val)) + _map := make([]MapEntry, 0, mapEntryMultiplier*len(val)) for k, v := range val { key, err := converter.FromRawObject(k) if err != nil { @@ -216,9 +244,9 @@ func (converter *TemplateTokenConverter) FromRawObject(value interface{}) (*Temp Value: value, }) } - return &TemplateToken{Type: 2, Map: &_map}, nil + return &TemplateToken{Type: TokenTypeMapping, Map: &_map}, nil case map[interface{}]interface{}: - _map := make([]MapEntry, 0, 2*len(val)) + _map := make([]MapEntry, 0, mapEntryMultiplier*len(val)) for k, v := range val { key, err := converter.FromRawObject(k) if err != nil { @@ -233,11 +261,11 @@ func (converter *TemplateTokenConverter) FromRawObject(value interface{}) (*Temp Value: value, }) } - return &TemplateToken{Type: 2, Map: &_map}, nil + return &TemplateToken{Type: TokenTypeMapping, Map: &_map}, nil case bool: - return &TemplateToken{Type: 5, Bool: &val}, nil + return &TemplateToken{Type: TokenTypeBool, Bool: &val}, nil case float64: - return &TemplateToken{Type: 6, Num: &val}, nil + return &TemplateToken{Type: TokenTypeNumber, Num: &val}, nil default: return nil, fmt.Errorf("unexpected TemplateToken type: %v", val) } @@ -245,12 +273,12 @@ func (converter *TemplateTokenConverter) FromRawObject(value interface{}) (*Temp func (converter *TemplateTokenConverter) ToRawObject(token *TemplateToken) (interface{}, error) { switch token.Type { - case 0: + case TokenTypeLiteral: if converter.AllowExpressions { return escapeExpression(*token.Lit), nil } return *token.Lit, nil - case 1: + case TokenTypeSequence: a := make([]interface{}, 0) for _, v := range *token.Seq { c, err := converter.ToRawObject(v) @@ -260,7 +288,7 @@ func (converter *TemplateTokenConverter) ToRawObject(token *TemplateToken) (inte a = append(a, c) } return a, nil - case 2: + case TokenTypeMapping: if !converter.StringKeys { m := make(map[interface{}]interface{}) for _, v := range *token.Map { @@ -292,19 +320,19 @@ func (converter *TemplateTokenConverter) ToRawObject(token *TemplateToken) (inte } return m, nil } - case 3: + case TokenTypeExpression: if !converter.AllowExpressions { return nil, fmt.Errorf("expressions are not allowed: %s", *token.Expr) } - return "${{" + *token.Expr + "}}", nil - case 4: + return templateOpenToken + *token.Expr + templateCloseToken, nil + case TokenTypeInsert: if !converter.AllowExpressions { return nil, fmt.Errorf("directives are not allowed: %s", *token.Directive) } - return "${{" + *token.Directive + "}}", nil - case 5: + return templateOpenToken + *token.Directive + templateCloseToken, nil + case TokenTypeBool: return *token.Bool, nil - case 6: + case TokenTypeNumber: return *token.Num, nil default: return nil, fmt.Errorf("unexpected TemplateToken type: %v", token.Type) @@ -339,7 +367,7 @@ func (converter *TemplateTokenConverter) ToYamlNode(token *TemplateToken) (ret * a = append(a, r) } return &yaml.Node{Kind: yaml.SequenceNode, Content: a}, nil - case 2: + case TokenTypeMapping: a := make([]*yaml.Node, 0) for _, v := range *token.Map { k, err := converter.ToYamlNode(v.Key) @@ -353,23 +381,24 @@ func (converter *TemplateTokenConverter) ToYamlNode(token *TemplateToken) (ret * a = append(a, k, v) } return &yaml.Node{Kind: yaml.MappingNode, Content: a}, nil - case 3: + case TokenTypeExpression: if !converter.AllowExpressions { return nil, fmt.Errorf("expressions are not allowed: %s", *token.Expr) } - return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.DoubleQuotedStyle, Value: "${{" + *token.Expr + "}}"}, nil - case 4: + return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.DoubleQuotedStyle, Value: templateOpenToken + *token.Expr + templateCloseToken}, nil + case TokenTypeInsert: if !converter.AllowExpressions { return nil, fmt.Errorf("directives are not allowed: %s", *token.Expr) } - return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.DoubleQuotedStyle, Value: "${{" + *token.Directive + "}}"}, nil - case 5: + return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.DoubleQuotedStyle, + Value: templateOpenToken + *token.Directive + templateCloseToken}, nil + case TokenTypeBool: val, _ := yaml.Marshal(token.Bool) return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.FlowStyle, Value: string(val[:len(val)-1])}, nil - case 6: + case TokenTypeNumber: val, _ := yaml.Marshal(token.Num) return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.FlowStyle, Value: string(val[:len(val)-1])}, nil - case 7: + case TokenTypeNull: return &yaml.Node{Kind: yaml.ScalarNode, Style: yaml.FlowStyle, Value: "null"}, nil default: return nil, fmt.Errorf("unexpected TemplateToken type: %v", token.Type) @@ -379,46 +408,53 @@ func (converter *TemplateTokenConverter) ToYamlNode(token *TemplateToken) (ret * func (converter *TemplateTokenConverter) FromYamlNode(node *yaml.Node) (ret *TemplateToken, err error) { defer func() { if ret != nil && (node.Column != 0 || node.Line != 0) { - column := int32(node.Column) - line := int32(node.Line) - ret.Column = &column - ret.Line = &line + // Check for integer overflow before conversion + if node.Column <= math.MaxInt32 && node.Line <= math.MaxInt32 && node.Column >= math.MinInt32 && node.Line >= math.MinInt32 { + column := int32(node.Column) //nolint:gosec // bounds checked above + line := int32(node.Line) //nolint:gosec // bounds checked above + ret.Column = &column + ret.Line = &line + } } }() - retNil := func() (*TemplateToken, error) { + retNil := func() *TemplateToken { if converter.IgnoreDefaultValues { - return nil, nil + return nil } - return &TemplateToken{Type: 7}, nil + return &TemplateToken{Type: TokenTypeNull} } if node == nil || node.IsZero() { - return retNil() + return retNil(), nil } switch node.Kind { case yaml.DocumentNode: return converter.FromYamlNode(node.Content[0]) + case yaml.AliasNode: + return converter.FromYamlNode(node.Alias) case yaml.ScalarNode: var number float64 var c interface{} var val interface{} if node.Tag == "!!null" || converter.IgnoreDefaultValues && node.Value == "" { - return retNil() + return retNil(), nil } - if err := node.Decode(&number); err == nil { + if decodeErr := node.Decode(&number); decodeErr == nil { if converter.IgnoreDefaultValues && number == 0 { return nil, nil } val = number - } else if err := node.Decode(&c); err == nil { - if b, ok := c.(bool); ok { - if !converter.IgnoreDefaultValues || b { - val = b + } else if decodeErr := node.Decode(&c); decodeErr == nil { + switch val := c.(type) { + case bool: + if !converter.IgnoreDefaultValues || val { + c = val } - } else if s, ok := c.(string); ok { - if !converter.IgnoreDefaultValues || s != "" { - val = s + case string: + if !converter.IgnoreDefaultValues || val != "" { + c = val } } + val = c } return converter.FromRawObject(val) case yaml.SequenceNode: @@ -434,9 +470,9 @@ func (converter *TemplateTokenConverter) FromYamlNode(node *yaml.Node) (ret *Tem Seq: &content, }, nil case yaml.MappingNode: - cap := len(node.Content) / 2 - content := make([]MapEntry, 0, cap) - for i := 0; i < cap; i++ { + capacity := len(node.Content) / yamlKeyValuePairs + content := make([]MapEntry, 0, capacity) + for i := 0; i < capacity; i++ { key, err := converter.FromYamlNode(node.Content[i*2]) if err != nil { return nil, err @@ -451,7 +487,7 @@ func (converter *TemplateTokenConverter) FromYamlNode(node *yaml.Node) (ret *Tem } } return &TemplateToken{ - Type: 2, + Type: TokenTypeMapping, Map: &content, }, nil default: diff --git a/protocol/timeline.go b/protocol/timeline.go index 74e3475..3a670b6 100644 --- a/protocol/timeline.go +++ b/protocol/timeline.go @@ -67,24 +67,24 @@ type TimelineRecordFeedLinesWrapper struct { } func (rec *TimelineRecord) Start() { - time := time.Now().UTC().Format(TimestampOutputFormat) + timestamp := time.Now().UTC().Format(TimestampOutputFormat) rec.PercentComplete = 0 rec.State = "InProgress" - rec.StartTime = time + rec.StartTime = timestamp rec.FinishTime = nil - rec.LastModified = time + rec.LastModified = timestamp } func (rec *TimelineRecord) Complete(res string) { - time := time.Now().UTC().Format(TimestampOutputFormat) + timestamp := time.Now().UTC().Format(TimestampOutputFormat) rec.PercentComplete = 100 rec.State = "Completed" - rec.FinishTime = &time - rec.LastModified = time + rec.FinishTime = ×tamp + rec.LastModified = timestamp rec.Result = &res } -func CreateTimelineEntry(parent string, refname string, name string) TimelineRecord { +func CreateTimelineEntry(parent, refname, name string) TimelineRecord { record := TimelineRecord{} record.ID = uuid.New().String() record.RefName = refname diff --git a/runnerconfiguration/add.go b/runnerconfiguration/add.go index 9898c45..8d96268 100644 --- a/runnerconfiguration/add.go +++ b/runnerconfiguration/add.go @@ -12,9 +12,17 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/ChristopherHX/github-act-runner/common" "github.com/ChristopherHX/github-act-runner/protocol" - "github.com/google/uuid" +) + +const ( + // RSA key generation constants + rsaKeyBits = 2048 + rsaExponentBytes = 4 + defaultLabelsCount = 3 ) func containsEphemeralConfiguration(settings *RunnerSettings) bool { @@ -29,26 +37,30 @@ func containsEphemeralConfiguration(settings *RunnerSettings) bool { return false } -func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey, auth *protocol.GitHubAuthResult) (*RunnerSettings, error) { +func (config *ConfigureRunner) Configure( + settings *RunnerSettings, + survey Survey, + auth *protocol.GitHubAuthResult, +) (*RunnerSettings, error) { instance := &RunnerInstance{ RunnerGuard: config.RunnerGuard, WorkFolder: config.WorkFolder, } if config.Ephemeral && len(settings.Instances) > 0 || containsEphemeralConfiguration(settings) { - return nil, fmt.Errorf("ephemeral is not supported for multi runners, runner already configured.") + return nil, fmt.Errorf("ephemeral is not supported for multi runners, runner already configured") } - if len(config.URL) == 0 { + if config.URL == "" { if !config.Unattended { config.URL = survey.GetInput("Please enter your repository, organization or enterprise url:", "") } else { return nil, fmt.Errorf("no url provided") } } - if len(config.URL) == 0 { + if config.URL == "" { return nil, fmt.Errorf("no url provided") } instance.RegistrationURL = config.URL - c := config.GetHttpClient() + c := config.GetHTTPClient() res := auth if res == nil { authres, err := config.Authenticate(c, survey) @@ -70,7 +82,7 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey taskAgentPools := []string{} _taskAgentPools, err := vssConnection.GetAgentPools() if err != nil { - return nil, fmt.Errorf("failed to configure runner: %v\n", err) + return nil, fmt.Errorf("failed to configure runner: %v", err) } for _, val := range _taskAgentPools.Value { if !val.IsHosted { @@ -80,7 +92,7 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey if len(taskAgentPools) == 0 { return nil, fmt.Errorf("failed to configure runner, no self-hosted runner group available") } - if len(config.RunnerGroup) > 0 { + if config.RunnerGroup != "" { taskAgentPool = config.RunnerGroup } else { taskAgentPool = taskAgentPools[0] @@ -95,20 +107,24 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey } } if vssConnection.PoolID < 0 { - return nil, fmt.Errorf("runner Pool %v not found\n", taskAgentPool) + return nil, fmt.Errorf("runner Pool %v not found", taskAgentPool) } } - key, _ := rsa.GenerateKey(rand.Reader, 2048) + key, _ := rsa.GenerateKey(rand.Reader, rsaKeyBits) instance.Key = base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PrivateKey(key)) taskAgent := &protocol.TaskAgent{} - bs := make([]byte, 4) - ui := uint32(key.E) + bs := make([]byte, rsaExponentBytes) + ui := uint32(key.E) //nolint:gosec binary.BigEndian.PutUint32(bs, ui) expof := 0 - for ; expof < 3 && bs[expof] == 0; expof++ { + for ; expof < 3 && bs[expof] == 0; expof++ { //nolint:revive // empty-block: intentionally empty loop to find non-zero bytes + // Skip leading zero bytes + } + taskAgent.Authorization.PublicKey = protocol.TaskAgentPublicKey{ + Exponent: base64.StdEncoding.EncodeToString(bs[expof:]), + Modulus: base64.StdEncoding.EncodeToString(key.N.Bytes()), } - taskAgent.Authorization.PublicKey = protocol.TaskAgentPublicKey{Exponent: base64.StdEncoding.EncodeToString(bs[expof:]), Modulus: base64.StdEncoding.EncodeToString(key.N.Bytes())} taskAgent.Version = "3.0.0" // version, will not use fips crypto if set to 0.0.0 * taskAgent.OSDescription = "github-act-runner " + runtime.GOOS + "/" + runtime.GOARCH if config.Name != "" { @@ -120,11 +136,11 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey } } if !config.Unattended && len(config.Labels) == 0 { - if res := survey.GetInput("Please enter custom labels of your new runner (case insensitive, separated by ','):", ""); len(res) > 0 { + if res := survey.GetInput("Please enter custom labels of your new runner (case insensitive, separated by ','):", ""); res != "" { config.Labels = strings.Split(res, ",") } } - systemLabels := make([]string, 0, 3) + systemLabels := make([]string, 0, defaultLabelsCount) if !config.NoDefaultLabels { systemLabels = append(systemLabels, "self-hosted", runtime.GOOS, runtime.GOARCH) } @@ -149,7 +165,7 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey }, map[string]string{}, taskAgent, taskAgent) if err != nil { if !config.Replace { - return nil, fmt.Errorf("failed to create taskAgent: %v\n", err.Error()) + return nil, fmt.Errorf("failed to create taskAgent: %v", err.Error()) } // Try replaceing runner if creation failed taskAgents := &protocol.TaskAgents{} @@ -157,7 +173,7 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey "poolId": fmt.Sprint(vssConnection.PoolID), }, map[string]string{}, nil, taskAgents) if err != nil { - return nil, fmt.Errorf("failed to update taskAgent: %v\n", err.Error()) + return nil, fmt.Errorf("failed to update taskAgent: %v", err.Error()) } invalid := true for i := 0; i < len(taskAgents.Value); i++ { @@ -168,14 +184,14 @@ func (config *ConfigureRunner) Configure(settings *RunnerSettings, survey Survey } } if invalid { - return nil, fmt.Errorf("failed to update taskAgent: Failed to find agent") + return nil, fmt.Errorf("failed to update taskAgent: failed to find agent") } err = vssConnection.Request("e298ef32-5878-4cab-993c-043836571f42", "6.0-preview.2", "PUT", map[string]string{ "poolId": fmt.Sprint(vssConnection.PoolID), "agentId": fmt.Sprint(taskAgent.ID), }, map[string]string{}, taskAgent, taskAgent) if err != nil { - return nil, fmt.Errorf("failed to update taskAgent: %v\n", err.Error()) + return nil, fmt.Errorf("failed to update taskAgent: %v", err.Error()) } } } @@ -197,7 +213,7 @@ func (config *ConfigureRunner) ReadFromEnvironment() { config.DisableUpdate = v } } - if len(config.Name) == 0 { + if config.Name == "" { if v, ok := os.LookupEnv("ACTIONS_RUNNER_INPUT_NAME"); ok { config.Name = v } diff --git a/runnerconfiguration/common.go b/runnerconfiguration/common.go index f653b36..eb9b4f4 100644 --- a/runnerconfiguration/common.go +++ b/runnerconfiguration/common.go @@ -18,6 +18,13 @@ import ( "github.com/ChristopherHX/github-act-runner/protocol" ) +const ( + // HTTP client timeout + httpClientTimeout = 100 * time.Second + // Path segments for repository URL validation + repositoryPathSegments = 2 +) + type ConfigureRemoveRunner struct { Client *http.Client URL string @@ -28,16 +35,17 @@ type ConfigureRemoveRunner struct { Trace bool } -func (c *ConfigureRemoveRunner) GetHttpClient() *http.Client { +func (c *ConfigureRemoveRunner) GetHTTPClient() *http.Client { if c.Client != nil { return c.Client } customTransport := http.DefaultTransport.(*http.Transport).Clone() if v, ok := common.LookupEnvBool("SKIP_TLS_CERT_VALIDATION"); ok && v { + //nolint:gosec // Intentionally allows insecure TLS when explicitly configured customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } c.Client = &http.Client{ - Timeout: 100 * time.Second, + Timeout: httpClientTimeout, Transport: customTransport, } return c.Client @@ -93,36 +101,38 @@ type RunnerSettings struct { Instances []*RunnerInstance } -func gitHubAuth(config *ConfigureRemoveRunner, c *http.Client, runnerEvent string, apiEndpoint string, survey Survey) (*protocol.GitHubAuthResult, error) { +func gitHubAuth( + config *ConfigureRemoveRunner, c *http.Client, runnerEvent, apiEndpoint string, survey Survey, +) (*protocol.GitHubAuthResult, error) { if config.URL == "" && !config.Unattended { - config.URL = survey.GetInput("Which GitHub Url is assosiated with this runner (Normally this isn't missing):", "") + config.URL = survey.GetInput("Which GitHub Url is associated with this runner (Normally this isn't missing):", "") } - registerUrl, err := url.Parse(config.URL) + registerURL, err := url.Parse(config.URL) if err != nil { return nil, fmt.Errorf("invalid Url: %v", config.URL) } - if registerUrl.Hostname() == "" { + if registerURL.Hostname() == "" { return nil, fmt.Errorf("invalid Url missing Hostname: %v", config.URL) } apiscope := "/" - if strings.ToLower(registerUrl.Host) == "github.com" { - registerUrl.Host = "api." + registerUrl.Host + if strings.EqualFold(registerURL.Host, "github.com") { + registerURL.Host = "api." + registerURL.Host } else { apiscope = "/api/v3" } - if len(config.Token) == 0 { - if len(config.Pat) > 0 { - paths := strings.Split(strings.TrimPrefix(registerUrl.Path, "/"), "/") - url := *registerUrl + if config.Token == "" { + if config.Pat != "" { + paths := strings.Split(strings.TrimPrefix(registerURL.Path, "/"), "/") + repoURL := *registerURL if len(paths) == 1 { - url.Path = path.Join(apiscope, "orgs", paths[0], "actions/runners", apiEndpoint) - } else if len(paths) == 2 { + repoURL.Path = path.Join(apiscope, "orgs", paths[0], "actions/runners", apiEndpoint) + } else if len(paths) == repositoryPathSegments { scope := "repos" if strings.EqualFold(paths[0], "enterprises") { scope = "" } - url.Path = path.Join(apiscope, scope, paths[0], paths[1], "actions/runners", apiEndpoint) + repoURL.Path = path.Join(apiscope, scope, paths[0], paths[1], "actions/runners", apiEndpoint) } else { return nil, fmt.Errorf("unsupported registration url") } @@ -132,7 +142,7 @@ func gitHubAuth(config *ConfigureRemoveRunner, c *http.Client, runnerEvent strin Client: c, } tokenresp := &protocol.GitHubRunnerRegisterToken{} - err = client.RequestWithContext2(context.Background(), "POST", url.String(), "", nil, tokenresp) + err = client.RequestWithContext2(context.Background(), "POST", repoURL.String(), "", nil, tokenresp) if err != nil { return nil, fmt.Errorf("failed to retrieve %v token via pat: %v", apiEndpoint, err.Error()) } @@ -141,12 +151,12 @@ func gitHubAuth(config *ConfigureRemoveRunner, c *http.Client, runnerEvent strin config.Token = survey.GetInput("Please enter your runner registration token:", "") } } - if len(config.Token) == 0 { + if config.Token == "" { return nil, fmt.Errorf("no runner registration token provided") } - registerUrl.Path = path.Join(apiscope, "actions/runner-registration") + registerURL.Path = path.Join(apiscope, "actions/runner-registration") - finalregisterUrl := registerUrl.String() + finalregisterURL := registerURL.String() client := &protocol.VssConnection{ AuthHeader: "RemoteAuth " + config.Token, @@ -154,7 +164,7 @@ func gitHubAuth(config *ConfigureRemoveRunner, c *http.Client, runnerEvent strin Client: c, } res := &protocol.GitHubAuthResult{} - err = client.RequestWithContext2(context.Background(), "POST", finalregisterUrl, "", &protocol.RunnerAddRemove{ + err = client.RequestWithContext2(context.Background(), "POST", finalregisterURL, "", &protocol.RunnerAddRemove{ URL: config.URL, RunnerEvent: runnerEvent, }, res) @@ -172,35 +182,35 @@ func (config *RemoveRunner) Authenticate(c *http.Client, survey Survey) (*protoc return gitHubAuth(&config.ConfigureRemoveRunner, c, "remove", "remove-token", survey) } -// Deprecated: Use the Authenticate method. +// Authenicate is deprecated: Use the Authenticate method. func (config *ConfigureRunner) Authenicate(c *http.Client, survey Survey) (*protocol.GitHubAuthResult, error) { return config.Authenticate(c, survey) } -// Deprecated: Use the Authenticate method. +// Authenicate is deprecated: Use the Authenticate method. func (config *RemoveRunner) Authenicate(c *http.Client, survey Survey) (*protocol.GitHubAuthResult, error) { return config.Authenticate(c, survey) } -func (confremove *ConfigureRemoveRunner) ReadFromEnvironment() { - if len(confremove.Pat) == 0 { +func (c *ConfigureRemoveRunner) ReadFromEnvironment() { + if c.Pat == "" { if v, ok := os.LookupEnv("ACTIONS_RUNNER_INPUT_PAT"); ok { - confremove.Pat = v + c.Pat = v } } - if len(confremove.Token) == 0 { + if c.Token == "" { if v, ok := os.LookupEnv("ACTIONS_RUNNER_INPUT_TOKEN"); ok { - confremove.Token = v + c.Token = v } } - if !confremove.Unattended { + if !c.Unattended { if v, ok := common.LookupEnvBool("ACTIONS_RUNNER_INPUT_UNATTENDED"); ok { - confremove.Unattended = v + c.Unattended = v } } - if len(confremove.URL) == 0 { + if c.URL == "" { if v, ok := os.LookupEnv("ACTIONS_RUNNER_INPUT_URL"); ok { - confremove.URL = v + c.URL = v } } } diff --git a/runnerconfiguration/compat/actions_runner_compat.go b/runnerconfiguration/compat/actions_runner_compat.go index f380870..de10361 100644 --- a/runnerconfiguration/compat/actions_runner_compat.go +++ b/runnerconfiguration/compat/actions_runner_compat.go @@ -26,15 +26,15 @@ type DotnetRsaParameters struct { } type DotnetAgent struct { - AgentId string `json:"AgentId"` + AgentID string `json:"AgentId"` AgentName string `json:"AgentName"` DisableUpdate string `json:"DisableUpdate"` Ephemeral string `json:"Ephemeral"` - PoolId string `json:"PoolId"` + PoolID string `json:"PoolId"` PoolName string `json:"PoolName,omitempty"` - ServerUrl string `json:"ServerUrl"` + ServerURL string `json:"ServerUrl"` WorkFolder string `json:"WorkFolder"` - GitHubUrl string `json:"GitHubUrl"` + GitHubURL string `json:"GitHubUrl"` } type DotnetCredentials struct { @@ -43,8 +43,8 @@ type DotnetCredentials struct { } type DotnetCredentialsData struct { - ClientId string `json:"ClientId"` - AuthorizationUrl string `json:"AuthorizationUrl"` + ClientID string `json:"ClientId"` + AuthorizationURL string `json:"AuthorizationUrl"` } func BytesToBigInt(bytes []byte) *big.Int { @@ -93,11 +93,11 @@ type ConfigFileAccess interface { type DefaultConfigFileAccess struct{} func (config DefaultConfigFileAccess) Read(name string, obj interface{}) error { - return common.ReadJson(name, obj) + return common.ReadJSON(name, obj) } func (config DefaultConfigFileAccess) Write(name string, obj interface{}) error { - return common.WriteJson(name, obj) + return common.WriteJSON(name, obj) } type JITConfigFileAccess map[string][]byte @@ -127,11 +127,11 @@ func ToRunnerInstance(fileAccess ConfigFileAccess) (*runnerconfiguration.RunnerI if err := fileAccess.Read(".credentials_rsaparams", rsaParameters); err != nil { return nil, err } - poolID, err := strconv.ParseInt(agent.PoolId, 10, 64) + poolID, err := strconv.ParseInt(agent.PoolID, 10, 64) if err != nil { return nil, err } - agentID, err := strconv.ParseInt(agent.AgentId, 10, 32) + agentID, err := strconv.ParseInt(agent.AgentID, 10, 32) if err != nil { return nil, err } @@ -140,7 +140,7 @@ func ToRunnerInstance(fileAccess ConfigFileAccess) (*runnerconfiguration.RunnerI return &runnerconfiguration.RunnerInstance{ PoolID: poolID, Auth: &protocol.GitHubAuthResult{ - TenantURL: agent.ServerUrl, + TenantURL: agent.ServerURL, }, PKey: FromRsaParameters(rsaParameters), Agent: &protocol.TaskAgent{ @@ -149,27 +149,27 @@ func ToRunnerInstance(fileAccess ConfigFileAccess) (*runnerconfiguration.RunnerI Name: agent.AgentName, MaxParallelism: 1, Authorization: protocol.TaskAgentAuthorization{ - AuthorizationURL: credentials.Data.AuthorizationUrl, - ClientID: credentials.Data.ClientId, + AuthorizationURL: credentials.Data.AuthorizationURL, + ClientID: credentials.Data.ClientID, }, DisableUpdate: disableUpdate, Version: "3.0.0", }, WorkFolder: agent.WorkFolder, - RegistrationURL: agent.GitHubUrl, + RegistrationURL: agent.GitHubURL, }, nil } func FromRunnerInstance(instance *runnerconfiguration.RunnerInstance, fileAccess ConfigFileAccess) error { agent := &DotnetAgent{ - AgentId: fmt.Sprint(instance.Agent.ID), + AgentID: fmt.Sprint(instance.Agent.ID), AgentName: instance.Agent.Name, Ephemeral: fmt.Sprint(instance.Agent.Ephemeral), DisableUpdate: fmt.Sprint(instance.Agent.DisableUpdate), - PoolId: fmt.Sprint(instance.PoolID), - ServerUrl: instance.Auth.TenantURL, + PoolID: fmt.Sprint(instance.PoolID), + ServerURL: instance.Auth.TenantURL, WorkFolder: instance.WorkFolder, - GitHubUrl: instance.RegistrationURL, + GitHubURL: instance.RegistrationURL, } if agent.WorkFolder == "" { agent.WorkFolder = "_work" @@ -177,8 +177,8 @@ func FromRunnerInstance(instance *runnerconfiguration.RunnerInstance, fileAccess credentials := &DotnetCredentials{ Scheme: "OAuth", Data: DotnetCredentialsData{ - ClientId: instance.Agent.Authorization.ClientID, - AuthorizationUrl: instance.Agent.Authorization.AuthorizationURL, + ClientID: instance.Agent.Authorization.ClientID, + AuthorizationURL: instance.Agent.Authorization.AuthorizationURL, }, } if err := fileAccess.Write(".runner", agent); err != nil { @@ -202,11 +202,14 @@ func ParseJitRunnerConfig(conf string) (*runnerconfiguration.RunnerSettings, err return nil, err } files := map[string][]byte{} - if err := json.Unmarshal(rawfiles, &files); err != nil { - return nil, err + if unmarshalErr := json.Unmarshal(rawfiles, &files); unmarshalErr != nil { + return nil, unmarshalErr } ret, err := ToRunnerInstance(JITConfigFileAccess(files)) - ToXmlString(&ret.PKey.PublicKey) + _, xmlErr := ToXMLString(&ret.PKey.PublicKey) + if xmlErr != nil { + fmt.Printf("convert xml string: %v", xmlErr) + } return &runnerconfiguration.RunnerSettings{ Instances: []*runnerconfiguration.RunnerInstance{ ret, @@ -231,7 +234,7 @@ type RSAKeyValue struct { Exponent string } -func ToXmlString(publicKey *rsa.PublicKey) (string, error) { +func ToXMLString(publicKey *rsa.PublicKey) (string, error) { res, err := xml.Marshal(&RSAKeyValue{ Modulus: base64.StdEncoding.EncodeToString(publicKey.N.Bytes()), Exponent: base64.StdEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()), diff --git a/runnerconfiguration/remove.go b/runnerconfiguration/remove.go index aafa841..359ab67 100644 --- a/runnerconfiguration/remove.go +++ b/runnerconfiguration/remove.go @@ -7,10 +7,10 @@ import ( ) func (config *RemoveRunner) Remove(settings *RunnerSettings, survey Survey, auth *protocol.GitHubAuthResult) (*RunnerSettings, error) { - c := config.GetHttpClient() + c := config.GetHTTPClient() var instancesToRemove []*RunnerInstance for _, i := range settings.Instances { - if (len(config.URL) == 0 || i.RegistrationURL == config.URL) || (len(config.Name) == 0 || i.Agent.Name == config.Name) { + if (config.URL == "" || i.RegistrationURL == config.URL) || (config.Name == "" || i.Agent.Name == config.Name) { instancesToRemove = append(instancesToRemove, i) } } @@ -39,18 +39,20 @@ func (config *RemoveRunner) Remove(settings *RunnerSettings, survey Survey, auth regurl := "" needsPat := false for _, i := range instancesToRemove { - if len(regurl) > 0 && regurl != i.RegistrationURL { + if regurl != "" && regurl != i.RegistrationURL { needsPat = true } else { regurl = i.RegistrationURL } } - if needsPat && len(config.Pat) == 0 { + if needsPat && config.Pat == "" { if !config.Unattended { config.Pat = survey.GetInput("Please enter your Personal Access token", "") } - if len(config.Pat) == 0 { - return nil, fmt.Errorf("you have to provide a Personal access token with access to the repositories to remove or use the --url parameter") + if config.Pat == "" { + return nil, fmt.Errorf( + "you have to provide a Personal access token with access to the repositories to remove or use the --url parameter", + ) } } for i, instance := range instancesToRemove { @@ -81,7 +83,7 @@ func (config *RemoveRunner) Remove(settings *RunnerSettings, survey Survey, auth Trace: config.Trace, } if err := vssConnection.DeleteAgent(instance.Agent); err != nil { - return fmt.Errorf("failed to remove Runner from server: %v\n", err) + return fmt.Errorf("failed to remove Runner from server: %v", err) } return nil }() diff --git a/runnersurvey.go b/runnersurvey.go index 7b90430..28e77e3 100644 --- a/runnersurvey.go +++ b/runnersurvey.go @@ -1,44 +1,45 @@ -// +build linux darwin windows openbsd netbsd freebsd - -package main - -import ( - "fmt" - - "github.com/AlecAivazis/survey/v2" -) - -func RunnerGroupSurvey(taskAgentPool string, taskAgentPools []string) string { - prompt := &survey.Select{ - Message: "Choose a runner group:", - Options: taskAgentPools, - } - err := survey.AskOne(prompt, &taskAgentPool) - if err != nil { - fmt.Println("Failed to retrieve your choice using default runner group: " + taskAgentPool) - } - return taskAgentPool -} - -func GetInput(prompt string, answer string) string { - in := &survey.Input{ - Message: prompt, - Default: answer, - } - if err := survey.AskOne(in, &answer); err != nil { - fmt.Println("Failed to retrieve your choice using default: " + answer) - } - return answer -} - -func GetMultiSelectInput(prompt string, options []string) []string { - answer := []string{} - in := &survey.MultiSelect{ - Message: prompt, - Options: options, - } - if err := survey.AskOne(in, &answer); err != nil { - fmt.Printf("Failed to retrieve your choice selecting all: %v\n", options) - } - return answer -} +//go:build linux || darwin || windows || openbsd || netbsd || freebsd +// +build linux darwin windows openbsd netbsd freebsd + +package main + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" +) + +func RunnerGroupSurvey(taskAgentPool string, taskAgentPools []string) string { + prompt := &survey.Select{ + Message: "Choose a runner group:", + Options: taskAgentPools, + } + err := survey.AskOne(prompt, &taskAgentPool) + if err != nil { + fmt.Println("Failed to retrieve your choice using default runner group: " + taskAgentPool) + } + return taskAgentPool +} + +func GetInput(prompt, answer string) string { + in := &survey.Input{ + Message: prompt, + Default: answer, + } + if err := survey.AskOne(in, &answer); err != nil { + fmt.Println("Failed to retrieve your choice using default: " + answer) + } + return answer +} + +func GetMultiSelectInput(prompt string, options []string) []string { + answer := []string{} + in := &survey.MultiSelect{ + Message: prompt, + Options: options, + } + if err := survey.AskOne(in, &answer); err != nil { + fmt.Printf("Failed to retrieve your choice selecting all: %v\n", options) + } + return answer +}