From 4158e05daec97b459b4636c2e13ade71c5ca9f0b Mon Sep 17 00:00:00 2001 From: silverwind <2021+silverwind@noreply.gitea.com> Date: Sun, 18 Oct 2026 18:57:08 +0000 Subject: [PATCH] refactor: drop logrus and other direct dependencies (#1319) After https://gitea.com/gitea/runner/pulls/1304, logrus stayed only to forward logs from docker/cli and moby/go-archive. The runner used a small part of those: volume parsing, the build context tar, the `ssh://` connection and registry credentials. Own code that matches them replaces them, so logrus leaves the module graph. docker/cli stays for its logrus-free `opts` and `connhelper/ssh` packages. Other dependencies that the standard library or existing ones cover go too: mergo, httprouter (replaced by `http.ServeMux`), retry-go, go-isatty, go-selinux, container-device-interface, gotest.tools and go-cmp (replaced by testify). The binary shrinks by 5%. Behaviour differences: 1. An unreadable file in a build context is skipped with a warning, where go-archive left a truncated tar. 2. Build contexts no longer carry `security.capability` xattrs. 3. The cache and artifact servers answer HEAD on GET routes, and no longer redirect trailing slashes or answer OPTIONS. *Written by Claude.* --------- Co-authored-by: bircni Reviewed-on: https://gitea.com/gitea/runner/pulls/1319 Reviewed-by: bircni Reviewed-by: Lunny Xiao Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> --- .golangci.yml | 2 - DEVELOPMENT.md | 9 +- act/artifactcache/handler.go | 65 +++--- act/artifactcache/handler_v2.go | 22 +- act/artifacts/server.go | 35 ++- act/artifacts/server_test.go | 3 +- act/common/logging/global.go | 10 +- act/common/logging/logging_test.go | 54 ----- act/common/logging/logrus_bridge.go | 102 --------- act/container/docker_auth.go | 257 +++++++++++++++++++++-- act/container/docker_auth_test.go | 46 ++++ act/container/docker_build.go | 147 ++++++++++++- act/container/docker_build_test.go | 49 +++++ act/container/docker_build_unix.go | 18 ++ act/container/docker_build_unix_test.go | 35 +++ act/container/docker_build_windows.go | 10 + act/container/docker_cli.go | 13 +- act/container/docker_cli_test.go | 144 ++++++------- act/container/docker_pull_test.go | 8 +- act/container/docker_run.go | 79 +++++-- act/container/docker_ssh.go | 111 ++++++++++ act/container/docker_ssh_linux.go | 12 ++ act/container/docker_ssh_test.go | 51 +++++ act/container/docker_ssh_unix.go | 12 ++ act/container/docker_ssh_windows.go | 10 + act/container/volume_spec.go | 85 ++++++++ act/container/volume_spec_test.go | 18 ++ act/runner/run_context.go | 8 +- act/runner/run_context_test.go | 3 +- act/runner/selinux_linux.go | 37 ++++ act/runner/selinux_other.go | 8 + go.mod | 20 -- go.sum | 44 ---- internal/app/cmd/daemon.go | 37 ++-- internal/app/cmd/daemon_test.go | 7 - internal/app/cmd/register.go | 4 +- internal/app/cmd/register_test.go | 7 +- internal/app/run/workflow_test.go | 39 ++-- internal/pkg/labels/labels_test.go | 3 +- internal/pkg/report/reporter.go | 20 +- internal/pkg/report/reporter_test.go | 14 ++ internal/pkg/telemetry/telemetry.go | 38 ++-- internal/pkg/telemetry/telemetry_test.go | 2 +- 43 files changed, 1188 insertions(+), 510 deletions(-) delete mode 100644 act/common/logging/logrus_bridge.go create mode 100644 act/container/docker_auth_test.go create mode 100644 act/container/docker_build_test.go create mode 100644 act/container/docker_build_unix.go create mode 100644 act/container/docker_build_unix_test.go create mode 100644 act/container/docker_build_windows.go create mode 100644 act/container/docker_ssh.go create mode 100644 act/container/docker_ssh_linux.go create mode 100644 act/container/docker_ssh_test.go create mode 100644 act/container/docker_ssh_unix.go create mode 100644 act/container/docker_ssh_windows.go create mode 100644 act/container/volume_spec.go create mode 100644 act/container/volume_spec_test.go create mode 100644 act/runner/selinux_linux.go create mode 100644 act/runner/selinux_other.go diff --git a/.golangci.yml b/.golangci.yml index f6b61763..bd1d3281 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -44,8 +44,6 @@ linters: - $all - "!**/act/common/logging/**" deny: - - pkg: github.com/sirupsen/logrus - desc: log through gitea.com/gitea/runner/act/common/logging - pkg: log/slog desc: log through gitea.com/gitea/runner/act/common/logging nolintlint: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e18051f8..ecf0954f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,7 +33,7 @@ depending on the prefix: ## Logging -Log through `act/common/logging`; the linter rejects importing `log/slog` or logrus elsewhere. +Log through `act/common/logging`; the linter rejects importing `log/slog` elsewhere. A job's logger comes from `common.Logger(ctx)`; code with no job uses the package functions (`logging.Infof`, `logging.With`). Both take printf-style messages, and `With("key", value)` adds fields. @@ -43,11 +43,8 @@ fields. - The job logger's output can reach the job log workflow authors see, the process-wide logger's stays on the runner host, so keep operator-only detail (host paths, internal addresses) off the job logger. -- Libraries that log through logrus's or slog's global logger are forwarded to the process-wide - one, so they follow its level and masking too. -- That forwarding only works while logrus's global logger is left as `logging` configures it. A - dependency that calls `logrus.SetOutput` or `SetLevel` breaks it, so check - `logging.LogrusBridgeIntact()` when adding or upgrading one. +- Libraries that log through slog's global logger are forwarded to the process-wide one, so they + follow its level and masking too. Avoid dependencies that log through logrus, nothing forwards it. - Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the package functions. diff --git a/act/artifactcache/handler.go b/act/artifactcache/handler.go index 00b22045..3d6df28e 100644 --- a/act/artifactcache/handler.go +++ b/act/artifactcache/handler.go @@ -32,7 +32,6 @@ import ( "gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/internal/pkg/disk" - "github.com/julienschmidt/httprouter" "github.com/timshannon/bolthold" "go.etcd.io/bbolt" ) @@ -80,7 +79,7 @@ type Handler struct { dir string storage cacheStorage s3 *s3Storage - router *httprouter.Router + router *http.ServeMux listener net.Listener port int server *http.Server @@ -201,22 +200,22 @@ func StartHandler(opts Options) (*Handler, error) { } h.secret = secret - router := httprouter.New() - router.GET(apiPath+"/cache", h.bearerAuth(h.find)) - router.POST(apiPath+"/caches", h.bearerAuth(h.reserve)) - router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload)) - router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit)) - router.POST(apiPath+"/clean", h.bearerAuth(h.clean)) + router := http.NewServeMux() + router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find)) + router.HandleFunc("POST "+apiPath+"/caches", h.bearerAuth(h.reserve)) + router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.bearerAuth(h.upload)) + router.HandleFunc("POST "+apiPath+"/caches/{id}", h.bearerAuth(h.commit)) + router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean)) // Artifact GET is signed via query-string HMAC because @actions/cache // does not attach Authorization when downloading archiveLocation. - router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get)) + router.HandleFunc("GET "+apiPath+"/artifacts/{id}", h.signedAuth("", h.get)) // Control-plane: a remote runner registers/revokes per-job tokens so the // cache API can authenticate them. Always wired so the routes exist; the // handlers themselves 401 when internalSecret is unset. - router.POST(internalPath+"/register", h.internalAuth(h.internalRegister)) - router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke)) + router.HandleFunc("POST "+internalPath+"/register", h.internalAuth(h.internalRegister)) + router.HandleFunc("POST "+internalPath+"/revoke", h.internalAuth(h.internalRevoke)) h.registerV2Routes(router) - router.NotFound = http.HandlerFunc(h.forwardOrNotFound) + router.HandleFunc(artifactServicePath, h.forwardOrNotFound) h.router = router @@ -498,7 +497,7 @@ func (h *Handler) importMetadata(ctx context.Context) error { } // GET /_apis/artifactcache/cache -func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) find(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) keys := strings.Split(r.URL.Query().Get("keys"), ",") version := r.URL.Query().Get("version") @@ -549,7 +548,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve } // POST /_apis/artifactcache/caches -func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) reserve(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) api := &Request{} if err := json.UnmarshalRead(r.Body, api); err != nil { @@ -581,9 +580,9 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P } // PATCH /_apis/artifactcache/caches/:id -func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprouter.Params) { +func (h *Handler) upload(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) - id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { h.responseJSON(w, r, 400, err) return @@ -629,9 +628,9 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout } // POST /_apis/artifactcache/caches/:id -func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprouter.Params) { +func (h *Handler) commit(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) - id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { h.responseJSON(w, r, 400, err) return @@ -716,8 +715,8 @@ func (h *Handler) commitCache(cache *Cache) error { // @actions/cache toolkit downloads archiveLocation without Authorization. // Repository scoping is already enforced at find() time; the signature binds // the URL to the specific cache ID and an expiry. -func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) { - id, err := strconv.ParseInt(params.ByName("id"), 10, 64) +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { h.responseJSON(w, r, 400, err) return @@ -727,7 +726,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter. } // POST /_apis/artifactcache/clean -func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) clean(w http.ResponseWriter, r *http.Request) { // TODO: don't support force deleting cache entries // see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries @@ -737,8 +736,8 @@ func (h *Handler) clean(w http.ResponseWriter, r *http.Request, _ httprouter.Par // bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently // registered jobs. A match attaches the job's JobCredential to the request // context; a miss returns 401 before the handler body runs. -func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { +func (h *Handler) bearerAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h.logger.Debugf("%s %s", r.Method, r.URL.Path) token := bearerToken(r) if token == "" { @@ -751,7 +750,7 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle { return } ctx := context.WithValue(r.Context(), credKey{}, cred) - handler(w, r.WithContext(ctx), params) + handler(w, r.WithContext(ctx)) go h.gcCache() } } @@ -766,10 +765,10 @@ func (h *Handler) unauthorized(w http.ResponseWriter, r *http.Request, err error // signedAuth authenticates a signed URL. purpose separates the flavours of URL the // handler hands out, so one cannot be replayed as another; see computeSignature. -func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { +func (h *Handler) signedAuth(purpose string, handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h.logger.Debugf("%s %s", r.Method, r.URL.Path) - id, err := strconv.ParseInt(params.ByName("id"), 10, 64) + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { h.responseJSON(w, r, 400, err) return @@ -794,7 +793,7 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature")) return } - handler(w, r, params) + handler(w, r) go h.gcCache() } } @@ -803,8 +802,8 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout // constant-time-equal the configured internalSecret. If the secret is empty, // the control-plane is disabled and every request gets 404 — which matches // the upstream nektos/act behavior of "the route does not exist". -func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) { +func (h *Handler) internalAuth(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { if h.internalSecret == "" { http.NotFound(w, r) return @@ -814,7 +813,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle { h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret")) return } - handler(w, r, params) + handler(w, r) } } @@ -837,7 +836,7 @@ func (h *Handler) ResultsURL(cred JobCredential) string { return h.baseURL(cred) } -func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request) { var body internalRegisterBody if err := json.UnmarshalRead(r.Body, &body); err != nil { h.responseJSON(w, r, http.StatusBadRequest, err) @@ -852,7 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt } // POST /_internal/revoke -func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) internalRevoke(w http.ResponseWriter, r *http.Request) { var body internalRevokeBody if err := json.UnmarshalRead(r.Body, &body); err != nil { h.responseJSON(w, r, http.StatusBadRequest, err) diff --git a/act/artifactcache/handler_v2.go b/act/artifactcache/handler_v2.go index b5eff916..49574707 100644 --- a/act/artifactcache/handler_v2.go +++ b/act/artifactcache/handler_v2.go @@ -15,8 +15,6 @@ import ( "strconv" "strings" "time" - - "github.com/julienschmidt/httprouter" ) // The cache service v2 API. A client on this version talks twirp to @@ -44,16 +42,16 @@ const ( twirpUnauthenticated = "unauthenticated" ) -func (h *Handler) registerV2Routes(router *httprouter.Router) { - router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry)) - router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload)) - router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL)) - router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob)) +func (h *Handler) registerV2Routes(router *http.ServeMux) { + router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry)) + router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload)) + router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL)) + router.HandleFunc("PUT "+blobPath+"/{id}", h.signedAuth(blobUploadPurpose, h.v2UploadBlob)) } // An entry that already exists is reported as not ok, which is how the client learns to skip // the upload. -func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) req, err := decodeTwirpRequest[v2CreateRequest](r) if err != nil { @@ -123,7 +121,7 @@ func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ h }) } -func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) req, err := decodeTwirpRequest[v2FinalizeRequest](r) if err != nil { @@ -172,7 +170,7 @@ func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Requ }) } -func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { +func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) { cred := credFromContext(r.Context()) req, err := decodeTwirpRequest[v2DownloadRequest](r) if err != nil { @@ -207,8 +205,8 @@ func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Requ // The archive arrives over the subset of the Azure blob API the toolkit uses: a small // cache is a single PUT, a large one is staged as blocks that a final block list puts // in order. -func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) { - id, err := strconv.ParseUint(params.ByName("id"), 10, 64) +func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) if err != nil { h.responseJSON(w, r, http.StatusBadRequest, err) return diff --git a/act/artifacts/server.go b/act/artifacts/server.go index cb0f0287..b6f81074 100644 --- a/act/artifacts/server.go +++ b/act/artifacts/server.go @@ -19,8 +19,6 @@ import ( "time" "gitea.com/gitea/runner/act/common" - - "github.com/julienschmidt/httprouter" ) type FileContainerResourceURL struct { @@ -67,24 +65,21 @@ func writeJSON(w http.ResponseWriter, value any) { } } -func uploads(router *httprouter.Router, baseDir string) { - router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { - runID := params.ByName("runId") - +func uploads(router *http.ServeMux, baseDir string) { + router.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) { writeJSON(w, FileContainerResourceURL{ - FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, runID), + FileContainerResourceURL: fmt.Sprintf("http://%s/upload/%s", req.Host, req.PathValue("runId")), }) }) - router.PUT("/upload/:runId", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { + router.HandleFunc("PUT /upload/{runId}", func(w http.ResponseWriter, req *http.Request) { itemPath := req.URL.Query().Get("itemPath") - runID := params.ByName("runId") if req.Header.Get("Content-Encoding") == "gzip" { itemPath += gzipExtension } - safeRunPath := safeResolve(baseDir, runID) + safeRunPath := safeResolve(baseDir, req.PathValue("runId")) safePath := safeResolve(safeRunPath, itemPath) if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil { @@ -114,16 +109,16 @@ func uploads(router *httprouter.Router, baseDir string) { }) }) - router.PATCH("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { + router.HandleFunc("PATCH /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, _req *http.Request) { writeJSON(w, ResponseMessage{ Message: "success", }) }) } -func downloads(router *httprouter.Router, baseDir string) { - router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { - runID := params.ByName("runId") +func downloads(router *http.ServeMux, baseDir string) { + router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) { + runID := req.PathValue("runId") safePath := safeResolve(baseDir, runID) @@ -146,8 +141,8 @@ func downloads(router *httprouter.Router, baseDir string) { }) }) - router.GET("/download/:container", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { - container := params.ByName("container") + router.HandleFunc("GET /download/{container}", func(w http.ResponseWriter, req *http.Request) { + container := req.PathValue("container") itemPath := req.URL.Query().Get("itemPath") safePath := safeResolve(baseDir, filepath.Join(container, itemPath)) @@ -183,10 +178,8 @@ func downloads(router *httprouter.Router, baseDir string) { }) }) - router.GET("/artifact/*path", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { - path := params.ByName("path")[1:] - - safePath := safeResolve(baseDir, path) + router.HandleFunc("GET /artifact/{path...}", func(w http.ResponseWriter, req *http.Request) { + safePath := safeResolve(baseDir, req.PathValue("path")) file, err := os.Open(safePath) if err != nil { @@ -214,7 +207,7 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF return cancel } - router := httprouter.New() + router := http.NewServeMux() logger.Debugf("Artifacts base path '%s'", artifactPath) uploads(router, artifactPath) diff --git a/act/artifacts/server_test.go b/act/artifacts/server_test.go index ccb6c15e..3c2dad23 100644 --- a/act/artifacts/server_test.go +++ b/act/artifacts/server_test.go @@ -19,14 +19,13 @@ import ( "testing" "time" - "github.com/julienschmidt/httprouter" "github.com/stretchr/testify/require" ) func TestArtifactFlow(t *testing.T) { artifactPath := t.TempDir() - router := httprouter.New() + router := http.NewServeMux() uploads(router, artifactPath) downloads(router, artifactPath) server := httptest.NewServer(router) diff --git a/act/common/logging/global.go b/act/common/logging/global.go index f93ab868..a4cce31b 100644 --- a/act/common/logging/global.go +++ b/act/common/logging/global.go @@ -9,8 +9,6 @@ import ( "os" "runtime" "sync/atomic" - - "github.com/sirupsen/logrus" ) // TextOptions describes how the process-wide Logger writes a record as text. @@ -41,11 +39,8 @@ func Default() *Logger { return std.Load() } // initial console Logger. func SetDefault(l *Logger) { std.Store(l) } -// SetLevel sets the minimum level of the initial process-wide Logger and of what libraries log through logrus. -func SetLevel(level Level) { - stdState.level.Set(slog.Level(level)) - logrus.SetLevel(toLogrusLevel(level)) -} +// SetLevel sets the minimum level of the initial process-wide Logger. +func SetLevel(level Level) { stdState.level.Set(slog.Level(level)) } // GetLevel returns the minimum level of the initial process-wide Logger. func GetLevel() Level { return Level(stdState.level.Level()) } @@ -72,7 +67,6 @@ func SetReportCaller(on bool) { stdState.mu.Lock() defer stdState.mu.Unlock() stdState.reportCaller = on - logrus.SetReportCaller(on) } func Enabled(level Level) bool { return Default().Enabled(level) } diff --git a/act/common/logging/logging_test.go b/act/common/logging/logging_test.go index 4aa31bdb..f577a749 100644 --- a/act/common/logging/logging_test.go +++ b/act/common/logging/logging_test.go @@ -13,7 +13,6 @@ import ( "gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging/logtest" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -132,43 +131,6 @@ func TestSlogHandler(t *testing.T) { assert.Contains(t, out, "error=boom") } -func TestLogrusBridge(t *testing.T) { - prevLevel, prevDefault := logging.GetLevel(), logging.Default() - t.Cleanup(func() { - logging.SetLevel(prevLevel) - logging.SetReportCaller(false) - logging.SetDefault(prevDefault) - }) - logging.SetLevel(logging.LevelInfo) - assert.False(t, logrus.IsLevelEnabled(logrus.DebugLevel)) - logging.SetLevel(logging.LevelTrace) - logging.SetReportCaller(true) - h := logtest.NewWithLevel(logging.LevelTrace) - logging.SetDefault(logging.New(h)) - - logrus.WithField("lib", "docker").Warnf("from a library %d", 1) - _, _, line, _ := runtime.Caller(0) - logrus.Debug("below") - logrus.Trace("lowest") - - recs := h.AllRecords() - require.Len(t, recs, 3) - assert.Equal(t, "from a library 1", recs[0].Message) - assert.Equal(t, logging.LevelWarn, recs[0].Level) - v, _ := recs[0].Attr("lib") - assert.Equal(t, "docker", v) - assert.Equal(t, logging.LevelDebug, recs[1].Level) - f := frameOf(t, recs[1].PC) - assert.True(t, strings.HasSuffix(f.Function, "TestLogrusBridge"), f.Function) - assert.Equal(t, line+1, f.Line) - assert.Equal(t, logging.LevelTrace, recs[2].Level) - - h = logtest.New() // logrus passes Debug, the Logger does not - logging.SetDefault(logging.New(h)) - logrus.Debug("dropped") - assert.Empty(t, h.AllRecords()) -} - func TestSlogBridge(t *testing.T) { h := logtest.SetDefault(t) slog.With("lib", "actionslib").Warn("from a library", "n", 1) @@ -185,19 +147,3 @@ func TestSlogBridge(t *testing.T) { assert.EqualValues(t, 1, n) assert.Equal(t, line-1, frameOf(t, recs[0].PC).Line) } - -func TestLogrusBridgeIntact(t *testing.T) { - assert.True(t, logging.LogrusBridgeIntact()) - - prev := logrus.StandardLogger().Out - t.Cleanup(func() { logrus.SetOutput(prev) }) - logrus.SetOutput(&strings.Builder{}) // what a dependency doing this would do - assert.False(t, logging.LogrusBridgeIntact()) - logrus.SetOutput(prev) - assert.True(t, logging.LogrusBridgeIntact()) - - prevLevel := logrus.GetLevel() - t.Cleanup(func() { logrus.SetLevel(prevLevel) }) - logrus.SetLevel(logrus.PanicLevel) - assert.False(t, logging.LogrusBridgeIntact()) -} diff --git a/act/common/logging/logrus_bridge.go b/act/common/logging/logrus_bridge.go deleted file mode 100644 index 295f594c..00000000 --- a/act/common/logging/logrus_bridge.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package logging - -import ( - "context" - "io" - - "github.com/sirupsen/logrus" -) - -// Libraries the runner depends on, such as docker/cli and moby/go-archive, log through -// logrus's global logger. Hand what they log to the process-wide Logger, so it follows the same -// level, format and secret masking as the rest of the output. -func init() { - logrus.SetOutput(io.Discard) - logrus.SetFormatter(discardFormatter{}) - logrus.SetLevel(toLogrusLevel(GetLevel())) - logrus.AddHook(bridgeHook{}) -} - -type discardFormatter struct{} - -func (discardFormatter) Format(*logrus.Entry) ([]byte, error) { return nil, nil } - -type bridgeHook struct{} - -func (bridgeHook) Levels() []logrus.Level { return logrus.AllLevels } - -func (bridgeHook) Fire(e *logrus.Entry) error { - l := Default() - level := fromLogrusLevel(e.Level) - ctx := e.Context - if ctx == nil { - ctx = context.Background() - } - if !l.h.Enabled(ctx, level) { - return nil - } - attrs := make([]Attr, 0, len(e.Data)) - for k, v := range e.Data { - attrs = append(attrs, Attr{Key: k, Value: v}) - } - r := Record{Time: e.Time, Level: level, Message: e.Message, Attrs: attrs} - if e.Caller != nil { - r.PC = e.Caller.PC + 1 // Frame.PC is one below the return address Record.PC holds - } - return l.h.Handle(ctx, r) -} - -func toLogrusLevel(l Level) logrus.Level { - switch { - case l <= LevelTrace: - return logrus.TraceLevel - case l <= LevelDebug: - return logrus.DebugLevel - case l <= LevelInfo: - return logrus.InfoLevel - case l <= LevelWarn: - return logrus.WarnLevel - default: - return logrus.ErrorLevel - } -} - -func fromLogrusLevel(l logrus.Level) Level { - switch l { - case logrus.TraceLevel: - return LevelTrace - case logrus.DebugLevel: - return LevelDebug - case logrus.InfoLevel: - return LevelInfo - case logrus.WarnLevel: - return LevelWarn - default: - return LevelError - } -} - -// LogrusBridgeIntact reports whether logrus's global logger is still set up as the bridge needs, -// discarding its own output and following the process-wide level. A dependency that calls -// logrus.SetOutput or SetLevel breaks that, and its lines then show up twice, or at the wrong level. -func LogrusBridgeIntact() bool { - std := logrus.StandardLogger() - if std.Out != io.Discard || std.GetLevel() != toLogrusLevel(GetLevel()) { - return false - } - for _, level := range logrus.AllLevels { - found := false - for _, h := range std.Hooks[level] { - if _, ok := h.(bridgeHook); ok { - found = true - } - } - if !found { - return false - } - } - return true -} diff --git a/act/container/docker_auth.go b/act/container/docker_auth.go index 8f5f2fa8..f1d96c55 100644 --- a/act/container/docker_auth.go +++ b/act/container/docker_auth.go @@ -7,20 +7,33 @@ package container import ( + "bytes" "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "maps" + "net" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" "gitea.com/gitea/runner/act/common" "github.com/distribution/reference" - "github.com/docker/cli/cli/config" "github.com/moby/moby/api/types/registry" ) func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) { logger := common.Logger(ctx) - // config.LoadDefaultConfigFile panics on nil io.Writer when the config - // file is malformed; use config.Load to route errors through the logger. - cfg, err := config.Load(config.Dir()) + cfg, err := loadDockerConfig(ctx) if err != nil { logger.Warnf("Could not load docker config: %v", err) return registry.AuthConfig{}, err @@ -34,32 +47,27 @@ func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfi } } - authConfig, err := cfg.GetAuthConfig(registryKey) + authConfig, err := cfg.authConfig(ctx, registryKey) if err != nil { logger.Warnf("Could not get auth config from docker config: %v", err) return registry.AuthConfig{}, err } - return registry.AuthConfig(authConfig), nil + return authConfig, nil } func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig { logger := common.Logger(ctx) - cfg, err := config.Load(config.Dir()) + cfg, err := loadDockerConfig(ctx) if err != nil { logger.Warnf("Could not load docker config: %v", err) return nil } - creds, err := cfg.GetAllCredentials() + authConfigs, err := cfg.allAuthConfigs(ctx) if err != nil { logger.Warnf("Could not get docker auth configs: %v", err) return nil } - authConfigs := make(map[string]registry.AuthConfig, len(creds)) - for k, v := range creds { - authConfigs[k] = registry.AuthConfig(v) - } - return authConfigs } @@ -69,3 +77,226 @@ func registryAuthConfigKey(domainName string) string { } return domainName } + +type dockerConfig struct { + Auths map[string]registry.AuthConfig `json:"auths"` + CredsStore string `json:"credsStore"` + CredHelpers map[string]string `json:"credHelpers"` + env map[string]registry.AuthConfig // from DOCKER_AUTH_CONFIG, nil when unset or unusable +} + +func loadDockerConfig(ctx context.Context) (*dockerConfig, error) { + dir := os.Getenv("DOCKER_CONFIG") + if dir == "" { + home, _ := os.UserHomeDir() + if home == "" && runtime.GOOS != "windows" { + if current, err := user.Current(); err == nil { + home = current.HomeDir + } + } + dir = filepath.Join(home, ".docker") + } + cfg := &dockerConfig{} + filename := filepath.Join(dir, "config.json") + file, err := os.Open(filename) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("loading config file: %w", err) + } + if err == nil { + defer file.Close() + if err := json.NewDecoder(file).Decode(cfg); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("parsing config file (%s): %w", filename, err) + } + } + for address, auth := range cfg.Auths { + if auth.Auth != "" { + if auth.Username, auth.Password, err = decodeAuth(auth.Auth); err != nil { + return nil, fmt.Errorf("parsing config file (%s): %w", filename, err) + } + } + auth.Auth, auth.ServerAddress = "", address + cfg.Auths[address] = auth + } + if value := os.Getenv("DOCKER_AUTH_CONFIG"); value != "" { + if cfg.env, err = parseDockerAuthConfigEnv(value); err != nil { + common.Logger(ctx).Warnf("Failed to create credential store from DOCKER_AUTH_CONFIG: %v", err) + } + } + return cfg, nil +} + +func decodeAuth(auth string) (string, string, error) { + decoded, err := base64.StdEncoding.DecodeString(auth) + if err != nil { + return "", "", err + } + username, password, ok := strings.Cut(string(decoded), ":") + if !ok || username == "" { + return "", "", errors.New("invalid auth configuration file") + } + return username, strings.Trim(password, "\x00"), nil +} + +func parseDockerAuthConfigEnv(value string) (map[string]registry.AuthConfig, error) { + var env struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + decoder := json.NewDecoder(strings.NewReader(value)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&env); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if decoder.More() { + return nil, errors.New("DOCKER_AUTH_CONFIG does not support more than one JSON object") + } + auths := make(map[string]registry.AuthConfig, len(env.Auths)) + for address, entry := range env.Auths { + if entry.Auth == "" { + return nil, fmt.Errorf("DOCKER_AUTH_CONFIG environment variable is missing key `auth` for %s", address) + } + username, password, err := decodeAuth(entry.Auth) + if err != nil { + return nil, err + } + auths[address] = registry.AuthConfig{Username: username, Password: password, ServerAddress: address} + } + return auths, nil +} + +func (c *dockerConfig) authConfig(ctx context.Context, key string) (registry.AuthConfig, error) { + if auth, ok := c.env[key]; ok { + return auth, nil + } + helper := c.CredsStore + if named, ok := c.CredHelpers[key]; ok { + helper = named + } + if helper == "" { + return c.fileAuth(key), nil + } + auth := c.fileAuth(key) + stored, err := helperCredentials(ctx, helper, key) + if err != nil { + return auth, err + } + auth.Username, auth.Password, auth.IdentityToken, auth.ServerAddress = stored.Username, stored.Password, stored.IdentityToken, stored.ServerAddress + return auth, nil +} + +func (c *dockerConfig) fileAuth(key string) registry.AuthConfig { + if auth, ok := c.Auths[key]; ok { + return auth + } + for address, auth := range c.Auths { + if registryHostname(address) == key { + return auth + } + } + return registry.AuthConfig{} +} + +func (c *dockerConfig) allAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) { + var auths map[string]registry.AuthConfig + if c.CredsStore == "" { + auths = maps.Clone(c.Auths) + } else { + var err error + if auths, err = c.helperAuthConfigs(ctx); err != nil { + if c.env == nil { + return nil, err + } + common.Logger(ctx).Warnf("Could not list the credentials of docker-credential-%s: %v", c.CredsStore, err) + } + } + if auths == nil { + auths = map[string]registry.AuthConfig{} + } + maps.Copy(auths, c.env) + if c.CredsStore == "" { + c.Auths = maps.Clone(auths) // docker's file store holds the DOCKER_AUTH_CONFIG entries after listing, and credHelpers set to "" read it + } + for address := range c.CredHelpers { + auth, err := c.authConfig(ctx, registryAuthConfigKey(address)) + if err != nil { + common.Logger(ctx).Warnf("Failed to get credentials for registry: %s: %v", address, err) + continue + } + auths[address] = auth + } + return auths, nil +} + +func (c *dockerConfig) helperAuthConfigs(ctx context.Context) (map[string]registry.AuthConfig, error) { + out, err := runCredentialHelper(ctx, c.CredsStore, "list", "unused") + if err != nil { + return nil, fmt.Errorf("error listing credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out))) + } + var addresses map[string]string + if err := json.NewDecoder(bytes.NewReader(out)).Decode(&addresses); err != nil { + return nil, err + } + auths := make(map[string]registry.AuthConfig, len(addresses)) + for address := range addresses { + stored, err := helperCredentials(ctx, c.CredsStore, address) + if err != nil { + return nil, err + } + auth := c.Auths[address] + auth.Username, auth.Password, auth.IdentityToken = stored.Username, stored.Password, stored.IdentityToken + if auth.ServerAddress == "" { + auth.ServerAddress = stored.ServerAddress + } + auths[address] = auth + } + return auths, nil +} + +func helperCredentials(ctx context.Context, helper, address string) (registry.AuthConfig, error) { + out, err := runCredentialHelper(ctx, helper, "get", address) + if err != nil { + if strings.TrimSpace(string(out)) == "credentials not found in native keychain" { + return registry.AuthConfig{}, nil + } + return registry.AuthConfig{}, fmt.Errorf("error getting credentials - err: %w, out: `%s`", err, strings.TrimSpace(string(out))) + } + var stored struct{ Username, Secret string } + if err := json.NewDecoder(bytes.NewReader(out)).Decode(&stored); err != nil { + return registry.AuthConfig{}, err + } + if stored.Username == "" { + return registry.AuthConfig{IdentityToken: stored.Secret, ServerAddress: address}, nil + } + return registry.AuthConfig{Username: stored.Username, Password: stored.Secret, ServerAddress: address}, nil +} + +func runCredentialHelper(ctx context.Context, helper, action, input string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "docker-credential-"+helper, action) + cmd.Stdin = strings.NewReader(input) + cmd.Stderr = os.Stderr + return cmd.Output() +} + +func registryHostname(address string) string { + if _, rest, ok := strings.Cut(address, "://"); ok { + if u, err := url.Parse(address); err == nil && u.Hostname() != "" { + if u.Port() == "" { + return u.Hostname() + } + return net.JoinHostPort(u.Hostname(), u.Port()) + } + if host, _, _ := strings.Cut(rest, "/"); host != "" { + if colon := strings.LastIndex(host, ":"); strings.Count(host, ":") > 1 && !strings.HasPrefix(host, "[") && isDigits(host[colon+1:]) { + return net.JoinHostPort(host[:colon], host[colon+1:]) + } + return host + } + } + host, _, _ := strings.Cut(address, "/") + return host +} + +func isDigits(s string) bool { + return s != "" && strings.Trim(s, "0123456789") == "" +} diff --git a/act/container/docker_auth_test.go b/act/container/docker_auth_test.go new file mode 100644 index 00000000..41cfb930 --- /dev/null +++ b/act/container/docker_auth_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !windows && !(WITHOUT_DOCKER || !(linux || darwin || netbsd)) + +package container + +import ( + "encoding/base64" + "os" + "path/filepath" + "testing" + + "github.com/moby/moby/api/types/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadDockerAuthConfigFromFileHelpersAndEnv(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-fake"), []byte("#!/bin/sh\nread -r server\n[ \"$1\" = get ] && [ \"$server\" = helper.example.com ] && exec echo '{\"Username\":\"helped\",\"Secret\":\"pw\"}'\necho 'credentials not found in native keychain'\nexit 1\n"), 0o755)) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + auth := func(credentials string) string { return base64.StdEncoding.EncodeToString([]byte(credentials)) } + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"auths": {"https://legacy.example.com/v1/": {"auth": "`+auth("legacy:pw")+`"}}, "credHelpers": {"helper.example.com": "fake", "gone.example.com": "fake", "env-legacy.example.com": ""}}`), 0o600)) + t.Setenv("DOCKER_CONFIG", dir) + t.Setenv("DOCKER_AUTH_CONFIG", `{"auths": {"env.example.com": {"auth": "`+auth("env:pw")+`"}, "https://env-legacy.example.com/v1/": {"auth": "`+auth("env:pw")+`"}}}`) + + for image, want := range map[string]registry.AuthConfig{ + "legacy.example.com/app": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"}, + "helper.example.com/app": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"}, + "env.example.com/app": {Username: "env", Password: "pw", ServerAddress: "env.example.com"}, + "gone.example.com/app": {}, + } { + got, err := LoadDockerAuthConfig(t.Context(), image) + require.NoError(t, err) + assert.Equal(t, want, got, image) + } + assert.Equal(t, map[string]registry.AuthConfig{ + "https://legacy.example.com/v1/": {Username: "legacy", Password: "pw", ServerAddress: "https://legacy.example.com/v1/"}, + "helper.example.com": {Username: "helped", Password: "pw", ServerAddress: "helper.example.com"}, + "gone.example.com": {}, + "env.example.com": {Username: "env", Password: "pw", ServerAddress: "env.example.com"}, + "https://env-legacy.example.com/v1/": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"}, + "env-legacy.example.com": {Username: "env", Password: "pw", ServerAddress: "https://env-legacy.example.com/v1/"}, + }, LoadDockerAuthConfigs(t.Context())) +} diff --git a/act/container/docker_build.go b/act/container/docker_build.go index 2e072252..2beb62ca 100644 --- a/act/container/docker_build.go +++ b/act/container/docker_build.go @@ -7,15 +7,18 @@ package container import ( + "archive/tar" "context" "io" + "io/fs" "os" "path/filepath" + "runtime" + "strings" + "time" "gitea.com/gitea/runner/act/common" - "github.com/moby/go-archive" - "github.com/moby/go-archive/compression" "github.com/moby/moby/client" "github.com/moby/patternmatcher" "github.com/moby/patternmatcher/ignorefile" @@ -115,14 +118,140 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) ( includes = append(includes, ".dockerignore", relDockerfile) } - buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{ - Compression: compression.None, - ExcludePatterns: excludes, - IncludeFiles: includes, - }) + pm, err := patternmatcher.New(excludes) if err != nil { return nil, err } - - return buildCtx, nil + reader, writer := io.Pipe() + go func() { writer.CloseWithError(writeBuildContext(ctx, writer, contextDir, includes, pm)) }() + return reader, nil } + +func writeBuildContext(ctx context.Context, w io.Writer, contextDir string, includes []string, pm *patternmatcher.PatternMatcher) error { + tw := tar.NewWriter(w) + seen := map[string]bool{} + hardlinks := map[uint64]string{} + buffer := make([]byte, 32*1024) + for _, include := range includes { + include = filepath.FromSlash(include) + var parents []parentMatch + root := strings.TrimSuffix(contextDir, string(filepath.Separator)) + string(filepath.Separator) + include // unlike filepath.Join, follows a symlinked context dir as docker build does + err := filepath.WalkDir(root, func(file string, entry fs.DirEntry, err error) error { + if err != nil { + return leaveOut(ctx, file, err) + } + rel, err := filepath.Rel(contextDir, file) + if err != nil || rel == "." { + return nil + } + if rel != include { + for len(parents) > 0 && !strings.HasPrefix(rel, parents[len(parents)-1].dir+string(filepath.Separator)) { + parents = parents[:len(parents)-1] + } + var parentInfo patternmatcher.MatchInfo + if len(parents) > 0 { + parentInfo = parents[len(parents)-1].info + } + skip, info, err := pm.MatchesUsingParentResults(rel, parentInfo) + if err != nil { + return err + } + if entry.IsDir() { + parents = append(parents, parentMatch{rel, info}) + } + if skip { + if entry.IsDir() && !hasExceptionWithin(pm, rel) { + return filepath.SkipDir + } + return nil + } + } + if seen[rel] { + return nil + } + seen[rel] = true + return addBuildContextEntry(ctx, tw, file, rel, entry, hardlinks, buffer) + }) + if err != nil { + return err + } + } + return tw.Close() +} + +type parentMatch struct { + dir string + info patternmatcher.MatchInfo +} + +func hasExceptionWithin(pm *patternmatcher.PatternMatcher, dir string) bool { + dirPrefix := dir + string(filepath.Separator) + for _, pattern := range pm.Patterns() { + if pattern.Exclusion() && strings.HasPrefix(pattern.String()+string(filepath.Separator), dirPrefix) { + return true + } + } + return false +} + +func leaveOut(ctx context.Context, file string, err error) error { + common.Logger(ctx).Warnf("Leaving %s out of the build context: %v", file, err) + return nil +} + +func addBuildContextEntry(ctx context.Context, tw *tar.Writer, file, rel string, entry fs.DirEntry, hardlinks map[uint64]string, buffer []byte) error { + info, err := entry.Info() + if err != nil { + return leaveOut(ctx, file, err) + } + if info.Mode()&fs.ModeSocket != 0 { + return nil // tar has no socket type + } + var link string + if info.Mode()&fs.ModeSymlink != 0 { + if link, err = os.Readlink(file); err != nil { + return leaveOut(ctx, file, err) + } + } + header, err := tar.FileInfoHeader(noNameLookups{info}, link) + if err != nil { + return err + } + header.Name = filepath.ToSlash(rel) + inode, linked := hardlinkInode(info) + if target, ok := hardlinks[inode]; linked && ok { + header.Typeflag, header.Linkname, header.Size = tar.TypeLink, target, 0 + } + var content *os.File + if header.Typeflag == tar.TypeReg && header.Size > 0 { + if content, err = os.Open(file); err != nil { + return leaveOut(ctx, file, err) + } + defer content.Close() + } + if linked && header.Typeflag != tar.TypeLink { + hardlinks[inode] = header.Name + } + if info.IsDir() { + header.Name += "/" + } + header.Format = tar.FormatPAX + header.ModTime = header.ModTime.Truncate(time.Second) + header.AccessTime, header.ChangeTime = time.Time{}, time.Time{} + if runtime.GOOS == "windows" { + header.Mode = header.Mode&0o755 | 0o111 // Windows has no exec bit, so mark everything executable like docker build + } + if err := tw.WriteHeader(header); err != nil { + return err + } + if content != nil { + _, err = io.CopyBuffer(tw, struct{ io.Reader }{content}, buffer) // hides os.File's WriteTo, which would allocate a buffer per file + } + return err +} + +type noNameLookups struct{ fs.FileInfo } // leaves Uname and Gname empty like docker build, so packing never waits on NSS + +func (noNameLookups) Uname() (string, error) { return "", nil } + +func (noNameLookups) Gname() (string, error) { return "", nil } diff --git a/act/container/docker_build_test.go b/act/container/docker_build_test.go new file mode 100644 index 00000000..a1843fd4 --- /dev/null +++ b/act/container/docker_build_test.go @@ -0,0 +1,49 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) + +package container + +import ( + "archive/tar" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateBuildContextFollowsDockerignoreAndSymlinkedRootButKeepsDockerfile(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + ".dockerignore": "*.log\nbuild\n!build/keep.txt\nDockerfile", "Dockerfile": "FROM scratch", "a.txt": "a", + "a.log": "log", "build/drop.txt": "drop", "build/keep.txt": "keep", + } { + require.NoError(t, os.MkdirAll(filepath.Join(dir, filepath.Dir(name)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) + } + require.NoError(t, os.Mkdir(filepath.Join(dir, "empty"), 0o755)) + + contexts := []string{dir} + if link := filepath.Join(t.TempDir(), "link"); os.Symlink(dir, link) == nil { + contexts = append(contexts, link+string(filepath.Separator)) + } + + for _, contextDir := range contexts { + buildContext, err := createBuildContext(t.Context(), contextDir, "Dockerfile") + require.NoError(t, err) + var names []string + for reader := tar.NewReader(buildContext); ; { + header, err := reader.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + names = append(names, header.Name) + } + require.NoError(t, buildContext.Close()) + require.Equal(t, []string{".dockerignore", "a.txt", "build/keep.txt", "empty/", "Dockerfile"}, names, contextDir) + } +} diff --git a/act/container/docker_build_unix.go b/act/container/docker_build_unix.go new file mode 100644 index 00000000..d9511c34 --- /dev/null +++ b/act/container/docker_build_unix.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd) + +package container + +import ( + "io/fs" + "syscall" +) + +func hardlinkInode(info fs.FileInfo) (uint64, bool) { + if stat, ok := info.Sys().(*syscall.Stat_t); ok && !info.IsDir() && stat.Nlink > 1 { + return stat.Ino, true + } + return 0, false +} diff --git a/act/container/docker_build_unix_test.go b/act/container/docker_build_unix_test.go new file mode 100644 index 00000000..ae24420d --- /dev/null +++ b/act/container/docker_build_unix_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER && (linux || darwin || netbsd) + +package container + +import ( + "archive/tar" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateBuildContextKeepsHardlinkedSpecialFilesLinked(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "first"), 0o644)) + require.NoError(t, os.Link(filepath.Join(dir, "first"), filepath.Join(dir, "second"))) + + buildContext, err := createBuildContext(t.Context(), dir, "Dockerfile") + require.NoError(t, err) + defer buildContext.Close() + reader := tar.NewReader(buildContext) + first, err := reader.Next() + require.NoError(t, err) + assert.Equal(t, byte(tar.TypeFifo), first.Typeflag) + second, err := reader.Next() + require.NoError(t, err) + assert.Equal(t, byte(tar.TypeLink), second.Typeflag) + assert.Equal(t, first.Name, second.Linkname) +} diff --git a/act/container/docker_build_windows.go b/act/container/docker_build_windows.go new file mode 100644 index 00000000..400ae14e --- /dev/null +++ b/act/container/docker_build_windows.go @@ -0,0 +1,10 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER + +package container + +import "io/fs" + +func hardlinkInode(_info fs.FileInfo) (uint64, bool) { return 0, false } diff --git a/act/container/docker_cli.go b/act/container/docker_cli.go index a515c1f8..f0747709 100644 --- a/act/container/docker_cli.go +++ b/act/container/docker_cli.go @@ -6,7 +6,8 @@ // This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with: // * appended with license information -// * regexp and loader.ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec +// * regexp and ParseVolume in place of the import-restricted internal/lazyregexp and internal/volumespec +// * cdiDeviceName in place of the container-device-interface parser's IsQualifiedName // * jsontext in place of json.Compact for the seccomp profile // * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go // @@ -33,14 +34,12 @@ import ( "time" cerrdefs "github.com/containerd/errdefs" - "github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/opts" "github.com/docker/go-connections/nat" "github.com/moby/moby/api/types/container" "github.com/moby/moby/api/types/mount" "github.com/moby/moby/api/types/network" "github.com/spf13/pflag" - cdi "tags.cncf.io/container-device-interface/pkg/parser" ) const ( @@ -388,7 +387,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con volumes := copts.volumes.GetMap() // add any bind targets to the list of container volumes for bind := range volumes { - parsed, err := loader.ParseVolume(bind) + parsed, err := ParseVolume(bind) if err != nil { return nil, err } @@ -396,7 +395,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con if parsed.Source != "" { toBind := bind - if parsed.Type == string(mount.TypeBind) { + if parsed.Type == mount.TypeBind { if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok { if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") { if absHostPart, err := filepath.Abs(hostPart); err == nil { @@ -504,7 +503,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con deviceMappings := make([]container.DeviceMapping, 0, len(devices)) cdiDeviceNames := make([]string, 0, len(devices)) for _, device := range devices { - if cdi.IsQualifiedName(device) { + if cdiDeviceName.MatchString(device) { cdiDeviceNames = append(cdiDeviceNames, device) continue } @@ -1197,6 +1196,8 @@ func convertPortSet(ports nat.PortSet) (network.PortSet, error) { return converted, nil } +var cdiDeviceName = regexp.MustCompile(`^[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?/[a-zA-Z]([a-zA-Z0-9_.-]*[a-zA-Z0-9])?=[a-zA-Z0-9]([a-zA-Z0-9_.:-]*[a-zA-Z0-9])?$`) + func convertPortMap(bindings nat.PortMap) (network.PortMap, error) { converted := make(network.PortMap, len(bindings)) for port, portBindings := range bindings { diff --git a/act/container/docker_cli_test.go b/act/container/docker_cli_test.go index ab38d874..7a4ef542 100644 --- a/act/container/docker_cli_test.go +++ b/act/container/docker_cli_test.go @@ -25,13 +25,11 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/moby/moby/api/types/container" networktypes "github.com/moby/moby/api/types/network" "github.com/spf13/pflag" - "gotest.tools/v3/assert" - is "gotest.tools/v3/assert/cmp" - "gotest.tools/v3/skip" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func mustParseMAC(s string) networktypes.HardwareAddr { @@ -90,7 +88,7 @@ func setupRunFlags() (*pflag.FlagSet, *containerOptions) { func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) { t.Helper() config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash")) - assert.NilError(t, err) + require.NoError(t, err) return config, hostConfig, nwConfig } @@ -156,12 +154,12 @@ func TestParseRunLinks(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { _, hostConfig, netConfig := mustParse(t, tc.input) - assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks)) - assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks))) + assert.Equal(t, tc.expHostConfigLinks, hostConfig.Links) + assert.Len(t, netConfig.EndpointsConfig, len(tc.expNetConfigLinks)) for netName, expLinks := range tc.expNetConfigLinks { nc, ok := netConfig.EndpointsConfig[netName] - assert.Assert(t, ok) - assert.Check(t, is.DeepEqual(nc.Links, expLinks)) + require.True(t, ok) + assert.Equal(t, expLinks, nc.Links) } }) } @@ -169,15 +167,15 @@ func TestParseRunLinks(t *testing.T) { func TestParseRunWithoutUmask(t *testing.T) { _, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"}) - assert.NilError(t, err) - assert.Assert(t, hostConfig.Umask == nil) + require.NoError(t, err) + require.Nil(t, hostConfig.Umask) } func TestParseRunUmask(t *testing.T) { _, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"}) - assert.NilError(t, err) - assert.Assert(t, hostConfig.Umask != nil) - assert.Equal(t, uint32(0o22), *hostConfig.Umask) + require.NoError(t, err) + require.NotNil(t, hostConfig.Umask) + require.Equal(t, uint32(0o22), *hostConfig.Umask) } func TestParseRunAttach(t *testing.T) { @@ -225,9 +223,9 @@ func TestParseRunAttach(t *testing.T) { for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { config, _, _ := mustParse(t, tc.input) - assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin) - assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout) - assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr) + require.Equal(t, tc.expected.AttachStdin, config.AttachStdin) + require.Equal(t, tc.expected.AttachStdout, config.AttachStdout) + require.Equal(t, tc.expected.AttachStderr, config.AttachStderr) }) } } @@ -273,7 +271,7 @@ func TestParseRunWithInvalidArgs(t *testing.T) { flags, _ := setupRunFlags() for _, tc := range tests { t.Run(strings.Join(tc.args, " "), func(t *testing.T) { - assert.Error(t, flags.Parse(tc.args), tc.error) + require.EqualError(t, flags.Parse(tc.args), tc.error) }) } } @@ -396,23 +394,23 @@ func TestRunFlagsParseWithMemory(t *testing.T) { flags, _ := setupRunFlags() args := []string{"--memory=invalid", "img", "cmd"} err := flags.Parse(args) - assert.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`) + require.ErrorContains(t, err, `invalid argument "invalid" for "-m, --memory" flag`) _, hostconfig, _ := mustParse(t, "--memory=1G") - assert.Check(t, is.Equal(int64(1073741824), hostconfig.Memory)) + assert.Equal(t, int64(1073741824), hostconfig.Memory) } func TestParseWithMemorySwap(t *testing.T) { flags, _ := setupRunFlags() args := []string{"--memory-swap=invalid", "img", "cmd"} err := flags.Parse(args) - assert.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`) + require.ErrorContains(t, err, `invalid argument "invalid" for "--memory-swap" flag`) _, hostconfig, _ := mustParse(t, "--memory-swap=1G") - assert.Check(t, is.Equal(int64(1073741824), hostconfig.MemorySwap)) + assert.Equal(t, int64(1073741824), hostconfig.MemorySwap) _, hostconfig, _ = mustParse(t, "--memory-swap=-1") - assert.Check(t, is.Equal(int64(-1), hostconfig.MemorySwap)) + assert.Equal(t, int64(-1), hostconfig.MemorySwap) } func TestParseHostname(t *testing.T) { @@ -474,7 +472,7 @@ func TestParseWithExpose(t *testing.T) { for expose, expectedError := range tests { t.Run(expose, func(t *testing.T) { _, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}) - assert.Error(t, err, expectedError) + require.EqualError(t, err, expectedError) }) } }) @@ -489,10 +487,10 @@ func TestParseWithExpose(t *testing.T) { for expose, exposedPorts := range tests { t.Run(expose, func(t *testing.T) { config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"}) - assert.NilError(t, err) + require.NoError(t, err) for _, port := range exposedPorts { _, ok := config.ExposedPorts[port] - assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port]) + assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port]) } }) } @@ -501,18 +499,20 @@ func TestParseWithExpose(t *testing.T) { t.Run("merge with published", func(t *testing.T) { // Merge with actual published port config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"}) - assert.NilError(t, err) - assert.Check(t, is.Len(config.ExposedPorts, 2)) + require.NoError(t, err) + assert.Len(t, config.ExposedPorts, 2) ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")} for _, port := range ports { _, ok := config.ExposedPorts[port] - assert.Check(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port]) + assert.True(t, ok, "missing port %q in exposed ports: %#+v", port, config.ExposedPorts[port]) } }) } func TestParseDevice(t *testing.T) { - skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side + if runtime.GOOS != "linux" { + t.Skip("Windows and macOS validate server-side") + } testCases := []struct { devices []string deviceMapping *container.DeviceMapping @@ -586,17 +586,17 @@ func TestParseDevice(t *testing.T) { _, hostconfig, _, err := parseRun(args) - assert.NilError(t, err) + require.NoError(t, err) if tc.deviceMapping != nil { - if assert.Check(t, is.Len(hostconfig.Devices, 1)) { - assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0])) + if assert.Len(t, hostconfig.Devices, 1) { + assert.Equal(t, *tc.deviceMapping, hostconfig.Devices[0]) } } else { - assert.Check(t, is.Len(hostconfig.Devices, 0)) + assert.Empty(t, hostconfig.Devices) } - assert.Check(t, is.DeepEqual(tc.deviceRequests, hostconfig.DeviceRequests)) + assert.Equal(t, tc.deviceRequests, hostconfig.DeviceRequests) }) } } @@ -665,11 +665,11 @@ func TestParseDeviceByServerOS(t *testing.T) { t.Run(tc.name, func(t *testing.T) { got, err := parseDevice(tc.device, tc.serverOS) if tc.wantErr != "" { - assert.Error(t, err, tc.wantErr) + require.EqualError(t, err, tc.wantErr) return } - assert.NilError(t, err) - assert.Equal(t, got, tc.want) + require.NoError(t, err) + require.Equal(t, tc.want, got) }) } } @@ -848,13 +848,13 @@ func TestParseNetworkConfig(t *testing.T) { _, hConfig, nwConfig, err := parseRun(tc.flags) if tc.expectedErr != "" { - assert.Error(t, err, tc.expectedErr) + require.EqualError(t, err, tc.expectedErr) return } - assert.NilError(t, err) - assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode) - assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{})) + require.NoError(t, err) + require.Equal(t, tc.expectedHostCfg.NetworkMode, hConfig.NetworkMode) + require.Equal(t, tc.expected, nwConfig.EndpointsConfig) }) } } @@ -863,24 +863,24 @@ func TestParseModes(t *testing.T) { // pid ko flags, copts := setupRunFlags() args := []string{"--pid=container:", "img", "cmd"} - assert.NilError(t, flags.Parse(args)) + require.NoError(t, flags.Parse(args)) _, err := parse(flags, copts, runtime.GOOS) - assert.ErrorContains(t, err, "--pid: invalid PID mode") + require.ErrorContains(t, err, "--pid: invalid PID mode") // pid ok _, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"}) - assert.NilError(t, err) + require.NoError(t, err) if !hostconfig.PidMode.Valid() { t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode) } // uts ko _, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests - assert.ErrorContains(t, err, "--uts: invalid UTS mode") + require.ErrorContains(t, err, "--uts: invalid UTS mode") // uts ok _, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"}) - assert.NilError(t, err) + require.NoError(t, err) if !hostconfig.UTSMode.Valid() { t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode) } @@ -892,11 +892,11 @@ func TestRunFlagsParseShmSize(t *testing.T) { args := []string{"--shm-size=a128m", "img", "cmd"} expectedErr := `invalid argument "a128m" for "--shm-size" flag:` err := flags.Parse(args) - assert.ErrorContains(t, err, expectedErr) + require.ErrorContains(t, err, expectedErr) // shm-size ok _, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"}) - assert.NilError(t, err) + require.NoError(t, err) if hostconfig.ShmSize != 134217728 { t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize) } @@ -967,11 +967,11 @@ func TestParseRestartPolicy(t *testing.T) { t.Run(tc.input, func(t *testing.T) { _, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"}) if tc.expectedErr != "" { - assert.Check(t, is.Error(err, tc.expectedErr)) - assert.Check(t, is.Nil(hostConfig)) + assert.Nil(t, hostConfig) + assert.EqualError(t, err, tc.expectedErr) } else { - assert.NilError(t, err) - assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected)) + require.NoError(t, err) + assert.Equal(t, tc.expected, hostConfig.RestartPolicy) } }) } @@ -980,7 +980,7 @@ func TestParseRestartPolicy(t *testing.T) { func TestParseRestartPolicyAutoRemove(t *testing.T) { _, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests const expected = "conflicting options: cannot specify both --restart and --rm" - assert.Check(t, is.Error(err, expected)) + assert.EqualError(t, err, expected) } func TestParseHealth(t *testing.T) { @@ -1118,12 +1118,14 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy func TestParseEntryPoint(t *testing.T) { config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"}) - assert.NilError(t, err) - assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"})) + require.NoError(t, err) + assert.Equal(t, []string{"anything"}, config.Entrypoint) } func TestValidateDevice(t *testing.T) { - skip.If(t, runtime.GOOS != "linux") // Windows and macOS validate server-side + if runtime.GOOS != "linux" { + t.Skip("Windows and macOS validate server-side") + } valid := []string{ "/home", "/home:/home", @@ -1222,22 +1224,22 @@ func TestValidateDeviceByServerOS(t *testing.T) { t.Run(tc.name, func(t *testing.T) { got, err := validateDevice(tc.value, tc.serverOS) if tc.wantError != "" { - assert.Error(t, err, tc.wantError) + require.EqualError(t, err, tc.wantError) return } - assert.NilError(t, err) - assert.Equal(t, got, tc.want) + require.NoError(t, err) + require.Equal(t, tc.want, got) }) } } func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) { got, err := validateDeviceCgroupRule("c 1:3 rwm") - assert.NilError(t, err) - assert.Equal(t, got, "c 1:3 rwm") + require.NoError(t, err) + require.Equal(t, "c 1:3 rwm", got) _, err = validateDeviceCgroupRule("invalid") - assert.Error(t, err, "invalid device cgroup format 'invalid'") + require.EqualError(t, err, "invalid device cgroup format 'invalid'") if invalidParameter(nil) != nil { t.Fatal("invalidParameter(nil) should be nil") @@ -1245,9 +1247,9 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) { cause := errors.New("bad input") err = invalidParameter(cause) var invalid interface{ InvalidParameter() } - assert.Assert(t, errors.As(err, &invalid)) - assert.Assert(t, errors.Is(err, cause)) - assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice + require.ErrorAs(t, err, &invalid) + require.ErrorIs(t, err, cause) + require.Condition(t, func() bool { return invalidParameter(err) == err }) } func TestParseSystemPaths(t *testing.T) { @@ -1298,9 +1300,9 @@ func TestParseSystemPaths(t *testing.T) { for _, tc := range tests { securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in) - assert.DeepEqual(t, securityOpts, tc.out) - assert.DeepEqual(t, maskedPaths, tc.masked) - assert.DeepEqual(t, readonlyPaths, tc.readonly) + require.Equal(t, tc.out, securityOpts) + require.Equal(t, tc.masked, maskedPaths) + require.Equal(t, tc.readonly, readonlyPaths) } } @@ -1322,9 +1324,9 @@ func TestConvertToStandardNotation(t *testing.T) { for key, ports := range valid { convertedPorts, err := convertToStandardNotation(ports) if err != nil { - assert.NilError(t, err) + require.NoError(t, err) } - assert.DeepEqual(t, strings.Split(key, " "), convertedPorts) + require.Equal(t, strings.Split(key, " "), convertedPorts) } for _, ports := range invalid { diff --git a/act/container/docker_pull_test.go b/act/container/docker_pull_test.go index 0b778bca..0b3d5f24 100644 --- a/act/container/docker_pull_test.go +++ b/act/container/docker_pull_test.go @@ -12,7 +12,6 @@ import ( "gitea.com/gitea/runner/act/common/logging" "gitea.com/gitea/runner/act/common/logging/logtest" - "github.com/docker/cli/cli/config" assert "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,10 +43,7 @@ func TestCleanImage(t *testing.T) { func TestGetImagePullOptions(t *testing.T) { ctx := t.Context() - orig := config.Dir() - t.Cleanup(func() { config.SetDir(orig) }) - - config.SetDir("/non-existent/docker") + t.Setenv("DOCKER_CONFIG", "/non-existent/docker") options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{}) assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act @@ -61,7 +57,7 @@ func TestGetImagePullOptions(t *testing.T) { assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act assert.Equal(t, "eyJ1c2VybmFtZSI6InVzZXJuYW1lIiwicGFzc3dvcmQiOiJwYXNzd29yZCJ9", options.RegistryAuth, "Username and Password should be provided") - config.SetDir("testdata/docker-pull-options") + t.Setenv("DOCKER_CONFIG", "testdata/docker-pull-options") options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{ Image: "nektos/act", diff --git a/act/container/docker_run.go b/act/container/docker_run.go index 646bd308..4681d06b 100644 --- a/act/container/docker_run.go +++ b/act/container/docker_run.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "net" "os" "path" "path/filepath" @@ -27,11 +28,8 @@ import ( "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common/logging" - "dario.cat/mergo" "github.com/bmatcuk/doublestar/v4" cerrdefs "github.com/containerd/errdefs" - "github.com/docker/cli/cli/compose/loader" - "github.com/docker/cli/cli/connhelper" "github.com/docker/cli/opts" "github.com/moby/moby/api/pkg/stdcopy" "github.com/moby/moby/api/types/container" @@ -372,15 +370,13 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) { dockerHost := os.Getenv("DOCKER_HOST") if strings.HasPrefix(dockerHost, "ssh://") { - var helper *connhelper.ConnectionHelper - - helper, err = connhelper.GetConnectionHelper(dockerHost) - if err != nil { + var args []string + if args, err = sshArgs(dockerHost); err != nil { return nil, err } cli, err = client.New( - client.WithHost(helper.Host), - client.WithDialContext(helper.Dialer), + client.WithHost("http://docker.example.com"), // the dialer below ignores the address + client.WithDialContext(func(context.Context, string, string) (net.Conn, error) { return dialCommand("ssh", args...) }), ) } else { cli, err = client.New(client.FromEnv) @@ -622,10 +618,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config) - err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice) - if err != nil { - return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err) - } + overlay(reflect.ValueOf(config).Elem(), reflect.ValueOf(containerConfig.Config).Elem(), true) logger.Debugf("Merged container.Config ==> %+v", config) logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig) @@ -635,10 +628,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config binds := hostConfig.Binds mounts := hostConfig.Mounts networkMode := hostConfig.NetworkMode - err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride) - if err != nil { - return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", options, err) - } + overlay(reflect.ValueOf(hostConfig).Elem(), reflect.ValueOf(containerConfig.HostConfig).Elem(), false) hostConfig.Binds = binds hostConfig.Mounts = mounts if cf.name != "" { @@ -1117,7 +1107,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai // sanitize binds sanitizedBinds := make([]string, 0, len(hostConfig.Binds)) for _, bind := range hostConfig.Binds { - parsed, err := loader.ParseVolume(bind) + parsed, err := ParseVolume(bind) if err != nil { logger.Warnf("parse volume [%s] error: %v", bind, err) continue @@ -1127,8 +1117,8 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai sanitizedBinds = append(sanitizedBinds, bind) continue } - if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) { - if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) { + if matcher.isValid(parsed.Source, parsed.Type, parsed.ReadOnly) { + if matcher.needsNoCopy(parsed.Source, parsed.Type) && !parsed.NoCopy { bind += ",nocopy" } sanitizedBinds = append(sanitizedBinds, bind) @@ -1168,9 +1158,56 @@ func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string { return hostConfig.Binds } +func overlay(dst, src reflect.Value, appendSlices bool) { + switch dst.Kind() { + case reflect.Struct: + opaque := true + for i := range dst.NumField() { + if dst.Type().Field(i).IsExported() { + opaque = false + overlay(dst.Field(i), src.Field(i), appendSlices) + } + } + if opaque { + dst.Set(src) + } + case reflect.Map: + if src.IsNil() { + return + } + if dst.IsNil() { + dst.Set(reflect.MakeMap(dst.Type())) + } + for key, value := range src.Seq2() { + dst.SetMapIndex(key, value) + } + case reflect.Slice: + if appendSlices { + dst.Set(reflect.AppendSlice(dst, src)) + } else if src.Len() > 0 { + dst.Set(src) + } + case reflect.Pointer: + if src.IsNil() { + return + } + if dst.IsNil() { + dst.Set(src) + return + } + overlay(dst.Elem(), src.Elem(), appendSlices) + case reflect.Array: + dst.Set(src) + default: + if !src.IsZero() { + dst.Set(src) + } + } +} + // bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed. func bindTarget(bind string) string { - parsed, err := loader.ParseVolume(bind) + parsed, err := ParseVolume(bind) if err != nil { return "" } diff --git a/act/container/docker_ssh.go b/act/container/docker_ssh.go new file mode 100644 index 00000000..3599957a --- /dev/null +++ b/act/container/docker_ssh.go @@ -0,0 +1,111 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) + +package container + +import ( + "bytes" + "errors" + "fmt" + "io" + "net" + "os/exec" + "strings" + "sync" + "syscall" + "time" + + "github.com/docker/cli/cli/connhelper/ssh" +) + +func sshArgs(daemonURL string) ([]string, error) { + spec, err := ssh.ParseURL(daemonURL) + if err != nil { + return nil, err + } + remote := []string{"docker", "system", "dial-stdio"} + if strings.Trim(spec.Path, "/") != "" { + remote = []string{"docker", "--host=unix://" + spec.Path, "system", "dial-stdio"} + } + return spec.Command([]string{"-o", "ConnectTimeout=30", "-T"}, remote...) +} + +func dialCommand(name string, args ...string) (net.Conn, error) { + conn := &commandConn{cmd: exec.Command(name, args...)} + conn.cmd.SysProcAttr = sshSysProcAttr() // a session of its own like docker's, so a ProxyCommand never reads the runner's terminal: https://github.com/docker/cli/issues/1707 + conn.cmd.Stderr = &conn.stderr + conn.cmd.WaitDelay = time.Second // a ProxyCommand child keeping stderr open would otherwise block Wait after ssh exits + conn.wait = sync.OnceValue(func() error { + if err := conn.cmd.Wait(); err != nil && !errors.Is(err, exec.ErrWaitDelay) { + return fmt.Errorf("%s: %w: %s", strings.Join(conn.cmd.Args, " "), err, strings.TrimSpace(conn.stderr.String())) + } + return nil + }) + var err error + if conn.stdin, err = conn.cmd.StdinPipe(); err != nil { + return nil, err + } + if conn.stdout, err = conn.cmd.StdoutPipe(); err != nil { + return nil, err + } + return conn, conn.cmd.Start() +} + +type commandConn struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr cappedBuffer + wait func() error +} + +func (c *commandConn) waitWithin(timeout time.Duration) error { + timer := time.AfterFunc(timeout, func() { _ = c.cmd.Process.Kill() }) + defer timer.Stop() + return c.wait() +} + +func (c *commandConn) Read(p []byte) (int, error) { + n, err := c.stdout.Read(p) + if errors.Is(err, io.EOF) { + if waitErr := c.waitWithin(10 * time.Second); waitErr != nil { + return n, waitErr + } + } + return n, err +} + +func (c *commandConn) Write(p []byte) (int, error) { return c.stdin.Write(p) } + +// CloseWrite lets the daemon see the end of a hijacked stream, like a TCP half-close. +func (c *commandConn) CloseWrite() error { return c.stdin.Close() } + +func (c *commandConn) Close() error { + _ = c.stdin.Close() + if c.cmd.Process.Signal(syscall.SIGTERM) != nil { // SIGTERM lets ssh take its ProxyCommand down, Windows only kills + _ = c.cmd.Process.Kill() + } + _ = c.waitWithin(3 * time.Second) + return nil +} + +func (c *commandConn) LocalAddr() net.Addr { return &net.UnixAddr{Name: "stdio", Net: "unix"} } + +func (c *commandConn) RemoteAddr() net.Addr { return &net.UnixAddr{Name: "stdio", Net: "unix"} } + +func (c *commandConn) SetDeadline(_deadline time.Time) error { return nil } + +func (c *commandConn) SetReadDeadline(_deadline time.Time) error { return nil } + +func (c *commandConn) SetWriteDeadline(_deadline time.Time) error { return nil } + +type cappedBuffer struct{ bytes.Buffer } + +func (b *cappedBuffer) Write(p []byte) (int, error) { + if b.Len() > 4096 { + b.Reset() + } + return b.Buffer.Write(p) +} diff --git a/act/container/docker_ssh_linux.go b/act/container/docker_ssh_linux.go new file mode 100644 index 00000000..3df51302 --- /dev/null +++ b/act/container/docker_ssh_linux.go @@ -0,0 +1,12 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER + +package container + +import "syscall" + +func sshSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true, Pdeathsig: syscall.SIGKILL} +} diff --git a/act/container/docker_ssh_test.go b/act/container/docker_ssh_test.go new file mode 100644 index 00000000..510983ad --- /dev/null +++ b/act/container/docker_ssh_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd)) + +package container + +import ( + "io" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSHArgs(t *testing.T) { + args, err := sshArgs("ssh://me@host:2222/run/user/1000/my docker.sock") + require.NoError(t, err) + assert.Equal(t, []string{"-l", "me", "-p", "2222", "-o", "ConnectTimeout=30", "-T", "--", "host", "docker '--host=unix:///run/user/1000/my docker.sock' system dial-stdio"}, args) + + args, err = sshArgs("ssh://[::1]") + require.NoError(t, err) + assert.Equal(t, []string{"-o", "ConnectTimeout=30", "-T", "--", "::1", "docker system dial-stdio"}, args) + + for _, invalid := range []string{"ssh://me:secret@host", "ssh://host?x=1", "ssh://host#x", "ssh://"} { + _, err := sshArgs(invalid) + assert.Error(t, err, invalid) + } +} + +func TestDialCommandStreamsStdioAndReportsExit(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("needs a POSIX shell") + } + conn, err := dialCommand("sh", "-c", "cat") + require.NoError(t, err) + _, err = conn.Write([]byte("ping")) + require.NoError(t, err) + require.NoError(t, conn.(interface{ CloseWrite() error }).CloseWrite()) + echoed, err := io.ReadAll(conn) + require.NoError(t, err) + assert.Equal(t, "ping", string(echoed)) + require.NoError(t, conn.Close()) + + conn, err = dialCommand("sh", "-c", "exec 3<&0; cat <&3 >/dev/null & echo 'no route to host' >&2; exit 255") + require.NoError(t, err) + _, err = io.ReadAll(conn) + require.ErrorContains(t, err, "no route to host") + require.NoError(t, conn.Close()) +} diff --git a/act/container/docker_ssh_unix.go b/act/container/docker_ssh_unix.go new file mode 100644 index 00000000..ce393182 --- /dev/null +++ b/act/container/docker_ssh_unix.go @@ -0,0 +1,12 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER && (darwin || netbsd) + +package container + +import "syscall" + +func sshSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/act/container/docker_ssh_windows.go b/act/container/docker_ssh_windows.go new file mode 100644 index 00000000..1970ec54 --- /dev/null +++ b/act/container/docker_ssh_windows.go @@ -0,0 +1,10 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !WITHOUT_DOCKER + +package container + +import "syscall" + +func sshSysProcAttr() *syscall.SysProcAttr { return nil } diff --git a/act/container/volume_spec.go b/act/container/volume_spec.go new file mode 100644 index 00000000..e482c4fb --- /dev/null +++ b/act/container/volume_spec.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "errors" + "fmt" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/moby/moby/api/types/mount" +) + +type VolumeSpec struct { + Type mount.Type + Source string + Target string + ReadOnly bool + NoCopy bool +} + +// ParseVolume parses spec like `docker run -v` does, without knowing the target platform. +func ParseVolume(spec string) (VolumeSpec, error) { + switch len(spec) { + case 0: + return VolumeSpec{}, errors.New("invalid empty volume spec") + case 1, 2: + return VolumeSpec{Type: mount.TypeVolume, Target: spec}, nil + } + + spec = string([]rune(spec)) // each invalid UTF-8 byte becomes U+FFFD, as in the JSON the daemon receives + var parts []string + for rest := spec; ; { + start := 0 + if hasDriveLetter(rest) { + start = strings.IndexByte(rest, ':') + 1 + } + end := strings.IndexByte(rest[start:], ':') + if end < 0 { + parts = append(parts, rest) + break + } + parts = append(parts, rest[:start+end]) + rest = rest[start+end+1:] + } + if len(parts) > 3 { + return VolumeSpec{}, fmt.Errorf("invalid spec: %s: too many colons", spec) + } + if slices.Contains(parts, "") { + return VolumeSpec{}, fmt.Errorf("invalid spec: %s: empty section between colons", spec) + } + + volume := VolumeSpec{Type: mount.TypeVolume, Target: parts[0]} + if len(parts) > 1 { + volume.Source, volume.Target = parts[0], parts[1] + if isHostPath(volume.Source) { + volume.Type = mount.TypeBind + } + } + if len(parts) == 3 { + for option := range strings.SplitSeq(parts[2], ",") { + switch option { + case "ro": + volume.ReadOnly = true + case "rw": + volume.ReadOnly = false + case "nocopy": + volume.NoCopy = true + } + } + } + return volume, nil +} + +func hasDriveLetter(s string) bool { + letter, size := utf8.DecodeRuneInString(s) + return size < len(s) && s[size] == ':' && unicode.IsLetter(letter) +} + +func isHostPath(source string) bool { + return strings.ContainsAny(source[:1], "./~") || strings.HasPrefix(source, `\\`) || hasDriveLetter(source) +} diff --git a/act/container/volume_spec_test.go b/act/container/volume_spec_test.go new file mode 100644 index 00000000..90a592e1 --- /dev/null +++ b/act/container/volume_spec_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "testing" + + "github.com/moby/moby/api/types/mount" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseVolumeReplacesInvalidUTF8LikeTheDaemonSees(t *testing.T) { + parsed, err := ParseVolume("/data/\xff\xfe:/mnt:ro") + require.NoError(t, err) + assert.Equal(t, VolumeSpec{Type: mount.TypeBind, Source: "/data/��", Target: "/mnt", ReadOnly: true}, parsed) +} diff --git a/act/runner/run_context.go b/act/runner/run_context.go index f43224b6..0ec7eb3e 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -36,11 +36,9 @@ import ( "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" - "github.com/docker/cli/cli/compose/loader" "github.com/docker/go-connections/nat" "github.com/kballard/go-shellquote" "github.com/moby/moby/api/types/mount" - "github.com/opencontainers/selinux/go-selinux" "go.yaml.in/yaml/v4" "golang.org/x/sync/errgroup" "golang.org/x/text/encoding/unicode" @@ -332,13 +330,13 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool) targets := map[string]bool{} for _, spec := range specs { - parsed, err := loader.ParseVolume(spec) + parsed, err := container.ParseVolume(spec) if err != nil { binds = append(binds, spec) // unclassifiable, sanitizeConfig warns and drops it continue } targets[parsed.Target] = true - if parsed.Type == string(mount.TypeVolume) && parsed.Source != "" && !parsed.ReadOnly { + if parsed.Type == mount.TypeVolume && parsed.Source != "" && !parsed.ReadOnly { mounts[parsed.Source] = parsed.Target } else { binds = append(binds, spec) @@ -379,7 +377,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri if runtime.GOOS == "darwin" { bindModifiers = ":delegated" } - if selinux.GetEnabled() { + if selinuxEnabled() { bindModifiers = ":z" } binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers)) diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 685f1251..9e5116ef 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -32,7 +32,6 @@ import ( "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" - "github.com/docker/cli/cli/compose/loader" "github.com/moby/moby/api/types/volume" assert "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -606,7 +605,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) { // volumes must displace the runner's rather than pile up next to them. targets := map[string]bool{} for _, bind := range gotbind { - parsed, err := loader.ParseVolume(bind) + parsed, err := container.ParseVolume(bind) require.NoError(t, err) assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind) targets[parsed.Target] = true diff --git a/act/runner/selinux_linux.go b/act/runner/selinux_linux.go new file mode 100644 index 00000000..d9643c79 --- /dev/null +++ b/act/runner/selinux_linux.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "os" + "strings" + "sync" + + "golang.org/x/sys/unix" +) + +var selinuxEnabled = sync.OnceValue(func() bool { + if !isSELinuxfs("/sys/fs/selinux") && !hasOtherSELinuxfs() { + return false + } + label, _ := os.ReadFile("/proc/self/attr/current") + return strings.TrimSuffix(string(label), "\x00") != "kernel" +}) + +func hasOtherSELinuxfs() bool { + mounts, _ := os.ReadFile("/proc/self/mountinfo") + for line := range strings.Lines(string(mounts)) { + if strings.Contains(line, " - selinuxfs ") { + if fields := strings.Fields(line); len(fields) > 4 && isSELinuxfs(fields[4]) { + return true + } + } + } + return false +} + +func isSELinuxfs(mountPoint string) bool { + var fs unix.Statfs_t + return unix.Statfs(mountPoint, &fs) == nil && uint32(fs.Type) == uint32(unix.SELINUX_MAGIC) && fs.Flags&unix.ST_RDONLY == 0 +} diff --git a/act/runner/selinux_other.go b/act/runner/selinux_other.go new file mode 100644 index 00000000..b9a819b9 --- /dev/null +++ b/act/runner/selinux_other.go @@ -0,0 +1,8 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +//go:build !linux + +package runner + +func selinuxEnabled() bool { return false } diff --git a/go.mod b/go.mod index ffd5ec92..e6ecaffa 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,7 @@ toolchain go1.27.2 require ( connectrpc.com/connect v1.21.0 - dario.cat/mergo v1.0.2 gitea.dev/actionslib v1.4.1 - github.com/avast/retry-go/v5 v5.0.0 github.com/bmatcuk/doublestar/v4 v4.10.2 github.com/coder/websocket v1.8.15 github.com/containerd/errdefs v1.0.0 @@ -17,21 +15,15 @@ require ( github.com/docker/cli v29.8.2+incompatible github.com/docker/go-connections v0.8.1 github.com/docker/go-units v0.5.0 - github.com/google/go-cmp v0.7.0 github.com/johannesboyne/gofakes3 v1.2.0 github.com/joho/godotenv v1.5.1 - github.com/julienschmidt/httprouter v1.3.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-isatty v0.0.24 - github.com/moby/go-archive v0.3.3 github.com/moby/moby/api v1.56.1 github.com/moby/moby/client v0.6.1 github.com/moby/patternmatcher v0.6.1 github.com/opencontainers/image-spec v1.1.1 - github.com/opencontainers/selinux v1.15.1 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.3 - github.com/sirupsen/logrus v1.10.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.12.1 @@ -49,37 +41,25 @@ require ( golang.org/x/term v0.46.0 golang.org/x/text v0.42.0 google.golang.org/protobuf v1.36.12 - gotest.tools/v3 v3.5.2 - tags.cncf.io/container-device-interface v1.1.1 ) require ( - cyphar.com/go-pathrs v0.2.5 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/cyphar/filepath-securejoin v0.7.0 // indirect - github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/sequential v0.7.0 // indirect - github.com/moby/sys/user v0.4.1 // indirect - github.com/moby/sys/userns v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/prometheus/common v0.70.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/stretchr/objx v0.5.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect diff --git a/go.sum b/go.sum index bb31c341..589dfb83 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,9 @@ connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs= connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -cyphar.com/go-pathrs v0.2.5 h1:SnX9FBvnoyn3lUs1dkMgZ52bAETpirNu3FTRh5HlRik= -cyphar.com/go-pathrs v0.2.5/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= gitea.dev/actionslib v1.4.1 h1:DWlUnzDfuuMArQWS6yZ+vwJu9av7sZVMOox7li/kyu4= gitea.dev/actionslib v1.4.1/go.mod h1:RbXaZudZ04YndjBBxZJF9rZAqU6SKGkaSD1sob5lQbE= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= -github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= @@ -52,23 +44,15 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= -github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.8.2+incompatible h1:2zgdFuoFst2T80oS42vhKGxkd0JRo305eIEKTsxR7pQ= github.com/docker/cli v29.8.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= -github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -80,12 +64,8 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -94,44 +74,26 @@ github.com/johannesboyne/gofakes3 v1.2.0 h1:I9VEzPWvvAUAGzDlhYFoZjF0AXMlkcEyZlmB github.com/johannesboyne/gofakes3 v1.2.0/go.mod h1:UHhRZRod9rENGFrUWTYnQHZqlNgSmjOq8DaD/ATQYRM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.3.3 h1:OxxR9paxsluYi+zDUEXTTaIxtkK3viymW+Ka7vRhhME= -github.com/moby/go-archive v0.3.3/go.mod h1:Npdv43fFqlhZW7Xo8fbm3ZMYFvAGNviUPqX21VERbcE= github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= github.com/moby/moby/client v0.6.1 h1:gvfKmdcWw+9MzXYP0vAUKQtBTGu1L8475K2nsmL7O98= github.com/moby/moby/client v0.6.1/go.mod h1:XHgTFqz9NCgS/VuoxXMmEDVmVgXQ4vFEc15wsCIwb24= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/mount v0.3.5 h1:eS3fsZTjHaBihwjp4/+5Z3jxqLXYsbwxqpVSfFv3M00= -github.com/moby/sys/mount v0.3.5/go.mod h1:WUQDO+/uCiCIkIztx8SrwIDVn2dtMFRBebRhpDFT71M= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= -github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= -github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= -github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= -github.com/moby/sys/userns v0.2.0 h1:nEtDtp7NCV/6dutSklNe8FrENPwFdc4mXnZqC/JWgXM= -github.com/moby/sys/userns v0.2.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/opencontainers/selinux v1.15.1 h1:ERxeh5caJvCzNAKdI8WQbJmB1LDTn4BuaAg8wihLBpA= -github.com/opencontainers/selinux v1.15.1/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= @@ -144,10 +106,6 @@ github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIj github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= -github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/spf13/afero v1.2.1 h1:qgMbHoJbPbw579P+1zVY+6n4nIFuIchaIjzZ/I/Yq8M= github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -227,5 +185,3 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -tags.cncf.io/container-device-interface v1.1.1 h1:YPwQz4xg8PlQ0yT/baR0BtLpTQROe4l6M1yuRgAu1vc= -tags.cncf.io/container-device-interface v1.1.1/go.mod h1:S1PSJWYPD4Iom0/39mvr/VVFCfG0Yt14j20d0OYrY2M= diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index 937435ba..f630b445 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -33,9 +33,8 @@ import ( "connectrpc.com/connect" runnerv1 "gitea.dev/actionslib/runner/v1" - "github.com/avast/retry-go/v5" - "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "golang.org/x/term" ) func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) func(cmd *cobra.Command, args []string) error { @@ -189,22 +188,22 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu }() // declare the labels of the runner before fetching tasks - resp, err := retry.NewWithData[*connect.Response[runnerv1.DeclareResponse]]( - retry.Context(ctx), - retry.UntilSucceeded(), - retry.Delay(declareRetryDelay), - retry.DelayType(retry.FixedDelay), - retry.RetryIf(shouldRetryDeclare), - retry.OnRetry(func(attempt uint, err error) { - if attempt == 0 { - logging.WithError(err).Warn("fail to invoke Declare, retrying until Gitea is reachable") - } else { - logging.WithError(err).Debug("fail to invoke Declare, retrying") - } - }), - ).Do(func() (*connect.Response[runnerv1.DeclareResponse], error) { - return runner.Declare(ctx, ls.Names()) - }) + var resp *connect.Response[runnerv1.DeclareResponse] + for attempt := 0; ; attempt++ { + resp, err = runner.Declare(ctx, ls.Names()) + if err == nil || !shouldRetryDeclare(err) || ctx.Err() != nil { + break + } + if attempt == 0 { + logging.WithError(err).Warn("fail to invoke Declare, retrying until Gitea is reachable") + } else { + logging.WithError(err).Debug("fail to invoke Declare, retrying") + } + select { + case <-time.After(declareRetryDelay): + case <-ctx.Done(): + } + } switch { case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err): return nil @@ -340,7 +339,7 @@ func initLogging(cfg *config.Config) { return funcname, filename } - isTerm := isatty.IsTerminal(os.Stdout.Fd()) + isTerm := term.IsTerminal(int(os.Stdout.Fd())) logging.SetTextFormat(logging.TextOptions{ DisableColors: !isTerm, FullTimestamp: true, diff --git a/internal/app/cmd/daemon_test.go b/internal/app/cmd/daemon_test.go index aa5c2ba3..a13cb580 100644 --- a/internal/app/cmd/daemon_test.go +++ b/internal/app/cmd/daemon_test.go @@ -188,10 +188,3 @@ func TestShouldRetryDeclare(t *testing.T) { require.Equal(t, tc.retry, shouldRetryDeclare(tc.err), tc.err.Error()) } } - -// A dependency that reconfigures logrus's global logger when it is loaded makes its own log lines -// show up twice or not at all, so this fails when upgrading one starts doing that. It does not -// cover a dependency that does so later, while running. -func TestLogrusBridgeSurvivesDependencies(t *testing.T) { - require.True(t, logging.LogrusBridgeIntact()) -} diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index fc9e5c1e..c2846fb3 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -24,15 +24,15 @@ import ( "connectrpc.com/connect" pingv1 "gitea.dev/actionslib/ping/v1" runnerv1 "gitea.dev/actionslib/runner/v1" - "github.com/mattn/go-isatty" "github.com/spf13/cobra" + "golang.org/x/term" ) // runRegister registers a runner to the server func runRegister(ctx context.Context, regArgs *registerArgs, configFile *string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { logging.SetReportCaller(false) - isTerm := isatty.IsTerminal(os.Stdout.Fd()) + isTerm := term.IsTerminal(int(os.Stdout.Fd())) logging.SetTextFormat(logging.TextOptions{ DisableColors: !isTerm, DisableTimestamp: true, diff --git a/internal/app/cmd/register_test.go b/internal/app/cmd/register_test.go index aecb33ce..f2a96f2e 100644 --- a/internal/app/cmd/register_test.go +++ b/internal/app/cmd/register_test.go @@ -14,7 +14,6 @@ import ( "gitea.com/gitea/runner/internal/pkg/config" "github.com/stretchr/testify/require" - "gotest.tools/v3/assert" ) func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) { @@ -23,7 +22,7 @@ func TestRegisterNonInteractiveReturnsLabelValidationError(t *testing.T) { Token: "token", InstanceAddr: "http://localhost:3000", }) - assert.ErrorContains(t, err, "empty label") + require.ErrorContains(t, err, "empty label") } func TestRegisterInputsValidate(t *testing.T) { @@ -262,6 +261,6 @@ func TestDoRegisterReturnsPingErrorWithoutRetrying(t *testing.T) { cfg := &config.Config{} cfg.Runner.File = filepath.Join(t.TempDir(), ".runner") err := doRegister(t.Context(), cfg, ®isterInputs{InstanceAddr: server.URL, RunnerName: "runner"}) - assert.ErrorContains(t, err, "cannot ping the Gitea instance server") - assert.Equal(t, int32(1), requests.Load()) + require.ErrorContains(t, err, "cannot ping the Gitea instance server") + require.Equal(t, int32(1), requests.Load()) } diff --git a/internal/app/run/workflow_test.go b/internal/app/run/workflow_test.go index 7d3d6663..b0cd031d 100644 --- a/internal/app/run/workflow_test.go +++ b/internal/app/run/workflow_test.go @@ -10,7 +10,6 @@ import ( runnerv1 "gitea.dev/actionslib/runner/v1" "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" - "gotest.tools/v3/assert" ) func Test_generateWorkflow(t *testing.T) { @@ -58,7 +57,7 @@ jobs: }, }, assert: func(t *testing.T, wf *model.Workflow) { - assert.DeepEqual(t, wf.GetJob("job9").Needs(), []string{"job1", "job2"}) + require.Equal(t, []string{"job1", "job2"}, wf.GetJob("job9").Needs()) }, want1: "job9", wantErr: false, @@ -83,8 +82,8 @@ jobs: }, assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("test") - assert.DeepEqual(t, job.Needs(), []string{}) - assert.Equal(t, len(job.Steps), 2) + require.Equal(t, []string{}, job.Needs()) + require.Len(t, job.Steps, 2) }, want1: "test", wantErr: false, @@ -125,9 +124,9 @@ jobs: assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("deploy") needs := job.Needs() - assert.DeepEqual(t, needs, []string{"build", "lint", "test"}) - assert.Equal(t, wf.Jobs["test"].Outputs["coverage"], "80%") - assert.Equal(t, wf.Jobs["lint"].Result, "failure") + require.Equal(t, []string{"build", "lint", "test"}, needs) + require.Equal(t, "80%", wf.Jobs["test"].Outputs["coverage"]) + require.Equal(t, "failure", wf.Jobs["lint"].Result) }, want1: "deploy", wantErr: false, @@ -165,11 +164,11 @@ jobs: }, }, assert: func(t *testing.T, wf *model.Workflow) { - assert.Equal(t, wf.Name, "Complex workflow") - assert.Equal(t, wf.Env["NODE_ENV"], "production") - assert.Equal(t, wf.Env["CI"], "true") + require.Equal(t, "Complex workflow", wf.Name) + require.Equal(t, "production", wf.Env["NODE_ENV"]) + require.Equal(t, "true", wf.Env["CI"]) job := wf.GetJob("build") - assert.Equal(t, len(job.Steps), 4) + require.Len(t, job.Steps, 4) }, want1: "build", wantErr: false, @@ -200,8 +199,8 @@ jobs: assert: func(t *testing.T, wf *model.Workflow) { job := wf.GetJob("integration") container := job.Container() - assert.Equal(t, container.Image, "node:18") - assert.Equal(t, job.Services["postgres"].Image, "postgres:15") + require.Equal(t, "node:18", container.Image) + require.Equal(t, "postgres:15", job.Services["postgres"].Image) }, want1: "integration", wantErr: false, @@ -231,7 +230,7 @@ jobs: job := wf.GetJob("test") matrixes, err := job.GetMatrixes() require.NoError(t, err) - assert.Equal(t, len(matrixes), 2) + require.Len(t, matrixes, 2) }, want1: "test", wantErr: false, @@ -245,9 +244,9 @@ jobs: }, }, assert: func(t *testing.T, wf *model.Workflow) { - assert.Equal(t, wf.Name, "Special: characters & test") + require.Equal(t, "Special: characters & test", wf.Name) job := wf.GetJob("test") - assert.Equal(t, len(job.Steps), 3) + require.Len(t, job.Steps, 3) }, want1: "test", wantErr: false, @@ -283,7 +282,7 @@ jobs: } require.NoError(t, err) tt.assert(t, got) - assert.Equal(t, got1, tt.want1) + require.Equal(t, tt.want1, got1) }) } } @@ -301,7 +300,7 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) { out, err := yaml.Marshal(&node) require.NoError(t, err) - assert.Equal(t, string(out), "- a\n- b\n- c\n") + require.Equal(t, "- a\n- b\n- c\n", string(out)) }) t.Run("unmarshal and re-marshal workflow", func(t *testing.T) { @@ -310,7 +309,7 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) { var wf map[string]any err := yaml.Unmarshal(input, &wf) require.NoError(t, err) - assert.Equal(t, wf["name"], "test") + require.Equal(t, "test", wf["name"]) out, err := yaml.Marshal(wf) require.NoError(t, err) @@ -318,7 +317,7 @@ func Test_yamlV4NodeRoundTrip(t *testing.T) { var wf2 map[string]any err = yaml.Unmarshal(out, &wf2) require.NoError(t, err) - assert.Equal(t, wf2["name"], "test") + require.Equal(t, "test", wf2["name"]) }) t.Run("node kind constants", func(t *testing.T) { diff --git a/internal/pkg/labels/labels_test.go b/internal/pkg/labels/labels_test.go index 6a665cc0..6ba226b3 100644 --- a/internal/pkg/labels/labels_test.go +++ b/internal/pkg/labels/labels_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/stretchr/testify/require" - "gotest.tools/v3/assert" ) func TestParse(t *testing.T) { @@ -77,7 +76,7 @@ func TestParse(t *testing.T) { return } require.NoError(t, err) - assert.DeepEqual(t, got, tt.want) + require.Equal(t, tt.want, got) }) } } diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index 59600f98..a4fbc738 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -8,6 +8,7 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "regexp" "slices" "strings" @@ -24,7 +25,6 @@ import ( "connectrpc.com/connect" "gitea.dev/actionslib/pkg/model" runnerv1 "gitea.dev/actionslib/runner/v1" - "github.com/avast/retry-go/v5" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -777,7 +777,23 @@ func (r *Reporter) Close(lastWords string) error { func (r *Reporter) flushFinal(fn func() error) error { ctx, cancel := context.WithTimeout(context.Background(), 3*r.effectiveCloseTimeout()) defer cancel() - return retry.New(retry.Context(ctx)).Do(fn) + var errs []error + for attempt := range 10 { + err := fn() + if err == nil { + return nil + } + errs = append(errs, err) + if attempt == 9 { + break + } + select { + case <-time.After(100*time.Millisecond<