mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 06:45:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d84ec6dab | ||
|
|
b769a9765f | ||
|
|
9c4c66e70f | ||
|
|
42858c61df | ||
|
|
470969dc74 | ||
|
|
be7ddcd15a | ||
|
|
c3c647ef83 | ||
|
|
160e18e705 | ||
|
|
20c8b4abc4 | ||
|
|
ccb9afd7a5 | ||
|
|
49e34813ae | ||
|
|
e0ef637805 | ||
|
|
8502663d0e | ||
|
|
4e8037a93b | ||
|
|
76110557ab | ||
|
|
3bc5cc03f8 | ||
|
|
de961b91eb | ||
|
|
ad9eeb668a | ||
|
|
b741335222 | ||
|
|
20143c9558 | ||
|
|
8b14a38d4a | ||
|
|
179e435cd0 | ||
|
|
f963e9cfe8 | ||
|
|
e5aa9add02 | ||
|
|
329115b148 | ||
|
|
ff30f39b1b | ||
|
|
3802feb3fe | ||
|
|
27e9b4de87 | ||
|
|
4bedadef9d | ||
|
|
83b63250a3 | ||
|
|
2e59141ae9 | ||
|
|
020f0d2861 | ||
|
|
711b72735b | ||
|
|
436d0d2942 | ||
|
|
a284a62d96 | ||
|
|
2b1e64e414 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
- run: make lint-pr-title
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
name: checks
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||
@@ -30,7 +28,7 @@ jobs:
|
||||
# daemon retains them between runs, so this is usually a fast manifest re-check.
|
||||
- name: pre-pull test images
|
||||
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
|
||||
run: |
|
||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||
|
||||
@@ -39,6 +39,15 @@ linters:
|
||||
deny:
|
||||
- pkg: golang.org/x/exp
|
||||
desc: it's experimental and unreliable
|
||||
logging:
|
||||
files:
|
||||
- $all
|
||||
- "!**/act/common/logging/**"
|
||||
deny:
|
||||
- pkg: github.com/sirupsen/logrus
|
||||
desc: log through gitea.com/gitea/runner/act/common/logging
|
||||
- pkg: log/slog
|
||||
desc: log through gitea.com/gitea/runner/act/common/logging
|
||||
nolintlint:
|
||||
allow-unused: false
|
||||
require-explanation: true
|
||||
|
||||
@@ -31,6 +31,26 @@ depending on the prefix:
|
||||
- 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.
|
||||
|
||||
## Logging
|
||||
|
||||
Log through `act/common/logging`; the linter rejects importing `log/slog` or logrus 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 logrus's or slog's global logger are forwarded to the process-wide
|
||||
one, so they follow its level and masking too.
|
||||
- That forwarding only works while logrus's global logger is left as `logging` configures it. A
|
||||
dependency that calls `logrus.SetOutput` or `SetLevel` breaks it, so check
|
||||
`logging.LogrusBridgeIntact()` when adding or upgrading one.
|
||||
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||
package functions.
|
||||
|
||||
## Built-in actions
|
||||
|
||||
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
||||
|
||||
@@ -9,7 +9,7 @@ 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
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # 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
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
|
||||
@@ -24,7 +24,7 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]3.2 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]4.0 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||
On the runner:
|
||||
|
||||
- `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/).
|
||||
|
||||
Where steps run, in the job image or on the runner in host mode:
|
||||
|
||||
- `bash` or `sh` for `run` steps, `pwsh` or `powershell` on a Windows host.
|
||||
- `node` for JavaScript actions and `hashFiles()`.
|
||||
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
|
||||
- `git-lfs` and `ssh` for `builtin:checkout`, only when its `lfs` or `ssh-key` input is used.
|
||||
|
||||
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
||||
|
||||
### Download pre-built binary
|
||||
|
||||
@@ -157,7 +169,7 @@ Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavo
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. Config values can read the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`. An unset `NAME` fails the load, and `$${NAME}` is a literal `${NAME}`. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
|
||||
### Labels
|
||||
|
||||
@@ -205,7 +217,7 @@ a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubunt
|
||||
|
||||
Names may themselves contain a colon, for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`. Only `host`, `docker` and `kubernetes` are treated as schemas.
|
||||
|
||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs in `runner.default_image`: as a container when the runner has `docker` labels, as a pod when it has `kubernetes` labels, else as a container where docker is available and on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
Jobs run on `runner.default_image` when no runner label matches their `runs-on`: as a pod on runners with `kubernetes` labels, otherwise in a container, or on the host when docker is unavailable. Suitable images are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||
|
||||
@@ -252,7 +264,7 @@ volumes:
|
||||
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||
```
|
||||
|
||||
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. Host jobs use their existing Docker access, where `.` works. The proxy removes the containers, networks and volumes a job creates, `docker compose` services included, after post steps and the completed hook. Set `container.sweep: false` when jobs deploy services to the runner's Docker daemon.
|
||||
|
||||
#### Proxy
|
||||
|
||||
@@ -356,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)
|
||||
- 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
|
||||
- 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
|
||||
- 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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -30,10 +29,10 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/internal/pkg/disk"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/timshannon/bolthold"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -85,7 +84,7 @@ type Handler struct {
|
||||
listener net.Listener
|
||||
port int
|
||||
server *http.Server
|
||||
logger logrus.FieldLogger
|
||||
logger *logging.Logger
|
||||
|
||||
gcing atomic.Bool
|
||||
gcAt time.Time
|
||||
@@ -133,7 +132,7 @@ type Options struct {
|
||||
InternalSecret string
|
||||
|
||||
Policy Policy
|
||||
Logger logrus.FieldLogger
|
||||
Logger *logging.Logger
|
||||
}
|
||||
|
||||
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
|
||||
@@ -147,11 +146,9 @@ func StartHandler(opts Options) (*Handler, error) {
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
discard := logrus.New()
|
||||
discard.Out = io.Discard
|
||||
logger = discard
|
||||
logger = logging.New(logging.DiscardHandler)
|
||||
}
|
||||
logger = logger.WithField("module", "artifactcache")
|
||||
logger = logger.With("module", "artifactcache")
|
||||
h.logger = logger
|
||||
|
||||
if opts.OutboundIP != "" {
|
||||
|
||||
@@ -230,7 +230,8 @@ func Serve(ctx context.Context, artifactPath, addr, port string) context.CancelF
|
||||
go func() {
|
||||
logger.Infof("Start server on http://%s", server.Addr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal(err)
|
||||
logger.Error(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
if Dryrun(ctx) {
|
||||
t.Fatal("plain context should not be dryrun")
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func TestDryrunContext(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJobErrorContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
err := errors.New("job failed")
|
||||
|
||||
SetJobError(ctx, err)
|
||||
@@ -40,34 +40,27 @@ func TestJobErrorContainer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerAndHookContext(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if Logger(ctx) != logrus.StandardLogger() {
|
||||
t.Fatal("plain context should use standard logger")
|
||||
func TestLoggerAndHandlerContext(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
if Logger(ctx) != logging.Default() {
|
||||
t.Fatal("plain context should use the default logger")
|
||||
}
|
||||
if LoggerHook(ctx) != nil {
|
||||
t.Fatal("plain context should not have a logger hook")
|
||||
if LoggerHandler(ctx) != nil {
|
||||
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)
|
||||
if Logger(ctx) != logger {
|
||||
t.Fatal("WithLogger should set logger")
|
||||
}
|
||||
|
||||
hook := testHook{}
|
||||
ctx = WithLoggerHook(ctx, hook)
|
||||
if LoggerHook(ctx) != hook {
|
||||
t.Fatal("WithLoggerHook should set hook")
|
||||
handler := logtest.New()
|
||||
ctx = WithLoggerHandler(ctx, handler)
|
||||
if LoggerHandler(ctx) != handler {
|
||||
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"
|
||||
"runtime/debug"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
)
|
||||
|
||||
// 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))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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++ {
|
||||
go func(workerID int, work <-chan Executor, errs chan<- error) {
|
||||
log.Debugf("Worker %d started", workerID)
|
||||
logging.Debugf("Worker %d started", workerID)
|
||||
taskCount := 0
|
||||
for executor := range work {
|
||||
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
|
||||
// goroutine which would leave the runner process hung.
|
||||
// https://gitea.com/gitea/runner/issues/371
|
||||
errs <- func() (err error) {
|
||||
defer func() {
|
||||
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)
|
||||
}
|
||||
}()
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
func TestNewWorkflow(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
// empty
|
||||
emptyWorkflow := NewPipelineExecutor()
|
||||
@@ -72,7 +72,7 @@ func concurrencyProbe(wantActive int32) (exec Executor, count, maxActive *atomic
|
||||
}
|
||||
|
||||
func TestNewParallelExecutor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
exec, count, maxActive := concurrencyProbe(2)
|
||||
require.NoError(t, NewParallelExecutor(2, exec, exec, exec)(ctx))
|
||||
@@ -89,10 +89,10 @@ func TestNewParallelExecutor(t *testing.T) {
|
||||
func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
require.NoError(t, NewParallelExecutor(2)(ctx))
|
||||
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
canceledCtx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
err := NewParallelExecutor(2)(canceledCtx)
|
||||
@@ -102,7 +102,7 @@ func TestNewParallelExecutorEmpty(t *testing.T) {
|
||||
func TestNewParallelExecutorFailed(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
count := 0
|
||||
@@ -118,7 +118,7 @@ func TestNewParallelExecutorFailed(t *testing.T) {
|
||||
func TestNewParallelExecutorCanceled(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func TestExecutorConditionalsAndFinally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
var calls []string
|
||||
record := func(name string) Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -180,7 +180,7 @@ func TestExecutorFinallyReturnsFinallyErrorWithOriginal(t *testing.T) {
|
||||
mainErr := errors.New("main 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)
|
||||
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)
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
+310
-410
@@ -6,62 +6,67 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
nethttp "net/http"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
codeCommitHTTPRegex = regexp.MustCompile(`^https?://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]+$`)
|
||||
|
||||
LocalEnvVars = []string{ // `git rev-parse --local-env-vars`, inherited ones point git at another repository
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY",
|
||||
"GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE", "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS",
|
||||
"GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR",
|
||||
}
|
||||
|
||||
cloneLocks lock.Keyed[string] // key: clone target directory
|
||||
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpClient := newHTTPClient(&nethttp.HTTP2Config{SendPingTimeout: 30 * time.Second})
|
||||
client.InstallProtocol("http", httpClient)
|
||||
client.InstallProtocol("https", httpClient)
|
||||
}
|
||||
|
||||
// newHTTPClient pings idle HTTP/2 connections to drop stalled ones.
|
||||
func newHTTPClient(h2 *nethttp.HTTP2Config) transport.Transport {
|
||||
defaultTransport, _ := nethttp.DefaultTransport.(*nethttp.Transport)
|
||||
httpTransport := defaultTransport.Clone()
|
||||
httpTransport.HTTP2 = h2
|
||||
return http.NewClient(&nethttp.Client{Transport: httpTransport})
|
||||
}
|
||||
|
||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
// otherwise a concurrent NewGitCloneExecutor on the same dir can mutate the worktree mid-read.
|
||||
func AcquireCloneLock(dir string) func() {
|
||||
return cloneLocks.Lock(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.
|
||||
func AcquireCloneLock(ctx context.Context, dir string) (func(), error) {
|
||||
dir = filepath.Clean(dir)
|
||||
if runtime.GOOS == "plan9" {
|
||||
return cloneLocks.Lock(dir), nil
|
||||
}
|
||||
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 {
|
||||
@@ -69,196 +74,97 @@ type Error struct {
|
||||
commit string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *Error) Commit() string {
|
||||
return e.commit
|
||||
}
|
||||
|
||||
// goGitMu serializes go-git repository access across the process. go-git is not safe for
|
||||
// concurrent use of the same repository (even read access decodes packfiles into shared
|
||||
// state), so parallel jobs inspecting the shared workdir repo race without this. The guarded
|
||||
// operations are fast local reads; gitea runs one job per process, so the lock is effectively
|
||||
// uncontended in production.
|
||||
var goGitMu sync.Mutex
|
||||
func (e *Error) Error() string { return e.err.Error() }
|
||||
func (e *Error) Unwrap() error { return e.err }
|
||||
func (e *Error) Commit() string { return e.commit }
|
||||
|
||||
// FindGitRevision get the current git revision
|
||||
func FindGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
return findGitRevision(ctx, file)
|
||||
}
|
||||
|
||||
func findGitRevision(ctx context.Context, file string) (shortSha, sha string, err error) {
|
||||
func FindGitRevision(ctx context.Context, file string) (shortSHA, sha string, err error) {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
gitDir, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
sha, err = gitOutput(ctx, file, "rev-parse", "--verify", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
logger.WithError(err).Error("path", file, "not located inside a git repository")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
head, err := gitDir.Reference(plumbing.HEAD, true)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
if len(sha) < 7 {
|
||||
return "", "", errors.New("HEAD could not be resolved")
|
||||
}
|
||||
|
||||
if head.Hash().IsZero() {
|
||||
return "", "", errors.New("HEAD sha1 could not be resolved")
|
||||
}
|
||||
|
||||
hash := head.Hash().String()
|
||||
|
||||
logger.Debugf("Found revision: %s", hash)
|
||||
return hash[:7], strings.TrimSpace(hash), nil
|
||||
logger.Debugf("Found revision: %s", sha)
|
||||
return sha[:7], sha, nil
|
||||
}
|
||||
|
||||
// FindGitRef get the current git ref
|
||||
func FindGitRef(ctx context.Context, file string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
logger.Debugf("Loading revision from git directory")
|
||||
_, ref, err := findGitRevision(ctx, file)
|
||||
_, revision, err := FindGitRevision(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Debugf("HEAD points to '%s'", revision)
|
||||
|
||||
logger.Debugf("HEAD points to '%s'", ref)
|
||||
|
||||
// Prefer the git library to iterate over the references and find a matching tag or branch.
|
||||
refTag := ""
|
||||
refBranch := ""
|
||||
repo, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
refs, err := gitOutput(ctx, file, "for-each-ref", "--points-at=HEAD", "--format=%(refname)", "refs/tags", "refs/heads")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
iter, err := repo.References()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// find the reference that matches the revision's has
|
||||
err = iter.ForEach(func(r *plumbing.Reference) error {
|
||||
/* tags and branches will have the same hash
|
||||
* when a user checks out a tag, it is not mentioned explicitly
|
||||
* in the go-git package, we must identify the revision
|
||||
* then check if any tag matches that revision,
|
||||
* if so then we checked out a tag
|
||||
* else we look for branches and if matches,
|
||||
* it means we checked out a branch
|
||||
*
|
||||
* If a branches matches first we must continue and check all tags (all references)
|
||||
* in case we match with a tag later in the interation
|
||||
*/
|
||||
if r.Hash().String() == ref {
|
||||
if r.Name().IsTag() {
|
||||
refTag = r.Name().String()
|
||||
}
|
||||
if r.Name().IsBranch() {
|
||||
refBranch = r.Name().String()
|
||||
}
|
||||
var tag, branch string
|
||||
for ref := range strings.FieldsSeq(refs) {
|
||||
switch {
|
||||
case strings.HasPrefix(ref, "refs/tags/"):
|
||||
tag = ref
|
||||
case strings.HasPrefix(ref, "refs/heads/"):
|
||||
branch = ref
|
||||
}
|
||||
|
||||
// we found what we where looking for
|
||||
if refTag != "" && refBranch != "" {
|
||||
return storer.ErrStop
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// order matters here see above comment.
|
||||
if refTag != "" {
|
||||
return refTag, nil
|
||||
if tag != "" {
|
||||
return tag, nil
|
||||
}
|
||||
if refBranch != "" {
|
||||
return refBranch, nil
|
||||
if branch != "" {
|
||||
return branch, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", ref)
|
||||
return "", fmt.Errorf("failed to identify reference (tag/branch) for the checked-out revision '%s'", revision)
|
||||
}
|
||||
|
||||
// FindGithubRepo get the repo
|
||||
func FindGithubRepo(ctx context.Context, file, githubInstance string) (string, error) {
|
||||
goGitMu.Lock()
|
||||
defer goGitMu.Unlock()
|
||||
|
||||
url, err := findGitRemoteURL(ctx, file, "origin")
|
||||
func FindGithubRepo(ctx context.Context, file string) (string, error) {
|
||||
url, err := findGitRemoteURL(ctx, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, slug := findGitSlug(url, githubInstance)
|
||||
return slug, nil
|
||||
return findGitSlug(url)
|
||||
}
|
||||
|
||||
func findGitRemoteURL(_ context.Context, file, remoteName string) (string, error) {
|
||||
repo, err := git.PlainOpenWithOptions(
|
||||
file,
|
||||
&git.PlainOpenOptions{
|
||||
DetectDotGit: true,
|
||||
EnableDotGitCommonDir: true,
|
||||
},
|
||||
)
|
||||
func findGitRemoteURL(ctx context.Context, file string) (string, error) {
|
||||
urls, err := gitOutput(ctx, file, "config", "--get-all", "remote.origin.url") // `remote get-url` applies insteadOf
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
remote, err := repo.Remote(remoteName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if urls == "" {
|
||||
return "", errors.New("remote 'origin' exists but has no URL")
|
||||
}
|
||||
|
||||
if len(remote.Config().URLs) < 1 {
|
||||
return "", fmt.Errorf("remote '%s' exists but has no URL", remoteName)
|
||||
}
|
||||
|
||||
return remote.Config().URLs[0], nil
|
||||
url, _, _ := strings.Cut(urls, "\n")
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func findGitSlug(url, githubInstance string) (string, string) {
|
||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "CodeCommit", matches[2]
|
||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(url); matches != nil {
|
||||
return "CodeCommit", matches[2]
|
||||
} 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])
|
||||
}
|
||||
func findGitSlug(remoteURL string) (string, error) {
|
||||
if matches := codeCommitHTTPRegex.FindStringSubmatch(remoteURL); matches != nil {
|
||||
return matches[2], nil
|
||||
} else if matches := codeCommitSSHRegex.FindStringSubmatch(remoteURL); matches != nil {
|
||||
return matches[2], nil
|
||||
}
|
||||
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
|
||||
@@ -273,214 +179,179 @@ type NewGitCloneExecutorInput struct {
|
||||
// 0 for full clone.
|
||||
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
|
||||
InsecureSkipTLS bool
|
||||
InsecureSkipTLS bool
|
||||
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
||||
Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport
|
||||
}
|
||||
|
||||
// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused.
|
||||
func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) {
|
||||
r, err := git.PlainOpen(input.Dir)
|
||||
// CloneIfRequired reports whether an existing local clone was reused.
|
||||
func CloneIfRequired(ctx context.Context, input NewGitCloneExecutorInput, logger *logging.Logger) (bool, error) {
|
||||
var origin string
|
||||
_, err := os.Stat(filepath.Join(input.Dir, ".git")) // without it git would discover a repository around Dir
|
||||
if err == nil {
|
||||
// Verify the cached clone still points to the resolved URL before reusing it.
|
||||
remote, err := r.Remote("origin")
|
||||
if err == nil && len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == input.URL {
|
||||
// Reuse existing clone
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||
case len(remote.Config().URLs) == 0:
|
||||
logger.Debugf("Removing cached clone at %s because origin has no URL", input.Dir)
|
||||
default:
|
||||
logger.Debugf("Removing cached clone at %s because origin URL changed from %s to %s", input.Dir, remote.Config().URLs[0], input.URL)
|
||||
}
|
||||
if err := os.RemoveAll(input.Dir); err != nil {
|
||||
return nil, false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||
}
|
||||
origin, err = findGitRemoteURL(ctx, input.Dir)
|
||||
}
|
||||
|
||||
var progressWriter io.Writer
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
if entry, ok := logger.(*log.Entry); ok {
|
||||
progressWriter = entry.WriterLevel(log.DebugLevel)
|
||||
} else if lgr, ok := logger.(*log.Logger); ok {
|
||||
progressWriter = lgr.WriterLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.Errorf("Unable to get writer from logger (type=%T)", logger)
|
||||
progressWriter = os.Stdout
|
||||
}
|
||||
if err == nil && origin == input.URL {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
cloneOptions := git.CloneOptions{
|
||||
URL: input.URL,
|
||||
Progress: progressWriter,
|
||||
|
||||
InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea
|
||||
if ctxErr := ctx.Err(); ctxErr != nil { // a cancelled origin lookup must not discard a valid clone
|
||||
return false, ctxErr
|
||||
}
|
||||
if input.Token != "" {
|
||||
cloneOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
if err == nil {
|
||||
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 {
|
||||
logger.Debugf("Removing cached clone at %s because origin cannot be read: %v", input.Dir, err)
|
||||
}
|
||||
|
||||
r, err = cloneAtDepth(ctx, input, cloneOptions, logger)
|
||||
if err != nil {
|
||||
logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err)
|
||||
return nil, false, err
|
||||
if err := os.RemoveAll(input.Dir); err != nil {
|
||||
return false, fmt.Errorf("remove cached clone %s: %w", input.Dir, err)
|
||||
}
|
||||
|
||||
if err = os.Chmod(input.Dir, 0o755); err != nil {
|
||||
return nil, false, err
|
||||
if err := clone(ctx, input); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return r, false, nil
|
||||
if err := os.Chmod(input.Dir, 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// NewGitCloneExecutor creates an executor to clone git repos
|
||||
func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
if input.Quiet {
|
||||
logger.Debugf("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
} else {
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
}
|
||||
logger.Infof("git clone '%s' # ref=%s", input.URL, input.Ref)
|
||||
logger.Debugf(" cloning %s to %s", input.URL, input.Dir)
|
||||
|
||||
defer AcquireCloneLock(input.Dir)()
|
||||
|
||||
refName := plumbing.ReferenceName("refs/heads/" + input.Ref)
|
||||
r, reused, err := CloneIfRequired(ctx, refName, input, logger)
|
||||
unlock, err := AcquireCloneLock(ctx, input.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
isOfflineMode := input.OfflineMode
|
||||
|
||||
fetchOptions := git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{"refs/*:refs/*", "HEAD:refs/heads/HEAD", "refs/heads/*:refs/remotes/origin/*"},
|
||||
Force: true,
|
||||
InsecureSkipTLS: input.InsecureSkipTLS,
|
||||
}
|
||||
if input.Token != "" {
|
||||
fetchOptions.Auth = &http.BasicAuth{
|
||||
Username: "token",
|
||||
Password: input.Token,
|
||||
}
|
||||
}
|
||||
|
||||
// Action clones only ever need the tip commit, so keep a shallow cache cheap on update at depth 1 regardless of its original depth
|
||||
// Turning action_shallow_clone off does not convert an existing shallow cache; evict it for a full clone.
|
||||
if isShallow(r) {
|
||||
fetchOptions.Depth = 1
|
||||
if spec, ok := shallowFetchRefSpec(r, input.Ref); ok {
|
||||
fetchOptions.RefSpecs = []config.RefSpec{spec}
|
||||
}
|
||||
}
|
||||
|
||||
// A just-cloned ref is as current as a fetch would make it, and a commit hash never moves.
|
||||
// TODO: revalidate a mutable ref with a conditional archive request instead, once every
|
||||
// supported Gitea sends an ETag for them: https://github.com/go-gitea/gitea/pull/39289
|
||||
_, present := refRevision(r, input.Ref)
|
||||
refresh := !isOfflineMode && (!present || (reused && !plumbing.IsHash(input.Ref)))
|
||||
|
||||
if refresh {
|
||||
err = r.FetchContext(ctx, &fetchOptions)
|
||||
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var hash *plumbing.Hash
|
||||
rev := plumbing.Revision(input.Ref)
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
// ResolveRevision returns a nil hash on error, and a branch ref legitimately fails
|
||||
// here (no local refs/heads/<ref>); the duck-typing below resolves it.
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
} else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) {
|
||||
return &Error{
|
||||
err: ErrShortRef,
|
||||
commit: hash.String(),
|
||||
}
|
||||
}
|
||||
|
||||
rev, _ = refRevision(r, input.Ref)
|
||||
|
||||
if hash, err = r.ResolveRevision(rev); err != nil {
|
||||
logger.Errorf("Unable to resolve %s: %v", input.Ref, err)
|
||||
resolved, reused, err := resolveClone(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var w *git.Worktree
|
||||
if w, err = r.Worktree(); err != nil {
|
||||
return err
|
||||
if _, err := runGit(ctx, input.Dir, nil, "checkout", "--force", "--detach", resolved); err != nil {
|
||||
return fmt.Errorf("checkout %s: %w", resolved, err)
|
||||
}
|
||||
|
||||
reusedMsg := ""
|
||||
if isOfflineMode && reused {
|
||||
if input.OfflineMode && reused {
|
||||
reusedMsg = " (reused in offline mode)"
|
||||
}
|
||||
|
||||
logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg)
|
||||
|
||||
if err = w.Checkout(&git.CheckoutOptions{
|
||||
Hash: *hash,
|
||||
Force: true,
|
||||
}); err != nil {
|
||||
logger.Errorf("Unable to checkout %s: %v", *hash, err)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debugf("Checked out %s", input.Ref)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// cloneAtDepth falls back to a full clone when the shallow attempt fails.
|
||||
func cloneAtDepth(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions, logger log.FieldLogger) (*git.Repository, error) {
|
||||
if input.Depth > 0 {
|
||||
if plumbing.IsHash(input.Ref) {
|
||||
r, err := fetchPinnedSHA(ctx, input, opts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow fetch of %s at %s failed: %v", input.URL, input.Ref, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
refNames := []plumbing.ReferenceName{plumbing.NewBranchReferenceName(input.Ref), plumbing.NewTagReferenceName(input.Ref)}
|
||||
if input.Ref == plumbing.HEAD.String() || strings.HasPrefix(input.Ref, "refs/") {
|
||||
refNames = []plumbing.ReferenceName{plumbing.ReferenceName(input.Ref)} // HEAD is the remote's default branch
|
||||
}
|
||||
for _, refName := range refNames {
|
||||
shallowOpts := opts
|
||||
shallowOpts.Depth = input.Depth
|
||||
shallowOpts.SingleBranch = true
|
||||
shallowOpts.ReferenceName = refName
|
||||
shallowOpts.Tags = git.NoTags
|
||||
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
|
||||
}
|
||||
|
||||
r, err := git.PlainCloneContext(ctx, input.Dir, false, &shallowOpts)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
logger.Debugf("Shallow clone of %s as %s failed: %v", input.URL, refName, err)
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func clone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||
if input.Depth > 0 && isFullObjectID(input.Ref) {
|
||||
if err := shallowPinnedClone(ctx, input); err == nil {
|
||||
return nil
|
||||
} else if err := removePartialClone(input.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
args := []string{"clone", "--no-checkout"}
|
||||
if input.Depth > 0 {
|
||||
args = append(args, "--no-local", "--depth", strconv.Itoa(input.Depth), "--no-tags")
|
||||
if input.Ref != "HEAD" && !strings.HasPrefix(input.Ref, "refs/") {
|
||||
args = append(args, "--branch", input.Ref)
|
||||
}
|
||||
}
|
||||
args = append(args, input.URL, input.Dir)
|
||||
if _, err := runGit(ctx, "", &input, args...); err == nil {
|
||||
return nil
|
||||
} else if input.Depth == 0 {
|
||||
return err
|
||||
}
|
||||
if err := removePartialClone(input.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runGit(ctx, "", &input, "clone", "--no-checkout", input.URL, input.Dir)
|
||||
return err
|
||||
}
|
||||
|
||||
func shallowPinnedClone(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||
if _, err := runGit(ctx, "", &input, "init", "--object-format="+ObjectFormat(input.Ref), input.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runGit(ctx, input.Dir, &input, "remote", "add", "origin", input.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := runGit(ctx, input.Dir, &input, "fetch", "--depth", strconv.Itoa(input.Depth), "--no-tags", "origin", "+"+input.Ref+":refs/pinned/"+input.Ref)
|
||||
return err
|
||||
}
|
||||
|
||||
func fetch(ctx context.Context, input NewGitCloneExecutorInput) error {
|
||||
args := []string{"fetch", "--force", "--no-prune"}
|
||||
if _, err := os.Stat(filepath.Join(input.Dir, ".git", "shallow")); err == nil {
|
||||
args = append(args, "--depth", "1", "--no-tags")
|
||||
}
|
||||
_, err := runGit(ctx, input.Dir, &input, append(args, "origin", "+"+input.Ref+":"+cachedRef(input.Ref))...) // git picks the remote ref, a tag before a branch
|
||||
return err
|
||||
}
|
||||
|
||||
func cachedRef(ref string) string {
|
||||
return fmt.Sprintf("refs/runner/%x", sha256.Sum256([]byte(ref)))
|
||||
}
|
||||
|
||||
func resolveRef(ctx context.Context, dir, ref string) (string, error) {
|
||||
if !isFullObjectID(ref) {
|
||||
for _, name := range []string{cachedRef(ref), "refs/tags/" + ref, "refs/remotes/origin/" + ref} {
|
||||
if sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", name+"^{commit}"); err == nil {
|
||||
return sha, nil
|
||||
}
|
||||
}
|
||||
logger.Debugf("Falling back to a full clone of %s for ref %q", input.URL, input.Ref)
|
||||
}
|
||||
sha, err := gitOutput(ctx, dir, "rev-parse", "--verify", ref+"^{commit}")
|
||||
if err == nil && hexRefRegex.MatchString(ref) && len(sha) != len(ref) {
|
||||
return "", &Error{err: ErrShortRef, commit: sha}
|
||||
}
|
||||
return sha, err
|
||||
}
|
||||
|
||||
return git.PlainCloneContext(ctx, input.Dir, false, &opts)
|
||||
func isFullObjectID(ref string) bool {
|
||||
return ObjectFormat(ref) != ""
|
||||
}
|
||||
|
||||
func isFullHash(ref, resolved string) bool {
|
||||
return isFullObjectID(ref) && len(ref) == len(resolved)
|
||||
}
|
||||
|
||||
// ObjectFormat returns the object format of a full commit hash, empty for anything else.
|
||||
func ObjectFormat(sha string) string {
|
||||
switch {
|
||||
case !hexRefRegex.MatchString(sha):
|
||||
return ""
|
||||
case len(sha) == 40:
|
||||
return "sha1"
|
||||
case len(sha) == 64:
|
||||
return "sha256"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func removePartialClone(dir string) error {
|
||||
@@ -490,71 +361,100 @@ func removePartialClone(dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchPinnedSHA(ctx context.Context, input NewGitCloneExecutorInput, opts git.CloneOptions) (*git.Repository, error) {
|
||||
r, err := git.PlainInit(input.Dir, false)
|
||||
func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
output, err := runGit(ctx, dir, nil, args...)
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
func RemoteConfig(token string, insecureSkipTLS bool) []string {
|
||||
var config []string
|
||||
if insecureSkipTLS {
|
||||
config = append(config, "http.sslVerify=false")
|
||||
}
|
||||
if token != "" {
|
||||
config = append(config, "http.extraHeader=", "http.extraHeader=Authorization: Basic "+base64.StdEncoding.EncodeToString([]byte("token:"+token)))
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// ConfigEnv applies `-c` style entries through the environment, keeping secrets out of argv.
|
||||
func ConfigEnv(config ...string) map[string]string {
|
||||
env := map[string]string{"GIT_CONFIG_COUNT": strconv.Itoa(len(config))}
|
||||
for i, entry := range config {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
env["GIT_CONFIG_KEY_"+strconv.Itoa(i)] = key
|
||||
env["GIT_CONFIG_VALUE_"+strconv.Itoa(i)] = value
|
||||
}
|
||||
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
|
||||
}
|
||||
if _, err := r.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{input.URL}}); 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)
|
||||
}
|
||||
}
|
||||
if err := r.FetchContext(ctx, &git.FetchOptions{
|
||||
RefSpecs: []config.RefSpec{pinnedRefSpec(input.Ref)},
|
||||
Depth: input.Depth,
|
||||
Tags: git.NoTags,
|
||||
Auth: opts.Auth,
|
||||
Progress: opts.Progress,
|
||||
InsecureSkipTLS: opts.InsecureSkipTLS,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
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 r, nil
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// pinnedRefSpec keeps a hash-shaped name out of refs/heads and refs/tags.
|
||||
func pinnedRefSpec(sha string) config.RefSpec {
|
||||
return config.RefSpec(fmt.Sprintf("+%s:refs/pinned/%s", sha, sha))
|
||||
}
|
||||
|
||||
// refRevision picks the revision to check out and reports whether it resolves locally.
|
||||
func refRevision(r *git.Repository, ref string) (plumbing.Revision, bool) {
|
||||
if plumbing.IsHash(ref) {
|
||||
// git ignores a ref named as 40 hex digits, so a full hash always denotes the commit itself.
|
||||
_, err := r.CommitObject(plumbing.NewHash(ref))
|
||||
return plumbing.Revision(ref), err == nil
|
||||
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
|
||||
if input != nil {
|
||||
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
||||
if input.ClientCertFile != "" {
|
||||
config = append(config, "http.sslCert="+input.ClientCertFile, "http.sslKey="+input.ClientKeyFile)
|
||||
}
|
||||
}
|
||||
if _, err := r.Tag(ref); err == nil {
|
||||
return plumbing.Revision(path.Join("refs", "tags", ref)), true
|
||||
if dir != "" {
|
||||
args = append([]string{"-C", dir}, args...)
|
||||
}
|
||||
remoteRef := plumbing.ReferenceName(path.Join("refs", "remotes", "origin", ref))
|
||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||
return plumbing.Revision(remoteRef), true
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool {
|
||||
name, _, _ := strings.Cut(entry, "=")
|
||||
return slices.Contains(LocalEnvVars, name)
|
||||
})
|
||||
for key, value := range ConfigEnv(config...) {
|
||||
cmd.Env = append(cmd.Env, key+"="+value)
|
||||
}
|
||||
rev := plumbing.Revision(ref)
|
||||
_, err := r.ResolveRevision(rev)
|
||||
return rev, err == nil
|
||||
}
|
||||
|
||||
// isShallow reports whether the local repository was cloned with a limited depth.
|
||||
func isShallow(r *git.Repository) bool {
|
||||
shallows, err := r.Storer.Shallow()
|
||||
return err == nil && len(shallows) > 0
|
||||
}
|
||||
|
||||
// shallowFetchRefSpec limits a shallow update to the requested ref, falling back to the broad refspec when it is not present locally.
|
||||
func shallowFetchRefSpec(r *git.Repository, ref string) (config.RefSpec, bool) {
|
||||
tagRef := plumbing.NewTagReferenceName(ref)
|
||||
if _, err := r.Reference(tagRef, false); err == nil {
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", tagRef, tagRef)), true
|
||||
cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
|
||||
if runtime.GOOS != "windows" {
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } // lets git remove its lock files
|
||||
}
|
||||
remoteRef := plumbing.NewRemoteReferenceName("origin", ref)
|
||||
if _, err := r.Reference(remoteRef, false); err == nil {
|
||||
branchRef := plumbing.NewBranchReferenceName(ref)
|
||||
return config.RefSpec(fmt.Sprintf("+%s:%s", branchRef, remoteRef)), true
|
||||
cmd.WaitDelay = time.Second
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if plumbing.IsHash(ref) {
|
||||
// The broad refspec carries only advertised tips, never a pinned commit.
|
||||
return pinnedRefSpec(ref), true
|
||||
}
|
||||
return "", false
|
||||
return output, nil
|
||||
}
|
||||
+199
-238
@@ -6,9 +6,8 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -17,48 +16,47 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"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"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFindGitSlug(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
slugTests := []struct {
|
||||
url string // input
|
||||
provider string // expected result
|
||||
slug string // expected result
|
||||
for _, testcase := range []struct {
|
||||
remoteURL string
|
||||
slug string
|
||||
}{
|
||||
{"https://git-codecommit.us-east-1.amazonaws.com/v1/repos/my-repo-name", "CodeCommit", "my-repo-name"},
|
||||
{"ssh://git-codecommit.us-west-2.amazonaws.com/v1/repos/my-repo", "CodeCommit", "my-repo"},
|
||||
{"[email protected]:nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"[email protected]:nektos/act", "GitHub", "nektos/act"},
|
||||
{"https://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"http://github.com/nektos/act.git", "GitHub", "nektos/act"},
|
||||
{"https://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||
{"http://github.com/nektos/act", "GitHub", "nektos/act"},
|
||||
{"git+ssh://[email protected]/owner/repo.git", "GitHub", "owner/repo"},
|
||||
{"http://myotherrepo.com/act.git", "", "http://myotherrepo.com/act.git"},
|
||||
}
|
||||
|
||||
for _, tt := range slugTests {
|
||||
provider, slug := findGitSlug(tt.url, "github.com")
|
||||
assert.Equal(tt.provider, provider)
|
||||
assert.Equal(tt.slug, slug)
|
||||
{"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", "my-repo"},
|
||||
{"[email protected]:nektos/act.git", "nektos/act"},
|
||||
{"[email protected]:nektos/act", "nektos/act"},
|
||||
{"https://github.com/nektos/act.git", "nektos/act"},
|
||||
{"http://github.com/nektos/act.git", "nektos/act"},
|
||||
{"https://github.com/nektos/act", "nektos/act"},
|
||||
{"http://github.com/nektos/act", "nektos/act"},
|
||||
{"git+ssh://[email protected]/owner/repo.git", "owner/repo"},
|
||||
{"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"},
|
||||
{"http://myotherrepo.com/act.git", ""},
|
||||
{"https://user:[email protected]/act.git", ""},
|
||||
{"/tmp/owner/repo.git", ""},
|
||||
{"./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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,23 +88,37 @@ func cleanGitHooks(dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
||||
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
basedir := t.TempDir()
|
||||
const remoteURL = "https://github.com/owner/repo.git"
|
||||
require.NoError(t, gitCmd("init", basedir))
|
||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
||||
require.NoError(t, cleanGitHooks(basedir))
|
||||
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
||||
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"))
|
||||
|
||||
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||
shortSHA, sha, err := FindGitRevision(t.Context(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
||||
require.Len(t, sha, 64)
|
||||
require.Equal(t, sha[:7], shortSHA)
|
||||
|
||||
ref, err := FindGitRef(t.Context(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/heads/main", ref)
|
||||
|
||||
url, err := findGitRemoteURL(t.Context(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, remoteURL, url)
|
||||
|
||||
slug, err := FindGithubRepo(context.Background(), basedir, "github.com")
|
||||
slug, err := FindGithubRepo(t.Context(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "owner/repo", slug)
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
basedir := t.TempDir()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
@@ -177,7 +189,7 @@ func TestGitFindRef(t *testing.T) {
|
||||
require.NoError(t, gitCmd("-C", dir, "init", "--initial-branch=master"))
|
||||
require.NoError(t, cleanGitHooks(dir))
|
||||
tt.Prepare(t, dir)
|
||||
ref, err := FindGitRef(context.Background(), dir)
|
||||
ref, err := FindGitRef(t.Context(), dir)
|
||||
tt.Assert(t, ref, err)
|
||||
})
|
||||
}
|
||||
@@ -193,7 +205,9 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
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, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||
@@ -207,6 +221,13 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
||||
|
||||
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
||||
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
||||
t.Setenv("GIT_DIR", t.TempDir())
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Err error
|
||||
Ref string
|
||||
@@ -227,26 +248,42 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
Err: &Error{ErrShortRef, fullSha},
|
||||
Ref: fullSha[:7],
|
||||
},
|
||||
"HEAD": {
|
||||
Ref: "HEAD",
|
||||
},
|
||||
"hex branch named like its own commit": {
|
||||
Ref: fullSha[:4],
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
URL: "https://example.invalid/action",
|
||||
Ref: tt.Ref,
|
||||
Dir: t.TempDir(),
|
||||
Dir: dir,
|
||||
})
|
||||
|
||||
err := clone(context.Background())
|
||||
err := clone(t.Context())
|
||||
if tt.Err != nil {
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.Err, err)
|
||||
} else {
|
||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.Err, clone(t.Context()), "a retry on the cache keeps the error")
|
||||
return
|
||||
}
|
||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
marker := filepath.Join(dir, "marker")
|
||||
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
||||
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
createRemote := func(message string) string {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -270,16 +307,27 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
nested := filepath.Join(cacheDir, "nested")
|
||||
require.NoError(t, os.Mkdir(nested, 0o755))
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: nested})(t.Context()))
|
||||
assert.DirExists(t, filepath.Join(nested, ".git"), "an empty cache dir inside a clone of the same URL gets its own repository")
|
||||
|
||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||
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{
|
||||
URL: newRemoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRemoteDir, originURL)
|
||||
|
||||
@@ -291,61 +339,47 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||
|
||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
|
||||
// We need a working clone to push commits from.
|
||||
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, "commit", "--allow-empty", "-m", "feature-1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
// Create a feature branch (simulates refs/pull/N/head).
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
require.NoError(t, gitCmd("-C", dir, "config", "fetch.prune", "true"))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
|
||||
// First clone via the executor — should succeed and cache the repo.
|
||||
cloneDir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: cloneDir,
|
||||
})
|
||||
require.NoError(t, clone(context.Background()))
|
||||
|
||||
// Now force-push the feature branch to a non-fast-forward commit (simulates
|
||||
// a PR rebase). This makes refs/heads/feature non-fast-forward.
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||
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, "push", "--force", "origin", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
||||
require.NoError(t, clone(t.Context()), "a non-fast-forward ref must update")
|
||||
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
||||
|
||||
// Also advance main so we can verify the clone picks up the new commit.
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
tagDir := t.TempDir()
|
||||
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, "push", "origin", "v9"))
|
||||
require.NoError(t, tagClone(t.Context()), "a tag published after a failed lookup must be fetched")
|
||||
|
||||
// Second clone to the same directory — before the fix this returned ErrForceNeeded
|
||||
// and left the working tree at the old commit.
|
||||
err := clone(context.Background())
|
||||
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
|
||||
|
||||
// Verify the working tree was actually updated to the latest main commit.
|
||||
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
||||
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) {
|
||||
t.Parallel()
|
||||
// Build a local "remote" with a single commit on main.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -361,7 +395,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
})(context.Background()))
|
||||
})(t.Context()))
|
||||
|
||||
t.Run("cached branch resolves without fetching", func(t *testing.T) {
|
||||
// Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref)
|
||||
@@ -371,7 +405,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
Ref: "main",
|
||||
Dir: cacheDir,
|
||||
OfflineMode: true,
|
||||
})(context.Background())
|
||||
})(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
@@ -386,50 +420,13 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
Ref: "never-fetched",
|
||||
Dir: cacheDir,
|
||||
OfflineMode: true,
|
||||
})(context.Background())
|
||||
})(t.Context())
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||
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) {
|
||||
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.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -473,12 +470,11 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
return remote
|
||||
}
|
||||
|
||||
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.NoFileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
@@ -505,76 +501,46 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "deadbeef", Dir: dir, Depth: 1,
|
||||
})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
|
||||
// Advance main on the remote, then reuse the existing shallow clone.
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Ref string
|
||||
Depth int
|
||||
}{
|
||||
"shallow branch": {"main", 1},
|
||||
"full clone branch": {"main", 0},
|
||||
"full lightweight tag": {"v1", 0},
|
||||
"full annotated tag": {"v2", 0},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
counter := installCountingTransport(t)
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||
})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, string(status))
|
||||
})
|
||||
}
|
||||
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
||||
work := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
||||
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
||||
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
||||
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
||||
sha := gitRevParse(t, work, "HEAD")
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.FileExists(t, shallowMarker(dir))
|
||||
require.ErrorIs(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha[:40], Dir: dir, Depth: 1, OfflineMode: true})(t.Context()), ErrShortRef)
|
||||
require.NoError(t, os.RemoveAll(remote))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
@@ -598,25 +564,6 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type countingTransport struct {
|
||||
transport.Transport
|
||||
sessions atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||
c.sessions.Add(1)
|
||||
return c.Transport.NewUploadPackSession(ep, auth)
|
||||
}
|
||||
|
||||
func installCountingTransport(t *testing.T) *countingTransport {
|
||||
t.Helper()
|
||||
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||
gogitclient.InstallProtocol("file", counter)
|
||||
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
@@ -667,14 +614,42 @@ func gitCmd(args ...string) error {
|
||||
}
|
||||
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
|
||||
unlock1 := AcquireCloneLock(dir)
|
||||
unlock1, err := AcquireCloneLock(t.Context(), dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
secondAcquired := make(chan struct{})
|
||||
go func() {
|
||||
unlock := AcquireCloneLock(dir)
|
||||
unlock, err := AcquireCloneLock(t.Context(), dir)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
close(secondAcquired)
|
||||
unlock()
|
||||
}()
|
||||
@@ -698,12 +673,16 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
dirA := t.TempDir()
|
||||
dirB := t.TempDir()
|
||||
|
||||
unlockA := AcquireCloneLock(dirA)
|
||||
unlockA, err := AcquireCloneLock(t.Context(), dirA)
|
||||
require.NoError(t, err)
|
||||
defer unlockA()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
unlock := AcquireCloneLock(dirB)
|
||||
unlock, err := AcquireCloneLock(t.Context(), dirB)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
unlock()
|
||||
close(done)
|
||||
}()
|
||||
@@ -717,12 +696,17 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
}
|
||||
|
||||
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
||||
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
block := make(chan struct{})
|
||||
reached := make(chan struct{})
|
||||
var once sync.Once
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
once.Do(func() { close(reached) })
|
||||
var authorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||
once.Do(func() {
|
||||
authorization = request.Header.Get("Authorization")
|
||||
close(reached)
|
||||
})
|
||||
<-block
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
@@ -731,17 +715,15 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
})
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gitCmd("init", dir))
|
||||
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir, Token: "secret"})(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -749,6 +731,7 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the executor never reached the remote")
|
||||
}
|
||||
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
@@ -758,25 +741,3 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
t.Fatal("fetch ignored context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitCloneExecutorFailsOnSilentHTTP2Connection(t *testing.T) {
|
||||
server := httptest.NewUnstartedServer(nil)
|
||||
server.EnableHTTP2 = true
|
||||
server.Config.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){
|
||||
"h2": func(_ *http.Server, conn *tls.Conn, _ http.Handler) { _, _ = io.Copy(io.Discard, conn) },
|
||||
}
|
||||
server.StartTLS()
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
previous := gogitclient.Protocols["https"]
|
||||
gogitclient.InstallProtocol("https", newHTTPClient(&http.HTTP2Config{SendPingTimeout: 50 * time.Millisecond, PingTimeout: 50 * time.Millisecond}))
|
||||
t.Cleanup(func() { gogitclient.InstallProtocol("https", previous) })
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: server.URL + "/action", Ref: "main", Dir: filepath.Join(t.TempDir(), "action"), InsecureSkipTLS: true,
|
||||
})(ctx)
|
||||
require.Error(t, err)
|
||||
require.NoError(t, ctx.Err())
|
||||
}
|
||||
+30
-23
@@ -7,46 +7,53 @@ package common
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
)
|
||||
|
||||
type loggerContextKey string
|
||||
|
||||
const loggerContextKeyVal = loggerContextKey("logrus.FieldLogger")
|
||||
const loggerContextKeyVal = loggerContextKey("logging.Logger")
|
||||
|
||||
// Logger returns the appropriate logger for current context
|
||||
func Logger(ctx context.Context) logrus.FieldLogger {
|
||||
val := ctx.Value(loggerContextKeyVal)
|
||||
if val != nil {
|
||||
if logger, ok := val.(logrus.FieldLogger); ok {
|
||||
return logger
|
||||
}
|
||||
func Logger(ctx context.Context) *logging.Logger {
|
||||
if logger, ok := ctx.Value(loggerContextKeyVal).(*logging.Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return logrus.StandardLogger()
|
||||
return logging.Default()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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
|
||||
// the hook affects job logger, not global logger
|
||||
func LoggerHook(ctx context.Context) logrus.Hook {
|
||||
val := ctx.Value(loggerHookKeyVal)
|
||||
if val != nil {
|
||||
if hook, ok := val.(logrus.Hook); ok {
|
||||
return hook
|
||||
}
|
||||
// LoggerHandler returns the extra handler the job logger sends its records to, or nil.
|
||||
// It affects the job logger, not the global logger.
|
||||
func LoggerHandler(ctx context.Context) logging.Handler {
|
||||
if h, ok := ctx.Value(loggerHandlerKeyVal).(logging.Handler); ok {
|
||||
return h
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithLoggerHook adds a value to the context for the logger hook
|
||||
func WithLoggerHook(ctx context.Context, hook logrus.Hook) context.Context {
|
||||
return context.WithValue(ctx, loggerHookKeyVal, hook)
|
||||
// WithLoggerHandler adds the extra handler for the job logger to the context
|
||||
func WithLoggerHandler(ctx context.Context, h logging.Handler) context.Context {
|
||||
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,91 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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 and of what libraries log through logrus.
|
||||
func SetLevel(level Level) {
|
||||
stdState.level.Set(slog.Level(level))
|
||||
logrus.SetLevel(toLogrusLevel(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
|
||||
logrus.SetReportCaller(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,203 @@
|
||||
// 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/sirupsen/logrus"
|
||||
"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 TestLogrusBridge(t *testing.T) {
|
||||
prevLevel, prevDefault := logging.GetLevel(), logging.Default()
|
||||
t.Cleanup(func() {
|
||||
logging.SetLevel(prevLevel)
|
||||
logging.SetReportCaller(false)
|
||||
logging.SetDefault(prevDefault)
|
||||
})
|
||||
logging.SetLevel(logging.LevelInfo)
|
||||
assert.False(t, logrus.IsLevelEnabled(logrus.DebugLevel))
|
||||
logging.SetLevel(logging.LevelTrace)
|
||||
logging.SetReportCaller(true)
|
||||
h := logtest.NewWithLevel(logging.LevelTrace)
|
||||
logging.SetDefault(logging.New(h))
|
||||
|
||||
logrus.WithField("lib", "docker").Warnf("from a library %d", 1)
|
||||
_, _, line, _ := runtime.Caller(0)
|
||||
logrus.Debug("below")
|
||||
logrus.Trace("lowest")
|
||||
|
||||
recs := h.AllRecords()
|
||||
require.Len(t, recs, 3)
|
||||
assert.Equal(t, "from a library 1", recs[0].Message)
|
||||
assert.Equal(t, logging.LevelWarn, recs[0].Level)
|
||||
v, _ := recs[0].Attr("lib")
|
||||
assert.Equal(t, "docker", v)
|
||||
assert.Equal(t, logging.LevelDebug, recs[1].Level)
|
||||
f := frameOf(t, recs[1].PC)
|
||||
assert.True(t, strings.HasSuffix(f.Function, "TestLogrusBridge"), f.Function)
|
||||
assert.Equal(t, line+1, f.Line)
|
||||
assert.Equal(t, logging.LevelTrace, recs[2].Level)
|
||||
|
||||
h = logtest.New() // logrus passes Debug, the Logger does not
|
||||
logging.SetDefault(logging.New(h))
|
||||
logrus.Debug("dropped")
|
||||
assert.Empty(t, h.AllRecords())
|
||||
}
|
||||
|
||||
func TestSlogBridge(t *testing.T) {
|
||||
h := logtest.SetDefault(t)
|
||||
slog.With("lib", "actionslib").Warn("from a library", "n", 1)
|
||||
_, _, 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)
|
||||
}
|
||||
|
||||
func TestLogrusBridgeIntact(t *testing.T) {
|
||||
assert.True(t, logging.LogrusBridgeIntact())
|
||||
|
||||
prev := logrus.StandardLogger().Out
|
||||
t.Cleanup(func() { logrus.SetOutput(prev) })
|
||||
logrus.SetOutput(&strings.Builder{}) // what a dependency doing this would do
|
||||
assert.False(t, logging.LogrusBridgeIntact())
|
||||
logrus.SetOutput(prev)
|
||||
assert.True(t, logging.LogrusBridgeIntact())
|
||||
|
||||
prevLevel := logrus.GetLevel()
|
||||
t.Cleanup(func() { logrus.SetLevel(prevLevel) })
|
||||
logrus.SetLevel(logrus.PanicLevel)
|
||||
assert.False(t, logging.LogrusBridgeIntact())
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Libraries the runner depends on, such as docker/cli and moby/go-archive, log through
|
||||
// logrus's global logger. Hand what they log to the process-wide Logger, so it follows the same
|
||||
// level, format and secret masking as the rest of the output.
|
||||
func init() {
|
||||
logrus.SetOutput(io.Discard)
|
||||
logrus.SetFormatter(discardFormatter{})
|
||||
logrus.SetLevel(toLogrusLevel(GetLevel()))
|
||||
logrus.AddHook(bridgeHook{})
|
||||
}
|
||||
|
||||
type discardFormatter struct{}
|
||||
|
||||
func (discardFormatter) Format(*logrus.Entry) ([]byte, error) { return nil, nil }
|
||||
|
||||
type bridgeHook struct{}
|
||||
|
||||
func (bridgeHook) Levels() []logrus.Level { return logrus.AllLevels }
|
||||
|
||||
func (bridgeHook) Fire(e *logrus.Entry) error {
|
||||
l := Default()
|
||||
level := fromLogrusLevel(e.Level)
|
||||
ctx := e.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if !l.h.Enabled(ctx, level) {
|
||||
return nil
|
||||
}
|
||||
attrs := make([]Attr, 0, len(e.Data))
|
||||
for k, v := range e.Data {
|
||||
attrs = append(attrs, Attr{Key: k, Value: v})
|
||||
}
|
||||
r := Record{Time: e.Time, Level: level, Message: e.Message, Attrs: attrs}
|
||||
if e.Caller != nil {
|
||||
r.PC = e.Caller.PC + 1 // Frame.PC is one below the return address Record.PC holds
|
||||
}
|
||||
return l.h.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func toLogrusLevel(l Level) logrus.Level {
|
||||
switch {
|
||||
case l <= LevelTrace:
|
||||
return logrus.TraceLevel
|
||||
case l <= LevelDebug:
|
||||
return logrus.DebugLevel
|
||||
case l <= LevelInfo:
|
||||
return logrus.InfoLevel
|
||||
case l <= LevelWarn:
|
||||
return logrus.WarnLevel
|
||||
default:
|
||||
return logrus.ErrorLevel
|
||||
}
|
||||
}
|
||||
|
||||
func fromLogrusLevel(l logrus.Level) Level {
|
||||
switch l {
|
||||
case logrus.TraceLevel:
|
||||
return LevelTrace
|
||||
case logrus.DebugLevel:
|
||||
return LevelDebug
|
||||
case logrus.InfoLevel:
|
||||
return LevelInfo
|
||||
case logrus.WarnLevel:
|
||||
return LevelWarn
|
||||
default:
|
||||
return LevelError
|
||||
}
|
||||
}
|
||||
|
||||
// LogrusBridgeIntact reports whether logrus's global logger is still set up as the bridge needs,
|
||||
// discarding its own output and following the process-wide level. A dependency that calls
|
||||
// logrus.SetOutput or SetLevel breaks that, and its lines then show up twice, or at the wrong level.
|
||||
func LogrusBridgeIntact() bool {
|
||||
std := logrus.StandardLogger()
|
||||
if std.Out != io.Discard || std.GetLevel() != toLogrusLevel(GetLevel()) {
|
||||
return false
|
||||
}
|
||||
for _, level := range logrus.AllLevels {
|
||||
found := false
|
||||
for _, h := range std.Hooks[level] {
|
||||
if _, ok := h.(bridgeHook); ok {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,10 +8,10 @@ package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if platform == "" || platform == "any" || fmt.Sprintf("%s/%s", inspectImage.Os, inspectImage.Architecture) == platform {
|
||||
if platform == "" || platform == "any" {
|
||||
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
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
// a non-existent image is reported absent
|
||||
missing, err := ImageExistsLocally(ctx, "library/alpine:this-random-tag-will-never-exist", "linux/amd64")
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
)
|
||||
|
||||
type dockerMessage struct {
|
||||
@@ -26,7 +26,7 @@ type dockerMessage struct {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func logDockerResponse(logger logrus.FieldLogger, dockerResponse io.ReadCloser,
|
||||
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 {
|
||||
logger.Errorf(format, args...)
|
||||
} else {
|
||||
|
||||
@@ -53,7 +53,9 @@ func removeOrphanNetworks(ctx context.Context, cli client.APIClient, runnerUUID,
|
||||
networks:
|
||||
for _, n := range networks.Items {
|
||||
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))
|
||||
continue
|
||||
}
|
||||
@@ -71,7 +73,9 @@ 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))
|
||||
continue
|
||||
}
|
||||
@@ -87,45 +91,54 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
return createDockerNetwork(ctx, cli, name, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var options map[string]string
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
// the daemon's mtu only reaches its default bridge, Docker's default is left out to keep default-network-opts
|
||||
if mtu := n.Options["com.docker.network.driver.mtu"]; n.Name == network.NetworkBridge && mtu != "" && mtu != "1500" {
|
||||
options = map[string]string{"com.docker.network.driver.mtu": mtu}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Options: options,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
@@ -194,36 +207,35 @@ func NewDockerNetworkRemoveExecutor(name string) common.Executor {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
return removeNetwork(ctx, cli, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that all network of the specified name are removed
|
||||
// cli.NetworkRemove refuses to remove a network if there are duplicates
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
func removeNetwork(ctx context.Context, cli client.APIClient, name string) error {
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{}) // NetworkRemove by name refuses duplicates, so remove each by ID
|
||||
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
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
var errs []error
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
result, err := cli.NetworkInspect(ctx, n.ID, client.NetworkInspectOptions{})
|
||||
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))
|
||||
}
|
||||
}
|
||||
if len(result.Network.Containers) != 0 {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
if _, err = cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
||||
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.
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
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")
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{}).Return(mobyclient.ContainerListResult{Items: []container.Summary{{}, {
|
||||
@@ -57,6 +57,27 @@ func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
func TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
for _, tc := range []struct {
|
||||
bridgeOptions, want map[string]string
|
||||
}{
|
||||
{
|
||||
map[string]string{"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1", "com.docker.network.driver.mtu": "1450"},
|
||||
map[string]string{"com.docker.network.driver.mtu": "1450"},
|
||||
},
|
||||
{map[string]string{"com.docker.network.driver.mtu": "1500"}, nil},
|
||||
} {
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).
|
||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{Name: network.NetworkBridge, Options: tc.bridgeOptions}}}, nil)
|
||||
client.On("NetworkCreate", ctx, "job-network", mobyclient.NetworkCreateOptions{Driver: "bridge", Scope: "local", Options: tc.want}).
|
||||
Return(mobyclient.NetworkCreateResult{}, nil)
|
||||
require.NoError(t, createDockerNetwork(ctx, client, "job-network", NewDockerNetworkCreateExecutorInput{}))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||
}
|
||||
@@ -66,7 +87,7 @@ func (m *mockDockerClient) NetworkDisconnect(ctx context.Context, id string, opt
|
||||
}
|
||||
|
||||
func TestConnectAndDisconnectNetwork(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
connect := func(answer error) error {
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkConnect", ctx, "jobs", mobyclient.NetworkConnectOptions{
|
||||
@@ -95,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
|
||||
// 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.
|
||||
func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
func TestRemoveNetworks(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
cutoff := time.Date(2026, time.April, 29, 20, 0, 0, 0, time.UTC)
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{
|
||||
@@ -107,11 +128,13 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
{ID: "starting"},
|
||||
{ID: "cached"},
|
||||
{ID: "shared"},
|
||||
{ID: "gone"},
|
||||
{ID: "raced"},
|
||||
}}, nil)
|
||||
client.On("NetworkInspect", ctx, "orphan", mobyclient.NetworkInspectOptions{}).
|
||||
Return(mobyclient.NetworkInspectResult{}, nil)
|
||||
Return(mobyclient.NetworkInspectResult{}, nil).Twice()
|
||||
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{}).
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{
|
||||
Network: network.Network{Created: cutoff.Add(time.Second)},
|
||||
@@ -121,13 +144,28 @@ func TestRemoveOrphanNetworks(t *testing.T) {
|
||||
Return(mobyclient.NetworkInspectResult{Network: network.Inspect{Containers: map[string]network.EndpointResource{"0123456789abcdef": {}}}}, nil)
|
||||
client.On("NetworkInspect", ctx, "shared", mobyclient.NetworkInspectOptions{}).
|
||||
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{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil)
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil).Twice()
|
||||
client.On("NetworkDisconnect", ctx, "cached", mobyclient.NetworkDisconnectOptions{Container: "0123456789abcdef", Force: true}).
|
||||
Return(nil).Once()
|
||||
client.On("NetworkRemove", ctx, "cached", mobyclient.NetworkRemoveOptions{}).
|
||||
Return(mobyclient.NetworkRemoveResult{}, nil).Once()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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 (
|
||||
jobLabel = "com.gitea.runner.job"
|
||||
jobLabel = "com.gitea.runner.job."
|
||||
maxCreateBody = 8 << 20
|
||||
|
||||
dockerProxyProbeTimeout = 5 * time.Second
|
||||
@@ -302,7 +302,7 @@ func rewriteCreate(r *http.Request, job string, mounts map[string]string) error
|
||||
if config.Labels == nil {
|
||||
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 {
|
||||
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 {
|
||||
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})
|
||||
errs := []error{err}
|
||||
for _, c := range containers.Items {
|
||||
|
||||
@@ -146,52 +146,52 @@ func TestDockerProxy(t *testing.T) {
|
||||
t.Run("labels creates", func(t *testing.T) {
|
||||
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"}}`,
|
||||
`{"Image":"alpine","Unknown":{"enabled":true},"Labels":{"own":"1","com.gitea.runner.job":"job-1"}}`,
|
||||
"/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-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"}}`,
|
||||
`{"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"}]}}`,
|
||||
`{"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"}}}}]}}`,
|
||||
`{"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}`,
|
||||
`{"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"}`,
|
||||
`{"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"]}}`,
|
||||
`{"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"}}`,
|
||||
`{"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"}}`,
|
||||
`{"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", "",
|
||||
`{"Labels":{"com.gitea.runner.job":"job-1"}}`,
|
||||
`{"Labels":{"com.gitea.runner.job.job-1":""}}`,
|
||||
},
|
||||
{
|
||||
"/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"}}`,
|
||||
`{"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))
|
||||
@@ -244,7 +244,7 @@ func TestDockerProxy(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
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, resp.Body.Close())
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -285,8 +285,8 @@ func TestRemoveLabelledRemovesContainersNetworksAndVolumes(t *testing.T) {
|
||||
containerFailure := errors.New("container removal failed")
|
||||
listFailure := errors.New("listing failed")
|
||||
volumeFailure := errors.New("volume removal failed")
|
||||
ctx := context.Background()
|
||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"=job-1")
|
||||
ctx := t.Context()
|
||||
filters := make(mobyclient.Filters).Add("label", jobLabel+"job-1")
|
||||
cli := &mockDockerClient{}
|
||||
cli.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true, Filters: filters}).
|
||||
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) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cleanupCtx := context.WithoutCancel(ctx)
|
||||
require.NoError(t, NewDockerPullExecutor(NewDockerPullExecutorInput{Image: "alpine"})(ctx))
|
||||
direct, err := GetDockerClient(ctx)
|
||||
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"}},
|
||||
})
|
||||
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{})
|
||||
require.NoError(t, err)
|
||||
net, err := viaProxy.NetworkCreate(ctx, job, mobyclient.NetworkCreateOptions{})
|
||||
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})
|
||||
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{})
|
||||
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{})
|
||||
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})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
|
||||
"github.com/docker/cli/cli/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
}
|
||||
|
||||
func TestCleanImage(t *testing.T) {
|
||||
@@ -36,13 +36,13 @@ func TestCleanImage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
imageOut := cleanImage(context.Background(), table.imageIn)
|
||||
imageOut := cleanImage(t.Context(), table.imageIn)
|
||||
assert.Equal(t, table.imageOut, imageOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetImagePullOptions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
orig := config.Dir()
|
||||
t.Cleanup(func() { config.SetDir(orig) })
|
||||
@@ -79,7 +79,7 @@ func TestIsPinnedImage(t *testing.T) {
|
||||
// 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.
|
||||
func TestLogDockerResponseError(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
logger := logging.New(logtest.New())
|
||||
streamErr := func(line string) error {
|
||||
return logDockerResponse(logger, io.NopCloser(strings.NewReader(line)), false)
|
||||
}
|
||||
|
||||
+107
-135
@@ -8,6 +8,7 @@ package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,13 +25,14 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/docker/cli/opts"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
@@ -39,7 +41,6 @@ import (
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/moby/moby/client/pkg/versions"
|
||||
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
|
||||
@@ -94,8 +95,8 @@ func (cr *containerReference) Start(attach bool) common.Executor {
|
||||
cr.attach().IfBool(attach),
|
||||
cr.start(),
|
||||
cr.wait().IfBool(attach),
|
||||
cr.tryReadUID(),
|
||||
cr.tryReadGID(),
|
||||
cr.tryReadID("-u", func(id int) { cr.UID = id }),
|
||||
cr.tryReadID("-g", func(id int) { cr.GID = id }),
|
||||
func(ctx context.Context) error {
|
||||
// If this fails, then folders have wrong permissions on non root container
|
||||
if cr.UID != 0 || cr.GID != 0 {
|
||||
@@ -304,7 +305,25 @@ func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]stri
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -566,20 +585,6 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
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 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)
|
||||
@@ -643,6 +648,9 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
if flags.Changed("network") || flags.Changed("net") {
|
||||
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
|
||||
logger.Debugf("Merged container.HostConfig ==> %+v", hostConfig)
|
||||
|
||||
@@ -672,17 +680,11 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
||||
ExposedPorts: exposedPorts,
|
||||
Tty: input.AllocatePTY,
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// logger.Debugf("Common container.Config ==> %+v", config)
|
||||
|
||||
if len(input.Cmd) != 0 {
|
||||
config.Cmd = input.Cmd
|
||||
}
|
||||
|
||||
if len(input.Entrypoint) != 0 {
|
||||
config.Entrypoint = input.Entrypoint
|
||||
}
|
||||
|
||||
mounts := make([]mount.Mount, 0)
|
||||
for mountSource, mountTarget := range input.Mounts {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
@@ -717,20 +719,20 @@ func (cr *containerReference) create(capAdd, capDrop []string) common.Executor {
|
||||
PortBindings: portBindings,
|
||||
AutoRemove: input.AutoRemove,
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// logger.Debugf("Common container.HostConfig ==> %+v", hostConfig)
|
||||
|
||||
config, hostConfig, err = cr.mergeContainerConfigs(ctx, config, hostConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.Entrypoint) != 0 {
|
||||
config.Entrypoint = input.Entrypoint
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
config, hostConfig = cr.sanitizeConfig(ctx, config, hostConfig)
|
||||
|
||||
var networkingConfig *network.NetworkingConfig
|
||||
// For Gitea, reduce log noise
|
||||
// logger.Debugf("input.NetworkAliases ==> %v", input.NetworkAliases)
|
||||
n := hostConfig.NetworkMode
|
||||
// IsUserDefined and IsHost are broken on windows
|
||||
if n.IsUserDefined() && n != "host" && len(input.NetworkAliases) > 0 {
|
||||
@@ -763,44 +765,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 {
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
@@ -818,11 +782,6 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
||||
|
||||
logger.Debugf("Exec command '%s'", cmd)
|
||||
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
|
||||
if workdir != "" {
|
||||
if strings.HasPrefix(workdir, "/") {
|
||||
@@ -839,7 +798,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
||||
User: user,
|
||||
Cmd: cmd,
|
||||
WorkingDir: wd,
|
||||
Env: envList,
|
||||
Env: getEnvListFromMap(env),
|
||||
TTY: isTerminal,
|
||||
AttachStderr: true,
|
||||
AttachStdout: true,
|
||||
@@ -856,7 +815,7 @@ func (cr *containerReference) exec(cmd []string, env map[string]string, user, wo
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
err = cr.waitForCommand(ctx, resp.HijackedResponse, idResp, user, workdir)
|
||||
err = cr.waitForCommand(ctx, resp.HijackedResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -906,15 +865,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *containerReference) tryReadUID() common.Executor {
|
||||
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 {
|
||||
func (cr *containerReference) waitForCommand(ctx context.Context, resp client.HijackedResponse) error {
|
||||
logger := common.Logger(ctx)
|
||||
|
||||
// Buffered so the copy goroutine never blocks on send if the grace-period
|
||||
@@ -956,6 +907,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
||||
}
|
||||
|
||||
func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
dstPath = path.Clean(dstPath)
|
||||
return func(ctx context.Context) error {
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy directory to %s", dstPath)
|
||||
@@ -977,7 +929,11 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
logger.Error(err)
|
||||
}
|
||||
}(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
|
||||
}
|
||||
|
||||
@@ -986,14 +942,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to seek tar archive: %w", err)
|
||||
}
|
||||
_, err = cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||
DestinationPath: "/",
|
||||
Content: tarFile,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||
}
|
||||
return nil
|
||||
return cr.upload(ctx, extractionRoot, tarFile)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,17 +957,17 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
||||
}
|
||||
|
||||
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
|
||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||
DestinationPath: dstPath,
|
||||
Content: &buf,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy content to container: %w", err)
|
||||
}
|
||||
return nil
|
||||
return cr.upload(ctx, dstPath, &buf)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(ctx context.Context) error {
|
||||
out, err := cr.cli.ContainerAttach(ctx, cr.id, client.ContainerAttachOptions{
|
||||
@@ -1097,7 +1046,7 @@ func (cr *containerReference) wait() common.Executor {
|
||||
// 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.
|
||||
// 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, "--ipc", &hostConfig.IpcMode, trusted.IpcMode)
|
||||
resetOption(logger, "--uts", &hostConfig.UTSMode, trusted.UTSMode)
|
||||
@@ -1117,6 +1066,10 @@ func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *c
|
||||
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
||||
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
||||
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
||||
resetOption(logger, "--log-driver", &hostConfig.LogConfig.Type, trusted.LogConfig.Type) // drivers run in the daemon, egressing from its network
|
||||
resetOption(logger, "--log-opt", &hostConfig.LogConfig.Config, trusted.LogConfig.Config)
|
||||
resetOption(logger, "--oom-score-adj", &hostConfig.OomScoreAdj, trusted.OomScoreAdj) // -1000 exempts it from the host OOM killer
|
||||
resetOption(logger, "--oom-kill-disable", &hostConfig.OomKillDisable, trusted.OomKillDisable)
|
||||
|
||||
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
||||
// valid_volumes never gets to see
|
||||
@@ -1132,7 +1085,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
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
@@ -1158,9 +1111,9 @@ func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||
logger := common.Logger(ctx)
|
||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
||||
|
||||
if len(cr.input.ValidVolumes) > 0 {
|
||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
||||
// sanitize binds
|
||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||
for _, bind := range hostConfig.Binds {
|
||||
@@ -1174,33 +1127,37 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
continue
|
||||
}
|
||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
|
||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
|
||||
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
|
||||
bind += ",nocopy"
|
||||
}
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
||||
}
|
||||
}
|
||||
hostConfig.Binds = sanitizedBinds
|
||||
// sanitize mounts
|
||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
if matcher.isValid(mt.Source, mt.Type) {
|
||||
sanitizedMounts = append(sanitizedMounts, mt)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||
}
|
||||
}
|
||||
hostConfig.Mounts = sanitizedMounts
|
||||
} else {
|
||||
for _, bind := range hostConfig.Binds {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
|
||||
}
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
hostConfig.Binds = []string{}
|
||||
}
|
||||
// sanitize mounts
|
||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
readOnly := mt.ReadOnly && (mt.BindOptions == nil || !mt.BindOptions.ReadOnlyNonRecursive) // bind-recursive=writable leaves submounts writable
|
||||
if matcher.isValid(mt.Source, mt.Type, readOnly) {
|
||||
if matcher.needsNoCopy(mt.Source, mt.Type) {
|
||||
mt.VolumeOptions = cmp.Or(mt.VolumeOptions, &mount.VolumeOptions{})
|
||||
mt.VolumeOptions.NoCopy = true
|
||||
}
|
||||
sanitizedMounts = append(sanitizedMounts, mt)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||
}
|
||||
hostConfig.Binds = []string{}
|
||||
hostConfig.Mounts = []mount.Mount{}
|
||||
}
|
||||
hostConfig.Mounts = sanitizedMounts
|
||||
|
||||
return config, hostConfig
|
||||
}
|
||||
@@ -1240,26 +1197,37 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
||||
}
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []string
|
||||
host []string
|
||||
allowAll bool
|
||||
allowAllReadOnly bool
|
||||
named []validVolumeRule
|
||||
host []validVolumeRule
|
||||
}
|
||||
|
||||
type validVolumeRule struct {
|
||||
pattern string
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||
logger := common.Logger(ctx)
|
||||
ret := validVolumeMatcher{
|
||||
named: make([]string, 0, len(validVolumes)),
|
||||
host: make([]string, 0, len(validVolumes)),
|
||||
named: make([]validVolumeRule, 0, len(validVolumes)),
|
||||
host: make([]validVolumeRule, 0, len(validVolumes)),
|
||||
}
|
||||
|
||||
for _, v := range validVolumes {
|
||||
v, readOnly := strings.CutSuffix(v, ":ro")
|
||||
if v == "**" {
|
||||
ret.allowAll = true
|
||||
if readOnly {
|
||||
ret.allowAllReadOnly = true
|
||||
} else {
|
||||
ret.allowAll = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isHostVolumePattern(v) {
|
||||
if doublestar.ValidatePattern(v) {
|
||||
ret.named = append(ret.named, v)
|
||||
ret.named = append(ret.named, validVolumeRule{pattern: v, readOnly: readOnly})
|
||||
} else {
|
||||
logger.Errorf("invalid volume pattern %s", v)
|
||||
}
|
||||
@@ -1271,7 +1239,7 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
||||
continue
|
||||
}
|
||||
if doublestar.ValidatePathPattern(normalized) {
|
||||
ret.host = append(ret.host, normalized)
|
||||
ret.host = append(ret.host, validVolumeRule{pattern: normalized, readOnly: readOnly})
|
||||
} else {
|
||||
logger.Errorf("invalid volume pattern %s", normalized)
|
||||
}
|
||||
@@ -1280,30 +1248,34 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
||||
if m.allowAll {
|
||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type, readOnly bool) bool {
|
||||
if m.allowAll || (m.allowAllReadOnly && readOnly) {
|
||||
return true
|
||||
}
|
||||
if sourceType == mount.TypeTmpfs && source == "" { // backed by memory, exposes nothing from the host, same as --tmpfs
|
||||
return true
|
||||
}
|
||||
rules, match := m.named, doublestar.MatchUnvalidated
|
||||
if isHostVolumeSource(source, sourceType) {
|
||||
normalized, err := normalizeHostVolumePath(source)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range m.host {
|
||||
if doublestar.PathMatchUnvalidated(pattern, normalized) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
rules, match, source = m.host, doublestar.PathMatchUnvalidated, normalized
|
||||
}
|
||||
for _, pattern := range m.named {
|
||||
if doublestar.MatchUnvalidated(pattern, source) {
|
||||
for _, rule := range rules {
|
||||
if (readOnly || !rule.readOnly) && match(rule.pattern, source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// needsNoCopy reports a volume only `:ro` rules allow, as dockerd fills an empty volume from the image even when mounted read-only.
|
||||
func (m validVolumeMatcher) needsNoCopy(source string, sourceType mount.Type) bool {
|
||||
return sourceType == mount.TypeVolume && !m.isValid(source, sourceType, false)
|
||||
}
|
||||
|
||||
func isHostVolumePattern(pattern string) bool {
|
||||
return filepath.IsAbs(pattern) ||
|
||||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"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/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDocker(t *testing.T) {
|
||||
requireDocker(t)
|
||||
ctx := context.Background()
|
||||
client, err := GetDockerClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer client.Close()
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
func TestDockerEnvironmentUsesContainerPath(t *testing.T) {
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerInspect", mock.Anything, "amd64-job", mobyclient.ContainerInspectOptions{}).
|
||||
Return(mobyclient.ContainerInspectResult{Container: container.InspectResponse{Config: &container.Config{Env: []string{
|
||||
"PATH=/opt/node/x64/bin:/usr/bin:/bin", "CONFLICT_VAR=container-value", "SOME_RANDOM_VAR=",
|
||||
"ANOTHER_ONE=old-value", "ANOTHER_ONE=BUT_I_HAVE_VALUE", "LITERAL='quoted'=$HOME\nsecond line",
|
||||
}}}}, nil)
|
||||
env := map[string]string{
|
||||
"PATH": "/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin",
|
||||
"PATH": "/custom/bin",
|
||||
"RANDOM_VAR": "WITH_VALUE",
|
||||
"ANOTHER_VAR": "",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
"CONFLICT_VAR": "step-value",
|
||||
}
|
||||
|
||||
envExecutor := cr.extractFromImageEnv(&env)
|
||||
err = envExecutor(ctx)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
require.NoError(t, (&containerReference{cli: client, id: "amd64-job"}).UpdateFromImageEnv(&env)(t.Context()))
|
||||
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",
|
||||
"ANOTHER_VAR": "",
|
||||
"SOME_RANDOM_VAR": "",
|
||||
"ANOTHER_ONE": "BUT_I_HAVE_VALUE",
|
||||
"CONFLICT_VAR": "I_EXIST_IN_MULTIPLE_PLACES",
|
||||
"CONFLICT_VAR": "step-value",
|
||||
"LITERAL": "'quoted'=$HOME\nsecond line",
|
||||
}, env)
|
||||
}
|
||||
|
||||
@@ -137,6 +123,11 @@ func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.Netw
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkCreate(ctx context.Context, name string, opts mobyclient.NetworkCreateOptions) (mobyclient.NetworkCreateResult, error) {
|
||||
args := m.Called(ctx, name, opts)
|
||||
return args.Get(0).(mobyclient.NetworkCreateResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
@@ -175,7 +166,7 @@ func (m *mockConn) Close() (err error) {
|
||||
}
|
||||
|
||||
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{})}
|
||||
conn := &mockConn{}
|
||||
@@ -216,7 +207,7 @@ func TestDockerExecAbort(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDockerExecFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
conn := &mockConn{}
|
||||
|
||||
@@ -263,7 +254,7 @@ func stdcopyFrame(stream stdcopy.StdType, payload string) []byte {
|
||||
// attach() streaming goroutine has drained and flushed the container's output,
|
||||
// so a final line without a trailing newline is not lost.
|
||||
func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
framed := bytes.NewBuffer(stdcopyFrame(stdcopy.Stdout, "line one\nlast line without newline"))
|
||||
|
||||
@@ -312,7 +303,7 @@ func TestDockerAttachFlushesTrailingLine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDockerWaitFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
statusCh := make(chan container.WaitResponse, 1)
|
||||
statusCh <- container.WaitResponse{StatusCode: 2}
|
||||
@@ -363,8 +354,8 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
{name: "real failure", err: removeFailure, wantErr: removeFailure},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerKill", ctx, "abc", killOpts).Return(mobyclient.ContainerKillResult{}, nil)
|
||||
client.On("ContainerRemove", ctx, "abc", removeOpts).Return(mobyclient.ContainerRemoveResult{}, tc.err)
|
||||
@@ -387,7 +378,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
} else {
|
||||
assert.Empty(t, cr.id)
|
||||
}
|
||||
assert.Empty(t, hook.AllEntries())
|
||||
assert.Empty(t, hook.AllRecords())
|
||||
client.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -397,7 +388,7 @@ func TestRemoveIgnoresAutoRemoveRace(t *testing.T) {
|
||||
// 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.
|
||||
func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerRemove", ctx, "job-1", mobyclient.ContainerRemoveOptions{RemoveVolumes: true, Force: true}).
|
||||
Return(mobyclient.ContainerRemoveResult{}, nil)
|
||||
@@ -410,7 +401,7 @@ func TestRemoveWithoutIDUsesName(t *testing.T) {
|
||||
// find() must drop a stale cached id so later Copy/Exec don't hit the
|
||||
// daemon with a torn-down container.
|
||||
func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
notFound := cerrdefs.ErrNotFound.WithMessage("No such container")
|
||||
boom := errors.New("daemon unreachable")
|
||||
newCR := func(id string) (*containerReference, *mockDockerClient) {
|
||||
@@ -468,7 +459,7 @@ func TestFindRevalidatesStaleID(t *testing.T) {
|
||||
// Every daemon entry point fails fast with a clear, container-named
|
||||
// error when no live cr.id is known.
|
||||
func TestRejectsMissingContainer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
client := &mockDockerClient{}
|
||||
client.On("ContainerList", ctx, mobyclient.ContainerListOptions{All: true}).Return(mobyclient.ContainerListResult{}, nil)
|
||||
cr := &containerReference{cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
@@ -493,25 +484,45 @@ func TestRejectsMissingContainer(t *testing.T) {
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
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.MatchedBy(func(opts mobyclient.CopyToContainerOptions) bool {
|
||||
return opts.DestinationPath == "/var/run/act"
|
||||
})).Return(mobyclient.CopyToContainerResult{}, nil)
|
||||
for _, testcase := range []struct {
|
||||
name, destination, extractionRoot, entry string
|
||||
directory bool
|
||||
}{
|
||||
{name: "files", destination: "/var/run/act", extractionRoot: "/var/run/act", entry: "x"},
|
||||
{name: "action below act volume", directory: true, destination: "/var/run/act/actions/checkout/", extractionRoot: "/var/run/act", entry: "actions/checkout/x"},
|
||||
{name: "sibling prefix", directory: true, destination: "/var/run/actor/action", extractionRoot: "/", entry: "var/run/actor/action/x"},
|
||||
} {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
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"}}
|
||||
require.NoError(t, cr.Copy("/var/run/act", &FileEntry{Name: "x", Mode: 0o644})(ctx))
|
||||
assert.Equal(t, "fresh", cr.id)
|
||||
client.AssertExpectations(t)
|
||||
cr := &containerReference{id: "stale", cli: client, input: &NewContainerInput{Name: "job-1"}}
|
||||
if testcase.directory {
|
||||
source := t.TempDir()
|
||||
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
|
||||
@@ -519,10 +530,12 @@ var _ ExecutionsEnvironment = &containerReference{}
|
||||
|
||||
func TestCheckVolumes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
mounts []mount.Mount
|
||||
expectedMounts []mount.Mount
|
||||
}{
|
||||
{
|
||||
desc: "match all volumes",
|
||||
@@ -578,19 +591,41 @@ func TestCheckVolumes(t *testing.T) {
|
||||
"/opt/app/cache:/cache",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "read-only rules allow only read-only mounts and copy nothing into volumes",
|
||||
validVolumes: []string{"/etc/ssl:ro", "data:ro", "shared"},
|
||||
binds: []string{"/etc/ssl:/ssl:ro", "/etc/ssl:/ssl", "data:/data:ro", "data:/data", "shared:/shared:ro"},
|
||||
expectedBinds: []string{"/etc/ssl:/ssl:ro", "data:/data:ro,nocopy", "shared:/shared:ro"},
|
||||
mounts: []mount.Mount{
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true, BindOptions: &mount.BindOptions{ReadOnlyNonRecursive: true}},
|
||||
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true},
|
||||
},
|
||||
expectedMounts: []mount.Mount{
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true, VolumeOptions: &mount.VolumeOptions{NoCopy: true}},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "read-only wildcard keeps writable rules writable",
|
||||
validVolumes: []string{"**:ro", "shared"},
|
||||
binds: []string{"/etc/ssl:/ssl:ro", "data:/data", "shared:/shared"},
|
||||
expectedBinds: []string{"/etc/ssl:/ssl:ro", "shared:/shared"},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
ValidVolumes: tc.validVolumes,
|
||||
},
|
||||
}
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
|
||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||
assert.Len(t, hook.AllEntries(), len(tc.binds)-len(tc.expectedBinds)) // every drop is warned about
|
||||
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
||||
assert.Len(t, hook.AllRecords(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -618,33 +653,33 @@ func TestMergeContainerConfigsKeepsTheRunnersFilesAndEnvToItself(t *testing.T) {
|
||||
t.Setenv("RUNNER_SECRET", "s3cr3t")
|
||||
|
||||
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}}
|
||||
|
||||
_, _, 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")
|
||||
|
||||
// the runner reading its own files is what those options are for
|
||||
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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
NetworkMode: "bridge",
|
||||
RunnerOptions: "--env RUNNER_SECRET",
|
||||
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)
|
||||
assert.Equal(t, []string{"RUNNER_SECRET", "RUNNER_SECRET", "GIVEN=value"}, config.Env)
|
||||
}
|
||||
|
||||
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
|
||||
hostConfig := &container.HostConfig{
|
||||
@@ -666,6 +701,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
LogConfig: container.LogConfig{Type: "syslog", Config: map[string]string{"syslog-address": "tcp://169.254.169.254:514"}},
|
||||
OomScoreAdj: -1000,
|
||||
OomKillDisable: new(true),
|
||||
}
|
||||
|
||||
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
||||
@@ -674,9 +712,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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()
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger, hook := logtest.NewLogger()
|
||||
cr := &containerReference{input: &NewContainerInput{
|
||||
RunnerOptions: runnerOptions,
|
||||
WorkflowOptions: workflowOptions,
|
||||
@@ -685,7 +723,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
|
||||
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,
|
||||
UsernsMode: container.UsernsMode("private"),
|
||||
NetworkMode: container.NetworkMode("bridge"),
|
||||
@@ -699,7 +737,9 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1 " +
|
||||
"--log-driver=syslog --log-opt syslog-address=tcp://169.254.169.254:514 " +
|
||||
"--oom-score-adj=-1000 --oom-kill-disable"
|
||||
|
||||
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
||||
// own options alone describe it, field for field
|
||||
@@ -726,8 +766,8 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
||||
}
|
||||
|
||||
func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger := logging.New(logtest.New())
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
|
||||
base := t.TempDir()
|
||||
allowed := filepath.Join(base, "allowed")
|
||||
@@ -762,6 +802,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
|
||||
hostConfig, _ := mergeOptions(t, "", "--mount type=tmpfs,dst=/var/lib/mysql", false)
|
||||
require.Len(t, hostConfig.Mounts, 1)
|
||||
|
||||
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
||||
logger, hook := logtest.NewLogger()
|
||||
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(t.Context(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
|
||||
hostConfig.Mounts[0],
|
||||
{Type: mount.TypeVolume, Target: "/anonymous"},
|
||||
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
||||
}})
|
||||
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
||||
assert.Len(t, hook.AllRecords(), 2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerInfoFromInspect(t *testing.T) {
|
||||
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
@@ -841,8 +897,8 @@ func TestContainerInfoFromInspect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger := logging.New(logtest.New())
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
@@ -864,7 +920,7 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
||||
warnings := func(runnerOptions, workflowOptions string) int {
|
||||
_, hook := mergeOptions(t, runnerOptions, workflowOptions, false)
|
||||
return len(hook.AllEntries())
|
||||
return len(hook.AllRecords())
|
||||
}
|
||||
|
||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
||||
@@ -873,8 +929,37 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
||||
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) {
|
||||
logger, _ := test.NewNullLogger()
|
||||
logger := logging.New(logtest.New())
|
||||
cr := &containerReference{input: &NewContainerInput{
|
||||
NetworkMode: "job-network", NetworkAliases: []string{"redis"}, WorkflowOptions: "--network-alias redis-primary",
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
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.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cli, err := GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
|
||||
@@ -27,9 +27,6 @@ import (
|
||||
"gitea.com/gitea/runner/internal/pkg/process"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -100,25 +97,16 @@ func (e *HostEnvironment) CopyDir(destPath, srcPath string, useGitIgnore, skipGi
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
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{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
UseGitIgnore: useGitIgnore,
|
||||
Handler: &filecollector.CopyCollector{
|
||||
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,
|
||||
Handler: tc,
|
||||
}
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"time"
|
||||
|
||||
"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/require"
|
||||
)
|
||||
@@ -33,38 +33,25 @@ func TestActionPlatformNames(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCopyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
ToolCache: filepath.Join(dir, "tool_cache"),
|
||||
ActPath: filepath.Join(dir, "act_path"),
|
||||
StdOut: os.Stdout,
|
||||
Workdir: path.Join("testdata", "scratch"),
|
||||
}
|
||||
_ = 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) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
useGitIgnore, skipGitDir bool
|
||||
}{
|
||||
{name: "git dir kept for a workspace copy"},
|
||||
{name: "git dir skipped for an action copy", skipGitDir: true},
|
||||
{name: "unfiltered outside a git repository", useGitIgnore: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "action")
|
||||
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, "action.yml"), []byte("name: x\n"), 0o600))
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "dest")
|
||||
e := &HostEnvironment{StdOut: os.Stdout}
|
||||
require.NoError(t, e.CopyDir(dest, src+string(filepath.Separator), false, skipGitDir)(context.Background()))
|
||||
require.NoError(t, (&HostEnvironment{}).CopyDir(dest, src+string(filepath.Separator), test.useGitIgnore, test.skipGitDir)(t.Context()))
|
||||
|
||||
assert.FileExists(t, filepath.Join(dest, "action.yml"))
|
||||
if skipGitDir {
|
||||
if test.skipGitDir {
|
||||
assert.NoDirExists(t, filepath.Join(dest, ".git"))
|
||||
} else {
|
||||
assert.FileExists(t, filepath.Join(dest, ".git", "objects", "pack"))
|
||||
@@ -75,7 +62,7 @@ func TestCopyDirSkipGitDir(t *testing.T) {
|
||||
|
||||
func TestGetContainerArchive(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
@@ -110,7 +97,7 @@ func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
e := &HostEnvironment{
|
||||
Path: filepath.Join(dir, "path"),
|
||||
TmpDir: filepath.Join(dir, "tmp"),
|
||||
@@ -161,7 +148,7 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
||||
err := e.Exec(
|
||||
[]string{"sh", "-c", "[ -t 1 ] && printf TTY || printf NOTTY"},
|
||||
map[string]string{"PATH": os.Getenv("PATH")}, "", "",
|
||||
)(context.Background())
|
||||
)(t.Context())
|
||||
require.NoError(t, err)
|
||||
got := strings.TrimSpace(strings.ReplaceAll(buf.String(), "\r", ""))
|
||||
assert.Equal(t, tc.expect, got)
|
||||
@@ -170,8 +157,7 @@ func TestHostEnvironmentAllocatePTY(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||
base := t.TempDir()
|
||||
miscRoot := filepath.Join(base, "misc")
|
||||
path := filepath.Join(miscRoot, "hostexecutor")
|
||||
@@ -193,8 +179,7 @@ func TestHostEnvironmentRemovePreservesWorkdirByDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHostEnvironmentRemoveCleansWorkdirWhenOwned(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||
base := t.TempDir()
|
||||
miscRoot := filepath.Join(base, "misc")
|
||||
path := filepath.Join(miscRoot, "hostexecutor")
|
||||
@@ -235,7 +220,7 @@ func TestRemoveAllWithContextDoesNotHangOnStuckDelete(t *testing.T) {
|
||||
removeAll = orig
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := removeAllWithContext(ctx, t.TempDir())
|
||||
@@ -252,8 +237,7 @@ func TestHostEnvironmentRemoveDoesNotHangOnStuckCleanUp(t *testing.T) {
|
||||
hostCleanupTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { hostCleanupTimeout = orig })
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
@@ -306,8 +290,7 @@ func TestHostEnvironmentRemoveDoesNotHangOnStuckPathRemoval(t *testing.T) {
|
||||
removeAll = origRemoveAll
|
||||
})
|
||||
|
||||
logger := logrus.New()
|
||||
ctx := common.WithLogger(context.Background(), logrus.NewEntry(logger))
|
||||
ctx := common.WithLogger(t.Context(), logging.Default())
|
||||
base := t.TempDir()
|
||||
path := filepath.Join(base, "misc", "hostexecutor")
|
||||
require.NoError(t, os.MkdirAll(path, 0o700))
|
||||
|
||||
@@ -433,8 +433,8 @@ func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.
|
||||
|
||||
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return c.extract(ctx, "/", func(writer io.Writer) error {
|
||||
return writeDirTar(ctx, writer, destPath, srcPath, useGitIgnore, skipGitDir, 0, 0)
|
||||
return c.extract(ctx, destPath, func(writer io.Writer) error {
|
||||
return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -230,6 +231,15 @@ func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||
_, err = job.GetContainerArchive(ctx, workdir+"/missing")
|
||||
require.Error(t, err)
|
||||
|
||||
actionDir := filepath.Join(t.TempDir(), "action")
|
||||
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "index.js"), []byte("main()"), 0o644))
|
||||
require.NoError(t, job.CopyDir(workdir+"/act/actions/sha/", actionDir+"/", false, false)(ctx))
|
||||
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "main()", string(script))
|
||||
assert.Contains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "command="+url.QueryEscape(workdir+"/act/actions/sha/"))
|
||||
|
||||
pathEnv := map[string]string{}
|
||||
require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx))
|
||||
assert.Equal(t, os.Getenv("PATH"), pathEnv["PATH"])
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
)
|
||||
|
||||
type LinuxContainerEnvironmentExtensions struct{}
|
||||
@@ -21,13 +21,13 @@ type LinuxContainerEnvironmentExtensions struct{}
|
||||
// For use in docker volumes and binds
|
||||
func (*LinuxContainerEnvironmentExtensions) ToContainerPath(path string) string {
|
||||
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 ""
|
||||
}
|
||||
|
||||
abspath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
logging.Error(err)
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ func TestContainerPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
logging.Error(err)
|
||||
}
|
||||
|
||||
rootDrive := os.Getenv("SystemDrive")
|
||||
@@ -49,7 +50,7 @@ func TestContainerPath(t *testing.T) {
|
||||
} else {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
logging.Error(err)
|
||||
}
|
||||
for _, v := range []containerPathJob{
|
||||
{"/home/act/go/src/github.com/nektos/act", "/home/act/go/src/github.com/nektos/act", ""},
|
||||
|
||||
@@ -5,7 +5,6 @@ package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -28,7 +27,7 @@ func TestParseEnvFileSingleLine(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO=bar\nBAZ=qux\n"), 0o600))
|
||||
|
||||
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, "qux", env["BAZ"])
|
||||
}
|
||||
@@ -39,7 +38,7 @@ func TestParseEnvFileMultiLine(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -50,7 +49,7 @@ func TestParseEnvFileLargeValueWithinLimit(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ func TestParseEnvFileLineExceedsBufferReportsScannerError(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600))
|
||||
|
||||
env := map[string]string{}
|
||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||
err := parseEnvFile(e, envPath, &env)(t.Context())
|
||||
require.ErrorIs(t, err, bufio.ErrTooLong)
|
||||
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))
|
||||
|
||||
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, "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))
|
||||
|
||||
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"])
|
||||
|
||||
require.NoError(t, os.WriteFile(envPath, []byte("FOO<<EOF\n\nline2\nEOF\n"), 0o600))
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -99,7 +98,7 @@ func TestParseEnvFileUTF8BOM(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
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"])
|
||||
}
|
||||
|
||||
@@ -121,7 +120,7 @@ func TestParseEnvFileUTF16(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(envPath, content, 0o600))
|
||||
|
||||
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, "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))
|
||||
|
||||
env := map[string]string{}
|
||||
err := parseEnvFile(e, envPath, &env)(context.Background())
|
||||
err := parseEnvFile(e, envPath, &env)(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "delimiter")
|
||||
}
|
||||
+6
-20
@@ -13,10 +13,6 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"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 {
|
||||
@@ -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)
|
||||
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{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
||||
}).CollectFiles(ctx, []string{})); err != nil {
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
UseGitIgnore: useGitIgnore,
|
||||
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
||||
}).CollectFiles(ctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
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"
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
git "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/filemode"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/index"
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Handler interface {
|
||||
@@ -94,73 +94,62 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
|
||||
}
|
||||
|
||||
type FileCollector struct {
|
||||
Ignorer gitignore.Matcher
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
SkipGitDir bool
|
||||
Handler Handler
|
||||
SrcPath string
|
||||
SrcPrefix string
|
||||
SkipGitDir bool
|
||||
UseGitIgnore bool
|
||||
Handler Handler
|
||||
}
|
||||
|
||||
func openGitIndex(path string) (*index.Index, error) {
|
||||
repo, err := git.PlainOpen(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// foldPath compares git's spelling of a path with the filesystem's, which may differ in case or Unicode normalization.
|
||||
func foldPath(name string) string {
|
||||
return strings.ToLower(norm.NFC.String(name))
|
||||
}
|
||||
|
||||
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 {
|
||||
i, _ := openGitIndex(path.Join(fc.SrcPath, path.Join(submodulePath...)))
|
||||
func (fc *FileCollector) CollectFiles(ctx context.Context) filepath.WalkFunc {
|
||||
keep := fc.gitFiles(ctx)
|
||||
return func(file string, fi os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("copy cancelled")
|
||||
}
|
||||
|
||||
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 fc.SkipGitDir && fi.Name() == ".git" {
|
||||
if fi.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var entry *index.Entry
|
||||
if i != nil {
|
||||
entry, err = i.Entry(strings.Join(split[len(submodulePath):], "/"))
|
||||
} else {
|
||||
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 {
|
||||
if keep != nil {
|
||||
rel, _ := filepath.Rel(fc.SrcPath, file)
|
||||
if rel = filepath.ToSlash(rel); !keep[foldPath(rel)] && !strings.Contains("/"+rel+"/", "/.git/") {
|
||||
if fi.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err == nil && entry.Mode == filemode.Submodule {
|
||||
err = filepath.Walk(file, fc.CollectFiles(ctx, split))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.SkipDir
|
||||
}
|
||||
path := filepath.ToSlash(sansPrefix)
|
||||
path := filepath.ToSlash(strings.TrimPrefix(file, fc.SrcPrefix))
|
||||
|
||||
// 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 {
|
||||
linkName, err := os.Readlink(file)
|
||||
if err != nil {
|
||||
@@ -171,17 +160,12 @@ func (fc *FileCollector) CollectFiles(ctx context.Context, submodulePath []strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// open file
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if ctx != nil {
|
||||
stop := context.AfterFunc(ctx, func() { _ = f.Close() })
|
||||
defer stop()
|
||||
}
|
||||
defer context.AfterFunc(ctx, func() { _ = f.Close() })()
|
||||
|
||||
return fc.Handler.WriteFile(path, fi, "", f)
|
||||
}
|
||||
|
||||
@@ -9,106 +9,78 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"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/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")
|
||||
repo, err := git.PlainInit(repoDir, false)
|
||||
require.NoError(t, err)
|
||||
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))
|
||||
worktree, err := repo.Worktree()
|
||||
require.NoError(t, err)
|
||||
_, err = worktree.Add(".gitignore")
|
||||
require.NoError(t, err)
|
||||
for _, dir := range []string{repoDir, filepath.Join(repoDir, "sub"), filepath.Join(repoDir, "nested")} {
|
||||
gitCmd(t, "init", "-q", dir)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
".gitignore": ".*\nign/\n",
|
||||
".env": "",
|
||||
"Renamed": "",
|
||||
"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
|
||||
tw := tar.NewWriter(&archive)
|
||||
patterns, err := gitignore.ReadPatterns(worktree.Filesystem, nil)
|
||||
require.NoError(t, err)
|
||||
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")
|
||||
fc := &FileCollector{SrcPath: repoDir, SrcPrefix: repoDir + string(filepath.Separator), UseGitIgnore: true, Handler: &TarCollector{TarWriter: tw}}
|
||||
require.NoError(t, filepath.Walk(repoDir, fc.CollectFiles(t.Context())))
|
||||
require.NoError(t, tw.Close())
|
||||
|
||||
files := map[string]string{}
|
||||
tr := tar.NewReader(&archive)
|
||||
h, err := tr.Next()
|
||||
assert.NoError(t, err, "tar must not be empty") //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, ".gitignore", h.Name)
|
||||
_, err = tr.Next()
|
||||
assert.ErrorIs(t, err, io.EOF, "tar must only contain one element")
|
||||
for header, err := tr.Next(); err != io.EOF; header, err = tr.Next() {
|
||||
require.NoError(t, err)
|
||||
files[header.Name] = header.Linkname
|
||||
}
|
||||
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) {
|
||||
dst := t.TempDir()
|
||||
target := filepath.Join(dst, "sub", "pack.idx")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
||||
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
|
||||
|
||||
src := filepath.Join(t.TempDir(), "pack.idx")
|
||||
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
|
||||
fi, err := os.Stat(src)
|
||||
fi, err := os.Stat(target)
|
||||
require.NoError(t, err)
|
||||
|
||||
cc := &CopyCollector{DstDir: dst}
|
||||
@@ -119,8 +91,6 @@ func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
|
||||
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) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("creating symlinks requires elevated privileges on Windows")
|
||||
@@ -129,11 +99,8 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
||||
target := filepath.Join(dst, "link")
|
||||
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
|
||||
|
||||
fi, err := os.Lstat(target)
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
@@ -143,7 +110,7 @@ func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
|
||||
func TestFileCollectorCancellationAndWalkError(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
walk := (&FileCollector{}).CollectFiles(ctx, nil)
|
||||
walk := (&FileCollector{}).CollectFiles(ctx)
|
||||
|
||||
err := walk("file", nil, nil)
|
||||
require.EqualError(t, err, "copy cancelled")
|
||||
|
||||
@@ -74,7 +74,7 @@ func SetRef(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
if ghc.Ref == "" {
|
||||
ref, err := pinned(ctx, "ref:"+repoPath, func() (string, error) { return findGitRef(ctx, repoPath) })
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git ref: %v", err)
|
||||
logger.Warnf("unable to get git ref: %v", err)
|
||||
} else {
|
||||
logger.Debugf("using github ref: %s", ref)
|
||||
ghc.Ref = ref
|
||||
@@ -121,7 +121,7 @@ func SetSha(ctx context.Context, ghc *model.GithubContext, repoPath string) {
|
||||
return revision, err
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warningf("unable to get git revision: %v", err)
|
||||
logger.Warnf("unable to get git revision: %v", err)
|
||||
} else {
|
||||
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
|
||||
// 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 == "" {
|
||||
repo, err := findGithubRepo(ctx, repoPath, githubInstance)
|
||||
repo, err := findGithubRepo(ctx, repoPath)
|
||||
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
|
||||
}
|
||||
ghc.Repository = repo
|
||||
|
||||
@@ -9,13 +9,14 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSetRef(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
oldFindGitRef := findGitRef
|
||||
oldFindGitRevision := findGitRevision
|
||||
@@ -104,7 +105,7 @@ func TestSetRef(t *testing.T) {
|
||||
Event: table.event,
|
||||
}
|
||||
|
||||
SetRef(context.Background(), ghc, "/some/dir")
|
||||
SetRef(t.Context(), ghc, "/some/dir")
|
||||
ghc.SetRefTypeAndName()
|
||||
|
||||
assert.Equal(t, table.ref, ghc.Ref)
|
||||
@@ -122,14 +123,14 @@ func TestSetRef(t *testing.T) {
|
||||
Event: map[string]any{},
|
||||
}
|
||||
|
||||
SetRef(context.Background(), ghc, "/some/dir")
|
||||
SetRef(t.Context(), ghc, "/some/dir")
|
||||
|
||||
assert.Equal(t, "refs/heads/master", ghc.Ref)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetSha(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
oldFindGitRef := findGitRef
|
||||
oldFindGitRevision := findGitRevision
|
||||
@@ -209,14 +210,14 @@ func TestSetSha(t *testing.T) {
|
||||
Event: table.event,
|
||||
}
|
||||
|
||||
SetSha(context.Background(), ghc, "/some/dir")
|
||||
SetSha(t.Context(), ghc, "/some/dir")
|
||||
|
||||
assert.Equal(t, table.sha, ghc.Sha)
|
||||
})
|
||||
}
|
||||
|
||||
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"} {
|
||||
findGitRevision = func(context.Context, string) (string, string, error) { return "", 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 {
|
||||
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 {
|
||||
return nil, err2
|
||||
}
|
||||
@@ -145,18 +145,18 @@ func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir, actio
|
||||
containerActionDirCopy += `/`
|
||||
}
|
||||
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
unlock, err := git.AcquireCloneLock(ctx, actionDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
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()))()
|
||||
}
|
||||
|
||||
if err := removeGitIgnore(ctx, actionDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore, true)(ctx)
|
||||
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", false, true)(ctx) // an exported commit has no .git to apply gitignore with
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -241,23 +241,6 @@ func nodeActionCommand(script string) []string {
|
||||
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
|
||||
// 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.
|
||||
inner := prepImage
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -522,25 +509,14 @@ func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *
|
||||
return nil
|
||||
}
|
||||
|
||||
func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) {
|
||||
actionName := ""
|
||||
containerActionDir := "."
|
||||
if step.Type() != model.StepTypeUsesActionRemote {
|
||||
actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir)
|
||||
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
|
||||
func getContainerActionPaths(step actionStep) (string, string) {
|
||||
rc := step.getRunContext()
|
||||
if sar, ok := step.(*stepActionRemote); ok { // a copy per action, as its repository's directory is shared
|
||||
actionName := path.Join(sar.containerKey(), sar.remoteAction.Path)
|
||||
return actionName, rc.JobContainer.GetActPath() + "/actions/" + actionName
|
||||
}
|
||||
|
||||
if actionName == "" {
|
||||
actionName = filepath.Base(actionDir)
|
||||
if runtime.GOOS == "windows" {
|
||||
actionName = strings.ReplaceAll(actionName, "\\", "/")
|
||||
}
|
||||
}
|
||||
return actionName, containerActionDir
|
||||
actionName := getOsSafeRelativePath(filepath.Join(rc.Config.Workdir, step.getStepModel().Uses), rc.Config.Workdir)
|
||||
return "./" + actionName, rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName
|
||||
}
|
||||
|
||||
func getOsSafeRelativePath(s, prefix string) string {
|
||||
@@ -586,13 +562,13 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
if sar, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = sar.actionDir()
|
||||
actionDir = sar.actionDir
|
||||
actionPath = sar.remoteAction.Path
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -681,17 +657,17 @@ func shouldRunPostStep(step actionStep) common.Conditional {
|
||||
stepResult := stepResults[step.getStepModel().ID]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
@@ -50,7 +50,9 @@ func TestCompositeActionParity(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{
|
||||
Config: &Config{GitHubInstance: "github.com", ActionCacheDir: t.TempDir()},
|
||||
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 {
|
||||
assert.Equal(t, "actionDir/actionPath/trampoline.js", filename)
|
||||
assert.Equal(t, fs.FileMode(0o400), perm)
|
||||
assert.Equal(t, fs.FileMode(0o644), perm)
|
||||
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.Equal(t, tt.expected, action)
|
||||
@@ -143,6 +143,7 @@ runs:
|
||||
}
|
||||
|
||||
func TestActionRunner(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
table := []struct {
|
||||
name string
|
||||
step actionStep
|
||||
@@ -177,7 +178,8 @@ func TestActionRunner(t *testing.T) {
|
||||
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"},
|
||||
},
|
||||
@@ -216,7 +218,8 @@ func TestActionRunner(t *testing.T) {
|
||||
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"},
|
||||
},
|
||||
@@ -224,10 +227,11 @@ func TestActionRunner(t *testing.T) {
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
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 {
|
||||
for k, v := range tt.expectedEnv {
|
||||
@@ -238,7 +242,7 @@ func TestActionRunner(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -289,7 +293,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := &RunContext{
|
||||
Name: "job",
|
||||
Config: &Config{
|
||||
@@ -326,7 +330,7 @@ func TestNewStepContainerDoesNotUseDockerSecrets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
actionDir := t.TempDir()
|
||||
|
||||
@@ -366,7 +370,10 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
|
||||
peerAcquired := make(chan struct{})
|
||||
go func() {
|
||||
unlock := git.AcquireCloneLock(actionDir)
|
||||
unlock, err := git.AcquireCloneLock(t.Context(), actionDir)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
close(peerAcquired)
|
||||
unlock()
|
||||
}()
|
||||
@@ -400,7 +407,9 @@ func TestMaybeCopyToActionDirHoldsCloneLock(t *testing.T) {
|
||||
func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
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()
|
||||
|
||||
innerEntered := make(chan struct{})
|
||||
@@ -441,7 +450,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) {
|
||||
env: map[string]string{},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
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("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)
|
||||
assert.Equal(t, tc.wantCmd, input.Cmd)
|
||||
assert.Equal(t, tc.wantEntrypoint, input.Entrypoint)
|
||||
@@ -628,7 +637,7 @@ func TestDockerActionHasPreAndPostStep(t *testing.T) {
|
||||
newStep := func(runs model.ActionRuns) actionStep {
|
||||
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, 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)
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestMainStepsExecutorRunsAlwaysStepsAfterCancel(t *testing.T) {
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
var ran []string
|
||||
@@ -110,7 +110,7 @@ func TestMainStepsExecutorMarksFailedOnTimeoutBetweenSteps(t *testing.T) {
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx := newControllableDeadlineContext(context.Background())
|
||||
ctx := newControllableDeadlineContext(t.Context())
|
||||
|
||||
var ran []string
|
||||
var laterStepCtxErr error
|
||||
@@ -149,7 +149,7 @@ func TestStepsExecutorRunsMainStepsAfterPreCancel(t *testing.T) {
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
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")
|
||||
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{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, ""),
|
||||
})
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
ctx := common.WithJobErrorContainer(t.Context())
|
||||
|
||||
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.
|
||||
func TestPostStepsContextCancelledIsUsableForFailingStep(t *testing.T) {
|
||||
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()
|
||||
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
|
||||
// survive into the post phase and the job is still reported as failed.
|
||||
func TestPostStepsContextDeadlinePreservesJobError(t *testing.T) {
|
||||
base := common.WithJobErrorContainer(context.Background())
|
||||
base := common.WithJobErrorContainer(t.Context())
|
||||
common.SetJobError(base, assert.AnError)
|
||||
expired, cancel := context.WithDeadline(base, time.Now().Add(-time.Hour))
|
||||
defer cancel()
|
||||
@@ -292,14 +292,14 @@ func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
||||
rc := &RunContext{}
|
||||
|
||||
// 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)
|
||||
require.NoError(t, common.JobError(live))
|
||||
assert.False(t, rc.jobFailed)
|
||||
assert.False(t, rc.jobCancelled)
|
||||
|
||||
// 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()
|
||||
reportStepError(cancelled, rc, context.Canceled)
|
||||
require.NoError(t, common.JobError(cancelled))
|
||||
@@ -307,7 +307,7 @@ func TestReportStepErrorTreatsCancelAsInterruption(t *testing.T) {
|
||||
assert.True(t, rc.jobCancelled)
|
||||
|
||||
// a real error still fails the job
|
||||
failed := common.WithJobErrorContainer(context.Background())
|
||||
failed := common.WithJobErrorContainer(t.Context())
|
||||
reportStepError(failed, rc, assert.AnError)
|
||||
require.ErrorIs(t, common.JobError(failed), assert.AnError)
|
||||
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.
|
||||
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. "+
|
||||
"Write to $GITHUB_ENV or $GITHUB_PATH instead, or set ACTIONS_ALLOW_UNSECURE_COMMANDS to allow it",
|
||||
command)))
|
||||
@@ -159,7 +159,7 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg
|
||||
if fromCommand {
|
||||
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
|
||||
}
|
||||
common.Logger(ctx).Infof("::set-env:: %s=%s", name, arg)
|
||||
|
||||
+18
-19
@@ -6,15 +6,14 @@ package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -26,8 +25,8 @@ func unsecureRC() *RunContext {
|
||||
|
||||
func TestSetEnv(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
logger, hook := test.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
rc := unsecureRC()
|
||||
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")
|
||||
a.NotContains(rc.Env, "NODE_OPTIONS")
|
||||
a.NotContains(rc.Env, "node_options")
|
||||
entries := hook.AllEntries()
|
||||
entries := hook.AllRecords()
|
||||
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 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) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
@@ -68,7 +67,7 @@ func TestStopCommandsKeepsSuppressedLinesInLog(t *testing.T) {
|
||||
|
||||
func TestSetOutput(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := new(RunContext)
|
||||
rc.StepResults = make(map[string]*model.StepResult)
|
||||
handler := rc.commandHandler(ctx)
|
||||
@@ -108,7 +107,7 @@ func TestSetOutput(t *testing.T) {
|
||||
|
||||
func TestAddpath(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
@@ -120,10 +119,10 @@ func TestAddpath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStopCommands(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger, hook := logtest.NewLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
@@ -137,7 +136,7 @@ func TestStopCommands(t *testing.T) {
|
||||
a.Equal("abcd", rc.Env["x"])
|
||||
|
||||
messages := make([]string, 0)
|
||||
for _, entry := range hook.AllEntries() {
|
||||
for _, entry := range hook.AllRecords() {
|
||||
messages = append(messages, entry.Message)
|
||||
}
|
||||
|
||||
@@ -149,7 +148,7 @@ func TestStopCommands(t *testing.T) {
|
||||
func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(context.Background())
|
||||
handler := rc.commandHandler(t.Context())
|
||||
|
||||
handler("::stop-commands::add-mask\n")
|
||||
handler("::set-env name=x::suppressed\n")
|
||||
@@ -162,7 +161,7 @@ func TestStopCommandsResumesOnCommandNamedToken(t *testing.T) {
|
||||
|
||||
func TestAddpathADO(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := unsecureRC()
|
||||
handler := rc.commandHandler(ctx)
|
||||
|
||||
@@ -174,17 +173,17 @@ func TestAddpathADO(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddmask(t *testing.T) {
|
||||
logger, hook := test.NewNullLogger()
|
||||
logger, hook := logtest.NewLogger()
|
||||
|
||||
a := assert.New(t)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
loggerCtx := common.WithLogger(ctx, logger)
|
||||
|
||||
rc := new(RunContext)
|
||||
handler := rc.commandHandler(loggerCtx)
|
||||
handler("::ADD-MASK::my::secret")
|
||||
|
||||
a.Equal("***", hook.LastEntry().Message)
|
||||
a.Equal("***", hook.LastRecord().Message)
|
||||
a.Equal([]string{"my::secret"}, rc.Masks)
|
||||
}
|
||||
|
||||
@@ -231,7 +230,7 @@ func TestAddmaskUsemask(t *testing.T) {
|
||||
}
|
||||
|
||||
re := captureOutput(t, func() {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
ctx = WithJobLogger(ctx, "0", "testjob", config, &rc.Masks, map[string]any{})
|
||||
|
||||
handler := rc.commandHandler(ctx)
|
||||
@@ -248,7 +247,7 @@ func TestSaveState(t *testing.T) {
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
handler := rc.commandHandler(ctx)
|
||||
handler("::save-state name=state-name::state-value\n")
|
||||
@@ -281,7 +280,7 @@ func TestUnsecureCommands(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
rc := &RunContext{Env: tt.jobEnv}
|
||||
rc.setCurrentStepEnv(tt.stepEnv)
|
||||
handler := rc.commandHandler(context.Background())
|
||||
handler := rc.commandHandler(t.Context())
|
||||
|
||||
handler("::set-env name=x::valz\n")
|
||||
handler("::add-path::/opt/bin\n")
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/client"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -83,10 +83,10 @@ func TestDockerProxyMountedJob(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
hook := &test.Hook{}
|
||||
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHook(ctx, hook)))
|
||||
hook := logtest.New()
|
||||
require.NoError(t, runner.NewPlanExecutor(plan)(common.WithLoggerHandler(ctx, hook)))
|
||||
var messages []string
|
||||
for _, entry := range hook.AllEntries() {
|
||||
for _, entry := range hook.AllRecords() {
|
||||
messages = append(messages, strings.TrimSpace(entry.Message))
|
||||
}
|
||||
require.Contains(t, messages, "docker proxy post verified")
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -82,7 +81,7 @@ func createRunContext(t *testing.T) *RunContext {
|
||||
|
||||
func TestEvaluateRunContext(t *testing.T) {
|
||||
rc := createRunContext(t)
|
||||
ee := rc.NewExpressionEvaluator(context.Background())
|
||||
ee := rc.NewExpressionEvaluator(t.Context())
|
||||
|
||||
tables := []struct {
|
||||
in string
|
||||
@@ -142,7 +141,7 @@ func TestEvaluateRunContext(t *testing.T) {
|
||||
for _, table := range tables {
|
||||
t.Run(table.in, func(t *testing.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 == "" {
|
||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assertObject.Equal(table.out, out, table.in)
|
||||
@@ -162,7 +161,7 @@ func TestEvaluateStep(t *testing.T) {
|
||||
env: map[string]string{"INPUT_FORGED": "leaked"},
|
||||
}
|
||||
|
||||
ee := rc.NewStepExpressionEvaluator(context.Background(), step)
|
||||
ee := rc.NewStepExpressionEvaluator(t.Context(), step)
|
||||
|
||||
tables := []struct {
|
||||
in string
|
||||
@@ -184,7 +183,7 @@ func TestEvaluateStep(t *testing.T) {
|
||||
for _, table := range tables {
|
||||
t.Run(table.in, func(t *testing.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 == "" {
|
||||
assertObject.NoError(err, table.in) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
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 {
|
||||
in string
|
||||
out string
|
||||
@@ -270,14 +269,14 @@ func TestInterpolate(t *testing.T) {
|
||||
|
||||
for _, table := range tables {
|
||||
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)
|
||||
assert.Equal(t, table.out, out, table.in)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -30,7 +29,7 @@ func requireLinuxDocker(t *testing.T) {
|
||||
// so the daemon has to be pinged explicitly here to decide whether to skip.
|
||||
func requireDocker(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cli, err := container.GetDockerClient(ctx)
|
||||
if err != nil {
|
||||
t.Skipf("skipping: docker client unavailable: %v", err)
|
||||
|
||||
+51
-29
@@ -29,7 +29,10 @@ import (
|
||||
"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
|
||||
// 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.
|
||||
type actionPreparer interface {
|
||||
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
|
||||
@@ -89,29 +92,30 @@ func printPrepareActions(rc *RunContext, preparers []actionPreparer) common.Exec
|
||||
return nil
|
||||
}
|
||||
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
rawLogger.Infof("Prepare all required actions")
|
||||
|
||||
for _, preparer := range preparers {
|
||||
if err := preparer.prepareActionExecutor()(ctx); err != nil {
|
||||
// No step has run yet, so the failure belongs to the job.
|
||||
reportStepError(ctx, rc, err)
|
||||
return err
|
||||
return setupError{err}
|
||||
}
|
||||
reference, sha, ok := preparer.actionDownloadInfo()
|
||||
if !ok {
|
||||
reference, sha := preparer.actionDownloadInfo()
|
||||
if sha == "" {
|
||||
continue
|
||||
}
|
||||
if sha == "" {
|
||||
rawLogger.Infof("Download action repository '%s'", reference)
|
||||
} else {
|
||||
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||
}
|
||||
rawLogger.Infof("Download action repository '%s' (SHA:%s)", reference, sha)
|
||||
}
|
||||
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.
|
||||
func printCompleteJobName(rc *RunContext) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -123,7 +127,7 @@ func printCompleteJobName(rc *RunContext) common.Executor {
|
||||
if name == "" && rc.Run != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -136,20 +140,18 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
preparers := make([]actionPreparer, 0)
|
||||
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()
|
||||
|
||||
if len(infoSteps) == 0 {
|
||||
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 {
|
||||
// Have to be skipped for some Tests
|
||||
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
|
||||
// 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, printPrepareActions(rc, preparers))
|
||||
preSteps = append(preSteps, stepPreSteps...)
|
||||
preSteps = append(preSteps, printCompleteJobName(rc))
|
||||
preSteps = append(preSteps, stepPreSteps...)
|
||||
|
||||
// Ahead of the teardown below, while the job environment is still up.
|
||||
postExecutor = postExecutor.Finally(rc.runJobCompletedHook)
|
||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||
postExecutor = postExecutor.Finally(useJobStageLogger(StageComplete, rc.runJobCompletedHook))
|
||||
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
|
||||
if err := info.interpolateOutputs()(ctx); err != nil {
|
||||
reportStepError(ctx, rc, err)
|
||||
@@ -243,13 +246,13 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
reportStepError(ctx, rc, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}))
|
||||
|
||||
postExecutor = postExecutor.Finally(func(ctx context.Context) error {
|
||||
postExecutor = postExecutor.Finally(useJobStageLogger(StageComplete, func(ctx context.Context) error {
|
||||
jobError := common.JobError(ctx)
|
||||
var err error
|
||||
// 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()
|
||||
|
||||
logger := common.Logger(ctx)
|
||||
@@ -262,7 +265,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
rc.removeDockerActionImages(ctx)
|
||||
|
||||
return err
|
||||
})
|
||||
}))
|
||||
|
||||
return common.Executor(func(ctx context.Context) error {
|
||||
if err := info.startContainer()(ctx); err != nil {
|
||||
@@ -316,6 +319,9 @@ func newStepsExecutor(rc *RunContext, preSteps, steps []common.Executor) common.
|
||||
mainExecutor := newMainStepsExecutor(rc, steps)
|
||||
return func(ctx context.Context) error {
|
||||
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)
|
||||
if preErr != nil {
|
||||
return preErr
|
||||
@@ -409,7 +415,7 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
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.
|
||||
@@ -532,7 +538,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
// Reuse a single client across all step uploads so connections can be pooled.
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout, Transport: rc.Config.instanceTransportFor(base)}
|
||||
for i := range rc.Run.Job().Steps {
|
||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||
@@ -544,6 +550,15 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// instanceTransportFor returns InstanceTransport only for URLs on the registered address, as steps can rewrite the summary URL through GITHUB_ENV.
|
||||
func (c Config) instanceTransportFor(rawURL string) http.RoundTripper {
|
||||
if !strings.HasPrefix(rawURL, c.GitHubInstance+"/") {
|
||||
return nil
|
||||
}
|
||||
return c.InstanceTransport
|
||||
}
|
||||
|
||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||
@@ -672,6 +687,13 @@ func archiveEntryMatchesPath(entryName, requestedPath string) bool {
|
||||
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 {
|
||||
return func(ctx context.Context) error {
|
||||
ctx = withStepLogger(ctx, stepModel.Number, stepModel.ID, rc.ExprEval.InterpolateName(ctx, stepModel.String()), stage.String())
|
||||
|
||||
@@ -21,11 +21,11 @@ import (
|
||||
"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/act/container"
|
||||
|
||||
"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/mock"
|
||||
"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},
|
||||
}
|
||||
// These tests are sufficient to only check syntax.
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
ctx := common.WithDryrun(t.Context(), true)
|
||||
for _, table := range tables {
|
||||
t.Run(table.workflowPath, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -118,7 +118,6 @@ func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, er
|
||||
type actionPreparerMock struct {
|
||||
reference string
|
||||
sha string
|
||||
ok bool
|
||||
err error
|
||||
prepared int
|
||||
}
|
||||
@@ -130,31 +129,25 @@ func (apm *actionPreparerMock) prepareActionExecutor() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func (apm *actionPreparerMock) actionDownloadInfo() (string, string, bool) {
|
||||
return apm.reference, apm.sha, apm.ok
|
||||
func (apm *actionPreparerMock) actionDownloadInfo() (string, string) {
|
||||
return apm.reference, apm.sha
|
||||
}
|
||||
|
||||
func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||
|
||||
preparers := []actionPreparer{
|
||||
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", ok: true},
|
||||
// A resolved commit is best effort; the ref alone is reported when it is unknown.
|
||||
&actionPreparerMock{reference: "actions/setup-go@v6", ok: true},
|
||||
&actionPreparerMock{reference: "actions/checkout@v7", sha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"},
|
||||
// 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))
|
||||
|
||||
want := strings.Join([]string{
|
||||
"[j1] | Prepare all required actions",
|
||||
"[j1] | Download action repository 'actions/checkout@v7' (SHA:9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)",
|
||||
"[j1] | Download action repository 'actions/setup-go@v6'",
|
||||
"",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, buf.String())
|
||||
@@ -162,10 +155,8 @@ func TestPrintPrepareActionsGolden(t *testing.T) {
|
||||
|
||||
func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||
|
||||
require.NoError(t, printPrepareActions(&RunContext{}, nil)(ctx))
|
||||
|
||||
@@ -173,12 +164,12 @@ func TestPrintPrepareActionsSkipsWithoutActions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||
logger, _ := logrustest.NewNullLogger()
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("job", "j1")))
|
||||
logger := logging.New(logtest.New())
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(t.Context(), logger.With("job", "j1")))
|
||||
|
||||
downloadErr := errors.New("failed to fetch \"actions/checkout\"")
|
||||
rc := &RunContext{}
|
||||
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", ok: true}
|
||||
remaining := &actionPreparerMock{reference: "actions/setup-go@v6", sha: "sha"}
|
||||
|
||||
err := printPrepareActions(rc, []actionPreparer{
|
||||
&actionPreparerMock{err: downloadErr},
|
||||
@@ -192,6 +183,27 @@ func TestPrintPrepareActionsFailsJobOnDownloadError(t *testing.T) {
|
||||
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) {
|
||||
for name, tt := range map[string]struct {
|
||||
rc *RunContext
|
||||
@@ -203,10 +215,8 @@ func TestPrintCompleteJobName(t *testing.T) {
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
ctx := common.WithLogger(context.Background(), logger.WithFields(log.Fields{"job": "j1"}))
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
ctx := common.WithLogger(t.Context(), logger.With("job", "j1"))
|
||||
|
||||
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
|
||||
// step that downloaded its own action would leave the log interleaved with the downloads.
|
||||
func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||
ctx := common.WithJobErrorContainer(context.Background())
|
||||
ctx := common.WithJobErrorContainer(t.Context())
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
rc := &RunContext{
|
||||
@@ -253,11 +263,12 @@ func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||
|
||||
for _, stepModel := range steps {
|
||||
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)
|
||||
|
||||
sm.On("pre").Return(func(context.Context) error {
|
||||
sm.On("pre").Return(func(ctx context.Context) error {
|
||||
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
||||
common.Logger(ctx).Infof("Run Pre %s", stepModel.ID)
|
||||
return nil
|
||||
})
|
||||
sm.On("main").Return(func(context.Context) error {
|
||||
@@ -269,24 +280,31 @@ func TestNewJobExecutorDownloadsAllActionsBeforeTheFirstStep(t *testing.T) {
|
||||
defer sm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.WithField("job", "test")))
|
||||
logger, hook := logtest.NewLogger()
|
||||
err := newJobExecutor(jim, sfm, rc)(common.WithLogger(ctx, logger.With("job", "test")))
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
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") ||
|
||||
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)
|
||||
}
|
||||
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{
|
||||
"Prepare all required actions",
|
||||
"Download action repository 'actions/checkout@v1'",
|
||||
"Download action repository 'actions/checkout@v2'",
|
||||
"Download action repository 'actions/checkout@v1' (SHA:sha1)",
|
||||
"Download action repository 'actions/checkout@v2' (SHA:sha2)",
|
||||
"Complete job name: test",
|
||||
"Run Pre 1",
|
||||
"Run Pre 2",
|
||||
}, setup)
|
||||
}
|
||||
|
||||
@@ -462,7 +480,7 @@ func TestNewJobExecutor(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
|
||||
|
||||
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(context.Background()))
|
||||
ctx, cancel := context.WithCancel(common.WithJobErrorContainer(t.Context()))
|
||||
defer cancel()
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
@@ -610,7 +628,7 @@ func (ctx *controllableDeadlineContext) expire() {
|
||||
// still run against a fresh, non-expired context, and the job must still be
|
||||
// reported as failed.
|
||||
func TestNewJobExecutorRunsPostStepsAfterTimeout(t *testing.T) {
|
||||
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(context.Background()))
|
||||
ctx := newControllableDeadlineContext(common.WithJobErrorContainer(t.Context()))
|
||||
|
||||
jim := &jobInfoMock{}
|
||||
sfm := &stepFactoryMock{}
|
||||
@@ -691,7 +709,7 @@ func TestSetJobResultMatrixContinueOnError(t *testing.T) {
|
||||
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
|
||||
// RunContext is its own jobInfo (rc implements jobInfo) and shares the job.
|
||||
@@ -786,7 +804,7 @@ func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cm := &containerMock{}
|
||||
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"}))),
|
||||
@@ -830,7 +848,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
||||
defer server.Close()
|
||||
defer close(release)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cm := &containerMock{}
|
||||
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"}))),
|
||||
@@ -858,7 +876,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t *testing.T) {
|
||||
runtimeToken := fakeRuntimeToken(34)
|
||||
|
||||
type upload struct {
|
||||
@@ -866,7 +884,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
body string
|
||||
}
|
||||
var got []upload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
got = append(got, upload{r.URL.Path, string(body)})
|
||||
@@ -874,7 +892,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cm := &containerMock{}
|
||||
// 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(
|
||||
@@ -896,6 +914,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, cm, 3)
|
||||
rc.Config.GitHubInstance, rc.Config.InstanceTransport = server.URL, server.Client().Transport
|
||||
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
|
||||
@@ -921,7 +940,7 @@ func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, &containerMock{}, 1)
|
||||
|
||||
tryUploadJobSummary(context.Background(), rc)
|
||||
tryUploadJobSummary(t.Context(), rc)
|
||||
|
||||
assert.Equal(t, 0, requests)
|
||||
}
|
||||
@@ -941,7 +960,7 @@ func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, &containerMock{}, 1)
|
||||
|
||||
tryUploadJobSummary(context.Background(), rc)
|
||||
tryUploadJobSummary(t.Context(), rc)
|
||||
|
||||
assert.Equal(t, 0, requests)
|
||||
}
|
||||
@@ -954,7 +973,7 @@ func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t,
|
||||
@@ -973,8 +992,8 @@ func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
cm := &containerMock{}
|
||||
content := strings.Repeat("a", 300)
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
@@ -991,15 +1010,15 @@ func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
||||
assert.LessOrEqual(t, len(body), maxBytes)
|
||||
keep := maxBytes - len(jobSummaryTruncationMarker)
|
||||
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
||||
if assert.Len(t, hook.Entries, 1) {
|
||||
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
||||
if assert.Len(t, hook.AllRecords(), 1) {
|
||||
assert.Contains(t, hook.AllRecords()[0].Message, "job summary truncated")
|
||||
}
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
cm := &containerMock{}
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
||||
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
||||
@@ -1011,7 +1030,7 @@ func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *test
|
||||
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, []byte("abc"), body)
|
||||
assert.Empty(t, hook.Entries)
|
||||
assert.Empty(t, hook.AllRecords())
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -1069,7 +1088,7 @@ func makeTestRC(t *testing.T, jobYAML string) *RunContext {
|
||||
Name: "workflow1",
|
||||
Jobs: map[string]*model.Job{"job1": job},
|
||||
}, nil)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
return rc
|
||||
}
|
||||
|
||||
@@ -1087,7 +1106,7 @@ func TestApplyJobTimeout(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rc := makeTestRC(t, tc.yaml)
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
newCtx, cancel := applyJobTimeout(ctx, rc, rc.Run.Job())
|
||||
defer cancel()
|
||||
_, hasDeadline := newCtx.Deadline()
|
||||
@@ -1111,7 +1130,7 @@ func TestEvaluateJobContinueOnError(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1176,7 +1195,7 @@ func TestTryUploadJobSummaryMasksSecrets(t *testing.T) {
|
||||
rc.Config.ExtraMasks = []string{"pr0xypw"}
|
||||
rc.Masks = []string{"runtime-added"}
|
||||
|
||||
tryUploadJobSummary(context.Background(), rc)
|
||||
tryUploadJobSummary(t.Context(), rc)
|
||||
|
||||
assert.Equal(t, "deployed true with *** and *** via ***", got)
|
||||
cm.AssertExpectations(t)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
|
||||
}
|
||||
|
||||
cmd, shell := hookCommand(hookPath)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
rawLogger.Infof("::group::Run '%s'", EscapeCommandData(hookPath))
|
||||
rawLogger.Infof("A %s hook has been configured by the runner administrator", name)
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
"github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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": {}}}},
|
||||
JobContainer: jobContainer,
|
||||
}
|
||||
logger, _ := test.NewNullLogger()
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.WithField("test", true)))
|
||||
logger := logging.New(logtest.New())
|
||||
ctx := common.WithJobErrorContainer(common.WithLogger(context.Background(), logger.With("test", true)))
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
return rc, ctx
|
||||
}
|
||||
|
||||
+136
-99
@@ -19,8 +19,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
@@ -35,6 +35,16 @@ const (
|
||||
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 (
|
||||
rawOutputField = "raw_output"
|
||||
scriptLineCyanField = "script_line_cyan"
|
||||
@@ -73,8 +83,9 @@ func WithMasks(ctx context.Context, masks *[]string) context.Context {
|
||||
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 {
|
||||
WithJobLogger() *logrus.Logger
|
||||
WithJobLogger() logging.Handler
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx = WithMasks(ctx, masks)
|
||||
|
||||
var logger *logrus.Logger
|
||||
var output logging.Handler
|
||||
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 {
|
||||
var formatter logrus.Formatter
|
||||
if config.JSONLogger {
|
||||
formatter = &logrus.JSONFormatter{}
|
||||
} else {
|
||||
mux.Lock()
|
||||
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)
|
||||
mux.Lock()
|
||||
nextColor++
|
||||
color := colors[nextColor%len(colors)]
|
||||
mux.Unlock()
|
||||
output = newJobLogHandler(os.Stdout, color)
|
||||
}
|
||||
|
||||
{ // Adapt to Gitea
|
||||
if hook := common.LoggerHook(ctx); hook != nil {
|
||||
logger.AddHook(hook)
|
||||
}
|
||||
if config.JobLoggerLevel != nil {
|
||||
logger.SetLevel(*config.JobLoggerLevel)
|
||||
} else {
|
||||
logger.SetLevel(logrus.TraceLevel)
|
||||
}
|
||||
var handlers []logging.Handler
|
||||
if h := common.LoggerHandler(ctx); h != nil { // Adapt to Gitea
|
||||
handlers = append(handlers, h)
|
||||
}
|
||||
|
||||
logger.SetFormatter(&maskedFormatter{
|
||||
Formatter: logger.Formatter,
|
||||
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
||||
handlers = append(handlers, &maskedHandler{
|
||||
Handler: output,
|
||||
masker: valueMasker(config.InsecureSecrets, config.maskers()),
|
||||
})
|
||||
rtn := logger.WithFields(logrus.Fields{
|
||||
"job": jobName,
|
||||
"jobID": jobID,
|
||||
"dryrun": common.Dryrun(ctx),
|
||||
"matrix": matrix,
|
||||
}).WithContext(ctx)
|
||||
|
||||
level := logging.LevelTrace
|
||||
if config.JobLoggerLevel != nil {
|
||||
level = *config.JobLoggerLevel
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func WithCompositeLogger(ctx context.Context, masks *[]string) context.Context {
|
||||
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 {
|
||||
val := common.Logger(ctx)
|
||||
stepIDs := make([]string, 0)
|
||||
stepIDs := append(slices.Clone(common.StepIDs(ctx)), stepID)
|
||||
ctx = common.WithStepIDs(ctx, stepIDs)
|
||||
|
||||
if logger, ok := val.(*logrus.Entry); ok {
|
||||
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))
|
||||
return common.WithLogger(ctx, common.Logger(ctx).With("stepID", stepIDs).WithContext(ctx))
|
||||
}
|
||||
|
||||
func withStepLogger(ctx context.Context, stepNumber int, stepID, stepName, stageName string) context.Context {
|
||||
rtn := common.Logger(ctx).WithFields(logrus.Fields{
|
||||
"stepNumber": stepNumber,
|
||||
"step": stepName,
|
||||
"stepID": []string{stepID},
|
||||
"stage": stageName,
|
||||
})
|
||||
stepIDs := []string{stepID}
|
||||
ctx = common.WithStepIDs(ctx, stepIDs)
|
||||
|
||||
rtn := common.Logger(ctx).With(
|
||||
"stepNumber", stepNumber,
|
||||
"step", stepName,
|
||||
"stepID", stepIDs,
|
||||
"stage", stageName,
|
||||
)
|
||||
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{
|
||||
func(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) },
|
||||
@@ -321,16 +318,16 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
||||
replacer *strings.Replacer
|
||||
)
|
||||
|
||||
return func(entry *logrus.Entry) *logrus.Entry {
|
||||
return func(ctx context.Context, r logging.Record) logging.Record {
|
||||
if insecureSecrets {
|
||||
return entry
|
||||
return r
|
||||
}
|
||||
|
||||
masks := Masks(entry.Context)
|
||||
masks := Masks(ctx)
|
||||
|
||||
if len(*masks) == 0 {
|
||||
entry.Message = defReplacer.Replace(entry.Message)
|
||||
return entry
|
||||
r.Message = defReplacer.Replace(r.Message)
|
||||
return r
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
@@ -349,89 +346,129 @@ func valueMasker(insecureSecrets bool, oldnew []string) entryProcessor {
|
||||
cmasker := replacer
|
||||
mu.Unlock()
|
||||
|
||||
entry.Message = cmasker.Replace(entry.Message)
|
||||
r.Message = cmasker.Replace(r.Message)
|
||||
|
||||
return entry
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
type maskedFormatter struct {
|
||||
logrus.Formatter
|
||||
type maskedHandler struct {
|
||||
logging.Handler
|
||||
masker entryProcessor
|
||||
}
|
||||
|
||||
func (f *maskedFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
return f.Formatter.Format(f.masker(entry))
|
||||
func (m *maskedHandler) Handle(ctx context.Context, r logging.Record) error {
|
||||
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
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) Format(entry *logrus.Entry) ([]byte, error) {
|
||||
func (f textFormatter) format(r logging.Record) ([]byte, error) {
|
||||
b := &bytes.Buffer{}
|
||||
|
||||
// the web renderer decodes command data, so this local view has to as well
|
||||
if _, _, _, ok := tryParseRawActionCommand(entry.Message + "\n"); ok {
|
||||
entry.Message = UnescapeCommandData(entry.Message)
|
||||
if _, _, _, ok := tryParseRawActionCommand(r.Message + "\n"); ok {
|
||||
r.Message = UnescapeCommandData(r.Message)
|
||||
}
|
||||
r.Message = strings.TrimSuffix(r.Message, "\n")
|
||||
|
||||
if f.isColored(entry) {
|
||||
f.printColored(b, entry)
|
||||
if f.isColored() {
|
||||
f.printColored(b, r)
|
||||
} else {
|
||||
f.print(b, entry)
|
||||
f.print(b, r)
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func (f *jobLogFormatter) printColored(b *bytes.Buffer, entry *logrus.Entry) {
|
||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||
func attrIsTrue(r logging.Record, key string) bool {
|
||||
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 := ""
|
||||
if entry.Level == logrus.DebugLevel {
|
||||
if r.Level == logging.LevelDebug {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Data[rawOutputField] == true:
|
||||
if entry.Data[scriptLineCyanField] == true {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, entry.Message)
|
||||
case attrIsTrue(r, rawOutputField):
|
||||
if attrIsTrue(r, scriptLineCyanField) {
|
||||
fmt.Fprintf(b, "\x1b[%dm|\x1b[0m \x1b[36;1m%s\x1b[0m", f.color, r.Message)
|
||||
} 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:
|
||||
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)
|
||||
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, r.Message)
|
||||
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) {
|
||||
entry.Message = strings.TrimSuffix(entry.Message, "\n")
|
||||
|
||||
job := entry.Data["job"]
|
||||
func (f textFormatter) print(b *bytes.Buffer, r logging.Record) {
|
||||
job, _ := r.Attr("job")
|
||||
|
||||
debugFlag := ""
|
||||
if entry.Level == logrus.DebugLevel {
|
||||
if r.Level == logging.LevelDebug {
|
||||
debugFlag = "[DEBUG] "
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Data[rawOutputField] == true:
|
||||
fmt.Fprintf(b, "[%s] | %s", job, entry.Message)
|
||||
case entry.Data["dryrun"] == true:
|
||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, entry.Message)
|
||||
case attrIsTrue(r, rawOutputField):
|
||||
fmt.Fprintf(b, "[%s] | %s", job, r.Message)
|
||||
case attrIsTrue(r, "dryrun"):
|
||||
fmt.Fprintf(b, "*DRYRUN* [%s] %s%s", job, debugFlag, r.Message)
|
||||
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 {
|
||||
isColored := checkIfTerminal(entry.Logger.Out)
|
||||
func (f textFormatter) isColored() bool {
|
||||
isColored := checkIfTerminal(f.w)
|
||||
|
||||
if force, ok := os.LookupEnv("CLICOLOR_FORCE"); ok && force != "0" {
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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/require"
|
||||
)
|
||||
@@ -49,10 +55,7 @@ func TestValueMasker(t *testing.T) {
|
||||
ctx := WithMasks(t.Context(), &entry.masks)
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, entry.secrets))
|
||||
for line := range strings.SplitSeq(entry.lines, "\n") {
|
||||
lentry := masker(&logrus.Entry{
|
||||
Context: ctx,
|
||||
Message: line,
|
||||
})
|
||||
lentry := masker(ctx, logging.Record{Message: line})
|
||||
for _, line := range entry.disallowed {
|
||||
assert.NotContains(t, lentry.Message, line)
|
||||
}
|
||||
@@ -79,7 +82,7 @@ func TestValueMaskerEncodedSecrets(t *testing.T) {
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
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, "***")
|
||||
for _, disallowed := range tc.encoded {
|
||||
@@ -105,7 +108,7 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||
{"literal (JS JSON.stringify / .NET)", jsonStringEscapeNoHTML(secret)},
|
||||
} {
|
||||
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.NotContains(t, entry.Message, tc.form)
|
||||
@@ -118,7 +121,7 @@ func TestValueMaskerJSONEscapesBothWays(t *testing.T) {
|
||||
func TestValueMaskerHidesExtraMasks(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -137,7 +140,7 @@ func TestValueMaskerEncodedMasks(t *testing.T) {
|
||||
{"encoded: " + base64.StdEncoding.EncodeToString([]byte("s3cr3t")), "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)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +157,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
for _, prefix := range []string{"x-access-token:", "user:", "ab:"} {
|
||||
t.Run(prefix, func(t *testing.T) {
|
||||
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.NotEqual(t, "Authorization: Basic "+encoded, entry.Message)
|
||||
@@ -172,7 +175,7 @@ func TestValueMaskerBase64Alignments(t *testing.T) {
|
||||
func TestValueMaskerCachedReplacerSeesNewMasks(t *testing.T) {
|
||||
masker := valueMasker(false, AppendSecretMaskers(nil, map[string]string{"TOKEN": "secret-token"}))
|
||||
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"}
|
||||
@@ -206,15 +209,61 @@ func TestAppendSecretMaskerSkipsUselessEncodings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJobLogFormatterDecodesCommandData(t *testing.T) {
|
||||
logger := logrus.New()
|
||||
logger.Out = io.Discard
|
||||
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)
|
||||
return string(out)
|
||||
return string(line)
|
||||
}
|
||||
|
||||
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
|
||||
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"])
|
||||
}
|
||||
@@ -223,8 +223,6 @@ func TestActionScriptPaths(t *testing.T) {
|
||||
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) {
|
||||
copiedBundle := func(t *testing.T, noPatch bool) string {
|
||||
t.Helper()
|
||||
@@ -234,12 +232,13 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
||||
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
||||
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
||||
actionDir: t.TempDir(),
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{ActionCacheDir: t.TempDir(), NoActionPatch: noPatch},
|
||||
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.WriteFile(script, []byte(gateTSC), 0o600))
|
||||
|
||||
@@ -250,7 +249,7 @@ func TestPatchActionsAtTheContainerCopy(t *testing.T) {
|
||||
copied = string(body)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, gateTSC, string(source), "the shared copy is left as it was found")
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
@@ -27,7 +26,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
if !rc.Config.NoSkipCheckout {
|
||||
// resolve the local workflow against the workspace root, not the process
|
||||
// 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)
|
||||
@@ -39,12 +38,9 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||
|
||||
// If the repository is private, we need a token to clone it
|
||||
token := rc.Config.GetToken()
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,11 +56,9 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||
|
||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path, true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,28 +70,32 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
||||
//
|
||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||
}
|
||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
return git.NewGitCloneExecutor(rc.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||
URL: interpolatedURL,
|
||||
Ref: ref,
|
||||
Dir: targetDirectory,
|
||||
Token: token,
|
||||
OfflineMode: rc.Config.ActionOfflineMode,
|
||||
Depth: rc.Config.ActionCloneDepth,
|
||||
})(ctx)
|
||||
}, rc.Config.GetToken()))(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Serialize workflow reads with cache updates.
|
||||
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)
|
||||
}()
|
||||
if err != nil {
|
||||
@@ -141,11 +139,7 @@ type remoteReusableWorkflow struct {
|
||||
}
|
||||
|
||||
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.
|
||||
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)
|
||||
return fmt.Sprintf("%s/%s/%s", withHTTPS(r.URL), r.Owner, r.Repo)
|
||||
}
|
||||
|
||||
var absoluteReusableWorkflowURLRegex = regexp.MustCompile(`^(https?://.*)/([^/]+/[^/]+/\.[^/]+/workflows/[^@]+@.*)$`)
|
||||
@@ -199,7 +193,7 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
||||
unlock := lockJob(rc.Run.Job())
|
||||
rc.result(reusedWorkflowJobResult)
|
||||
unlock()
|
||||
common.Logger(ctx).WithField("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||
common.Logger(ctx).With("jobResult", reusedWorkflowJobResult).Infof("Job %s", reusedWorkflowJobResultMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,13 +202,20 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
||||
// otherwise returns an empty string
|
||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
||||
return ""
|
||||
// withInstanceAuth passes instance credentials only to clones from this Gitea instance, and runner.insecure only to its registered address.
|
||||
func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token string) git.NewGitCloneExecutorInput {
|
||||
u, err := url.Parse(input.URL)
|
||||
if err != nil || !onInstanceHost(u.Host, c.GitHubInstance, c.trustedActionInstance()) {
|
||||
return input
|
||||
}
|
||||
return conf.GetToken()
|
||||
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
||||
if onInstanceHost(u.Host, c.GitHubInstance) {
|
||||
input.InsecureSkipTLS, input.Transport = c.InsecureSkipTLS, c.InstanceTransport
|
||||
}
|
||||
if u.User == nil {
|
||||
input.Token = token
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
@@ -228,26 +229,13 @@ func (c Config) trustedActionInstance() string {
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
||||
// 2. the cloneURL does not have basic auth embedded
|
||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
||||
u2, err := url.Parse(cloneURL)
|
||||
if err != nil || u2.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
||||
// onInstanceHost reports whether host is that of one of the instance URLs, which may lack a scheme.
|
||||
func onInstanceHost(host string, instanceURLs ...string) bool {
|
||||
for _, candidate := range instanceURLs {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "http://") &&
|
||||
!strings.HasPrefix(candidate, "https://") {
|
||||
candidate = "https://" + candidate
|
||||
}
|
||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
||||
if u, err := url.Parse(withHTTPS(candidate)); err == nil && u.Host == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -59,7 +63,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
||||
}
|
||||
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))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v1"), string(got))
|
||||
@@ -69,7 +73,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
||||
gitMust(t, workDir, "commit", "-am", "v2")
|
||||
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))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||
@@ -79,14 +83,16 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
||||
workflowDir := t.TempDir()
|
||||
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()
|
||||
|
||||
rc := &RunContext{
|
||||
Config: &Config{},
|
||||
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)
|
||||
go func() { done <- exec(context.Background()) }()
|
||||
@@ -108,8 +114,9 @@ func TestNewReusableWorkflowExecutorHoldsCloneLock(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.Mkdir(workdir+".lock", 0o755))
|
||||
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"} {
|
||||
@@ -118,112 +125,87 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
|
||||
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
||||
}
|
||||
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
||||
require.Error(t, err)
|
||||
require.NotErrorIs(t, err, fs.ErrNotExist)
|
||||
require.ErrorContains(t, err, "workflow is not valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.example.net",
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
|
||||
var gotCert bool
|
||||
var gotToken string
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCert = len(r.TLS.PeerCertificates) > 0
|
||||
_, gotToken, _ = r.BasicAuth()
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
pair := server.TLS.Certificates[0]
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
||||
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||
rc := &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: server.URL,
|
||||
Secrets: map[string]string{"GITEA_TOKEN": "token"},
|
||||
InsecureSkipTLS: true,
|
||||
ClientCertFile: certFile,
|
||||
ClientKeyFile: keyFile,
|
||||
},
|
||||
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
require.Error(t, cloneRemoteReusableWorkflow(rc, server.URL+"/owner/repo", "main", t.TempDir())(t.Context()))
|
||||
require.True(t, gotCert)
|
||||
require.Equal(t, "token", gotToken)
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
||||
// actions against AppURL on a different hostname for the same instance.
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.local",
|
||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
||||
DefaultActionInstanceIsSelfHosted: true,
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceURL string
|
||||
trustedActionInstance string
|
||||
cloneURL string
|
||||
want bool
|
||||
func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, instanceURL, trustedActionInstance, cloneURL string
|
||||
token, cert, insecure bool
|
||||
}{
|
||||
{
|
||||
name: "same host with schemaless instance",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://gitea.example.net/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same host with schemaless instance and port",
|
||||
instanceURL: "gitea.example.net:3000",
|
||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different host",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://github.com/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "embedded basic auth",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://user:[email protected]/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid clone URL",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "://gitea.example.net/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
||||
// registered instance: the token must still be attached.
|
||||
name: "self-hosted action instance on different host",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// embedded basic auth must still be rejected even when the host matches
|
||||
// the trusted action instance.
|
||||
name: "self-hosted action instance with embedded basic auth",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://user:[email protected]/owner/action",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
||||
// empty in github mode, so an off-instance clone URL gets no token.
|
||||
name: "github mode does not trust mirror host",
|
||||
instanceURL: "gitea.local",
|
||||
cloneURL: "https://mirror.example.com/owner/action",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
{"same host with schemaless instance", "gitea.example.net", "", "https://gitea.example.net/actions/tools", true, true, true},
|
||||
{"same host with schemaless instance and port", "gitea.example.net:3000", "", "https://gitea.example.net:3000/actions/tools", true, true, true},
|
||||
{"different host", "gitea.example.net", "", "https://github.com/actions/tools", false, false, false},
|
||||
{"embedded basic auth keeps its own credentials", "gitea.example.net", "", "https://user:pass@gitea.example.net/actions/tools", false, true, true},
|
||||
{"invalid clone URL", "gitea.example.net", "", "://gitea.example.net/actions/tools", false, false, false},
|
||||
{"self-hosted action instance on different host skips no verification", "gitea.local", "https://gitea.my-nas.lan", "https://gitea.my-nas.lan/owner/action", true, true, false},
|
||||
{"self-hosted action instance with embedded basic auth", "gitea.local", "https://gitea.my-nas.lan", "https://user:[email protected]/owner/action", false, true, false},
|
||||
{"github mode does not trust mirror host", "gitea.local", "", "https://mirror.example.com/owner/action", false, false, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
||||
conf := Config{
|
||||
GitHubInstance: tt.instanceURL,
|
||||
DefaultActionInstance: tt.trustedActionInstance,
|
||||
DefaultActionInstanceIsSelfHosted: tt.trustedActionInstance != "",
|
||||
InsecureSkipTLS: true,
|
||||
ClientCertFile: "/cert",
|
||||
ClientKeyFile: "/key",
|
||||
InstanceTransport: &http.Transport{},
|
||||
}
|
||||
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
||||
if tt.insecure {
|
||||
want.Transport = conf.InstanceTransport
|
||||
}
|
||||
if tt.token {
|
||||
want.Token = "token"
|
||||
}
|
||||
if tt.cert {
|
||||
want.ClientCertFile, want.ClientKeyFile = conf.ClientCertFile, conf.ClientKeyFile
|
||||
}
|
||||
require.Equal(t, want, conf.withInstanceAuth(git.NewGitCloneExecutorInput{URL: tt.cloneURL}, "token"))
|
||||
})
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
conf := Config{GitHubInstance: "https://gitea.local", InstanceTransport: transport}
|
||||
require.Same(t, transport, conf.instanceTransportFor("https://gitea.local/api/actions_pipeline/"))
|
||||
require.Nil(t, conf.instanceTransportFor("https://gitea.local.example/api/actions_pipeline/"))
|
||||
require.Nil(t, conf.instanceTransportFor("https://other.example/x#/api/actions_pipeline/"))
|
||||
}
|
||||
|
||||
func gitMust(t *testing.T, dir string, args ...string) {
|
||||
|
||||
+67
-63
@@ -22,6 +22,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -83,8 +84,9 @@ type RunContext struct {
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
// have already been created on the JobContainer. The runner sets up file-command files
|
||||
@@ -511,7 +513,7 @@ func (rc *RunContext) startKubernetesPod() common.Executor {
|
||||
// printStartJobContainerGroup mirrors actions/runner's "Starting job container"
|
||||
// section: emit the group header and summary, return a closer for ::endgroup::.
|
||||
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("image: %s", image)
|
||||
rawLogger.Infof("name: %s", name)
|
||||
@@ -706,7 +708,7 @@ func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
||||
}
|
||||
|
||||
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 {
|
||||
rawLogger.Infof("%s", line)
|
||||
return true
|
||||
@@ -718,31 +720,55 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
logger := common.Logger(ctx)
|
||||
rc.printServiceLogs(ctx)
|
||||
errs := []error{rc.closeDockerProxy(ctx)}
|
||||
var containers []common.Executor
|
||||
if rc.JobContainer != nil {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
containers = append(containers, rc.JobContainer.Remove())
|
||||
}
|
||||
if len(rc.serviceContainers) > 0 {
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
errs = append(errs, rc.stopServiceContainers()(ctx))
|
||||
containers = append(containers, rc.stopServiceContainers())
|
||||
}
|
||||
if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
|
||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
||||
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) {
|
||||
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 {
|
||||
errs = append(errs, rc.cleanupJobVolumes(ctx))
|
||||
rest = append(rest, rc.cleanupJobVolumes)
|
||||
} else {
|
||||
rc.deferVolumeCleanup(rc.cleanupJobVolumes)
|
||||
}
|
||||
if createAndDeleteNetwork {
|
||||
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).
|
||||
IfBool(rc.Config.CacheContainer != "")(ctx))
|
||||
errs = append(errs, container.NewDockerNetworkRemoveExecutor(networkName)(ctx))
|
||||
rest = append(rest, func(ctx context.Context) error {
|
||||
return errors.Join(
|
||||
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 {
|
||||
runnerUUID := rc.Config.ContainerNetworkCreateOptions.RunnerUUID
|
||||
if runnerUUID == "" || common.Dryrun(ctx) {
|
||||
@@ -957,7 +983,7 @@ func (rc *RunContext) waitForServiceContainers() common.Executor {
|
||||
// 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.
|
||||
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
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
@@ -1015,7 +1041,7 @@ const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
|
||||
func (rc *RunContext) printServiceLogs(ctx context.Context) {
|
||||
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
|
||||
defer cancel()
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
for _, svc := range rc.serviceContainers {
|
||||
if !svc.created || svc.logsDumped {
|
||||
continue
|
||||
@@ -1236,10 +1262,6 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||
if rc.Run.Job().RunsOn() == nil && rc.containerSpec.Image == "" {
|
||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||
}
|
||||
|
||||
runsOn := rc.Run.Job().RunsOn()
|
||||
for i, v := range runsOn {
|
||||
var err error
|
||||
@@ -1342,7 +1364,7 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
|
||||
rc.caller.setReusedWorkflowJobResult(rc.Run.JobID, "skipped")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1545,14 +1567,8 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
||||
merged.RunnerPerflog, merged.RunnerTrackingID = ghc.RunnerPerflog, ghc.RunnerTrackingID
|
||||
*ghc = merged
|
||||
|
||||
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
|
||||
rc.setInstanceURLs(ghc)
|
||||
ghc.GraphQLURL = "" // Gitea doesn't support graphql
|
||||
return ghc
|
||||
}
|
||||
}
|
||||
@@ -1566,7 +1582,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
||||
|
||||
ghc.SetBaseAndHeadRef()
|
||||
repoPath := rc.Config.Workdir
|
||||
ghcontext.SetRepositoryAndOwner(ctx, ghc, rc.Config.GitHubInstance, repoPath)
|
||||
ghcontext.SetRepositoryAndOwner(ctx, ghc, repoPath)
|
||||
if ghc.Ref == "" {
|
||||
ghcontext.SetRef(ctx, ghc, repoPath)
|
||||
}
|
||||
@@ -1576,27 +1592,7 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
||||
|
||||
ghc.SetRefTypeAndName()
|
||||
|
||||
// defaults
|
||||
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
|
||||
}
|
||||
rc.setInstanceURLs(ghc)
|
||||
|
||||
// allow to be overridden by user
|
||||
if rc.Config.Env["GITHUB_SERVER_URL"] != "" {
|
||||
@@ -1612,6 +1608,20 @@ func (rc *RunContext) getGithubContext(ctx context.Context) *model.GithubContext
|
||||
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 {
|
||||
if step.Type() == model.StepTypeInvalid {
|
||||
// This will be errored out by the executor later, we need this here to avoid a null panic though
|
||||
@@ -1661,20 +1671,16 @@ func (rc *RunContext) withGithubEnv(ctx context.Context, github *model.GithubCon
|
||||
env["GITHUB_HEAD_REF"] = github.HeadRef
|
||||
env["GITHUB_SERVER_URL"] = github.ServerURL
|
||||
env["GITHUB_API_URL"] = github.APIURL
|
||||
env["GITHUB_GRAPHQL_URL"] = github.GraphQLURL
|
||||
|
||||
{ // Adapt to Gitea
|
||||
instance := rc.Config.GitHubInstance
|
||||
if !strings.HasPrefix(instance, "http://") &&
|
||||
!strings.HasPrefix(instance, "https://") {
|
||||
instance = "https://" + instance
|
||||
for _, name := range []string{"GITHUB_SERVER_URL", "GITHUB_API_URL"} {
|
||||
if env[name] == "" {
|
||||
delete(env, name)
|
||||
}
|
||||
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_ENVIRONMENT"] = "self-hosted"
|
||||
if workspace := parentDir(github.Workspace); workspace != "" {
|
||||
@@ -1708,9 +1714,7 @@ func parentDir(p string) string {
|
||||
// still names a release when the label is a rolling one such as ubuntu-latest.
|
||||
func (rc *RunContext) imageOS(ctx context.Context) string {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
// A composite action runs on a synthetic job, and resolving its image would only
|
||||
// log that runs-on is missing.
|
||||
return ""
|
||||
return "" // a composite action runs on a synthetic job without runs-on
|
||||
}
|
||||
if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
|
||||
return imageOS
|
||||
|
||||
+124
-83
@@ -20,19 +20,20 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"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/act/container"
|
||||
|
||||
"gitea.dev/actionslib/pkg/exprparser"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"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"
|
||||
"github.com/stretchr/testify/mock"
|
||||
require "github.com/stretchr/testify/require"
|
||||
@@ -83,7 +84,7 @@ func TestRunContext_EvalBool(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
|
||||
tables := []struct {
|
||||
in string
|
||||
@@ -176,7 +177,7 @@ func TestRunContext_EvalBool(t *testing.T) {
|
||||
for _, table := range tables {
|
||||
t.Run(table.in, func(t *testing.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 {
|
||||
assertObject.Error(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
}
|
||||
@@ -563,8 +564,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
}
|
||||
rc.Run.JobID = "job1"
|
||||
rc.Run.Workflow.Jobs = map[string]*model.Job{"job1": job}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
require.NoError(t, rc.resolvePlatformImage(context.Background()))
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
@@ -775,22 +776,35 @@ func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
func TestCleanupJobResources(t *testing.T) {
|
||||
t.Setenv("TMPDIR", "/tmp")
|
||||
proxyDir := t.TempDir()
|
||||
for _, name := range []string{"synchronous", "proxy", "closed 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) {
|
||||
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
||||
removesLabelled := (proxy || preclean) && name != "kept proxy"
|
||||
if proxy && runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||
}
|
||||
jobError, removeError, closeError := errors.New("remove job"), errors.New("remove service"), errors.New("close service")
|
||||
job, service := &containerMock{}, &containerMock{}
|
||||
job.On("Remove").Return(func(context.Context) error { return jobError }).Once()
|
||||
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
|
||||
removeAlongside := func(started, other chan struct{}, failure error) func(context.Context) error {
|
||||
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()
|
||||
rc := &RunContext{
|
||||
Config: &Config{CacheContainer: "cache-container"},
|
||||
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: job,
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
@@ -824,7 +838,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
if name == "canceled" {
|
||||
if name == "canceled proxy" {
|
||||
cancel()
|
||||
}
|
||||
err := rc.cleanupJobResources("job-network", true, preclean)(ctx)
|
||||
@@ -838,16 +852,12 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
for _, failure := range []error{jobError, removeError, closeError} {
|
||||
require.ErrorIs(t, err, failure)
|
||||
}
|
||||
if name == "canceled" {
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
} 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)
|
||||
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, proxy || preclean, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||
if proxy || preclean {
|
||||
assert.Equal(t, 2, volumeRemovals)
|
||||
assert.Equal(t, removesLabelled, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||
if removesLabelled {
|
||||
require.ErrorContains(t, err, "labelled GET /networks")
|
||||
require.ErrorContains(t, err, "labelled GET /volumes")
|
||||
}
|
||||
@@ -863,7 +873,7 @@ func TestInterpolateOutputsIsPerMatrixComboKeepingNeedsOutputs(t *testing.T) {
|
||||
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}}}
|
||||
r := &runnerImpl{config: &Config{}}
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := r.newRunContext(ctx, &model.Run{JobID: "needed", Workflow: run.Workflow}, nil)
|
||||
require.NoError(t, err)
|
||||
@@ -898,7 +908,7 @@ func TestInterpolateOutputsFromExpression(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetGitHubContext(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -914,6 +924,7 @@ func TestGetGitHubContext(t *testing.T) {
|
||||
Run: &model.Run{
|
||||
Workflow: &model.Workflow{
|
||||
Name: "GitHubContextTest",
|
||||
Jobs: map[string]*model.Job{"job1": {}},
|
||||
},
|
||||
},
|
||||
Name: "GitHubContextTest",
|
||||
@@ -925,9 +936,9 @@ func TestGetGitHubContext(t *testing.T) {
|
||||
}
|
||||
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"
|
||||
if a := os.Getenv("ACT_ACTOR"); a != "" {
|
||||
@@ -955,10 +966,40 @@ func TestGetGitHubContext(t *testing.T) {
|
||||
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"}
|
||||
ghc = rc.getGithubContext(context.Background())
|
||||
ghc = rc.getGithubContext(t.Context())
|
||||
assert.Equal(t, "preset-actor", ghc.Actor)
|
||||
assert.Equal(t, "triggerer", ghc.TriggeringActor)
|
||||
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) {
|
||||
@@ -995,7 +1036,7 @@ func TestGetGithubContextRef(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
ghc := rc.getGithubContext(context.Background())
|
||||
ghc := rc.getGithubContext(t.Context())
|
||||
|
||||
assert.Equal(t, data.ref, ghc.Ref)
|
||||
})
|
||||
@@ -1028,54 +1069,54 @@ func createJob(t *testing.T, input, result string) *model.Job {
|
||||
}
|
||||
|
||||
func TestRunContextRunsOnPlatformNames(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"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{
|
||||
"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{
|
||||
"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{
|
||||
"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{
|
||||
"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
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"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{
|
||||
"job1": createJob(t, `runs-on:
|
||||
mapping: value`, ""),
|
||||
})
|
||||
assertObject.Equal([]string{}, rc.runsOnPlatformNames(context.Background()))
|
||||
assertObject.Equal([]string{}, rc.runsOnPlatformNames(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"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) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
// success()
|
||||
@@ -1085,8 +1126,8 @@ env:
|
||||
BROKEN: ${{ fromJSON('invalid') }}
|
||||
if: success()`, ""),
|
||||
})
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
require.Error(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1095,7 +1136,7 @@ needs: [job1]
|
||||
if: success()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.False(rc.isEnabled(context.Background()))
|
||||
assertObject.False(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||
@@ -1104,7 +1145,7 @@ needs: [job1]
|
||||
if: success()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1112,14 +1153,14 @@ if: success()`, ""),
|
||||
if: success()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
// failure()
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||
if: failure()`, ""),
|
||||
})
|
||||
assertObject.False(rc.isEnabled(context.Background()))
|
||||
assertObject.False(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1128,7 +1169,7 @@ needs: [job1]
|
||||
if: failure()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||
@@ -1137,7 +1178,7 @@ needs: [job1]
|
||||
if: failure()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.False(rc.isEnabled(context.Background()))
|
||||
assertObject.False(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1145,14 +1186,14 @@ if: failure()`, ""),
|
||||
if: failure()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.False(rc.isEnabled(context.Background()))
|
||||
assertObject.False(rc.isEnabled(t.Context()))
|
||||
|
||||
// always()
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||
if: always()`, ""),
|
||||
})
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1161,7 +1202,7 @@ needs: [job1]
|
||||
if: always()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||
@@ -1170,7 +1211,7 @@ needs: [job1]
|
||||
if: always()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "success"),
|
||||
@@ -1178,23 +1219,23 @@ if: always()`, ""),
|
||||
if: always()`, ""),
|
||||
})
|
||||
rc.Run.JobID = "job2"
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
assertObject.True(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"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{
|
||||
"job1": createJob(t, `uses: ./.github/workflows/reusable.yml
|
||||
if: false`, ""),
|
||||
})
|
||||
assertObject.False(rc.isEnabled(context.Background()))
|
||||
assertObject.False(rc.isEnabled(t.Context()))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `if: ${{ fromJSON('not-json') }}`, ""),
|
||||
})
|
||||
enabled, err := rc.isEnabled(context.Background())
|
||||
enabled, err := rc.isEnabled(t.Context())
|
||||
require.NoError(t, err)
|
||||
assertObject.False(enabled)
|
||||
}
|
||||
@@ -1274,12 +1315,9 @@ func TestCreateContainerNameBoundedForLongMatrixInput(t *testing.T) {
|
||||
|
||||
func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := log.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(log.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
entry := logger.With("job", "j1")
|
||||
ctx := common.WithLogger(t.Context(), entry)
|
||||
|
||||
printStartJobContainerGroup(ctx, "node:20", "GITEA-WORKFLOW-build-JOB-test", "gitea-runner-network")()
|
||||
|
||||
@@ -1331,8 +1369,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
} {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
var operations []string
|
||||
var operationsMu sync.Mutex
|
||||
record := func(operation string, failure error) func(context.Context) error {
|
||||
return func(context.Context) error {
|
||||
operationsMu.Lock()
|
||||
defer operationsMu.Unlock()
|
||||
operations = append(operations, operation)
|
||||
return failure
|
||||
}
|
||||
@@ -1370,11 +1411,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
Run: &model.Run{JobID: "job", Workflow: workflow},
|
||||
platformImage: "node:20",
|
||||
}
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
logger, hook := logtest.NewLogger()
|
||||
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(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 {
|
||||
case testcase.pullError != nil:
|
||||
require.ErrorIs(t, err, testcase.pullError)
|
||||
@@ -1389,9 +1430,11 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
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.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")
|
||||
}))
|
||||
})
|
||||
@@ -1414,7 +1457,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthNone}, nil).Once()
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -1431,9 +1474,8 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return(&container.Info{ID: "id", State: "running", Health: container.HealthHealthy}, nil).Once()
|
||||
|
||||
var output bytes.Buffer
|
||||
logger := log.New()
|
||||
logger.SetOutput(&output)
|
||||
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger.WithFields(nil))))
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(&output, cyan), logging.LevelInfo))
|
||||
require.NoError(t, newRunContext(0, &serviceContainer{name: "postgres", container: service}).waitForServiceContainers()(common.WithLogger(t.Context(), logger)))
|
||||
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.Contains(t, output.String(), "postgres service is healthy.")
|
||||
@@ -1450,7 +1492,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
service.On("DumpLogs", mock.Anything).Return(nil).Once()
|
||||
|
||||
rc := newRunContext(0, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
err := rc.waitForServiceContainers()(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "the service 'postgres' is unhealthy: connection refused")
|
||||
service.AssertExpectations(t)
|
||||
@@ -1462,7 +1504,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return(&container.Info{State: "exited", ExitCode: 2, Health: container.HealthNone}, nil).Once()
|
||||
|
||||
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) {
|
||||
@@ -1471,7 +1513,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return((*container.Info)(nil), container.ErrContainerNotFound).Once()
|
||||
|
||||
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) {
|
||||
@@ -1505,7 +1547,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return(&container.Info{State: "running", Health: container.HealthStarting}, nil)
|
||||
|
||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
err := rc.waitForServiceContainers()(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||
})
|
||||
@@ -1517,7 +1559,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
Return((*container.Info)(nil), errors.New("inspect aborted"))
|
||||
|
||||
rc := newRunContext(20*time.Millisecond, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
err := rc.waitForServiceContainers()(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "did not become healthy within")
|
||||
})
|
||||
@@ -1530,7 +1572,7 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
|
||||
svc := &serviceContainer{name: "postgres", container: service}
|
||||
rc := newRunContext(-1, svc)
|
||||
require.NoError(t, rc.waitForServiceContainers()(context.Background()))
|
||||
require.NoError(t, rc.waitForServiceContainers()(t.Context()))
|
||||
service.AssertExpectations(t)
|
||||
assert.Equal(t, "id", svc.info.ID)
|
||||
})
|
||||
@@ -1540,13 +1582,13 @@ func TestWaitForServiceContainers(t *testing.T) {
|
||||
service.On("Inspect", mock.Anything).Return((*container.Info)(nil), errors.New("daemon is gone")).Once()
|
||||
|
||||
rc := newRunContext(-1, &serviceContainer{name: "postgres", container: service})
|
||||
err := rc.waitForServiceContainers()(context.Background())
|
||||
err := rc.waitForServiceContainers()(t.Context())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to inspect service 'postgres'")
|
||||
})
|
||||
|
||||
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()))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1562,7 +1604,7 @@ func TestReportUnstartedServices(t *testing.T) {
|
||||
{name: "redis", container: running},
|
||||
}}
|
||||
|
||||
require.NoError(t, rc.reportUnstartedServices()(context.Background()))
|
||||
require.NoError(t, rc.reportUnstartedServices()(t.Context()))
|
||||
dead.AssertExpectations(t)
|
||||
running.AssertExpectations(t)
|
||||
}
|
||||
@@ -1602,14 +1644,14 @@ func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
|
||||
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, "/var/lib/docker/volumes/job/_data/owner/repo", rc.Env["GITEA_DOCKER_WORKSPACE"])
|
||||
|
||||
rc.Config.Workdir = "/elsewhere"
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1686,7 +1728,7 @@ func TestRunContextContainerImageCannotSelectBackend(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) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
@@ -1713,7 +1755,7 @@ func TestRunContextImageOS(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) {
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
@@ -1765,8 +1807,7 @@ func TestRunContextUpdateExtraPath(t *testing.T) {
|
||||
{name: "over 16 MiB", content: []byte(strings.Repeat("x", 16*1024*1024+1)), wantErr: true},
|
||||
} {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
logger := log.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
logger := logging.New(logging.DiscardHandler)
|
||||
ctx := common.WithLogger(t.Context(), logger)
|
||||
jobContainer := &containerMock{}
|
||||
jobContainer.On("GetContainerArchive", mock.Anything, "/github/path").
|
||||
@@ -1794,7 +1835,7 @@ func TestParentDir(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunContextWithGithubEnvRunnerValues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
rc := createRunsOnRunContext(t, "ubuntu-latest")
|
||||
rc.Config.RunnerName = "runner-1"
|
||||
rc.Config.Secrets = map[string]string{"ACTIONS_STEP_DEBUG": "true"}
|
||||
|
||||
+59
-50
@@ -5,20 +5,23 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/ghcontext"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
docker_container "github.com/moby/moby/api/types/container"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -47,7 +50,7 @@ type Config struct {
|
||||
ContainerDaemonSocket string // Path to Docker daemon socket
|
||||
ContainerOptions string // Options for the job container
|
||||
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
|
||||
ContainerCapDrop []string // list of kernel capabilities to remove from the containers
|
||||
ArtifactServerPath string // the path where the artifact server stores uploads
|
||||
@@ -76,16 +79,19 @@ type Config struct {
|
||||
DefaultActionInstanceIsSelfHosted bool
|
||||
PlatformPicker func(labels []string) string
|
||||
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||
JobLoggerLevel *log.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
|
||||
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
|
||||
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
|
||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||
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
|
||||
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
|
||||
InstanceTransport http.RoundTripper
|
||||
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
|
||||
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
|
||||
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)
|
||||
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)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// RunnerDebug reports whether debug logging is on, exposed as `runner.debug` and
|
||||
@@ -106,13 +112,7 @@ func (c Config) GetToken() string {
|
||||
|
||||
// DefaultActionURL returns the host used for implicit remote actions.
|
||||
func (c Config) DefaultActionURL() string {
|
||||
if c.DefaultActionInstance != "" {
|
||||
return c.DefaultActionInstance
|
||||
}
|
||||
if c.GitHubInstance != "" {
|
||||
return c.GitHubInstance
|
||||
}
|
||||
return "github.com"
|
||||
return cmp.Or(c.DefaultActionInstance, c.GitHubInstance, "github.com")
|
||||
}
|
||||
|
||||
type caller struct {
|
||||
@@ -149,7 +149,7 @@ func (runner *runnerImpl) configure() (*runnerImpl, error) {
|
||||
if runner.config.EventJSON != "" {
|
||||
runner.eventJSON = runner.config.EventJSON
|
||||
} 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -163,7 +163,7 @@ func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||
maxParallel := 4 // actionslib has no default for an undeclared max-parallel
|
||||
if strategy != 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 {
|
||||
maxParallel = limit
|
||||
}
|
||||
@@ -173,39 +173,48 @@ func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||
|
||||
// NewPlanExecutor ...
|
||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
for _, stage := range plan.Stages {
|
||||
for _, run := range stage.Runs {
|
||||
job := run.Job()
|
||||
if runsOn := job.RunsOn(); job.Uses == "" && len(job.Steps) > 0 && (len(runsOn) == 0 || slices.Contains(runsOn, "")) { // Gitea's needs placeholders carry no steps
|
||||
return common.NewErrorExecutor(fmt.Errorf("job %q requires a non-empty 'runs-on'", run.JobID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxJobNameLen := 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 {
|
||||
stage := plan.Stages[i]
|
||||
stagePipeline = append(stagePipeline, func(ctx context.Context) error {
|
||||
pipeline := make([]common.Executor, 0)
|
||||
for _, run := range stage.Runs {
|
||||
log.Debugf("Stages Runs: %v", stage.Runs)
|
||||
logging.Debugf("Stages Runs: %v", stage.Runs)
|
||||
stageExecutor := make([]common.Executor, 0)
|
||||
job := run.Job()
|
||||
log.Debugf("Job.Name: %v", job.Name)
|
||||
log.Debugf("Job.RawNeeds: %v", job.RawNeeds)
|
||||
log.Debugf("Job.RawRunsOn: %v", job.RawRunsOn)
|
||||
log.Debugf("Job.Env: %v", job.Env)
|
||||
log.Debugf("Job.If: %v", job.If)
|
||||
logging.Debugf("Job.Name: %v", job.Name)
|
||||
logging.Debugf("Job.RawNeeds: %v", job.RawNeeds)
|
||||
logging.Debugf("Job.RawRunsOn: %v", job.RawRunsOn)
|
||||
logging.Debugf("Job.Env: %v", job.Env)
|
||||
logging.Debugf("Job.If: %v", job.If)
|
||||
for step := range job.Steps {
|
||||
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)
|
||||
log.Debugf("Job.Services: %v", job.Services)
|
||||
log.Debugf("Job.Strategy: %v", job.Strategy)
|
||||
log.Debugf("Job.RawContainer: %v", job.RawContainer)
|
||||
log.Debugf("Job.Defaults.Run.Shell: %v", job.Defaults.Run.Shell)
|
||||
log.Debugf("Job.Defaults.Run.WorkingDirectory: %v", job.Defaults.Run.WorkingDirectory)
|
||||
log.Debugf("Job.Outputs: %v", job.Outputs)
|
||||
log.Debugf("Job.Uses: %v", job.Uses)
|
||||
log.Debugf("Job.With: %v", job.With)
|
||||
log.Debugf("Job.Result: %v", job.Result)
|
||||
logging.Debugf("Job.TimeoutMinutes: %v", job.TimeoutMinutes)
|
||||
logging.Debugf("Job.Services: %v", job.Services)
|
||||
logging.Debugf("Job.Strategy: %v", job.Strategy)
|
||||
logging.Debugf("Job.RawContainer: %v", job.RawContainer)
|
||||
logging.Debugf("Job.Defaults.Run.Shell: %v", job.Defaults.Run.Shell)
|
||||
logging.Debugf("Job.Defaults.Run.WorkingDirectory: %v", job.Defaults.Run.WorkingDirectory)
|
||||
logging.Debugf("Job.Outputs: %v", job.Outputs)
|
||||
logging.Debugf("Job.Uses: %v", job.Uses)
|
||||
logging.Debugf("Job.With: %v", job.With)
|
||||
logging.Debugf("Job.Result: %v", job.Result)
|
||||
|
||||
if job.Strategy != nil || job.RawStrategy.Kind == yaml.ScalarNode {
|
||||
strategyRc, err := runner.newRunContext(ctx, run, nil)
|
||||
@@ -217,13 +226,13 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||
log.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||
log.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
log.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
logging.Debugf("Job.Strategy.FailFast: %v", job.Strategy.GetFailFast())
|
||||
logging.Debugf("Job.Strategy.FailFastString: %v", job.Strategy.FailFastString)
|
||||
logging.Debugf("Job.Strategy.MaxParallelString: %v", job.Strategy.MaxParallelString)
|
||||
logging.Debugf("Job.Strategy.RawMatrix: %v", job.Strategy.RawMatrix)
|
||||
// An unevaluated expression is left in place, which GetMatrixes below rejects.
|
||||
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} {
|
||||
if evaluated, err := strategyRc.ExprEval.Interpolate(ctx, *value); err == nil {
|
||||
@@ -237,11 +246,11 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
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 {
|
||||
index, total := i, len(matrixes)
|
||||
@@ -300,7 +309,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
if len(pipeline) == 1 {
|
||||
// Single run/job: execute directly without additional parallelization wrapper
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -310,9 +319,9 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
// If MaxParallel is set in config, use it
|
||||
if runner.config.MaxParallel > 0 {
|
||||
parallelism = runner.config.MaxParallel
|
||||
log.Debugf("Using configured max-parallel: %d", parallelism)
|
||||
logging.Debugf("Using configured max-parallel: %d", parallelism)
|
||||
} 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
|
||||
@@ -320,7 +329,7 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
+44
-28
@@ -17,17 +17,18 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
baseImage = "node:24-bookworm-slim"
|
||||
platforms map[string]string
|
||||
logLevel = log.DebugLevel
|
||||
logLevel = logging.LevelDebug
|
||||
workdir = "testdata"
|
||||
secrets map[string]string
|
||||
testRunID = strings.ToLower(rand.Text()[:8]) // keeps docker names apart from concurrent test processes on one daemon
|
||||
@@ -54,7 +55,7 @@ func init() {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -70,10 +71,11 @@ func TestNoWorkflowsFoundByPlanner(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("res", true)
|
||||
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
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetOutput(&buf)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
plan, err := planner.PlanEvent("pull_request")
|
||||
assert.NotNil(t, plan)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -83,25 +85,24 @@ func TestNoWorkflowsFoundByPlanner(t *testing.T) {
|
||||
assert.NotNil(t, plan)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Contains(t, buf.String(), "no workflows found by planner")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphMissingEvent(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/no-event.yml", true)
|
||||
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
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetOutput(&buf)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.NotNil(t, plan)
|
||||
assert.Empty(t, plan.Stages)
|
||||
|
||||
assert.Contains(t, buf.String(), "no events found for workflow: no-event.yml")
|
||||
log.SetOutput(out)
|
||||
assert.Contains(t, buf.String(), `msg="no events found for workflow" file=no-event.yml`)
|
||||
}
|
||||
|
||||
func TestGraphMissingFirst(t *testing.T) {
|
||||
@@ -118,29 +119,30 @@ func TestGraphWithMissing(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/missing.yml", true)
|
||||
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
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetOutput(&buf)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
plan, err := planner.PlanEvent("push")
|
||||
assert.NotNil(t, plan)
|
||||
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.Contains(t, buf.String(), "unable to build dependency graph for missing (missing.yml)")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestGraphWithSomeMissing(t *testing.T) {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
planner, err := model.NewWorkflowPlanner("testdata/issue-1595/", true)
|
||||
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
|
||||
log.SetOutput(&buf)
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetOutput(&buf)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
|
||||
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
|
||||
@@ -148,7 +150,21 @@ func TestGraphWithSomeMissing(t *testing.T) {
|
||||
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 no first (no-first.yml)")
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
|
||||
for _, runsOn := range []string{"", "if: false", "runs-on: [ubuntu-latest, '']"} {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n first:\n runs-on: ubuntu-latest\n steps: [run: echo]\n test:\n needs: first\n " + runsOn + "\n steps: [run: echo]\n"))
|
||||
require.NoError(t, err)
|
||||
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob("test")
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(plan)(t.Context()), `job "test" requires a non-empty 'runs-on'`, runsOn)
|
||||
assert.Empty(t, workflow.Jobs["first"].Result, runsOn)
|
||||
}
|
||||
|
||||
workflow := &model.Workflow{Jobs: map[string]*model.Job{"build": {Result: "success"}}}
|
||||
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "build"}}}}})(t.Context()))
|
||||
assert.Equal(t, "success", workflow.Jobs["build"].Result)
|
||||
}
|
||||
|
||||
func TestGraphEvent(t *testing.T) {
|
||||
@@ -205,7 +221,7 @@ type TestJobFileInfo struct {
|
||||
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
|
||||
|
||||
log.SetLevel(logLevel)
|
||||
logging.SetLevel(logLevel)
|
||||
|
||||
workdir, err := filepath.Abs(j.workdir)
|
||||
assert.NoError(t, err, workdir) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -265,7 +281,7 @@ func TestRunEvent(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
tables := []TestJobFileInfo{
|
||||
// Shells
|
||||
@@ -368,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.
|
||||
requireHostTools(t, "bash", "node")
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
tables := []TestJobFileInfo{}
|
||||
|
||||
@@ -466,7 +482,7 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
func TestDryrunEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 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{
|
||||
// Shells
|
||||
@@ -497,7 +513,7 @@ func TestReusableWorkflowCaller(t *testing.T) {
|
||||
requireDocker(t)
|
||||
t.Parallel()
|
||||
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) {
|
||||
@@ -518,7 +534,7 @@ func TestRunEventSecrets(t *testing.T) {
|
||||
secrets, _ := godotenv.Read(filepath.Join(workdir, workflowPath, ".secrets"))
|
||||
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) {
|
||||
@@ -535,5 +551,5 @@ func TestRunEventPullRequest(t *testing.T) {
|
||||
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")})
|
||||
}
|
||||
+22
-15
@@ -47,11 +47,11 @@ const maxSymlinkDepth = 10
|
||||
func (s stepStage) String() string {
|
||||
switch s {
|
||||
case stepStagePre:
|
||||
return "Pre"
|
||||
return StagePre
|
||||
case stepStageMain:
|
||||
return "Main"
|
||||
return StageMain
|
||||
case stepStagePost:
|
||||
return "Post"
|
||||
return StagePost
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
@@ -97,14 +97,14 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
if err != nil {
|
||||
stepResult.Conclusion = 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
|
||||
}
|
||||
|
||||
if !runStep {
|
||||
stepResult.Conclusion = 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
|
||||
}
|
||||
|
||||
@@ -112,12 +112,13 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
if strings.Contains(stepString, "::add-mask::") {
|
||||
stepString = "add-mask command"
|
||||
}
|
||||
startLogger := logger.With(StepStartedField, true)
|
||||
if stage == stepStageMain {
|
||||
// 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.
|
||||
logger.Debugf("Run %s %s", stage, stepString)
|
||||
startLogger.Debugf("Run %s %s", stage, stepString)
|
||||
} else {
|
||||
logger.Infof("Run %s %s", stage, stepString)
|
||||
startLogger.Infof("Run %s %s", stage, stepString)
|
||||
}
|
||||
|
||||
// Prepare and clean Runner File Commands
|
||||
@@ -186,29 +187,35 @@ func runStepExecutor(step step, stage stepStage, executor common.Executor) commo
|
||||
if err == nil {
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
_ = rc.JobContainer.Copy(actPath, files...)(ctx)
|
||||
_ = rc.JobContainer.Copy(actPath, files...)(fileCommandCtx)
|
||||
|
||||
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 {
|
||||
stepResult.Outcome = model.StepStatusFailure
|
||||
|
||||
continueOnError, parseErr := isContinueOnError(ctx, stepModel.RawContinueOnError, step, stage)
|
||||
if parseErr != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -34,10 +34,11 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
}
|
||||
|
||||
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::")
|
||||
|
||||
rc := sab.RunContext
|
||||
rc.ApplyExtraPath(ctx, &sab.env)
|
||||
return sab.run(ctx, &action.Context{
|
||||
Container: rc.JobContainer,
|
||||
Github: rc.getGithubContext(ctx),
|
||||
@@ -48,6 +49,9 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
||||
UseGitIgnore: rc.Config.UseGitIgnore,
|
||||
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
||||
ClientCertFile: rc.Config.ClientCertFile,
|
||||
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||
Env: sab.env,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,11 +43,11 @@ func (sal *stepActionLocal) main() common.Executor {
|
||||
}
|
||||
|
||||
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::")
|
||||
|
||||
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) {
|
||||
spath := path.Join(containerActionPath, filename)
|
||||
@@ -127,8 +127,7 @@ func (sal *stepActionLocal) getActionModel() *model.Action {
|
||||
|
||||
func (sal *stepActionLocal) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||
if sal.compositeRunContext == nil {
|
||||
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
|
||||
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
|
||||
_, containerActionDir := getContainerActionPaths(sal)
|
||||
|
||||
compositeRunContext, err := newCompositeRunContext(ctx, sal.RunContext, sal, containerActionDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,7 +35,7 @@ func (salm *stepActionLocalMocks) readAction(_ context.Context, step *model.Step
|
||||
}
|
||||
|
||||
func TestStepActionLocalTest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
cm := &containerMock{}
|
||||
salm := &stepActionLocalMocks{}
|
||||
@@ -225,7 +225,7 @@ func TestStepActionLocalPost(t *testing.T) {
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
cm := &containerMock{}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
type stepActionRemote struct {
|
||||
@@ -31,13 +30,16 @@ type stepActionRemote struct {
|
||||
action *model.Action
|
||||
env map[string]string
|
||||
remoteAction *remoteAction
|
||||
resolvedSha string
|
||||
actionDir string // the repository at the resolved commit, shared by the job's actions in it
|
||||
resolvedSha string // set on the step that made the job's download of the repository
|
||||
}
|
||||
|
||||
var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor
|
||||
type actionDownload struct {
|
||||
dir string
|
||||
err error
|
||||
}
|
||||
|
||||
// selfRepoPrefix introduces a self-repository reference: the action lives in the repo holding the file that wrote the `uses:`.
|
||||
const selfRepoPrefix = "$/"
|
||||
var stepActionRemoteFetch = git.FetchAction
|
||||
|
||||
func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
@@ -65,75 +67,57 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
|
||||
return nil
|
||||
}
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
// empty token and clone the action anonymously (401 against the authenticated
|
||||
// instance). github.Token survives the composite config copy and matches the
|
||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||
token := ""
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||
token = github.Token
|
||||
}
|
||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
Ref: sar.remoteAction.Ref,
|
||||
Dir: actionDir,
|
||||
Token: token,
|
||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||
// printPrepareActions reports the download with its resolved commit.
|
||||
Quiet: true,
|
||||
|
||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||
})
|
||||
var ntErr common.Executor
|
||||
if err := gitClone(ctx); err != nil {
|
||||
var refErr *git.Error
|
||||
switch {
|
||||
case errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef):
|
||||
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||
case errors.Is(err, gogit.ErrForceNeeded): // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||
default:
|
||||
return err
|
||||
cloneURL := sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionURL())
|
||||
job := sar.RunContext.topLevelRunContext()
|
||||
key := cloneURL + "@" + sar.remoteAction.Ref
|
||||
download, ok := job.actionDownloads[key]
|
||||
if !ok {
|
||||
// For Gitea
|
||||
// github.Token, unlike Config.GetToken, survives the composite config copy, which nils Config.Secrets.
|
||||
download.dir, sar.resolvedSha, download.err = stepActionRemoteFetch(ctx, sar.RunContext.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
Ref: sar.remoteAction.Ref,
|
||||
Dir: filepath.Join(sar.RunContext.ActionCacheDir(), model.UsesHash(key)),
|
||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||
}, github.Token))
|
||||
if job.actionDownloads == nil {
|
||||
job.actionDownloads = map[string]actionDownload{}
|
||||
}
|
||||
job.actionDownloads[key] = download
|
||||
}
|
||||
var refErr *git.Error
|
||||
switch {
|
||||
case errors.As(download.err, &refErr) && errors.Is(download.err, git.ErrShortRef):
|
||||
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||
case download.err != nil:
|
||||
return download.err
|
||||
}
|
||||
sar.actionDir = download.dir
|
||||
|
||||
// Best effort: the download report falls back to the ref alone when the commit is unknown.
|
||||
if _, sha, err := git.FindGitRevision(ctx, actionDir); err != nil {
|
||||
common.Logger(ctx).Debugf("unable to resolve the commit of %s: %v", sar.remoteAction.Reference(), err)
|
||||
} else {
|
||||
sar.resolvedSha = sha
|
||||
unlock, err := git.AcquireCloneLock(ctx, sar.actionDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
remoteReader := func(filename string) (io.Reader, io.Closer, error) {
|
||||
f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
|
||||
f, err := os.Open(filepath.Join(sar.actionDir, sar.remoteAction.Path, filename))
|
||||
return f, f, err
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
ntErr,
|
||||
func(ctx context.Context) error {
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
)(ctx)
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, sar.actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// actionDownloadInfo reports the action this step downloaded and the commit it resolved to. ok is
|
||||
// false when nothing was fetched, as for the local checkout of the workflow's own repository.
|
||||
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string, ok bool) {
|
||||
if sar.remoteAction == nil || sar.action == nil {
|
||||
return "", "", false
|
||||
func (sar *stepActionRemote) actionDownloadInfo() (reference, sha string) { // once per job, as actions/runner reports
|
||||
if sar.resolvedSha == "" {
|
||||
return "", ""
|
||||
}
|
||||
return sar.remoteAction.Reference(), sar.resolvedSha, true
|
||||
return fmt.Sprintf("%s/%s@%s", sar.remoteAction.Org, sar.remoteAction.Repo, sar.remoteAction.Ref), sar.resolvedSha
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) pre() common.Executor {
|
||||
@@ -149,7 +133,7 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
|
||||
printRunActionHeader(ctx, sar.Step, sar.env, sar.RunContext)
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
github := sar.getGithubContext(ctx)
|
||||
@@ -162,7 +146,7 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore, false)(ctx)
|
||||
}
|
||||
|
||||
return sar.runAction(sar, sar.actionDir(), sar.remoteAction)(ctx)
|
||||
return sar.runAction(sar, sar.actionDir, sar.remoteAction)(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -171,13 +155,8 @@ func (sar *stepActionRemote) post() common.Executor {
|
||||
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) actionDir() string {
|
||||
uses := sar.Step.Uses
|
||||
if strings.HasPrefix(uses, selfRepoPrefix) {
|
||||
// The same `$/x` names a different action per enclosing repo, so key the cache on what it resolved to.
|
||||
uses = sar.remoteAction.URL + "/" + sar.remoteAction.Reference()
|
||||
}
|
||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), model.UsesHash(uses))
|
||||
func (sar *stepActionRemote) containerKey() string {
|
||||
return model.UsesHash(sar.remoteAction.URL + "/" + sar.remoteAction.Reference())
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||
@@ -228,9 +207,7 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) (*RunContext, error) {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
_, containerActionDir := getContainerActionPaths(sar)
|
||||
|
||||
compositeRunContext, err := newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir)
|
||||
if err != nil {
|
||||
@@ -259,22 +236,6 @@ func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||
return sar.compositeSteps
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// cloneSkipTLS returns true if the runner can clone an action from the Gitea instance
|
||||
func (sar *stepActionRemote) cloneSkipTLS() bool {
|
||||
if !sar.RunContext.Config.InsecureSkipTLS {
|
||||
// Return false if the Gitea instance is not an insecure instance
|
||||
return false
|
||||
}
|
||||
if sar.remoteAction.URL == "" {
|
||||
// Empty URL means the default action instance should be used
|
||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
|
||||
type remoteAction struct {
|
||||
URL string
|
||||
Org string
|
||||
@@ -287,8 +248,8 @@ func (ra *remoteAction) CloneURL(u string) string {
|
||||
if ra.URL == "" {
|
||||
// keep an absolute local path as-is (used by tests to resolve actions from a local
|
||||
// repo); only bare host names get the https:// scheme prepended
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !filepath.IsAbs(u) {
|
||||
u = "https://" + u
|
||||
if !filepath.IsAbs(u) {
|
||||
u = withHTTPS(u)
|
||||
}
|
||||
} else {
|
||||
u = ra.URL
|
||||
@@ -337,6 +298,9 @@ func newRemoteAction(action string, github *model.GithubContext, instanceURL str
|
||||
}
|
||||
ra.Org, ra.Repo, _ = strings.Cut(repo, "/")
|
||||
ra.URL, ra.Ref = github.ServerURL, ref
|
||||
if ra.URL == "" {
|
||||
return nil, fmt.Errorf("unable to resolve %q without a Gitea instance", action)
|
||||
}
|
||||
if ra.Org == "" || ra.Repo == "" || ra.Ref == "" {
|
||||
return nil, fmt.Errorf("unable to resolve %q without a repository and commit", action)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -31,14 +32,10 @@ type stepActionRemoteMocks struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func actionDirSuffix(suffix string) any {
|
||||
return mock.MatchedBy(func(actionDir string) bool { return strings.HasSuffix(actionDir, suffix) })
|
||||
}
|
||||
|
||||
func setCloneExecutor(t *testing.T, executor func(git.NewGitCloneExecutorInput) common.Executor) {
|
||||
original := stepActionRemoteNewCloneExecutor
|
||||
stepActionRemoteNewCloneExecutor = executor
|
||||
t.Cleanup(func() { stepActionRemoteNewCloneExecutor = original })
|
||||
func setFetch(t *testing.T, fetch func(context.Context, git.NewGitCloneExecutorInput) (string, string, error)) {
|
||||
original := stepActionRemoteFetch
|
||||
stepActionRemoteFetch = fetch
|
||||
t.Cleanup(func() { stepActionRemoteFetch = original })
|
||||
}
|
||||
|
||||
func TestShortSHAActionRejected(t *testing.T) {
|
||||
@@ -175,25 +172,23 @@ func TestStepActionRemote(t *testing.T) {
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
cm := &containerMock{}
|
||||
sarm := &stepActionRemoteMocks{}
|
||||
|
||||
clonedAction := false
|
||||
|
||||
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
clonedAction = true
|
||||
return nil
|
||||
}
|
||||
actionDir := t.TempDir()
|
||||
setFetch(t, func(_ctx context.Context, _input git.NewGitCloneExecutorInput) (string, string, error) {
|
||||
clonedAction = true
|
||||
return actionDir, "", nil
|
||||
})
|
||||
|
||||
sar := &stepActionRemote{
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: "github.com",
|
||||
ActionCacheDir: "/tmp/test-cache",
|
||||
ActionCacheDir: t.TempDir(),
|
||||
},
|
||||
Run: &model.Run{
|
||||
JobID: "1",
|
||||
@@ -213,10 +208,10 @@ func TestStepActionRemote(t *testing.T) {
|
||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
if tt.mocks.read {
|
||||
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
sarm.On("readAction", sar.Step, actionDir, "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
}
|
||||
if tt.mocks.run {
|
||||
sarm.On("runAction", sar, actionDirSuffix(sar.Step.UsesHash()), mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
||||
sarm.On("runAction", sar, actionDir, mustNewRemoteAction(t, sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
|
||||
|
||||
cm.On("Copy", "/var/run/act", mock.AnythingOfType("[]*container.FileEntry")).Return(noopExecutor)
|
||||
|
||||
@@ -263,40 +258,60 @@ func TestStepActionRemote(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStepActionRemotePrepare(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, uses, instance, actionPath, wantURL string
|
||||
}{
|
||||
{name: "nested action", uses: "org/repo/path@ref", instance: "https://github.com", actionPath: "path", wantURL: "https://github.com/org/repo"},
|
||||
{name: "instance fallback", uses: "actions/setup-go@v4", instance: "gitea.example", wantURL: "https://gitea.example/actions/setup-go"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var actualURL string
|
||||
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||
return func(context.Context) error {
|
||||
actualURL = input.URL
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
actionMocks := &stepActionRemoteMocks{}
|
||||
action := &stepActionRemote{
|
||||
Step: &model.Step{Uses: test.uses},
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{GitHubInstance: test.instance, ActionCacheDir: t.TempDir()},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{
|
||||
Jobs: map[string]*model.Job{"1": {}},
|
||||
}},
|
||||
},
|
||||
readAction: actionMocks.readAction,
|
||||
}
|
||||
actionMocks.On("readAction", action.Step, actionDirSuffix(action.Step.UsesHash()), test.actionPath,
|
||||
mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
|
||||
require.NoError(t, action.prepareActionExecutor()(t.Context()))
|
||||
assert.Equal(t, test.wantURL, actualURL)
|
||||
actionMocks.AssertExpectations(t)
|
||||
})
|
||||
var fetched, read []string
|
||||
setFetch(t, func(_ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
|
||||
fetched = append(fetched, input.URL+"@"+input.Ref)
|
||||
if input.Ref == "missing" {
|
||||
return "", "", assert.AnError
|
||||
}
|
||||
return filepath.Join(input.Dir, "commit"), "sha-" + input.Ref, nil
|
||||
})
|
||||
newJob := func(instance string) *RunContext {
|
||||
return &RunContext{
|
||||
Config: &Config{GitHubInstance: instance, ActionCacheDir: t.TempDir()},
|
||||
Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}},
|
||||
JobContainer: &jobContainerMock{},
|
||||
}
|
||||
}
|
||||
prepare := func(rc *RunContext, uses string) (*stepActionRemote, error) {
|
||||
sar := &stepActionRemote{Step: &model.Step{Uses: uses}, RunContext: rc, readAction: func(_ctx context.Context, _step *model.Step, _actionDir, actionPath string, _readFile actionYamlReader, _writeFile fileWriter) (*model.Action, error) {
|
||||
read = append(read, actionPath)
|
||||
return &model.Action{}, nil
|
||||
}}
|
||||
return sar, sar.prepareActionExecutor()(t.Context())
|
||||
}
|
||||
|
||||
job := newJob("https://github.com")
|
||||
restore, err := prepare(job, "org/repo/restore@v1")
|
||||
require.NoError(t, err)
|
||||
root, err := prepare(job, "org/repo@v1")
|
||||
require.NoError(t, err)
|
||||
nested, err := prepare(&RunContext{Config: job.Config, Run: job.Run, JobContainer: job.JobContainer, Parent: job}, "org/repo/save@v1")
|
||||
require.NoError(t, err)
|
||||
other, err := prepare(job, "org/repo@v2")
|
||||
require.NoError(t, err)
|
||||
_, err = prepare(newJob("gitea.example"), "org/repo@v1")
|
||||
require.NoError(t, err)
|
||||
for range 2 {
|
||||
_, err = prepare(job, "org/repo@missing")
|
||||
require.ErrorIs(t, err, assert.AnError)
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"https://github.com/org/repo@v1", "https://github.com/org/repo@v2", "https://gitea.example/org/repo@v1", "https://github.com/org/repo@missing"}, fetched)
|
||||
assert.Equal(t, []string{"restore", "", "save", "", ""}, read)
|
||||
assert.Equal(t, root.actionDir, restore.actionDir)
|
||||
assert.Equal(t, root.actionDir, nested.actionDir)
|
||||
assert.NotEqual(t, root.actionDir, other.actionDir)
|
||||
|
||||
reference, sha := restore.actionDownloadInfo()
|
||||
assert.Equal(t, []string{"org/repo@v1", "sha-v1"}, []string{reference, sha})
|
||||
_, sha = root.actionDownloadInfo()
|
||||
assert.Empty(t, sha)
|
||||
|
||||
_, rootContainerDir := getContainerActionPaths(root)
|
||||
_, restoreContainerDir := getContainerActionPaths(restore)
|
||||
assert.NotEqual(t, path.Dir(rootContainerDir), path.Dir(restoreContainerDir))
|
||||
assert.Equal(t, "restore", path.Base(restoreContainerDir))
|
||||
}
|
||||
|
||||
func TestStepActionRemotePost(t *testing.T) {
|
||||
@@ -438,7 +453,7 @@ func TestStepActionRemotePost(t *testing.T) {
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
cm := &containerMock{}
|
||||
|
||||
@@ -447,7 +462,7 @@ func TestStepActionRemotePost(t *testing.T) {
|
||||
RunContext: &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: "https://github.com",
|
||||
ActionCacheDir: "/tmp/test-cache",
|
||||
ActionCacheDir: t.TempDir(),
|
||||
},
|
||||
JobContainer: cm,
|
||||
Run: &model.Run{
|
||||
@@ -688,6 +703,7 @@ func Test_newRemoteActionSelfRepo(t *testing.T) {
|
||||
Ref: "v1",
|
||||
},
|
||||
},
|
||||
{name: "unknown instance", action: "$/.gitea/actions/build", github: &model.GithubContext{Repository: "owner/workflow-repo", Sha: "abc123"}},
|
||||
{name: "empty path", action: "$/", github: workflow},
|
||||
{name: "ref suffix is not allowed", action: "$/.gitea/actions/build@v1", github: workflow},
|
||||
{name: "path traversal", action: "$/../escape", github: workflow},
|
||||
@@ -700,17 +716,15 @@ func Test_newRemoteActionSelfRepo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_stepActionRemoteSelfRepoActionDir(t *testing.T) {
|
||||
dirFor := func(repo string) string {
|
||||
func Test_stepActionRemoteSelfRepoContainerKeyDiffersPerRepo(t *testing.T) {
|
||||
keyFor := func(repo string) string {
|
||||
sar := &stepActionRemote{
|
||||
Step: &model.Step{Uses: "$/.gitea/actions/build"},
|
||||
RunContext: &RunContext{Config: &Config{ActionCacheDir: "/cache"}},
|
||||
remoteAction: &remoteAction{Org: "owner", Repo: repo, Ref: "v1"},
|
||||
}
|
||||
return sar.actionDir()
|
||||
return sar.containerKey()
|
||||
}
|
||||
// The same `$/x` in two repos must not share a cache directory.
|
||||
assert.NotEqual(t, dirFor("one"), dirFor("two"))
|
||||
assert.NotEqual(t, keyFor("one"), keyFor("two"))
|
||||
}
|
||||
|
||||
func Test_remoteActionReference(t *testing.T) {
|
||||
@@ -771,39 +785,13 @@ func TestStepActionRemotePreResolvesDownloadedCommit(t *testing.T) {
|
||||
readAction: readActionImpl,
|
||||
}
|
||||
|
||||
require.NoError(t, sar.prepareActionExecutor()(context.Background()))
|
||||
require.NoError(t, sar.prepareActionExecutor()(t.Context()))
|
||||
|
||||
reference, sha, ok := sar.actionDownloadInfo()
|
||||
assert.True(t, ok)
|
||||
reference, sha := sar.actionDownloadInfo()
|
||||
assert.Equal(t, "actions/setup-go@main", reference)
|
||||
assert.Equal(t, wantSha, sha)
|
||||
}
|
||||
|
||||
func TestStepActionRemoteActionDownloadInfo(t *testing.T) {
|
||||
t.Run("reports the action and its resolved commit", func(t *testing.T) {
|
||||
sar := &stepActionRemote{
|
||||
remoteAction: mustNewRemoteAction(t, "actions/checkout@v7"),
|
||||
action: &model.Action{},
|
||||
resolvedSha: "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||
}
|
||||
|
||||
reference, sha, ok := sar.actionDownloadInfo()
|
||||
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "actions/checkout@v7", reference)
|
||||
assert.Equal(t, "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", sha)
|
||||
})
|
||||
|
||||
t.Run("reports nothing when no action was downloaded", func(t *testing.T) {
|
||||
// The local checkout of the workflow's own repository resolves no action.
|
||||
sar := &stepActionRemote{remoteAction: mustNewRemoteAction(t, "actions/checkout@v7")}
|
||||
|
||||
_, _, ok := sar.actionDownloadInfo()
|
||||
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_safeFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
@@ -852,12 +840,13 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
||||
|
||||
for _, tt := range table {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
var capturedToken string
|
||||
setCloneExecutor(t, func(input git.NewGitCloneExecutorInput) common.Executor {
|
||||
actionDir := t.TempDir()
|
||||
setFetch(t, func(_ctx context.Context, input git.NewGitCloneExecutorInput) (string, string, error) {
|
||||
capturedToken = input.Token
|
||||
return func(ctx context.Context) error { return nil }
|
||||
return actionDir, "", nil
|
||||
})
|
||||
|
||||
sarm := &stepActionRemoteMocks{}
|
||||
@@ -867,7 +856,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
||||
Config: &Config{
|
||||
GitHubInstance: tt.gitHubInstance,
|
||||
DefaultActionInstance: tt.defaultActionInstance,
|
||||
ActionCacheDir: "/tmp/test-cache",
|
||||
ActionCacheDir: t.TempDir(),
|
||||
// Mirrors the state of a composite RunContext: job secrets are
|
||||
// stripped, but the job token is still reachable via Config.Token.
|
||||
Secrets: nil,
|
||||
@@ -885,7 +874,7 @@ func TestStepActionRemoteCloneTokenSurvivesNilSecrets(t *testing.T) {
|
||||
}
|
||||
sar.RunContext.ExprEval = sar.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
sarm.On("readAction", sar.Step, actionDirSuffix(sar.Step.UsesHash()), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
sarm.On("readAction", sar.Step, actionDir, "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
|
||||
|
||||
err := sar.prepareActionExecutor()(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -6,7 +6,6 @@ package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -35,7 +34,7 @@ func TestStepDockerMain(t *testing.T) {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
@@ -124,7 +123,7 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
sd := &stepDocker{
|
||||
RunContext: &RunContext{
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
@@ -197,7 +196,7 @@ func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) {
|
||||
ContainerNewContainer = origContainerNewContainer
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
platform := tc.platform
|
||||
sd := &stepDocker{
|
||||
|
||||
@@ -14,12 +14,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/act/lookpath"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/sirupsen/logrus"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -58,8 +58,8 @@ func (sr *stepRun) main() common.Executor {
|
||||
// printRunScriptActionDetails mirrors actions/runner ScriptHandler.PrintActionDetails
|
||||
// for script steps.
|
||||
func (sr *stepRun) printRunScriptActionDetails(ctx context.Context) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
scriptLineLogger := rawLogger.WithField(scriptLineCyanField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
scriptLineLogger := rawLogger.With(scriptLineCyanField, true)
|
||||
|
||||
normalized := strings.TrimRight(strings.ReplaceAll(sr.interpolatedScript, "\r\n", "\n"), "\n")
|
||||
|
||||
@@ -84,7 +84,7 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
||||
if step == nil {
|
||||
return
|
||||
}
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
|
||||
title := step.Uses
|
||||
if step.Name != "" {
|
||||
@@ -105,7 +105,7 @@ func printRunActionHeader(ctx context.Context, step *model.Step, env map[string]
|
||||
// printStepEnvBlock emits the declared-env block (YAML order, internal vars filtered)
|
||||
// shared by the run: and uses: "Run" headers.
|
||||
func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]string, rc *RunContext) {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
rawLogger := common.Logger(ctx).With(rawOutputField, true)
|
||||
caseInsensitive := rc != nil && rc.JobContainer != nil && rc.JobContainer.IsEnvironmentCaseInsensitive()
|
||||
var visible []string
|
||||
for _, k := range stepDeclaredEnvKeysInOrder(step) {
|
||||
@@ -135,7 +135,7 @@ func printStepEnvBlock(ctx context.Context, step *model.Step, env map[string]str
|
||||
|
||||
// logKeyedValue prints one row per line of value: Gitea stores one log row per line, so an
|
||||
// embedded newline would reach the user as a literal "\n".
|
||||
func logKeyedValue(rawLogger *logrus.Entry, key, value string) {
|
||||
func logKeyedValue(rawLogger *logging.Logger, key, value string) {
|
||||
lines := strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n")
|
||||
rawLogger.Infof(" %s: %s", key, lines[0])
|
||||
for _, line := range lines[1:] {
|
||||
|
||||
@@ -6,14 +6,14 @@ package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/logging"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
yaml "go.yaml.in/yaml/v4"
|
||||
@@ -84,12 +84,9 @@ env:
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
entry := logger.With("job", "j1")
|
||||
ctx := common.WithLogger(t.Context(), entry)
|
||||
|
||||
sr := &stepRun{
|
||||
Step: &step,
|
||||
@@ -133,12 +130,9 @@ env:
|
||||
require.NoError(t, yaml.Unmarshal([]byte(raw), &step))
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(buf)
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.SetFormatter(&jobLogFormatter{color: cyan})
|
||||
entry := logger.WithFields(logrus.Fields{"job": "j1"})
|
||||
ctx := common.WithLogger(context.Background(), entry)
|
||||
logger := logging.New(logging.NewLevelHandler(newJobLogHandler(buf, cyan), logging.LevelInfo))
|
||||
entry := logger.With("job", "j1")
|
||||
ctx := common.WithLogger(t.Context(), entry)
|
||||
|
||||
printRunActionHeader(ctx, &step, map[string]string{"CUSTOM": "value", "GITHUB_TOKEN": "tok"}, &RunContext{})
|
||||
|
||||
@@ -166,17 +160,17 @@ func TestIsInternalEnvKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrintColoredScriptLineCyan(t *testing.T) {
|
||||
f := &jobLogFormatter{color: cyan}
|
||||
entry := &logrus.Entry{
|
||||
Level: logrus.InfoLevel,
|
||||
f := textFormatter{w: io.Discard, color: cyan}
|
||||
record := logging.Record{
|
||||
Level: logging.LevelInfo,
|
||||
Message: "echo one",
|
||||
Data: logrus.Fields{
|
||||
"job": "j1",
|
||||
rawOutputField: true,
|
||||
scriptLineCyanField: true,
|
||||
Attrs: []logging.Attr{
|
||||
{Key: "job", Value: "j1"},
|
||||
{Key: rawOutputField, Value: true},
|
||||
{Key: scriptLineCyanField, Value: true},
|
||||
},
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
f.printColored(buf, entry)
|
||||
f.printColored(buf, record)
|
||||
assert.Equal(t, "\x1b[36m|\x1b[0m \x1b[36;1mecho one\x1b[0m", buf.String())
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestStepRun(t *testing.T) {
|
||||
|
||||
cm.On("UpdateFromEnv", "/var/run/act/workflow/outputcmd.txt", mock.AnythingOfType("*map[string]string")).Return(noopExecutor)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := t.Context()
|
||||
|
||||
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/pathcmd.txt").Return(io.NopCloser(&bytes.Buffer{}), nil)
|
||||
|
||||
|
||||
+106
-39
@@ -7,18 +7,20 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
|
||||
"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/internal/pkg/telemetry"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -164,8 +166,8 @@ func TestSetupEnv(t *testing.T) {
|
||||
sm.On("getStepModel").Return(step)
|
||||
sm.On("getEnv").Return(&env)
|
||||
|
||||
require.NoError(t, setupEnv(context.Background(), sm))
|
||||
require.NoError(t, setupInputs(context.Background(), sm))
|
||||
require.NoError(t, setupEnv(t.Context(), sm))
|
||||
require.NoError(t, setupInputs(t.Context(), sm))
|
||||
|
||||
// These are commit or system specific
|
||||
delete(env, "GITHUB_REF")
|
||||
@@ -189,7 +191,6 @@ func TestSetupEnv(t *testing.T) {
|
||||
"GITHUB_ACTION_PATH": "",
|
||||
"GITHUB_ACTION_REF": "",
|
||||
"GITHUB_ACTION_REPOSITORY": "",
|
||||
"GITHUB_API_URL": "https:///api/v1", // Gitea uses api/v1 (upstream GitHub: api/v3)
|
||||
"GITHUB_BASE_REF": "",
|
||||
"GITHUB_EVENT_NAME": "",
|
||||
"GITHUB_EVENT_PATH": "/var/run/act/workflow/event.json",
|
||||
@@ -200,7 +201,6 @@ func TestSetupEnv(t *testing.T) {
|
||||
"GITHUB_RUN_ATTEMPT": "",
|
||||
"GITHUB_RUN_ID": "runId",
|
||||
"GITHUB_RUN_NUMBER": "1",
|
||||
"GITHUB_SERVER_URL": "https://",
|
||||
"GITHUB_WORKFLOW": "",
|
||||
"INPUT_ID": "1234567",
|
||||
"INPUT_STEP_WITH": "with-value",
|
||||
@@ -299,63 +299,63 @@ func TestIsStepEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
// success()
|
||||
step := createTestStep(t, "if: success()")
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getIfExpression(context.Background(), stepStageMain), step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getIfExpression(t.Context(), stepStageMain), step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: success()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: success()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
// failure()
|
||||
step = createTestStep(t, "if: failure()")
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: failure()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: failure()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
// always()
|
||||
step = createTestStep(t, "if: always()")
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: always()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusSuccess,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
step = createTestStep(t, "if: always()")
|
||||
step.getRunContext().StepResults["a"] = &model.StepResult{
|
||||
Conclusion: model.StepStatusFailure,
|
||||
}
|
||||
assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.True(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
|
||||
// neither env nor the step's own with: values are inputs, at any stage
|
||||
step = createTestStep(t, "if: inputs.forged")
|
||||
step.getRunContext().Env["INPUT_FORGED"] = "leaked"
|
||||
*step.getEnv() = map[string]string{"INPUT_FORGED": "leaked"}
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStagePost))
|
||||
assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStageMain))
|
||||
assertObject.False(isStepEnabled(t.Context(), step.getStepModel().If.Value, step, stepStagePost))
|
||||
}
|
||||
|
||||
func TestIsContinueOnError(t *testing.T) {
|
||||
@@ -383,49 +383,49 @@ func TestIsContinueOnError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
log.SetLevel(log.DebugLevel)
|
||||
logging.SetLevel(logging.LevelDebug)
|
||||
assertObject := assert.New(t)
|
||||
|
||||
// absent
|
||||
step := createTestStep(t, "name: test")
|
||||
continueOnError, err := isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err := isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// explcit true
|
||||
step = createTestStep(t, "continue-on-error: true")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.True(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// explicit false
|
||||
step = createTestStep(t, "continue-on-error: false")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// expression true
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' == 'test' }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.True(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// expression false
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != 'test' }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.NoError(err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
|
||||
// the step's own with: values are not inputs
|
||||
step = createTestStep(t, "continue-on-error: ${{ inputs.forged }}")
|
||||
*step.getEnv() = map[string]string{"INPUT_FORGED": "true"}
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
require.NoError(t, err)
|
||||
|
||||
// expression parse error
|
||||
step = createTestStep(t, "continue-on-error: ${{ 'test' != test }}")
|
||||
continueOnError, err = isContinueOnError(context.Background(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
continueOnError, err = isContinueOnError(t.Context(), step.getStepModel().RawContinueOnError, step, stepStageMain)
|
||||
assertObject.False(continueOnError)
|
||||
assertObject.Error(err)
|
||||
}
|
||||
@@ -449,9 +449,9 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||
StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: cm,
|
||||
}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
// Dryrun skips reading the path file back from the (mocked) container.
|
||||
ctx := common.WithDryrun(context.Background(), true)
|
||||
ctx := common.WithDryrun(t.Context(), true)
|
||||
|
||||
// A refusal parsed out of the job container's own output belongs to no step, so the
|
||||
// first step must not be failed by it.
|
||||
@@ -475,11 +475,31 @@ func TestRunStepExecutorDoesNotLeakRefusalToNextStep(t *testing.T) {
|
||||
require.NoError(t, errB)
|
||||
}
|
||||
|
||||
type fileCommandTestContainer struct {
|
||||
*container.HostEnvironment
|
||||
}
|
||||
|
||||
func (c *fileCommandTestContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HostEnvironment.UpdateFromEnv(srcPath, env)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fileCommandTestContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.HostEnvironment.GetContainerArchive(ctx, srcPath)
|
||||
}
|
||||
|
||||
func TestRunStepExecutorParity(t *testing.T) {
|
||||
newStep := func(t *testing.T, stepModel *model.Step) *stepRun {
|
||||
rc := createRunContext(t)
|
||||
rc.JobContainer = &container.HostEnvironment{ActPath: t.TempDir()}
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||
return &stepRun{RunContext: rc, Step: stepModel, env: map[string]string{}}
|
||||
}
|
||||
badExpression := "${{ 'test' != test }}"
|
||||
@@ -493,30 +513,48 @@ func TestRunStepExecutorParity(t *testing.T) {
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
step := newStep(t, test.step)
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
err := runStepExecutor(step, stepStageMain, test.executor)(common.WithLogger(context.Background(), logger))
|
||||
logger, hook := logtest.NewLogger()
|
||||
err := runStepExecutor(step, stepStageMain, test.executor)(common.WithLogger(t.Context(), logger))
|
||||
|
||||
require.ErrorContains(t, err, test.wantError)
|
||||
assert.Equal(t, model.StepStatusFailure, step.RunContext.StepResults[test.step.ID].Conclusion)
|
||||
assert.Equal(t, model.StepStatusFailure, hook.LastEntry().Data["stepResult"])
|
||||
assert.Equal(t, model.StepStatusFailure, lastRecordAttr(hook, "stepResult"))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("only a step that runs is marked started", func(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
condition string
|
||||
started int
|
||||
}{{"true", 1}, {"false", 0}} {
|
||||
logger, hook := logtest.NewLogger()
|
||||
step := newStep(t, &model.Step{ID: "pre", If: yaml.Node{Value: test.condition}})
|
||||
require.NoError(t, runStepExecutor(step, stepStagePre, noopExecutor)(common.WithLogger(context.Background(), logger)))
|
||||
started := 0
|
||||
for _, entry := range hook.AllRecords() {
|
||||
if v, _ := entry.Attr(StepStartedField); v == true {
|
||||
started++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, test.started, started, test.condition)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file command error honors continue-on-error", func(t *testing.T) {
|
||||
step := newStep(t, &model.Step{ID: "commands", RawContinueOnError: "true"})
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
logger, hook := logtest.NewLogger()
|
||||
err := runStepExecutor(step, stepStageMain, func(context.Context) error {
|
||||
require.NoError(t, os.WriteFile(step.env["GITHUB_ENV"], []byte("GOOD=1\nmalformed\n"), 0o600))
|
||||
require.NoError(t, os.WriteFile(step.env["GITHUB_OUTPUT"], []byte("kept=value\n"), 0o600))
|
||||
require.NoError(t, os.WriteFile(step.env["GITHUB_STATE"], []byte("saved=value\n"), 0o600))
|
||||
return nil
|
||||
})(common.WithLogger(context.Background(), logger))
|
||||
})(common.WithLogger(t.Context(), logger))
|
||||
|
||||
require.NoError(t, err)
|
||||
result := step.RunContext.StepResults[step.Step.ID]
|
||||
assert.Equal(t, model.StepStatusFailure, result.Outcome)
|
||||
assert.Equal(t, model.StepStatusSuccess, result.Conclusion)
|
||||
assert.Equal(t, model.StepStatusSuccess, hook.LastEntry().Data["stepResult"])
|
||||
assert.Equal(t, model.StepStatusSuccess, lastRecordAttr(hook, "stepResult"))
|
||||
assert.Equal(t, "1", step.RunContext.Env["GOOD"])
|
||||
assert.Equal(t, "value", result.Outputs["kept"])
|
||||
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
|
||||
@@ -527,13 +565,37 @@ func TestRunStepExecutorParity(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file commands survive a job timeout", func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
step := newStep(t, &model.Step{ID: "interrupted"})
|
||||
ctx, cancel := applyJobTimeout(t.Context(), step.RunContext, &model.Job{TimeoutMinutes: "1"})
|
||||
defer cancel()
|
||||
step.RunContext.JobContainer = &fileCommandTestContainer{step.RunContext.JobContainer.(*container.HostEnvironment)}
|
||||
err := runStepExecutor(step, stepStageMain, func(execCtx context.Context) error {
|
||||
for name, content := range map[string]string{
|
||||
"GITHUB_ENV": "KEPT_ENV=value\n", "GITHUB_STATE": "saved=value\n",
|
||||
"GITHUB_OUTPUT": "kept=value\n", "GITHUB_PATH": "/kept/bin\n",
|
||||
} {
|
||||
require.NoError(t, os.WriteFile(step.env[name], []byte(content), 0o600))
|
||||
}
|
||||
<-execCtx.Done()
|
||||
return execCtx.Err()
|
||||
})(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.Equal(t, "value", step.RunContext.Env["KEPT_ENV"])
|
||||
assert.Equal(t, "value", step.RunContext.IntraActionState[step.Step.ID]["saved"])
|
||||
assert.Equal(t, "value", step.RunContext.StepResults[step.Step.ID].Outputs["kept"])
|
||||
assert.Contains(t, step.RunContext.ExtraPath, "/kept/bin")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("composite child files do not leak", func(t *testing.T) {
|
||||
outer := newStep(t, &model.Step{ID: "outer"})
|
||||
childRC := &RunContext{
|
||||
Config: outer.RunContext.Config, Run: outer.RunContext.Run, Env: map[string]string{}, StepResults: map[string]*model.StepResult{},
|
||||
JobContainer: outer.RunContext.JobContainer, Parent: outer.RunContext,
|
||||
}
|
||||
childRC.ExprEval = childRC.NewExpressionEvaluator(context.Background())
|
||||
childRC.ExprEval = childRC.NewExpressionEvaluator(t.Context())
|
||||
child := &stepRun{RunContext: childRC, Step: &model.Step{ID: "child"}, env: map[string]string{}}
|
||||
|
||||
err := runStepExecutor(outer, stepStageMain, func(ctx context.Context) error {
|
||||
@@ -544,7 +606,7 @@ func TestRunStepExecutorParity(t *testing.T) {
|
||||
})(ctx))
|
||||
outer.RunContext.setOutput(ctx, map[string]string{"name": "declared"}, "outer")
|
||||
return nil
|
||||
})(context.Background())
|
||||
})(t.Context())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"declared": "outer"}, outer.RunContext.StepResults[outer.Step.ID].Outputs)
|
||||
@@ -577,12 +639,17 @@ func TestRunStepExecutorParity(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("timeout must be positive", func(t *testing.T) {
|
||||
exprEval := createRunContext(t).NewExpressionEvaluator(context.Background())
|
||||
exprEval := createRunContext(t).NewExpressionEvaluator(t.Context())
|
||||
for timeout, wantDeadline := range map[string]bool{"-1": false, "0": false, "1": true} {
|
||||
ctx, cancel := evaluateStepTimeout(context.Background(), exprEval, &model.Step{TimeoutMinutes: timeout})
|
||||
ctx, cancel := evaluateStepTimeout(t.Context(), exprEval, &model.Step{TimeoutMinutes: timeout})
|
||||
_, hasDeadline := ctx.Deadline()
|
||||
cancel()
|
||||
assert.Equal(t, wantDeadline, hasDeadline, timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func lastRecordAttr(h *logtest.Handler, key string) any {
|
||||
v, _ := h.LastRecord().Attr(key)
|
||||
return v
|
||||
}
|
||||
+3
-3
@@ -37,10 +37,10 @@ async function main() {
|
||||
await request('POST', `${api}/networks/${name}/connect`, {Container: job});
|
||||
fs.appendFileSync(process.env.GITHUB_STATE, 'post=true\n');
|
||||
}
|
||||
const label = process.env.PROXY_TEST_MODE === 'proxy' ? job : undefined;
|
||||
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.['com.gitea.runner.job'], label);
|
||||
const label = process.env.PROXY_TEST_MODE === 'proxy' ? '' : undefined;
|
||||
assert.equal(JSON.parse(await request('GET', `${api}/volumes/${name}`)).Labels?.[`com.gitea.runner.job.${job}`], label);
|
||||
const network = JSON.parse(await request('GET', `${api}/networks/${name}`));
|
||||
assert.equal(network.Labels?.['com.gitea.runner.job'], label);
|
||||
assert.equal(network.Labels?.[`com.gitea.runner.job.${job}`], label);
|
||||
assert(Object.values(network.Containers).some((container) => container.Name === job));
|
||||
if (post) {
|
||||
console.log('docker proxy post verified');
|
||||
|
||||
+1
-1
@@ -52,5 +52,5 @@ jobs:
|
||||
- run: |
|
||||
docker run --rm --user 1001 --group-add 2375 -v /var/run/docker.sock:/var/run/docker.sock -v /usr/local/bin/docker:/usr/local/bin/docker:ro -v "${GITEA_DOCKER_WORKSPACE:?}/data:/data:ro" \
|
||||
"$PROXY_TEST_IMAGE" sh -c 'grep -qx from-job /data/marker && docker volume create "$0"' "$PROXY_TEST_RESOURCE-nested"
|
||||
test "$(docker volume inspect -f '{{ index .Labels "com.gitea.runner.job" }}' "$PROXY_TEST_RESOURCE-nested")" = "$JOB_CONTAINER_NAME"
|
||||
test "$(docker volume ls -q -f "label=com.gitea.runner.job.$JOB_CONTAINER_NAME" -f "name=$PROXY_TEST_RESOURCE-nested")" = "$PROXY_TEST_RESOURCE-nested"
|
||||
echo "docker binds verified"
|
||||
+22
-4
@@ -8,14 +8,32 @@ Built-in actions ship with the runner, so a job needs no action download and no
|
||||
|
||||
## checkout
|
||||
|
||||
Works like `actions/checkout` with these inputs. Any other input fails the step.
|
||||
Works like `actions/checkout` with these inputs. Any other input fails the step. It runs `git` where the job runs, see the [prerequisites](../README.md#prerequisites).
|
||||
|
||||
| Input | Default |
|
||||
| --- | --- |
|
||||
| `repository` | `github.repository` |
|
||||
| `ref` | the triggering commit, or the default branch of another repository |
|
||||
| `ref` | the triggering ref and commit, or the default branch of another repository |
|
||||
| `token` | `github.token` |
|
||||
| `ssh-key` | empty, a private key fetches over SSH instead |
|
||||
| `ssh-known-hosts` | empty, host keys trusted besides `~/.ssh/known_hosts` |
|
||||
| `ssh-strict` | `true`, rejects unknown host keys |
|
||||
| `ssh-user` | the user of the repository's SSH URL, else `git` |
|
||||
| `persist-credentials` | `true`, later steps of the job can fetch and push with `token` or `ssh-key` |
|
||||
| `path` | workspace root |
|
||||
| `fetch-depth` | `1`, `0` for full history |
|
||||
| `clean` | `true`, removes untracked files, also in submodules |
|
||||
| `filter` | empty, a partial clone filter such as `blob:none` |
|
||||
| `sparse-checkout` | empty, the paths to check out, one per line |
|
||||
| `sparse-checkout-cone-mode` | `true`, `false` reads `sparse-checkout` as patterns |
|
||||
| `fetch-depth` | `1`, `0` for the full history of every branch and tag |
|
||||
| `fetch-tags` | `false`, `true` fetches every tag |
|
||||
| `show-progress` | `true`, shows the fetch progress |
|
||||
| `lfs` | `false`, `true` downloads Git LFS files |
|
||||
| `submodules` | `false`, `true` checks out submodules, `recursive` also theirs |
|
||||
| `set-safe-directory` | `true`, lets checkout's `git` work in a directory another user owns |
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` is set.
|
||||
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
|
||||
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||
+1
-1
@@ -45,7 +45,7 @@ kubernetes:
|
||||
nvidia.com/gpu.present: "true"
|
||||
```
|
||||
|
||||
A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of `pod_templates` merged over `pod_template`, while its image comes from the first of its labels the runner has. A template volume named `workspace` or `act` replaces the `emptyDir` the runner mounts at the workspace or at `/var/run/act`, for example to limit its size. Images are pulled by the cluster's `imagePullPolicy` default, a template's, or always with `container.force_pull`.
|
||||
A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of `pod_templates` merged over `pod_template`, while its image comes from the first of its labels the runner has. A template volume named `workspace` or `act` replaces the `emptyDir` the runner mounts at the workspace or at `/var/run/act`, for example to limit its size. Images are pulled by the cluster's `imagePullPolicy` default, a template's, or always with `container.force_pull`. Like every config value, templates expand `${NAME}` from the runner's environment, so a shell command in a template writes `$${NAME}` for the pod's own variable.
|
||||
|
||||
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ The script environment is **not** a full copy of the job container environment.
|
||||
|
||||
## Output and errors
|
||||
|
||||
- **Stdout/stderr** are written to the **runner process log** (logrus), prefixed with `post-task script stdout:` / `post-task script stderr:`.
|
||||
- **Stdout/stderr** are written to the **runner process log**, prefixed with `post-task script stdout:` / `post-task script stderr:`.
|
||||
- **Non-zero exit codes** are logged as warnings only. They do **not** change the job result already reported to Gitea.
|
||||
- **Timeouts and start failures** are logged as warnings; the runner still completes the task acknowledgement.
|
||||
|
||||
|
||||
+18
-2
@@ -31,7 +31,7 @@ Except for the endpoint, each `OTEL_EXPORTER_OTLP_*` variable has `TRACES` and `
|
||||
|
||||
- A span per job, named `RUN {workflow} {job}`, with the repository, ref, commit, run URL, run number, attempt and result.
|
||||
- A span per step, named as in Gitea's step list, including `Pre` and `Post` stages and the steps of composite actions, with the step's result.
|
||||
- The runner's name, UUID and version.
|
||||
- The runner's name, UUID, version, process ID and start time, and the Go version.
|
||||
|
||||
Logs, step output and secrets are never sent. Step names are taken from the workflow file as written, without expanding `${{ }}` expressions.
|
||||
|
||||
@@ -41,7 +41,23 @@ Jobs receive `TRACEPARENT`, so tools in a job that use OpenTelemetry can add the
|
||||
|
||||
## Metrics
|
||||
|
||||
The metrics of the Prometheus endpoint are also exported, under the same names and whether or not `metrics.enabled` is set. They are sent every export interval and once more on shutdown. If your collector only accepts traces, set `OTEL_METRICS_EXPORTER=none`.
|
||||
The metrics of the Prometheus endpoint are also exported, whether or not `metrics.enabled` is set, except `gitea_runner_info`, `go_info` and `process_start_time_seconds`, which are sent as resource attributes. They are sent every export interval and once more on shutdown. To turn them off, set `OTEL_METRICS_EXPORTER=none`.
|
||||
|
||||
Metrics are named after the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) where one exists, and under `gitea.runner.*` otherwise. Other Go and process metrics, such as `go_memstats_heap_alloc_bytes`, keep their Prometheus names.
|
||||
|
||||
| Metric | Attributes |
|
||||
| --- | --- |
|
||||
| `cicd.worker.count` | `cicd.worker.state` |
|
||||
| `cicd.system.errors` | `cicd.system.component`, `rpc.method`, `error.type` |
|
||||
| `gitea.runner.jobs` | `cicd.pipeline.result` |
|
||||
| `gitea.runner.job.active`, `gitea.runner.job.limit`, `gitea.runner.job.utilization`, `gitea.runner.job.duration` | |
|
||||
| `gitea.runner.poll.fetches` | `gitea.runner.poll.result` |
|
||||
| `gitea.runner.uptime`, `gitea.runner.poll.backoff`, `gitea.runner.poll.fetch.duration` | |
|
||||
| `gitea.runner.report.log.requests`, `gitea.runner.report.state.requests` | `gitea.runner.report.result` |
|
||||
| `gitea.runner.report.log.buffer`, `gitea.runner.report.log.duration`, `gitea.runner.report.state.duration` | |
|
||||
| `go.memory.used` | `go.memory.type` |
|
||||
| `go.goroutine.count`, `go.memory.allocated`, `go.memory.gc.goal`, `go.memory.gc.cycles`, `go.memory.limit`, `go.config.gogc`, `go.processor.limit` | |
|
||||
| `process.memory.usage`, `process.memory.virtual`, `process.unix.file_descriptor.count` (`process.windows.handle.count` on Windows) | |
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testMovingActionRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
|
||||
api, repo, _ := startIsolatedScenario(t, "action_ref.yml", "e2e-action-ref", runnerOptions{})
|
||||
const actionRepo = "e2e-moving-action"
|
||||
if err := api.CreateRepo(ctx, actionRepo); err != nil {
|
||||
t.Fatalf("create action repo: %v", err)
|
||||
}
|
||||
action, err := os.ReadFile("testdata/actions/moving.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("read action fixture: %v", err)
|
||||
}
|
||||
if err := api.CreateFile(ctx, actionRepo, "action.yml", string(action), "add action"); err != nil {
|
||||
t.Fatalf("push action: %v", err)
|
||||
}
|
||||
branch, err := api.DefaultBranch(ctx, repo)
|
||||
if err != nil {
|
||||
t.Fatalf("get default branch: %v", err)
|
||||
}
|
||||
|
||||
var previousID int64
|
||||
for revision := range 3 {
|
||||
name := fmt.Sprintf("revision-%d", revision)
|
||||
if err := api.CreateFile(ctx, actionRepo, name, name, "add "+name); err != nil {
|
||||
t.Fatalf("move the action ref: %v", err)
|
||||
}
|
||||
if err := waitForDispatch(ctx, api, repo, "action_ref.yml", branch, map[string]string{"revision": name}); err != nil {
|
||||
t.Fatalf("dispatch workflow: %v", err)
|
||||
}
|
||||
wfRun := waitForRunAfter(t, api, repo, previousID)
|
||||
requireSuccess(t, api, repo, wfRun.ID)
|
||||
previousID = wfRun.ID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import "testing"
|
||||
|
||||
func testBuiltinCheckout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, repo := newScenario(t)
|
||||
pushWorkflow(t, api, repo, "checkout.yml")
|
||||
branchRun := waitForRun(t, api, repo)
|
||||
if err := api.CreateTag(t.Context(), repo, "v1"); err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
tagRun := waitForRunAfter(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, tagRun.ID)
|
||||
}
|
||||
@@ -30,6 +30,8 @@ func TestCompatibility(t *testing.T) {
|
||||
t.Run("artifact", testArtifactRoundTrip)
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", testWorkflowDispatch)
|
||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
||||
t.Run("moving_action_ref", testMovingActionRef)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
t.Run("kubernetes", testKubernetes)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func testWorkflowDispatch(t *testing.T) {
|
||||
t.Fatalf("get default branch: %v", err)
|
||||
}
|
||||
inputs := map[string]string{"subject": "dispatch-input-value"}
|
||||
if err := waitForDispatch(ctx, api, repo, branch, inputs); err != nil {
|
||||
if err := waitForDispatch(ctx, api, repo, "dispatch.yml", branch, inputs); err != nil {
|
||||
t.Fatalf("dispatch workflow: %v", err)
|
||||
}
|
||||
|
||||
@@ -35,13 +35,13 @@ func testWorkflowDispatch(t *testing.T) {
|
||||
}
|
||||
|
||||
// Gitea indexes workflows asynchronously after the Contents API commit.
|
||||
func waitForDispatch(ctx context.Context, api *GiteaAPI, repo, branch string, inputs map[string]string) error {
|
||||
func waitForDispatch(ctx context.Context, api *GiteaAPI, repo, workflow, branch string, inputs map[string]string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var lastErr error
|
||||
for {
|
||||
lastErr = api.DispatchWorkflow(ctx, repo, "dispatch.yml", branch, inputs)
|
||||
lastErr = api.DispatchWorkflow(ctx, repo, workflow, branch, inputs)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message
|
||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
||||
body := map[string]any{"tag_name": name}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
|
||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||
body := map[string]any{"data": value}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
||||
|
||||
@@ -188,6 +188,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
"GITEA__security__INSTALL_LOCK=true",
|
||||
"GITEA__database__DB_TYPE=sqlite3",
|
||||
"GITEA__actions__ENABLED=true",
|
||||
"GITEA__server__LFS_START_SERVER=true",
|
||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
||||
},
|
||||
ExposedPorts: network.PortSet{containerPort: struct{}{}},
|
||||
|
||||
+6
-1
@@ -141,6 +141,11 @@ func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
||||
}
|
||||
|
||||
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||
t.Helper()
|
||||
return waitForRunAfter(t, api, repo, 0)
|
||||
}
|
||||
|
||||
func waitForRunAfter(t *testing.T, api *GiteaAPI, repo string, previousID int64) *ActionRun {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
@@ -150,7 +155,7 @@ func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||
if err != nil {
|
||||
t.Fatalf("get latest run: %v", err)
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
if len(runs) > 0 && runs[0].ID > previousID {
|
||||
return &runs[0]
|
||||
}
|
||||
select {
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user