mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 14:55:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2b3f6df5 | ||
|
|
785313cc21 | ||
|
|
41f1e08bec | ||
|
|
4158e05dae | ||
|
|
e356c2ed85 | ||
|
|
ed940db4f0 | ||
|
|
755af87095 | ||
|
|
b9aa47d1a5 | ||
|
|
0d84ec6dab | ||
|
|
b769a9765f | ||
|
|
9c4c66e70f | ||
|
|
42858c61df | ||
|
|
470969dc74 | ||
|
|
be7ddcd15a | ||
|
|
c3c647ef83 | ||
|
|
160e18e705 | ||
|
|
20c8b4abc4 | ||
|
|
ccb9afd7a5 | ||
|
|
49e34813ae | ||
|
|
e0ef637805 | ||
|
|
8502663d0e | ||
|
|
4e8037a93b | ||
|
|
76110557ab | ||
|
|
3bc5cc03f8 | ||
|
|
de961b91eb | ||
|
|
ad9eeb668a | ||
|
|
b741335222 | ||
|
|
20143c9558 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
- run: make lint-pr-title
|
- run: make lint-pr-title
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||||
- name: pre-pull test images
|
- name: pre-pull test images
|
||||||
env:
|
env:
|
||||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 # renovate: datasource=docker
|
||||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
|
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
|
||||||
run: |
|
run: |
|
||||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ linters:
|
|||||||
deny:
|
deny:
|
||||||
- pkg: golang.org/x/exp
|
- pkg: golang.org/x/exp
|
||||||
desc: it's experimental and unreliable
|
desc: it's experimental and unreliable
|
||||||
|
logging:
|
||||||
|
files:
|
||||||
|
- $all
|
||||||
|
- "!**/act/common/logging/**"
|
||||||
|
deny:
|
||||||
|
- pkg: log/slog
|
||||||
|
desc: log through gitea.com/gitea/runner/act/common/logging
|
||||||
nolintlint:
|
nolintlint:
|
||||||
allow-unused: false
|
allow-unused: false
|
||||||
require-explanation: true
|
require-explanation: true
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ depending on the prefix:
|
|||||||
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
- Command *properties* also escape `%3A` and `%2C`, which the UI never decodes, so the reporter
|
||||||
decodes exactly those two when folding a location into an annotation.
|
decodes exactly those two when folding a location into an annotation.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- The process-wide logger masks the secrets of every live task, the job logger masks its own.
|
||||||
|
Going around either leaks them.
|
||||||
|
- 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 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.
|
||||||
|
|
||||||
## Built-in actions
|
## Built-in actions
|
||||||
|
|
||||||
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
### BUILDER STAGE
|
### BUILDER STAGE
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM golang:1.27-alpine3.23 AS builder
|
FROM golang:1.27-alpine AS builder
|
||||||
|
|
||||||
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
# Do not remove `git` here, it is required for getting runner version when executing `make build`
|
||||||
RUN apk add --no-cache make git
|
RUN apk add --no-cache make git
|
||||||
@@ -17,7 +17,7 @@ RUN make clean && make build
|
|||||||
### DIND VARIANT
|
### DIND VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind AS dind
|
FROM docker:29.9.1-dind AS dind
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENTRYPOINT ["s6-svscan","/etc/s6"]
|
|||||||
### DIND-ROOTLESS VARIANT
|
### DIND-ROOTLESS VARIANT
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
FROM docker:29.7.2-dind-rootless AS dind-rootless
|
FROM docker:29.9.1-dind-rootless AS dind-rootless
|
||||||
|
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
DIST := dist
|
DIST := dist
|
||||||
EXECUTABLE := gitea-runner
|
EXECUTABLE := gitea-runner
|
||||||
DIST_DIRS := $(DIST)/binaries $(DIST)/release
|
|
||||||
GO ?= go
|
GO ?= go
|
||||||
SHASUM ?= shasum -a 256
|
|
||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
|
||||||
XGO_VERSION := go-1.27.x
|
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
|
||||||
|
|
||||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
||||||
|
|
||||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
|
||||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
|
||||||
WINDOWS_ARCHS ?= windows/amd64
|
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|
||||||
DOCKER_IMAGE ?= gitea/runner
|
DOCKER_IMAGE ?= gitea/runner
|
||||||
@@ -38,9 +30,6 @@ endif
|
|||||||
ifeq ($(HAS_GO), GO)
|
ifeq ($(HAS_GO), GO)
|
||||||
GOPATH ?= $(shell $(GO) env GOPATH)
|
GOPATH ?= $(shell $(GO) env GOPATH)
|
||||||
export PATH := $(GOPATH)/bin:$(PATH)
|
export PATH := $(GOPATH)/bin:$(PATH)
|
||||||
|
|
||||||
CGO_EXTRA_CFLAGS := -DSQLITE_MAX_VARIABLE_NUMBER=32766
|
|
||||||
CGO_CFLAGS ?= $(shell $(GO) env CGO_CFLAGS) $(CGO_EXTRA_CFLAGS)
|
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifeq ($(OS), Windows_NT)
|
ifeq ($(OS), Windows_NT)
|
||||||
@@ -108,8 +97,6 @@ fmt-check: ## check the Go code formatting
|
|||||||
.PHONY: deps-tools
|
.PHONY: deps-tools
|
||||||
deps-tools: ## install tool dependencies
|
deps-tools: ## install tool dependencies
|
||||||
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \
|
$(GO) install $(GOLANGCI_LINT_PACKAGE) & \
|
||||||
$(GO) install $(GXZ_PACKAGE) & \
|
|
||||||
$(GO) install $(XGO_PACKAGE) & \
|
|
||||||
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
$(GO) install $(GOVULNCHECK_PACKAGE) & \
|
||||||
wait
|
wait
|
||||||
|
|
||||||
@@ -187,45 +174,6 @@ $(EXECUTABLE): $(GOFILES)
|
|||||||
deps-backend: ## install backend dependencies
|
deps-backend: ## install backend dependencies
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
|
|
||||||
.PHONY: release
|
|
||||||
release: release-windows release-linux release-darwin release-copy release-compress release-check ## build release artifacts
|
|
||||||
|
|
||||||
$(DIST_DIRS):
|
|
||||||
mkdir -p $(DIST_DIRS)
|
|
||||||
|
|
||||||
.PHONY: release-windows
|
|
||||||
release-windows: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -buildmode exe -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(WINDOWS_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-linux
|
|
||||||
release-linux: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w -linkmode external -extldflags "-static" $(LDFLAGS)' -targets '$(LINUX_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-darwin
|
|
||||||
release-darwin: | $(DIST_DIRS)
|
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" $(GO) run $(XGO_PACKAGE) -go $(XGO_VERSION) -dest $(DIST)/binaries -tags 'netgo osusergo $(TAGS)' -ldflags '-s -w $(LDFLAGS)' -targets '$(DARWIN_ARCHS)' -out $(EXECUTABLE)-$(VERSION) .
|
|
||||||
ifeq ($(CI),true)
|
|
||||||
cp -r /build/* $(DIST)/binaries/
|
|
||||||
endif
|
|
||||||
|
|
||||||
.PHONY: release-copy
|
|
||||||
release-copy: | $(DIST_DIRS)
|
|
||||||
cd $(DIST); for file in `find . -type f -name "*"`; do cp $${file} ./release/; done;
|
|
||||||
|
|
||||||
.PHONY: release-check
|
|
||||||
release-check: | $(DIST_DIRS)
|
|
||||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "checksumming $${file}" && $(SHASUM) `echo $${file} | sed 's/^..//'` > $${file}.sha256; done;
|
|
||||||
|
|
||||||
.PHONY: release-compress
|
|
||||||
release-compress: | $(DIST_DIRS)
|
|
||||||
cd $(DIST)/release/; for file in `find . -type f -name "*"`; do echo "compressing $${file}" && $(GO) run $(GXZ_PACKAGE) -k -9 $${file}; done;
|
|
||||||
|
|
||||||
.PHONY: docker
|
.PHONY: docker
|
||||||
docker: ## build the docker image
|
docker: ## build the docker image
|
||||||
if ! docker buildx version >/dev/null 2>&1; then \
|
if ! docker buildx version >/dev/null 2>&1; then \
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
On the runner:
|
On the runner:
|
||||||
|
|
||||||
- `git` 2.34.1 or newer on `PATH`, to download actions and reusable workflows.
|
- `git` 2.34.1 or newer on `PATH`, to clone reusable workflows, and actions whose host serves no archive of them.
|
||||||
- `docker` or `podman` with `podman.socket` enabled, for docker mode, Docker actions and `docker://` steps. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
- `docker` or `podman` with `podman.socket` enabled, for docker mode, Docker actions and `docker://` steps. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
Where steps run, in the job image or on the runner in host mode:
|
Where steps run, in the job image or on the runner in host mode:
|
||||||
@@ -242,7 +242,7 @@ A job's `services` are started before its steps run. When a service's image or i
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:18
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
@@ -368,7 +368,7 @@ While the runner is idle it cleans up after earlier jobs:
|
|||||||
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
- when `container.bind_workdir` is enabled, stale task workspace directories older than `runner.workdir_cleanup_age` are removed (default: `24h`; set `0` to disable)
|
||||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||||
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
- on runners that use docker, per-job networks and volumes left behind by jobs the runner did not live to tear down are removed once unused and 10 minutes old, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||||
- on runners with `kubernetes` labels, job pods with that label older than `runner.workdir_cleanup_age` are removed the same way, together with their Secrets
|
- on runners with `kubernetes` labels, job pods with that label older than `runner.workdir_cleanup_age` are removed the same way, together with their Secrets
|
||||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
@@ -30,10 +29,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/internal/pkg/disk"
|
"gitea.com/gitea/runner/internal/pkg/disk"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/timshannon/bolthold"
|
"github.com/timshannon/bolthold"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
@@ -81,11 +79,11 @@ type Handler struct {
|
|||||||
dir string
|
dir string
|
||||||
storage cacheStorage
|
storage cacheStorage
|
||||||
s3 *s3Storage
|
s3 *s3Storage
|
||||||
router *httprouter.Router
|
router *http.ServeMux
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
port int
|
port int
|
||||||
server *http.Server
|
server *http.Server
|
||||||
logger logrus.FieldLogger
|
logger *logging.Logger
|
||||||
|
|
||||||
gcing atomic.Bool
|
gcing atomic.Bool
|
||||||
gcAt time.Time
|
gcAt time.Time
|
||||||
@@ -133,7 +131,7 @@ type Options struct {
|
|||||||
InternalSecret string
|
InternalSecret string
|
||||||
|
|
||||||
Policy Policy
|
Policy Policy
|
||||||
Logger logrus.FieldLogger
|
Logger *logging.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
||||||
@@ -147,11 +145,9 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
discard := logrus.New()
|
logger = logging.New(logging.DiscardHandler)
|
||||||
discard.Out = io.Discard
|
|
||||||
logger = discard
|
|
||||||
}
|
}
|
||||||
logger = logger.WithField("module", "artifactcache")
|
logger = logger.With("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if opts.OutboundIP != "" {
|
||||||
@@ -204,22 +200,22 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
}
|
}
|
||||||
h.secret = secret
|
h.secret = secret
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
router.GET(apiPath+"/cache", h.bearerAuth(h.find))
|
router.HandleFunc("GET "+apiPath+"/cache", h.bearerAuth(h.find))
|
||||||
router.POST(apiPath+"/caches", h.bearerAuth(h.reserve))
|
router.HandleFunc("POST "+apiPath+"/caches", h.bearerAuth(h.reserve))
|
||||||
router.PATCH(apiPath+"/caches/:id", h.bearerAuth(h.upload))
|
router.HandleFunc("PATCH "+apiPath+"/caches/{id}", h.bearerAuth(h.upload))
|
||||||
router.POST(apiPath+"/caches/:id", h.bearerAuth(h.commit))
|
router.HandleFunc("POST "+apiPath+"/caches/{id}", h.bearerAuth(h.commit))
|
||||||
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
|
router.HandleFunc("POST "+apiPath+"/clean", h.bearerAuth(h.clean))
|
||||||
// Artifact GET is signed via query-string HMAC because @actions/cache
|
// Artifact GET is signed via query-string HMAC because @actions/cache
|
||||||
// does not attach Authorization when downloading archiveLocation.
|
// 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
|
// Control-plane: a remote runner registers/revokes per-job tokens so the
|
||||||
// cache API can authenticate them. Always wired so the routes exist; the
|
// cache API can authenticate them. Always wired so the routes exist; the
|
||||||
// handlers themselves 401 when internalSecret is unset.
|
// handlers themselves 401 when internalSecret is unset.
|
||||||
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
|
router.HandleFunc("POST "+internalPath+"/register", h.internalAuth(h.internalRegister))
|
||||||
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
router.HandleFunc("POST "+internalPath+"/revoke", h.internalAuth(h.internalRevoke))
|
||||||
h.registerV2Routes(router)
|
h.registerV2Routes(router)
|
||||||
router.NotFound = http.HandlerFunc(h.forwardOrNotFound)
|
router.HandleFunc(artifactServicePath, h.forwardOrNotFound)
|
||||||
|
|
||||||
h.router = router
|
h.router = router
|
||||||
|
|
||||||
@@ -501,7 +497,7 @@ func (h *Handler) importMetadata(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /_apis/artifactcache/cache
|
// 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())
|
cred := credFromContext(r.Context())
|
||||||
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
keys := strings.Split(r.URL.Query().Get("keys"), ",")
|
||||||
version := r.URL.Query().Get("version")
|
version := r.URL.Query().Get("version")
|
||||||
@@ -552,7 +548,7 @@ func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches
|
// 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())
|
cred := credFromContext(r.Context())
|
||||||
api := &Request{}
|
api := &Request{}
|
||||||
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
if err := json.UnmarshalRead(r.Body, api); err != nil {
|
||||||
@@ -584,9 +580,9 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PATCH /_apis/artifactcache/caches/:id
|
// 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())
|
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 {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -632,9 +628,9 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/caches/:id
|
// 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())
|
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 {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -719,8 +715,8 @@ func (h *Handler) commitCache(cache *Cache) error {
|
|||||||
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
// @actions/cache toolkit downloads archiveLocation without Authorization.
|
||||||
// Repository scoping is already enforced at find() time; the signature binds
|
// Repository scoping is already enforced at find() time; the signature binds
|
||||||
// the URL to the specific cache ID and an expiry.
|
// the URL to the specific cache ID and an expiry.
|
||||||
func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -730,7 +726,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_apis/artifactcache/clean
|
// 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
|
// 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
|
// see: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
|
||||||
|
|
||||||
@@ -740,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
|
// bearerAuth resolves ACTIONS_RUNTIME_TOKEN against the set of currently
|
||||||
// registered jobs. A match attaches the job's JobCredential to the request
|
// registered jobs. A match attaches the job's JobCredential to the request
|
||||||
// context; a miss returns 401 before the handler body runs.
|
// context; a miss returns 401 before the handler body runs.
|
||||||
func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) bearerAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
||||||
token := bearerToken(r)
|
token := bearerToken(r)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -754,7 +750,7 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
ctx := context.WithValue(r.Context(), credKey{}, cred)
|
||||||
handler(w, r.WithContext(ctx), params)
|
handler(w, r.WithContext(ctx))
|
||||||
go h.gcCache()
|
go h.gcCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,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
|
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
|
||||||
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
// handler hands out, so one cannot be replayed as another; see computeSignature.
|
||||||
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) signedAuth(purpose string, handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
|
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 {
|
if err != nil {
|
||||||
h.responseJSON(w, r, 400, err)
|
h.responseJSON(w, r, 400, err)
|
||||||
return
|
return
|
||||||
@@ -797,7 +793,7 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout
|
|||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler(w, r, params)
|
handler(w, r)
|
||||||
go h.gcCache()
|
go h.gcCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,8 +802,8 @@ func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprout
|
|||||||
// constant-time-equal the configured internalSecret. If the secret is empty,
|
// constant-time-equal the configured internalSecret. If the secret is empty,
|
||||||
// the control-plane is disabled and every request gets 404 — which matches
|
// the control-plane is disabled and every request gets 404 — which matches
|
||||||
// the upstream nektos/act behavior of "the route does not exist".
|
// the upstream nektos/act behavior of "the route does not exist".
|
||||||
func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
func (h *Handler) internalAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.internalSecret == "" {
|
if h.internalSecret == "" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -817,7 +813,7 @@ func (h *Handler) internalAuth(handler httprouter.Handle) httprouter.Handle {
|
|||||||
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("internal: bad secret"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler(w, r, params)
|
handler(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,7 +836,7 @@ func (h *Handler) ResultsURL(cred JobCredential) string {
|
|||||||
return h.baseURL(cred)
|
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
|
var body internalRegisterBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
@@ -855,7 +851,7 @@ func (h *Handler) internalRegister(w http.ResponseWriter, r *http.Request, _ htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST /_internal/revoke
|
// 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
|
var body internalRevokeBody
|
||||||
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
if err := json.UnmarshalRead(r.Body, &body); err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The cache service v2 API. A client on this version talks twirp to
|
// The cache service v2 API. A client on this version talks twirp to
|
||||||
@@ -44,16 +42,16 @@ const (
|
|||||||
twirpUnauthenticated = "unauthenticated"
|
twirpUnauthenticated = "unauthenticated"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) registerV2Routes(router *httprouter.Router) {
|
func (h *Handler) registerV2Routes(router *http.ServeMux) {
|
||||||
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
|
||||||
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
|
||||||
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
router.HandleFunc("POST "+cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
|
||||||
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
|
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
|
// An entry that already exists is reported as not ok, which is how the client learns to skip
|
||||||
// the upload.
|
// 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())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
req, err := decodeTwirpRequest[v2CreateRequest](r)
|
||||||
if err != nil {
|
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())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
|
||||||
if err != nil {
|
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())
|
cred := credFromContext(r.Context())
|
||||||
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
req, err := decodeTwirpRequest[v2DownloadRequest](r)
|
||||||
if err != nil {
|
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
|
// 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
|
// cache is a single PUT, a large one is staged as blocks that a final block list puts
|
||||||
// in order.
|
// in order.
|
||||||
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
|
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.responseJSON(w, r, http.StatusBadRequest, err)
|
h.responseJSON(w, r, http.StatusBadRequest, err)
|
||||||
return
|
return
|
||||||
|
|||||||
+16
-22
@@ -19,8 +19,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type FileContainerResourceURL struct {
|
type FileContainerResourceURL struct {
|
||||||
@@ -67,24 +65,21 @@ func writeJSON(w http.ResponseWriter, value any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploads(router *httprouter.Router, baseDir string) {
|
func uploads(router *http.ServeMux, baseDir string) {
|
||||||
router.POST("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("POST /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
||||||
runID := params.ByName("runId")
|
|
||||||
|
|
||||||
writeJSON(w, FileContainerResourceURL{
|
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")
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
runID := params.ByName("runId")
|
|
||||||
|
|
||||||
if req.Header.Get("Content-Encoding") == "gzip" {
|
if req.Header.Get("Content-Encoding") == "gzip" {
|
||||||
itemPath += gzipExtension
|
itemPath += gzipExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
safeRunPath := safeResolve(baseDir, runID)
|
safeRunPath := safeResolve(baseDir, req.PathValue("runId"))
|
||||||
safePath := safeResolve(safeRunPath, itemPath)
|
safePath := safeResolve(safeRunPath, itemPath)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(safePath), os.ModePerm); err != nil {
|
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{
|
writeJSON(w, ResponseMessage{
|
||||||
Message: "success",
|
Message: "success",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func downloads(router *httprouter.Router, baseDir string) {
|
func downloads(router *http.ServeMux, baseDir string) {
|
||||||
router.GET("/_apis/pipelines/workflows/:runId/artifacts", func(w http.ResponseWriter, req *http.Request, params httprouter.Params) {
|
router.HandleFunc("GET /_apis/pipelines/workflows/{runId}/artifacts", func(w http.ResponseWriter, req *http.Request) {
|
||||||
runID := params.ByName("runId")
|
runID := req.PathValue("runId")
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, 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) {
|
router.HandleFunc("GET /download/{container}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
container := params.ByName("container")
|
container := req.PathValue("container")
|
||||||
itemPath := req.URL.Query().Get("itemPath")
|
itemPath := req.URL.Query().Get("itemPath")
|
||||||
safePath := safeResolve(baseDir, filepath.Join(container, 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) {
|
router.HandleFunc("GET /artifact/{path...}", func(w http.ResponseWriter, req *http.Request) {
|
||||||
path := params.ByName("path")[1:]
|
safePath := safeResolve(baseDir, req.PathValue("path"))
|
||||||
|
|
||||||
safePath := safeResolve(baseDir, path)
|
|
||||||
|
|
||||||
file, err := os.Open(safePath)
|
file, err := os.Open(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -214,7 +207,7 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
return cancel
|
return cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
|
|
||||||
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
logger.Debugf("Artifacts base path '%s'", artifactPath)
|
||||||
uploads(router, artifactPath)
|
uploads(router, artifactPath)
|
||||||
@@ -230,7 +223,8 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
|||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Start server on http://%s", server.Addr)
|
logger.Infof("Start server on http://%s", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
logger.Fatal(err)
|
logger.Error(err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestArtifactFlow(t *testing.T) {
|
func TestArtifactFlow(t *testing.T) {
|
||||||
artifactPath := t.TempDir()
|
artifactPath := t.TempDir()
|
||||||
|
|
||||||
router := httprouter.New()
|
router := http.NewServeMux()
|
||||||
uploads(router, artifactPath)
|
uploads(router, artifactPath)
|
||||||
downloads(router, artifactPath)
|
downloads(router, artifactPath)
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDryrunContext(t *testing.T) {
|
func TestDryrunContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
if Dryrun(ctx) {
|
if Dryrun(ctx) {
|
||||||
t.Fatal("plain context should not be dryrun")
|
t.Fatal("plain context should not be dryrun")
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ func TestDryrunContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJobErrorContainer(t *testing.T) {
|
func TestJobErrorContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
err := errors.New("job failed")
|
err := errors.New("job failed")
|
||||||
|
|
||||||
SetJobError(ctx, err)
|
SetJobError(ctx, err)
|
||||||
@@ -40,34 +40,27 @@ func TestJobErrorContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoggerAndHookContext(t *testing.T) {
|
func TestLoggerAndHandlerContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
if Logger(ctx) != logrus.StandardLogger() {
|
if Logger(ctx) != logging.Default() {
|
||||||
t.Fatal("plain context should use standard logger")
|
t.Fatal("plain context should use the default logger")
|
||||||
}
|
}
|
||||||
if LoggerHook(ctx) != nil {
|
if LoggerHandler(ctx) != nil {
|
||||||
t.Fatal("plain context should not have a logger hook")
|
t.Fatal("plain context should not have a logger handler")
|
||||||
|
}
|
||||||
|
if StepIDs(ctx) != nil {
|
||||||
|
t.Fatal("plain context should not have step IDs")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger := logrus.New()
|
logger := logging.New(logtest.New())
|
||||||
ctx = WithLogger(ctx, logger)
|
ctx = WithLogger(ctx, logger)
|
||||||
if Logger(ctx) != logger {
|
if Logger(ctx) != logger {
|
||||||
t.Fatal("WithLogger should set logger")
|
t.Fatal("WithLogger should set logger")
|
||||||
}
|
}
|
||||||
|
|
||||||
hook := testHook{}
|
handler := logtest.New()
|
||||||
ctx = WithLoggerHook(ctx, hook)
|
ctx = WithLoggerHandler(ctx, handler)
|
||||||
if LoggerHook(ctx) != hook {
|
if LoggerHandler(ctx) != handler {
|
||||||
t.Fatal("WithLoggerHook should set hook")
|
t.Fatal("WithLoggerHandler should set handler")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type testHook struct{}
|
|
||||||
|
|
||||||
func (testHook) Levels() []logrus.Level {
|
|
||||||
return logrus.AllLevels
|
|
||||||
}
|
|
||||||
|
|
||||||
func (testHook) Fire(*logrus.Entry) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Executor define contract for the steps of a workflow
|
// Executor define contract for the steps of a workflow
|
||||||
@@ -74,33 +74,33 @@ func NewParallelExecutor(parallel int, executors ...Executor) Executor {
|
|||||||
errs := make(chan error, len(executors))
|
errs := make(chan error, len(executors))
|
||||||
|
|
||||||
if 1 > parallel {
|
if 1 > parallel {
|
||||||
log.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
logging.Debugf("Parallel tasks (%d) below minimum, setting to 1", parallel)
|
||||||
parallel = 1
|
parallel = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
logging.Infof("NewParallelExecutor: Creating %d workers for %d executors", parallel, len(executors))
|
||||||
|
|
||||||
for i := 0; i < parallel; i++ {
|
for i := 0; i < parallel; i++ {
|
||||||
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
||||||
log.Debugf("Worker %d started", workerID)
|
logging.Debugf("Worker %d started", workerID)
|
||||||
taskCount := 0
|
taskCount := 0
|
||||||
for executor := range work {
|
for executor := range work {
|
||||||
taskCount++
|
taskCount++
|
||||||
log.Debugf("Worker %d executing task %d", workerID, taskCount)
|
logging.Debugf("Worker %d executing task %d", workerID, taskCount)
|
||||||
// Recover from panics in executors to avoid crashing the worker
|
// Recover from panics in executors to avoid crashing the worker
|
||||||
// goroutine which would leave the runner process hung.
|
// goroutine which would leave the runner process hung.
|
||||||
// https://gitea.com/gitea/runner/issues/371
|
// https://gitea.com/gitea/runner/issues/371
|
||||||
errs <- func() (err error) {
|
errs <- func() (err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
logging.Errorf("panic in executor: %v\n%s", r, debug.Stack())
|
||||||
err = fmt.Errorf("panic: %v", r)
|
err = fmt.Errorf("panic: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return executor(ctx)
|
return executor(ctx)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
log.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
logging.Debugf("Worker %d finished (%d tasks executed)", workerID, taskCount)
|
||||||
}(i, work, errs)
|
}(i, work, errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
func TestNewWorkflow(t *testing.T) {
|
func TestNewWorkflow(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
// empty
|
// empty
|
||||||
emptyWorkflow := NewPipelineExecutor()
|
emptyWorkflow := NewPipelineExecutor()
|
||||||
@@ -72,7 +72,7 @@ func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewParallelExecutor(t *testing.T) {
|
func TestNewParallelExecutor(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
exec, count, maxActive := concurrencyProbe(2)
|
exec, count, maxActive := concurrencyProbe(2)
|
||||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||||
@@ -89,10 +89,10 @@ func TestNewParallelExecutor(t *testing.T) {
|
|||||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
require.NoError(t, NewParallelExecutor(2)(ctx))
|
require.NoError(t, NewParallelExecutor(2)(ctx))
|
||||||
|
|
||||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
canceledCtx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
err := NewParallelExecutor(2)(canceledCtx)
|
err := NewParallelExecutor(2)(canceledCtx)
|
||||||
@@ -102,7 +102,7 @@ func TestNewParallelExecutorEmpty(t *testing.T) {
|
|||||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
@@ -118,7 +118,7 @@ func TestNewParallelExecutorFailed(t *testing.T) {
|
|||||||
func TestNewParallelExecutorCanceled(t *testing.T) {
|
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
errExpected := errors.New("fake error")
|
errExpected := errors.New("fake error")
|
||||||
@@ -150,12 +150,12 @@ func TestNewParallelExecutorRunsRemainingAfterFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
require.Error(t, NewParallelExecutor(2, executors...)(context.Background()))
|
require.Error(t, NewParallelExecutor(2, executors...)(t.Context()))
|
||||||
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
assert.Equal(t, int32(4), successCount.Load(), "a failing executor must not stop the others")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
var calls []string
|
var calls []string
|
||||||
record := func(name string) Executor {
|
record := func(name string) Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -180,7 +180,7 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
|||||||
mainErr := errors.New("main failed")
|
mainErr := errors.New("main failed")
|
||||||
finalErr := errors.New("cleanup failed")
|
finalErr := errors.New("cleanup failed")
|
||||||
|
|
||||||
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(context.Background())
|
err := NewErrorExecutor(mainErr).Finally(NewErrorExecutor(finalErr))(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
if !strings.Contains(err.Error(), "cleanup failed") || !strings.Contains(err.Error(), "main failed") {
|
||||||
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
t.Fatalf("finally error = %q, want both cleanup and original error", err)
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
var archiveRetryDelay = func(retryAfter string) time.Duration { // as actions/runner backs off
|
||||||
|
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||||
|
return min(max(time.Duration(seconds)*time.Second, 10*time.Second), 10*time.Minute)
|
||||||
|
}
|
||||||
|
return time.Duration(10+rand.IntN(21)) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAction(ctx context.Context, input NewGitCloneExecutorInput) (string, string, error) { // input.Dir is one clone URL and ref's, holding a directory per commit
|
||||||
|
unlock, err := AcquireCloneLock(ctx, input.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
if err := os.MkdirAll(input.Dir, 0o755); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tree := func(sha string) string { return filepath.Join(input.Dir, sha) }
|
||||||
|
record := filepath.Join(input.Dir, "ref")
|
||||||
|
data, _ := os.ReadFile(record)
|
||||||
|
sha, etag, _ := strings.Cut(string(data), "\n")
|
||||||
|
if !isFullObjectID(sha) || !exists(tree(sha)) {
|
||||||
|
sha, etag = "", ""
|
||||||
|
} else if input.OfflineMode || isFullObjectID(input.Ref) {
|
||||||
|
return tree(sha), sha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newSha, newEtag, err := downloadArchive(ctx, input, sha, etag)
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
common.Logger(ctx).Debugf("Cloning %s@%s: %v", input.URL, input.Ref, err)
|
||||||
|
newEtag = ""
|
||||||
|
newSha, err = exportClone(ctx, input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if newSha != sha || newEtag != etag {
|
||||||
|
if err := os.WriteFile(record, []byte(newSha+"\n"+newEtag), 0o600); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree(newSha), newSha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadArchive(ctx context.Context, input NewGitCloneExecutorInput, sha, etag string) (string, string, error) {
|
||||||
|
rewritten, err := gitOutput(ctx, "", "ls-remote", "--get-url", input.URL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
source, err := url.Parse(rewritten)
|
||||||
|
if err != nil || source.Scheme != "http" && source.Scheme != "https" || hexRefRegex.MatchString(input.Ref) && !isFullObjectID(input.Ref) { // only git tells a short commit from a branch
|
||||||
|
return "", "", errors.New("no archive")
|
||||||
|
}
|
||||||
|
archive := source.JoinPath("archive", input.Ref+".tar.gz")
|
||||||
|
if source.Host == "github.com" {
|
||||||
|
source.Host = "codeload.github.com"
|
||||||
|
archive = source.JoinPath("tar.gz", input.Ref)
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: input.Transport, Timeout: 5 * time.Minute, CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 10 {
|
||||||
|
return errors.New("stopped after 10 redirects")
|
||||||
|
}
|
||||||
|
if request.URL.Host != via[0].URL.Host || request.URL.Scheme != via[0].URL.Scheme { // the token stays on its origin
|
||||||
|
request.Header.Del("Authorization")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
for attempt := 1; ; attempt++ {
|
||||||
|
newSha, newEtag, retryAfter, err := downloadArchiveOnce(ctx, client, archive.String(), input.Token, sha, etag, input.Dir)
|
||||||
|
if retryAfter == 0 || attempt == 3 || ctx.Err() != nil {
|
||||||
|
return newSha, newEtag, err
|
||||||
|
}
|
||||||
|
common.Logger(ctx).Warnf("Failed to download action '%s'. Error: %v. Back off %s before retry.", archive.Redacted(), err, retryAfter)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", "", ctx.Err()
|
||||||
|
case <-time.After(retryAfter):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadArchiveOnce(ctx context.Context, client *http.Client, archive, token, sha, etag, dir string) (string, string, time.Duration, error) {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, archive, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, err
|
||||||
|
}
|
||||||
|
if etag != "" {
|
||||||
|
request.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
request.SetBasicAuth("token", token)
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) && opErr.Op == "dial" {
|
||||||
|
return "", "", 0, err
|
||||||
|
}
|
||||||
|
return "", "", archiveRetryDelay(""), err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
switch status := response.StatusCode; {
|
||||||
|
case status == http.StatusNotModified && sha != "":
|
||||||
|
return sha, etag, 0, nil
|
||||||
|
case status == http.StatusOK:
|
||||||
|
newEtag := response.Header.Get("ETag")
|
||||||
|
gz, err := gzip.NewReader(response.Body)
|
||||||
|
if err == nil {
|
||||||
|
sha, err = extractTree(gz, dir, !strings.HasSuffix(newEtag, `-false"`)) // Gitea's mark of an unprefixed archive
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) || errors.As(err, &netErr) {
|
||||||
|
return "", "", archiveRetryDelay(""), err
|
||||||
|
}
|
||||||
|
return sha, newEtag, 0, err
|
||||||
|
case status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= http.StatusInternalServerError:
|
||||||
|
return "", "", archiveRetryDelay(response.Header.Get("Retry-After")), errors.New(response.Status)
|
||||||
|
default:
|
||||||
|
return "", "", 0, errors.New(response.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportClone(ctx context.Context, input NewGitCloneExecutorInput) (string, error) {
|
||||||
|
clone := input
|
||||||
|
clone.Dir = filepath.Join(input.Dir, "git")
|
||||||
|
sha, _, err := resolveClone(ctx, clone)
|
||||||
|
if err != nil || exists(filepath.Join(input.Dir, sha)) {
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
archive, err := runGit(ctx, clone.Dir, nil, "archive", "--format=tar", sha)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = extractTree(bytes.NewReader(archive), input.Dir, false)
|
||||||
|
return sha, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTree(archive io.Reader, dir string, strip bool) (string, error) { // into dir/<commit of the pax header>, strip dropping the directory wrapping the files
|
||||||
|
temp, err := os.MkdirTemp(dir, "tmp-")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(temp)
|
||||||
|
root, err := os.OpenRoot(temp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
sha := ""
|
||||||
|
reader := tar.NewReader(archive)
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if header.Typeflag == tar.TypeXGlobalHeader {
|
||||||
|
if sha = header.PAXRecords["comment"]; isFullObjectID(sha) && exists(filepath.Join(dir, sha)) {
|
||||||
|
return sha, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := header.Name
|
||||||
|
if strip {
|
||||||
|
_, name, _ = strings.Cut(name, "/")
|
||||||
|
}
|
||||||
|
if name = strings.TrimSuffix(name, "/"); name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
err = root.MkdirAll(name, 0o755)
|
||||||
|
case tar.TypeReg:
|
||||||
|
err = writeTreeFile(root, name, header.Mode, reader)
|
||||||
|
case tar.TypeSymlink:
|
||||||
|
if err = root.Symlink(header.Linkname, name); err != nil && runtime.GOOS == "windows" {
|
||||||
|
err = writeTreeFile(root, name, 0, strings.NewReader(header.Linkname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isFullObjectID(sha) {
|
||||||
|
return "", errors.New("the archive names no commit")
|
||||||
|
}
|
||||||
|
if err := root.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sha, os.Rename(temp, filepath.Join(dir, sha))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTreeFile(root *os.Root, name string, mode int64, content io.Reader) error {
|
||||||
|
perm := os.FileMode(0o644)
|
||||||
|
if mode&0o111 != 0 {
|
||||||
|
perm = 0o755
|
||||||
|
}
|
||||||
|
file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(file, content)
|
||||||
|
return errors.Join(err, file.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(name string) bool {
|
||||||
|
_, err := os.Stat(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cgi"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchAction(t *testing.T) {
|
||||||
|
original := archiveRetryDelay
|
||||||
|
archiveRetryDelay = func(_retryAfter string) time.Duration { return time.Nanosecond }
|
||||||
|
t.Cleanup(func() { archiveRetryDelay = original })
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
remote := filepath.Join(root, "org", "action")
|
||||||
|
require.NoError(t, gitCmd("init", "--initial-branch=main", remote))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "action.yml"), []byte("name: action\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(remote, "run.sh"), []byte("#!/bin/sh\n"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink("action.yml", filepath.Join(remote, "link")))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "add", "."))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "commit", "-m", "initial"))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "tag", "v1"))
|
||||||
|
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
require.NoError(t, err)
|
||||||
|
backend := &cgi.Handler{Path: gitPath, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||||
|
var mu sync.Mutex
|
||||||
|
var archiveRequests []http.Header
|
||||||
|
var mirror *httptest.Server
|
||||||
|
requests, archives, failures := 0, "", 0
|
||||||
|
handler := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
requests++
|
||||||
|
ref, ok := strings.CutPrefix(request.URL.Path, "/org/action/archive/")
|
||||||
|
if !ok {
|
||||||
|
backend.ServeHTTP(writer, request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
archiveRequests = append(archiveRequests, request.Header.Clone())
|
||||||
|
switch {
|
||||||
|
case failures > 0:
|
||||||
|
failures--
|
||||||
|
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
case archives == "none":
|
||||||
|
http.NotFound(writer, request)
|
||||||
|
return
|
||||||
|
case archives == "unreachable":
|
||||||
|
http.Redirect(writer, request, "http://127.0.0.1:1"+request.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
case archives == "redirect" && request.Host != mirror.Listener.Addr().String():
|
||||||
|
http.Redirect(writer, request, mirror.URL+request.URL.Path, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sha, _ := gitOutput(request.Context(), remote, "rev-parse", strings.TrimSuffix(ref, ".tar.gz"))
|
||||||
|
etag := fmt.Sprintf(`W/"%s-tar.gz-%t"`, sha, archives != "unprefixed")
|
||||||
|
if request.Header.Get("If-None-Match") == etag {
|
||||||
|
writer.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
args := []string{"archive", "--format=tar.gz", sha}
|
||||||
|
if archives != "unprefixed" {
|
||||||
|
args = append(args, "--prefix=action/")
|
||||||
|
}
|
||||||
|
archive, _ := runGit(request.Context(), remote, nil, args...)
|
||||||
|
writer.Header().Set("ETag", etag)
|
||||||
|
_, _ = writer.Write(archive)
|
||||||
|
})
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
mirror = httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
t.Cleanup(mirror.Close)
|
||||||
|
serve := func(mode string, failing int) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
requests, archives, failures, archiveRequests = 0, mode, failing, nil
|
||||||
|
}
|
||||||
|
served := func() (int, []http.Header) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return requests, append([]http.Header(nil), archiveRequests...)
|
||||||
|
}
|
||||||
|
newInput := func(ref string) NewGitCloneExecutorInput {
|
||||||
|
return NewGitCloneExecutorInput{URL: server.URL + "/org/action", Ref: ref, Dir: t.TempDir(), Token: "secret"}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("downloads, revalidates and follows a moved tag", func(t *testing.T) {
|
||||||
|
serve("prefixed", 0)
|
||||||
|
input := newInput("v1")
|
||||||
|
tree, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "v1"), sha)
|
||||||
|
assert.Equal(t, filepath.Join(input.Dir, sha), tree)
|
||||||
|
assert.NoDirExists(t, filepath.Join(tree, ".git"))
|
||||||
|
info, err := os.Stat(filepath.Join(tree, "run.sh"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
|
||||||
|
link, err := os.Readlink(filepath.Join(tree, "link"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "action.yml", link)
|
||||||
|
|
||||||
|
again, _, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tree, again)
|
||||||
|
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "commit", "--allow-empty", "-m", "moved"))
|
||||||
|
require.NoError(t, gitCmd("-C", remote, "tag", "--force", "v1"))
|
||||||
|
moved, movedSha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "v1"), movedSha)
|
||||||
|
assert.NotEqual(t, tree, moved)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
||||||
|
|
||||||
|
input.OfflineMode = true
|
||||||
|
_, offlineSha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, movedSha, offlineSha)
|
||||||
|
|
||||||
|
requests, headers := served()
|
||||||
|
assert.Equal(t, 3, requests)
|
||||||
|
require.Len(t, headers, 3)
|
||||||
|
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), headers[0].Get("Authorization"))
|
||||||
|
assert.Empty(t, headers[0].Get("If-None-Match"))
|
||||||
|
assert.Equal(t, fmt.Sprintf(`W/"%s-tar.gz-true"`, sha), headers[1].Get("If-None-Match"))
|
||||||
|
assert.Equal(t, headers[1].Get("If-None-Match"), headers[2].Get("If-None-Match"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("downloads a pinned commit once and rejects a short one", func(t *testing.T) {
|
||||||
|
serve("prefixed", 0)
|
||||||
|
sha := gitRevParse(t, remote, "main")
|
||||||
|
input := newInput(sha)
|
||||||
|
for range 2 {
|
||||||
|
tree, got, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, sha, got)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"))
|
||||||
|
}
|
||||||
|
_, _, err := FetchAction(t.Context(), newInput(sha[:7]))
|
||||||
|
require.ErrorIs(t, err, ErrShortRef)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retries a failing download and drops the token on a redirect to another origin", func(t *testing.T) {
|
||||||
|
serve("redirect", 2)
|
||||||
|
input := newInput("main")
|
||||||
|
_, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
_, headers := served()
|
||||||
|
require.Len(t, headers, 4)
|
||||||
|
assert.NotEmpty(t, headers[2].Get("Authorization"))
|
||||||
|
assert.Empty(t, headers[3].Get("Authorization"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("keeps an unprefixed archive whole, downloads where insteadOf points and exports a clone without an archive or from an unreachable host", func(t *testing.T) {
|
||||||
|
for _, mode := range []string{"unprefixed", "none", "unreachable"} {
|
||||||
|
serve(mode, 0)
|
||||||
|
tree, sha, err := FetchAction(t.Context(), newInput("main"))
|
||||||
|
require.NoError(t, err, mode)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha, mode)
|
||||||
|
assert.FileExists(t, filepath.Join(tree, "action.yml"), mode)
|
||||||
|
assert.NoDirExists(t, filepath.Join(tree, ".git"), mode)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
serve("prefixed", 0)
|
||||||
|
config := filepath.Join(t.TempDir(), "gitconfig")
|
||||||
|
require.NoError(t, os.WriteFile(config, []byte("[url \""+server.URL+"/org/\"]\n\tinsteadOf = https://example.invalid/org/\n"), 0o644))
|
||||||
|
t.Setenv("GIT_CONFIG_GLOBAL", config)
|
||||||
|
input := newInput("main")
|
||||||
|
input.URL = "https://example.invalid/org/action"
|
||||||
|
_, sha, err := FetchAction(t.Context(), input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, gitRevParse(t, remote, "main"), sha)
|
||||||
|
_, headers := served()
|
||||||
|
assert.Len(t, headers, 1)
|
||||||
|
assert.NoDirExists(t, filepath.Join(input.Dir, "git"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractTreeConfinesEntries(t *testing.T) {
|
||||||
|
outside := t.TempDir()
|
||||||
|
for name, entries := range map[string][]*tar.Header{
|
||||||
|
"parent path": {{Name: "../escape", Typeflag: tar.TypeReg}},
|
||||||
|
"through a symlink": {{Name: "out", Typeflag: tar.TypeSymlink, Linkname: outside}, {Name: "out/escape", Typeflag: tar.TypeReg}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := tar.NewWriter(&archive)
|
||||||
|
require.NoError(t, writer.WriteHeader(&tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": strings.Repeat("a", 40)}}))
|
||||||
|
for _, entry := range entries {
|
||||||
|
require.NoError(t, writer.WriteHeader(entry))
|
||||||
|
}
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := extractTree(&archive, dir, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.NoFileExists(t, filepath.Join(dir, "escape"))
|
||||||
|
assert.NoFileExists(t, filepath.Join(outside, "escape"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
-59
@@ -10,8 +10,11 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -21,16 +24,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
codeCommitHTTPRegex = regexp.MustCompile(`^https?://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
codeCommitSSHRegex = regexp.MustCompile(`ssh://git-codecommit\.(.+)\.amazonaws.com/v1/repos/(.+)$`)
|
||||||
githubHTTPRegex = regexp.MustCompile(`^https?://.*github.com.*/(.+)/(.+?)(?:.git)?$`)
|
|
||||||
githubSSHRegex = regexp.MustCompile(`github.com[:/](.+)/(.+?)(?:.git)?$`)
|
|
||||||
hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
|
hexRefRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`)
|
||||||
|
|
||||||
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
|
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
|
||||||
@@ -44,12 +44,29 @@ var (
|
|||||||
ErrShortRef = errors.New("short SHA references are not supported")
|
ErrShortRef = errors.New("short SHA references are not supported")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
// AcquireCloneLock serializes access to dir across runner processes, or within this one where no lock file can be created. Readers of dir must hold it too.
|
||||||
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
func AcquireCloneLock(ctx context.Context, dir string) (func(), error) {
|
||||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
dir = filepath.Clean(dir)
|
||||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
if runtime.GOOS == "plan9" {
|
||||||
func AcquireCloneLock(dir string) func() {
|
return cloneLocks.Lock(dir), nil
|
||||||
return cloneLocks.Lock(dir)
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
release, err := lock.TryLock(dir)
|
||||||
|
if err == nil {
|
||||||
|
return func() { _ = release() }, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lock.ErrLocked) {
|
||||||
|
return cloneLocks.Lock(dir), nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
@@ -109,13 +126,12 @@ func FindGitRef(ctx context.Context, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FindGithubRepo get the repo
|
// FindGithubRepo get the repo
|
||||||
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
func FindGithubRepo(ctx context.Context, file string) (string, error) {
|
||||||
url, err := findGitRemoteURL(ctx, file)
|
url, err := findGitRemoteURL(ctx, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_, slug := findGitSlug(url, githubInstance)
|
return findGitSlug(url)
|
||||||
return slug, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitRemoteURL(ctx context.Context, file string) (string, error) {
|
func findGitRemoteURL(ctx context.Context, file string) (string, error) {
|
||||||
@@ -130,25 +146,25 @@ func findGitRemoteURL(ctx context.Context, file string) (string, error) {
|
|||||||
return url, nil
|
return url, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findGitSlug(url, githubInstance string) (string, string) {
|
func findGitSlug(remoteURL string) (string, error) {
|
||||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
if matches := codeCommitHTTPRegex.FindStringSubmatch(remoteURL); matches != nil {
|
||||||
return "CodeCommit", matches[2]
|
return matches[2], nil
|
||||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
} else if matches := codeCommitSSHRegex.FindStringSubmatch(remoteURL); matches != nil {
|
||||||
return "CodeCommit", matches[2]
|
return matches[2], nil
|
||||||
} else if matches := githubHTTPRegex.FindStringSubmatch(url); matches != nil {
|
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
|
||||||
} else if matches := githubSSHRegex.FindStringSubmatch(url); matches != nil {
|
|
||||||
return "GitHub", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
|
||||||
} else if githubInstance != "github.com" {
|
|
||||||
gheHTTPRegex := regexp.MustCompile(fmt.Sprintf(`^https?://%s/(.+)/(.+?)(?:.git)?$`, githubInstance))
|
|
||||||
gheSSHRegex := regexp.MustCompile(githubInstance + "[:/](.+)/(.+?)(?:.git)?$")
|
|
||||||
if matches := gheHTTPRegex.FindStringSubmatch(url); matches != nil {
|
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
|
||||||
} else if matches := gheSSHRegex.FindStringSubmatch(url); matches != nil {
|
|
||||||
return "GitHubEnterprise", fmt.Sprintf("%s/%s", matches[1], matches[2])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return "", url
|
if host, repoPath, ok := strings.Cut(remoteURL, ":"); ok && filepath.VolumeName(remoteURL) == "" && !strings.Contains(host, "/") && !strings.HasPrefix(repoPath, "//") {
|
||||||
|
remoteURL = "ssh://" + host + "/" + repoPath // scp-like [user@]host:path
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(remoteURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("cannot parse the origin remote URL")
|
||||||
|
}
|
||||||
|
owner, repo := path.Split(strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git"))
|
||||||
|
owner = path.Base(owner)
|
||||||
|
if parsed.Host == "" || repo == "" || owner == "." || owner == "/" {
|
||||||
|
return "", errors.New("cannot determine owner and repository from the origin remote URL")
|
||||||
|
}
|
||||||
|
return owner + "/" + repo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
// NewGitCloneExecutorInput the input for the NewGitCloneExecutor
|
||||||
@@ -163,17 +179,14 @@ type NewGitCloneExecutorInput struct {
|
|||||||
// 0 for full clone.
|
// 0 for full clone.
|
||||||
Depth int
|
Depth int
|
||||||
|
|
||||||
// Quiet drops the informational clone line to debug level, for callers that log their own
|
|
||||||
// download summary (the setup section's action report).
|
|
||||||
Quiet bool
|
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
||||||
|
Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneIfRequired reports whether an existing local clone was reused.
|
// CloneIfRequired reports whether an existing local clone was reused.
|
||||||
func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger log.FieldLogger) (bool, error) {
|
func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger *logging.Logger) (bool, error) {
|
||||||
var origin string
|
var origin string
|
||||||
_, err := os.Stat(filepath.Join(input.Dir, ".git")) // without it git would discover a repository around Dir
|
_, err := os.Stat(filepath.Join(input.Dir, ".git")) // without it git would discover a repository around Dir
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -182,6 +195,9 @@ func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger
|
|||||||
if err == nil && origin == input.URL {
|
if err == nil && origin == input.URL {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil { // a cancelled origin lookup must not discard a valid clone
|
||||||
|
return false, ctxErr
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL)
|
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, origin, input.URL)
|
||||||
} else if _, statErr := os.Stat(input.Dir); statErr == nil {
|
} else if _, statErr := os.Stat(input.Dir); statErr == nil {
|
||||||
@@ -203,32 +219,18 @@ func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger
|
|||||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
if input.Quiet {
|
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||||
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
|
||||||
} else {
|
|
||||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
|
||||||
}
|
|
||||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||||
defer AcquireCloneLock(input.Dir)()
|
unlock, err := AcquireCloneLock(ctx, input.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
reused, err := CloneIfRequired(ctx, input, logger)
|
resolved, reused, err := resolveClone(ctx, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
resolved, err := resolveRef(ctx, input.Dir, input.Ref)
|
|
||||||
if errors.Is(err, ErrShortRef) || (err != nil && input.OfflineMode) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !input.OfflineMode && (err != nil || (reused && !isFullHash(input.Ref, resolved))) {
|
|
||||||
if err := fetch(ctx, input); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resolved, err = resolveRef(ctx, input.Dir, input.Ref)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := runGit(ctx, input.Dir, nil, "checkout", "--force", "--detach", resolved); err != nil {
|
if _, err := runGit(ctx, input.Dir, nil, "checkout", "--force", "--detach", resolved); err != nil {
|
||||||
return fmt.Errorf("checkout %s: %w", resolved, err)
|
return fmt.Errorf("checkout %s: %w", resolved, err)
|
||||||
}
|
}
|
||||||
@@ -242,6 +244,28 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveClone(ctx context.Context, input NewGitCloneExecutorInput) (string, bool, error) { // the caller holds input.Dir's lock
|
||||||
|
logger := common.Logger(ctx)
|
||||||
|
reused, err := CloneIfRequired(ctx, input, logger)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
resolved, err := resolveRef(ctx, input.Dir, input.Ref)
|
||||||
|
if errors.Is(err, ErrShortRef) || (err != nil && input.OfflineMode) {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !input.OfflineMode && (err != nil || (reused && !isFullHash(input.Ref, resolved))) {
|
||||||
|
if err := fetch(ctx, input); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
resolved, err = resolveRef(ctx, input.Dir, input.Ref)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||||
|
}
|
||||||
|
return resolved, reused, err
|
||||||
|
}
|
||||||
|
|
||||||
func clone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
func clone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||||
if input.Depth > 0 && isFullObjectID(input.Ref) {
|
if input.Depth > 0 && isFullObjectID(input.Ref) {
|
||||||
if err := shallowPinnedClone(ctx, input); err == nil {
|
if err := shallowPinnedClone(ctx, input); err == nil {
|
||||||
@@ -282,7 +306,7 @@ func shallowPinnedClone(ctx context.Context, input NewGitCloneExecutorInput) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetch(ctx context.Context, input NewGitCloneExecutorInput) error {
|
func fetch(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||||
args := []string{"fetch", "--force", "--prune"}
|
args := []string{"fetch", "--force", "--no-prune"}
|
||||||
if _, err := os.Stat(filepath.Join(input.Dir, ".git", "shallow")); err == nil {
|
if _, err := os.Stat(filepath.Join(input.Dir, ".git", "shallow")); err == nil {
|
||||||
args = append(args, "--depth", "1", "--no-tags")
|
args = append(args, "--depth", "1", "--no-tags")
|
||||||
}
|
}
|
||||||
@@ -364,6 +388,44 @@ func ConfigEnv(config ...string) map[string]string {
|
|||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListFiles(ctx context.Context, dir string) ([]string, error) {
|
||||||
|
listed, err := runGit(ctx, dir, nil, "ls-files", "-z", "-t", "--stage", "--others", "--exclude-standard")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files, repos []string
|
||||||
|
for entry := range strings.SplitSeq(string(listed), "\x00") {
|
||||||
|
tag, file, ok := strings.Cut(entry, " ")
|
||||||
|
if tag != "?" {
|
||||||
|
var meta string
|
||||||
|
meta, file, ok = strings.Cut(file, "\t")
|
||||||
|
if strings.HasPrefix(meta, "160000 ") {
|
||||||
|
file += "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repo, isRepo := strings.CutSuffix(file, "/"); isRepo {
|
||||||
|
repos = append(repos, repo)
|
||||||
|
} else if ok {
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(repos)
|
||||||
|
for _, repo := range slices.Compact(repos) {
|
||||||
|
files = append(files, repo)
|
||||||
|
if _, err := os.Lstat(filepath.Join(dir, repo, ".git")); err != nil {
|
||||||
|
continue // git lists an uninitialized submodule as "./" from inside it
|
||||||
|
}
|
||||||
|
nested, err := ListFiles(ctx, filepath.Join(dir, repo))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, file := range nested {
|
||||||
|
files = append(files, repo+"/"+file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) {
|
func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, args ...string) ([]byte, error) {
|
||||||
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
||||||
if input != nil {
|
if input != nil {
|
||||||
|
|||||||
+99
-84
@@ -18,40 +18,45 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFindGitSlug(t *testing.T) {
|
func TestFindGitSlug(t *testing.T) {
|
||||||
assert := assert.New(t)
|
for _, testcase := range []struct {
|
||||||
|
remoteURL string
|
||||||
slugTests := []struct {
|
slug string
|
||||||
url string // input
|
|
||||||
provider string // expected result
|
|
||||||
slug string // expected result
|
|
||||||
}{
|
}{
|
||||||
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
|
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "my-repo-name"},
|
||||||
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
|
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "my-repo"},
|
||||||
{"[email protected]:nektos/act.git", "GitHub", "nektos/act"},
|
{"[email protected]:nektos/act.git", "nektos/act"},
|
||||||
{"[email protected]:nektos/act", "GitHub", "nektos/act"},
|
{"[email protected]:nektos/act", "nektos/act"},
|
||||||
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
{"https://github.com/nektos/act.git", "nektos/act"},
|
||||||
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
{"http://github.com/nektos/act.git", "nektos/act"},
|
||||||
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
|
{"https://github.com/nektos/act", "nektos/act"},
|
||||||
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
|
{"http://github.com/nektos/act", "nektos/act"},
|
||||||
{"git+ssh://[email protected]/owner/repo.git", "GitHub", "owner/repo"},
|
{"git+ssh://[email protected]/owner/repo.git", "owner/repo"},
|
||||||
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
|
{"https://example.com:3000/gitea/owner/repo.git/", "owner/repo"},
|
||||||
}
|
{"ssh://[email protected]:2222/gitea/owner/repo.git", "owner/repo"},
|
||||||
|
{"example.com:owner/repo.git", "owner/repo"},
|
||||||
for _, tt := range slugTests {
|
{"http://myotherrepo.com/act.git", ""},
|
||||||
provider, slug := findGitSlug(tt.url, "github.com")
|
{"https://user:[email protected]/act.git", ""},
|
||||||
assert.Equal(tt.provider, provider)
|
{"/tmp/owner/repo.git", ""},
|
||||||
assert.Equal(tt.slug, slug)
|
{"./owner/repo:branch", ""},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.remoteURL, func(t *testing.T) {
|
||||||
|
slug, err := findGitSlug(testcase.remoteURL)
|
||||||
|
assert.Equal(t, testcase.slug == "", err != nil)
|
||||||
|
assert.NotContains(t, fmt.Sprint(err), "secret")
|
||||||
|
assert.Equal(t, testcase.slug, slug)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,21 +98,21 @@ func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||||
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
||||||
|
|
||||||
shortSHA, sha, err := FindGitRevision(context.Background(), basedir)
|
shortSHA, sha, err := FindGitRevision(t.Context(), basedir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
||||||
require.Len(t, sha, 64)
|
require.Len(t, sha, 64)
|
||||||
require.Equal(t, sha[:7], shortSHA)
|
require.Equal(t, sha[:7], shortSHA)
|
||||||
|
|
||||||
ref, err := FindGitRef(context.Background(), basedir)
|
ref, err := FindGitRef(t.Context(), basedir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "refs/heads/main", ref)
|
require.Equal(t, "refs/heads/main", ref)
|
||||||
|
|
||||||
url, err := findGitRemoteURL(context.Background(), basedir)
|
url, err := findGitRemoteURL(t.Context(), basedir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, remoteURL, url)
|
require.Equal(t, remoteURL, url)
|
||||||
|
|
||||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com")
|
slug, err := FindGithubRepo(t.Context(), basedir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "owner/repo", slug)
|
require.Equal(t, "owner/repo", slug)
|
||||||
}
|
}
|
||||||
@@ -184,7 +189,7 @@ func TestGitFindRef(t *testing.T) {
|
|||||||
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
||||||
require.NoError(t, cleanGitHooks(dir))
|
require.NoError(t, cleanGitHooks(dir))
|
||||||
tt.Prepare(t, dir)
|
tt.Prepare(t, dir)
|
||||||
ref, err := FindGitRef(context.Background(), dir)
|
ref, err := FindGitRef(t.Context(), dir)
|
||||||
tt.Assert(t, ref, err)
|
tt.Assert(t, ref, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -258,17 +263,17 @@ func TestGitCloneExecutor(t *testing.T) {
|
|||||||
Dir: dir,
|
Dir: dir,
|
||||||
})
|
})
|
||||||
|
|
||||||
err := clone(context.Background())
|
err := clone(t.Context())
|
||||||
if tt.Err != nil {
|
if tt.Err != nil {
|
||||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.Err, err)
|
assert.Equal(t, tt.Err, err)
|
||||||
assert.Equal(t, tt.Err, clone(context.Background()), "a retry on the cache keeps the error")
|
assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
marker := filepath.Join(dir, "marker")
|
marker := filepath.Join(dir, "marker")
|
||||||
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
||||||
require.NoError(t, clone(context.Background()))
|
require.NoError(t, clone(t.Context()))
|
||||||
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
||||||
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -310,6 +315,12 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
|||||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||||
|
|
||||||
|
cancelled, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
_, err := CloneIfRequired(cancelled, NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: cacheDir}, logging.New(logtest.New()))
|
||||||
|
require.ErrorIs(t, err, context.Canceled)
|
||||||
|
require.FileExists(t, markerPath)
|
||||||
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||||
URL: newRemoteDir,
|
URL: newRemoteDir,
|
||||||
Ref: "main",
|
Ref: "main",
|
||||||
@@ -341,19 +352,30 @@ func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
||||||
require.NoError(t, clone(context.Background()))
|
require.NoError(t, clone(t.Context()))
|
||||||
|
require.NoError(t, gitCmd("-C", dir, "config", "fetch.prune", "true"))
|
||||||
|
require.NoError(t, clone(t.Context()))
|
||||||
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
|
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
||||||
require.NoError(t, clone(context.Background()), "a non-fast-forward ref must update")
|
require.NoError(t, clone(t.Context()), "a non-fast-forward ref must update")
|
||||||
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
||||||
|
|
||||||
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: t.TempDir()})
|
tagDir := t.TempDir()
|
||||||
require.Error(t, tagClone(context.Background()))
|
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: tagDir})
|
||||||
|
require.Error(t, tagClone(t.Context()))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
|
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
|
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
|
||||||
require.NoError(t, tagClone(context.Background()), "a tag published after a failed lookup must be fetched")
|
require.NoError(t, tagClone(t.Context()), "a tag published after a failed lookup must be fetched")
|
||||||
|
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "tag-moved"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v9"))
|
||||||
|
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "v9"))
|
||||||
|
for range 2 {
|
||||||
|
require.NoError(t, tagClone(t.Context()), "a moved tag must update and stay current")
|
||||||
|
assert.Equal(t, "tag-moved", gitHeadSubject(t, tagDir))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||||
@@ -373,7 +395,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
URL: remoteDir,
|
URL: remoteDir,
|
||||||
Ref: "main",
|
Ref: "main",
|
||||||
Dir: cacheDir,
|
Dir: cacheDir,
|
||||||
})(context.Background()))
|
})(t.Context()))
|
||||||
|
|
||||||
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
||||||
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
||||||
@@ -383,7 +405,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
Ref: "main",
|
Ref: "main",
|
||||||
Dir: cacheDir,
|
Dir: cacheDir,
|
||||||
OfflineMode: true,
|
OfflineMode: true,
|
||||||
})(context.Background())
|
})(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||||
@@ -398,50 +420,11 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
|||||||
Ref: "never-fetched",
|
Ref: "never-fetched",
|
||||||
Dir: cacheDir,
|
Dir: cacheDir,
|
||||||
OfflineMode: true,
|
OfflineMode: true,
|
||||||
})(context.Background())
|
})(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
remoteDir := t.TempDir()
|
|
||||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
|
||||||
workDir := t.TempDir()
|
|
||||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
|
||||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
|
||||||
|
|
||||||
// Quiet callers report the download themselves, so the clone line must not reach the job log.
|
|
||||||
for name, quiet := range map[string]bool{"quiet": true, "not quiet": false} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
logger, hook := logrustest.NewNullLogger()
|
|
||||||
logger.SetLevel(log.InfoLevel)
|
|
||||||
ctx := common.WithLogger(context.Background(), logger.WithField("job", "j1"))
|
|
||||||
|
|
||||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
|
||||||
URL: remoteDir,
|
|
||||||
Ref: "main",
|
|
||||||
Dir: t.TempDir(),
|
|
||||||
Quiet: quiet,
|
|
||||||
})(ctx))
|
|
||||||
|
|
||||||
var cloneLines int
|
|
||||||
for _, entry := range hook.AllEntries() {
|
|
||||||
if strings.HasPrefix(entry.Message, "git clone ") {
|
|
||||||
cloneLines++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if quiet {
|
|
||||||
assert.Zero(t, cloneLines)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, 1, cloneLines)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||||
@@ -631,14 +614,42 @@ func gitCmd(args ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAcquireCloneLock(t *testing.T) {
|
func TestAcquireCloneLock(t *testing.T) {
|
||||||
|
t.Run("blocks on a lock held by another process despite a trailing separator", func(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "cache")
|
||||||
|
release, err := lock.TryLock(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, release()) }()
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err := AcquireCloneLock(ctx, dir+string(filepath.Separator))
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("read-only parent falls back to an in-process lock", func(t *testing.T) {
|
||||||
|
parent := t.TempDir()
|
||||||
|
dir := filepath.Join(parent, "cache")
|
||||||
|
require.NoError(t, os.Mkdir(dir, 0o755))
|
||||||
|
require.NoError(t, os.Chmod(parent, 0o555))
|
||||||
|
defer func() { require.NoError(t, os.Chmod(parent, 0o755)) }()
|
||||||
|
unlock, err := AcquireCloneLock(t.Context(), dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
unlock()
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("same directory serializes", func(t *testing.T) {
|
t.Run("same directory serializes", func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|
||||||
unlock1 := AcquireCloneLock(dir)
|
unlock1, err := AcquireCloneLock(t.Context(), dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
secondAcquired := make(chan struct{})
|
secondAcquired := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
unlock := AcquireCloneLock(dir)
|
unlock, err := AcquireCloneLock(t.Context(), dir)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
close(secondAcquired)
|
close(secondAcquired)
|
||||||
unlock()
|
unlock()
|
||||||
}()
|
}()
|
||||||
@@ -662,12 +673,16 @@ func TestAcquireCloneLock(t *testing.T) {
|
|||||||
dirA := t.TempDir()
|
dirA := t.TempDir()
|
||||||
dirB := t.TempDir()
|
dirB := t.TempDir()
|
||||||
|
|
||||||
unlockA := AcquireCloneLock(dirA)
|
unlockA, err := AcquireCloneLock(t.Context(), dirA)
|
||||||
|
require.NoError(t, err)
|
||||||
defer unlockA()
|
defer unlockA()
|
||||||
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
unlock := AcquireCloneLock(dirB)
|
unlock, err := AcquireCloneLock(t.Context(), dirB)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
unlock()
|
unlock()
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|||||||
+30
-23
@@ -7,46 +7,53 @@ package common
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loggerContextKey string
|
type loggerContextKey string
|
||||||
|
|
||||||
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
const loggerContextKeyVal = loggerContextKey("logging.Logger")
|
||||||
|
|
||||||
// Logger returns the appropriate logger for current context
|
// Logger returns the appropriate logger for current context
|
||||||
func Logger(ctx context.Context) logrus.FieldLogger {
|
func Logger(ctx context.Context) *logging.Logger {
|
||||||
val := ctx.Value(loggerContextKeyVal)
|
if logger, ok := ctx.Value(loggerContextKeyVal).(*logging.Logger); ok {
|
||||||
if val != nil {
|
return logger
|
||||||
if logger, ok := val.(logrus.FieldLogger); ok {
|
|
||||||
return logger
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return logrus.StandardLogger()
|
return logging.Default()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLogger adds a value to the context for the logger
|
// WithLogger adds a value to the context for the logger
|
||||||
func WithLogger(ctx context.Context, logger logrus.FieldLogger) context.Context {
|
func WithLogger(ctx context.Context, logger *logging.Logger) context.Context {
|
||||||
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
return context.WithValue(ctx, loggerContextKeyVal, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
type loggerHookKey string
|
type loggerHandlerKey string
|
||||||
|
|
||||||
const loggerHookKeyVal = loggerHookKey("logrus.Hook")
|
const loggerHandlerKeyVal = loggerHandlerKey("logging.Handler")
|
||||||
|
|
||||||
// LoggerHook returns the appropriate logger hook for current context
|
// LoggerHandler returns the extra handler the job logger sends its records to, or nil.
|
||||||
// the hook affects job logger, not global logger
|
// It affects the job logger, not the global logger.
|
||||||
func LoggerHook(ctx context.Context) logrus.Hook {
|
func LoggerHandler(ctx context.Context) logging.Handler {
|
||||||
val := ctx.Value(loggerHookKeyVal)
|
if h, ok := ctx.Value(loggerHandlerKeyVal).(logging.Handler); ok {
|
||||||
if val != nil {
|
return h
|
||||||
if hook, ok := val.(logrus.Hook); ok {
|
|
||||||
return hook
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLoggerHook adds a value to the context for the logger hook
|
// WithLoggerHandler adds the extra handler for the job logger to the context
|
||||||
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
func WithLoggerHandler(ctx context.Context, h logging.Handler) context.Context {
|
||||||
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
return context.WithValue(ctx, loggerHandlerKeyVal, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
type stepIDsContextKey struct{}
|
||||||
|
|
||||||
|
// StepIDs returns the IDs of the step and the composite steps enclosing the current one, outermost first.
|
||||||
|
func StepIDs(ctx context.Context) []string {
|
||||||
|
ids, _ := ctx.Value(stepIDsContextKey{}).([]string)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStepIDs replaces the step IDs in the context
|
||||||
|
func WithStepIDs(ctx context.Context, ids []string) context.Context {
|
||||||
|
return context.WithValue(ctx, stepIDsContextKey{}, ids)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ansiReset = "\x1b[0m"
|
||||||
|
ansiRed = "\x1b[31m"
|
||||||
|
ansiYellow = "\x1b[33m"
|
||||||
|
ansiCyan = "\x1b[36m"
|
||||||
|
ansiDimCyan = "\x1b[2;36m"
|
||||||
|
ansiDimWhite = "\x1b[2;37m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// consoleStart is the zero of the elapsed-seconds stamp used when full timestamps are off.
|
||||||
|
var consoleStart = time.Now()
|
||||||
|
|
||||||
|
// console is the state shared by every consoleHandler derived from one another.
|
||||||
|
type console struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
out io.Writer
|
||||||
|
terminal bool
|
||||||
|
opts TextOptions
|
||||||
|
reportCaller bool
|
||||||
|
level slog.LevelVar
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConsole(out io.Writer) *console {
|
||||||
|
c := &console{}
|
||||||
|
c.setOutput(out)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *console) setOutput(w io.Writer) {
|
||||||
|
terminal := checkIfTerminal(w)
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.out, c.terminal = w, terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleHandler is a slog.Handler writing the text format the runner has always used, so that
|
||||||
|
// moving off logrus changes nothing a user can see: sorted key=value fields on a plain stream,
|
||||||
|
// and a colored prefix with elapsed or full time on a terminal.
|
||||||
|
type consoleHandler struct {
|
||||||
|
c *console
|
||||||
|
attrs []slog.Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) Enabled(_ context.Context, level slog.Level) bool {
|
||||||
|
return level >= h.c.level.Level()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *consoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
return &consoleHandler{c: h.c, attrs: append(slices.Clone(h.attrs), attrs...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGroup is a no-op, as only NewSlogHandler wraps a consoleHandler and it never groups.
|
||||||
|
func (h *consoleHandler) WithGroup(string) slog.Handler { return h }
|
||||||
|
|
||||||
|
func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
|
||||||
|
c := h.c
|
||||||
|
c.mu.Lock()
|
||||||
|
opts, reportCaller, terminal := c.opts, c.reportCaller, c.terminal
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
data := make(map[string]any, len(h.attrs)+r.NumAttrs())
|
||||||
|
for _, a := range h.attrs {
|
||||||
|
data[a.Key] = a.Value.Resolve().Any()
|
||||||
|
}
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
data[a.Key] = a.Value.Resolve().Any()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
var caller *runtime.Frame
|
||||||
|
if reportCaller && r.PC != 0 {
|
||||||
|
frame, _ := runtime.CallersFrames([]uintptr{r.PC}).Next()
|
||||||
|
caller = &frame
|
||||||
|
}
|
||||||
|
prefixFieldClashes(data, caller != nil)
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(data))
|
||||||
|
for k := range data {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
slices.Sort(keys)
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if terminal && !opts.DisableColors {
|
||||||
|
printColored(&b, opts, r, caller, keys, data)
|
||||||
|
} else {
|
||||||
|
printPlain(&b, opts, r, caller, keys, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := b.Bytes()
|
||||||
|
if opts.Filter != nil {
|
||||||
|
line = opts.Filter(line)
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
_, err := c.out.Write(line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixFieldClashes moves fields that share a name with a fixed key out of its way.
|
||||||
|
func prefixFieldClashes(data map[string]any, reportCaller bool) {
|
||||||
|
for _, key := range []string{"time", "level", "msg"} {
|
||||||
|
if v, ok := data[key]; ok {
|
||||||
|
data["fields."+key] = v
|
||||||
|
delete(data, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reportCaller {
|
||||||
|
for _, key := range []string{"func", "file"} {
|
||||||
|
if v, ok := data[key]; ok {
|
||||||
|
data["fields."+key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printPlain(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
|
||||||
|
if !opts.DisableTimestamp {
|
||||||
|
appendKeyValue(b, "time", r.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
appendKeyValue(b, "level", plainLevel(Level(r.Level)))
|
||||||
|
if r.Message != "" {
|
||||||
|
appendKeyValue(b, "msg", r.Message)
|
||||||
|
}
|
||||||
|
if caller != nil {
|
||||||
|
funcVal, fileVal := callerText(opts, caller)
|
||||||
|
if funcVal != "" {
|
||||||
|
appendKeyValue(b, "func", funcVal)
|
||||||
|
}
|
||||||
|
if fileVal != "" {
|
||||||
|
appendKeyValue(b, "file", fileVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
appendKeyValue(b, k, data[k])
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
func callerText(opts TextOptions, caller *runtime.Frame) (funcVal, fileVal string) {
|
||||||
|
if opts.CallerPrettyfier != nil {
|
||||||
|
return opts.CallerPrettyfier(caller)
|
||||||
|
}
|
||||||
|
return caller.Function, caller.File + ":" + strconv.Itoa(caller.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printColored(b *bytes.Buffer, opts TextOptions, r slog.Record, caller *runtime.Frame, keys []string, data map[string]any) {
|
||||||
|
msg := strings.TrimSuffix(r.Message, "\n")
|
||||||
|
|
||||||
|
var callerStr string
|
||||||
|
if caller != nil {
|
||||||
|
funcVal, fileVal := callerText(opts, caller)
|
||||||
|
if opts.CallerPrettyfier == nil && caller.Function != "" {
|
||||||
|
funcVal += "()"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case fileVal == "":
|
||||||
|
callerStr = funcVal
|
||||||
|
case funcVal == "":
|
||||||
|
callerStr = fileVal
|
||||||
|
default:
|
||||||
|
callerStr = fileVal + " " + funcVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
level := Level(r.Level)
|
||||||
|
prefix := colorize(level, strings.ToUpper(plainLevel(level))[:4])
|
||||||
|
switch {
|
||||||
|
case opts.DisableTimestamp:
|
||||||
|
fmt.Fprintf(b, "%s%s %-44s ", prefix, callerStr, msg)
|
||||||
|
case !opts.FullTimestamp:
|
||||||
|
fmt.Fprintf(b, "%s[%04d]%s %-44s ", prefix, int(r.Time.Sub(consoleStart)/time.Second), callerStr, msg)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(b, "%s[%s]%s %-44s ", prefix, r.Time.Format(time.RFC3339), callerStr, msg)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(colorize(level, k))
|
||||||
|
b.WriteByte('=')
|
||||||
|
appendValue(b, data[k])
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainLevel spells Warn the way logrus did, as "warning".
|
||||||
|
func plainLevel(l Level) string {
|
||||||
|
if s := l.String(); s != "warn" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorize(l Level, s string) string {
|
||||||
|
var color string
|
||||||
|
switch {
|
||||||
|
case l <= LevelTrace:
|
||||||
|
color = ansiDimWhite
|
||||||
|
case l <= LevelDebug:
|
||||||
|
color = ansiDimCyan
|
||||||
|
case l <= LevelInfo:
|
||||||
|
color = ansiCyan
|
||||||
|
case l <= LevelWarn:
|
||||||
|
color = ansiYellow
|
||||||
|
default:
|
||||||
|
color = ansiRed
|
||||||
|
}
|
||||||
|
return color + s + ansiReset
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendKeyValue(b *bytes.Buffer, key string, value any) {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(key)
|
||||||
|
b.WriteByte('=')
|
||||||
|
appendValue(b, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendValue(b *bytes.Buffer, value any) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
appendString(b, v)
|
||||||
|
case []byte:
|
||||||
|
appendString(b, string(v))
|
||||||
|
case error:
|
||||||
|
appendFormatted(b, v, v.Error)
|
||||||
|
case fmt.Stringer:
|
||||||
|
appendFormatted(b, v, v.String)
|
||||||
|
default:
|
||||||
|
appendString(b, fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendFormatted writes what get returns, and "<nil>" if that panics on a nil receiver.
|
||||||
|
func appendFormatted(b *bytes.Buffer, v any, get func() string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if rv := reflect.ValueOf(v); rv.Kind() == reflect.Pointer && rv.IsNil() {
|
||||||
|
appendString(b, "<nil>")
|
||||||
|
} else {
|
||||||
|
appendString(b, fmt.Sprintf("%%!v(PANIC=%v)", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
appendString(b, get())
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendString(b *bytes.Buffer, s string) {
|
||||||
|
if needsQuoting(s) {
|
||||||
|
b.WriteString(strconv.Quote(s))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsQuoting(s string) bool {
|
||||||
|
for i := range len(s) {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||||
|
case c == '-', c == '.', c == '_', c == '/', c == '@', c == '^', c == '+':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func consoleLine(t *testing.T, opts TextOptions, terminal, reportCaller bool, build func(*Logger)) string {
|
||||||
|
t.Helper()
|
||||||
|
var out bytes.Buffer
|
||||||
|
c := newConsole(&out)
|
||||||
|
c.opts, c.terminal, c.reportCaller = opts, terminal, reportCaller
|
||||||
|
c.level.Set(slog.LevelDebug)
|
||||||
|
build(New(NewSlogHandler(&consoleHandler{c: c})))
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePlain(t *testing.T) {
|
||||||
|
line := consoleLine(t, TextOptions{DisableTimestamp: true}, false, false, func(l *Logger) {
|
||||||
|
l.With("job", "build x", "n", 3).WithError(errors.New("boom")).Warnf("hello world")
|
||||||
|
l.With("msg", "clash", "empty", "").Info("")
|
||||||
|
})
|
||||||
|
assert.Equal(t, "level=warning msg=\"hello world\" error=boom job=\"build x\" n=3\n"+
|
||||||
|
"level=info empty= fields.msg=clash\n", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsolePlainTimeFilterAndCaller(t *testing.T) {
|
||||||
|
opts := TextOptions{
|
||||||
|
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
|
||||||
|
return "[fn]", "[" + strings.TrimPrefix(f.Function, "x") + "]"
|
||||||
|
},
|
||||||
|
Filter: func(b []byte) []byte { return bytes.ReplaceAll(b, []byte("secret"), []byte("***")) },
|
||||||
|
}
|
||||||
|
line := consoleLine(t, opts, false, true, func(l *Logger) { l.With("k", "secret").Debugf("a secret") })
|
||||||
|
assert.Regexp(t, `^time="[^"]+" level=debug msg="a \*\*\*" func="\[fn\]" file="\[.*TestConsolePlainTimeFilterAndCaller[^"]*\]" k=\*\*\*\n$`, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleColored(t *testing.T) {
|
||||||
|
line := consoleLine(t, TextOptions{DisableTimestamp: true}, true, false, func(l *Logger) {
|
||||||
|
l.With("job", "j").Warn("careful")
|
||||||
|
})
|
||||||
|
assert.Equal(t, "\x1b[33mWARN\x1b[0m "+"careful"+strings.Repeat(" ", 44-len("careful"))+" \x1b[33mjob\x1b[0m=j\n", line)
|
||||||
|
|
||||||
|
full := consoleLine(t, TextOptions{FullTimestamp: true}, true, false, func(l *Logger) { l.Error("bad") })
|
||||||
|
assert.Regexp(t, `^\x1b\[31mERRO\x1b\[0m\[\d{4}-\d\d-\d\dT[^\]]+\] bad`, full)
|
||||||
|
|
||||||
|
elapsed := consoleLine(t, TextOptions{}, true, false, func(l *Logger) { l.Info("x") })
|
||||||
|
assert.Regexp(t, `^\x1b\[36mINFO\x1b\[0m\[\d{4}\] x`, elapsed)
|
||||||
|
|
||||||
|
plain := consoleLine(t, TextOptions{DisableColors: true, DisableTimestamp: true}, true, false, func(l *Logger) { l.Info("x") })
|
||||||
|
assert.Equal(t, "level=info msg=x\n", plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsoleLevelAndOutput(t *testing.T) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
c := newConsole(&out)
|
||||||
|
h := &consoleHandler{c: c}
|
||||||
|
assert.False(t, h.Enabled(t.Context(), slog.LevelDebug)) // info unless raised
|
||||||
|
c.level.Set(slog.LevelError)
|
||||||
|
assert.False(t, h.Enabled(t.Context(), slog.LevelWarn))
|
||||||
|
assert.True(t, h.Enabled(t.Context(), slog.LevelError))
|
||||||
|
|
||||||
|
rec := slog.NewRecord(time.Unix(0, 0), slog.LevelError, "m", 0)
|
||||||
|
rec.AddAttrs(slog.String("c", "3"))
|
||||||
|
require.NoError(t, h.WithAttrs([]slog.Attr{slog.Int("a", 1)}).Handle(t.Context(), rec))
|
||||||
|
assert.Contains(t, out.String(), "a=1 c=3")
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextOptions describes how the process-wide Logger writes a record as text.
|
||||||
|
type TextOptions struct {
|
||||||
|
DisableColors bool
|
||||||
|
DisableTimestamp bool
|
||||||
|
FullTimestamp bool
|
||||||
|
// CallerPrettyfier renames the function and file reported with a record when ReportCaller is on.
|
||||||
|
CallerPrettyfier func(*runtime.Frame) (function, file string)
|
||||||
|
// Filter rewrites each formatted line, for example to mask secrets.
|
||||||
|
Filter func(line []byte) []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
std atomic.Pointer[Logger]
|
||||||
|
stdState = newConsole(os.Stderr)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
std.Store(New(NewSlogHandler(&consoleHandler{c: stdState})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default returns the process-wide Logger.
|
||||||
|
func Default() *Logger { return std.Load() }
|
||||||
|
|
||||||
|
// SetDefault replaces the process-wide Logger, which tests use to capture what the package
|
||||||
|
// functions log. SetLevel, GetLevel, SetOutput and the format functions keep configuring the
|
||||||
|
// initial console Logger.
|
||||||
|
func SetDefault(l *Logger) { std.Store(l) }
|
||||||
|
|
||||||
|
// 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()) }
|
||||||
|
|
||||||
|
// SetOutput sets where the initial process-wide Logger writes.
|
||||||
|
func SetOutput(w io.Writer) { stdState.setOutput(w) }
|
||||||
|
|
||||||
|
// Output returns where the initial process-wide Logger writes.
|
||||||
|
func Output() io.Writer {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
return stdState.out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTextFormat sets the text format of the initial process-wide Logger.
|
||||||
|
func SetTextFormat(o TextOptions) {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
stdState.opts = o
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReportCaller makes the initial process-wide Logger record where each call came from, which costs time.
|
||||||
|
func SetReportCaller(on bool) {
|
||||||
|
stdState.mu.Lock()
|
||||||
|
defer stdState.mu.Unlock()
|
||||||
|
stdState.reportCaller = on
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enabled(level Level) bool { return Default().Enabled(level) }
|
||||||
|
|
||||||
|
func With(args ...any) *Logger { return Default().With(args...) }
|
||||||
|
func WithError(err error) *Logger { return Default().WithError(err) }
|
||||||
|
func Tracef(format string, args ...any) { Default().logf(LevelTrace, format, args) }
|
||||||
|
func Debugf(format string, args ...any) { Default().logf(LevelDebug, format, args) }
|
||||||
|
func Infof(format string, args ...any) { Default().logf(LevelInfo, format, args) }
|
||||||
|
func Warnf(format string, args ...any) { Default().logf(LevelWarn, format, args) }
|
||||||
|
func Errorf(format string, args ...any) { Default().logf(LevelError, format, args) }
|
||||||
|
func Trace(args ...any) { Default().log(LevelTrace, args) }
|
||||||
|
func Debug(args ...any) { Default().log(LevelDebug, args) }
|
||||||
|
func Info(args ...any) { Default().log(LevelInfo, args) }
|
||||||
|
func Warn(args ...any) { Default().log(LevelWarn, args) }
|
||||||
|
func Error(args ...any) { Default().log(LevelError, args) }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attr is a structured key/value pair attached to a log record.
|
||||||
|
type Attr struct {
|
||||||
|
Key string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record is one log event. Attrs holds the call-site attributes only, the ones added with
|
||||||
|
// Logger.With are accumulated by the Handler.
|
||||||
|
type Record struct {
|
||||||
|
Time time.Time
|
||||||
|
Level Level
|
||||||
|
Message string
|
||||||
|
PC uintptr // program counter of the logging call, 0 if unknown
|
||||||
|
Attrs []Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attr returns the value of the last attribute named key.
|
||||||
|
func (r Record) Attr(key string) (any, bool) {
|
||||||
|
for _, a := range slices.Backward(r.Attrs) {
|
||||||
|
if a.Key == key {
|
||||||
|
return a.Value, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler consumes records, and is shaped like slog.Handler so that a slog backend can replace
|
||||||
|
// the current one without touching callers. Handle must not modify the Attrs slice it is given.
|
||||||
|
type Handler interface {
|
||||||
|
Enabled(ctx context.Context, level Level) bool
|
||||||
|
Handle(ctx context.Context, r Record) error
|
||||||
|
// WithAttrs returns a Handler that adds attrs to every record it handles.
|
||||||
|
WithAttrs(attrs []Attr) Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
type multiHandler []Handler
|
||||||
|
|
||||||
|
// NewMultiHandler fans every record out to all handlers that have its level enabled.
|
||||||
|
func NewMultiHandler(handlers ...Handler) Handler {
|
||||||
|
return multiHandler(handlers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
for _, h := range m {
|
||||||
|
if h.Enabled(ctx, level) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
var errs []error
|
||||||
|
for _, h := range m {
|
||||||
|
if h.Enabled(ctx, r.Level) {
|
||||||
|
errs = append(errs, h.Handle(ctx, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m multiHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
out := make(multiHandler, len(m))
|
||||||
|
for i, h := range m {
|
||||||
|
out[i] = h.WithAttrs(attrs)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type discardHandler struct{}
|
||||||
|
|
||||||
|
// DiscardHandler drops every record.
|
||||||
|
var DiscardHandler Handler = discardHandler{}
|
||||||
|
|
||||||
|
func (discardHandler) Enabled(context.Context, Level) bool { return false }
|
||||||
|
func (discardHandler) Handle(context.Context, Record) error { return nil }
|
||||||
|
func (d discardHandler) WithAttrs([]Attr) Handler { return d }
|
||||||
|
|
||||||
|
type levelHandler struct {
|
||||||
|
h Handler
|
||||||
|
minLevel Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLevelHandler passes only records at or above minLevel on to h.
|
||||||
|
func NewLevelHandler(h Handler, minLevel Level) Handler {
|
||||||
|
return levelHandler{h: h, minLevel: minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
return level >= l.minLevel && l.h.Enabled(ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
if r.Level < l.minLevel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.h.Handle(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l levelHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
return levelHandler{h: l.h.WithAttrs(attrs), minLevel: l.minLevel}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level is a log severity. The values match log/slog, with Trace added below Debug.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelTrace Level = -8
|
||||||
|
LevelDebug Level = -4
|
||||||
|
LevelInfo Level = 0
|
||||||
|
LevelWarn Level = 4
|
||||||
|
LevelError Level = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch {
|
||||||
|
case l <= LevelTrace:
|
||||||
|
return "trace"
|
||||||
|
case l <= LevelDebug:
|
||||||
|
return "debug"
|
||||||
|
case l <= LevelInfo:
|
||||||
|
return "info"
|
||||||
|
case l <= LevelWarn:
|
||||||
|
return "warn"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLevel accepts every name logrus does. "fatal" and "panic" map to LevelError because
|
||||||
|
// the Logger has no level above it.
|
||||||
|
func ParseLevel(s string) (Level, error) {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "trace":
|
||||||
|
return LevelTrace, nil
|
||||||
|
case "debug":
|
||||||
|
return LevelDebug, nil
|
||||||
|
case "info":
|
||||||
|
return LevelInfo, nil
|
||||||
|
case "warn", "warning":
|
||||||
|
return LevelWarn, nil
|
||||||
|
case "error", "fatal", "panic":
|
||||||
|
return LevelError, nil
|
||||||
|
}
|
||||||
|
return LevelInfo, fmt.Errorf("not a valid log level: %q", s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const badKey = "!BADKEY"
|
||||||
|
|
||||||
|
// Logger writes printf-style messages with structured attributes to a Handler.
|
||||||
|
type Logger struct {
|
||||||
|
h Handler
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(h Handler) *Logger {
|
||||||
|
return &Logger{h: h, ctx: context.Background()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With returns a Logger that adds the key/value pairs to every record, like slog.Logger.With.
|
||||||
|
func (l *Logger) With(args ...any) *Logger {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return &Logger{h: l.h.WithAttrs(argsToAttrs(args)), ctx: l.ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) WithError(err error) *Logger {
|
||||||
|
return l.With("error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext returns a Logger that passes ctx to its Handler.
|
||||||
|
func (l *Logger) WithContext(ctx context.Context) *Logger {
|
||||||
|
return &Logger{h: l.h, ctx: ctx}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Enabled(level Level) bool {
|
||||||
|
return l.h.Enabled(l.ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Tracef(format string, args ...any) { l.logf(LevelTrace, format, args) }
|
||||||
|
func (l *Logger) Debugf(format string, args ...any) { l.logf(LevelDebug, format, args) }
|
||||||
|
func (l *Logger) Infof(format string, args ...any) { l.logf(LevelInfo, format, args) }
|
||||||
|
func (l *Logger) Warnf(format string, args ...any) { l.logf(LevelWarn, format, args) }
|
||||||
|
func (l *Logger) Errorf(format string, args ...any) { l.logf(LevelError, format, args) }
|
||||||
|
|
||||||
|
// Trace and the methods below format their arguments like fmt.Sprint.
|
||||||
|
func (l *Logger) Trace(args ...any) { l.log(LevelTrace, args) }
|
||||||
|
func (l *Logger) Debug(args ...any) { l.log(LevelDebug, args) }
|
||||||
|
func (l *Logger) Info(args ...any) { l.log(LevelInfo, args) }
|
||||||
|
func (l *Logger) Warn(args ...any) { l.log(LevelWarn, args) }
|
||||||
|
func (l *Logger) Error(args ...any) { l.log(LevelError, args) }
|
||||||
|
|
||||||
|
func (l *Logger) logf(level Level, format string, args []any) {
|
||||||
|
if !l.Enabled(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.emit(callerPC(4), level, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(level Level, args []any) {
|
||||||
|
if !l.Enabled(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l.emit(callerPC(4), level, fmt.Sprint(args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit drops the Handler error, as a logger has nowhere to report it.
|
||||||
|
func (l *Logger) emit(pc uintptr, level Level, msg string) {
|
||||||
|
_ = l.h.Handle(l.ctx, Record{Time: time.Now(), Level: level, Message: msg, PC: pc})
|
||||||
|
}
|
||||||
|
|
||||||
|
// callerPC returns the program counter skip frames up, 4 being the caller of an exported method.
|
||||||
|
func callerPC(skip int) uintptr {
|
||||||
|
var pcs [1]uintptr
|
||||||
|
runtime.Callers(skip, pcs[:])
|
||||||
|
return pcs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func argsToAttrs(args []any) []Attr {
|
||||||
|
attrs := make([]Attr, 0, len(args)/2+1)
|
||||||
|
for len(args) > 0 {
|
||||||
|
switch k := args[0].(type) {
|
||||||
|
case Attr:
|
||||||
|
attrs = append(attrs, k)
|
||||||
|
args = args[1:]
|
||||||
|
case string:
|
||||||
|
if len(args) == 1 {
|
||||||
|
attrs = append(attrs, Attr{Key: badKey, Value: k})
|
||||||
|
args = nil
|
||||||
|
} else {
|
||||||
|
attrs = append(attrs, Attr{Key: k, Value: args[1]})
|
||||||
|
args = args[2:]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
attrs = append(attrs, Attr{Key: badKey, Value: k})
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseLevel(t *testing.T) {
|
||||||
|
for in, want := range map[string]logging.Level{
|
||||||
|
"trace": logging.LevelTrace, "DEBUG": logging.LevelDebug, "info": logging.LevelInfo,
|
||||||
|
"warn": logging.LevelWarn, "warning": logging.LevelWarn,
|
||||||
|
"error": logging.LevelError, "fatal": logging.LevelError, "panic": logging.LevelError,
|
||||||
|
} {
|
||||||
|
got, err := logging.ParseLevel(in)
|
||||||
|
require.NoError(t, err, in)
|
||||||
|
assert.Equal(t, want, got, in)
|
||||||
|
}
|
||||||
|
_, err := logging.ParseLevel("loud")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Equal(t, "warn", logging.LevelWarn.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingStringer struct{ n *int }
|
||||||
|
|
||||||
|
func (c countingStringer) String() string { *c.n++; return "x" }
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
h := logtest.NewWithLevel(logging.LevelTrace)
|
||||||
|
l := logging.New(h)
|
||||||
|
|
||||||
|
l.With("job", "j1").WithError(errors.New("boom")).Warnf("step %d", 2)
|
||||||
|
l.Info("a", 1, 2, "b")
|
||||||
|
l.With("k").With(7).Debug("x")
|
||||||
|
|
||||||
|
recs := h.AllRecords()
|
||||||
|
require.Len(t, recs, 3)
|
||||||
|
assert.Equal(t, "step 2", recs[0].Message)
|
||||||
|
assert.Equal(t, logging.LevelWarn, recs[0].Level)
|
||||||
|
assert.False(t, recs[0].Time.IsZero())
|
||||||
|
job, _ := recs[0].Attr("job")
|
||||||
|
assert.Equal(t, "j1", job)
|
||||||
|
errv, _ := recs[0].Attr("error")
|
||||||
|
require.EqualError(t, errv.(error), "boom")
|
||||||
|
assert.Equal(t, "a1 2b", recs[1].Message) // fmt.Sprint spacing
|
||||||
|
bad, _ := recs[2].Attr("!BADKEY")
|
||||||
|
assert.Equal(t, 7, bad)
|
||||||
|
|
||||||
|
h.Reset()
|
||||||
|
assert.Nil(t, h.LastRecord())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerSkipsFormattingWhenDisabled(t *testing.T) {
|
||||||
|
n := 0
|
||||||
|
l := logging.New(logging.NewLevelHandler(logtest.NewWithLevel(logging.LevelTrace), logging.LevelWarn))
|
||||||
|
l.Debugf("%v", countingStringer{&n})
|
||||||
|
assert.Zero(t, n)
|
||||||
|
assert.False(t, l.Enabled(logging.LevelInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiHandler(t *testing.T) {
|
||||||
|
assert.False(t, logging.DiscardHandler.Enabled(t.Context(), logging.LevelError))
|
||||||
|
all, warnOnly := logtest.New(), logtest.NewWithLevel(logging.LevelTrace)
|
||||||
|
l := logging.New(logging.NewMultiHandler(all, logging.NewLevelHandler(warnOnly, logging.LevelWarn), logging.DiscardHandler)).With("a", 1)
|
||||||
|
l.Info("i")
|
||||||
|
l.Error("e")
|
||||||
|
assert.Len(t, all.AllRecords(), 2)
|
||||||
|
require.Len(t, warnOnly.AllRecords(), 1)
|
||||||
|
v, _ := warnOnly.LastRecord().Attr("a")
|
||||||
|
assert.Equal(t, 1, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameOf(t *testing.T, pc uintptr) runtime.Frame {
|
||||||
|
t.Helper()
|
||||||
|
require.NotZero(t, pc)
|
||||||
|
f, _ := runtime.CallersFrames([]uintptr{pc}).Next()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCallerPC(t *testing.T) {
|
||||||
|
l, h := logtest.NewLogger()
|
||||||
|
l.Infof("method")
|
||||||
|
l.Info("method")
|
||||||
|
|
||||||
|
prev := logging.Default()
|
||||||
|
t.Cleanup(func() { logging.SetDefault(prev) })
|
||||||
|
logging.SetDefault(l)
|
||||||
|
logging.Infof("global")
|
||||||
|
logging.Error("global")
|
||||||
|
|
||||||
|
require.Len(t, h.AllRecords(), 4)
|
||||||
|
for _, r := range h.AllRecords() {
|
||||||
|
assert.True(t, strings.HasSuffix(frameOf(t, r.PC).Function, "TestCallerPC"), r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultLevel(t *testing.T) {
|
||||||
|
prev := logging.GetLevel()
|
||||||
|
t.Cleanup(func() { logging.SetLevel(prev) })
|
||||||
|
|
||||||
|
logging.SetLevel(logging.LevelWarn)
|
||||||
|
assert.Equal(t, logging.LevelWarn, logging.GetLevel())
|
||||||
|
assert.True(t, logging.Enabled(logging.LevelError))
|
||||||
|
assert.False(t, logging.Enabled(logging.LevelInfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogHandler(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
slogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
l := logging.New(logging.NewSlogHandler(slogger.Handler())).With("job", "j1")
|
||||||
|
|
||||||
|
l.Tracef("dropped")
|
||||||
|
assert.False(t, l.Enabled(logging.LevelTrace))
|
||||||
|
l.WithError(errors.New("boom")).Errorf("fail %s", "x")
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
assert.Contains(t, out, "level=ERROR")
|
||||||
|
assert.Contains(t, out, `msg="fail x"`)
|
||||||
|
assert.Contains(t, out, "job=j1")
|
||||||
|
assert.Contains(t, out, "error=boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlogBridge(t *testing.T) {
|
||||||
|
h := logtest.SetDefault(t)
|
||||||
|
slog.With("lib", "actionslib").Warn("from a library", "n", 1)
|
||||||
|
_, _, line, _ := runtime.Caller(0)
|
||||||
|
slog.Debug("dropped")
|
||||||
|
|
||||||
|
recs := h.AllRecords()
|
||||||
|
require.Len(t, recs, 1)
|
||||||
|
assert.Equal(t, "from a library", recs[0].Message)
|
||||||
|
assert.Equal(t, logging.LevelWarn, recs[0].Level)
|
||||||
|
lib, _ := recs[0].Attr("lib")
|
||||||
|
assert.Equal(t, "actionslib", lib)
|
||||||
|
n, _ := recs[0].Attr("n")
|
||||||
|
assert.EqualValues(t, 1, n)
|
||||||
|
assert.Equal(t, line-1, frameOf(t, recs[0].PC).Line)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// Package logtest provides a Handler that keeps records in memory for assertions.
|
||||||
|
package logtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records []logging.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler records what it receives at or above its level. Loggers derived with WithAttrs share one store.
|
||||||
|
type Handler struct {
|
||||||
|
store *store
|
||||||
|
attrs []logging.Attr
|
||||||
|
minLevel logging.Level
|
||||||
|
}
|
||||||
|
|
||||||
|
// New records from LevelInfo up, like the logger of logrus's hooks/test.
|
||||||
|
func New() *Handler {
|
||||||
|
return NewWithLevel(logging.LevelInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger returns a Logger writing to a new Handler from New, and that Handler.
|
||||||
|
func NewLogger() (*logging.Logger, *Handler) {
|
||||||
|
h := New()
|
||||||
|
return logging.New(h), h
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWithLevel(minLevel logging.Level) *Handler {
|
||||||
|
return &Handler{store: &store{}, minLevel: minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Enabled(_ context.Context, level logging.Level) bool { return level >= h.minLevel }
|
||||||
|
|
||||||
|
// Handle stores the record with the attributes accumulated by WithAttrs placed first.
|
||||||
|
func (h *Handler) Handle(_ context.Context, r logging.Record) error {
|
||||||
|
r.Attrs = append(slices.Clone(h.attrs), r.Attrs...)
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
h.store.records = append(h.store.records, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
||||||
|
return &Handler{store: h.store, attrs: append(slices.Clone(h.attrs), attrs...), minLevel: h.minLevel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AllRecords() []logging.Record {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
return slices.Clone(h.store.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastRecord returns nil when nothing was logged.
|
||||||
|
func (h *Handler) LastRecord() *logging.Record {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
if len(h.store.records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r := h.store.records[len(h.store.records)-1]
|
||||||
|
return &r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Reset() {
|
||||||
|
h.store.mu.Lock()
|
||||||
|
defer h.store.mu.Unlock()
|
||||||
|
h.store.records = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefault makes the process-wide Logger record into a new Handler until the test ends.
|
||||||
|
func SetDefault(t testing.TB) *Handler {
|
||||||
|
t.Helper()
|
||||||
|
prev := logging.Default()
|
||||||
|
logger, h := NewLogger()
|
||||||
|
logging.SetDefault(logger)
|
||||||
|
t.Cleanup(func() { logging.SetDefault(prev) })
|
||||||
|
return h
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
slog.SetDefault(slog.New(slogBridge{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// slogBridge hands what libraries log through slog's global logger to the process-wide Logger.
|
||||||
|
type slogBridge struct {
|
||||||
|
attrs []Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
return Default().h.Enabled(ctx, Level(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
h := Default().h
|
||||||
|
if len(b.attrs) > 0 {
|
||||||
|
h = h.WithAttrs(b.attrs)
|
||||||
|
}
|
||||||
|
attrs := make([]Attr, 0, r.NumAttrs())
|
||||||
|
r.Attrs(func(a slog.Attr) bool {
|
||||||
|
attrs = append(attrs, fromSlogAttr(a))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return h.Handle(ctx, Record{Time: r.Time, Level: Level(r.Level), Message: r.Message, PC: r.PC, Attrs: attrs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
out := slices.Clone(b.attrs)
|
||||||
|
for _, a := range attrs {
|
||||||
|
out = append(out, fromSlogAttr(a))
|
||||||
|
}
|
||||||
|
return slogBridge{attrs: out}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b slogBridge) WithGroup(string) slog.Handler { return b }
|
||||||
|
|
||||||
|
func fromSlogAttr(a slog.Attr) Attr {
|
||||||
|
return Attr{Key: a.Key, Value: a.Value.Resolve().Any()}
|
||||||
|
}
|
||||||
|
|
||||||
|
type slogHandler struct {
|
||||||
|
h slog.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSlogHandler writes to h. Levels map one to one, as Level uses slog's values.
|
||||||
|
func NewSlogHandler(h slog.Handler) Handler {
|
||||||
|
return slogHandler{h: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) Enabled(ctx context.Context, level Level) bool {
|
||||||
|
return s.h.Enabled(ctx, slog.Level(level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) Handle(ctx context.Context, r Record) error {
|
||||||
|
rec := slog.NewRecord(r.Time, slog.Level(r.Level), r.Message, r.PC)
|
||||||
|
rec.AddAttrs(toSlogAttrs(r.Attrs)...)
|
||||||
|
return s.h.Handle(ctx, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s slogHandler) WithAttrs(attrs []Attr) Handler {
|
||||||
|
return slogHandler{h: s.h.WithAttrs(toSlogAttrs(attrs))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSlogAttrs(attrs []Attr) []slog.Attr {
|
||||||
|
out := make([]slog.Attr, len(attrs))
|
||||||
|
for i, a := range attrs {
|
||||||
|
out[i] = slog.Any(a.Key, a.Value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkIfTerminal(w io.Writer) bool {
|
||||||
|
if f, ok := w.(*os.File); ok {
|
||||||
|
return term.IsTerminal(int(f.Fd()))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// checkIfTerminal also turns on virtual terminal processing, which the console needs to show colors.
|
||||||
|
func checkIfTerminal(w io.Writer) bool {
|
||||||
|
f, ok := w.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
handle := windows.Handle(f.Fd())
|
||||||
|
var mode uint32
|
||||||
|
if err := windows.GetConsoleMode(handle, &mode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil
|
||||||
|
}
|
||||||
+244
-13
@@ -7,20 +7,33 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"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"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/cli/cli/config"
|
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/moby/moby/api/types/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
func LoadDockerAuthConfig(ctx context.Context, image string) (registry.AuthConfig, error) {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
// config.LoadDefaultConfigFile panics on nil io.Writer when the config
|
cfg, err := loadDockerConfig(ctx)
|
||||||
// file is malformed; use config.Load to route errors through the logger.
|
|
||||||
cfg, err := config.Load(config.Dir())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return registry.AuthConfig{}, 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 {
|
if err != nil {
|
||||||
logger.Warnf("Could not get auth config from docker config: %v", err)
|
logger.Warnf("Could not get auth config from docker config: %v", err)
|
||||||
return registry.AuthConfig{}, err
|
return registry.AuthConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return registry.AuthConfig(authConfig), nil
|
return authConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
func LoadDockerAuthConfigs(ctx context.Context) map[string]registry.AuthConfig {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
cfg, err := config.Load(config.Dir())
|
cfg, err := loadDockerConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not load docker config: %v", err)
|
logger.Warnf("Could not load docker config: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
creds, err := cfg.GetAllCredentials()
|
authConfigs, err := cfg.allAuthConfigs(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Could not get docker auth configs: %v", err)
|
logger.Warnf("Could not get docker auth configs: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
authConfigs := make(map[string]registry.AuthConfig, len(creds))
|
|
||||||
for k, v := range creds {
|
|
||||||
authConfigs[k] = registry.AuthConfig(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
return authConfigs
|
return authConfigs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,3 +77,226 @@ func registryAuthConfigKey(domainName string) string {
|
|||||||
}
|
}
|
||||||
return domainName
|
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 == "<token>" {
|
||||||
|
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") == ""
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
@@ -7,15 +7,18 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"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/moby/client"
|
||||||
"github.com/moby/patternmatcher"
|
"github.com/moby/patternmatcher"
|
||||||
"github.com/moby/patternmatcher/ignorefile"
|
"github.com/moby/patternmatcher/ignorefile"
|
||||||
@@ -115,14 +118,140 @@ func createBuildContext(ctx context.Context, contextDir, relDockerfile string) (
|
|||||||
includes = append(includes, ".dockerignore", relDockerfile)
|
includes = append(includes, ".dockerignore", relDockerfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCtx, err := archive.TarWithOptions(contextDir, &archive.TarOptions{
|
pm, err := patternmatcher.New(excludes)
|
||||||
Compression: compression.None,
|
|
||||||
ExcludePatterns: excludes,
|
|
||||||
IncludeFiles: includes,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reader, writer := io.Pipe()
|
||||||
return buildCtx, nil
|
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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
// This file is exact copy of https://github.com/docker/cli/blob/4a63305d74332de5ceba7fcbccbc3cbb7412f5ba/cli/command/container/opts.go with:
|
||||||
// * appended with license information
|
// * 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
|
// * 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
|
// * invalidParameter from the package's errors.go, and convertPortSet/convertPortMap for the callers in docker_run.go
|
||||||
//
|
//
|
||||||
@@ -33,14 +34,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
cdi "tags.cncf.io/container-device-interface/pkg/parser"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -388,7 +387,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
volumes := copts.volumes.GetMap()
|
volumes := copts.volumes.GetMap()
|
||||||
// add any bind targets to the list of container volumes
|
// add any bind targets to the list of container volumes
|
||||||
for bind := range volumes {
|
for bind := range volumes {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -396,7 +395,7 @@ func parse(flags *pflag.FlagSet, copts *containerOptions, serverOS string) (*con
|
|||||||
if parsed.Source != "" {
|
if parsed.Source != "" {
|
||||||
toBind := bind
|
toBind := bind
|
||||||
|
|
||||||
if parsed.Type == string(mount.TypeBind) {
|
if parsed.Type == mount.TypeBind {
|
||||||
if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok {
|
if hostPart, targetPath, ok := strings.Cut(bind, ":"); ok {
|
||||||
if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") {
|
if !filepath.IsAbs(hostPart) && strings.HasPrefix(hostPart, ".") {
|
||||||
if absHostPart, err := filepath.Abs(hostPart); err == nil {
|
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))
|
deviceMappings := make([]container.DeviceMapping, 0, len(devices))
|
||||||
cdiDeviceNames := make([]string, 0, len(devices))
|
cdiDeviceNames := make([]string, 0, len(devices))
|
||||||
for _, device := range devices {
|
for _, device := range devices {
|
||||||
if cdi.IsQualifiedName(device) {
|
if cdiDeviceName.MatchString(device) {
|
||||||
cdiDeviceNames = append(cdiDeviceNames, device)
|
cdiDeviceNames = append(cdiDeviceNames, device)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1196,8 @@ func convertPortSet(ports nat.PortSet) (network.PortSet, error) {
|
|||||||
return converted, nil
|
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) {
|
func convertPortMap(bindings nat.PortMap) (network.PortMap, error) {
|
||||||
converted := make(network.PortMap, len(bindings))
|
converted := make(network.PortMap, len(bindings))
|
||||||
for port, portBindings := range bindings {
|
for port, portBindings := range bindings {
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
networktypes "github.com/moby/moby/api/types/network"
|
networktypes "github.com/moby/moby/api/types/network"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"gotest.tools/v3/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
is "gotest.tools/v3/assert/cmp"
|
"github.com/stretchr/testify/require"
|
||||||
"gotest.tools/v3/skip"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func mustParseMAC(s string) networktypes.HardwareAddr {
|
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) {
|
func mustParse(t *testing.T, args string) (*container.Config, *container.HostConfig, *networktypes.NetworkingConfig) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
config, hostConfig, nwConfig, err := parseRun(append(strings.Split(args, " "), "ubuntu", "bash"))
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
return config, hostConfig, nwConfig
|
return config, hostConfig, nwConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +154,12 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, hostConfig, netConfig := mustParse(t, tc.input)
|
_, hostConfig, netConfig := mustParse(t, tc.input)
|
||||||
assert.Check(t, is.DeepEqual(hostConfig.Links, tc.expHostConfigLinks))
|
assert.Equal(t, tc.expHostConfigLinks, hostConfig.Links)
|
||||||
assert.Check(t, is.Len(netConfig.EndpointsConfig, len(tc.expNetConfigLinks)))
|
assert.Len(t, netConfig.EndpointsConfig, len(tc.expNetConfigLinks))
|
||||||
for netName, expLinks := range tc.expNetConfigLinks {
|
for netName, expLinks := range tc.expNetConfigLinks {
|
||||||
nc, ok := netConfig.EndpointsConfig[netName]
|
nc, ok := netConfig.EndpointsConfig[netName]
|
||||||
assert.Assert(t, ok)
|
require.True(t, ok)
|
||||||
assert.Check(t, is.DeepEqual(nc.Links, expLinks))
|
assert.Equal(t, expLinks, nc.Links)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -169,15 +167,15 @@ func TestParseRunLinks(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseRunWithoutUmask(t *testing.T) {
|
func TestParseRunWithoutUmask(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
_, hostConfig, _, err := parseRun([]string{"ubuntu", "bash"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Assert(t, hostConfig.Umask == nil)
|
require.Nil(t, hostConfig.Umask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunUmask(t *testing.T) {
|
func TestParseRunUmask(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
_, hostConfig, _, err := parseRun([]string{"--umask", "0022", "ubuntu", "bash"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Assert(t, hostConfig.Umask != nil)
|
require.NotNil(t, hostConfig.Umask)
|
||||||
assert.Equal(t, uint32(0o22), *hostConfig.Umask)
|
require.Equal(t, uint32(0o22), *hostConfig.Umask)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunAttach(t *testing.T) {
|
func TestParseRunAttach(t *testing.T) {
|
||||||
@@ -225,9 +223,9 @@ func TestParseRunAttach(t *testing.T) {
|
|||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
config, _, _ := mustParse(t, tc.input)
|
config, _, _ := mustParse(t, tc.input)
|
||||||
assert.Equal(t, config.AttachStdin, tc.expected.AttachStdin)
|
require.Equal(t, tc.expected.AttachStdin, config.AttachStdin)
|
||||||
assert.Equal(t, config.AttachStdout, tc.expected.AttachStdout)
|
require.Equal(t, tc.expected.AttachStdout, config.AttachStdout)
|
||||||
assert.Equal(t, config.AttachStderr, tc.expected.AttachStderr)
|
require.Equal(t, tc.expected.AttachStderr, config.AttachStderr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +271,7 @@ func TestParseRunWithInvalidArgs(t *testing.T) {
|
|||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
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()
|
flags, _ := setupRunFlags()
|
||||||
args := []string{"--memory=invalid", "img", "cmd"}
|
args := []string{"--memory=invalid", "img", "cmd"}
|
||||||
err := flags.Parse(args)
|
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")
|
_, 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) {
|
func TestParseWithMemorySwap(t *testing.T) {
|
||||||
flags, _ := setupRunFlags()
|
flags, _ := setupRunFlags()
|
||||||
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
args := []string{"--memory-swap=invalid", "img", "cmd"}
|
||||||
err := flags.Parse(args)
|
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")
|
_, 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")
|
_, 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) {
|
func TestParseHostname(t *testing.T) {
|
||||||
@@ -474,7 +472,7 @@ func TestParseWithExpose(t *testing.T) {
|
|||||||
for expose, expectedError := range tests {
|
for expose, expectedError := range tests {
|
||||||
t.Run(expose, func(t *testing.T) {
|
t.Run(expose, func(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
_, _, _, 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 {
|
for expose, exposedPorts := range tests {
|
||||||
t.Run(expose, func(t *testing.T) {
|
t.Run(expose, func(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
config, _, _, err := parseRun([]string{fmt.Sprintf("--expose=%v", expose), "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
for _, port := range exposedPorts {
|
for _, port := range exposedPorts {
|
||||||
_, ok := config.ExposedPorts[port]
|
_, 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) {
|
t.Run("merge with published", func(t *testing.T) {
|
||||||
// Merge with actual published port
|
// Merge with actual published port
|
||||||
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
config, _, _, err := parseRun([]string{"--publish=80", "--expose=80-81/tcp", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.Len(config.ExposedPorts, 2))
|
assert.Len(t, config.ExposedPorts, 2)
|
||||||
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
ports := []networktypes.Port{networktypes.MustParsePort("80/tcp"), networktypes.MustParsePort("81/tcp")}
|
||||||
for _, port := range ports {
|
for _, port := range ports {
|
||||||
_, ok := config.ExposedPorts[port]
|
_, 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) {
|
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 {
|
testCases := []struct {
|
||||||
devices []string
|
devices []string
|
||||||
deviceMapping *container.DeviceMapping
|
deviceMapping *container.DeviceMapping
|
||||||
@@ -586,17 +586,17 @@ func TestParseDevice(t *testing.T) {
|
|||||||
|
|
||||||
_, hostconfig, _, err := parseRun(args)
|
_, hostconfig, _, err := parseRun(args)
|
||||||
|
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if tc.deviceMapping != nil {
|
if tc.deviceMapping != nil {
|
||||||
if assert.Check(t, is.Len(hostconfig.Devices, 1)) {
|
if assert.Len(t, hostconfig.Devices, 1) {
|
||||||
assert.Check(t, is.DeepEqual(*tc.deviceMapping, hostconfig.Devices[0]))
|
assert.Equal(t, *tc.deviceMapping, hostconfig.Devices[0])
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got, err := parseDevice(tc.device, tc.serverOS)
|
got, err := parseDevice(tc.device, tc.serverOS)
|
||||||
if tc.wantErr != "" {
|
if tc.wantErr != "" {
|
||||||
assert.Error(t, err, tc.wantErr)
|
require.EqualError(t, err, tc.wantErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, tc.want)
|
require.Equal(t, tc.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,13 +848,13 @@ func TestParseNetworkConfig(t *testing.T) {
|
|||||||
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
_, hConfig, nwConfig, err := parseRun(tc.flags)
|
||||||
|
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
assert.Error(t, err, tc.expectedErr)
|
require.EqualError(t, err, tc.expectedErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.DeepEqual(t, hConfig.NetworkMode, tc.expectedHostCfg.NetworkMode)
|
require.Equal(t, tc.expectedHostCfg.NetworkMode, hConfig.NetworkMode)
|
||||||
assert.DeepEqual(t, nwConfig.EndpointsConfig, tc.expected, cmpopts.EquateComparable(netip.Addr{}))
|
require.Equal(t, tc.expected, nwConfig.EndpointsConfig)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -863,24 +863,24 @@ func TestParseModes(t *testing.T) {
|
|||||||
// pid ko
|
// pid ko
|
||||||
flags, copts := setupRunFlags()
|
flags, copts := setupRunFlags()
|
||||||
args := []string{"--pid=container:", "img", "cmd"}
|
args := []string{"--pid=container:", "img", "cmd"}
|
||||||
assert.NilError(t, flags.Parse(args))
|
require.NoError(t, flags.Parse(args))
|
||||||
_, err := parse(flags, copts, runtime.GOOS)
|
_, err := parse(flags, copts, runtime.GOOS)
|
||||||
assert.ErrorContains(t, err, "--pid: invalid PID mode")
|
require.ErrorContains(t, err, "--pid: invalid PID mode")
|
||||||
|
|
||||||
// pid ok
|
// pid ok
|
||||||
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
_, hostconfig, _, err := parseRun([]string{"--pid=host", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if !hostconfig.PidMode.Valid() {
|
if !hostconfig.PidMode.Valid() {
|
||||||
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
t.Fatalf("Expected a valid PidMode, got %v", hostconfig.PidMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uts ko
|
// uts ko
|
||||||
_, _, _, err = parseRun([]string{"--uts=container:", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, 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
|
// uts ok
|
||||||
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
_, hostconfig, _, err = parseRun([]string{"--uts=host", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if !hostconfig.UTSMode.Valid() {
|
if !hostconfig.UTSMode.Valid() {
|
||||||
t.Fatalf("Expected a valid UTSMode, got %v", hostconfig.UTSMode)
|
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"}
|
args := []string{"--shm-size=a128m", "img", "cmd"}
|
||||||
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
expectedErr := `invalid argument "a128m" for "--shm-size" flag:`
|
||||||
err := flags.Parse(args)
|
err := flags.Parse(args)
|
||||||
assert.ErrorContains(t, err, expectedErr)
|
require.ErrorContains(t, err, expectedErr)
|
||||||
|
|
||||||
// shm-size ok
|
// shm-size ok
|
||||||
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
_, hostconfig, _, err := parseRun([]string{"--shm-size=128m", "img", "cmd"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
if hostconfig.ShmSize != 134217728 {
|
if hostconfig.ShmSize != 134217728 {
|
||||||
t.Fatalf("Expected a valid ShmSize, got %d", hostconfig.ShmSize)
|
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) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
_, hostConfig, _, err := parseRun([]string{"--restart=" + tc.input, "img", "cmd"})
|
||||||
if tc.expectedErr != "" {
|
if tc.expectedErr != "" {
|
||||||
assert.Check(t, is.Error(err, tc.expectedErr))
|
assert.Nil(t, hostConfig)
|
||||||
assert.Check(t, is.Nil(hostConfig))
|
assert.EqualError(t, err, tc.expectedErr)
|
||||||
} else {
|
} else {
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.DeepEqual(hostConfig.RestartPolicy, tc.expected))
|
assert.Equal(t, tc.expected, hostConfig.RestartPolicy)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -980,7 +980,7 @@ func TestParseRestartPolicy(t *testing.T) {
|
|||||||
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
func TestParseRestartPolicyAutoRemove(t *testing.T) {
|
||||||
_, _, _, err := parseRun([]string{"--rm", "--restart=always", "img", "cmd"}) //nolint:dogsled // verbatim copy from docker/cli tests
|
_, _, _, 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"
|
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) {
|
func TestParseHealth(t *testing.T) {
|
||||||
@@ -1118,12 +1118,14 @@ func TestParseLabelfileVariables(t *testing.T) { //nolint:dupl // verbatim copy
|
|||||||
|
|
||||||
func TestParseEntryPoint(t *testing.T) {
|
func TestParseEntryPoint(t *testing.T) {
|
||||||
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
config, _, _, err := parseRun([]string{"--entrypoint=anything", "cmd", "img"})
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Check(t, is.DeepEqual(config.Entrypoint, []string{"anything"}))
|
assert.Equal(t, []string{"anything"}, config.Entrypoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDevice(t *testing.T) {
|
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{
|
valid := []string{
|
||||||
"/home",
|
"/home",
|
||||||
"/home:/home",
|
"/home:/home",
|
||||||
@@ -1222,22 +1224,22 @@ func TestValidateDeviceByServerOS(t *testing.T) {
|
|||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got, err := validateDevice(tc.value, tc.serverOS)
|
got, err := validateDevice(tc.value, tc.serverOS)
|
||||||
if tc.wantError != "" {
|
if tc.wantError != "" {
|
||||||
assert.Error(t, err, tc.wantError)
|
require.EqualError(t, err, tc.wantError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, tc.want)
|
require.Equal(t, tc.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
||||||
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
got, err := validateDeviceCgroupRule("c 1:3 rwm")
|
||||||
assert.NilError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, got, "c 1:3 rwm")
|
require.Equal(t, "c 1:3 rwm", got)
|
||||||
|
|
||||||
_, err = validateDeviceCgroupRule("invalid")
|
_, 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 {
|
if invalidParameter(nil) != nil {
|
||||||
t.Fatal("invalidParameter(nil) should be nil")
|
t.Fatal("invalidParameter(nil) should be nil")
|
||||||
@@ -1245,9 +1247,9 @@ func TestDeviceCgroupRulesAndInvalidParameter(t *testing.T) {
|
|||||||
cause := errors.New("bad input")
|
cause := errors.New("bad input")
|
||||||
err = invalidParameter(cause)
|
err = invalidParameter(cause)
|
||||||
var invalid interface{ InvalidParameter() }
|
var invalid interface{ InvalidParameter() }
|
||||||
assert.Assert(t, errors.As(err, &invalid))
|
require.ErrorAs(t, err, &invalid)
|
||||||
assert.Assert(t, errors.Is(err, cause))
|
require.ErrorIs(t, err, cause)
|
||||||
assert.Equal(t, invalidParameter(err), err) // already invalid, so not wrapped twice
|
require.Condition(t, func() bool { return invalidParameter(err) == err })
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSystemPaths(t *testing.T) {
|
func TestParseSystemPaths(t *testing.T) {
|
||||||
@@ -1298,9 +1300,9 @@ func TestParseSystemPaths(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
securityOpts, maskedPaths, readonlyPaths := parseSystemPaths(tc.in)
|
||||||
assert.DeepEqual(t, securityOpts, tc.out)
|
require.Equal(t, tc.out, securityOpts)
|
||||||
assert.DeepEqual(t, maskedPaths, tc.masked)
|
require.Equal(t, tc.masked, maskedPaths)
|
||||||
assert.DeepEqual(t, readonlyPaths, tc.readonly)
|
require.Equal(t, tc.readonly, readonlyPaths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,9 +1324,9 @@ func TestConvertToStandardNotation(t *testing.T) {
|
|||||||
for key, ports := range valid {
|
for key, ports := range valid {
|
||||||
convertedPorts, err := convertToStandardNotation(ports)
|
convertedPorts, err := convertToStandardNotation(ports)
|
||||||
if err != nil {
|
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 {
|
for _, ports := range invalid {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ImageExistsLocally returns a boolean indicating if an image with the
|
// ImageExistsLocally returns a boolean indicating if an image with the
|
||||||
@@ -30,11 +30,23 @@ func ImageExistsLocally(ctx context.Context, imageName, platform string) (bool,
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform {
|
if platform == "" || platform == "any" {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, nil
|
requested, err := parsePlatform(platform)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return platformMatches(requested, &specs.Platform{OS: inspectImage.Os, Architecture: inspectImage.Architecture, Variant: inspectImage.Variant}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformMatches(requested, image *specs.Platform) bool {
|
||||||
|
variant := image.Variant
|
||||||
|
if variant == "" {
|
||||||
|
variant = map[string]string{"amd64": "v1", "arm": "v7", "arm64": "v8"}[image.Architecture]
|
||||||
|
}
|
||||||
|
return image.OS == requested.OS && image.Architecture == requested.Architecture && (requested.Variant == "" || requested.Variant == variant)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveImage removes image from local store, the function is used to run different
|
// RemoveImage removes image from local store, the function is used to run different
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
// buildScratchImage builds a tiny empty image for the given platform locally (FROM scratch, no
|
||||||
@@ -41,7 +41,7 @@ func buildScratchImage(t *testing.T, platform string) string {
|
|||||||
|
|
||||||
func TestImageExistsLocally(t *testing.T) {
|
func TestImageExistsLocally(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
// a non-existent image is reported absent
|
// a non-existent image is reported absent
|
||||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type dockerMessage struct {
|
type dockerMessage struct {
|
||||||
@@ -26,7 +26,7 @@ type dockerMessage struct {
|
|||||||
Progress string `json:"progress"`
|
Progress string `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser, isError bool) error {
|
func logDockerResponse(logger *logging.Logger, dockerResponse io.ReadCloser, isError bool) error {
|
||||||
if dockerResponse == nil {
|
if dockerResponse == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLog(logger logrus.FieldLogger, isError bool, format string, args ...any) {
|
func writeLog(logger *logging.Logger, isError bool, format string, args ...any) {
|
||||||
if isError {
|
if isError {
|
||||||
logger.Errorf(format, args...)
|
logger.Errorf(format, args...)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID,
|
|||||||
networks:
|
networks:
|
||||||
for _, n := range networks.Items {
|
for _, n := range networks.Items {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
if err != nil {
|
if cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to inspect network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,9 @@ networks:
|
|||||||
continue networks
|
continue networks
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -203,36 +207,35 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cli.Close()
|
defer cli.Close()
|
||||||
|
return removeNetwork(ctx, cli, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure that all network of the specified name are removed
|
func removeNetwork(ctx context.Context, cli client.APIClient, name string) error {
|
||||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{}) // NetworkRemove by name refuses duplicates, so remove each by ID
|
||||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
for _, n := range networks.Items {
|
||||||
|
if n.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||||
|
if cerrdefs.IsNotFound(err) {
|
||||||
|
continue
|
||||||
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
if len(result.Network.Containers) != 0 {
|
||||||
// common.Logger(ctx).Debugf("%v", networks)
|
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the orphan cleanup reclaims it once they are gone", name)
|
||||||
var errs []error
|
continue
|
||||||
for _, n := range networks.Items {
|
}
|
||||||
if n.Name == name {
|
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// it holds a subnet out of the daemon's pool until something reclaims it
|
|
||||||
if len(result.Network.Containers) != 0 {
|
|
||||||
common.Logger(ctx).Warnf("Refusing to remove network %s because it still has active endpoints, the idle cleanup reclaims it once they are gone", name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil {
|
|
||||||
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", name, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors.Join(errs...)
|
|
||||||
}
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
// IsolatedCacheContainer returns the container holding addr, when jobs on jobNetwork cannot reach it there.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestIsolatedCacheContainer(t *testing.T) {
|
func TestIsolatedCacheContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
cache, lan := netip.MustParseAddr("172.18.0.3"), netip.MustParseAddr("192.168.1.20")
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||||
@@ -58,7 +58,7 @@ func TestIsAddressPoolExhausted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
func TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
bridgeOptions, want map[string]string
|
bridgeOptions, want map[string]string
|
||||||
}{
|
}{
|
||||||
@@ -87,7 +87,7 @@ func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
connect := func(answer error) error {
|
connect := func(answer error) error {
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||||
@@ -116,8 +116,8 @@ func TestConnectAndDisconnectNetwork(t *testing.T) {
|
|||||||
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
// Of this runner's networks, only the ones nothing but its own cache container is attached to
|
||||||
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
// and old enough to predate any job now starting are the runner's to reclaim. An unexpected
|
||||||
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
// NetworkRemove fails the test on its own, since testify has no expectation to match it against.
|
||||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
func TestRemoveNetworks(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||||
@@ -128,11 +128,13 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
{ID: "starting"},
|
{ID: "starting"},
|
||||||
{ID: "cached"},
|
{ID: "cached"},
|
||||||
{ID: "shared"},
|
{ID: "shared"},
|
||||||
|
{ID: "gone"},
|
||||||
|
{ID: "raced"},
|
||||||
}}, nil)
|
}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
Return(mobyclient.NetworkInspectResult{}, nil).Twice()
|
||||||
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "busy", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"c": {}}}}, nil).Twice()
|
||||||
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "starting", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||||
Network: network.Network{Created: cutoff.Add(time.Second)},
|
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||||
@@ -142,13 +144,28 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
|||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||||
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}, "job": {}}}}, nil)
|
||||||
|
client.On("NetworkInspect", ctx, "gone", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, cerrdefs.ErrNotFound).Twice()
|
||||||
|
client.On("NetworkInspect", ctx, "raced", mobyclient.NetworkInspectOptions{}).
|
||||||
|
Return(mobyclient.NetworkInspectResult{}, nil).Twice()
|
||||||
|
client.On("NetworkRemove", ctx, "raced", mobyclient.NetworkRemoveOptions{}).
|
||||||
|
Return(mobyclient.NetworkRemoveResult{}, cerrdefs.ErrNotFound).Twice()
|
||||||
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "orphan", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
Return(mobyclient.NetworkRemoveResult{}, nil).Twice()
|
||||||
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||||
Return(nil).Once()
|
Return(nil).Once()
|
||||||
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||||
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||||
|
|
||||||
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
require.NoError(t, removeOrphanNetworks(ctx, client, "runner-1", "0123456789ab", cutoff))
|
||||||
|
|
||||||
|
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).Return(mobyclient.NetworkListResult{Items: []network.Summary{
|
||||||
|
{ID: "orphan", Name: "job"},
|
||||||
|
{ID: "busy", Name: "job"},
|
||||||
|
{ID: "gone", Name: "job"},
|
||||||
|
{ID: "raced", Name: "job"},
|
||||||
|
{ID: "shared", Name: "other"},
|
||||||
|
}}, nil)
|
||||||
|
require.NoError(t, removeNetwork(ctx, client, "job"))
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -61,3 +61,26 @@ func TestParsePlatform(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlatformMatches(t *testing.T) {
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
requested, image string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"linux/amd64", "linux/amd64", true},
|
||||||
|
{"linux/amd64/v1", "linux/amd64", true},
|
||||||
|
{"linux/arm64", "linux/amd64", false},
|
||||||
|
{"linux/arm64/v8", "linux/arm64", true},
|
||||||
|
{"linux/arm/v7", "linux/arm/v7", true},
|
||||||
|
{"linux/arm", "linux/arm/v7", true},
|
||||||
|
{"linux/arm/v6", "linux/arm/v7", false},
|
||||||
|
{"linux/arm", "linux/arm/v6", true},
|
||||||
|
{"windows/arm/v7", "linux/arm/v7", false},
|
||||||
|
} {
|
||||||
|
requested, err := parsePlatform(testcase.requested)
|
||||||
|
require.NoError(t, err)
|
||||||
|
image, err := parsePlatform(testcase.image)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testcase.want, platformMatches(requested, image), "%s on %s", testcase.requested, testcase.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jobLabel = "com.gitea.runner.job"
|
jobLabel = "com.gitea.runner.job."
|
||||||
maxCreateBody = 8 << 20
|
maxCreateBody = 8 << 20
|
||||||
|
|
||||||
dockerProxyProbeTimeout = 5 * time.Second
|
dockerProxyProbeTimeout = 5 * time.Second
|
||||||
@@ -302,7 +302,7 @@ func rewriteCreate(r *http.Request, job string, mounts map[string]string) error
|
|||||||
if config.Labels == nil {
|
if config.Labels == nil {
|
||||||
config.Labels = make(map[string]string)
|
config.Labels = make(map[string]string)
|
||||||
}
|
}
|
||||||
config.Labels[jobLabel] = job
|
config.Labels[jobLabel+job] = "" // per-job key, so nested proxies keep each other's
|
||||||
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
if fields["Labels"], err = json.Marshal(config.Labels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -560,7 +560,7 @@ func RemoveDockerJobResources(ctx context.Context, job string) error {
|
|||||||
|
|
||||||
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
filters := make(client.Filters).Add("label", jobLabel+job)
|
||||||
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
||||||
errs := []error{err}
|
errs := []error{err}
|
||||||
for _, c := range containers.Items {
|
for _, c := range containers.Items {
|
||||||
|
|||||||
@@ -146,52 +146,52 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
t.Run("labels creates", func(t *testing.T) {
|
t.Run("labels creates", func(t *testing.T) {
|
||||||
for _, testCase := range []struct{ path, body, want string }{
|
for _, testCase := range []struct{ path, body, want string }{
|
||||||
{
|
{
|
||||||
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"other"}}`,
|
"/v1.47/containers/create", `{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job.job-0":""}}`,
|
||||||
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job.job-0":"","com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
|
"/containers/create", `{"HostConfig":{"Binds":["/workspace/o/r:/src:ro","/workspace/o/r/../r/sub/:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2"}}`,
|
||||||
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job":"job-1"}}`,
|
`{"HostConfig":{"Binds":["/volumes/job/_data:/src:ro","/volumes/job/_data/sub:/sub","workspace/o/r:/relative","/workspace/o/rest:/rest","/workspace/o/r/tmp/x:/tmp","named:/named","/anonymous","/workspace/o/r:ro","/volumes/job/_data/x:/daemon-path"]},"DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"Foo":"1","foo":"2","com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/var/run/docker.sock","Target":"/var/run/docker.sock"},{"type":"bind","source":"/workspace/o/r/sub/","target":"/m"},{"Type":"bind","Source":"/workspace/o/r/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/workspace/o/r/../r","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]}}`,
|
||||||
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"HostConfig":{"Mounts":[{"Type":"bind","Source":"/tmp/p/docker.sock","Target":"/var/run/docker.sock"},{"Type":"bind","Source":"/volumes/job/_data/sub/","target":"/m"},{"Type":"bind","Source":"/volumes/job/_data/sub/../data","Target":"/dotted"},{"Type":"bind","Source":"/volumes/job/_data","Target":"/escaping"},{"Type":"BIND","Source":"/workspace/o/r"},{"Type":"volume","Source":"/workspace/o/r"}]},"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
|
"/containers/create", `{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/workspace/o/r/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]}}`,
|
||||||
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"HostConfig":{"Mounts":[{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind","device":"/volumes/job/_data/data"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"unbindable","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Options":{"o":"bind,remount","device":"/workspace/o/r"}}}},{"Type":"volume","VolumeOptions":{"DriverConfig":{"Name":"plugin","Options":{"o":"bind","device":"/workspace/o/r"}}}}]},"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
|
"/containers/create", `{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null}`,
|
||||||
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"HostConfig":{"privileged":true,"Privileged":false,"Binds":["/workspace/o/r:/src"]},"hoſtconfig":null,"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/networks/create", `{"Name":"n"}`,
|
"/networks/create", `{"Name":"n"}`,
|
||||||
`{"Name":"n","Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Name":"n","Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
"/volumes/create", `{"Name":"v","labels":null,"DriverOpts":{"type":"none","o":"rbind,ro","device":"/workspace/o/r/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
`{"Name":"v","Labels":{"com.gitea.runner.job":"job-1"},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
`{"Name":"v","Labels":{"com.gitea.runner.job.job-1":""},"DriverOpts":{"type":"none","o":"rbind,ro","device":"/volumes/job/_data/"},"HostConfig":{"Binds":["/workspace/o/r:/src"]}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
|
"/volumes/create", `{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"}}`,
|
||||||
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Name":"d","DriverOpts":{"o":"bind","device":1,"device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
|
"/volumes/create", `{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"}}`,
|
||||||
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Name":"p","Driver":"plugin","DriverOpts":{"o":"bind","device":"/workspace/o/r"},"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", "",
|
"/volumes/create", "",
|
||||||
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", "null",
|
"/volumes/create", "null",
|
||||||
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
"/volumes/create", `{"Labels":{"discard":"1"},"labels":null,"LABELS":{"own":"1"},"LABELS":{"extra":"2"}}`,
|
||||||
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job":"job-1"}}`,
|
`{"Labels":{"own":"1","extra":"2","com.gitea.runner.job.job-1":""}}`,
|
||||||
},
|
},
|
||||||
} {
|
} {
|
||||||
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
resp, err := client.Post("http://docker"+testCase.path, "application/json", strings.NewReader(testCase.body))
|
||||||
@@ -244,7 +244,7 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job.job-1":""}}`, string(<-bodies))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ func TestDockerProxy(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job":"job-1"}}`, string(<-bodies))
|
assert.JSONEq(t, `{"Labels":{"com.gitea.runner.job.job-1":""}}`, string(<-bodies))
|
||||||
require.NoError(t, <-closed)
|
require.NoError(t, <-closed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -285,8 +285,8 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
containerFailure := errors.New("container removal failed")
|
containerFailure := errors.New("container removal failed")
|
||||||
listFailure := errors.New("listing failed")
|
listFailure := errors.New("listing failed")
|
||||||
volumeFailure := errors.New("volume removal failed")
|
volumeFailure := errors.New("volume removal failed")
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
filters := make(mobyclient.Filters).Add("label", jobLabel+"job-1")
|
||||||
cli := &mockDockerClient{}
|
cli := &mockDockerClient{}
|
||||||
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{{ID: "c1", Names: []string{"/app"}}}}, nil).Once()
|
||||||
@@ -312,7 +312,8 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
|||||||
|
|
||||||
func TestDockerProxyWithDaemon(t *testing.T) {
|
func TestDockerProxyWithDaemon(t *testing.T) {
|
||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
cleanupCtx := context.WithoutCancel(ctx)
|
||||||
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||||
direct, err := GetDockerClient(ctx)
|
direct, err := GetDockerClient(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -337,22 +338,24 @@ func TestDockerProxyWithDaemon(t *testing.T) {
|
|||||||
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
Config: &container.Config{Image: "alpine", Cmd: []string{"sleep", "300"}, Labels: map[string]string{"own": "1"}},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true}) })
|
t.Cleanup(func() {
|
||||||
|
_, _ = direct.ContainerRemove(cleanupCtx, created.ID, mobyclient.ContainerRemoveOptions{Force: true})
|
||||||
|
})
|
||||||
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
_, err = viaProxy.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.NetworkRemove(ctx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
t.Cleanup(func() { _, _ = direct.NetworkRemove(cleanupCtx, net.ID, mobyclient.NetworkRemoveOptions{}) })
|
||||||
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
_, err = viaProxy.VolumeCreate(ctx, mobyclient.VolumeCreateOptions{Name: job})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(func() { _, _ = direct.VolumeRemove(ctx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
t.Cleanup(func() { _, _ = direct.VolumeRemove(cleanupCtx, job, mobyclient.VolumeRemoveOptions{Force: true}) })
|
||||||
|
|
||||||
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
inspected, err := direct.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, map[string]string{"own": "1", jobLabel: job}, inspected.Container.Config.Labels)
|
assert.Subset(t, inspected.Container.Config.Labels, map[string]string{"own": "1", jobLabel + job: ""})
|
||||||
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
vol, err := direct.VolumeInspect(ctx, job, mobyclient.VolumeInspectOptions{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, job, vol.Volume.Labels[jobLabel])
|
assert.Contains(t, vol.Volume.Labels, jobLabel+job)
|
||||||
|
|
||||||
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
exec, err := viaProxy.ExecCreate(ctx, created.ID, mobyclient.ExecCreateOptions{Cmd: []string{"cat"}, AttachStdin: true, AttachStdout: true, TTY: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -5,20 +5,19 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/cli/cli/config"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanImage(t *testing.T) {
|
func TestCleanImage(t *testing.T) {
|
||||||
@@ -36,18 +35,15 @@ func TestCleanImage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
imageOut := cleanImage(context.Background(), table.imageIn)
|
imageOut := cleanImage(t.Context(), table.imageIn)
|
||||||
assert.Equal(t, table.imageOut, imageOut)
|
assert.Equal(t, table.imageOut, imageOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetImagePullOptions(t *testing.T) {
|
func TestGetImagePullOptions(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
orig := config.Dir()
|
t.Setenv("DOCKER_CONFIG", "/non-existent/docker")
|
||||||
t.Cleanup(func() { config.SetDir(orig) })
|
|
||||||
|
|
||||||
config.SetDir("/non-existent/docker")
|
|
||||||
|
|
||||||
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
options, err := getImagePullOptions(ctx, NewDockerPullExecutorInput{})
|
||||||
assert.NoError(t, err, "Failed to create ImagePullOptions") //nolint:testifylint // pre-existing issue from nektos/act
|
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.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")
|
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{
|
options, err = getImagePullOptions(ctx, NewDockerPullExecutorInput{
|
||||||
Image: "nektos/act",
|
Image: "nektos/act",
|
||||||
@@ -79,7 +75,7 @@ func TestIsPinnedImage(t *testing.T) {
|
|||||||
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
// The pull path reports a failure the daemon sent mid-stream, so it must carry the reason
|
||||||
// whichever of the two shapes the daemon used.
|
// whichever of the two shapes the daemon used.
|
||||||
func TestLogDockerResponseError(t *testing.T) {
|
func TestLogDockerResponseError(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
streamErr := func(line string) error {
|
streamErr := func(line string) error {
|
||||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-123
@@ -13,6 +13,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -25,13 +26,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"dario.cat/mergo"
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/cli/cli/connhelper"
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
"github.com/moby/moby/api/types/container"
|
"github.com/moby/moby/api/types/container"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
@@ -40,7 +39,6 @@ import (
|
|||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
"github.com/moby/moby/client/pkg/versions"
|
"github.com/moby/moby/client/pkg/versions"
|
||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
||||||
@@ -95,8 +93,8 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
|||||||
cr.attach().IfBool(attach),
|
cr.attach().IfBool(attach),
|
||||||
cr.start(),
|
cr.start(),
|
||||||
cr.wait().IfBool(attach),
|
cr.wait().IfBool(attach),
|
||||||
cr.tryReadUID(),
|
cr.tryReadID("-u", func(id int) { cr.UID = id }),
|
||||||
cr.tryReadGID(),
|
cr.tryReadID("-g", func(id int) { cr.GID = id }),
|
||||||
func(ctx context.Context) error {
|
func(ctx context.Context) error {
|
||||||
// If this fails, then folders have wrong permissions on non root container
|
// If this fails, then folders have wrong permissions on non root container
|
||||||
if cr.UID != 0 || cr.GID != 0 {
|
if cr.UID != 0 || cr.GID != 0 {
|
||||||
@@ -305,7 +303,25 @@ func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
||||||
return cr.extractFromImageEnv(env).IfNot(common.Dryrun)
|
envMap := *env
|
||||||
|
return common.Executor(func(ctx context.Context) error {
|
||||||
|
inspect, err := cr.cli.ContainerInspect(ctx, cr.id, client.ContainerInspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Error(err)
|
||||||
|
return fmt.Errorf("inspect container: %w", err)
|
||||||
|
}
|
||||||
|
if inspect.Container.Config == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, value := range opts.ConvertKVStringsToMap(inspect.Container.Config.Env) {
|
||||||
|
if envMap[key] == "" {
|
||||||
|
envMap[key] = value
|
||||||
|
} else if key == "PATH" {
|
||||||
|
envMap[key] += ":" + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}).IfNot(common.Dryrun)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
@@ -354,15 +370,13 @@ func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) {
|
|||||||
dockerHost := os.Getenv("DOCKER_HOST")
|
dockerHost := os.Getenv("DOCKER_HOST")
|
||||||
|
|
||||||
if strings.HasPrefix(dockerHost, "ssh://") {
|
if strings.HasPrefix(dockerHost, "ssh://") {
|
||||||
var helper *connhelper.ConnectionHelper
|
var args []string
|
||||||
|
if args, err = sshArgs(dockerHost); err != nil {
|
||||||
helper, err = connhelper.GetConnectionHelper(dockerHost)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cli, err = client.New(
|
cli, err = client.New(
|
||||||
client.WithHost(helper.Host),
|
client.WithHost("http://docker.example.com"), // the dialer below ignores the address
|
||||||
client.WithDialContext(helper.Dialer),
|
client.WithDialContext(func(context.Context, string, string) (net.Conn, error) { return dialCommand("ssh", args...) }),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
cli, err = client.New(client.FromEnv)
|
cli, err = client.New(client.FromEnv)
|
||||||
@@ -567,20 +581,6 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
return nil, nil, fmt.Errorf("cannot process container options: '%s': '%w'", options, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: If everything is fine after gitea/act v0.260.0, remove the following comment.
|
|
||||||
// In the old fork version, the code is
|
|
||||||
// if len(copts.netMode.Value()) == 0 {
|
|
||||||
// if err = copts.netMode.Set("host"); err != nil {
|
|
||||||
// return nil, nil, fmt.Errorf("cannot parse networkmode=host. This is an internal error and should not happen: '%w'", err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// And it has been commented with:
|
|
||||||
// If a service container's network is set to `host`, the container will not be able to
|
|
||||||
// connect to the specified network created for the job container and the service containers.
|
|
||||||
// So comment out the following code.
|
|
||||||
// Not the if it's necessary to comment it in the new version,
|
|
||||||
// since it's cr.input.NetworkMode now.
|
|
||||||
|
|
||||||
if len(copts.netMode.Value()) == 0 {
|
if len(copts.netMode.Value()) == 0 {
|
||||||
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
|
if err = copts.netMode.Set(cr.input.NetworkMode); err != nil {
|
||||||
return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
|
return nil, nil, fmt.Errorf("cannot parse networkmode=%s. This is an internal error and should not happen: '%w'", cr.input.NetworkMode, err)
|
||||||
@@ -618,10 +618,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
|
|
||||||
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
logger.Debugf("Custom container.Config from options ==> %+v", containerConfig.Config)
|
||||||
|
|
||||||
err = mergo.Merge(config, containerConfig.Config, mergo.WithOverride, mergo.WithAppendSlice)
|
overlay(reflect.ValueOf(config).Elem(), reflect.ValueOf(containerConfig.Config).Elem(), true)
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("cannot merge container.Config options: '%s': '%w'", options, err)
|
|
||||||
}
|
|
||||||
logger.Debugf("Merged container.Config ==> %+v", config)
|
logger.Debugf("Merged container.Config ==> %+v", config)
|
||||||
|
|
||||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||||
@@ -631,10 +628,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
binds := hostConfig.Binds
|
binds := hostConfig.Binds
|
||||||
mounts := hostConfig.Mounts
|
mounts := hostConfig.Mounts
|
||||||
networkMode := hostConfig.NetworkMode
|
networkMode := hostConfig.NetworkMode
|
||||||
err = mergo.Merge(hostConfig, containerConfig.HostConfig, mergo.WithOverride)
|
overlay(reflect.ValueOf(hostConfig).Elem(), reflect.ValueOf(containerConfig.HostConfig).Elem(), false)
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("cannot merge container.HostConfig options: '%s': '%w'", options, err)
|
|
||||||
}
|
|
||||||
hostConfig.Binds = binds
|
hostConfig.Binds = binds
|
||||||
hostConfig.Mounts = mounts
|
hostConfig.Mounts = mounts
|
||||||
if cf.name != "" {
|
if cf.name != "" {
|
||||||
@@ -644,6 +638,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
|||||||
if flags.Changed("network") || flags.Changed("net") {
|
if flags.Changed("network") || flags.Changed("net") {
|
||||||
logger.Warn("--network and --net in the options will be ignored.")
|
logger.Warn("--network and --net in the options will be ignored.")
|
||||||
}
|
}
|
||||||
|
if len(cr.input.Entrypoint) != 0 && flags.Changed("entrypoint") {
|
||||||
|
logger.Warn("--entrypoint in the options will be ignored.")
|
||||||
|
}
|
||||||
hostConfig.NetworkMode = networkMode
|
hostConfig.NetworkMode = networkMode
|
||||||
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
|
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
|
||||||
|
|
||||||
@@ -673,17 +670,11 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
ExposedPorts: exposedPorts,
|
ExposedPorts: exposedPorts,
|
||||||
Tty: input.AllocatePTY,
|
Tty: input.AllocatePTY,
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
|
||||||
// logger.Debugf("Common container.Config ==> %+v", config)
|
|
||||||
|
|
||||||
if len(input.Cmd) != 0 {
|
if len(input.Cmd) != 0 {
|
||||||
config.Cmd = input.Cmd
|
config.Cmd = input.Cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(input.Entrypoint) != 0 {
|
|
||||||
config.Entrypoint = input.Entrypoint
|
|
||||||
}
|
|
||||||
|
|
||||||
mounts := make([]mount.Mount, 0)
|
mounts := make([]mount.Mount, 0)
|
||||||
for mountSource, mountTarget := range input.Mounts {
|
for mountSource, mountTarget := range input.Mounts {
|
||||||
mounts = append(mounts, mount.Mount{
|
mounts = append(mounts, mount.Mount{
|
||||||
@@ -718,20 +709,20 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
PortBindings: portBindings,
|
PortBindings: portBindings,
|
||||||
AutoRemove: input.AutoRemove,
|
AutoRemove: input.AutoRemove,
|
||||||
}
|
}
|
||||||
// For Gitea, reduce log noise
|
|
||||||
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
|
|
||||||
|
|
||||||
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
|
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(input.Entrypoint) != 0 {
|
||||||
|
config.Entrypoint = input.Entrypoint
|
||||||
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig)
|
config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig)
|
||||||
|
|
||||||
var networkingConfig *network.NetworkingConfig
|
var networkingConfig *network.NetworkingConfig
|
||||||
// For Gitea, reduce log noise
|
|
||||||
// logger.Debugf("input.NetworkAliases ==> %v", input.NetworkAliases)
|
|
||||||
n := hostConfig.NetworkMode
|
n := hostConfig.NetworkMode
|
||||||
// IsUserDefined and IsHost are broken on windows
|
// IsUserDefined and IsHost are broken on windows
|
||||||
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
|
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
|
||||||
@@ -764,44 +755,6 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) extractFromImageEnv(env *map[string]string) common.Executor {
|
|
||||||
envMap := *env
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
|
|
||||||
inspect, err := cr.cli.ImageInspect(ctx, cr.input.Image)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(err)
|
|
||||||
return fmt.Errorf("inspect image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if inspect.Config == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
imageEnv, err := godotenv.Unmarshal(strings.Join(inspect.Config.Env, "\n"))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(err)
|
|
||||||
return fmt.Errorf("unmarshal image env: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range imageEnv {
|
|
||||||
if k == "PATH" {
|
|
||||||
if envMap[k] == "" {
|
|
||||||
envMap[k] = v
|
|
||||||
} else {
|
|
||||||
envMap[k] += `:` + v
|
|
||||||
}
|
|
||||||
} else if envMap[k] == "" {
|
|
||||||
envMap[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
env = &envMap
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
@@ -819,11 +772,6 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
|
|
||||||
logger.Debugf("Exec command '%s'", cmd)
|
logger.Debugf("Exec command '%s'", cmd)
|
||||||
isTerminal := cr.input.AllocatePTY
|
isTerminal := cr.input.AllocatePTY
|
||||||
envList := make([]string, 0)
|
|
||||||
for k, v := range env {
|
|
||||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
|
||||||
}
|
|
||||||
|
|
||||||
var wd string
|
var wd string
|
||||||
if workdir != "" {
|
if workdir != "" {
|
||||||
if strings.HasPrefix(workdir, "/") {
|
if strings.HasPrefix(workdir, "/") {
|
||||||
@@ -840,7 +788,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
User: user,
|
User: user,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
WorkingDir: wd,
|
WorkingDir: wd,
|
||||||
Env: envList,
|
Env: getEnvListFromMap(env),
|
||||||
TTY: isTerminal,
|
TTY: isTerminal,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
@@ -857,7 +805,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
|||||||
}
|
}
|
||||||
defer resp.Close()
|
defer resp.Close()
|
||||||
|
|
||||||
err = cr.waitForCommand(ctx, resp.HijackedResponse, idResp, user, workdir)
|
err = cr.waitForCommand(ctx, resp.HijackedResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -907,15 +855,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) tryReadUID() common.Executor {
|
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse) error {
|
||||||
return cr.tryReadID("-u", func(id int) { cr.UID = id })
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) tryReadGID() common.Executor {
|
|
||||||
return cr.tryReadID("-g", func(id int) { cr.GID = id })
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse, _ client.ExecCreateResult, _, _ string) error {
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
|
|
||||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||||
@@ -957,6 +897,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||||
|
dstPath = path.Clean(dstPath)
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||||
@@ -978,7 +919,11 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
}
|
}
|
||||||
}(tarFile)
|
}(tarFile)
|
||||||
if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
|
extractionRoot := "/"
|
||||||
|
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
||||||
|
extractionRoot = actPath // keeps tar entries from crossing the image's /var/run symlink
|
||||||
|
}
|
||||||
|
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,14 +932,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to seek tar archive: %w", err)
|
return fmt.Errorf("failed to seek tar archive: %w", err)
|
||||||
}
|
}
|
||||||
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
return cr.upload(ctx, extractionRoot, tarFile)
|
||||||
DestinationPath: "/",
|
|
||||||
Content: tarFile,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,17 +947,17 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
|||||||
}
|
}
|
||||||
|
|
||||||
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
|
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
|
||||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
return cr.upload(ctx, dstPath, &buf)
|
||||||
DestinationPath: dstPath,
|
|
||||||
Content: &buf,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) upload(ctx context.Context, dstPath string, content io.Reader) error {
|
||||||
|
if _, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{DestinationPath: dstPath, Content: content}); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) attach() common.Executor {
|
func (cr *containerReference) attach() common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
|
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
|
||||||
@@ -1098,7 +1036,7 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
// sanitizeOptionsHostConfig takes back everything a workflow could escape the container with,
|
// sanitizeOptionsHostConfig takes back everything a workflow could escape the container with,
|
||||||
// setting each field to trusted, which is what the runner's own options parse to on their own.
|
// setting each field to trusted, which is what the runner's own options parse to on their own.
|
||||||
// Only for unprivileged mode, since privileged mode grants host access anyway.
|
// Only for unprivileged mode, since privileged mode grants host access anyway.
|
||||||
func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *container.HostConfig) {
|
func sanitizeOptionsHostConfig(logger *logging.Logger, hostConfig, trusted *container.HostConfig) {
|
||||||
resetOption(logger, "--pid", &hostConfig.PidMode, trusted.PidMode)
|
resetOption(logger, "--pid", &hostConfig.PidMode, trusted.PidMode)
|
||||||
resetOption(logger, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
|
resetOption(logger, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
|
||||||
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
|
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
|
||||||
@@ -1137,7 +1075,7 @@ func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *c
|
|||||||
|
|
||||||
// resetOption puts a field back to the runner's own value. It compares the values rather than
|
// resetOption puts a field back to the runner's own value. It compares the values rather than
|
||||||
// the flags, so a field that more than one option feeds cannot slip through.
|
// the flags, so a field that more than one option feeds cannot slip through.
|
||||||
func resetOption[T any](logger logrus.FieldLogger, option string, field *T, trusted T) {
|
func resetOption[T any](logger *logging.Logger, option string, field *T, trusted T) {
|
||||||
if reflect.DeepEqual(*field, trusted) {
|
if reflect.DeepEqual(*field, trusted) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1169,7 +1107,7 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
// sanitize binds
|
// sanitize binds
|
||||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||||
for _, bind := range hostConfig.Binds {
|
for _, bind := range hostConfig.Binds {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("parse volume [%s] error: %v", bind, err)
|
logger.Warnf("parse volume [%s] error: %v", bind, err)
|
||||||
continue
|
continue
|
||||||
@@ -1179,8 +1117,8 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
|||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
|
if matcher.isValid(parsed.Source, parsed.Type, parsed.ReadOnly) {
|
||||||
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
|
if matcher.needsNoCopy(parsed.Source, parsed.Type) && !parsed.NoCopy {
|
||||||
bind += ",nocopy"
|
bind += ",nocopy"
|
||||||
}
|
}
|
||||||
sanitizedBinds = append(sanitizedBinds, bind)
|
sanitizedBinds = append(sanitizedBinds, bind)
|
||||||
@@ -1220,9 +1158,56 @@ func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
|
|||||||
return hostConfig.Binds
|
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.
|
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||||
func bindTarget(bind string) string {
|
func bindTarget(bind string) string {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := ParseVolume(bind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/api/pkg/stdcopy"
|
"github.com/moby/moby/api/pkg/stdcopy"
|
||||||
@@ -25,50 +28,33 @@ import (
|
|||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/moby/moby/api/types/network"
|
"github.com/moby/moby/api/types/network"
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDocker(t *testing.T) {
|
func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
|
||||||
requireDocker(t)
|
client := &mockDockerClient{}
|
||||||
ctx := context.Background()
|
client.On("ContainerInspect", mock.Anything, "amd64-job", mobyclient.ContainerInspectOptions{}).
|
||||||
client, err := GetDockerClient(ctx)
|
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Env: []string{
|
||||||
require.NoError(t, err)
|
"PATH=/opt/node/x64/bin:/usr/bin:/bin", "CONFLICT_VAR=container-value", "SOME_RANDOM_VAR=",
|
||||||
defer client.Close()
|
"ANOTHER_ONE=old-value", "ANOTHER_ONE=BUT_I_HAVE_VALUE", "LITERAL='quoted'=$HOME\nsecond line",
|
||||||
|
}}}}, nil)
|
||||||
dockerBuild := NewDockerBuildExecutor(NewDockerBuildExecutorInput{
|
|
||||||
ContextDir: "testdata",
|
|
||||||
ImageTag: "envmergetest",
|
|
||||||
})
|
|
||||||
|
|
||||||
err = dockerBuild(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
|
|
||||||
cr := &containerReference{
|
|
||||||
cli: client,
|
|
||||||
input: &NewContainerInput{
|
|
||||||
Image: "envmergetest",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
"PATH": "/custom/bin",
|
||||||
"RANDOM_VAR": "WITH_VALUE",
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
"ANOTHER_VAR": "",
|
"ANOTHER_VAR": "",
|
||||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
"CONFLICT_VAR": "step-value",
|
||||||
}
|
}
|
||||||
|
require.NoError(t, (&containerReference{cli: client, id: "amd64-job"}).UpdateFromImageEnv(&env)(t.Context()))
|
||||||
envExecutor := cr.extractFromImageEnv(&env)
|
|
||||||
err = envExecutor(ctx)
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
||||||
assert.Equal(t, map[string]string{
|
assert.Equal(t, map[string]string{
|
||||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin:/this/path/does/not/exists/anywhere:/this/either",
|
"PATH": "/custom/bin:/opt/node/x64/bin:/usr/bin:/bin",
|
||||||
"RANDOM_VAR": "WITH_VALUE",
|
"RANDOM_VAR": "WITH_VALUE",
|
||||||
"ANOTHER_VAR": "",
|
"ANOTHER_VAR": "",
|
||||||
"SOME_RANDOM_VAR": "",
|
"SOME_RANDOM_VAR": "",
|
||||||
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
"CONFLICT_VAR": "step-value",
|
||||||
|
"LITERAL": "'quoted'=$HOME\nsecond line",
|
||||||
}, env)
|
}, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +166,7 @@ func (m *mockConn) Close() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerExecAbort(t *testing.T) {
|
func TestDockerExecAbort(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
|
||||||
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
reader := &interruptReader{started: make(chan struct{}), interrupted: make(chan struct{}), stopped: make(chan struct{})}
|
||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
@@ -221,7 +207,7 @@ func TestDockerExecAbort(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerExecFailure(t *testing.T) {
|
func TestDockerExecFailure(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
conn := &mockConn{}
|
conn := &mockConn{}
|
||||||
|
|
||||||
@@ -268,7 +254,7 @@ func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
|||||||
// attach() streaming goroutine has drained and flushed the container's output,
|
// attach() streaming goroutine has drained and flushed the container's output,
|
||||||
// so a final line without a trailing newline is not lost.
|
// so a final line without a trailing newline is not lost.
|
||||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||||
|
|
||||||
@@ -317,7 +303,7 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
statusCh <- container.WaitResponse{StatusCode: 2}
|
statusCh <- container.WaitResponse{StatusCode: 2}
|
||||||
@@ -368,8 +354,8 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||||
@@ -392,7 +378,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
assert.Empty(t, cr.id)
|
assert.Empty(t, cr.id)
|
||||||
}
|
}
|
||||||
assert.Empty(t, hook.AllEntries())
|
assert.Empty(t, hook.AllRecords())
|
||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -402,7 +388,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
|||||||
// create() lost its reply, must still be removed rather than leaking with its network. It
|
// create() lost its reply, must still be removed rather than leaking with its network. It
|
||||||
// was never started here, so it is not worth a kill of its own.
|
// was never started here, so it is not worth a kill of its own.
|
||||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||||
@@ -415,7 +401,7 @@ func TestRemoveWithoutIDUsesName(t *testing.T) {
|
|||||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||||
// daemon with a torn-down container.
|
// daemon with a torn-down container.
|
||||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||||
boom := errors.New("daemon unreachable")
|
boom := errors.New("daemon unreachable")
|
||||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||||
@@ -473,7 +459,7 @@ func TestFindRevalidatesStaleID(t *testing.T) {
|
|||||||
// Every daemon entry point fails fast with a clear, container-named
|
// Every daemon entry point fails fast with a clear, container-named
|
||||||
// error when no live cr.id is known.
|
// error when no live cr.id is known.
|
||||||
func TestRejectsMissingContainer(t *testing.T) {
|
func TestRejectsMissingContainer(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
client := &mockDockerClient{}
|
client := &mockDockerClient{}
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
@@ -498,25 +484,45 @@ func TestRejectsMissingContainer(t *testing.T) {
|
|||||||
check("Inspect after removal", err)
|
check("Inspect after removal", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// End-to-end: a stale cr.id is cleared, repopulated from name lookup,
|
|
||||||
// and the Copy completes against the fresh id.
|
|
||||||
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
func TestPublicCopyPipelineHandlesStaleID(t *testing.T) {
|
||||||
ctx := context.Background()
|
for _, testcase := range []struct {
|
||||||
client := &mockDockerClient{}
|
name, destination, extractionRoot, entry string
|
||||||
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
directory bool
|
||||||
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
}{
|
||||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
{name: "files", destination: "/var/run/act", extractionRoot: "/var/run/act", entry: "x"},
|
||||||
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
{name: "action below act volume", directory: true, destination: "/var/run/act/actions/checkout/", extractionRoot: "/var/run/act", entry: "actions/checkout/x"},
|
||||||
{ID: "fresh", Names: []string{"/job-1"}},
|
{name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
|
||||||
}}, nil)
|
} {
|
||||||
client.On("CopyToContainer", ctx, "fresh", mock.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
return opts.DestinationPath == "/var/run/act"
|
ctx := t.Context()
|
||||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
client := &mockDockerClient{}
|
||||||
|
client.On("ContainerInspect", ctx, "stale", mobyclient.ContainerInspectOptions{}).
|
||||||
|
Return(mobyclient.ContainerInspectResult{}, cerrdefs.ErrNotFound.WithMessage("gone"))
|
||||||
|
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).
|
||||||
|
Return(mobyclient.ContainerListResult{Items: []container.Summary{
|
||||||
|
{ID: "fresh", Names: []string{"/job-1"}},
|
||||||
|
}}, nil)
|
||||||
|
client.On("CopyToContainer", ctx, "fresh", mock.Anything).
|
||||||
|
Run(func(args mock.Arguments) {
|
||||||
|
opts := args.Get(2).(mobyclient.CopyToContainerOptions)
|
||||||
|
assert.Equal(t, testcase.extractionRoot, opts.DestinationPath)
|
||||||
|
header, err := tar.NewReader(opts.Content).Next()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testcase.entry, header.Name)
|
||||||
|
}).Return(mobyclient.CopyToContainerResult{}, nil).Once()
|
||||||
|
|
||||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
if testcase.directory {
|
||||||
assert.Equal(t, "fresh", cr.id)
|
source := t.TempDir()
|
||||||
client.AssertExpectations(t)
|
require.NoError(t, os.WriteFile(filepath.Join(source, "x"), nil, 0o644))
|
||||||
|
require.NoError(t, cr.CopyDir(testcase.destination, source+string(filepath.Separator), false, true)(ctx))
|
||||||
|
} else {
|
||||||
|
require.NoError(t, cr.Copy(testcase.destination, &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||||
|
}
|
||||||
|
assert.Equal(t, "fresh", cr.id)
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type assert containerReference implements ExecutionsEnvironment
|
// Type assert containerReference implements ExecutionsEnvironment
|
||||||
@@ -609,8 +615,8 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.desc, func(t *testing.T) {
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
ValidVolumes: tc.validVolumes,
|
ValidVolumes: tc.validVolumes,
|
||||||
@@ -619,7 +625,7 @@ func TestCheckVolumes(t *testing.T) {
|
|||||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
|
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
|
||||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||||
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
||||||
assert.Len(t, hook.AllEntries(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -647,33 +653,33 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
|||||||
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
||||||
|
|
||||||
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
for _, option := range []string{"--env-file " + hostFile, "--label-file " + hostFile} {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
cr := &containerReference{input: &NewContainerInput{NetworkMode: "bridge", WorkflowOptions: option}}
|
||||||
|
|
||||||
_, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
_, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.ErrorContains(t, err, "not allowed in a workflow")
|
require.ErrorContains(t, err, "not allowed in a workflow")
|
||||||
|
|
||||||
// the runner reading its own files is what those options are for
|
// the runner reading its own files is what those options are for
|
||||||
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
cr = &containerReference{input: &NewContainerInput{NetworkMode: "bridge", RunnerOptions: option}}
|
||||||
_, _, err = cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
_, _, err = cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// a bare name is no longer resolved from the runner's environment, for either source
|
// a bare name is no longer resolved from the runner's environment, for either source
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
RunnerOptions: "--env RUNNER_SECRET",
|
RunnerOptions: "--env RUNNER_SECRET",
|
||||||
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
WorkflowOptions: "--env RUNNER_SECRET --env GIVEN=value",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
config, _, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{})
|
config, _, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
|
|
||||||
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
// every field the sanitizer resets, so a reset dropped in a refactor fails here
|
||||||
hostConfig := &container.HostConfig{
|
hostConfig := &container.HostConfig{
|
||||||
@@ -706,9 +712,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
// mergeOptions merges both option sources into a bare container, returning the result and its log.
|
||||||
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *test.Hook) {
|
func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privileged bool) (*container.HostConfig, *logtest.Handler) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
RunnerOptions: runnerOptions,
|
RunnerOptions: runnerOptions,
|
||||||
WorkflowOptions: workflowOptions,
|
WorkflowOptions: workflowOptions,
|
||||||
@@ -717,7 +723,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
|
|||||||
ValidVolumes: []string{"**"},
|
ValidVolumes: []string{"**"},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{
|
||||||
Privileged: privileged,
|
Privileged: privileged,
|
||||||
UsernsMode: container.UsernsMode("private"),
|
UsernsMode: container.UsernsMode("private"),
|
||||||
NetworkMode: container.NetworkMode("bridge"),
|
NetworkMode: container.NetworkMode("bridge"),
|
||||||
@@ -760,8 +766,8 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
allowed := filepath.Join(base, "allowed")
|
allowed := filepath.Join(base, "allowed")
|
||||||
@@ -801,14 +807,14 @@ func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
|
|||||||
require.Len(t, hostConfig.Mounts, 1)
|
require.Len(t, hostConfig.Mounts, 1)
|
||||||
|
|
||||||
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
|
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
|
||||||
hostConfig.Mounts[0],
|
hostConfig.Mounts[0],
|
||||||
{Type: mount.TypeVolume, Target: "/anonymous"},
|
{Type: mount.TypeVolume, Target: "/anonymous"},
|
||||||
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
||||||
}})
|
}})
|
||||||
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
||||||
assert.Len(t, hook.AllEntries(), 2)
|
assert.Len(t, hook.AllRecords(), 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,8 +897,8 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cr := &containerReference{
|
cr := &containerReference{
|
||||||
input: &NewContainerInput{
|
input: &NewContainerInput{
|
||||||
NetworkMode: "bridge",
|
NetworkMode: "bridge",
|
||||||
@@ -914,7 +920,7 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
|||||||
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
||||||
warnings := func(runnerOptions, workflowOptions string) int {
|
warnings := func(runnerOptions, workflowOptions string) int {
|
||||||
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
||||||
return len(hook.AllEntries())
|
return len(hook.AllRecords())
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
||||||
@@ -923,8 +929,37 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
|||||||
assert.Equal(t, 1, warnings("", "--privileged"))
|
assert.Equal(t, 1, warnings("", "--privileged"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerCreateKeepsInputEntrypoint(t *testing.T) {
|
||||||
|
for _, entrypoint := range [][]string{nil, {"sleep", "3600"}} {
|
||||||
|
for _, optionEntrypoint := range []string{"/sbin/dumb-init", ""} {
|
||||||
|
logger, hook := logtest.NewLogger()
|
||||||
|
cr := &containerReference{
|
||||||
|
input: &NewContainerInput{
|
||||||
|
NetworkMode: "bridge",
|
||||||
|
Entrypoint: entrypoint,
|
||||||
|
RunnerOptions: "--entrypoint=runner-entrypoint",
|
||||||
|
WorkflowOptions: "--entrypoint=" + optionEntrypoint + " --init",
|
||||||
|
},
|
||||||
|
cli: &probeClient{create: func(opts mobyclient.ContainerCreateOptions) (mobyclient.ContainerCreateResult, error) {
|
||||||
|
if len(entrypoint) > 0 {
|
||||||
|
assert.Equal(t, entrypoint, opts.Config.Entrypoint)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, []string{optionEntrypoint}, opts.Config.Entrypoint)
|
||||||
|
}
|
||||||
|
require.NotNil(t, opts.HostConfig.Init)
|
||||||
|
assert.True(t, *opts.HostConfig.Init)
|
||||||
|
return mobyclient.ContainerCreateResult{ID: "created"}, nil
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, cr.create(nil, nil)(common.WithLogger(t.Context(), logger)))
|
||||||
|
assert.Equal(t, "created", cr.id)
|
||||||
|
assert.Equal(t, len(entrypoint) > 0, len(hook.AllRecords()) == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
cr := &containerReference{input: &NewContainerInput{
|
cr := &containerReference{input: &NewContainerInput{
|
||||||
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
mobyclient "github.com/moby/moby/client"
|
mobyclient "github.com/moby/moby/client"
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||||
func requireDocker(t *testing.T) {
|
func requireDocker(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cli, err := GetDockerClient(ctx)
|
cli, err := GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ import (
|
|||||||
"gitea.com/gitea/runner/internal/pkg/process"
|
"gitea.com/gitea/runner/internal/pkg/process"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,25 +97,16 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGi
|
|||||||
srcPrefix += string(filepath.Separator)
|
srcPrefix += string(filepath.Separator)
|
||||||
}
|
}
|
||||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||||
var ignorer gitignore.Matcher
|
|
||||||
if useGitIgnore {
|
|
||||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debugf("Error loading .gitignore: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ignorer = gitignore.NewMatcher(ps)
|
|
||||||
}
|
|
||||||
fc := &filecollector.FileCollector{
|
fc := &filecollector.FileCollector{
|
||||||
Ignorer: ignorer,
|
SrcPath: srcPath,
|
||||||
SrcPath: srcPath,
|
SrcPrefix: srcPrefix,
|
||||||
SrcPrefix: srcPrefix,
|
SkipGitDir: skipGitDir,
|
||||||
SkipGitDir: skipGitDir,
|
UseGitIgnore: useGitIgnore,
|
||||||
Handler: &filecollector.CopyCollector{
|
Handler: &filecollector.CopyCollector{
|
||||||
DstDir: destPath,
|
DstDir: destPath,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
return filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +140,7 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
|||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
Handler: tc,
|
Handler: tc,
|
||||||
}
|
}
|
||||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
err = filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -350,7 +338,7 @@ func (e *HostEnvironment) exec(ctx context.Context, command []string, cmdline st
|
|||||||
} else {
|
} else {
|
||||||
defer k.Close()
|
defer k.Close()
|
||||||
}
|
}
|
||||||
err = cmd.Wait()
|
err = treeKill.Wait(ctx, cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
||||||
return ExitCodeError(exitErr.ExitCode())
|
return ExitCodeError(exitErr.ExitCode())
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -33,38 +33,25 @@ func TestActionPlatformNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCopyDir(t *testing.T) {
|
func TestCopyDir(t *testing.T) {
|
||||||
dir := t.TempDir()
|
for _, test := range []struct {
|
||||||
ctx := context.Background()
|
name string
|
||||||
e := &HostEnvironment{
|
useGitIgnore, skipGitDir bool
|
||||||
Path: filepath.Join(dir, "path"),
|
}{
|
||||||
TmpDir: filepath.Join(dir, "tmp"),
|
{name: "git dir kept for a workspace copy"},
|
||||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
{name: "git dir skipped for an action copy", skipGitDir: true},
|
||||||
ActPath: filepath.Join(dir, "act_path"),
|
{name: "unfiltered outside a git repository", useGitIgnore: true},
|
||||||
StdOut: os.Stdout,
|
} {
|
||||||
Workdir: path.Join("testdata", "scratch"),
|
t.Run(test.name, func(t *testing.T) {
|
||||||
}
|
|
||||||
_ = os.MkdirAll(e.Path, 0o700)
|
|
||||||
_ = os.MkdirAll(e.TmpDir, 0o700)
|
|
||||||
_ = os.MkdirAll(e.ToolCache, 0o700)
|
|
||||||
_ = os.MkdirAll(e.ActPath, 0o700)
|
|
||||||
err := e.CopyDir(e.Workdir, e.Path, true, false)(ctx)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCopyDirSkipGitDir(t *testing.T) {
|
|
||||||
for name, skipGitDir := range map[string]bool{"kept for a workspace copy": false, "skipped for an action copy": true} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
src := filepath.Join(t.TempDir(), "action")
|
src := filepath.Join(t.TempDir(), "action")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
require.NoError(t, os.MkdirAll(filepath.Join(src, ".git", "objects"), 0o700))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(src, ".git", "objects", "pack"), []byte("x"), 0o600))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(src, "action.yml"), []byte("name: x\n"), 0o600))
|
||||||
|
|
||||||
dest := filepath.Join(t.TempDir(), "dest")
|
dest := filepath.Join(t.TempDir(), "dest")
|
||||||
e := &HostEnvironment{StdOut: os.Stdout}
|
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
|
||||||
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
|
||||||
|
|
||||||
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||||
if skipGitDir {
|
if test.skipGitDir {
|
||||||
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||||
} else {
|
} else {
|
||||||
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||||
@@ -75,7 +62,7 @@ func TestCopyDirSkipGitDir(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetContainerArchive(t *testing.T) {
|
func TestGetContainerArchive(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
e := &HostEnvironment{
|
e := &HostEnvironment{
|
||||||
Path: filepath.Join(dir, "path"),
|
Path: filepath.Join(dir, "path"),
|
||||||
TmpDir: filepath.Join(dir, "tmp"),
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
@@ -110,7 +97,7 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
|
|||||||
t.Skip("uses POSIX shell")
|
t.Skip("uses POSIX shell")
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
e := &HostEnvironment{
|
e := &HostEnvironment{
|
||||||
Path: filepath.Join(dir, "path"),
|
Path: filepath.Join(dir, "path"),
|
||||||
TmpDir: filepath.Join(dir, "tmp"),
|
TmpDir: filepath.Join(dir, "tmp"),
|
||||||
@@ -161,7 +148,7 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
|||||||
err := e.Exec(
|
err := e.Exec(
|
||||||
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
||||||
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
||||||
)(context.Background())
|
)(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
||||||
assert.Equal(t, tc.expect, got)
|
assert.Equal(t, tc.expect, got)
|
||||||
@@ -170,8 +157,7 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||||
logger := logrus.New()
|
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
miscRoot := filepath.Join(base, "misc")
|
miscRoot := filepath.Join(base, "misc")
|
||||||
path := filepath.Join(miscRoot, "hostexecutor")
|
path := filepath.Join(miscRoot, "hostexecutor")
|
||||||
@@ -193,8 +179,7 @@ func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||||
logger := logrus.New()
|
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
miscRoot := filepath.Join(base, "misc")
|
miscRoot := filepath.Join(base, "misc")
|
||||||
path := filepath.Join(miscRoot, "hostexecutor")
|
path := filepath.Join(miscRoot, "hostexecutor")
|
||||||
@@ -235,7 +220,7 @@ func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
|||||||
removeAll = orig
|
removeAll = orig
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
err := removeAllWithContext(ctx, t.TempDir())
|
err := removeAllWithContext(ctx, t.TempDir())
|
||||||
@@ -252,8 +237,7 @@ func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
|||||||
hostCleanupTimeout = 100 * time.Millisecond
|
hostCleanupTimeout = 100 * time.Millisecond
|
||||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||||
|
|
||||||
logger := logrus.New()
|
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
path := filepath.Join(base, "misc", "hostexecutor")
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
@@ -306,8 +290,7 @@ func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
|||||||
removeAll = origRemoveAll
|
removeAll = origRemoveAll
|
||||||
})
|
})
|
||||||
|
|
||||||
logger := logrus.New()
|
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
path := filepath.Join(base, "misc", "hostexecutor")
|
path := filepath.Join(base, "misc", "hostexecutor")
|
||||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||||
|
|||||||
@@ -93,17 +93,29 @@ func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnviron
|
|||||||
return p.job
|
return p.job
|
||||||
}
|
}
|
||||||
|
|
||||||
var invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
var (
|
||||||
|
invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||||
|
dnsSubdomain = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||||
|
)
|
||||||
|
|
||||||
// KubernetesServiceName returns the container name and hostname of service id, made a DNS label as both must be.
|
// kubernetesServiceName returns the container name of service id, made a DNS label as it must be.
|
||||||
func KubernetesServiceName(id string) string {
|
func kubernetesServiceName(id string) string {
|
||||||
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
|
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
|
||||||
return strings.TrimRight(name[:min(63, len(name))], "-")
|
return strings.TrimRight(name[:min(63, len(name))], "-")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceContainer returns a container of the pod the job reaches at hostname KubernetesServiceName(id).
|
// KubernetesServiceHostnames returns the hostnames of service id, its container name and, where hostAliases accept it as a DNS subdomain, id lowercased.
|
||||||
|
func KubernetesServiceHostnames(id string) []string {
|
||||||
|
name, host := kubernetesServiceName(id), strings.ToLower(id)
|
||||||
|
if host != name && len(host) <= 253 && dnsSubdomain.MatchString(host) { // https://github.com/kubernetes/apimachinery/blob/v0.35.1/pkg/util/validation/validation.go#L198-L207
|
||||||
|
return []string{name, host}
|
||||||
|
}
|
||||||
|
return []string{name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceContainer returns a container of the pod the job reaches at KubernetesServiceHostnames(id).
|
||||||
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
|
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
|
||||||
service := &kubernetesContainer{pod: p, id: id, name: KubernetesServiceName(id), input: input}
|
service := &kubernetesContainer{pod: p, id: id, name: kubernetesServiceName(id), input: input}
|
||||||
p.services = append(p.services, service)
|
p.services = append(p.services, service)
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
@@ -131,7 +143,7 @@ func (p *KubernetesPod) create(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, service := range p.services {
|
for _, service := range p.services {
|
||||||
if !strings.EqualFold(service.id, service.name) {
|
if !slices.ContainsFunc(KubernetesServiceHostnames(service.id), func(hostname string) bool { return strings.EqualFold(hostname, service.id) }) { // glibc and Go match /etc/hosts case-insensitively
|
||||||
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
|
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +177,8 @@ func (p *KubernetesPod) manifest(secret string, secretData map[string]string, me
|
|||||||
serviceTemplate = templateContainers[index]
|
serviceTemplate = templateContainers[index]
|
||||||
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
|
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
|
||||||
}
|
}
|
||||||
var containers, hostnames, volumes []any
|
var containers, volumes []any
|
||||||
|
var hostnames []string
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, container := range templateContainers {
|
for _, container := range templateContainers {
|
||||||
names[manifestField(container, "name")] = true
|
names[manifestField(container, "name")] = true
|
||||||
@@ -184,7 +197,7 @@ func (p *KubernetesPod) manifest(secret string, secretData map[string]string, me
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
|
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
|
||||||
hostnames = append(hostnames, c.name)
|
hostnames = append(hostnames, KubernetesServiceHostnames(c.id)...)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"workspace", "act"} {
|
for _, name := range []string{"workspace", "act"} {
|
||||||
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
|
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
|
||||||
|
|||||||
@@ -129,6 +129,19 @@ func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKubernetesServiceHostnames(t *testing.T) {
|
||||||
|
for id, hostnames := range map[string][]string{
|
||||||
|
"db": {"db"},
|
||||||
|
"My_DB": {"my-db"},
|
||||||
|
"devstoreaccount1.azurite.local": {"devstoreaccount1-azurite-local", "devstoreaccount1.azurite.local"},
|
||||||
|
"DB.Local": {"db-local", "db.local"},
|
||||||
|
strings.Repeat("a", 64): {strings.Repeat("a", 63), strings.Repeat("a", 64)},
|
||||||
|
strings.Repeat("a", 254): {strings.Repeat("a", 63)},
|
||||||
|
} {
|
||||||
|
assert.Equal(t, hostnames, KubernetesServiceHostnames(id), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("uses POSIX shell")
|
t.Skip("uses POSIX shell")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LinuxContainerEnvironmentExtensions struct{}
|
type LinuxContainerEnvironmentExtensions struct{}
|
||||||
@@ -21,13 +21,13 @@ type LinuxContainerEnvironmentExtensions struct{}
|
|||||||
// For use in docker volumes and binds
|
// For use in docker volumes and binds
|
||||||
func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string {
|
func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string {
|
||||||
if runtime.GOOS == "windows" && strings.Contains(path, "/") {
|
if runtime.GOOS == "windows" && strings.Contains(path, "/") {
|
||||||
log.Error("You cannot specify linux style local paths (/mnt/etc) on Windows as it does not understand them.")
|
logging.Error("You cannot specify linux style local paths (/mnt/etc) on Windows as it does not understand them.")
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
abspath, err := filepath.Abs(path)
|
abspath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
logging.Error(err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ func TestContainerPath(t *testing.T) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
logging.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootDrive := os.Getenv("SystemDrive")
|
rootDrive := os.Getenv("SystemDrive")
|
||||||
@@ -49,7 +50,7 @@ func TestContainerPath(t *testing.T) {
|
|||||||
} else {
|
} else {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
logging.Error(err)
|
||||||
}
|
}
|
||||||
for _, v := range []containerPathJob{
|
for _, v := range []containerPathJob{
|
||||||
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
|
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,7 +27,7 @@ func TestParseEnvFileSingleLine(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "bar", env["FOO"])
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
assert.Equal(t, "qux", env["BAZ"])
|
assert.Equal(t, "qux", env["BAZ"])
|
||||||
}
|
}
|
||||||
@@ -39,7 +38,7 @@ func TestParseEnvFileMultiLine(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "line1\nline2", env["FOO"])
|
assert.Equal(t, "line1\nline2", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,7 @@ func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, big, env["FOO"])
|
assert.Equal(t, big, env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
err := parseEnvFile(e, envPath, &env)(t.Context())
|
||||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||||
assert.Contains(t, err.Error(), "reading env file")
|
assert.Contains(t, err.Error(), "reading env file")
|
||||||
}
|
}
|
||||||
@@ -73,7 +72,7 @@ func TestParseEnvFileBlankLines(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("\nFOO=bar\n\n \nBAZ=qux\n\n"), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "bar", env["FOO"])
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
assert.Equal(t, "qux", env["BAZ"])
|
assert.Equal(t, "qux", env["BAZ"])
|
||||||
}
|
}
|
||||||
@@ -84,12 +83,12 @@ func TestParseEnvFileMultiLineKeepsBlankLines(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\n\nline2\nEOF\n"), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
assert.Equal(t, "line1\n\nline2", env["FOO"])
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
||||||
env = map[string]string{}
|
env = map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "\nline2", env["FOO"])
|
assert.Equal(t, "\nline2", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ func TestParseEnvFileUTF8BOM(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "bar", env["FOO"])
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ func TestParseEnvFileUTF16(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
require.NoError(t, parseEnvFile(e, envPath, &env)(context.Background()))
|
require.NoError(t, parseEnvFile(e, envPath, &env)(t.Context()))
|
||||||
assert.Equal(t, "bar", env["FOO"])
|
assert.Equal(t, "bar", env["FOO"])
|
||||||
assert.Equal(t, "line1", env["MULTI"])
|
assert.Equal(t, "line1", env["MULTI"])
|
||||||
})
|
})
|
||||||
@@ -133,7 +132,7 @@ func TestParseEnvFileMissingDelimiter(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\nline1\nline2\n"), 0o600))
|
||||||
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
err := parseEnvFile(e, envPath, &env)(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "delimiter")
|
assert.Contains(t, err.Error(), "delimiter")
|
||||||
}
|
}
|
||||||
+6
-20
@@ -13,10 +13,6 @@ import (
|
|||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
|
|
||||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
|
||||||
"github.com/go-git/go-billy/v5/osfs"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||||
@@ -46,23 +42,13 @@ func writeDirTar(ctx context.Context, w io.Writer, dstPath, srcPath string, useG
|
|||||||
srcPrefix := strings.TrimSuffix(filepath.Dir(srcPath), string(filepath.Separator)) + string(filepath.Separator)
|
srcPrefix := strings.TrimSuffix(filepath.Dir(srcPath), string(filepath.Separator)) + string(filepath.Separator)
|
||||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||||
|
|
||||||
var ignorer gitignore.Matcher
|
|
||||||
if useGitIgnore {
|
|
||||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debugf("Error loading .gitignore: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ignorer = gitignore.NewMatcher(ps)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := filepath.Walk(srcPath, (&filecollector.FileCollector{
|
if err := filepath.Walk(srcPath, (&filecollector.FileCollector{
|
||||||
Ignorer: ignorer,
|
SrcPath: srcPath,
|
||||||
SrcPath: srcPath,
|
SrcPrefix: srcPrefix,
|
||||||
SrcPrefix: srcPrefix,
|
SkipGitDir: skipGitDir,
|
||||||
SkipGitDir: skipGitDir,
|
UseGitIgnore: useGitIgnore,
|
||||||
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
||||||
}).CollectFiles(ctx, []string{})); err != nil {
|
}).CollectFiles(ctx)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tw.Close()
|
return tw.Close()
|
||||||
|
|||||||
Vendored
-5
@@ -1,5 +0,0 @@
|
|||||||
FROM scratch
|
|
||||||
ENV PATH="/this/path/does/not/exists/anywhere:/this/either"
|
|
||||||
ENV SOME_RANDOM_VAR=""
|
|
||||||
ENV ANOTHER_ONE="BUT_I_HAVE_VALUE"
|
|
||||||
ENV CONFLICT_VAR="I_EXIST_ONLY_HERE"
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -16,10 +16,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
git "github.com/go-git/go-git/v5"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/index"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
@@ -94,73 +94,62 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FileCollector struct {
|
type FileCollector struct {
|
||||||
Ignorer gitignore.Matcher
|
SrcPath string
|
||||||
SrcPath string
|
SrcPrefix string
|
||||||
SrcPrefix string
|
SkipGitDir bool
|
||||||
SkipGitDir bool
|
UseGitIgnore bool
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func openGitIndex(path string) (*index.Index, error) {
|
// foldPath compares git's spelling of a path with the filesystem's, which may differ in case or Unicode normalization.
|
||||||
repo, err := git.PlainOpen(path)
|
func foldPath(name string) string {
|
||||||
if err != nil {
|
return strings.ToLower(norm.NFC.String(name))
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
func (fc *FileCollector) gitFiles(ctx context.Context) map[string]bool {
|
||||||
|
if !fc.UseGitIgnore {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return repo.Storer.Index()
|
files, err := git.ListFiles(ctx, fc.SrcPath)
|
||||||
|
if err != nil {
|
||||||
|
common.Logger(ctx).Debugf("Copying %s without applying .gitignore: %v", fc.SrcPath, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keep := map[string]bool{".": true}
|
||||||
|
for _, file := range files {
|
||||||
|
for file = foldPath(file); !keep[file]; file = path.Dir(file) {
|
||||||
|
keep[file] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []string) filepath.WalkFunc {
|
func (fc *FileCollector) CollectFiles(ctx context.Context) filepath.WalkFunc {
|
||||||
i, _ := openGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
keep := fc.gitFiles(ctx)
|
||||||
return func(file string, fi os.FileInfo, err error) error {
|
return func(file string, fi os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ctx != nil && ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return errors.New("copy cancelled")
|
return errors.New("copy cancelled")
|
||||||
}
|
}
|
||||||
|
if fc.SkipGitDir && fi.Name() == ".git" {
|
||||||
sansPrefix := strings.TrimPrefix(file, fc.SrcPrefix)
|
|
||||||
split := strings.Split(sansPrefix, string(filepath.Separator))
|
|
||||||
// The root folders should be skipped, submodules only have the last path component set to "." by filepath.Walk
|
|
||||||
if fi.IsDir() && len(split) > 0 && split[len(split)-1] == "." {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if fc.SkipGitDir && split[len(split)-1] == ".git" {
|
|
||||||
if fi.IsDir() {
|
if fi.IsDir() {
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var entry *index.Entry
|
if keep != nil {
|
||||||
if i != nil {
|
rel, _ := filepath.Rel(fc.SrcPath, file)
|
||||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
if rel = filepath.ToSlash(rel); !keep[foldPath(rel)] && !strings.Contains("/"+rel+"/", "/.git/") {
|
||||||
} else {
|
if fi.IsDir() {
|
||||||
err = index.ErrEntryNotFound
|
|
||||||
}
|
|
||||||
if err != nil && fc.Ignorer != nil && fc.Ignorer.Match(split, fi.IsDir()) {
|
|
||||||
if fi.IsDir() {
|
|
||||||
if i != nil {
|
|
||||||
ms, err := i.Glob(strings.Join(append(split[len(submodulePath):], "**"), "/"))
|
|
||||||
if err != nil || len(ms) == 0 {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return filepath.SkipDir
|
return filepath.SkipDir
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil && entry.Mode == filemode.Submodule {
|
path := filepath.ToSlash(strings.TrimPrefix(file, fc.SrcPrefix))
|
||||||
err = filepath.Walk(file, fc.CollectFiles(ctx, split))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
path := filepath.ToSlash(sansPrefix)
|
|
||||||
|
|
||||||
// return on non-regular files (thanks to [kumo](https://medium.com/@komuw/just-like-you-did-fbdd7df829d3) for this suggested update)
|
|
||||||
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||||
linkName, err := os.Readlink(file)
|
linkName, err := os.Readlink(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -171,17 +160,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// open file
|
|
||||||
f, err := os.Open(file)
|
f, err := os.Open(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
defer context.AfterFunc(ctx, func() { _ = f.Close() })()
|
||||||
if ctx != nil {
|
|
||||||
stop := context.AfterFunc(ctx, func() { _ = f.Close() })
|
|
||||||
defer stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
return fc.Handler.WriteFile(path, fi, "", f)
|
return fc.Handler.WriteFile(path, fi, "", f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,106 +9,78 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
git "github.com/go-git/go-git/v5"
|
|
||||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIgnoredTrackedfile(t *testing.T) {
|
func gitCmd(t *testing.T, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
out, err := exec.Command("git", args...).CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectFilesKeepsWhatGitTracksOrDoesNotIgnore(t *testing.T) {
|
||||||
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
||||||
repo, err := git.PlainInit(repoDir, false)
|
for _, dir := range []string{repoDir, filepath.Join(repoDir, "sub"), filepath.Join(repoDir, "nested")} {
|
||||||
require.NoError(t, err)
|
gitCmd(t, "init", "-q", dir)
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".gitignore"), []byte(".*\n"), 0o644))
|
}
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
for name, body := range map[string]string{
|
||||||
worktree, err := repo.Worktree()
|
".gitignore": ".*\nign/\n",
|
||||||
require.NoError(t, err)
|
".env": "",
|
||||||
_, err = worktree.Add(".gitignore")
|
"Renamed": "",
|
||||||
require.NoError(t, err)
|
"ign/tracked": "",
|
||||||
|
"ign/untracked": "",
|
||||||
|
"sub/.gitignore": "dropped\n",
|
||||||
|
"sub/dropped": "",
|
||||||
|
"sub/kept": "",
|
||||||
|
"nested/kept": "",
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repoDir, name)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, name), []byte(body), 0o644))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.Mkdir(filepath.Join(repoDir, "uninitialized"), 0o755))
|
||||||
|
gitCmd(t, "-C", repoDir, "add", "-f", ".gitignore", "Renamed", "ign/tracked")
|
||||||
|
require.NoError(t, os.Rename(filepath.Join(repoDir, "Renamed"), filepath.Join(repoDir, "renamed")))
|
||||||
|
for _, gitlink := range []string{"sub", "uninitialized"} {
|
||||||
|
gitCmd(t, "-C", repoDir, "update-index", "--add", "--cacheinfo", "160000,4b825dc642cb6eb9a060e54bf8d69288fbee4904,"+gitlink)
|
||||||
|
}
|
||||||
|
expected := map[string]string{".gitignore": "", "ign/tracked": "", "nested/kept": "", "renamed": "", "sub/.gitignore": "", "sub/kept": ""}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
require.NoError(t, os.Symlink(".env", filepath.Join(repoDir, "test.env")))
|
||||||
|
expected["test.env"] = ".env"
|
||||||
|
}
|
||||||
|
|
||||||
var archive bytes.Buffer
|
var archive bytes.Buffer
|
||||||
tw := tar.NewWriter(&archive)
|
tw := tar.NewWriter(&archive)
|
||||||
patterns, err := gitignore.ReadPatterns(worktree.Filesystem, nil)
|
fc := &FileCollector{SrcPath: repoDir, SrcPrefix: repoDir + string(filepath.Separator), UseGitIgnore: true, Handler: &TarCollector{TarWriter: tw}}
|
||||||
require.NoError(t, err)
|
require.NoError(t, filepath.Walk(repoDir, fc.CollectFiles(t.Context())))
|
||||||
ignorer := gitignore.NewMatcher(patterns)
|
|
||||||
fc := &FileCollector{
|
|
||||||
Ignorer: ignorer,
|
|
||||||
SrcPath: repoDir,
|
|
||||||
SrcPrefix: repoDir + string(filepath.Separator),
|
|
||||||
Handler: &TarCollector{
|
|
||||||
TarWriter: tw,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
|
||||||
assert.NoError(t, err, "successfully collect files")
|
|
||||||
require.NoError(t, tw.Close())
|
require.NoError(t, tw.Close())
|
||||||
|
|
||||||
|
files := map[string]string{}
|
||||||
tr := tar.NewReader(&archive)
|
tr := tar.NewReader(&archive)
|
||||||
h, err := tr.Next()
|
for header, err := tr.Next(); err != io.EOF; header, err = tr.Next() {
|
||||||
assert.NoError(t, err, "tar must not be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
require.NoError(t, err)
|
||||||
assert.Equal(t, ".gitignore", h.Name)
|
files[header.Name] = header.Linkname
|
||||||
_, err = tr.Next()
|
}
|
||||||
assert.ErrorIs(t, err, io.EOF, "tar must only contain one element")
|
assert.Contains(t, files, ".git/HEAD")
|
||||||
|
maps.DeleteFunc(files, func(name, _ string) bool { return strings.Contains("/"+name, "/.git/") })
|
||||||
|
assert.Equal(t, expected, files)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSymlinks(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
|
||||||
}
|
|
||||||
repoDir := filepath.Join(t.TempDir(), "mygitrepo")
|
|
||||||
repo, err := git.PlainInit(repoDir, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("test=val1\n"), 0o644))
|
|
||||||
require.NoError(t, os.Symlink(".env", filepath.Join(repoDir, "test.env")))
|
|
||||||
worktree, err := repo.Worktree()
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = worktree.Add("test.env")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var archive bytes.Buffer
|
|
||||||
tw := tar.NewWriter(&archive)
|
|
||||||
fc := &FileCollector{
|
|
||||||
SrcPath: repoDir,
|
|
||||||
SrcPrefix: repoDir + string(filepath.Separator),
|
|
||||||
Handler: &TarCollector{
|
|
||||||
TarWriter: tw,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err = filepath.Walk(repoDir, fc.CollectFiles(context.Background(), nil))
|
|
||||||
assert.NoError(t, err, "successfully collect files")
|
|
||||||
require.NoError(t, tw.Close())
|
|
||||||
tr := tar.NewReader(&archive)
|
|
||||||
h, err := tr.Next()
|
|
||||||
files := map[string]tar.Header{}
|
|
||||||
for err == nil {
|
|
||||||
files[h.Name] = *h
|
|
||||||
h, err = tr.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, ".env", files[".env"].Name)
|
|
||||||
assert.Equal(t, "test.env", files["test.env"].Name)
|
|
||||||
assert.Equal(t, ".env", files["test.env"].Linkname)
|
|
||||||
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression for https://gitea.com/gitea/runner/issues/876 and /941:
|
|
||||||
// re-copying an action directory must overwrite a pre-existing read-only
|
|
||||||
// file (e.g. a git pack .idx at mode 0444) instead of failing with EACCES
|
|
||||||
// on macOS or "Access is denied" on Windows.
|
|
||||||
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
|
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
|
||||||
dst := t.TempDir()
|
dst := t.TempDir()
|
||||||
target := filepath.Join(dst, "sub", "pack.idx")
|
target := filepath.Join(dst, "sub", "pack.idx")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
||||||
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
|
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
|
||||||
|
fi, err := os.Stat(target)
|
||||||
src := filepath.Join(t.TempDir(), "pack.idx")
|
|
||||||
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
|
|
||||||
fi, err := os.Stat(src)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cc := &CopyCollector{DstDir: dst}
|
cc := &CopyCollector{DstDir: dst}
|
||||||
@@ -119,8 +91,6 @@ func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
|
|||||||
assert.Equal(t, "new", string(got))
|
assert.Equal(t, "new", string(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without the destination removal, os.Symlink fails with EEXIST when the
|
|
||||||
// path already holds a regular file from an earlier copy of the action.
|
|
||||||
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||||
@@ -129,11 +99,8 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|||||||
target := filepath.Join(dst, "link")
|
target := filepath.Join(dst, "link")
|
||||||
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
|
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
|
||||||
|
|
||||||
fi, err := os.Lstat(target)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
cc := &CopyCollector{DstDir: dst}
|
cc := &CopyCollector{DstDir: dst}
|
||||||
require.NoError(t, cc.WriteFile("link", fi, "target", nil))
|
require.NoError(t, cc.WriteFile("link", nil, "target", nil))
|
||||||
|
|
||||||
resolved, err := os.Readlink(target)
|
resolved, err := os.Readlink(target)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -143,7 +110,7 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
|||||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
walk := (&FileCollector{}).CollectFiles(ctx, nil)
|
walk := (&FileCollector{}).CollectFiles(ctx)
|
||||||
|
|
||||||
err := walk("file", nil, nil)
|
err := walk("file", nil, nil)
|
||||||
require.EqualError(t, err, "copy cancelled")
|
require.EqualError(t, err, "copy cancelled")
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git ref: %v", err)
|
logger.Warnf("unable to get git ref: %v", err)
|
||||||
} else {
|
} else {
|
||||||
logger.Debugf("using github ref: %s", ref)
|
logger.Debugf("using github ref: %s", ref)
|
||||||
ghc.Ref = ref
|
ghc.Ref = ref
|
||||||
@@ -121,7 +121,7 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
return revision, err
|
return revision, err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warningf("unable to get git revision: %v", err)
|
logger.Warnf("unable to get git revision: %v", err)
|
||||||
} else {
|
} else {
|
||||||
ghc.Sha = sha
|
ghc.Sha = sha
|
||||||
}
|
}
|
||||||
@@ -130,11 +130,11 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
|||||||
|
|
||||||
// SetRepositoryAndOwner resolves the repository of the context from the git
|
// SetRepositoryAndOwner resolves the repository of the context from the git
|
||||||
// remote in repoPath when it is not set yet, and derives its owner.
|
// remote in repoPath when it is not set yet, and derives its owner.
|
||||||
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, githubInstance, repoPath string) {
|
func SetRepositoryAndOwner(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||||
if ghc.Repository == "" {
|
if ghc.Repository == "" {
|
||||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance)
|
repo, err := findGithubRepo(ctx, repoPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.Logger(ctx).Warningf("unable to get git repo (githubInstance: %v, repoPath: %v): %v", githubInstance, repoPath, err)
|
common.Logger(ctx).Warnf("unable to get git repo (repoPath: %v): %v", repoPath, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ghc.Repository = repo
|
ghc.Repository = repo
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSetRef(t *testing.T) {
|
func TestSetRef(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
oldFindGitRef := findGitRef
|
oldFindGitRef := findGitRef
|
||||||
oldFindGitRevision := findGitRevision
|
oldFindGitRevision := findGitRevision
|
||||||
@@ -104,7 +105,7 @@ func TestSetRef(t *testing.T) {
|
|||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "/some/dir")
|
SetRef(t.Context(), ghc, "/some/dir")
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
assert.Equal(t, table.ref, ghc.Ref)
|
assert.Equal(t, table.ref, ghc.Ref)
|
||||||
@@ -122,14 +123,14 @@ func TestSetRef(t *testing.T) {
|
|||||||
Event: map[string]any{},
|
Event: map[string]any{},
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRef(context.Background(), ghc, "/some/dir")
|
SetRef(t.Context(), ghc, "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetSha(t *testing.T) {
|
func TestSetSha(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
oldFindGitRef := findGitRef
|
oldFindGitRef := findGitRef
|
||||||
oldFindGitRevision := findGitRevision
|
oldFindGitRevision := findGitRevision
|
||||||
@@ -209,14 +210,14 @@ func TestSetSha(t *testing.T) {
|
|||||||
Event: table.event,
|
Event: table.event,
|
||||||
}
|
}
|
||||||
|
|
||||||
SetSha(context.Background(), ghc, "/some/dir")
|
SetSha(t.Context(), ghc, "/some/dir")
|
||||||
|
|
||||||
assert.Equal(t, table.sha, ghc.Sha)
|
assert.Equal(t, table.sha, ghc.Sha)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
|
t.Run("pinned checkout keeps the first sha and ref", func(t *testing.T) {
|
||||||
ctx := WithPinnedCheckout(context.Background())
|
ctx := WithPinnedCheckout(t.Context())
|
||||||
for _, head := range []string{"first", "moved"} {
|
for _, head := range []string{"first", "moved"} {
|
||||||
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
|
findGitRevision = func(context.Context, string) (string, string, error) { return "", head, nil }
|
||||||
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
|
findGitRef = func(context.Context, string) (string, error) { return "refs/heads/" + head, nil }
|
||||||
|
|||||||
+26
-50
@@ -91,7 +91,7 @@ func readActionImpl(ctx context.Context, step *model.Step, actionDir, actionPath
|
|||||||
if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil {
|
if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o400)
|
err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o644)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return nil, err2
|
return nil, err2
|
||||||
}
|
}
|
||||||
@@ -145,18 +145,18 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
|||||||
containerActionDirCopy += `/`
|
containerActionDirCopy += `/`
|
||||||
}
|
}
|
||||||
|
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
if !rc.Config.NoActionPatch {
|
if !rc.Config.NoActionPatch {
|
||||||
// A concurrent job's prepare resets this directory, so patch under the copy's lock.
|
// Other jobs share the directory, so patch under the copy's lock.
|
||||||
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
defer patchActions(ctx, actionScriptPaths(filepath.Join(actionDir, actionPath), step.getActionModel()))()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", false, true)(ctx) // an exported commit has no .git to apply gitignore with
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
|
||||||
@@ -179,7 +179,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
|
|||||||
}
|
}
|
||||||
|
|
||||||
actionLocation := path.Join(actionDir, actionPath)
|
actionLocation := path.Join(actionDir, actionPath)
|
||||||
actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
|
actionName, containerActionDir := getContainerActionPaths(step)
|
||||||
|
|
||||||
logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
|
logger.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
|
||||||
|
|
||||||
@@ -241,23 +241,6 @@ func nodeActionCommand(script string) []string {
|
|||||||
return []string{"node", "--preserve-symlinks-main", script}
|
return []string{"node", "--preserve-symlinks-main", script}
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/nektos/act/issues/228#issuecomment-629709055
|
|
||||||
// files in .gitignore are not copied in a Docker container
|
|
||||||
// this causes issues with actions that ignore other important resources
|
|
||||||
// such as `node_modules` for example
|
|
||||||
func removeGitIgnore(ctx context.Context, directory string) error {
|
|
||||||
gitIgnorePath := path.Join(directory, ".gitignore")
|
|
||||||
if _, err := os.Stat(gitIgnorePath); err == nil {
|
|
||||||
// .gitignore exists
|
|
||||||
common.Logger(ctx).Debugf("Removing %s before docker cp", gitIgnorePath)
|
|
||||||
err := os.Remove(gitIgnorePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dockerActionImageTag derives the local docker image tag used when an action
|
// dockerActionImageTag derives the local docker image tag used when an action
|
||||||
// is built from a Dockerfile.
|
// is built from a Dockerfile.
|
||||||
//
|
//
|
||||||
@@ -366,7 +349,11 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
|||||||
// Held across the whole build: the daemon drains contextDir lazily.
|
// Held across the whole build: the daemon drains contextDir lazily.
|
||||||
inner := prepImage
|
inner := prepImage
|
||||||
prepImage = func(ctx context.Context) error {
|
prepImage = func(ctx context.Context) error {
|
||||||
defer git.AcquireCloneLock(actionDir)()
|
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
return inner(ctx)
|
return inner(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,25 +509,14 @@ func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
func getContainerActionPaths(step actionStep) (string, string) {
|
||||||
actionName := ""
|
rc := step.getRunContext()
|
||||||
containerActionDir := "."
|
if sar, ok := step.(*stepActionRemote); ok { // a copy per action, as its repository's directory is shared
|
||||||
if step.Type() != model.StepTypeUsesActionRemote {
|
actionName := path.Join(sar.containerKey(), sar.remoteAction.Path)
|
||||||
actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir)
|
return actionName, rc.JobContainer.GetActPath() + "/actions/" + actionName
|
||||||
containerActionDir = rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName
|
|
||||||
actionName = "./" + actionName
|
|
||||||
} else if step.Type() == model.StepTypeUsesActionRemote {
|
|
||||||
actionName = getOsSafeRelativePath(actionDir, rc.ActionCacheDir())
|
|
||||||
containerActionDir = rc.JobContainer.GetActPath() + "/actions/" + actionName
|
|
||||||
}
|
}
|
||||||
|
actionName := getOsSafeRelativePath(filepath.Join(rc.Config.Workdir, step.getStepModel().Uses), rc.Config.Workdir)
|
||||||
if actionName == "" {
|
return "./" + actionName, rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName
|
||||||
actionName = filepath.Base(actionDir)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
actionName = strings.ReplaceAll(actionName, "\\", "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return actionName, containerActionDir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOsSafeRelativePath(s, prefix string) string {
|
func getOsSafeRelativePath(s, prefix string) string {
|
||||||
@@ -586,13 +562,13 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
|||||||
stepModel := step.getStepModel()
|
stepModel := step.getStepModel()
|
||||||
|
|
||||||
if sar, ok := step.(*stepActionRemote); ok {
|
if sar, ok := step.(*stepActionRemote); ok {
|
||||||
actionDir = sar.actionDir()
|
actionDir = sar.actionDir
|
||||||
actionPath = sar.remoteAction.Path
|
actionPath = sar.remoteAction.Path
|
||||||
} else {
|
} else {
|
||||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||||
}
|
}
|
||||||
|
|
||||||
actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc)
|
actionName, containerActionDir = getContainerActionPaths(step)
|
||||||
return actionDir, actionPath, actionName, containerActionDir
|
return actionDir, actionPath, actionName, containerActionDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,17 +657,17 @@ func shouldRunPostStep(step actionStep) common.Conditional {
|
|||||||
stepResult := stepResults[step.getStepModel().ID]
|
stepResult := stepResults[step.getStepModel().ID]
|
||||||
|
|
||||||
if stepResult == nil {
|
if stepResult == nil {
|
||||||
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel())
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if stepResult.Conclusion == model.StepStatusSkipped {
|
if stepResult.Conclusion == model.StepStatusSkipped {
|
||||||
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel())
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if step.getActionModel() == nil {
|
if step.getActionModel() == nil {
|
||||||
log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s': no action model available", step.getStepModel())
|
log.With("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s': no action model available", step.getStepModel())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
@@ -50,7 +50,9 @@ func TestCompositeActionParity(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("propagates pre failures", func(t *testing.T) {
|
t.Run("propagates pre failures", func(t *testing.T) {
|
||||||
setCloneExecutor(t, func(git.NewGitCloneExecutorInput) common.Executor { return common.NewErrorExecutor(assert.AnError) })
|
setFetch(t, func(_ctx context.Context, _input git.NewGitCloneExecutorInput) (string, string, error) {
|
||||||
|
return "", "", assert.AnError
|
||||||
|
})
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
|
|||||||
+23
-14
@@ -128,11 +128,11 @@ runs:
|
|||||||
|
|
||||||
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
writeFile := func(filename string, data []byte, perm fs.FileMode) error {
|
||||||
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
|
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
|
||||||
assert.Equal(t, fs.FileMode(0o400), perm)
|
assert.Equal(t, fs.FileMode(0o644), perm)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
action, err := readActionImpl(context.Background(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
action, err := readActionImpl(t.Context(), tt.step, "actionDir", "actionPath", readFile, writeFile)
|
||||||
|
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Equal(t, tt.expected, action)
|
assert.Equal(t, tt.expected, action)
|
||||||
@@ -143,6 +143,7 @@ runs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestActionRunner(t *testing.T) {
|
func TestActionRunner(t *testing.T) {
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
table := []struct {
|
table := []struct {
|
||||||
name string
|
name string
|
||||||
step actionStep
|
step actionStep
|
||||||
@@ -177,7 +178,8 @@ func TestActionRunner(t *testing.T) {
|
|||||||
Using: "node16",
|
Using: "node16",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
|
remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"),
|
||||||
},
|
},
|
||||||
expectedEnv: map[string]string{"INPUT_KEY": "default value"},
|
expectedEnv: map[string]string{"INPUT_KEY": "default value"},
|
||||||
},
|
},
|
||||||
@@ -216,7 +218,8 @@ func TestActionRunner(t *testing.T) {
|
|||||||
Using: "node16",
|
Using: "node16",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
|
remoteAction: mustNewRemoteAction(t, "org/repo/path@ref"),
|
||||||
},
|
},
|
||||||
expectedEnv: map[string]string{"STATE_name": "state value"},
|
expectedEnv: map[string]string{"STATE_name": "state value"},
|
||||||
},
|
},
|
||||||
@@ -224,10 +227,11 @@ func TestActionRunner(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range table {
|
for _, tt := range table {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false, true).Return(func(ctx context.Context) error { return nil })
|
containerActionDir := "/var/run/act/actions/" + model.UsesHash("/org/repo/path@ref") + "/"
|
||||||
|
cm.On("CopyDir", containerActionDir, "dir/", false, true).Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
envMatcher := mock.MatchedBy(func(env map[string]string) bool {
|
||||||
for k, v := range tt.expectedEnv {
|
for k, v := range tt.expectedEnv {
|
||||||
@@ -238,7 +242,7 @@ func TestActionRunner(t *testing.T) {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
cm.On("Exec", []string{"node", "--preserve-symlinks-main", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
cm.On("Exec", []string{"node", "--preserve-symlinks-main", containerActionDir + "path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
|
||||||
|
|
||||||
tt.step.getRunContext().JobContainer = cm
|
tt.step.getRunContext().JobContainer = cm
|
||||||
|
|
||||||
@@ -289,7 +293,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
ContainerNewContainer = origContainerNewContainer
|
ContainerNewContainer = origContainerNewContainer
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Name: "job",
|
Name: "job",
|
||||||
Config: &Config{
|
Config: &Config{
|
||||||
@@ -326,7 +330,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
actionDir := t.TempDir()
|
actionDir := t.TempDir()
|
||||||
|
|
||||||
@@ -366,7 +370,10 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|||||||
|
|
||||||
peerAcquired := make(chan struct{})
|
peerAcquired := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
unlock := git.AcquireCloneLock(actionDir)
|
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
|
||||||
|
if !assert.NoError(t, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
close(peerAcquired)
|
close(peerAcquired)
|
||||||
unlock()
|
unlock()
|
||||||
}()
|
}()
|
||||||
@@ -400,7 +407,9 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
|||||||
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||||
actionDir := t.TempDir()
|
actionDir := t.TempDir()
|
||||||
|
|
||||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(actionDir))
|
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
unlockOnce := sync.OnceFunc(unlock)
|
||||||
defer unlockOnce()
|
defer unlockOnce()
|
||||||
|
|
||||||
innerEntered := make(chan struct{})
|
innerEntered := make(chan struct{})
|
||||||
@@ -441,7 +450,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
|||||||
env: map[string]string{},
|
env: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
@@ -613,7 +622,7 @@ func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
|||||||
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
cm.On("Start", true).Return(func(context.Context) error { return nil })
|
||||||
cm.On("Close").Return(func(context.Context) error { return nil })
|
cm.On("Close").Return(func(context.Context) error { return nil })
|
||||||
|
|
||||||
require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
require.NoError(t, execAsDocker(t.Context(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage))
|
||||||
require.NotNil(t, input)
|
require.NotNil(t, input)
|
||||||
assert.Equal(t, tc.wantCmd, input.Cmd)
|
assert.Equal(t, tc.wantCmd, input.Cmd)
|
||||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||||
@@ -628,7 +637,7 @@ func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
|||||||
newStep := func(runs model.ActionRuns) actionStep {
|
newStep := func(runs model.ActionRuns) actionStep {
|
||||||
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
return &stepActionRemote{action: &model.Action{Runs: runs}}
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||||
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx))
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func TestCancelledJobStatusEnablesAlwaysAndCancelledSteps(t *testing.T) {
|
|||||||
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
enabled, err := interp.Evaluate("always()", exprparser.DefaultStatusCheckSuccess)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
assert.Equal(t, true, enabled, "`if: always()` step must run on a cancelled job")
|
||||||
setJobResult(context.Background(), rc, rc, true)
|
setJobResult(t.Context(), rc, rc, true)
|
||||||
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
assert.Equal(t, "cancelled", rc.Run.Job().Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
|||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var ran []string
|
var ran []string
|
||||||
@@ -110,7 +110,7 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
|||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx := newControllableDeadlineContext(context.Background())
|
ctx := newControllableDeadlineContext(t.Context())
|
||||||
|
|
||||||
var ran []string
|
var ran []string
|
||||||
var laterStepCtxErr error
|
var laterStepCtxErr error
|
||||||
@@ -149,7 +149,7 @@ func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
|||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var ran []string
|
var ran []string
|
||||||
@@ -200,7 +200,7 @@ func TestStepsExecutorRunsMainStepsAfterPreFailure(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := newStepsExecutor(rc, preSteps, steps)(context.Background())
|
err := newStepsExecutor(rc, preSteps, steps)(t.Context())
|
||||||
|
|
||||||
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
require.ErrorIs(t, err, assert.AnError, "the pre-step error is propagated")
|
||||||
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
assert.Equal(t, []string{"pre1", "always-step"}, ran, "the main always() step runs after a pre-step failure")
|
||||||
@@ -215,7 +215,7 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
|||||||
rc := createIfTestRunContext(map[string]*model.Job{
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
ctx := common.WithJobErrorContainer(context.Background())
|
ctx := common.WithJobErrorContainer(t.Context())
|
||||||
|
|
||||||
reportStepError(ctx, rc, assert.AnError)
|
reportStepError(ctx, rc, assert.AnError)
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ func TestPreStepFailureAffectsMainStepIfStatus(t *testing.T) {
|
|||||||
// from cancellation (so the steps run) yet still carry a usable error container.
|
// from cancellation (so the steps run) yet still carry a usable error container.
|
||||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||||
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
jobSpan := trace.NewSpanContext(trace.SpanContextConfig{TraceID: trace.TraceID{1}, SpanID: trace.SpanID{1}})
|
||||||
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(context.Background()), jobSpan))
|
cancelled, cancel := context.WithCancel(trace.ContextWithSpanContext(common.WithJobErrorContainer(t.Context()), jobSpan))
|
||||||
cancel()
|
cancel()
|
||||||
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
require.ErrorIs(t, cancelled.Err(), context.Canceled)
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
|||||||
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
// job-error container (via context.WithoutCancel), so the timeout failure and any post-step error
|
||||||
// survive into the post phase and the job is still reported as failed.
|
// survive into the post phase and the job is still reported as failed.
|
||||||
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||||
base := common.WithJobErrorContainer(context.Background())
|
base := common.WithJobErrorContainer(t.Context())
|
||||||
common.SetJobError(base, assert.AnError)
|
common.SetJobError(base, assert.AnError)
|
||||||
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -292,14 +292,14 @@ func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
|||||||
rc := &RunContext{}
|
rc := &RunContext{}
|
||||||
|
|
||||||
// stray read cancellation while the job context is live: ignored, not a failure
|
// stray read cancellation while the job context is live: ignored, not a failure
|
||||||
live := common.WithJobErrorContainer(context.Background())
|
live := common.WithJobErrorContainer(t.Context())
|
||||||
reportStepError(live, rc, context.Canceled)
|
reportStepError(live, rc, context.Canceled)
|
||||||
require.NoError(t, common.JobError(live))
|
require.NoError(t, common.JobError(live))
|
||||||
assert.False(t, rc.jobFailed)
|
assert.False(t, rc.jobFailed)
|
||||||
assert.False(t, rc.jobCancelled)
|
assert.False(t, rc.jobCancelled)
|
||||||
|
|
||||||
// genuine job cancellation: recorded as cancelled, still not a failure
|
// genuine job cancellation: recorded as cancelled, still not a failure
|
||||||
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
cancelled, cancel := context.WithCancel(common.WithJobErrorContainer(t.Context()))
|
||||||
cancel()
|
cancel()
|
||||||
reportStepError(cancelled, rc, context.Canceled)
|
reportStepError(cancelled, rc, context.Canceled)
|
||||||
require.NoError(t, common.JobError(cancelled))
|
require.NoError(t, common.JobError(cancelled))
|
||||||
@@ -307,7 +307,7 @@ func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
|||||||
assert.True(t, rc.jobCancelled)
|
assert.True(t, rc.jobCancelled)
|
||||||
|
|
||||||
// a real error still fails the job
|
// a real error still fails the job
|
||||||
failed := common.WithJobErrorContainer(context.Background())
|
failed := common.WithJobErrorContainer(t.Context())
|
||||||
reportStepError(failed, rc, assert.AnError)
|
reportStepError(failed, rc, assert.AnError)
|
||||||
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
||||||
assert.True(t, rc.jobFailed)
|
assert.True(t, rc.jobFailed)
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ func (rc *RunContext) refuseUnsecureCommand(ctx context.Context, command string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The step executor logs the failure itself, so keep this line's wording distinct.
|
// The step executor logs the failure itself, so keep this line's wording distinct.
|
||||||
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(fmt.Sprintf(
|
||||||
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
"The `%s` command is disabled: it can set the environment of every later step from untrusted output. "+
|
||||||
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||||
command)))
|
command)))
|
||||||
@@ -159,7 +159,7 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
|||||||
if fromCommand {
|
if fromCommand {
|
||||||
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
|
message = "Can't update NODE_OPTIONS environment variable using ::set-env:: command."
|
||||||
}
|
}
|
||||||
common.Logger(ctx).WithField(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
common.Logger(ctx).With(rawOutputField, true).Errorf("##[error]%s", EscapeCommandData(message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||||
|
|||||||
+18
-19
@@ -6,15 +6,14 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -26,8 +25,8 @@ func unsecureRC() *RunContext {
|
|||||||
|
|
||||||
func TestSetEnv(t *testing.T) {
|
func TestSetEnv(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ func TestSetEnv(t *testing.T) {
|
|||||||
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
rc.setEnvFile(ctx, map[string]string{"name": "node_options"}, "--require env.js")
|
||||||
a.NotContains(rc.Env, "NODE_OPTIONS")
|
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||||
a.NotContains(rc.Env, "node_options")
|
a.NotContains(rc.Env, "node_options")
|
||||||
entries := hook.AllEntries()
|
entries := hook.AllRecords()
|
||||||
require.Len(t, entries, 3)
|
require.Len(t, entries, 3)
|
||||||
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
a.Equal("##[error]Can't update NODE_OPTIONS environment variable using ::set-env:: command.", entries[1].Message)
|
||||||
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
a.Equal("##[error]Can't store NODE_OPTIONS output parameter using '$GITHUB_ENV' command.", entries[2].Message)
|
||||||
@@ -45,7 +44,7 @@ func TestSetEnv(t *testing.T) {
|
|||||||
|
|
||||||
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
@@ -68,7 +67,7 @@ func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
|||||||
|
|
||||||
func TestSetOutput(t *testing.T) {
|
func TestSetOutput(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := new(RunContext)
|
rc := new(RunContext)
|
||||||
rc.StepResults = make(map[string]*model.StepResult)
|
rc.StepResults = make(map[string]*model.StepResult)
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
@@ -108,7 +107,7 @@ func TestSetOutput(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddpath(t *testing.T) {
|
func TestAddpath(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
@@ -120,10 +119,10 @@ func TestAddpath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestStopCommands(t *testing.T) {
|
func TestStopCommands(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
|
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
@@ -137,7 +136,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
a.Equal("abcd", rc.Env["x"])
|
a.Equal("abcd", rc.Env["x"])
|
||||||
|
|
||||||
messages := make([]string, 0)
|
messages := make([]string, 0)
|
||||||
for _, entry := range hook.AllEntries() {
|
for _, entry := range hook.AllRecords() {
|
||||||
messages = append(messages, entry.Message)
|
messages = append(messages, entry.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +148,7 @@ func TestStopCommands(t *testing.T) {
|
|||||||
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(context.Background())
|
handler := rc.commandHandler(t.Context())
|
||||||
|
|
||||||
handler("::stop-commands::add-mask\n")
|
handler("::stop-commands::add-mask\n")
|
||||||
handler("::set-env name=x::suppressed\n")
|
handler("::set-env name=x::suppressed\n")
|
||||||
@@ -162,7 +161,7 @@ func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
|||||||
|
|
||||||
func TestAddpathADO(t *testing.T) {
|
func TestAddpathADO(t *testing.T) {
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := unsecureRC()
|
rc := unsecureRC()
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
|
|
||||||
@@ -174,17 +173,17 @@ func TestAddpathADO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAddmask(t *testing.T) {
|
func TestAddmask(t *testing.T) {
|
||||||
logger, hook := test.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
|
|
||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
loggerCtx := common.WithLogger(ctx, logger)
|
loggerCtx := common.WithLogger(ctx, logger)
|
||||||
|
|
||||||
rc := new(RunContext)
|
rc := new(RunContext)
|
||||||
handler := rc.commandHandler(loggerCtx)
|
handler := rc.commandHandler(loggerCtx)
|
||||||
handler("::ADD-MASK::my::secret")
|
handler("::ADD-MASK::my::secret")
|
||||||
|
|
||||||
a.Equal("***", hook.LastEntry().Message)
|
a.Equal("***", hook.LastRecord().Message)
|
||||||
a.Equal([]string{"my::secret"}, rc.Masks)
|
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +230,7 @@ func TestAddmaskUsemask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
re := captureOutput(t, func() {
|
re := captureOutput(t, func() {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
|
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
|
||||||
|
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
@@ -248,7 +247,7 @@ func TestSaveState(t *testing.T) {
|
|||||||
StepResults: map[string]*model.StepResult{},
|
StepResults: map[string]*model.StepResult{},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
handler := rc.commandHandler(ctx)
|
handler := rc.commandHandler(ctx)
|
||||||
handler("::save-state name=state-name::state-value\n")
|
handler("::save-state name=state-name::state-value\n")
|
||||||
@@ -281,7 +280,7 @@ func TestUnsecureCommands(t *testing.T) {
|
|||||||
a := assert.New(t)
|
a := assert.New(t)
|
||||||
rc := &RunContext{Env: tt.jobEnv}
|
rc := &RunContext{Env: tt.jobEnv}
|
||||||
rc.setCurrentStepEnv(tt.stepEnv)
|
rc.setCurrentStepEnv(tt.stepEnv)
|
||||||
handler := rc.commandHandler(context.Background())
|
handler := rc.commandHandler(t.Context())
|
||||||
|
|
||||||
handler("::set-env name=x::valz\n")
|
handler("::set-env name=x::valz\n")
|
||||||
handler("::add-path::/opt/bin\n")
|
handler("::add-path::/opt/bin\n")
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
cerrdefs "github.com/containerd/errdefs"
|
cerrdefs "github.com/containerd/errdefs"
|
||||||
"github.com/moby/moby/client"
|
"github.com/moby/moby/client"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -83,10 +83,10 @@ func TestDockerProxyMountedJob(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
hook := &test.Hook{}
|
hook := logtest.New()
|
||||||
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHandler(ctx, hook)))
|
||||||
var messages []string
|
var messages []string
|
||||||
for _, entry := range hook.AllEntries() {
|
for _, entry := range hook.AllRecords() {
|
||||||
messages = append(messages, strings.TrimSpace(entry.Message))
|
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||||
}
|
}
|
||||||
require.Contains(t, messages, "docker proxy post verified")
|
require.Contains(t, messages, "docker proxy post verified")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ func createRunContext(t *testing.T) *RunContext {
|
|||||||
|
|
||||||
func TestEvaluateRunContext(t *testing.T) {
|
func TestEvaluateRunContext(t *testing.T) {
|
||||||
rc := createRunContext(t)
|
rc := createRunContext(t)
|
||||||
ee := rc.NewExpressionEvaluator(context.Background())
|
ee := rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
tables := []struct {
|
tables := []struct {
|
||||||
in string
|
in string
|
||||||
@@ -142,7 +141,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
|||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.in, func(t *testing.T) {
|
t.Run(table.in, func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
assertObject := assert.New(t)
|
||||||
out, err := ee.evaluate(context.Background(), table.in, exprparser.DefaultStatusCheckNone)
|
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
|
||||||
if table.errMesg == "" {
|
if table.errMesg == "" {
|
||||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assertObject.Equal(table.out, out, table.in)
|
assertObject.Equal(table.out, out, table.in)
|
||||||
@@ -162,7 +161,7 @@ func TestEvaluateStep(t *testing.T) {
|
|||||||
env: map[string]string{"INPUT_FORGED": "leaked"},
|
env: map[string]string{"INPUT_FORGED": "leaked"},
|
||||||
}
|
}
|
||||||
|
|
||||||
ee := rc.NewStepExpressionEvaluator(context.Background(), step)
|
ee := rc.NewStepExpressionEvaluator(t.Context(), step)
|
||||||
|
|
||||||
tables := []struct {
|
tables := []struct {
|
||||||
in string
|
in string
|
||||||
@@ -184,7 +183,7 @@ func TestEvaluateStep(t *testing.T) {
|
|||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.in, func(t *testing.T) {
|
t.Run(table.in, func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
assertObject := assert.New(t)
|
||||||
out, err := ee.evaluate(context.Background(), table.in, exprparser.DefaultStatusCheckNone)
|
out, err := ee.evaluate(t.Context(), table.in, exprparser.DefaultStatusCheckNone)
|
||||||
if table.errMesg == "" {
|
if table.errMesg == "" {
|
||||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assertObject.Equal(table.out, out, table.in)
|
assertObject.Equal(table.out, out, table.in)
|
||||||
@@ -224,7 +223,7 @@ func TestInterpolate(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ee := rc.NewExpressionEvaluator(context.Background())
|
ee := rc.NewExpressionEvaluator(t.Context())
|
||||||
tables := []struct {
|
tables := []struct {
|
||||||
in string
|
in string
|
||||||
out string
|
out string
|
||||||
@@ -270,14 +269,14 @@ func TestInterpolate(t *testing.T) {
|
|||||||
|
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run("interpolate", func(t *testing.T) {
|
t.Run("interpolate", func(t *testing.T) {
|
||||||
out, err := ee.Interpolate(context.Background(), table.in)
|
out, err := ee.Interpolate(t.Context(), table.in)
|
||||||
require.NoError(t, err, table.in)
|
require.NoError(t, err, table.in)
|
||||||
assert.Equal(t, table.out, out, table.in)
|
assert.Equal(t, table.out, out, table.in)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
for _, in := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 1"} {
|
||||||
_, err := ee.Interpolate(context.Background(), in)
|
_, err := ee.Interpolate(t.Context(), in)
|
||||||
assert.Error(t, err, in)
|
assert.Error(t, err, in)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -30,7 +29,7 @@ func requireLinuxDocker(t *testing.T) {
|
|||||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||||
func requireDocker(t *testing.T) {
|
func requireDocker(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cli, err := container.GetDockerClient(ctx)
|
cli, err := container.GetDockerClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||||
|
|||||||
+41
-28
@@ -29,7 +29,10 @@ import (
|
|||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxJobSummaryBytes = 1024 * 1024
|
const (
|
||||||
|
jobCleanupTimeout = time.Minute
|
||||||
|
maxJobSummaryBytes = 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
// jobSummaryTruncationMarker is appended to a summary that exceeded the size limit
|
||||||
// so the rendered output makes the truncation visible instead of silently cutting off.
|
// so the rendered output makes the truncation visible instead of silently cutting off.
|
||||||
@@ -77,7 +80,7 @@ func reportStepError(ctx context.Context, rc *RunContext, err error) {
|
|||||||
// executor can fetch all of them up front.
|
// executor can fetch all of them up front.
|
||||||
type actionPreparer interface {
|
type actionPreparer interface {
|
||||||
prepareActionExecutor() common.Executor
|
prepareActionExecutor() common.Executor
|
||||||
actionDownloadInfo() (reference, sha string, ok bool)
|
actionDownloadInfo() (reference, sha string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
// printPrepareActions downloads every action the job uses before its first step runs and reports
|
||||||
@@ -89,29 +92,30 @@ func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Exec
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
rawLogger.Infof("Prepare all required actions")
|
rawLogger.Infof("Prepare all required actions")
|
||||||
|
|
||||||
for _, preparer := range preparers {
|
for _, preparer := range preparers {
|
||||||
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||||
// No step has run yet, so the failure belongs to the job.
|
// No step has run yet, so the failure belongs to the job.
|
||||||
reportStepError(ctx, rc, err)
|
reportStepError(ctx, rc, err)
|
||||||
return err
|
return setupError{err}
|
||||||
}
|
}
|
||||||
reference, sha, ok := preparer.actionDownloadInfo()
|
reference, sha := preparer.actionDownloadInfo()
|
||||||
if !ok {
|
if sha == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sha == "" {
|
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||||
rawLogger.Infof("Download action repository '%s'", reference)
|
|
||||||
} else {
|
|
||||||
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupError is a failure of the job setup, such as an action that cannot be downloaded.
|
||||||
|
type setupError struct{ error }
|
||||||
|
|
||||||
|
func (e setupError) Unwrap() error { return e.error }
|
||||||
|
|
||||||
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
// printCompleteJobName closes the setup section the way actions/runner ends its "Set up job" step.
|
||||||
func printCompleteJobName(rc *RunContext) common.Executor {
|
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
@@ -123,7 +127,7 @@ func printCompleteJobName(rc *RunContext) common.Executor {
|
|||||||
if name == "" && rc.Run != nil {
|
if name == "" && rc.Run != nil {
|
||||||
name = rc.Run.JobID
|
name = rc.Run.JobID
|
||||||
}
|
}
|
||||||
common.Logger(ctx).WithField(rawOutputField, true).Infof("Complete job name: %s", name)
|
common.Logger(ctx).With(rawOutputField, true).Infof("Complete job name: %s", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,20 +140,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
preparers := make([]actionPreparer, 0)
|
preparers := make([]actionPreparer, 0)
|
||||||
var postExecutor common.Executor
|
var postExecutor common.Executor
|
||||||
|
|
||||||
steps = append(steps, func(ctx context.Context) error {
|
|
||||||
logger := common.Logger(ctx)
|
|
||||||
if len(info.matrix()) > 0 {
|
|
||||||
logger.Infof("Matrix: %v", info.matrix())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
infoSteps := info.steps()
|
infoSteps := info.steps()
|
||||||
|
|
||||||
if len(infoSteps) == 0 {
|
if len(infoSteps) == 0 {
|
||||||
return common.NewDebugExecutor("No steps found")
|
return common.NewDebugExecutor("No steps found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preSteps = append(preSteps, func(ctx context.Context) error {
|
||||||
|
if len(info.matrix()) > 0 {
|
||||||
|
common.Logger(ctx).Infof("Matrix: %v", info.matrix())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
preSteps = append(preSteps, func(ctx context.Context) error {
|
preSteps = append(preSteps, func(ctx context.Context) error {
|
||||||
// Have to be skipped for some Tests
|
// Have to be skipped for some Tests
|
||||||
if rc.Run == nil {
|
if rc.Run == nil {
|
||||||
@@ -228,14 +230,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
|
|
||||||
// The setup section of the job log. The started hook goes first, so what it sets up is
|
// The setup section of the job log. The started hook goes first, so what it sets up is
|
||||||
// in place for the first action download and the first step.
|
// in place for the first action download and the first step.
|
||||||
|
// The pre steps come after it, as steps of their own.
|
||||||
preSteps = append(preSteps, rc.runJobStartedHook)
|
preSteps = append(preSteps, rc.runJobStartedHook)
|
||||||
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
preSteps = append(preSteps, printPrepareActions(rc, preparers))
|
||||||
preSteps = append(preSteps, stepPreSteps...)
|
|
||||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||||
|
preSteps = append(preSteps, stepPreSteps...)
|
||||||
|
|
||||||
// Ahead of the teardown below, while the job environment is still up.
|
// Ahead of the teardown below, while the job environment is still up.
|
||||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
postExecutor = postExecutor.Finally(useJobStageLogger(StageComplete, rc.runJobCompletedHook))
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(useJobStageLogger(StageComplete, func(ctx context.Context) error {
|
||||||
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
// swallowed: a bad output fails this job, it must not abandon the rest of the plan
|
||||||
if err := info.interpolateOutputs()(ctx); err != nil {
|
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||||
reportStepError(ctx, rc, err)
|
reportStepError(ctx, rc, err)
|
||||||
@@ -243,13 +246,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
reportStepError(ctx, rc, err)
|
reportStepError(ctx, rc, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
}))
|
||||||
|
|
||||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
postExecutor = postExecutor.Finally(useJobStageLogger(StageComplete, func(ctx context.Context) error {
|
||||||
jobError := common.JobError(ctx)
|
jobError := common.JobError(ctx)
|
||||||
var err error
|
var err error
|
||||||
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
// always allow 1 min for stopping and removing the runner, even if we were cancelled
|
||||||
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), time.Minute)
|
ctx, cancel := context.WithTimeout(common.WithLogger(context.Background(), common.Logger(ctx)), jobCleanupTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
@@ -262,7 +265,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
|||||||
rc.removeDockerActionImages(ctx)
|
rc.removeDockerActionImages(ctx)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
})
|
}))
|
||||||
|
|
||||||
return common.Executor(func(ctx context.Context) error {
|
return common.Executor(func(ctx context.Context) error {
|
||||||
if err := info.startContainer()(ctx); err != nil {
|
if err := info.startContainer()(ctx); err != nil {
|
||||||
@@ -316,6 +319,9 @@ func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.
|
|||||||
mainExecutor := newMainStepsExecutor(rc, steps)
|
mainExecutor := newMainStepsExecutor(rc, steps)
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
preErr := preExecutor(ctx)
|
preErr := preExecutor(ctx)
|
||||||
|
if errors.As(preErr, new(setupError)) {
|
||||||
|
return preErr // as on GitHub, no step runs after the job setup failed
|
||||||
|
}
|
||||||
mainErr := mainExecutor(ctx)
|
mainErr := mainExecutor(ctx)
|
||||||
if preErr != nil {
|
if preErr != nil {
|
||||||
return preErr
|
return preErr
|
||||||
@@ -409,7 +415,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
|||||||
jobResultMessage = "cancelled"
|
jobResultMessage = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
logger.With("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
|
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
|
||||||
@@ -681,6 +687,13 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
|||||||
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
return entryName == requestedPath || entryName == path.Base(requestedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// useJobStageLogger tags the log entries of a job level executor with the stage they belong to.
|
||||||
|
func useJobStageLogger(stage string, executor common.Executor) common.Executor {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
return executor(common.WithLogger(ctx, common.Logger(ctx).With("stage", stage)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
func useStepLogger(rc *RunContext, stepModel *model.Step, stage stepStage, executor common.Executor) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -44,7 +44,7 @@ func TestJobExecutor(t *testing.T) {
|
|||||||
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
||||||
}
|
}
|
||||||
// These tests are sufficient to only check syntax.
|
// These tests are sufficient to only check syntax.
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(t.Context(), true)
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.workflowPath, func(t *testing.T) {
|
t.Run(table.workflowPath, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -118,7 +118,6 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
|
|||||||
type actionPreparerMock struct {
|
type actionPreparerMock struct {
|
||||||
reference string
|
reference string
|
||||||
sha string
|
sha string
|
||||||
ok bool
|
|
||||||
err error
|
err error
|
||||||
prepared int
|
prepared int
|
||||||
}
|
}
|
||||||
@@ -130,31 +129,25 @@ func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
|
func (apm *actionPreparerMock) actionDownloadInfo() (string, string) {
|
||||||
return apm.reference, apm.sha, apm.ok
|
return apm.reference, apm.sha
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPrintPrepareActionsGolden(t *testing.T) {
|
func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
logger := log.New()
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||||
logger.SetOutput(buf)
|
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||||
logger.SetLevel(log.InfoLevel)
|
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
|
||||||
|
|
||||||
preparers := []actionPreparer{
|
preparers := []actionPreparer{
|
||||||
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
|
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"},
|
||||||
// A resolved commit is best effort; the ref alone is reported when it is unknown.
|
|
||||||
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
|
|
||||||
// A step that downloads nothing, such as the checkout of the workflow's own repository.
|
// A step that downloads nothing, such as the checkout of the workflow's own repository.
|
||||||
&actionPreparerMock{ok: false},
|
&actionPreparerMock{},
|
||||||
}
|
}
|
||||||
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
|
require.NoError(t, printPrepareActions(&RunContext{}, preparers)(ctx))
|
||||||
|
|
||||||
want := strings.Join([]string{
|
want := strings.Join([]string{
|
||||||
"[j1] | Prepare all required actions",
|
"[j1] | Prepare all required actions",
|
||||||
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
||||||
"[j1] | Download action repository 'actions/setup-go@v6'",
|
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
assert.Equal(t, want, buf.String())
|
assert.Equal(t, want, buf.String())
|
||||||
@@ -162,10 +155,8 @@ func TestPrintPrepareActionsGolden(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
logger := log.New()
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||||
logger.SetOutput(buf)
|
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
|
||||||
|
|
||||||
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
||||||
|
|
||||||
@@ -173,12 +164,12 @@ func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||||
logger, _ := logrustest.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
|
ctx := common.WithJobErrorContainer(common.WithLogger(t.Context(), logger.With("job", "j1")))
|
||||||
|
|
||||||
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
||||||
rc := &RunContext{}
|
rc := &RunContext{}
|
||||||
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
|
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", sha: "sha"}
|
||||||
|
|
||||||
err := printPrepareActions(rc, []actionPreparer{
|
err := printPrepareActions(rc, []actionPreparer{
|
||||||
&actionPreparerMock{err: downloadErr},
|
&actionPreparerMock{err: downloadErr},
|
||||||
@@ -192,6 +183,27 @@ func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
|||||||
assert.Zero(t, remaining.prepared)
|
assert.Zero(t, remaining.prepared)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewStepsExecutorSkipsStepsAfterSetupFailure(t *testing.T) {
|
||||||
|
for name, tt := range map[string]struct {
|
||||||
|
preErr error
|
||||||
|
mainRan bool
|
||||||
|
}{
|
||||||
|
"setup failure": {preErr: setupError{assert.AnError}},
|
||||||
|
"pre step failure": {preErr: assert.AnError, mainRan: true}, // always() steps still run, as on GitHub
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logging.New(logtest.New())))
|
||||||
|
mainRan := false
|
||||||
|
err := newStepsExecutor(&RunContext{},
|
||||||
|
[]common.Executor{func(context.Context) error { return tt.preErr }},
|
||||||
|
[]common.Executor{func(context.Context) error { mainRan = true; return nil }},
|
||||||
|
)(ctx)
|
||||||
|
require.ErrorIs(t, err, assert.AnError)
|
||||||
|
assert.Equal(t, tt.mainRan, mainRan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrintCompleteJobName(t *testing.T) {
|
func TestPrintCompleteJobName(t *testing.T) {
|
||||||
for name, tt := range map[string]struct {
|
for name, tt := range map[string]struct {
|
||||||
rc *RunContext
|
rc *RunContext
|
||||||
@@ -203,10 +215,8 @@ func TestPrintCompleteJobName(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
logger := log.New()
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||||
logger.SetOutput(buf)
|
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
|
||||||
|
|
||||||
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
|
require.NoError(t, printCompleteJobName(tt.rc)(ctx))
|
||||||
|
|
||||||
@@ -225,7 +235,7 @@ type actionStepMock struct {
|
|||||||
// every action is downloaded before any step runs, and the job name closes the section. A pre
|
// every action is downloaded before any step runs, and the job name closes the section. A pre
|
||||||
// step that downloaded its own action would leave the log interleaved with the downloads.
|
// step that downloaded its own action would leave the log interleaved with the downloads.
|
||||||
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||||
ctx := common.WithJobErrorContainer(context.Background())
|
ctx := common.WithJobErrorContainer(t.Context())
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
@@ -253,11 +263,12 @@ func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
|||||||
|
|
||||||
for _, stepModel := range steps {
|
for _, stepModel := range steps {
|
||||||
sm := &stepMock{}
|
sm := &stepMock{}
|
||||||
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, ok: true}
|
apm := &actionPreparerMock{reference: "actions/checkout@v" + stepModel.ID, sha: "sha" + stepModel.ID}
|
||||||
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
sfm.On("newStep", stepModel, rc).Return(&actionStepMock{stepMock: sm, actionPreparerMock: apm}, nil)
|
||||||
|
|
||||||
sm.On("pre").Return(func(context.Context) error {
|
sm.On("pre").Return(func(ctx context.Context) error {
|
||||||
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
||||||
|
common.Logger(ctx).Infof("Run Pre %s", stepModel.ID)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
sm.On("main").Return(func(context.Context) error {
|
sm.On("main").Return(func(context.Context) error {
|
||||||
@@ -269,24 +280,31 @@ func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
|||||||
defer sm.AssertExpectations(t)
|
defer sm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger, hook := logrustest.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
|
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.With("job", "test")))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
assert.Equal(t, []string{"pre1", "pre2", "step1", "step2"}, executorOrder)
|
||||||
|
|
||||||
|
// the setup is logged ahead of the pre steps and the cleanup is tagged, so the reporter can show both as steps of their own
|
||||||
setup := make([]string, 0)
|
setup := make([]string, 0)
|
||||||
for _, entry := range hook.AllEntries() {
|
for _, entry := range hook.AllRecords() {
|
||||||
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
if strings.HasPrefix(entry.Message, "Prepare all required actions") || strings.HasPrefix(entry.Message, "Download action") ||
|
||||||
strings.HasPrefix(entry.Message, "Complete job name") {
|
strings.HasPrefix(entry.Message, "Complete job name") || strings.HasPrefix(entry.Message, "Run Pre") {
|
||||||
setup = append(setup, entry.Message)
|
setup = append(setup, entry.Message)
|
||||||
}
|
}
|
||||||
|
if _, ok := entry.Attr("jobResult"); ok || strings.HasPrefix(entry.Message, "Cleaning up container") {
|
||||||
|
stage, _ := entry.Attr("stage")
|
||||||
|
assert.Equal(t, StageComplete, stage, entry.Message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert.Equal(t, []string{
|
assert.Equal(t, []string{
|
||||||
"Prepare all required actions",
|
"Prepare all required actions",
|
||||||
"Download action repository 'actions/checkout@v1'",
|
"Download action repository 'actions/checkout@v1' (SHA:sha1)",
|
||||||
"Download action repository 'actions/checkout@v2'",
|
"Download action repository 'actions/checkout@v2' (SHA:sha2)",
|
||||||
"Complete job name: test",
|
"Complete job name: test",
|
||||||
|
"Run Pre 1",
|
||||||
|
"Run Pre 2",
|
||||||
}, setup)
|
}, setup)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +480,7 @@ func TestNewJobExecutor(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(t.Context()))
|
||||||
defer cancel()
|
defer cancel()
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
@@ -610,7 +628,7 @@ func (ctx *controllableDeadlineContext) expire() {
|
|||||||
// still run against a fresh, non-expired context, and the job must still be
|
// still run against a fresh, non-expired context, and the job must still be
|
||||||
// reported as failed.
|
// reported as failed.
|
||||||
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
||||||
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(context.Background()))
|
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(t.Context()))
|
||||||
|
|
||||||
jim := &jobInfoMock{}
|
jim := &jobInfoMock{}
|
||||||
sfm := &stepFactoryMock{}
|
sfm := &stepFactoryMock{}
|
||||||
@@ -691,7 +709,7 @@ func TestSetJobResultMatrixContinueOnError(t *testing.T) {
|
|||||||
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}}
|
return &model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: wf, JobID: "job1"}}}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
// fail drives a single matrix combination through the failure path; each
|
// fail drives a single matrix combination through the failure path; each
|
||||||
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job.
|
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job.
|
||||||
@@ -786,7 +804,7 @@ func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
||||||
@@ -830,7 +848,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
defer close(release)
|
defer close(release)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
||||||
@@ -874,7 +892,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
||||||
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
||||||
@@ -922,7 +940,7 @@ func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
|
|||||||
"GITEA_RUN_ID": "12",
|
"GITEA_RUN_ID": "12",
|
||||||
}, &containerMock{}, 1)
|
}, &containerMock{}, 1)
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
tryUploadJobSummary(t.Context(), rc)
|
||||||
|
|
||||||
assert.Equal(t, 0, requests)
|
assert.Equal(t, 0, requests)
|
||||||
}
|
}
|
||||||
@@ -942,7 +960,7 @@ func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
|
|||||||
"GITEA_RUN_ID": "12",
|
"GITEA_RUN_ID": "12",
|
||||||
}, &containerMock{}, 1)
|
}, &containerMock{}, 1)
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
tryUploadJobSummary(t.Context(), rc)
|
||||||
|
|
||||||
assert.Equal(t, 0, requests)
|
assert.Equal(t, 0, requests)
|
||||||
}
|
}
|
||||||
@@ -955,7 +973,7 @@ func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t,
|
io.NopCloser(bytes.NewReader(tarArchive(t,
|
||||||
@@ -974,8 +992,8 @@ func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
||||||
logger, hook := logrustest.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
content := strings.Repeat("a", 300)
|
content := strings.Repeat("a", 300)
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||||
@@ -992,15 +1010,15 @@ func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
|||||||
assert.LessOrEqual(t, len(body), maxBytes)
|
assert.LessOrEqual(t, len(body), maxBytes)
|
||||||
keep := maxBytes - len(jobSummaryTruncationMarker)
|
keep := maxBytes - len(jobSummaryTruncationMarker)
|
||||||
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
||||||
if assert.Len(t, hook.Entries, 1) {
|
if assert.Len(t, hook.AllRecords(), 1) {
|
||||||
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
assert.Contains(t, hook.AllRecords()[0].Message, "job summary truncated")
|
||||||
}
|
}
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
||||||
logger, hook := logrustest.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(context.Background(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
cm := &containerMock{}
|
cm := &containerMock{}
|
||||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
||||||
@@ -1012,7 +1030,7 @@ func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *test
|
|||||||
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, []byte("abc"), body)
|
assert.Equal(t, []byte("abc"), body)
|
||||||
assert.Empty(t, hook.Entries)
|
assert.Empty(t, hook.AllRecords())
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1088,7 @@ func makeTestRC(t *testing.T, jobYAML string) *RunContext {
|
|||||||
Name: "workflow1",
|
Name: "workflow1",
|
||||||
Jobs: map[string]*model.Job{"job1": job},
|
Jobs: map[string]*model.Job{"job1": job},
|
||||||
}, nil)
|
}, nil)
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
return rc
|
return rc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,7 +1106,7 @@ func TestApplyJobTimeout(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
rc := makeTestRC(t, tc.yaml)
|
rc := makeTestRC(t, tc.yaml)
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, hasDeadline := newCtx.Deadline()
|
_, hasDeadline := newCtx.Deadline()
|
||||||
@@ -1112,7 +1130,7 @@ func TestEvaluateJobContinueOnError(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
rc := makeTestRC(t, tc.yaml)
|
rc := makeTestRC(t, tc.yaml)
|
||||||
got := evaluateJobContinueOnError(context.Background(), rc, rc.Run.Job())
|
got := evaluateJobContinueOnError(t.Context(), rc, rc.Run.Job())
|
||||||
assert.Equal(t, tc.want, got)
|
assert.Equal(t, tc.want, got)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1177,7 +1195,7 @@ func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
|
|||||||
rc.Config.ExtraMasks = []string{"pr0xypw"}
|
rc.Config.ExtraMasks = []string{"pr0xypw"}
|
||||||
rc.Masks = []string{"runtime-added"}
|
rc.Masks = []string{"runtime-added"}
|
||||||
|
|
||||||
tryUploadJobSummary(context.Background(), rc)
|
tryUploadJobSummary(t.Context(), rc)
|
||||||
|
|
||||||
assert.Equal(t, "deployed true with *** and *** via ***", got)
|
assert.Equal(t, "deployed true with *** and *** via ***", got)
|
||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd, shell := hookCommand(hookPath)
|
cmd, shell := hookCommand(hookPath)
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/sirupsen/logrus/hooks/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -64,8 +65,8 @@ func newHookRunContext(jobContainer *hookContainer, config *Config) (*RunContext
|
|||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
|
||||||
JobContainer: jobContainer,
|
JobContainer: jobContainer,
|
||||||
}
|
}
|
||||||
logger, _ := test.NewNullLogger()
|
logger := logging.New(logtest.New())
|
||||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.With("test", true)))
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
return rc, ctx
|
return rc, ctx
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-99
@@ -19,8 +19,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +35,16 @@ const (
|
|||||||
gray = 37
|
gray = 37
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The "stage" of the log entries of a step, and of the job level entries that finalize the job after its steps.
|
||||||
|
StagePre = "Pre"
|
||||||
|
StageMain = "Main"
|
||||||
|
StagePost = "Post"
|
||||||
|
StageComplete = "Complete"
|
||||||
|
// StepStartedField marks the log entry of a step that is about to run, unlike one that is skipped.
|
||||||
|
StepStartedField = "stepStarted"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
rawOutputField = "raw_output"
|
rawOutputField = "raw_output"
|
||||||
scriptLineCyanField = "script_line_cyan"
|
scriptLineCyanField = "script_line_cyan"
|
||||||
@@ -73,8 +83,9 @@ func WithMasks(ctx context.Context, masks *[]string) context.Context {
|
|||||||
return context.WithValue(ctx, masksContextKeyVal, masks)
|
return context.WithValue(ctx, masksContextKeyVal, masks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobLoggerFactory supplies the handler a job's own log output goes to, in place of stdout.
|
||||||
type JobLoggerFactory interface {
|
type JobLoggerFactory interface {
|
||||||
WithJobLogger() *logrus.Logger
|
WithJobLogger() logging.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type jobLoggerFactoryContextKey string
|
type jobLoggerFactoryContextKey string
|
||||||
@@ -89,84 +100,70 @@ func WithJobLoggerFactory(ctx context.Context, factory JobLoggerFactory) context
|
|||||||
func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, masks *[]string, matrix map[string]any) context.Context {
|
func WithJobLogger(ctx context.Context, jobID, jobName string, config *Config, masks *[]string, matrix map[string]any) context.Context {
|
||||||
ctx = WithMasks(ctx, masks)
|
ctx = WithMasks(ctx, masks)
|
||||||
|
|
||||||
var logger *logrus.Logger
|
var output logging.Handler
|
||||||
if jobLoggerFactory, ok := ctx.Value(jobLoggerFactoryContextKeyVal).(JobLoggerFactory); ok && jobLoggerFactory != nil {
|
if jobLoggerFactory, ok := ctx.Value(jobLoggerFactoryContextKeyVal).(JobLoggerFactory); ok && jobLoggerFactory != nil {
|
||||||
logger = jobLoggerFactory.WithJobLogger()
|
output = jobLoggerFactory.WithJobLogger()
|
||||||
|
} else if config.JSONLogger {
|
||||||
|
output = newJobLogJSONHandler(os.Stdout)
|
||||||
} else {
|
} else {
|
||||||
var formatter logrus.Formatter
|
mux.Lock()
|
||||||
if config.JSONLogger {
|
nextColor++
|
||||||
formatter = &logrus.JSONFormatter{}
|
color := colors[nextColor%len(colors)]
|
||||||
} else {
|
mux.Unlock()
|
||||||
mux.Lock()
|
output = newJobLogHandler(os.Stdout, color)
|
||||||
defer mux.Unlock()
|
|
||||||
nextColor++
|
|
||||||
formatter = &jobLogFormatter{color: colors[nextColor%len(colors)]}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger = logrus.New()
|
|
||||||
logger.SetOutput(os.Stdout)
|
|
||||||
logger.SetLevel(logrus.GetLevel())
|
|
||||||
logger.SetFormatter(formatter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
{ // Adapt to Gitea
|
var handlers []logging.Handler
|
||||||
if hook := common.LoggerHook(ctx); hook != nil {
|
if h := common.LoggerHandler(ctx); h != nil { // Adapt to Gitea
|
||||||
logger.AddHook(hook)
|
handlers = append(handlers, h)
|
||||||
}
|
|
||||||
if config.JobLoggerLevel != nil {
|
|
||||||
logger.SetLevel(*config.JobLoggerLevel)
|
|
||||||
} else {
|
|
||||||
logger.SetLevel(logrus.TraceLevel)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
handlers = append(handlers, &maskedHandler{
|
||||||
logger.SetFormatter(&maskedFormatter{
|
Handler: output,
|
||||||
Formatter: logger.Formatter,
|
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
||||||
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
|
||||||
})
|
})
|
||||||
rtn := logger.WithFields(logrus.Fields{
|
|
||||||
"job": jobName,
|
level := logging.LevelTrace
|
||||||
"jobID": jobID,
|
if config.JobLoggerLevel != nil {
|
||||||
"dryrun": common.Dryrun(ctx),
|
level = *config.JobLoggerLevel
|
||||||
"matrix": matrix,
|
}
|
||||||
}).WithContext(ctx)
|
|
||||||
|
rtn := logging.New(logging.NewLevelHandler(logging.NewMultiHandler(handlers...), level)).With(
|
||||||
|
"job", jobName,
|
||||||
|
"jobID", jobID,
|
||||||
|
"dryrun", common.Dryrun(ctx),
|
||||||
|
"matrix", matrix,
|
||||||
|
).WithContext(ctx)
|
||||||
|
|
||||||
return common.WithLogger(ctx, rtn)
|
return common.WithLogger(ctx, rtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithCompositeLogger(ctx context.Context, masks *[]string) context.Context {
|
func WithCompositeLogger(ctx context.Context, masks *[]string) context.Context {
|
||||||
ctx = WithMasks(ctx, masks)
|
ctx = WithMasks(ctx, masks)
|
||||||
return common.WithLogger(ctx, common.Logger(ctx).WithFields(logrus.Fields{}).WithContext(ctx))
|
return common.WithLogger(ctx, common.Logger(ctx).WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithCompositeStepLogger(ctx context.Context, stepID string) context.Context {
|
func WithCompositeStepLogger(ctx context.Context, stepID string) context.Context {
|
||||||
val := common.Logger(ctx)
|
stepIDs := append(slices.Clone(common.StepIDs(ctx)), stepID)
|
||||||
stepIDs := make([]string, 0)
|
ctx = common.WithStepIDs(ctx, stepIDs)
|
||||||
|
|
||||||
if logger, ok := val.(*logrus.Entry); ok {
|
return common.WithLogger(ctx, common.Logger(ctx).With("stepID", stepIDs).WithContext(ctx))
|
||||||
if oldStepIDs, ok := logger.Data["stepID"].([]string); ok {
|
|
||||||
stepIDs = append(stepIDs, oldStepIDs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stepIDs = append(stepIDs, stepID)
|
|
||||||
|
|
||||||
return common.WithLogger(ctx, common.Logger(ctx).WithFields(logrus.Fields{
|
|
||||||
"stepID": stepIDs,
|
|
||||||
}).WithContext(ctx))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
|
func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
|
||||||
rtn := common.Logger(ctx).WithFields(logrus.Fields{
|
stepIDs := []string{stepID}
|
||||||
"stepNumber": stepNumber,
|
ctx = common.WithStepIDs(ctx, stepIDs)
|
||||||
"step": stepName,
|
|
||||||
"stepID": []string{stepID},
|
rtn := common.Logger(ctx).With(
|
||||||
"stage": stageName,
|
"stepNumber", stepNumber,
|
||||||
})
|
"step", stepName,
|
||||||
|
"stepID", stepIDs,
|
||||||
|
"stage", stageName,
|
||||||
|
)
|
||||||
return common.WithLogger(ctx, rtn)
|
return common.WithLogger(ctx, rtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
type entryProcessor func(entry *logrus.Entry) *logrus.Entry
|
// entryProcessor rewrites a record before the output handler sees it.
|
||||||
|
type entryProcessor func(ctx context.Context, r logging.Record) logging.Record
|
||||||
|
|
||||||
var secretValueEncoders = []func(string) string{
|
var secretValueEncoders = []func(string) string{
|
||||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||||
@@ -321,16 +318,16 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
|||||||
replacer *strings.Replacer
|
replacer *strings.Replacer
|
||||||
)
|
)
|
||||||
|
|
||||||
return func(entry *logrus.Entry) *logrus.Entry {
|
return func(ctx context.Context, r logging.Record) logging.Record {
|
||||||
if insecureSecrets {
|
if insecureSecrets {
|
||||||
return entry
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
masks := Masks(entry.Context)
|
masks := Masks(ctx)
|
||||||
|
|
||||||
if len(*masks) == 0 {
|
if len(*masks) == 0 {
|
||||||
entry.Message = defReplacer.Replace(entry.Message)
|
r.Message = defReplacer.Replace(r.Message)
|
||||||
return entry
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -349,89 +346,129 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
|||||||
cmasker := replacer
|
cmasker := replacer
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
entry.Message = cmasker.Replace(entry.Message)
|
r.Message = cmasker.Replace(r.Message)
|
||||||
|
|
||||||
return entry
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type maskedFormatter struct {
|
type maskedHandler struct {
|
||||||
logrus.Formatter
|
logging.Handler
|
||||||
masker entryProcessor
|
masker entryProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *maskedFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
func (m *maskedHandler) Handle(ctx context.Context, r logging.Record) error {
|
||||||
return f.Formatter.Format(f.masker(entry))
|
return m.Handler.Handle(ctx, m.masker(ctx, r))
|
||||||
}
|
}
|
||||||
|
|
||||||
type jobLogFormatter struct {
|
func (m *maskedHandler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
||||||
|
return &maskedHandler{Handler: m.Handler.WithAttrs(attrs), masker: m.masker}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobLogHandler writes a job's log to w, one record per line in the shape format gives it.
|
||||||
|
type jobLogHandler struct {
|
||||||
|
w io.Writer
|
||||||
|
mu *sync.Mutex // shared by every handler derived with WithAttrs, as they share w
|
||||||
|
attrs []logging.Attr
|
||||||
|
format func(logging.Record) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJobLogHandler writes readable text.
|
||||||
|
func newJobLogHandler(w io.Writer, color int) *jobLogHandler {
|
||||||
|
return &jobLogHandler{w: w, mu: &sync.Mutex{}, format: textFormatter{w: w, color: color}.format}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *jobLogHandler) Enabled(context.Context, logging.Level) bool { return true }
|
||||||
|
|
||||||
|
func (h *jobLogHandler) WithAttrs(attrs []logging.Attr) logging.Handler {
|
||||||
|
c := *h
|
||||||
|
c.attrs = slices.Concat(h.attrs, attrs)
|
||||||
|
return &c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *jobLogHandler) Handle(_ context.Context, r logging.Record) error {
|
||||||
|
r.Attrs = slices.Concat(h.attrs, r.Attrs)
|
||||||
|
line, err := h.format(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
_, err = h.w.Write(line)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type textFormatter struct {
|
||||||
|
w io.Writer
|
||||||
color int
|
color int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
func (f textFormatter) format(r logging.Record) ([]byte, error) {
|
||||||
b := &bytes.Buffer{}
|
b := &bytes.Buffer{}
|
||||||
|
|
||||||
// the web renderer decodes command data, so this local view has to as well
|
// the web renderer decodes command data, so this local view has to as well
|
||||||
if _, _, _, ok := tryParseRawActionCommand(entry.Message + "\n"); ok {
|
if _, _, _, ok := tryParseRawActionCommand(r.Message + "\n"); ok {
|
||||||
entry.Message = UnescapeCommandData(entry.Message)
|
r.Message = UnescapeCommandData(r.Message)
|
||||||
}
|
}
|
||||||
|
r.Message = strings.TrimSuffix(r.Message, "\n")
|
||||||
|
|
||||||
if f.isColored(entry) {
|
if f.isColored() {
|
||||||
f.printColored(b, entry)
|
f.printColored(b, r)
|
||||||
} else {
|
} else {
|
||||||
f.print(b, entry)
|
f.print(b, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.WriteByte('\n')
|
b.WriteByte('\n')
|
||||||
return b.Bytes(), nil
|
return b.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
func attrIsTrue(r logging.Record, key string) bool {
|
||||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
v, _ := r.Attr(key)
|
||||||
|
return v == true
|
||||||
|
}
|
||||||
|
|
||||||
job := entry.Data["job"]
|
func (f textFormatter) printColored(b *bytes.Buffer, r logging.Record) {
|
||||||
|
job, _ := r.Attr("job")
|
||||||
|
|
||||||
debugFlag := ""
|
debugFlag := ""
|
||||||
if entry.Level == logrus.DebugLevel {
|
if r.Level == logging.LevelDebug {
|
||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case entry.Data[rawOutputField] == true:
|
case attrIsTrue(r, rawOutputField):
|
||||||
if entry.Data[scriptLineCyanField] == true {
|
if attrIsTrue(r, scriptLineCyanField) {
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, r.Message)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m %s", f.color, r.Message)
|
||||||
}
|
}
|
||||||
case entry.Data["dryrun"] == true:
|
case attrIsTrue(r, "dryrun"):
|
||||||
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "\x1b[1m\x1b[%dm\x1b[7m*DRYRUN*\x1b[0m \x1b[%dm[%s] \x1b[0m%s%s", gray, f.color, job, debugFlag, r.Message)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(b, "\x1b[%dm[%s] \x1b[0m%s%s", f.color, job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "\x1b[%dm[%s] \x1b[0m%s%s", f.color, job, debugFlag, r.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *jobLogFormatter) print(b *bytes.Buffer, entry *logrus.Entry) {
|
func (f textFormatter) print(b *bytes.Buffer, r logging.Record) {
|
||||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
job, _ := r.Attr("job")
|
||||||
|
|
||||||
job := entry.Data["job"]
|
|
||||||
|
|
||||||
debugFlag := ""
|
debugFlag := ""
|
||||||
if entry.Level == logrus.DebugLevel {
|
if r.Level == logging.LevelDebug {
|
||||||
debugFlag = "[DEBUG] "
|
debugFlag = "[DEBUG] "
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case entry.Data[rawOutputField] == true:
|
case attrIsTrue(r, rawOutputField):
|
||||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
fmt.Fprintf(b, "[%s] | %s", job, r.Message)
|
||||||
case entry.Data["dryrun"] == true:
|
case attrIsTrue(r, "dryrun"):
|
||||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, r.Message)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, entry.Message)
|
fmt.Fprintf(b, "[%s] %s%s", job, debugFlag, r.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *jobLogFormatter) isColored(entry *logrus.Entry) bool {
|
func (f textFormatter) isColored() bool {
|
||||||
isColored := checkIfTerminal(entry.Logger.Out)
|
isColored := checkIfTerminal(f.w)
|
||||||
|
|
||||||
if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" {
|
if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" {
|
||||||
isColored = true
|
isColored = true
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newJobLogJSONHandler writes one JSON object per record, in the shape of logrus's JSONFormatter.
|
||||||
|
func newJobLogJSONHandler(w io.Writer) *jobLogHandler {
|
||||||
|
return &jobLogHandler{w: w, mu: &sync.Mutex{}, format: formatJSON}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatJSON(r logging.Record) ([]byte, error) {
|
||||||
|
data := make(map[string]any, len(r.Attrs)+3)
|
||||||
|
for _, a := range r.Attrs {
|
||||||
|
switch v := a.Value.(type) {
|
||||||
|
case error:
|
||||||
|
data[a.Key] = v.Error() // an error marshals to {}
|
||||||
|
default:
|
||||||
|
data[a.Key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"time", "msg", "level"} {
|
||||||
|
if v, ok := data[key]; ok { // keep the fixed keys from being overwritten
|
||||||
|
data["fields."+key] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["time"] = r.Time.Format(time.RFC3339)
|
||||||
|
data["msg"] = r.Message
|
||||||
|
data["level"] = jsonLevel(r.Level)
|
||||||
|
|
||||||
|
line, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal fields to JSON, %w", err)
|
||||||
|
}
|
||||||
|
return append(line, '\n'), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonLevel(l logging.Level) string {
|
||||||
|
if l.String() == "warn" {
|
||||||
|
return "warning" // what logrus calls it
|
||||||
|
}
|
||||||
|
return l.String()
|
||||||
|
}
|
||||||
+64
-15
@@ -4,13 +4,19 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -49,10 +55,7 @@ func TestValueMasker(t *testing.T) {
|
|||||||
ctx := WithMasks(t.Context(), &entry.masks)
|
ctx := WithMasks(t.Context(), &entry.masks)
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
||||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||||
lentry := masker(&logrus.Entry{
|
lentry := masker(ctx, logging.Record{Message: line})
|
||||||
Context: ctx,
|
|
||||||
Message: line,
|
|
||||||
})
|
|
||||||
for _, line := range entry.disallowed {
|
for _, line := range entry.disallowed {
|
||||||
assert.NotContains(t, lentry.Message, line)
|
assert.NotContains(t, lentry.Message, line)
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,7 @@ func TestValueMaskerEncodedSecrets(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": tc.secret}))
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: strings.Join(tc.encoded, " ")})
|
entry := masker(t.Context(), logging.Record{Message: strings.Join(tc.encoded, " ")})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
for _, disallowed := range tc.encoded {
|
for _, disallowed := range tc.encoded {
|
||||||
@@ -105,7 +108,7 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
|||||||
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: `{"t":"` + tc.form + `"}`})
|
entry := masker(t.Context(), logging.Record{Message: `{"t":"` + tc.form + `"}`})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
assert.NotContains(t, entry.Message, tc.form)
|
assert.NotContains(t, entry.Message, tc.form)
|
||||||
@@ -118,7 +121,7 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
|||||||
func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
||||||
masker := valueMasker(false, (&Config{ExtraMasks: []string{"pr0xypw"}}).maskers())
|
masker := valueMasker(false, (&Config{ExtraMasks: []string{"pr0xypw"}}).maskers())
|
||||||
|
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "proxy is http://user:pr0xypw@proxy:3128"})
|
entry := masker(t.Context(), logging.Record{Message: "proxy is http://user:pr0xypw@proxy:3128"})
|
||||||
|
|
||||||
assert.Equal(t, "proxy is http://user:***@proxy:3128", entry.Message)
|
assert.Equal(t, "proxy is http://user:***@proxy:3128", entry.Message)
|
||||||
}
|
}
|
||||||
@@ -137,7 +140,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
|||||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "encoded: ***"},
|
||||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("first")), "encoded: ***"},
|
||||||
} {
|
} {
|
||||||
entry := masker(&logrus.Entry{Context: WithMasks(t.Context(), &masks), Message: tc.line})
|
entry := masker(WithMasks(t.Context(), &masks), logging.Record{Message: tc.line})
|
||||||
assert.Equal(t, tc.want, entry.Message, tc.line)
|
assert.Equal(t, tc.want, entry.Message, tc.line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +157,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||||
t.Run(prefix, func(t *testing.T) {
|
t.Run(prefix, func(t *testing.T) {
|
||||||
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
|
encoded := base64.StdEncoding.EncodeToString([]byte(prefix + secret))
|
||||||
entry := masker(&logrus.Entry{Context: t.Context(), Message: "Authorization: Basic " + encoded})
|
entry := masker(t.Context(), logging.Record{Message: "Authorization: Basic " + encoded})
|
||||||
|
|
||||||
assert.Contains(t, entry.Message, "***")
|
assert.Contains(t, entry.Message, "***")
|
||||||
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
assert.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||||
@@ -172,7 +175,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
|||||||
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": "secret-token"}))
|
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": "secret-token"}))
|
||||||
mask := func(masks *[]string, message string) string {
|
mask := func(masks *[]string, message string) string {
|
||||||
return masker(&logrus.Entry{Context: WithMasks(t.Context(), masks), Message: message}).Message
|
return masker(WithMasks(t.Context(), masks), logging.Record{Message: message}).Message
|
||||||
}
|
}
|
||||||
|
|
||||||
job := []string{"first mask"}
|
job := []string{"first mask"}
|
||||||
@@ -206,15 +209,61 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||||
logger := logrus.New()
|
|
||||||
logger.Out = io.Discard
|
|
||||||
format := func(message string) string {
|
format := func(message string) string {
|
||||||
out, err := (&jobLogFormatter{}).Format(&logrus.Entry{Logger: logger, Message: message, Data: logrus.Fields{rawOutputField: true}})
|
line, err := textFormatter{w: io.Discard}.format(logging.Record{Message: message, Attrs: []logging.Attr{{Key: rawOutputField, Value: true}}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return string(out)
|
return string(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
|
assert.Contains(t, format("##[error]deploy 50%25 traffic"), "##[error]deploy 50% traffic")
|
||||||
// a plain line is not command data and keeps its literal escapes
|
// a plain line is not command data and keeps its literal escapes
|
||||||
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
|
assert.Contains(t, format("progress 50%25 done"), "progress 50%25 done")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type factoryFunc func() logging.Handler
|
||||||
|
|
||||||
|
func (f factoryFunc) WithJobLogger() logging.Handler { return f() }
|
||||||
|
|
||||||
|
func TestWithJobLoggerFansOutAndMasksOnlyOutput(t *testing.T) {
|
||||||
|
output, hook := logtest.NewWithLevel(logging.LevelTrace), logtest.NewWithLevel(logging.LevelTrace)
|
||||||
|
ctx := WithJobLoggerFactory(t.Context(), factoryFunc(func() logging.Handler { return output }))
|
||||||
|
ctx = common.WithLoggerHandler(ctx, hook)
|
||||||
|
level := logging.LevelInfo
|
||||||
|
cfg := &Config{Secrets: map[string]string{"TOKEN": "s3cr3t-value"}, JobLoggerLevel: &level}
|
||||||
|
|
||||||
|
ctx = WithJobLogger(ctx, "j1", "build", cfg, &[]string{}, nil)
|
||||||
|
ctx = withStepLogger(ctx, 2, "s2", "step two", "Main")
|
||||||
|
ctx = WithCompositeStepLogger(ctx, "inner")
|
||||||
|
common.Logger(ctx).Infof("token is %s", "s3cr3t-value")
|
||||||
|
common.Logger(ctx).Debugf("below the job logger level")
|
||||||
|
|
||||||
|
// the reporter masks for itself, so its record keeps the original
|
||||||
|
require.Len(t, hook.AllRecords(), 1)
|
||||||
|
assert.Equal(t, "token is s3cr3t-value", hook.LastRecord().Message)
|
||||||
|
require.Len(t, output.AllRecords(), 1)
|
||||||
|
assert.Equal(t, "token is ***", output.LastRecord().Message)
|
||||||
|
|
||||||
|
for _, want := range []struct {
|
||||||
|
key string
|
||||||
|
val any
|
||||||
|
}{{"job", "build"}, {"jobID", "j1"}, {"stepNumber", 2}, {"stage", "Main"}, {"stepID", []string{"s2", "inner"}}} {
|
||||||
|
got, ok := hook.LastRecord().Attr(want.key)
|
||||||
|
assert.True(t, ok, want.key)
|
||||||
|
assert.Equal(t, want.val, got, want.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobLogJSONHandler(t *testing.T) {
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
logger := logging.New(newJobLogJSONHandler(buf)).With("job", "build", "msg", "clash").WithError(errors.New("boom"))
|
||||||
|
logger.Warnf("careful %d", 1)
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
|
||||||
|
assert.Equal(t, "careful 1", got["msg"])
|
||||||
|
assert.Equal(t, "warning", got["level"])
|
||||||
|
assert.Equal(t, "build", got["job"])
|
||||||
|
assert.Equal(t, "boom", got["error"])
|
||||||
|
assert.Equal(t, "clash", got["fields.msg"])
|
||||||
|
assert.NotEmpty(t, got["time"])
|
||||||
|
}
|
||||||
@@ -200,21 +200,21 @@ func TestPatchedBundleAcrossActions(t *testing.T) {
|
|||||||
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
|
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
|
||||||
// actions that drive a Go or a Rust cache client of their own.
|
// actions that drive a Go or a Rust cache client of their own.
|
||||||
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
||||||
{"actions/setup-node", "v7.0.0", "dist/cache-save/index.js", true},
|
{"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true},
|
||||||
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
||||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
||||||
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
||||||
{"ruby/setup-ruby", "v1.321.0", "dist/index.js", true},
|
{"ruby/setup-ruby", "v1.331.0", "dist/index.js", true},
|
||||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js", true},
|
{"pnpm/action-setup", "v6.1.0", "dist/index.js", true},
|
||||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
||||||
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
||||||
{"docker/build-push-action", "v7.3.0", "dist/index.cjs", true},
|
{"docker/build-push-action", "v7.4.0", "dist/index.cjs", true},
|
||||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||||
// releases before it refuse in a shape the runner leaves alone.
|
// releases before it refuse in a shape the runner leaves alone.
|
||||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
||||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js", true},
|
{"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true},
|
||||||
{"actions/download-artifact", "v8.0.1", "dist/index.js", true},
|
{"actions/download-artifact", "v8.0.2", "dist/index.js", true},
|
||||||
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
|
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
|
||||||
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
|
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
|
||||||
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
||||||
@@ -297,7 +297,7 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
|||||||
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
|
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
|
||||||
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
|
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
|
||||||
|
|
||||||
upload := patchedAction(t, "actions/upload-artifact", "v7.0.1", "dist/upload/index.js")
|
upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js")
|
||||||
|
|
||||||
env := jobEnv{
|
env := jobEnv{
|
||||||
workspace: tempDirPath(t),
|
workspace: tempDirPath(t),
|
||||||
@@ -319,7 +319,7 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
|||||||
|
|
||||||
// And back down again: listing and downloading go the same way, and the signed URL the
|
// And back down again: listing and downloading go the same way, and the signed URL the
|
||||||
// artifact service hands out is fetched straight from it.
|
// artifact service hands out is fetched straight from it.
|
||||||
download := patchedAction(t, "actions/download-artifact", "v8.0.1", "dist/index.js")
|
download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js")
|
||||||
env.workspace = tempDirPath(t)
|
env.workspace = tempDirPath(t)
|
||||||
out = runActionEntrypoint(t, download, env, map[string]string{
|
out = runActionEntrypoint(t, download, env, map[string]string{
|
||||||
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
||||||
@@ -341,7 +341,7 @@ func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
|||||||
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
||||||
requireHostTools(t, "node", "npm")
|
requireHostTools(t, "node", "npm")
|
||||||
|
|
||||||
setup := patchedAction(t, "actions/setup-node", "v7.0.0", "dist/setup/index.js")
|
setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js")
|
||||||
|
|
||||||
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ func TestActionScriptPaths(t *testing.T) {
|
|||||||
assert.Nil(t, actionScriptPaths("/a", escaping))
|
assert.Nil(t, actionScriptPaths("/a", escaping))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bundle has to be patched whatever state the shared action directory is in, because a
|
|
||||||
// concurrent job's prepare checks the action out again and resets it.
|
|
||||||
func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||||
copiedBundle := func(t *testing.T, noPatch bool) string {
|
copiedBundle := func(t *testing.T, noPatch bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -234,12 +232,13 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
|||||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||||
|
actionDir: t.TempDir(),
|
||||||
RunContext: &RunContext{
|
RunContext: &RunContext{
|
||||||
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
|
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
|
||||||
JobContainer: cm,
|
JobContainer: cm,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
script := filepath.Join(sar.actionDir, "sub", "index.js")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
||||||
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
||||||
|
|
||||||
@@ -250,7 +249,7 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
|||||||
copied = string(body)
|
copied = string(body)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir(), "sub", "/var/run/act/actions/repo/sub"))
|
require.NoError(t, maybeCopyToActionDir(t.Context(), sar, sar.actionDir, "sub", "/var/run/act/actions/repo/sub"))
|
||||||
source, err := os.ReadFile(script)
|
source, err := os.ReadFile(script)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
"gitea.com/gitea/runner/act/common/git"
|
||||||
@@ -27,7 +26,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
if !rc.Config.NoSkipCheckout {
|
if !rc.Config.NoSkipCheckout {
|
||||||
// resolve the local workflow against the workspace root, not the process
|
// resolve the local workflow against the workspace root, not the process
|
||||||
// working directory, so it is found regardless of where the runner is invoked
|
// working directory, so it is found regardless of where the runner is invoked
|
||||||
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path)
|
return newReusableWorkflowExecutor(rc, rc.Config.Workdir, localWorkflow.Path, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
uses := fmt.Sprintf("%s/%s@%s", rc.Config.PresetGitHubContext.Repository, localWorkflow.Path, rc.Config.PresetGitHubContext.Sha)
|
||||||
@@ -41,7 +40,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +86,16 @@ func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor {
|
func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string, cached bool) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// Serialize workflow reads with cache updates.
|
|
||||||
planner, err := func() (model.WorkflowPlanner, error) {
|
planner, err := func() (model.WorkflowPlanner, error) {
|
||||||
defer git.AcquireCloneLock(directory)()
|
if cached {
|
||||||
|
unlock, err := git.AcquireCloneLock(ctx, directory)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer unlock()
|
||||||
|
}
|
||||||
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
return model.NewWorkflowPlanner(path.Join(directory, workflow), true)
|
||||||
}()
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,11 +139,7 @@ type remoteReusableWorkflow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *remoteReusableWorkflow) CloneURL() string {
|
func (r *remoteReusableWorkflow) CloneURL() string {
|
||||||
// In Gitea, r.URL always has the protocol prefix, we don't need to add extra prefix in this case.
|
return fmt.Sprintf("%s/%s/%s", withHTTPS(r.URL), r.Owner, r.Repo)
|
||||||
if strings.HasPrefix(r.URL, "http://") || strings.HasPrefix(r.URL, "https://") {
|
|
||||||
return fmt.Sprintf("%s/%s/%s", r.URL, r.Owner, r.Repo)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Owner, r.Repo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
||||||
@@ -193,7 +193,7 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
|||||||
unlock := lockJob(rc.Run.Job())
|
unlock := lockJob(rc.Run.Job())
|
||||||
rc.result(reusedWorkflowJobResult)
|
rc.result(reusedWorkflowJobResult)
|
||||||
unlock()
|
unlock()
|
||||||
common.Logger(ctx).WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +209,9 @@ func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token strin
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
||||||
input.InsecureSkipTLS = c.InsecureSkipTLS && onInstanceHost(u.Host, c.GitHubInstance)
|
if onInstanceHost(u.Host, c.GitHubInstance) {
|
||||||
|
input.InsecureSkipTLS, input.Transport = c.InsecureSkipTLS, c.InstanceTransport
|
||||||
|
}
|
||||||
if u.User == nil {
|
if u.User == nil {
|
||||||
input.Token = token
|
input.Token = token
|
||||||
}
|
}
|
||||||
@@ -233,10 +235,7 @@ func onInstanceHost(host string, instanceURLs ...string) bool {
|
|||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") {
|
if u, err := url.Parse(withHTTPS(candidate)); err == nil && u.Host == host {
|
||||||
candidate = "https://" + candidate
|
|
||||||
}
|
|
||||||
if u, err := url.Parse(candidate); err == nil && u.Host == host {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -64,7 +63,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cacheDir := t.TempDir()
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
|
||||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v1"), string(got))
|
require.Equal(t, tmpl("v1"), string(got))
|
||||||
@@ -74,7 +73,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
gitMust(t, workDir, "commit", "-am", "v2")
|
gitMust(t, workDir, "commit", "-am", "v2")
|
||||||
gitMust(t, workDir, "push", "origin", "master")
|
gitMust(t, workDir, "push", "origin", "master")
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(t.Context()))
|
||||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||||
@@ -84,14 +83,16 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|||||||
workflowDir := t.TempDir()
|
workflowDir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
|
||||||
|
|
||||||
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
unlock, err := git.AcquireCloneLock(t.Context(), workflowDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
unlockOnce := sync.OnceFunc(unlock)
|
||||||
defer unlockOnce()
|
defer unlockOnce()
|
||||||
|
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{},
|
Config: &Config{},
|
||||||
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
||||||
}
|
}
|
||||||
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
|
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml", true)
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- exec(context.Background()) }()
|
go func() { done <- exec(context.Background()) }()
|
||||||
@@ -113,8 +114,9 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
||||||
workdir := t.TempDir()
|
workdir := filepath.Join(t.TempDir(), "workspace")
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
||||||
|
require.NoError(t, os.Mkdir(workdir+".lock", 0o755))
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
||||||
|
|
||||||
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
||||||
@@ -123,8 +125,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
|
|||||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
||||||
}
|
}
|
||||||
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
||||||
require.Error(t, err)
|
require.ErrorContains(t, err, "workflow is not valid")
|
||||||
require.NotErrorIs(t, err, fs.ErrNotExist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,8 +185,12 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
|||||||
InsecureSkipTLS: true,
|
InsecureSkipTLS: true,
|
||||||
ClientCertFile: "/cert",
|
ClientCertFile: "/cert",
|
||||||
ClientKeyFile: "/key",
|
ClientKeyFile: "/key",
|
||||||
|
InstanceTransport: &http.Transport{},
|
||||||
}
|
}
|
||||||
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
||||||
|
if tt.insecure {
|
||||||
|
want.Transport = conf.InstanceTransport
|
||||||
|
}
|
||||||
if tt.token {
|
if tt.token {
|
||||||
want.Token = "token"
|
want.Token = "token"
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-60
@@ -22,6 +22,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -35,11 +36,9 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/moby/moby/api/types/mount"
|
"github.com/moby/moby/api/types/mount"
|
||||||
"github.com/opencontainers/selinux/go-selinux"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"golang.org/x/text/encoding/unicode"
|
"golang.org/x/text/encoding/unicode"
|
||||||
@@ -83,8 +82,9 @@ type RunContext struct {
|
|||||||
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||||
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
||||||
|
|
||||||
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
|
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
|
||||||
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
|
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
|
||||||
|
actionDownloads map[string]actionDownload // remote action repositories by clone URL and ref, fetched once per job
|
||||||
|
|
||||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||||
// have already been created on the JobContainer. The runner sets up file-command files
|
// have already been created on the JobContainer. The runner sets up file-command files
|
||||||
@@ -330,13 +330,13 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
|||||||
targets := map[string]bool{}
|
targets := map[string]bool{}
|
||||||
|
|
||||||
for _, spec := range specs {
|
for _, spec := range specs {
|
||||||
parsed, err := loader.ParseVolume(spec)
|
parsed, err := container.ParseVolume(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
binds = append(binds, spec) // unclassifiable, sanitizeConfig warns and drops it
|
binds = append(binds, spec) // unclassifiable, sanitizeConfig warns and drops it
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
targets[parsed.Target] = true
|
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
|
mounts[parsed.Source] = parsed.Target
|
||||||
} else {
|
} else {
|
||||||
binds = append(binds, spec)
|
binds = append(binds, spec)
|
||||||
@@ -377,7 +377,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
bindModifiers = ":delegated"
|
bindModifiers = ":delegated"
|
||||||
}
|
}
|
||||||
if selinux.GetEnabled() {
|
if selinuxEnabled() {
|
||||||
bindModifiers = ":z"
|
bindModifiers = ":z"
|
||||||
}
|
}
|
||||||
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
|
binds = append(binds, fmt.Sprintf("%s:%s%s", source, target, bindModifiers))
|
||||||
@@ -511,7 +511,7 @@ func (rc *RunContext) startKubernetesPod() common.Executor {
|
|||||||
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
||||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
// section: emit the group header and summary, return a closer for ::endgroup::.
|
||||||
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
func printStartJobContainerGroup(ctx context.Context, image, name, network string) func() {
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
rawLogger.Infof("::group::Starting job container")
|
rawLogger.Infof("::group::Starting job container")
|
||||||
rawLogger.Infof("image: %s", image)
|
rawLogger.Infof("image: %s", image)
|
||||||
rawLogger.Infof("name: %s", name)
|
rawLogger.Infof("name: %s", name)
|
||||||
@@ -706,7 +706,7 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rawLogLine(ctx context.Context) common.LineHandler {
|
func rawLogLine(ctx context.Context) common.LineHandler {
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
return func(line string) bool {
|
return func(line string) bool {
|
||||||
rawLogger.Infof("%s", line)
|
rawLogger.Infof("%s", line)
|
||||||
return true
|
return true
|
||||||
@@ -718,31 +718,55 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
logger := common.Logger(ctx)
|
logger := common.Logger(ctx)
|
||||||
rc.printServiceLogs(ctx)
|
rc.printServiceLogs(ctx)
|
||||||
errs := []error{rc.closeDockerProxy(ctx)}
|
errs := []error{rc.closeDockerProxy(ctx)}
|
||||||
|
var containers []common.Executor
|
||||||
if rc.JobContainer != nil {
|
if rc.JobContainer != nil {
|
||||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
containers = append(containers, rc.JobContainer.Remove())
|
||||||
}
|
}
|
||||||
if len(rc.serviceContainers) > 0 {
|
if len(rc.serviceContainers) > 0 {
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
errs = append(errs, rc.stopServiceContainers()(ctx))
|
containers = append(containers, rc.stopServiceContainers())
|
||||||
}
|
}
|
||||||
|
errs = append(errs, runAll(ctx, containers))
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) {
|
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) {
|
||||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName())) // after the containers, whose endpoints would block removing the job networks
|
||||||
}
|
}
|
||||||
|
var rest []common.Executor
|
||||||
if preclean || rc.deferVolumeCleanup == nil {
|
if preclean || rc.deferVolumeCleanup == nil {
|
||||||
errs = append(errs, rc.cleanupJobVolumes(ctx))
|
rest = append(rest, rc.cleanupJobVolumes)
|
||||||
} else {
|
} else {
|
||||||
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
||||||
}
|
}
|
||||||
if createAndDeleteNetwork {
|
if createAndDeleteNetwork {
|
||||||
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
logger.Infof("Cleaning up network for job %s, and network name is: %s", rc.JobName, networkName)
|
||||||
errs = append(errs, container.NewDockerNetworkDisconnectExecutor(networkName, rc.Config.CacheContainer).
|
rest = append(rest, func(ctx context.Context) error {
|
||||||
IfBool(rc.Config.CacheContainer != "")(ctx))
|
return errors.Join(
|
||||||
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
container.NewDockerNetworkDisconnectExecutor(networkName, rc.Config.CacheContainer).IfBool(rc.Config.CacheContainer != "")(ctx),
|
||||||
|
container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(append(errs, runAll(ctx, rest))...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runAll(ctx context.Context, executors []common.Executor) error {
|
||||||
|
errs := make([]error, len(executors))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, executor := range executors {
|
||||||
|
wg.Go(func() {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
errs[i] = fmt.Errorf("panic: %v\n%s", recovered, debug.Stack())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
errs[i] = executor(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
|
func (rc *RunContext) createJobVolumes(ctx context.Context, mounts map[string]string) error {
|
||||||
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
|
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
|
||||||
if runnerUUID == "" || common.Dryrun(ctx) {
|
if runnerUUID == "" || common.Dryrun(ctx) {
|
||||||
@@ -957,7 +981,7 @@ func (rc *RunContext) waitForServiceContainers() common.Executor {
|
|||||||
// waitUntilHealthy waits on the healthcheck alone, so a container that declares none is
|
// waitUntilHealthy waits on the healthcheck alone, so a container that declares none is
|
||||||
// ready at once and one that exited is left to the steps that need it.
|
// ready at once and one that exited is left to the steps that need it.
|
||||||
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
func (svc *serviceContainer) waitUntilHealthy(ctx context.Context, timeout time.Duration) error {
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
loggedStarting := false
|
loggedStarting := false
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
@@ -1015,7 +1039,7 @@ const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
|
|||||||
func (rc *RunContext) printServiceLogs(ctx context.Context) {
|
func (rc *RunContext) printServiceLogs(ctx context.Context) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
|
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
for _, svc := range rc.serviceContainers {
|
for _, svc := range rc.serviceContainers {
|
||||||
if !svc.created || svc.logsDumped {
|
if !svc.created || svc.logsDumped {
|
||||||
continue
|
continue
|
||||||
@@ -1338,7 +1362,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
|||||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
|
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
l.WithField("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
|
l.With("jobResult", "skipped").Debugf("Skipping job '%s' due to '%s'", job.Name, job.If.Value)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1541,14 +1565,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
|
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
|
||||||
*ghc = merged
|
*ghc = merged
|
||||||
|
|
||||||
instance := rc.Config.GitHubInstance
|
rc.setInstanceURLs(ghc)
|
||||||
if !strings.HasPrefix(instance, "http://") &&
|
ghc.GraphQLURL = "" // Gitea doesn't support graphql
|
||||||
!strings.HasPrefix(instance, "https://") {
|
|
||||||
instance = "https://" + instance
|
|
||||||
}
|
|
||||||
ghc.ServerURL = instance
|
|
||||||
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
|
|
||||||
ghc.GraphQLURL = "" // Gitea doesn't support graphql
|
|
||||||
return ghc
|
return ghc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1562,7 +1580,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetBaseAndHeadRef()
|
ghc.SetBaseAndHeadRef()
|
||||||
repoPath := rc.Config.Workdir
|
repoPath := rc.Config.Workdir
|
||||||
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, repoPath)
|
ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
|
||||||
if ghc.Ref == "" {
|
if ghc.Ref == "" {
|
||||||
ghcontext.SetRef(ctx, ghc, repoPath)
|
ghcontext.SetRef(ctx, ghc, repoPath)
|
||||||
}
|
}
|
||||||
@@ -1572,27 +1590,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
|
|
||||||
ghc.SetRefTypeAndName()
|
ghc.SetRefTypeAndName()
|
||||||
|
|
||||||
// defaults
|
rc.setInstanceURLs(ghc)
|
||||||
ghc.ServerURL = "https://github.com"
|
|
||||||
ghc.APIURL = "https://api.github.com"
|
|
||||||
ghc.GraphQLURL = "https://api.github.com/graphql"
|
|
||||||
// per GHES
|
|
||||||
if rc.Config.GitHubInstance != "github.com" {
|
|
||||||
ghc.ServerURL = "https://" + rc.Config.GitHubInstance
|
|
||||||
ghc.APIURL = fmt.Sprintf("https://%s/api/v3", rc.Config.GitHubInstance)
|
|
||||||
ghc.GraphQLURL = fmt.Sprintf("https://%s/api/graphql", rc.Config.GitHubInstance)
|
|
||||||
}
|
|
||||||
|
|
||||||
{ // Adapt to Gitea
|
|
||||||
instance := rc.Config.GitHubInstance
|
|
||||||
if !strings.HasPrefix(instance, "http://") &&
|
|
||||||
!strings.HasPrefix(instance, "https://") {
|
|
||||||
instance = "https://" + instance
|
|
||||||
}
|
|
||||||
ghc.ServerURL = instance
|
|
||||||
ghc.APIURL = instance + "/api/v1" // the version of Gitea is v1
|
|
||||||
ghc.GraphQLURL = "" // Gitea doesn't support graphql
|
|
||||||
}
|
|
||||||
|
|
||||||
// allow to be overridden by user
|
// allow to be overridden by user
|
||||||
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
|
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
|
||||||
@@ -1608,6 +1606,20 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
|||||||
return ghc
|
return ghc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) setInstanceURLs(ghc *model.GithubContext) {
|
||||||
|
if instance := strings.TrimRight(rc.Config.GitHubInstance, "/"); instance != "" {
|
||||||
|
ghc.ServerURL = withHTTPS(instance)
|
||||||
|
ghc.APIURL = ghc.ServerURL + "/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withHTTPS(u string) string {
|
||||||
|
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return "https://" + u
|
||||||
|
}
|
||||||
|
|
||||||
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
|
func isLocalCheckout(ghc *model.GithubContext, step *model.Step) bool {
|
||||||
if step.Type() == model.StepTypeInvalid {
|
if step.Type() == model.StepTypeInvalid {
|
||||||
// This will be errored out by the executor later, we need this here to avoid a null panic though
|
// This will be errored out by the executor later, we need this here to avoid a null panic though
|
||||||
@@ -1657,20 +1669,16 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
|
|||||||
env["GITHUB_HEAD_REF"] = github.HeadRef
|
env["GITHUB_HEAD_REF"] = github.HeadRef
|
||||||
env["GITHUB_SERVER_URL"] = github.ServerURL
|
env["GITHUB_SERVER_URL"] = github.ServerURL
|
||||||
env["GITHUB_API_URL"] = github.APIURL
|
env["GITHUB_API_URL"] = github.APIURL
|
||||||
env["GITHUB_GRAPHQL_URL"] = github.GraphQLURL
|
|
||||||
|
|
||||||
{ // Adapt to Gitea
|
for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
|
||||||
instance := rc.Config.GitHubInstance
|
if env[name] == "" {
|
||||||
if !strings.HasPrefix(instance, "http://") &&
|
delete(env, name)
|
||||||
!strings.HasPrefix(instance, "https://") {
|
|
||||||
instance = "https://" + instance
|
|
||||||
}
|
}
|
||||||
env["GITHUB_SERVER_URL"] = instance
|
|
||||||
env["GITHUB_API_URL"] = instance + "/api/v1" // the version of Gitea is v1
|
|
||||||
env["GITHUB_GRAPHQL_URL"] = "" // Gitea doesn't support graphql
|
|
||||||
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env["GITHUB_GRAPHQL_URL"] = ""
|
||||||
|
env["GITHUB_RUN_ATTEMPT"] = github.RunAttempt
|
||||||
|
|
||||||
env["RUNNER_NAME"] = rc.Config.RunnerName
|
env["RUNNER_NAME"] = rc.Config.RunnerName
|
||||||
env["RUNNER_ENVIRONMENT"] = "self-hosted"
|
env["RUNNER_ENVIRONMENT"] = "self-hosted"
|
||||||
if workspace := parentDir(github.Workspace); workspace != "" {
|
if workspace := parentDir(github.Workspace); workspace != "" {
|
||||||
|
|||||||
+121
-82
@@ -20,19 +20,19 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/exprparser"
|
"gitea.dev/actionslib/pkg/exprparser"
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/docker/cli/cli/compose/loader"
|
|
||||||
"github.com/moby/moby/api/types/volume"
|
"github.com/moby/moby/api/types/volume"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
require "github.com/stretchr/testify/require"
|
require "github.com/stretchr/testify/require"
|
||||||
@@ -83,7 +83,7 @@ func TestRunContext_EvalBool(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
|
|
||||||
tables := []struct {
|
tables := []struct {
|
||||||
in string
|
in string
|
||||||
@@ -176,7 +176,7 @@ func TestRunContext_EvalBool(t *testing.T) {
|
|||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
t.Run(table.in, func(t *testing.T) {
|
t.Run(table.in, func(t *testing.T) {
|
||||||
assertObject := assert.New(t)
|
assertObject := assert.New(t)
|
||||||
b, err := EvalBool(context.Background(), rc.ExprEval, table.in, exprparser.DefaultStatusCheckSuccess)
|
b, err := EvalBool(t.Context(), rc.ExprEval, table.in, exprparser.DefaultStatusCheckSuccess)
|
||||||
if table.wantErr {
|
if table.wantErr {
|
||||||
assertObject.Error(err) //nolint:testifylint // pre-existing issue from nektos/act
|
assertObject.Error(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
}
|
}
|
||||||
@@ -563,8 +563,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
require.NoError(t, rc.resolvePlatformImage(context.Background()))
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||||
@@ -605,7 +605,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
// volumes must displace the runner's rather than pile up next to them.
|
// volumes must displace the runner's rather than pile up next to them.
|
||||||
targets := map[string]bool{}
|
targets := map[string]bool{}
|
||||||
for _, bind := range gotbind {
|
for _, bind := range gotbind {
|
||||||
parsed, err := loader.ParseVolume(bind)
|
parsed, err := container.ParseVolume(bind)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
|
assert.NotContains(t, targets, parsed.Target, "%s mounts an already mounted target", bind)
|
||||||
targets[parsed.Target] = true
|
targets[parsed.Target] = true
|
||||||
@@ -775,10 +775,10 @@ func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
func TestCleanupJobResources(t *testing.T) {
|
||||||
t.Setenv("TMPDIR", "/tmp")
|
t.Setenv("TMPDIR", "/tmp")
|
||||||
proxyDir := t.TempDir()
|
proxyDir := t.TempDir()
|
||||||
for _, name := range []string{"synchronous", "proxy", "closed proxy", "kept proxy", "deferred", "preclean deferred", "canceled"} {
|
for _, name := range []string{"synchronous", "proxy", "closed proxy", "kept proxy", "deferred", "preclean deferred", "canceled proxy"} {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
||||||
removesLabelled := (proxy || preclean) && name != "kept proxy"
|
removesLabelled := (proxy || preclean) && name != "kept proxy"
|
||||||
@@ -787,8 +787,20 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
|
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
|
||||||
job, service := &containerMock{}, &containerMock{}
|
job, service := &containerMock{}, &containerMock{}
|
||||||
job.On("Remove").Return(func(context.Context) error { return jobError }).Once()
|
removeAlongside := func(started, other chan struct{}, failure error) func(context.Context) error {
|
||||||
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
|
return func(context.Context) error {
|
||||||
|
close(started)
|
||||||
|
select {
|
||||||
|
case <-other:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Error("the containers are removed one after the other instead of alongside each other")
|
||||||
|
}
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobRemoving, serviceRemoving := make(chan struct{}), make(chan struct{})
|
||||||
|
job.On("Remove").Return(removeAlongside(jobRemoving, serviceRemoving, jobError)).Once()
|
||||||
|
service.On("Remove").Return(removeAlongside(serviceRemoving, jobRemoving, removeError)).Once()
|
||||||
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
||||||
rc := &RunContext{
|
rc := &RunContext{
|
||||||
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"},
|
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"},
|
||||||
@@ -825,7 +837,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if name == "canceled" {
|
if name == "canceled proxy" {
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
|
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
|
||||||
@@ -839,14 +851,10 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
|||||||
for _, failure := range []error{jobError, removeError, closeError} {
|
for _, failure := range []error{jobError, removeError, closeError} {
|
||||||
require.ErrorIs(t, err, failure)
|
require.ErrorIs(t, err, failure)
|
||||||
}
|
}
|
||||||
if name == "canceled" {
|
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "POST /networks/job-network/disconnect", "GET /networks"} {
|
||||||
require.ErrorIs(t, err, context.Canceled)
|
require.ErrorContains(t, err, operation)
|
||||||
} else {
|
|
||||||
for _, operation := range []string{"DELETE /volumes/" + rc.jobContainerName(), "DELETE /volumes/" + rc.jobContainerName() + "-env", "POST /networks/job-network/disconnect", "GET /networks"} {
|
|
||||||
require.ErrorContains(t, err, operation)
|
|
||||||
}
|
|
||||||
assert.Equal(t, 2, volumeRemovals)
|
|
||||||
}
|
}
|
||||||
|
assert.Equal(t, 2, volumeRemovals)
|
||||||
assert.Equal(t, removesLabelled, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
assert.Equal(t, removesLabelled, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||||
if removesLabelled {
|
if removesLabelled {
|
||||||
require.ErrorContains(t, err, "labelled GET /networks")
|
require.ErrorContains(t, err, "labelled GET /networks")
|
||||||
@@ -864,7 +872,7 @@ func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) {
|
|||||||
needed := &model.Job{Outputs: map[string]string{"o": "from-gitea"}}
|
needed := &model.Job{Outputs: map[string]string{"o": "from-gitea"}}
|
||||||
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job, "needed": needed}}}
|
run := &model.Run{JobID: "j", Workflow: &model.Workflow{Name: "w", Jobs: map[string]*model.Job{"j": job, "needed": needed}}}
|
||||||
r := &runnerImpl{config: &Config{}}
|
r := &runnerImpl{config: &Config{}}
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
_, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil)
|
_, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -899,7 +907,7 @@ func TestInterpolateOutputsFromExpression(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitHubContext(t *testing.T) {
|
func TestGetGitHubContext(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -915,6 +923,7 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
Run: &model.Run{
|
Run: &model.Run{
|
||||||
Workflow: &model.Workflow{
|
Workflow: &model.Workflow{
|
||||||
Name: "GitHubContextTest",
|
Name: "GitHubContextTest",
|
||||||
|
Jobs: map[string]*model.Job{"job1": {}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Name: "GitHubContextTest",
|
Name: "GitHubContextTest",
|
||||||
@@ -926,9 +935,9 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
|
|
||||||
ghc := rc.getGithubContext(context.Background())
|
ghc := rc.getGithubContext(t.Context())
|
||||||
|
|
||||||
log.Debugf("%v", ghc)
|
logging.Debugf("%v", ghc)
|
||||||
|
|
||||||
actor := "nektos/act"
|
actor := "nektos/act"
|
||||||
if a := os.Getenv("ACT_ACTOR"); a != "" {
|
if a := os.Getenv("ACT_ACTOR"); a != "" {
|
||||||
@@ -956,10 +965,40 @@ func TestGetGitHubContext(t *testing.T) {
|
|||||||
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Equal(t, ghc.Job, "job1") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"}
|
rc.Config.PresetGitHubContext = &model.GithubContext{Actor: "preset-actor", TriggeringActor: "triggerer", Job: "preset-job"}
|
||||||
ghc = rc.getGithubContext(context.Background())
|
ghc = rc.getGithubContext(t.Context())
|
||||||
assert.Equal(t, "preset-actor", ghc.Actor)
|
assert.Equal(t, "preset-actor", ghc.Actor)
|
||||||
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
||||||
assert.Equal(t, "job1", ghc.Job)
|
assert.Equal(t, "job1", ghc.Job)
|
||||||
|
|
||||||
|
preset := &model.GithubContext{ServerURL: "https://preset.test", APIURL: "https://preset.test/api/v1"}
|
||||||
|
for _, testcase := range []struct {
|
||||||
|
name, instance, serverURL, apiURL string
|
||||||
|
preset *model.GithubContext
|
||||||
|
env map[string]string
|
||||||
|
}{
|
||||||
|
{name: "unknown instance"},
|
||||||
|
{name: "bare host with trailing slash", instance: "gitea.test/", serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
|
||||||
|
{name: "scheme port and base path", instance: "http://gitea.test:3000/gitea/", serverURL: "http://gitea.test:3000/gitea", apiURL: "http://gitea.test:3000/gitea/api/v1"},
|
||||||
|
{name: "daemon instance replaces preset endpoints", instance: "gitea.test", preset: preset, serverURL: "https://gitea.test", apiURL: "https://gitea.test/api/v1"},
|
||||||
|
{name: "daemon keeps preset endpoints without instance", preset: preset, serverURL: "https://preset.test", apiURL: "https://preset.test/api/v1"},
|
||||||
|
{name: "environment overrides", env: map[string]string{"GITHUB_SERVER_URL": "https://override.test", "GITHUB_API_URL": "https://override.test/api"}, serverURL: "https://override.test", apiURL: "https://override.test/api"},
|
||||||
|
} {
|
||||||
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
|
rc.Config.GitHubInstance = testcase.instance
|
||||||
|
rc.Config.PresetGitHubContext = testcase.preset
|
||||||
|
rc.Config.Env = map[string]string{"GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "SHA_REF": "1234567890"}
|
||||||
|
maps.Copy(rc.Config.Env, testcase.env)
|
||||||
|
github := rc.getGithubContext(t.Context())
|
||||||
|
assert.Equal(t, testcase.serverURL, github.ServerURL)
|
||||||
|
assert.Equal(t, testcase.apiURL, github.APIURL)
|
||||||
|
env := map[string]string{}
|
||||||
|
rc.withGithubEnv(t.Context(), github, env)
|
||||||
|
_, exported := env["GITHUB_SERVER_URL"]
|
||||||
|
assert.Equal(t, testcase.serverURL != "", exported)
|
||||||
|
assert.Equal(t, testcase.serverURL, env["GITHUB_SERVER_URL"])
|
||||||
|
assert.Equal(t, testcase.apiURL, env["GITHUB_API_URL"])
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGithubContextRef(t *testing.T) {
|
func TestGetGithubContextRef(t *testing.T) {
|
||||||
@@ -996,7 +1035,7 @@ func TestGetGithubContextRef(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ghc := rc.getGithubContext(context.Background())
|
ghc := rc.getGithubContext(t.Context())
|
||||||
|
|
||||||
assert.Equal(t, data.ref, ghc.Ref)
|
assert.Equal(t, data.ref, ghc.Ref)
|
||||||
})
|
})
|
||||||
@@ -1029,54 +1068,54 @@ func createJob(t *testing.T, input, result string) *model.Job {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextRunsOnPlatformNames(t *testing.T) {
|
func TestRunContextRunsOnPlatformNames(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
assertObject := assert.New(t)
|
assertObject := assert.New(t)
|
||||||
|
|
||||||
rc := createIfTestRunContext(map[string]*model.Job{
|
rc := createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ${{ 'ubuntu-latest' }}`, ""),
|
"job1": createJob(t, `runs-on: ${{ 'ubuntu-latest' }}`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: [self-hosted, my-runner]`, ""),
|
"job1": createJob(t, `runs-on: [self-hosted, my-runner]`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: [self-hosted, "${{ 'my-runner' }}"]`, ""),
|
"job1": createJob(t, `runs-on: [self-hosted, "${{ 'my-runner' }}"]`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{"self-hosted", "my-runner"}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`, ""),
|
"job1": createJob(t, `runs-on: ${{ fromJSON('["ubuntu-latest"]') }}`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{"ubuntu-latest"}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
// test missing / invalid runs-on
|
// test missing / invalid runs-on
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `name: something`, ""),
|
"job1": createJob(t, `name: something`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on:
|
"job1": createJob(t, `runs-on:
|
||||||
mapping: value`, ""),
|
mapping: value`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ${{ invalid expression }}`, ""),
|
"job1": createJob(t, `runs-on: ${{ invalid expression }}`, ""),
|
||||||
})
|
})
|
||||||
assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
|
assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextIsEnabled(t *testing.T) {
|
func TestRunContextIsEnabled(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
assertObject := assert.New(t)
|
assertObject := assert.New(t)
|
||||||
|
|
||||||
// success()
|
// success()
|
||||||
@@ -1086,8 +1125,8 @@ env:
|
|||||||
BROKEN: ${{ fromJSON('invalid') }}
|
BROKEN: ${{ fromJSON('invalid') }}
|
||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
|
require.Error(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -1096,7 +1135,7 @@ needs: [job1]
|
|||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||||
@@ -1105,7 +1144,7 @@ needs: [job1]
|
|||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -1113,14 +1152,14 @@ if: success()`, ""),
|
|||||||
if: success()`, ""),
|
if: success()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
// failure()
|
// failure()
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||||
if: failure()`, ""),
|
if: failure()`, ""),
|
||||||
})
|
})
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -1129,7 +1168,7 @@ needs: [job1]
|
|||||||
if: failure()`, ""),
|
if: failure()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||||
@@ -1138,7 +1177,7 @@ needs: [job1]
|
|||||||
if: failure()`, ""),
|
if: failure()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -1146,14 +1185,14 @@ if: failure()`, ""),
|
|||||||
if: failure()`, ""),
|
if: failure()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
// always()
|
// always()
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||||
if: always()`, ""),
|
if: always()`, ""),
|
||||||
})
|
})
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||||
@@ -1162,7 +1201,7 @@ needs: [job1]
|
|||||||
if: always()`, ""),
|
if: always()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||||
@@ -1171,7 +1210,7 @@ needs: [job1]
|
|||||||
if: always()`, ""),
|
if: always()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||||
@@ -1179,23 +1218,23 @@ if: always()`, ""),
|
|||||||
if: always()`, ""),
|
if: always()`, ""),
|
||||||
})
|
})
|
||||||
rc.Run.JobID = "job2"
|
rc.Run.JobID = "job2"
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `uses: ./.github/workflows/reusable.yml`, ""),
|
"job1": createJob(t, `uses: ./.github/workflows/reusable.yml`, ""),
|
||||||
})
|
})
|
||||||
assertObject.True(rc.isEnabled(context.Background()))
|
assertObject.True(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `uses: ./.github/workflows/reusable.yml
|
"job1": createJob(t, `uses: ./.github/workflows/reusable.yml
|
||||||
if: false`, ""),
|
if: false`, ""),
|
||||||
})
|
})
|
||||||
assertObject.False(rc.isEnabled(context.Background()))
|
assertObject.False(rc.isEnabled(t.Context()))
|
||||||
|
|
||||||
rc = createIfTestRunContext(map[string]*model.Job{
|
rc = createIfTestRunContext(map[string]*model.Job{
|
||||||
"job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""),
|
"job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""),
|
||||||
})
|
})
|
||||||
enabled, err := rc.isEnabled(context.Background())
|
enabled, err := rc.isEnabled(t.Context())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assertObject.False(enabled)
|
assertObject.False(enabled)
|
||||||
}
|
}
|
||||||
@@ -1275,12 +1314,9 @@ func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
|
|||||||
|
|
||||||
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
logger := log.New()
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||||
logger.SetOutput(buf)
|
entry := logger.With("job", "j1")
|
||||||
logger.SetLevel(log.InfoLevel)
|
ctx := common.WithLogger(t.Context(), entry)
|
||||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
|
||||||
entry := logger.WithFields(log.Fields{"job": "j1"})
|
|
||||||
ctx := common.WithLogger(context.Background(), entry)
|
|
||||||
|
|
||||||
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
|
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
|
||||||
|
|
||||||
@@ -1332,8 +1368,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(testcase.name, func(t *testing.T) {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
var operations []string
|
var operations []string
|
||||||
|
var operationsMu sync.Mutex
|
||||||
record := func(operation string, failure error) func(context.Context) error {
|
record := func(operation string, failure error) func(context.Context) error {
|
||||||
return func(context.Context) error {
|
return func(context.Context) error {
|
||||||
|
operationsMu.Lock()
|
||||||
|
defer operationsMu.Unlock()
|
||||||
operations = append(operations, operation)
|
operations = append(operations, operation)
|
||||||
return failure
|
return failure
|
||||||
}
|
}
|
||||||
@@ -1371,11 +1410,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
Run: &model.Run{JobID: "job", Workflow: workflow},
|
Run: &model.Run{JobID: "job", Workflow: workflow},
|
||||||
platformImage: "node:20",
|
platformImage: "node:20",
|
||||||
}
|
}
|
||||||
logger, hook := logrustest.NewNullLogger()
|
logger, hook := logtest.NewLogger()
|
||||||
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
|
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
|
||||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||||
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
|
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
|
||||||
want := "job.Remove service.Remove service.Close service.Pull job.Pull"
|
want := "job.Remove service.Close service.Remove service.Pull job.Pull"
|
||||||
switch {
|
switch {
|
||||||
case testcase.pullError != nil:
|
case testcase.pullError != nil:
|
||||||
require.ErrorIs(t, err, testcase.pullError)
|
require.ErrorIs(t, err, testcase.pullError)
|
||||||
@@ -1390,9 +1429,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
|
slices.Sort(operations[:3])
|
||||||
|
slices.Sort(operations[len(operations)-4 : len(operations)-1])
|
||||||
|
assert.Equal(t, strings.Fields(want+" job.Remove service.Close service.Remove job.Close"), operations)
|
||||||
assert.Empty(t, rc.Masks)
|
assert.Empty(t, rc.Masks)
|
||||||
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllEntries(), func(entry *log.Entry) bool {
|
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllRecords(), func(entry logging.Record) bool {
|
||||||
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
|
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -1415,7 +1456,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once()
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once()
|
||||||
|
|
||||||
rc := newRunContext(0, &serviceContainer{name: "redis", container: service})
|
rc := newRunContext(0, &serviceContainer{name: "redis", container: service})
|
||||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
require.NoError(t, rc.waitForServiceContainers()(t.Context()))
|
||||||
service.AssertExpectations(t)
|
service.AssertExpectations(t)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1432,9 +1473,8 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||||
|
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
logger := log.New()
|
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(&output, cyan), logging.LevelInfo))
|
||||||
logger.SetOutput(&output)
|
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger)))
|
||||||
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger.WithFields(nil))))
|
|
||||||
assert.Equal(t, []time.Duration{0, time.Second, 2 * time.Second}, pollTimes)
|
assert.Equal(t, []time.Duration{0, time.Second, 2 * time.Second}, pollTimes)
|
||||||
assert.Equal(t, 1, strings.Count(output.String(), "postgres service is starting."))
|
assert.Equal(t, 1, strings.Count(output.String(), "postgres service is starting."))
|
||||||
assert.Contains(t, output.String(), "postgres service is healthy.")
|
assert.Contains(t, output.String(), "postgres service is healthy.")
|
||||||
@@ -1451,7 +1491,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
service.On("DumpLogs", mock.Anything).Return(nil).Once()
|
service.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||||
|
|
||||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
err := rc.waitForServiceContainers()(context.Background())
|
err := rc.waitForServiceContainers()(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused")
|
assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused")
|
||||||
service.AssertExpectations(t)
|
service.AssertExpectations(t)
|
||||||
@@ -1463,7 +1503,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once()
|
Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once()
|
||||||
|
|
||||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
require.NoError(t, rc.waitForServiceContainers()(t.Context()))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("proceeds when the container is gone", func(t *testing.T) {
|
t.Run("proceeds when the container is gone", func(t *testing.T) {
|
||||||
@@ -1472,7 +1512,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return((*container.Info)(nil), container.ErrContainerNotFound).Once()
|
Return((*container.Info)(nil), container.ErrContainerNotFound).Once()
|
||||||
|
|
||||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
require.NoError(t, rc.waitForServiceContainers()(t.Context()))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("fails right away when one service fails while another is still starting", func(t *testing.T) {
|
t.Run("fails right away when one service fails while another is still starting", func(t *testing.T) {
|
||||||
@@ -1506,7 +1546,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||||
|
|
||||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||||
err := rc.waitForServiceContainers()(context.Background())
|
err := rc.waitForServiceContainers()(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||||
})
|
})
|
||||||
@@ -1518,7 +1558,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
Return((*container.Info)(nil), errors.New("inspect aborted"))
|
Return((*container.Info)(nil), errors.New("inspect aborted"))
|
||||||
|
|
||||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||||
err := rc.waitForServiceContainers()(context.Background())
|
err := rc.waitForServiceContainers()(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||||
})
|
})
|
||||||
@@ -1531,7 +1571,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
|
|
||||||
svc := &serviceContainer{name: "postgres", container: service}
|
svc := &serviceContainer{name: "postgres", container: service}
|
||||||
rc := newRunContext(-1, svc)
|
rc := newRunContext(-1, svc)
|
||||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
require.NoError(t, rc.waitForServiceContainers()(t.Context()))
|
||||||
service.AssertExpectations(t)
|
service.AssertExpectations(t)
|
||||||
assert.Equal(t, "id", svc.info.ID)
|
assert.Equal(t, "id", svc.info.ID)
|
||||||
})
|
})
|
||||||
@@ -1541,13 +1581,13 @@ func TestWaitForServiceContainers(t *testing.T) {
|
|||||||
service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once()
|
service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once()
|
||||||
|
|
||||||
rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service})
|
rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service})
|
||||||
err := rc.waitForServiceContainers()(context.Background())
|
err := rc.waitForServiceContainers()(t.Context())
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "failed to inspect service 'postgres'")
|
assert.Contains(t, err.Error(), "failed to inspect service 'postgres'")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("is a no-op without services", func(t *testing.T) {
|
t.Run("is a no-op without services", func(t *testing.T) {
|
||||||
require.NoError(t, newRunContext(0).waitForServiceContainers()(context.Background()))
|
require.NoError(t, newRunContext(0).waitForServiceContainers()(t.Context()))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,7 +1603,7 @@ func TestReportUnstartedServices(t *testing.T) {
|
|||||||
{name: "redis", container: running},
|
{name: "redis", container: running},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
require.NoError(t, rc.reportUnstartedServices()(context.Background()))
|
require.NoError(t, rc.reportUnstartedServices()(t.Context()))
|
||||||
dead.AssertExpectations(t)
|
dead.AssertExpectations(t)
|
||||||
running.AssertExpectations(t)
|
running.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
@@ -1603,14 +1643,14 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
|||||||
JobContainer: job,
|
JobContainer: job,
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
require.NoError(t, rc.captureJobContainerInfo()(t.Context()))
|
||||||
|
|
||||||
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
assert.Equal(t, "job-container-id", rc.jobContainerID)
|
||||||
assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
assert.Equal(t, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
||||||
|
|
||||||
rc.Config.Workdir = "/elsewhere"
|
rc.Config.Workdir = "/elsewhere"
|
||||||
rc.Env = map[string]string{}
|
rc.Env = map[string]string{}
|
||||||
require.NoError(t, rc.captureJobContainerInfo()(context.Background()))
|
require.NoError(t, rc.captureJobContainerInfo()(t.Context()))
|
||||||
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
|
assert.NotContains(t, rc.Env, "GITEA_DOCKER_WORKSPACE")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1687,7 +1727,7 @@ func TestRunContextContainerImageCannotSelectBackend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextImageOS(t *testing.T) {
|
func TestRunContextImageOS(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
t.Run("prefers the release in the resolved image tag", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
@@ -1714,7 +1754,7 @@ func TestRunContextImageOS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextGetRunnerContext(t *testing.T) {
|
func TestRunContextGetRunnerContext(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
|
t.Run("adds the runner values the container cannot know", func(t *testing.T) {
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
@@ -1766,8 +1806,7 @@ func TestRunContextUpdateExtraPath(t *testing.T) {
|
|||||||
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
|
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
|
||||||
} {
|
} {
|
||||||
t.Run(testcase.name, func(t *testing.T) {
|
t.Run(testcase.name, func(t *testing.T) {
|
||||||
logger := log.New()
|
logger := logging.New(logging.DiscardHandler)
|
||||||
logger.SetOutput(io.Discard)
|
|
||||||
ctx := common.WithLogger(t.Context(), logger)
|
ctx := common.WithLogger(t.Context(), logger)
|
||||||
jobContainer := &containerMock{}
|
jobContainer := &containerMock{}
|
||||||
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
||||||
@@ -1795,7 +1834,7 @@ func TestParentDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||||
rc.Config.RunnerName = "runner-1"
|
rc.Config.RunnerName = "runner-1"
|
||||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||||
|
|||||||
+47
-52
@@ -5,6 +5,7 @@
|
|||||||
package runner
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,12 +16,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
docker_container "github.com/moby/moby/api/types/container"
|
docker_container "github.com/moby/moby/api/types/container"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ type Config struct {
|
|||||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||||
ContainerOptions string // Options for the job container
|
ContainerOptions string // Options for the job container
|
||||||
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true
|
||||||
GitHubInstance string // GitHub instance to use, default "github.com"
|
GitHubInstance string // instance hostname or URL, empty when unknown
|
||||||
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
ContainerCapAdd []string // list of kernel capabilities to add to the containers
|
||||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||||
@@ -79,18 +80,18 @@ type Config struct {
|
|||||||
PlatformPicker func(labels []string) string
|
PlatformPicker func(labels []string) string
|
||||||
KubernetesPicker func(labels []string) container.KubernetesOptions
|
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||||
InstanceTransport http.RoundTripper
|
InstanceTransport http.RoundTripper
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
JobLoggerLevel *logging.Level // the level of job logger
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy
|
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy
|
||||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||||
@@ -111,13 +112,7 @@ func (c Config) GetToken() string {
|
|||||||
|
|
||||||
// DefaultActionURL returns the host used for implicit remote actions.
|
// DefaultActionURL returns the host used for implicit remote actions.
|
||||||
func (c Config) DefaultActionURL() string {
|
func (c Config) DefaultActionURL() string {
|
||||||
if c.DefaultActionInstance != "" {
|
return cmp.Or(c.DefaultActionInstance, c.GitHubInstance, "github.com")
|
||||||
return c.DefaultActionInstance
|
|
||||||
}
|
|
||||||
if c.GitHubInstance != "" {
|
|
||||||
return c.GitHubInstance
|
|
||||||
}
|
|
||||||
return "github.com"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type caller struct {
|
type caller struct {
|
||||||
@@ -154,7 +149,7 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
|
|||||||
if runner.config.EventJSON != "" {
|
if runner.config.EventJSON != "" {
|
||||||
runner.eventJSON = runner.config.EventJSON
|
runner.eventJSON = runner.config.EventJSON
|
||||||
} else if runner.config.EventPath != "" {
|
} else if runner.config.EventPath != "" {
|
||||||
log.Debugf("Reading event.json from %s", runner.config.EventPath)
|
logging.Debugf("Reading event.json from %s", runner.config.EventPath)
|
||||||
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
|
eventJSONBytes, err := os.ReadFile(runner.config.EventPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -168,7 +163,7 @@ func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
|||||||
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
||||||
if strategy != nil {
|
if strategy != nil {
|
||||||
if limit, err := strategy.MaxParallel(); err != nil {
|
if limit, err := strategy.MaxParallel(); err != nil {
|
||||||
log.Errorf("Ignoring invalid max-parallel: %v", err)
|
logging.Errorf("Ignoring invalid max-parallel: %v", err)
|
||||||
} else if limit > 0 {
|
} else if limit > 0 {
|
||||||
maxParallel = limit
|
maxParallel = limit
|
||||||
}
|
}
|
||||||
@@ -190,36 +185,36 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
maxJobNameLen := 0
|
maxJobNameLen := 0
|
||||||
|
|
||||||
stagePipeline := make([]common.Executor, 0)
|
stagePipeline := make([]common.Executor, 0)
|
||||||
log.Debugf("Plan Stages: %v", plan.Stages)
|
logging.Debugf("Plan Stages: %v", plan.Stages)
|
||||||
|
|
||||||
for i := range plan.Stages {
|
for i := range plan.Stages {
|
||||||
stage := plan.Stages[i]
|
stage := plan.Stages[i]
|
||||||
stagePipeline = append(stagePipeline, func(ctx context.Context) error {
|
stagePipeline = append(stagePipeline, func(ctx context.Context) error {
|
||||||
pipeline := make([]common.Executor, 0)
|
pipeline := make([]common.Executor, 0)
|
||||||
for _, run := range stage.Runs {
|
for _, run := range stage.Runs {
|
||||||
log.Debugf("Stages Runs: %v", stage.Runs)
|
logging.Debugf("Stages Runs: %v", stage.Runs)
|
||||||
stageExecutor := make([]common.Executor, 0)
|
stageExecutor := make([]common.Executor, 0)
|
||||||
job := run.Job()
|
job := run.Job()
|
||||||
log.Debugf("Job.Name: %v", job.Name)
|
logging.Debugf("Job.Name: %v", job.Name)
|
||||||
log.Debugf("Job.RawNeeds: %v", job.RawNeeds)
|
logging.Debugf("Job.RawNeeds: %v", job.RawNeeds)
|
||||||
log.Debugf("Job.RawRunsOn: %v", job.RawRunsOn)
|
logging.Debugf("Job.RawRunsOn: %v", job.RawRunsOn)
|
||||||
log.Debugf("Job.Env: %v", job.Env)
|
logging.Debugf("Job.Env: %v", job.Env)
|
||||||
log.Debugf("Job.If: %v", job.If)
|
logging.Debugf("Job.If: %v", job.If)
|
||||||
for step := range job.Steps {
|
for step := range job.Steps {
|
||||||
if nil != job.Steps[step] {
|
if nil != job.Steps[step] {
|
||||||
log.Debugf("Job.Steps: %v", job.Steps[step].String())
|
logging.Debugf("Job.Steps: %v", job.Steps[step].String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Debugf("Job.TimeoutMinutes: %v", job.TimeoutMinutes)
|
logging.Debugf("Job.TimeoutMinutes: %v", job.TimeoutMinutes)
|
||||||
log.Debugf("Job.Services: %v", job.Services)
|
logging.Debugf("Job.Services: %v", job.Services)
|
||||||
log.Debugf("Job.Strategy: %v", job.Strategy)
|
logging.Debugf("Job.Strategy: %v", job.Strategy)
|
||||||
log.Debugf("Job.RawContainer: %v", job.RawContainer)
|
logging.Debugf("Job.RawContainer: %v", job.RawContainer)
|
||||||
log.Debugf("Job.Defaults.Run.Shell: %v", job.Defaults.Run.Shell)
|
logging.Debugf("Job.Defaults.Run.Shell: %v", job.Defaults.Run.Shell)
|
||||||
log.Debugf("Job.Defaults.Run.WorkingDirectory: %v", job.Defaults.Run.WorkingDirectory)
|
logging.Debugf("Job.Defaults.Run.WorkingDirectory: %v", job.Defaults.Run.WorkingDirectory)
|
||||||
log.Debugf("Job.Outputs: %v", job.Outputs)
|
logging.Debugf("Job.Outputs: %v", job.Outputs)
|
||||||
log.Debugf("Job.Uses: %v", job.Uses)
|
logging.Debugf("Job.Uses: %v", job.Uses)
|
||||||
log.Debugf("Job.With: %v", job.With)
|
logging.Debugf("Job.With: %v", job.With)
|
||||||
log.Debugf("Job.Result: %v", job.Result)
|
logging.Debugf("Job.Result: %v", job.Result)
|
||||||
|
|
||||||
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||||
@@ -231,13 +226,13 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
logging.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
logging.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
logging.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
logging.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||||
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
||||||
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
if err := strategyRc.NewExpressionEvaluator(ctx).EvaluateYamlNode(ctx, &job.Strategy.RawMatrix); err != nil {
|
||||||
log.Errorf("Error while evaluating matrix: %v", err)
|
logging.Errorf("Error while evaluating matrix: %v", err)
|
||||||
}
|
}
|
||||||
for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} {
|
for _, value := range []*string{&job.Strategy.FailFastString, &job.Strategy.MaxParallelString} {
|
||||||
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
|
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
|
||||||
@@ -251,11 +246,11 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not get job matrix: %w", err)
|
return fmt.Errorf("could not get job matrix: %w", err)
|
||||||
}
|
}
|
||||||
log.Debugf("Job Matrices: %v", matrixes)
|
logging.Debugf("Job Matrices: %v", matrixes)
|
||||||
|
|
||||||
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
maxParallel := maxParallelFor(job.Strategy, len(matrixes))
|
||||||
|
|
||||||
log.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
logging.Infof("Running job with maxParallel=%d for %d matrix combinations", maxParallel, len(matrixes))
|
||||||
|
|
||||||
for i, matrix := range matrixes {
|
for i, matrix := range matrixes {
|
||||||
index, total := i, len(matrixes)
|
index, total := i, len(matrixes)
|
||||||
@@ -314,7 +309,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
if len(pipeline) == 1 {
|
if len(pipeline) == 1 {
|
||||||
// Single run/job: execute directly without additional parallelization wrapper
|
// Single run/job: execute directly without additional parallelization wrapper
|
||||||
// This ensures max-parallel is the only limiting factor
|
// This ensures max-parallel is the only limiting factor
|
||||||
log.Debugf("Single pipeline element, executing directly")
|
logging.Debugf("Single pipeline element, executing directly")
|
||||||
return pipeline[0](ctx)
|
return pipeline[0](ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,9 +319,9 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
// If MaxParallel is set in config, use it
|
// If MaxParallel is set in config, use it
|
||||||
if runner.config.MaxParallel > 0 {
|
if runner.config.MaxParallel > 0 {
|
||||||
parallelism = runner.config.MaxParallel
|
parallelism = runner.config.MaxParallel
|
||||||
log.Debugf("Using configured max-parallel: %d", parallelism)
|
logging.Debugf("Using configured max-parallel: %d", parallelism)
|
||||||
} else {
|
} else {
|
||||||
log.Debugf("Using CPU count for parallelism: %d", parallelism)
|
logging.Debugf("Using CPU count for parallelism: %d", parallelism)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't exceed the number of pipeline elements
|
// Don't exceed the number of pipeline elements
|
||||||
@@ -334,7 +329,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
|||||||
parallelism = len(pipeline)
|
parallelism = len(pipeline)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism)
|
logging.Infof("Executing %d pipeline elements with parallelism %d", len(pipeline), parallelism)
|
||||||
return common.NewParallelExecutor(parallelism, pipeline...)(ctx)
|
return common.NewParallelExecutor(parallelism, pipeline...)(ctx)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -17,10 +17,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
assert "github.com/stretchr/testify/assert"
|
assert "github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
baseImage = "node:24-bookworm-slim"
|
baseImage = "node:24-bookworm-slim"
|
||||||
platforms map[string]string
|
platforms map[string]string
|
||||||
logLevel = log.DebugLevel
|
logLevel = logging.LevelDebug
|
||||||
workdir = "testdata"
|
workdir = "testdata"
|
||||||
secrets map[string]string
|
secrets map[string]string
|
||||||
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
|
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
|
||||||
@@ -55,7 +55,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if l := os.Getenv("ACT_TEST_LOG_LEVEL"); l != "" {
|
if l := os.Getenv("ACT_TEST_LOG_LEVEL"); l != "" {
|
||||||
if lvl, err := log.ParseLevel(l); err == nil {
|
if lvl, err := logging.ParseLevel(l); err == nil {
|
||||||
logLevel = lvl
|
logLevel = lvl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,10 +71,11 @@ func TestNoWorkflowsFoundByPlanner(t *testing.T) {
|
|||||||
planner, err := model.NewWorkflowPlanner("res", true)
|
planner, err := model.NewWorkflowPlanner("res", true)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
out := log.StandardLogger().Out
|
out := logging.Output()
|
||||||
|
t.Cleanup(func() { logging.SetOutput(out) })
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
log.SetOutput(&buf)
|
logging.SetOutput(&buf)
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
plan, err := planner.PlanEvent("pull_request")
|
plan, err := planner.PlanEvent("pull_request")
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -84,25 +85,24 @@ func TestNoWorkflowsFoundByPlanner(t *testing.T) {
|
|||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Contains(t, buf.String(), "no workflows found by planner")
|
assert.Contains(t, buf.String(), "no workflows found by planner")
|
||||||
log.SetOutput(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGraphMissingEvent(t *testing.T) {
|
func TestGraphMissingEvent(t *testing.T) {
|
||||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/no-event.yml", true)
|
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/no-event.yml", true)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
out := log.StandardLogger().Out
|
out := logging.Output()
|
||||||
|
t.Cleanup(func() { logging.SetOutput(out) })
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
log.SetOutput(&buf)
|
logging.SetOutput(&buf)
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Empty(t, plan.Stages)
|
assert.Empty(t, plan.Stages)
|
||||||
|
|
||||||
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
|
assert.Contains(t, buf.String(), `msg="no events found for workflow" file=no-event.yml`)
|
||||||
log.SetOutput(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGraphMissingFirst(t *testing.T) {
|
func TestGraphMissingFirst(t *testing.T) {
|
||||||
@@ -119,29 +119,30 @@ func TestGraphWithMissing(t *testing.T) {
|
|||||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/missing.yml", true)
|
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/missing.yml", true)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
out := log.StandardLogger().Out
|
out := logging.Output()
|
||||||
|
t.Cleanup(func() { logging.SetOutput(out) })
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
log.SetOutput(&buf)
|
logging.SetOutput(&buf)
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
plan, err := planner.PlanEvent("push")
|
plan, err := planner.PlanEvent("push")
|
||||||
assert.NotNil(t, plan)
|
assert.NotNil(t, plan)
|
||||||
assert.Empty(t, plan.Stages)
|
assert.Empty(t, plan.Stages)
|
||||||
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.EqualError(t, err, "unable to build dependency graph for missing (missing.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||||
log.SetOutput(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGraphWithSomeMissing(t *testing.T) {
|
func TestGraphWithSomeMissing(t *testing.T) {
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/", true)
|
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/", true)
|
||||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
out := log.StandardLogger().Out
|
out := logging.Output()
|
||||||
|
t.Cleanup(func() { logging.SetOutput(out) })
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
log.SetOutput(&buf)
|
logging.SetOutput(&buf)
|
||||||
log.SetLevel(log.DebugLevel)
|
logging.SetLevel(logging.LevelDebug)
|
||||||
|
|
||||||
plan, err := planner.PlanAll()
|
plan, err := planner.PlanAll()
|
||||||
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.Error(t, err, "unable to build dependency graph for no first (no-first.yml)") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -149,7 +150,6 @@ func TestGraphWithSomeMissing(t *testing.T) {
|
|||||||
assert.Len(t, plan.Stages, 1)
|
assert.Len(t, plan.Stages, 1)
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||||
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
|
assert.Contains(t, buf.String(), "unable to build dependency graph for no first (no-first.yml)")
|
||||||
log.SetOutput(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
|
func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
|
||||||
@@ -221,7 +221,7 @@ type TestJobFileInfo struct {
|
|||||||
func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config) {
|
func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config) {
|
||||||
fmt.Printf("::group::%s\n", j.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
fmt.Printf("::group::%s\n", j.workflowPath) //nolint:forbidigo // pre-existing issue from nektos/act
|
||||||
|
|
||||||
log.SetLevel(logLevel)
|
logging.SetLevel(logLevel)
|
||||||
|
|
||||||
workdir, err := filepath.Abs(j.workdir)
|
workdir, err := filepath.Abs(j.workdir)
|
||||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
@@ -281,7 +281,7 @@ func TestRunEvent(t *testing.T) {
|
|||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
@@ -384,7 +384,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
|
// nix-specific cases gate on their own tool below so a missing pwsh/nix skips just those.
|
||||||
requireHostTools(t, "bash", "node")
|
requireHostTools(t, "bash", "node")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := t.Context()
|
||||||
|
|
||||||
tables := []TestJobFileInfo{}
|
tables := []TestJobFileInfo{}
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
func TestDryrunEvent(t *testing.T) {
|
func TestDryrunEvent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
// Dryrun plans without containers or network (shells and local actions only).
|
// Dryrun plans without containers or network (shells and local actions only).
|
||||||
ctx := common.WithDryrun(context.Background(), true)
|
ctx := common.WithDryrun(t.Context(), true)
|
||||||
|
|
||||||
tables := []TestJobFileInfo{
|
tables := []TestJobFileInfo{
|
||||||
// Shells
|
// Shells
|
||||||
@@ -513,7 +513,7 @@ func TestReusableWorkflowCaller(t *testing.T) {
|
|||||||
requireDocker(t)
|
requireDocker(t)
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
table := TestJobFileInfo{workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}
|
||||||
table.runTest(context.Background(), t, &Config{Secrets: table.secrets})
|
table.runTest(t.Context(), t, &Config{Secrets: table.secrets})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventSecrets(t *testing.T) {
|
func TestRunEventSecrets(t *testing.T) {
|
||||||
@@ -534,7 +534,7 @@ func TestRunEventSecrets(t *testing.T) {
|
|||||||
secrets, _ := godotenv.Read(filepath.Join(workdir, workflowPath, ".secrets"))
|
secrets, _ := godotenv.Read(filepath.Join(workdir, workflowPath, ".secrets"))
|
||||||
assert.NoError(t, err, "Failed to read .secrets") //nolint:testifylint // pre-existing issue from nektos/act
|
assert.NoError(t, err, "Failed to read .secrets") //nolint:testifylint // pre-existing issue from nektos/act
|
||||||
|
|
||||||
tjfi.runTest(context.Background(), t, &Config{Secrets: secrets, Env: env})
|
tjfi.runTest(t.Context(), t, &Config{Secrets: secrets, Env: env})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunEventPullRequest(t *testing.T) {
|
func TestRunEventPullRequest(t *testing.T) {
|
||||||
@@ -551,5 +551,5 @@ func TestRunEventPullRequest(t *testing.T) {
|
|||||||
platforms: platforms,
|
platforms: platforms,
|
||||||
}
|
}
|
||||||
|
|
||||||
tjfi.runTest(context.Background(), t, &Config{EventPath: filepath.Join(workdir, workflowPath, "event.json")})
|
tjfi.runTest(t.Context(), t, &Config{EventPath: filepath.Join(workdir, workflowPath, "event.json")})
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
+22
-15
@@ -47,11 +47,11 @@ const maxSymlinkDepth = 10
|
|||||||
func (s stepStage) String() string {
|
func (s stepStage) String() string {
|
||||||
switch s {
|
switch s {
|
||||||
case stepStagePre:
|
case stepStagePre:
|
||||||
return "Pre"
|
return StagePre
|
||||||
case stepStageMain:
|
case stepStageMain:
|
||||||
return "Main"
|
return StageMain
|
||||||
case stepStagePost:
|
case stepStagePost:
|
||||||
return "Post"
|
return StagePost
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@@ -97,14 +97,14 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
|
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepModel)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !runStep {
|
if !runStep {
|
||||||
stepResult.Conclusion = model.StepStatusSkipped
|
stepResult.Conclusion = model.StepStatusSkipped
|
||||||
stepResult.Outcome = model.StepStatusSkipped
|
stepResult.Outcome = model.StepStatusSkipped
|
||||||
logger.WithField("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
logger.With("stepResult", stepResult.Conclusion).Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +112,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if strings.Contains(stepString, "::add-mask::") {
|
if strings.Contains(stepString, "::add-mask::") {
|
||||||
stepString = "add-mask command"
|
stepString = "add-mask command"
|
||||||
}
|
}
|
||||||
|
startLogger := logger.With(StepStartedField, true)
|
||||||
if stage == stepStageMain {
|
if stage == stepStageMain {
|
||||||
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
// Main steps print their own raw "Run <title>" header, so this line is redundant and
|
||||||
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
// only leaks into the "Set up job" section for the first step; keep it as a debug trace.
|
||||||
logger.Debugf("Run %s %s", stage, stepString)
|
startLogger.Debugf("Run %s %s", stage, stepString)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof("Run %s %s", stage, stepString)
|
startLogger.Infof("Run %s %s", stage, stepString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare and clean Runner File Commands
|
// Prepare and clean Runner File Commands
|
||||||
@@ -186,29 +187,35 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
err = insecureErr
|
err = insecureErr
|
||||||
}
|
}
|
||||||
if fileErr := processRunnerEnvFileCommand(ctx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
|
fileCommandCtx := ctx
|
||||||
|
if ctx.Err() != nil { // an interrupted step still records its file commands, a live deadline stays in force
|
||||||
|
var cancelFileCommands context.CancelFunc
|
||||||
|
fileCommandCtx, cancelFileCommands = context.WithTimeout(context.WithoutCancel(ctx), jobCleanupTimeout)
|
||||||
|
defer cancelFileCommands()
|
||||||
|
}
|
||||||
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, envFileCommand, rc, rc.setEnvFile); fileErr != nil && err == nil {
|
||||||
err = fileErr
|
err = fileErr
|
||||||
}
|
}
|
||||||
if fileErr := processRunnerEnvFileCommand(ctx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, stateFileCommand, rc, rc.saveState); fileErr != nil && err == nil {
|
||||||
err = fileErr
|
err = fileErr
|
||||||
}
|
}
|
||||||
if fileErr := processRunnerEnvFileCommand(ctx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
|
if fileErr := processRunnerEnvFileCommand(fileCommandCtx, outputFileCommand, rc, rc.setOutput); fileErr != nil && err == nil {
|
||||||
err = fileErr
|
err = fileErr
|
||||||
}
|
}
|
||||||
if fileErr := rc.UpdateExtraPath(ctx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
|
if fileErr := rc.UpdateExtraPath(fileCommandCtx, path.Join(actPath, pathFileCommand)); fileErr != nil && err == nil {
|
||||||
err = fileErr
|
err = fileErr
|
||||||
}
|
}
|
||||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
_ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
logger.WithField("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
|
logger.With("stepResult", stepResult.Conclusion).Infof("Success - %s %s", stage, stepString)
|
||||||
} else {
|
} else {
|
||||||
stepResult.Outcome = model.StepStatusFailure
|
stepResult.Outcome = model.StepStatusFailure
|
||||||
|
|
||||||
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
stepResult.Conclusion = model.StepStatusFailure
|
stepResult.Conclusion = model.StepStatusFailure
|
||||||
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
return parseErr
|
return parseErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +230,7 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
|||||||
|
|
||||||
// Infof: Errorf entries are promoted to the user log by the reporter,
|
// Infof: Errorf entries are promoted to the user log by the reporter,
|
||||||
// which would duplicate the ##[error] annotation emitted elsewhere.
|
// which would duplicate the ##[error] annotation emitted elsewhere.
|
||||||
logger.WithField("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
logger.With("stepResult", stepResult.Conclusion).Infof("Failure - %s %s", stage, stepString)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext)
|
printRunActionHeader(ctx, sab.Step, sab.env, sab.RunContext)
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
|
||||||
rc := sab.RunContext
|
rc := sab.RunContext
|
||||||
|
|||||||
@@ -43,11 +43,11 @@ func (sal *stepActionLocal) main() common.Executor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
|
printRunActionHeader(ctx, sal.Step, sal.env, sal.getRunContext())
|
||||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||||
defer rawLogger.Infof("::endgroup::")
|
defer rawLogger.Infof("::endgroup::")
|
||||||
|
|
||||||
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
|
||||||
_, containerActionPath := getContainerActionPaths(sal.Step, path.Join(actionDir, ""), sal.RunContext)
|
_, containerActionPath := getContainerActionPaths(sal)
|
||||||
|
|
||||||
localReader := func(filename string) (io.Reader, io.Closer, error) {
|
localReader := func(filename string) (io.Reader, io.Closer, error) {
|
||||||
spath := path.Join(containerActionPath, filename)
|
spath := path.Join(containerActionPath, filename)
|
||||||
@@ -127,8 +127,7 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
|
|||||||
|
|
||||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||||
if sal.compositeRunContext == nil {
|
if sal.compositeRunContext == nil {
|
||||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
_, containerActionDir := getContainerActionPaths(sal)
|
||||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
|
||||||
|
|
||||||
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user