mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-11 18:34:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5305c6785 | ||
|
|
a021d3139d | ||
|
|
84c0fcd1fa | ||
|
|
c59caa9689 | ||
|
|
85d007c47f | ||
|
|
d34e4cf11a | ||
|
|
8e999cd039 | ||
|
|
cd748053b0 | ||
|
|
9bb72bbe5c | ||
|
|
79faf5e55d | ||
|
|
993389d428 | ||
|
|
db76fd5159 | ||
|
|
c8b16f4792 | ||
|
|
02446620df | ||
|
|
8c8950d9ef | ||
|
|
b88d111cc5 |
No files matched your search
@@ -51,11 +51,6 @@ fields.
|
|||||||
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
- Tests read what was logged with `logtest.NewLogger()`, or `logtest.SetDefault(t)` for the
|
||||||
package functions.
|
package functions.
|
||||||
|
|
||||||
## Built-in actions
|
|
||||||
|
|
||||||
Each lives in `internal/pkg/action/<name>` and is registered in `builtinActions` in
|
|
||||||
`act/runner/step_action_builtin.go`.
|
|
||||||
|
|
||||||
## End-to-end compatibility tests
|
## End-to-end compatibility tests
|
||||||
|
|
||||||
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
`make test-e2e` runs the runner against `E2E_GITEA_IMAGE`. It defaults to the nightly image.
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ EXECUTABLE := gitea-runner$(shell $(GO) env GOEXE)
|
|||||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||||
|
|
||||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.9-immutable@sha256:87f42f37fa85b421664c30b8f5f9cfc23920057f5e5fcadf0b6f68d9edc6d415 # renovate: datasource=docker
|
||||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:657378e5cfd898e36f820459c60ef8e69be8fcda9fd354e4381a8b654d091bbc # renovate: datasource=docker
|
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:b96ba465021b1a4c6a1c9e79220c2b9bfad0f29b09dbb528bbfb35411443a3a4 # renovate: datasource=docker
|
||||||
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
SERVICE_IMAGE ?= nginx:1.31.7-alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d # renovate: datasource=docker
|
||||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.1-k3s1@sha256:ca7f37d993d82ef0dcdcfecb2e0e2618ea541dbaffc620c8cedebe01a82acd0d # renovate: datasource=docker
|
E2E_K3S_IMAGE ?= rancher/k3s:v1.37.2-k3s1@sha256:21ea6e0fac4e3930c2ce8aa40ddd41a8b18cb3e9a0222c030dc005e7e88c4332 # renovate: datasource=docker
|
||||||
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
|
WINDOWS_TEST_IMAGE ?= dockurr/windows:6.06@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea # renovate: datasource=docker
|
||||||
|
|
||||||
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
GOFILES := $(shell find . -type f -name "*.go" -o -name "go.mod" ! -name "generated.*")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Gitea Runner runs the jobs of Gitea Actions workflows. Register it with a Gitea
|
|||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Linux, macOS or Windows, or FreeBSD.
|
- Linux, macOS or Windows, or FreeBSD.
|
||||||
|
- Gitea 1.26.2 or newer.
|
||||||
- `git` 2.34.1 or newer.
|
- `git` 2.34.1 or newer.
|
||||||
- `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/).
|
- `docker`, or `podman` with `podman.socket` enabled, for `docker` labels and Docker actions. See Docker's [install instructions](https://docs.docker.com/engine/install/).
|
||||||
|
|
||||||
@@ -14,9 +15,6 @@ In the job image, or on the host for host jobs:
|
|||||||
|
|
||||||
- `bash` or `sh`, or `pwsh` or `powershell` on Windows.
|
- `bash` or `sh`, or `pwsh` or `powershell` on Windows.
|
||||||
- `node` for JavaScript actions and `hashFiles()`.
|
- `node` for JavaScript actions and `hashFiles()`.
|
||||||
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
|
|
||||||
- `git-lfs` for `builtin:checkout` with `lfs: true`.
|
|
||||||
- `ssh` for `builtin:checkout` with `ssh-key`.
|
|
||||||
|
|
||||||
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
||||||
|
|
||||||
@@ -112,7 +110,7 @@ Versioned and `nightly` tags take the same suffixes, like `nightly-dind`. The im
|
|||||||
|
|
||||||
### Example deployments
|
### Example deployments
|
||||||
|
|
||||||
See the [examples](examples) directory for more deployments, among them systemd, Kubernetes and VMs.
|
See the [examples](examples) directory for more deployments, among them systemd, launchd on macOS, Kubernetes and VMs.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -183,7 +181,7 @@ Setup actions like `setup-go` install tools into `RUNNER_TOOL_CACHE`. `runner.to
|
|||||||
| Mode | Tool cache |
|
| Mode | Tool cache |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
|
| `none` (default) | Provided by the job image. Versions it lacks are downloaded in every job. |
|
||||||
| `shared` | One volume for all jobs. Only safe with `runner.capacity: 1`. |
|
| `shared` | One volume per `runner.capacity` slot, reused by later jobs. It hides the tools later job images ship, so use images without them. |
|
||||||
|
|
||||||
To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do:
|
To ship tools in a job image, install them to `/opt/hostedtoolcache/<tool>/<version>/<arch>` with an empty `<arch>.complete` file next to it, as the `-full` [runner images](https://gitea.com/gitea/runner-images) do:
|
||||||
|
|
||||||
@@ -245,10 +243,6 @@ volumes:
|
|||||||
|
|
||||||
Set `container.sweep: true` to remove the containers, networks and volumes most Linux container jobs create when they end, `docker compose` services included. Leave it off if jobs deploy services to the runner's Docker daemon.
|
Set `container.sweep: true` to remove the containers, networks and volumes most Linux container jobs create when they end, `docker compose` services included. Leave it off if jobs deploy services to the runner's Docker daemon.
|
||||||
|
|
||||||
### Built-in actions (`uses: builtin:<name>`)
|
|
||||||
|
|
||||||
`uses: builtin:checkout` checks out a repository without downloading `actions/checkout` or needing Node in the job image, see [docs/builtin-actions.md](docs/builtin-actions.md).
|
|
||||||
|
|
||||||
### Caching (`actions/cache`)
|
### Caching (`actions/cache`)
|
||||||
|
|
||||||
Each runner runs its own cache server. As on GitHub, a job restores caches of its own ref, its pull request's base branch and the default branch.
|
Each runner runs its own cache server. As on GitHub, a job restores caches of its own ref, its pull request's base branch and the default branch.
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ type Handler struct {
|
|||||||
stopSync context.CancelFunc
|
stopSync context.CancelFunc
|
||||||
syncing sync.WaitGroup
|
syncing sync.WaitGroup
|
||||||
|
|
||||||
|
outboundIPMu sync.RWMutex
|
||||||
outboundIP string
|
outboundIP string
|
||||||
|
detectOutboundIP func() net.IP // nil when the address is configured
|
||||||
instanceTransport http.RoundTripper
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
// internalSecret guards /_internal/{register,revoke}. When set, a remote
|
||||||
@@ -158,6 +160,7 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
policy: opts.Policy.withDefaults(),
|
policy: opts.Policy.withDefaults(),
|
||||||
freeDisk: disk.FreeBytes,
|
freeDisk: disk.FreeBytes,
|
||||||
dbLock: make(chan struct{}, 1),
|
dbLock: make(chan struct{}, 1),
|
||||||
|
outboundIP: opts.OutboundIP,
|
||||||
instanceTransport: opts.InstanceTransport,
|
instanceTransport: opts.InstanceTransport,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,12 +170,11 @@ func StartHandler(opts Options) (*Handler, error) {
|
|||||||
logger = logger.With("module", "artifactcache")
|
logger = logger.With("module", "artifactcache")
|
||||||
h.logger = logger
|
h.logger = logger
|
||||||
|
|
||||||
if opts.OutboundIP != "" {
|
if opts.OutboundIP == "" {
|
||||||
h.outboundIP = opts.OutboundIP
|
h.detectOutboundIP = common.GetOutboundIP
|
||||||
} else if ip := common.GetOutboundIP(); ip == nil {
|
if h.RefreshExternalURL() == "" {
|
||||||
return nil, errors.New("unable to determine outbound IP address")
|
logger.Warn("unable to determine an outbound IP address for the cache server yet, set cache.host to configure one")
|
||||||
} else {
|
}
|
||||||
h.outboundIP = ip.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.Upstream != "" {
|
if opts.Upstream != "" {
|
||||||
@@ -304,11 +306,36 @@ func (h *Handler) cacheForwarder(upstream string) (http.Handler, error) {
|
|||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExternalURL is "" while no address is known.
|
||||||
func (h *Handler) ExternalURL() string {
|
func (h *Handler) ExternalURL() string {
|
||||||
// TODO: make the external url configurable if necessary
|
if h == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
h.outboundIPMu.RLock()
|
||||||
|
defer h.outboundIPMu.RUnlock()
|
||||||
|
if h.outboundIP == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
return "http://" + net.JoinHostPort(h.outboundIP, strconv.Itoa(h.port))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshExternalURL re-detects an unconfigured address, which can change after startup.
|
||||||
|
func (h *Handler) RefreshExternalURL() string {
|
||||||
|
if h != nil && h.detectOutboundIP != nil {
|
||||||
|
if ip := h.detectOutboundIP(); ip != nil {
|
||||||
|
h.outboundIPMu.Lock()
|
||||||
|
if address := ip.String(); address != h.outboundIP {
|
||||||
|
h.outboundIP = address
|
||||||
|
if ip.To4() == nil {
|
||||||
|
h.logger.Warnf("detected IPv6 address %s for the cache server, jobs on IPv4-only networks cannot reach it, set cache.host if they fail to", address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.outboundIPMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h.ExternalURL()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) baseURL(cred JobCredential) string {
|
func (h *Handler) baseURL(cred JobCredential) string {
|
||||||
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
if base := strings.TrimRight(cred.PublicURL, "/"); base != "" {
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,6 +23,8 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/act/common/logging/logtest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
@@ -50,7 +53,23 @@ func signArtifactURL(h *Handler, id int64) string {
|
|||||||
|
|
||||||
func TestHandler_ExternalURL(t *testing.T) {
|
func TestHandler_ExternalURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
assert.Equal(t, "http://[2001:db8::1]:8080", (&Handler{outboundIP: "2001:db8::1", port: 8080}).ExternalURL())
|
assert.Empty(t, (*Handler)(nil).RefreshExternalURL())
|
||||||
|
assert.Equal(t, "http://cache.example:8080", (&Handler{outboundIP: "cache.example", port: 8080}).RefreshExternalURL())
|
||||||
|
|
||||||
|
logger, logs := logtest.NewLogger()
|
||||||
|
var ip net.IP
|
||||||
|
handler := &Handler{port: 8080, logger: logger, detectOutboundIP: func() net.IP { return ip }}
|
||||||
|
assert.Empty(t, handler.RefreshExternalURL())
|
||||||
|
ip = net.ParseIP("2001:db8::1")
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.RefreshExternalURL())
|
||||||
|
ip = net.ParseIP("192.0.2.1")
|
||||||
|
assert.Equal(t, "http://[2001:db8::1]:8080", handler.ExternalURL())
|
||||||
|
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||||
|
ip = nil
|
||||||
|
assert.Equal(t, "http://192.0.2.1:8080", handler.RefreshExternalURL())
|
||||||
|
require.Len(t, logs.AllRecords(), 1)
|
||||||
|
assert.Contains(t, logs.LastRecord().Message, "IPv6")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandler_Upstream(t *testing.T) {
|
func TestHandler_Upstream(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package common
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -55,19 +56,22 @@ func GetOutboundIP() net.IP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preferredIP(ips []interfaceIP) net.IP {
|
func preferredIP(ips []interfaceIP) net.IP {
|
||||||
|
ips = slices.DeleteFunc(ips, func(ip interfaceIP) bool {
|
||||||
|
return len(ip.Name) == 15 && strings.HasPrefix(ip.Name, "br-") && strings.Trim(ip.Name[3:], "0123456789abcdef") == "" // a Docker network's bridge, removed with the network
|
||||||
|
})
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sort.Slice(ips, func(i, j int) bool {
|
sort.Slice(ips, func(i, j int) bool {
|
||||||
first, second := ips[i], ips[j]
|
first, second := ips[i], ips[j]
|
||||||
|
|
||||||
// ethernet is preferred
|
// IPv4 is preferred, as job networks are IPv4-only by default
|
||||||
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4 is preferred
|
// ethernet is preferred
|
||||||
if vi, vj := first.To4() != nil, second.To4() != nil; vi != vj {
|
if vi, vj := strings.HasPrefix(first.Name, "e"), strings.HasPrefix(second.Name, "e"); vi != vj {
|
||||||
return vi
|
return vi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import (
|
|||||||
func TestPreferredIP(t *testing.T) {
|
func TestPreferredIP(t *testing.T) {
|
||||||
assert.Nil(t, preferredIP(nil))
|
assert.Nil(t, preferredIP(nil))
|
||||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{{IP: net.ParseIP("2001:db8::1")}}))
|
||||||
assert.Equal(t, net.ParseIP("2001:db8::1"), preferredIP([]interfaceIP{
|
assert.Equal(t, net.ParseIP("192.0.2.1"), preferredIP([]interfaceIP{
|
||||||
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
{IP: net.ParseIP("192.0.2.1"), Name: "wlan0"},
|
||||||
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||||
|
{IP: net.ParseIP("172.18.0.1"), Name: "br-0123456789ab"},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
|
||||||
@@ -23,6 +24,9 @@ import (
|
|||||||
|
|
||||||
var hostOS = runtime.GOOS // replaced in tests
|
var hostOS = runtime.GOOS // replaced in tests
|
||||||
|
|
||||||
|
// DrainGracePeriod caps the wait for container output to drain so the log tail is not truncated
|
||||||
|
const DrainGracePeriod = 2 * time.Second
|
||||||
|
|
||||||
// ExitCodeError reports a non-zero process exit code from a container command.
|
// ExitCodeError reports a non-zero process exit code from a container command.
|
||||||
type ExitCodeError int
|
type ExitCodeError int
|
||||||
|
|
||||||
|
|||||||
+11
-13
@@ -41,13 +41,6 @@ import (
|
|||||||
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
specs "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drainGracePeriod bounds how long we wait for an output-copy goroutine to
|
|
||||||
// finish draining a container's output before returning, so that neither a
|
|
||||||
// cancellation (waitForCommand) nor a normal container exit (wait) truncates
|
|
||||||
// the tail of the log. It is a safety bound: in the common case the stream
|
|
||||||
// reaches EOF and the goroutine returns well before this elapses.
|
|
||||||
const drainGracePeriod = 2 * time.Second
|
|
||||||
|
|
||||||
// NewContainer creates a reference to a container
|
// NewContainer creates a reference to a container
|
||||||
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
func NewContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||||
// Resolved up front because the image pull runs before the container is created.
|
// Resolved up front because the image pull runs before the container is created.
|
||||||
@@ -861,6 +854,7 @@ func (cr *containerReference) tryReadID(opt string, cbk func(id int)) common.Exe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
defer resp.Close()
|
defer resp.Close()
|
||||||
|
defer context.AfterFunc(ctx, resp.Close)()
|
||||||
|
|
||||||
sid, err := resp.Reader.ReadString('\n')
|
sid, err := resp.Reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -906,7 +900,7 @@ func (cr *containerReference) waitForCommand(ctx context.Context, resp client.Hi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
logger.Debugf("Failed to drain command output after cancellation: %v", err)
|
||||||
}
|
}
|
||||||
case <-time.After(drainGracePeriod):
|
case <-time.After(DrainGracePeriod):
|
||||||
logger.Warn("Timed out draining command output after cancellation")
|
logger.Warn("Timed out draining command output after cancellation")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,10 +932,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
logger.Error(err)
|
logger.Error(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
extractionRoot := "/"
|
extractionRoot := cr.extractionRoot(dstPath)
|
||||||
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, cr.markExecutable(ctx)); err != nil {
|
if err := writeDirTar(ctx, tarFile, path.Join("/", strings.TrimPrefix(dstPath, extractionRoot)), srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID, cr.markExecutable(ctx)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -954,6 +945,13 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *containerReference) extractionRoot(dstPath string) string {
|
||||||
|
if actPath := cr.GetActPath(); strings.HasPrefix(dstPath+"/", actPath+"/") {
|
||||||
|
return actPath // keeps tar entries from crossing the image's /var/run symlink
|
||||||
|
}
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|
||||||
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
if cr.id == "" {
|
if cr.id == "" {
|
||||||
@@ -1046,7 +1044,7 @@ func (cr *containerReference) wait() common.Executor {
|
|||||||
if cr.attachDone != nil {
|
if cr.attachDone != nil {
|
||||||
select {
|
select {
|
||||||
case <-cr.attachDone:
|
case <-cr.attachDone:
|
||||||
case <-time.After(drainGracePeriod):
|
case <-time.After(DrainGracePeriod):
|
||||||
logger.Warn("Timed out draining container output")
|
logger.Warn("Timed out draining container output")
|
||||||
}
|
}
|
||||||
cr.attachDone = nil
|
cr.attachDone = nil
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, id string, optio
|
|||||||
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
return args.Get(0).(mobyclient.CopyToContainerResult), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDockerClient) ContainerStatPath(ctx context.Context, id string, options mobyclient.ContainerStatPathOptions) (mobyclient.ContainerStatPathResult, error) {
|
||||||
|
args := m.Called(ctx, id, options)
|
||||||
|
return args.Get(0).(mobyclient.ContainerStatPathResult), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
func (m *mockDockerClient) ContainerInspect(ctx context.Context, id string, opts mobyclient.ContainerInspectOptions) (mobyclient.ContainerInspectResult, error) {
|
||||||
args := m.Called(ctx, id, opts)
|
args := m.Called(ctx, id, opts)
|
||||||
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
return args.Get(0).(mobyclient.ContainerInspectResult), args.Error(1)
|
||||||
@@ -351,6 +356,21 @@ func TestDockerAttachStreamClosesWithItsContext(t *testing.T) {
|
|||||||
client.AssertExpectations(t)
|
client.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerReadIDStreamClosesWithItsContext(t *testing.T) {
|
||||||
|
conn, peer := net.Pipe()
|
||||||
|
t.Cleanup(func() { _ = peer.Close() })
|
||||||
|
client := &mockDockerClient{}
|
||||||
|
client.On("ExecCreate", mock.Anything, "123", mock.AnythingOfType("client.ExecCreateOptions")).
|
||||||
|
Return(mobyclient.ExecCreateResult{ID: "id"}, nil)
|
||||||
|
client.On("ExecAttach", mock.Anything, "id", mobyclient.ExecAttachOptions{}).
|
||||||
|
Return(mobyclient.ExecAttachResult{HijackedResponse: mobyclient.NewHijackedResponse(conn, "")}, nil)
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
require.NoError(t, (&containerReference{id: "123", cli: client}).tryReadID("-u", func(int) { t.Error("read an ID after cancellation") })(ctx))
|
||||||
|
client.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerWaitFailure(t *testing.T) {
|
func TestDockerWaitFailure(t *testing.T) {
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
statusCh := make(chan container.WaitResponse, 1)
|
statusCh := make(chan container.WaitResponse, 1)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"bytes"
|
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -117,17 +116,24 @@ func (e *HostEnvironment) Inspect(_ context.Context) (*Info, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||||
buf := &bytes.Buffer{}
|
|
||||||
tw := tar.NewWriter(buf)
|
|
||||||
defer tw.Close()
|
|
||||||
srcPath = filepath.Clean(srcPath)
|
srcPath = filepath.Clean(srcPath)
|
||||||
fi, err := os.Lstat(srcPath)
|
fi, err := os.Lstat(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tc := &filecollector.TarCollector{
|
reader, writer := io.Pipe()
|
||||||
TarWriter: tw,
|
go func() {
|
||||||
}
|
tw := tar.NewWriter(writer)
|
||||||
|
err := writeHostArchive(ctx, &filecollector.TarCollector{TarWriter: tw}, srcPath, fi)
|
||||||
|
if err == nil {
|
||||||
|
err = tw.Close()
|
||||||
|
}
|
||||||
|
writer.CloseWithError(err)
|
||||||
|
}()
|
||||||
|
return startedArchive(reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHostArchive(ctx context.Context, tc *filecollector.TarCollector, srcPath string, fi fs.FileInfo) error {
|
||||||
if fi.IsDir() {
|
if fi.IsDir() {
|
||||||
srcPrefix := srcPath
|
srcPrefix := srcPath
|
||||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||||
@@ -138,31 +144,22 @@ func (e *HostEnvironment) GetContainerArchive(ctx context.Context, srcPath strin
|
|||||||
SrcPrefix: srcPrefix,
|
SrcPrefix: srcPrefix,
|
||||||
Handler: tc,
|
Handler: tc,
|
||||||
}
|
}
|
||||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
return filepath.Walk(srcPath, fc.CollectFiles(ctx))
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
var f io.ReadCloser
|
||||||
|
var linkname string
|
||||||
|
var err error
|
||||||
|
if fi.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if linkname, err = os.Readlink(srcPath); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var f io.ReadCloser
|
if f, err = os.Open(srcPath); err != nil {
|
||||||
var linkname string
|
return err
|
||||||
if fi.Mode()&fs.ModeSymlink != 0 {
|
|
||||||
linkname, err = os.Readlink(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
f, err = os.Open(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
}
|
|
||||||
err := tc.WriteFile(fi.Name(), fi, linkname, f)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
defer f.Close()
|
||||||
}
|
}
|
||||||
return io.NopCloser(buf), nil
|
return tc.WriteFile(fi.Name(), fi, linkname, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
func (e *HostEnvironment) Pull(_ bool) common.Executor {
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ func TestGetContainerArchive(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedContent, content)
|
assert.Equal(t, expectedContent, content)
|
||||||
_, err = reader.Next()
|
_, err = reader.Next()
|
||||||
assert.ErrorIs(t, err, io.EOF)
|
require.ErrorIs(t, err, io.EOF)
|
||||||
|
require.NoError(t, archive.Close())
|
||||||
|
|
||||||
|
_, err = e.GetContainerArchive(ctx, filepath.Join(e.Path, "missing"))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
func TestHostEnvironmentExecExitCode(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package container
|
package container
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
@@ -573,15 +572,7 @@ func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath s
|
|||||||
}
|
}
|
||||||
writer.CloseWithError(err)
|
writer.CloseWithError(err)
|
||||||
}()
|
}()
|
||||||
buffered := bufio.NewReader(reader)
|
return startedArchive(reader)
|
||||||
if _, err := buffered.Peek(1); err != nil {
|
|
||||||
reader.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return struct {
|
|
||||||
io.Reader
|
|
||||||
io.Closer
|
|
||||||
}{buffered, reader}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package container
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,6 +16,19 @@ import (
|
|||||||
"gitea.com/gitea/runner/act/filecollector"
|
"gitea.com/gitea/runner/act/filecollector"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// startedArchive returns reader once its first byte arrives, so failing to start archiving errors here, not on read.
|
||||||
|
func startedArchive(reader *io.PipeReader) (io.ReadCloser, error) {
|
||||||
|
buffered := bufio.NewReader(reader)
|
||||||
|
if _, err := buffered.Peek(1); err != nil {
|
||||||
|
reader.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return struct {
|
||||||
|
io.Reader
|
||||||
|
io.Closer
|
||||||
|
}{buffered, reader}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||||
tw := tar.NewWriter(w)
|
tw := tar.NewWriter(w)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
|||||||
|
|
||||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||||
|
|
||||||
binds, mounts := rc.GetBindsAndMounts(ctx)
|
binds, mounts, _ := rc.GetBindsAndMounts(ctx)
|
||||||
networkMode := "container:" + rc.jobContainerName()
|
networkMode := "container:" + rc.jobContainerName()
|
||||||
if rc.IsHostEnv() {
|
if rc.IsHostEnv() {
|
||||||
networkMode = "default"
|
networkMode = "default"
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
|
|||||||
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
rawLogger.Infof("%s", formatAnnotation(command, kvPairs, arg))
|
||||||
return false
|
return false
|
||||||
case "group", "endgroup":
|
case "group", "endgroup":
|
||||||
return true
|
if strings.HasPrefix(line, "::"+command+"::") || strings.HasPrefix(line, "##["+command+"]") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rawLogger.Infof("::%s::%s", command, EscapeCommandData(arg))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return echoed
|
return echoed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,10 +240,23 @@ func TestCommandLogWriter(t *testing.T) {
|
|||||||
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
|
"::notice::title: m", "::error::x: m", "::error::x:1: m", "::notice::std::vector", "::error::x;y: m]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "groups normalize unsupported prefixes and preserve recognized prefixes",
|
||||||
|
steps: [][]string{{
|
||||||
|
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
|
||||||
|
"::GrOuP::a%25b%0Ac", "::EnDgRoUp::", " \t::group::indented", " \t::endgroup::",
|
||||||
|
"prefix ##[GrOuP]a%3Bb%5Dc%253B%0A", "prefix ##[EnDgRoUp]",
|
||||||
|
}},
|
||||||
|
log: []string{
|
||||||
|
"::group::a%3Bb%5Dc%250A", "::endgroup::", "##[group]a%3Bb%5Dc%250A", "##[endgroup]",
|
||||||
|
"::group::a%25b%0Ac", "::endgroup::", "::group::indented", "::endgroup::",
|
||||||
|
"::group::a;b]c%253B%0A", "::endgroup::",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "stop-commands keeps lines verbatim until resumed",
|
name: "stop-commands keeps lines verbatim until resumed",
|
||||||
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::", "::add-mask::masked"}},
|
steps: [][]string{{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::", "::add-mask::masked"}},
|
||||||
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", "::RESUME-TOKEN::"},
|
log: []string{"::stop-commands::resume-token", "::add-mask::kept", "::set-output name=a::b", " \t::GrOuP::kept%3B", "prefix ##[EnDgRoUp]", "::RESUME-TOKEN::"},
|
||||||
masks: []string{"resume-token", "masked"},
|
masks: []string{"resume-token", "masked"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,3 +82,8 @@ func (cm *containerMock) Inspect(ctx context.Context) (*container.Info, error) {
|
|||||||
err, _ := args.Get(1).(error)
|
err, _ := args.Get(1).(error)
|
||||||
return info, err
|
return info, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cm *containerMock) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||||
|
args := cm.Called(stdout, stderr)
|
||||||
|
return args.Get(0).(io.Writer), args.Get(1).(io.Writer)
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ func getHashFilesFunction(ctx context.Context, rc *RunContext) func(v []reflect.
|
|||||||
followSymlink := false
|
followSymlink := false
|
||||||
|
|
||||||
for i, p := range v {
|
for i, p := range v {
|
||||||
s := p.String()
|
s := exprparser.CoerceToString(p)
|
||||||
if i == 0 && strings.HasPrefix(s, "--") {
|
if i == 0 && strings.HasPrefix(s, "--") {
|
||||||
if strings.EqualFold(s, "--follow-symbolic-links") {
|
if strings.EqualFold(s, "--follow-symbolic-links") {
|
||||||
followSymlink = true
|
followSymlink = true
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func (c *hashFilesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHashFilesFunction(t *testing.T) {
|
func TestGetHashFilesFunction(t *testing.T) {
|
||||||
const failureMessage = "hashFiles('dir''file, *.sum') failed. Fail to hash files under directory '/workspace'"
|
const failureMessage = "hashFiles('dir''file, *.sum, 123, true, ') failed. Fail to hash files under directory '/workspace'"
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
output string
|
output string
|
||||||
@@ -192,7 +192,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
|||||||
{name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
|
{name: "success", output: "patterns __OUTPUT__wrong__OUTPUT__ echoed\n__OUTPUT__digest__OUTPUT__", hash: "digest"},
|
||||||
{name: "no matches", output: "__OUTPUT____OUTPUT__"},
|
{name: "no matches", output: "__OUTPUT____OUTPUT__"},
|
||||||
{name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage},
|
{name: "script failure", output: "Cannot find: node in PATH\n__OUTPUT__digest__OUTPUT__", execErr: container.ExitCodeError(1), err: failureMessage},
|
||||||
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum') couldn't finish within 120 seconds"},
|
{name: "timeout", execErr: context.DeadlineExceeded, err: "hashFiles('dir''file, *.sum, 123, true, ') couldn't finish within 120 seconds"},
|
||||||
{name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()},
|
{name: "job deadline", execErr: context.DeadlineExceeded, expired: true, err: context.DeadlineExceeded.Error()},
|
||||||
{name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
|
{name: "node missing", execErr: fmt.Errorf("wrapped: %w", container.ExitCodeError(127)), warning: true},
|
||||||
{name: "node not executable", execErr: container.ExitCodeError(126), warning: true},
|
{name: "node not executable", execErr: container.ExitCodeError(126), warning: true},
|
||||||
@@ -205,7 +205,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
|||||||
jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
|
jobContainer := &hashFilesContainer{stdout: stdout, stderr: stderr, copyErr: test.copyErr}
|
||||||
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
|
jobContainer.execute = func(command []string, env map[string]string, user, workdir string) common.Executor {
|
||||||
assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command)
|
assert.Equal(t, []string{"node", "/var/run/act/workflow/hashfiles/index.js"}, command)
|
||||||
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum", "followSymbolicLinks": "true"}, env)
|
assert.Equal(t, map[string]string{"patterns": "dir'file\n*.sum\n123\ntrue\n", "followSymbolicLinks": "true"}, env)
|
||||||
assert.Empty(t, user)
|
assert.Empty(t, user)
|
||||||
assert.Empty(t, workdir)
|
assert.Empty(t, workdir)
|
||||||
return func(_ctx context.Context) error {
|
return func(_ctx context.Context) error {
|
||||||
@@ -223,7 +223,7 @@ func TestGetHashFilesFunction(t *testing.T) {
|
|||||||
result, err := getHashFilesFunction(ctx, &RunContext{
|
result, err := getHashFilesFunction(ctx, &RunContext{
|
||||||
Config: &Config{Workdir: "/workspace"},
|
Config: &Config{Workdir: "/workspace"},
|
||||||
JobContainer: jobContainer,
|
JobContainer: jobContainer,
|
||||||
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum")})
|
})([]reflect.Value{reflect.ValueOf("--follow-symbolic-links"), reflect.ValueOf("dir'file"), reflect.ValueOf("*.sum"), reflect.ValueOf(float64(123)), reflect.ValueOf(true), {}})
|
||||||
if test.err == "" {
|
if test.err == "" {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
"gitea.com/gitea/runner/act/common"
|
||||||
|
"gitea.com/gitea/runner/act/common/logging"
|
||||||
"gitea.com/gitea/runner/act/container"
|
"gitea.com/gitea/runner/act/container"
|
||||||
"gitea.com/gitea/runner/act/ghcontext"
|
"gitea.com/gitea/runner/act/ghcontext"
|
||||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||||
@@ -75,6 +76,7 @@ type RunContext struct {
|
|||||||
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
actionInputs map[string]any // inputs of the composite action this runs, nil for a job
|
||||||
Masks []string
|
Masks []string
|
||||||
cleanUpJobContainer common.Executor
|
cleanUpJobContainer common.Executor
|
||||||
|
taskWorkspace string
|
||||||
deferVolumeCleanup func(common.Executor)
|
deferVolumeCleanup func(common.Executor)
|
||||||
caller *caller // job calling this RunContext (reusable workflows)
|
caller *caller // job calling this RunContext (reusable workflows)
|
||||||
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
workflowCallInputs map[string]any // the caller's with:, resolved once by resolveWorkflowCall
|
||||||
@@ -271,7 +273,7 @@ func (rc *RunContext) validVolumes() []string {
|
|||||||
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
||||||
}
|
}
|
||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
volumes = append(volumes, sharedToolCacheVolume)
|
volumes = append(volumes, rc.toolCacheVolume())
|
||||||
}
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
volumes = append(volumes, rc.Config.Workdir, rc.workdirMountRoot())
|
||||||
@@ -331,6 +333,13 @@ func (rc *RunContext) startDockerProxy(ctx context.Context) {
|
|||||||
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
rc.dockerProxy = container.NewDockerProxy(ctx, rc.jobContainerName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rc *RunContext) toolCacheVolume() string {
|
||||||
|
if rc.Config.ToolCacheSlot == "" {
|
||||||
|
return sharedToolCacheVolume
|
||||||
|
}
|
||||||
|
return sharedToolCacheVolume + "-" + rc.Config.ToolCacheSlot
|
||||||
|
}
|
||||||
|
|
||||||
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
// toolCache returns the tool cache path the job sees, relocatable through RUNNER_TOOL_CACHE.
|
||||||
func (rc *RunContext) toolCache(fallback string) string {
|
func (rc *RunContext) toolCache(fallback string) string {
|
||||||
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
|
return cmp.Or(rc.GetEnv()["RUNNER_TOOL_CACHE"], fallback)
|
||||||
@@ -378,8 +387,8 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
|||||||
return binds, mounts, targets
|
return binds, mounts, targets
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
// Returns the container's binds, mounts and bound task workspace, resolving paths as appropriate
|
||||||
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
|
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string, string) {
|
||||||
name := rc.jobContainerName()
|
name := rc.jobContainerName()
|
||||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||||
|
|
||||||
@@ -391,7 +400,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
}
|
}
|
||||||
if rc.Config.SharedToolCache {
|
if rc.Config.SharedToolCache {
|
||||||
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
if toolCache := rc.toolCache(container.DefaultToolCache); !claimed[toolCache] {
|
||||||
mounts[sharedToolCacheVolume] = toolCache
|
mounts[rc.toolCacheVolume()] = toolCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||||
@@ -399,12 +408,16 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
}
|
}
|
||||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||||
|
|
||||||
|
taskWorkspace := ""
|
||||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||||
source := rc.workdirMountRoot()
|
source := rc.workdirMountRoot()
|
||||||
target := ext.ToContainerPath(source)
|
target := ext.ToContainerPath(source)
|
||||||
if claimed[target] {
|
if claimed[target] {
|
||||||
source, target = rc.Config.Workdir, workdir
|
source, target = rc.Config.Workdir, workdir
|
||||||
}
|
}
|
||||||
|
if rc.Config.BindWorkdir && rc.Config.PresetGitHubContext != nil {
|
||||||
|
taskWorkspace = target
|
||||||
|
}
|
||||||
if rc.Config.BindWorkdir {
|
if rc.Config.BindWorkdir {
|
||||||
bindModifiers := ""
|
bindModifiers := ""
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
@@ -419,7 +432,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return binds, mounts
|
return binds, mounts, taskWorkspace
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rc *RunContext) startHostEnvironment() common.Executor {
|
func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||||
@@ -447,6 +460,10 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
|||||||
toolCacheParent = cacheDir
|
toolCacheParent = cacheDir
|
||||||
}
|
}
|
||||||
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
toolCache := rc.toolCache(filepath.Join(toolCacheParent, "tool_cache"))
|
||||||
|
if rc.Config.ToolCacheSlot != "" {
|
||||||
|
toolCache = filepath.Join(toolCache, rc.Config.ToolCacheSlot)
|
||||||
|
rc.Env["RUNNER_TOOL_CACHE"] = toolCache // overrides a RUNNER_TOOL_CACHE the slots would otherwise share
|
||||||
|
}
|
||||||
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
if err := os.MkdirAll(toolCache, 0o777); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -672,7 +689,8 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rc.startDockerProxy(ctx)
|
rc.startDockerProxy(ctx)
|
||||||
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
|
var taskWorkspace string
|
||||||
|
containerInput.Binds, containerInput.Mounts, taskWorkspace = rc.GetBindsAndMounts(ctx)
|
||||||
containerInput.ValidVolumes = rc.validVolumes()
|
containerInput.ValidVolumes = rc.validVolumes()
|
||||||
|
|
||||||
rc.jobNetworkName = networkName
|
rc.jobNetworkName = networkName
|
||||||
@@ -687,6 +705,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
|||||||
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
func(ctx context.Context) error { return rc.createJobVolumes(ctx, containerInput.Mounts) },
|
||||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||||
rc.JobContainer.Start(false),
|
rc.JobContainer.Start(false),
|
||||||
|
func(context.Context) error { rc.taskWorkspace = taskWorkspace; return nil },
|
||||||
rc.captureJobContainerInfo(),
|
rc.captureJobContainerInfo(),
|
||||||
rc.copyWorkflowFiles(),
|
rc.copyWorkflowFiles(),
|
||||||
rc.waitForServiceContainers(),
|
rc.waitForServiceContainers(),
|
||||||
@@ -801,7 +820,10 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
errs := []error{rc.closeDockerProxy(ctx)}
|
errs := []error{rc.closeDockerProxy(ctx)}
|
||||||
var containers []common.Executor
|
var containers []common.Executor
|
||||||
if rc.JobContainer != nil {
|
if rc.JobContainer != nil {
|
||||||
containers = append(containers, rc.JobContainer.Remove())
|
containers = append(containers, func(ctx context.Context) error {
|
||||||
|
rc.emptyTaskWorkspace(ctx)
|
||||||
|
return rc.JobContainer.Remove()(ctx)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(rc.serviceContainers) > 0 {
|
if len(rc.serviceContainers) > 0 {
|
||||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||||
@@ -831,6 +853,42 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exits 3 unless workspace $1 is safe to empty per mountinfo $2
|
||||||
|
const emptyTaskWorkspaceScript = `case "$1" in /?*/?*) ;; *) exit 3 ;; esac
|
||||||
|
mounted=
|
||||||
|
while read -r _ _ _ _ point _; do
|
||||||
|
case "$point" in "$1") mounted=1 ;; "$1"/*) exit 3 ;; esac
|
||||||
|
done <"$2"
|
||||||
|
[ "$mounted" ] || exit 3
|
||||||
|
rm -rf "$1"/* "$1"/.[!.]* "$1"/..?*`
|
||||||
|
|
||||||
|
var runnerCanRemoveJobFiles = runtime.GOOS != "linux" || os.Geteuid() == 0 // root, or a desktop VM mapping file ownership, replaced in tests
|
||||||
|
|
||||||
|
// runs as root with root-owned PATH dirs only, skipped for called workflows sharing the workspace
|
||||||
|
func (rc *RunContext) emptyTaskWorkspace(ctx context.Context) {
|
||||||
|
if rc.taskWorkspace == "" || rc.caller != nil || runnerCanRemoveJobFiles {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
budget := 30 * time.Second
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
budget = min(budget, time.Until(deadline)/2-container.DrainGracePeriod)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, budget)
|
||||||
|
defer cancel()
|
||||||
|
stdout, stderr := rc.JobContainer.ReplaceLogWriter(io.Discard, io.Discard)
|
||||||
|
defer rc.JobContainer.ReplaceLogWriter(stdout, stderr)
|
||||||
|
err := rc.JobContainer.Exec(
|
||||||
|
[]string{"sh", "-c", emptyTaskWorkspaceScript, "sh", rc.taskWorkspace, "/proc/self/mountinfo"},
|
||||||
|
map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/",
|
||||||
|
)(common.WithLogger(ctx, logging.New(logging.DiscardHandler)))
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, container.ExitCodeError(3)):
|
||||||
|
logging.Infof("not emptying workspace %s of %s: under two levels deep, not a mountpoint as given, or a mount below it", rc.taskWorkspace, rc.jobContainerName())
|
||||||
|
case err != nil:
|
||||||
|
logging.Infof("could not empty workspace %s of %s: %v", rc.taskWorkspace, rc.jobContainerName(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runAll(ctx context.Context, executors []common.Executor) error {
|
func runAll(ctx context.Context, executors []common.Executor) error {
|
||||||
errs := make([]error, len(executors))
|
errs := make([]error, len(executors))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -455,7 +456,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
|
t.Run(testcase.name+testBindSuffix, func(t *testing.T) {
|
||||||
rctemplate.Config.Workdir = testcase.name
|
rctemplate.Config.Workdir = testcase.name
|
||||||
rctemplate.Config.BindWorkdir = bindWorkDir
|
rctemplate.Config.BindWorkdir = bindWorkDir
|
||||||
gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
|
gotbind, gotmount, _ := rctemplate.GetBindsAndMounts(t.Context())
|
||||||
|
|
||||||
if bindWorkDir {
|
if bindWorkDir {
|
||||||
fullBind := testcase.name + ":" + testcase.want
|
fullBind := testcase.name + ":" + testcase.want
|
||||||
@@ -507,7 +508,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
rc.ExprEval = rc.NewExpressionEvaluator(t.Context())
|
||||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||||
@@ -522,7 +523,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
rc.containerSpec.Volumes = testcase.volumes
|
rc.containerSpec.Volumes = testcase.volumes
|
||||||
rc.Run.JobID = "job1"
|
rc.Run.JobID = "job1"
|
||||||
|
|
||||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
gotbind, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
|
|
||||||
if len(testcase.wantbind) > 0 {
|
if len(testcase.wantbind) > 0 {
|
||||||
assert.Contains(t, gotbind, testcase.wantbind)
|
assert.Contains(t, gotbind, testcase.wantbind)
|
||||||
@@ -551,13 +552,19 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
t.Run("ToolCacheMount", func(t *testing.T) {
|
t.Run("ToolCacheMount", func(t *testing.T) {
|
||||||
rc := newRunContext(&Config{})
|
rc := newRunContext(&Config{})
|
||||||
|
|
||||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
_, gotmount, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
|
||||||
rc.Config.SharedToolCache = true
|
rc.Config.SharedToolCache = true
|
||||||
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
||||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||||
|
|
||||||
|
rc.Config.ToolCacheSlot = "1"
|
||||||
|
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume+"-1"])
|
||||||
|
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||||
|
assert.Contains(t, rc.validVolumes(), sharedToolCacheVolume+"-1")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
|
t.Run("DaemonMountsAboveOwnerRepo", func(t *testing.T) {
|
||||||
@@ -569,16 +576,26 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
|||||||
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
|
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
gotbind, _ := rc.GetBindsAndMounts(t.Context())
|
gotbind, _, workspace := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
||||||
|
assert.Equal(t, "/workspace/1", workspace)
|
||||||
|
|
||||||
rc.Config.BindWorkdir = false
|
rc.Config.BindWorkdir = false
|
||||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
_, gotmount, workspace := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||||
|
assert.Empty(t, workspace)
|
||||||
|
|
||||||
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
||||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
_, gotmount, _ = rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||||
|
rc.Config.BindWorkdir = true
|
||||||
|
_, _, workspace = rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Equal(t, "/workspace/1/owner/repo", workspace)
|
||||||
|
|
||||||
|
rc.containerSpec.Volumes = nil
|
||||||
|
rc.Config.PresetGitHubContext = nil
|
||||||
|
_, _, workspace = rc.GetBindsAndMounts(t.Context())
|
||||||
|
assert.Empty(t, workspace)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,7 +628,7 @@ func TestRunContextValidVolumes(t *testing.T) {
|
|||||||
rc.JobContainer = hostEnv
|
rc.JobContainer = hostEnv
|
||||||
rc.Config.BindWorkdir = false
|
rc.Config.BindWorkdir = false
|
||||||
rc.Config.SharedToolCache = true
|
rc.Config.SharedToolCache = true
|
||||||
binds, mounts := rc.GetBindsAndMounts(t.Context())
|
binds, mounts, _ := rc.GetBindsAndMounts(t.Context())
|
||||||
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||||
assert.Contains(t, mounts, sharedToolCacheVolume)
|
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||||
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
||||||
@@ -683,6 +700,110 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanupJobResourcesEmptiesTaskWorkspace(t *testing.T) {
|
||||||
|
defer func(previous bool) { runnerCanRemoveJobFiles = previous }(runnerCanRemoveJobFiles)
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name, workspace string
|
||||||
|
caller *caller
|
||||||
|
runnerCanRemove bool
|
||||||
|
execErr error
|
||||||
|
log string
|
||||||
|
}{
|
||||||
|
{name: "refusal is logged", workspace: "/workspace/1", execErr: container.ExitCodeError(3), log: "not emptying workspace /workspace/1"},
|
||||||
|
{name: "failure does not fail the job", workspace: "/workspace/1", execErr: errors.New("no rm"), log: "could not empty workspace /workspace/1"},
|
||||||
|
{name: "no runner bind"},
|
||||||
|
{name: "called workflow shares it", workspace: "/workspace/1", caller: &caller{}},
|
||||||
|
{name: "runner removes it itself", workspace: "/workspace/1", runnerCanRemove: true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
runnerCanRemoveJobFiles = tt.runnerCanRemove
|
||||||
|
processLog := logtest.SetDefault(t)
|
||||||
|
var jobLog bytes.Buffer
|
||||||
|
var calls []string
|
||||||
|
job := &containerMock{}
|
||||||
|
if tt.log != "" {
|
||||||
|
job.On("ReplaceLogWriter", io.Discard, io.Discard).Return(&jobLog, &jobLog).Once()
|
||||||
|
job.On("ReplaceLogWriter", &jobLog, &jobLog).Return(io.Discard, io.Discard).Once()
|
||||||
|
job.On("Exec", mock.MatchedBy(func(command []string) bool { return command[4] == tt.workspace }), map[string]string{"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}, "0", "/").Return(func(ctx context.Context) error {
|
||||||
|
calls = append(calls, "exec")
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.LessOrEqual(t, time.Until(deadline), 10*time.Second-container.DrainGracePeriod)
|
||||||
|
common.Logger(ctx).Infof("workspace script output")
|
||||||
|
return tt.execErr
|
||||||
|
})
|
||||||
|
}
|
||||||
|
job.On("Remove").Return(func(context.Context) error {
|
||||||
|
calls = append(calls, "remove")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{},
|
||||||
|
Run: &model.Run{Workflow: &model.Workflow{Name: "workflow"}, JobID: "job"},
|
||||||
|
JobContainer: job,
|
||||||
|
taskWorkspace: tt.workspace,
|
||||||
|
caller: tt.caller,
|
||||||
|
deferVolumeCleanup: func(common.Executor) {},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
ctx = common.WithLogger(ctx, logging.New(logging.NewLevelHandler(newJobLogHandler(&jobLog, cyan), logging.LevelInfo)))
|
||||||
|
require.NoError(t, rc.cleanupJobResources("", false, false)(ctx))
|
||||||
|
job.AssertExpectations(t)
|
||||||
|
assert.NotContains(t, jobLog.String(), "workspace")
|
||||||
|
if tt.log == "" {
|
||||||
|
assert.Equal(t, []string{"remove"}, calls)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Equal(t, []string{"exec", "remove"}, calls)
|
||||||
|
assert.True(t, slices.ContainsFunc(processLog.AllRecords(), func(record logging.Record) bool {
|
||||||
|
return strings.HasPrefix(record.Message, tt.log)
|
||||||
|
}), processLog.AllRecords())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyTaskWorkspaceScript(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the script runs in the linux job container")
|
||||||
|
}
|
||||||
|
run := func(t *testing.T, workspace string, mountpoints ...string) error {
|
||||||
|
t.Helper()
|
||||||
|
var mountinfo strings.Builder
|
||||||
|
for _, mountpoint := range mountpoints {
|
||||||
|
fmt.Fprintf(&mountinfo, "36 35 98:0 /src %s rw - ext4 /dev/sda1 rw\n", mountpoint)
|
||||||
|
}
|
||||||
|
info := filepath.Join(t.TempDir(), "mountinfo")
|
||||||
|
require.NoError(t, os.WriteFile(info, []byte(mountinfo.String()), 0o600))
|
||||||
|
return exec.Command("sh", "-c", emptyTaskWorkspaceScript, "sh", workspace, info).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := t.TempDir()
|
||||||
|
for _, file := range []string{"owner/repo/file", ".hidden", "..odd"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(workspace, file)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(workspace, file), nil, 0o600))
|
||||||
|
}
|
||||||
|
require.NoError(t, run(t, workspace, workspace))
|
||||||
|
entries, err := os.ReadDir(workspace)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name, workspace string
|
||||||
|
mountpoints []string
|
||||||
|
}{
|
||||||
|
{"a mount below it", "/nonexistent/1", []string{"/nonexistent/1", "/nonexistent/1/owner/repo/cache"}},
|
||||||
|
{"a workspace that is not a mountpoint", "/nonexistent/1", []string{"/nonexistent/2"}},
|
||||||
|
{"a top-level directory", "/nonexistent", []string{"/nonexistent"}},
|
||||||
|
} {
|
||||||
|
t.Run("refuses "+tt.name, func(t *testing.T) {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
require.ErrorAs(t, run(t, tt.workspace, tt.mountpoints...), &exitErr)
|
||||||
|
assert.Equal(t, 3, exitErr.ExitCode())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||||
var removed []string
|
var removed []string
|
||||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
@@ -1181,6 +1302,26 @@ func TestPrintStartJobContainerGroupGolden(t *testing.T) {
|
|||||||
assert.Equal(t, want, buf.String())
|
assert.Equal(t, want, buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartHostEnvironmentToolCacheSlotIsSubdirOfSharedCache(t *testing.T) {
|
||||||
|
cacheDir := t.TempDir()
|
||||||
|
override := filepath.Join(cacheDir, "override")
|
||||||
|
for sharedCache, env := range map[string]map[string]string{
|
||||||
|
filepath.Join(cacheDir, "tool_cache"): {},
|
||||||
|
override: {"RUNNER_TOOL_CACHE": override},
|
||||||
|
} {
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{ActionCacheDir: cacheDir, SharedToolCache: true, ToolCacheSlot: "1"},
|
||||||
|
Env: env,
|
||||||
|
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}},
|
||||||
|
}
|
||||||
|
require.NoError(t, rc.startHostEnvironment()(t.Context()))
|
||||||
|
t.Cleanup(func() { require.NoError(t, rc.cleanUpJobContainer(context.Background())) })
|
||||||
|
want := filepath.Join(sharedCache, "1")
|
||||||
|
assert.Equal(t, want, rc.JobContainer.(*container.HostEnvironment).ToolCache)
|
||||||
|
assert.Equal(t, want, rc.Env["RUNNER_TOOL_CACHE"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunContext_cleanupFailedStart(t *testing.T) {
|
func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||||
type ctxKey string
|
type ctxKey string
|
||||||
const sentinel = ctxKey("sentinel")
|
const sentinel = ctxKey("sentinel")
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ type Config struct {
|
|||||||
JobLoggerLevel *logging.Level // the level of job logger
|
JobLoggerLevel *logging.Level // the level of job logger
|
||||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
ContainerVolumes []string // mounted into every job, service and docker action container
|
ContainerVolumes []string // mounted into every job, service and docker action container
|
||||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
SharedToolCache bool // keeps the tool cache across jobs instead of one per job
|
||||||
|
ToolCacheSlot string // keeps concurrent tasks' tool caches apart, empty for slot 0 and in exec
|
||||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||||
CAFile string // PEM CA certificates the Gitea instance's certificate chains to
|
CAFile string // PEM CA certificates the Gitea instance's certificate chains to
|
||||||
|
|||||||
@@ -455,7 +455,6 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
|||||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||||
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
||||||
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
{workdir, "local-action-dockerfile", "push", "", platforms, secrets},
|
||||||
{workdir, "builtin-checkout", "push", "", platforms, secrets},
|
|
||||||
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
{workdir, "inputs-via-env-context", "push", "", platforms, secrets},
|
||||||
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
{workdir, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||||
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
{workdir, "set-env-step-env-override", "push", "", platforms, secrets},
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package runner
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action/checkout"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
var builtinActions = map[string]func(context.Context, *action.Context) error{
|
|
||||||
"checkout": checkout.Main,
|
|
||||||
}
|
|
||||||
|
|
||||||
type stepActionBuiltin struct {
|
|
||||||
Step *model.Step
|
|
||||||
RunContext *RunContext
|
|
||||||
run func(context.Context, *action.Context) error
|
|
||||||
env map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) pre() common.Executor { return common.NewPipelineExecutor() }
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) main() common.Executor {
|
|
||||||
sab.env = map[string]string{}
|
|
||||||
return runStepExecutor(sab, stepStageMain, func(ctx context.Context) error {
|
|
||||||
if common.Dryrun(ctx) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
printRunActionHeader(ctx, sab.Step, nil, sab.env, sab.RunContext)
|
|
||||||
|
|
||||||
rc := sab.RunContext
|
|
||||||
rc.ApplyExtraPath(ctx, &sab.env)
|
|
||||||
return sab.run(ctx, &action.Context{
|
|
||||||
Container: rc.JobContainer,
|
|
||||||
Github: getStepGithubContext(ctx, sab),
|
|
||||||
Inputs: sab.Step.With,
|
|
||||||
HostWorkdir: rc.Config.Workdir,
|
|
||||||
Workspace: rc.JobContainer.ToContainerPath(rc.Config.Workdir),
|
|
||||||
BindWorkdir: rc.Config.BindWorkdir,
|
|
||||||
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
|
||||||
UseGitIgnore: rc.Config.UseGitIgnore,
|
|
||||||
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
|
||||||
ClientCertFile: rc.Config.ClientCertFile,
|
|
||||||
ClientKeyFile: rc.Config.ClientKeyFile,
|
|
||||||
CAFile: rc.jobCAFile(),
|
|
||||||
Env: sab.env,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) post() common.Executor { return common.NewPipelineExecutor() }
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getRunContext() *RunContext { return sab.RunContext }
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getStepModel() *model.Step { return sab.Step }
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getEnv() *map[string]string { return &sab.env }
|
|
||||||
|
|
||||||
func (sab *stepActionBuiltin) getIfExpression(_ context.Context, _ stepStage) string {
|
|
||||||
return sab.Step.If.Value
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,6 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
"gitea.dev/actionslib/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -36,18 +33,6 @@ func (sf *stepFactoryImpl) newStep(stepModel *model.Step, rc *RunContext) (step,
|
|||||||
runAction: runActionImpl,
|
runAction: runActionImpl,
|
||||||
}, nil
|
}, nil
|
||||||
case model.StepTypeUsesActionRemote:
|
case model.StepTypeUsesActionRemote:
|
||||||
if name, ok := strings.CutPrefix(stepModel.Uses, "builtin:"); ok {
|
|
||||||
run, ok := builtinActions[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown built-in action %q (known: %v) for job:%s step:%+v",
|
|
||||||
name, slices.Sorted(maps.Keys(builtinActions)), rc.Name, stepModel)
|
|
||||||
}
|
|
||||||
return &stepActionBuiltin{
|
|
||||||
Step: stepModel,
|
|
||||||
RunContext: rc,
|
|
||||||
run: run,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return &stepActionRemote{
|
return &stepActionRemote{
|
||||||
Step: stepModel,
|
Step: stepModel,
|
||||||
RunContext: rc,
|
RunContext: rc,
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
|
{"StepLocalAction", &model.Step{Uses: "./action@v1"}, &stepActionLocal{}},
|
||||||
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
|
{"StepDocker", &model.Step{Uses: "docker://image:tag"}, &stepDocker{}},
|
||||||
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
|
{"StepRun", &model.Step{Run: "cmd"}, &stepRun{}},
|
||||||
{"StepBuiltinAction", &model.Step{Uses: "builtin:checkout"}, &stepActionBuiltin{}},
|
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
|
step, err := (&stepFactoryImpl{}).newStep(test.model, &RunContext{})
|
||||||
@@ -35,7 +34,6 @@ func TestStepFactoryNewStep(t *testing.T) {
|
|||||||
func TestStepFactoryInvalidStep(t *testing.T) {
|
func TestStepFactoryInvalidStep(t *testing.T) {
|
||||||
for _, stepModel := range []*model.Step{
|
for _, stepModel := range []*model.Step{
|
||||||
{Uses: "remote/action@v1", Run: "cmd"},
|
{Uses: "remote/action@v1", Run: "cmd"},
|
||||||
{Uses: "builtin:unknown"},
|
|
||||||
} {
|
} {
|
||||||
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
_, err := (&stepFactoryImpl{}).newStep(stepModel, &RunContext{Name: "build"})
|
||||||
assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
|
assert.ErrorContains(t, err, "job:build ", stepModel.Uses)
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
name: builtin-checkout
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: builtin:checkout
|
|
||||||
with:
|
|
||||||
path: ${{ 'sub' }}
|
|
||||||
- run: test -f sub/builtin-checkout/push.yml
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Built-in actions
|
|
||||||
|
|
||||||
Built-in actions ship with the runner, so a job needs no action download and no Node in its image.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: builtin:checkout
|
|
||||||
```
|
|
||||||
|
|
||||||
## checkout
|
|
||||||
|
|
||||||
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 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 |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. 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,22 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
+36
-47
@@ -90,20 +90,29 @@ container:
|
|||||||
type cliDaemon struct {
|
type cliDaemon struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
log *syncBuffer
|
log *syncBuffer
|
||||||
exited chan error
|
exited chan struct{}
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan error, 1)}
|
d := &cliDaemon{log: &syncBuffer{}, exited: make(chan struct{})}
|
||||||
d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
|
d.cmd = exec.Command(c.bin, append([]string{"daemon", "-c", c.cfgFile}, args...)...)
|
||||||
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
d.cmd.Stdout, d.cmd.Stderr = d.log, d.log
|
||||||
if err := d.cmd.Start(); err != nil {
|
if err := d.cmd.Start(); err != nil {
|
||||||
t.Fatalf("start daemon: %v", err)
|
t.Fatalf("start daemon: %v", err)
|
||||||
}
|
}
|
||||||
go func() { d.exited <- d.cmd.Wait() }()
|
go func() {
|
||||||
|
d.err = d.cmd.Wait()
|
||||||
|
close(d.exited)
|
||||||
|
}()
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_ = d.cmd.Process.Kill() // no-op once the daemon has exited
|
_ = d.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case <-d.exited:
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
_ = d.cmd.Process.Kill()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
@@ -112,8 +121,8 @@ func (c *cliRunner) startDaemon(t *testing.T, args ...string) *cliDaemon {
|
|||||||
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
|
func (d *cliDaemon) wait(t *testing.T, timeout time.Duration) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
select {
|
select {
|
||||||
case err := <-d.exited:
|
case <-d.exited:
|
||||||
return err
|
return d.err
|
||||||
case <-time.After(timeout):
|
case <-time.After(timeout):
|
||||||
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
t.Fatalf("daemon still running after %s\n%s", timeout, d.log)
|
||||||
return nil
|
return nil
|
||||||
@@ -140,8 +149,8 @@ func (d *cliDaemon) requireSuccess(t *testing.T, c *cliRunner, runID int64) {
|
|||||||
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
|
// testCLIRegisterDaemon drives the built binary the way a user does: register, then daemon.
|
||||||
func testCLIRegisterDaemon(t *testing.T) {
|
func testCLIRegisterDaemon(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := newCLIRunner(t, "e2e-cli", "")
|
c := newCLIRunner(t, "e2e-registered", "")
|
||||||
d := c.startDaemon(t)
|
d := c.startDaemon(t, "--labels", "e2e-cli:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
||||||
|
|
||||||
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
pushWorkflow(t, c.api, c.repo, "cli.yml")
|
||||||
wfRun := waitForRun(t, c.api, c.repo)
|
wfRun := waitForRun(t, c.api, c.repo)
|
||||||
@@ -150,6 +159,22 @@ func testCLIRegisterDaemon(t *testing.T) {
|
|||||||
t.Errorf("job logs are missing the marker:\n%s", logs)
|
t.Errorf("job logs are missing the marker:\n%s", logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runners, err := c.api.Runners(t.Context(), c.repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list runners: %v", err)
|
||||||
|
}
|
||||||
|
var labels []string
|
||||||
|
for _, runner := range runners {
|
||||||
|
if runner.Name == c.repo {
|
||||||
|
for _, label := range runner.Labels {
|
||||||
|
labels = append(labels, label.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(labels, []string{"e2e-cli"}) {
|
||||||
|
t.Errorf("runner %q has labels %v on the server, want only the --labels override", c.repo, labels)
|
||||||
|
}
|
||||||
|
|
||||||
d.terminate(t)
|
d.terminate(t)
|
||||||
if err := d.wait(t, 30*time.Second); err != nil {
|
if err := d.wait(t, 30*time.Second); err != nil {
|
||||||
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log)
|
t.Errorf("daemon did not exit cleanly on SIGTERM: %v\n%s", err, d.log)
|
||||||
@@ -186,36 +211,6 @@ func testCLIOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testCLILabelsOverride checks that daemon --labels replaces the labels given at registration.
|
|
||||||
func testCLILabelsOverride(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
c := newCLIRunner(t, "e2e-registered", "")
|
|
||||||
d := c.startDaemon(t, "--labels", "e2e-override:docker://"+os.Getenv("E2E_JOB_IMAGE"))
|
|
||||||
|
|
||||||
pushWorkflow(t, c.api, c.repo, "cli_labels.yml")
|
|
||||||
wfRun := waitForRun(t, c.api, c.repo)
|
|
||||||
d.requireSuccess(t, c, wfRun.ID)
|
|
||||||
|
|
||||||
runners, err := c.api.Runners(t.Context(), c.repo)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list runners: %v", err)
|
|
||||||
}
|
|
||||||
for _, runner := range runners {
|
|
||||||
if runner.Name != c.repo {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var labels []string
|
|
||||||
for _, label := range runner.Labels {
|
|
||||||
labels = append(labels, label.Name)
|
|
||||||
}
|
|
||||||
if !slices.Equal(labels, []string{"e2e-override"}) {
|
|
||||||
t.Errorf("runner has labels %v on the server, want only the --labels override", labels)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Errorf("runner %q not found among %+v", c.repo, runners)
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
|
// testCLIShutdownDrains checks that SIGTERM lets a running job finish within shutdown_timeout.
|
||||||
func testCLIShutdownDrains(t *testing.T) {
|
func testCLIShutdownDrains(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -282,10 +277,6 @@ func testExecArtifacts(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|
||||||
bin, err := runnerBinary()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
workflow, err := os.ReadFile("testdata/workflows/exec_artifact.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -309,11 +300,9 @@ func testExecArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
artifacts := t.TempDir()
|
artifacts := t.TempDir()
|
||||||
args := []string{"exec", "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network}
|
stdout, stderr, err := runExec(t, nil, "-C", workdir, "-W", workflows, "-i", os.Getenv("E2E_JOB_IMAGE"), "--artifact-server-path", artifacts, "--network", fixture.network)
|
||||||
cmd := exec.CommandContext(ctx, bin, args...)
|
if err != nil {
|
||||||
cmd.Env = slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
t.Fatalf("exec: %v\n%s%s", err, stdout, stderr)
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
t.Fatalf("exec: %v\n%s", err, out)
|
|
||||||
}
|
}
|
||||||
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
|
if blobs, _ := filepath.Glob(filepath.Join(artifacts, "v4", "*", "*", "blob")); len(blobs) != 2 {
|
||||||
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
|
t.Fatalf("want one v4 artifact blob per workflow run, got %v", blobs)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func TestCompatibility(t *testing.T) {
|
|||||||
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
t.Run("cache_and_artifacts", testCacheAndArtifactRoundTrips)
|
||||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||||
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
t.Run("dispatch", inAllModes(testWorkflowDispatch))
|
||||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
|
||||||
t.Run("exec_artifacts", testExecArtifacts)
|
t.Run("exec_artifacts", testExecArtifacts)
|
||||||
t.Run("moving_action_ref", testMovingActionRef)
|
t.Run("moving_action_ref", testMovingActionRef)
|
||||||
t.Run("ephemeral", testEphemeralRunner)
|
t.Run("ephemeral", testEphemeralRunner)
|
||||||
@@ -37,7 +36,6 @@ func TestCompatibility(t *testing.T) {
|
|||||||
t.Run("job_timeout", testJobTimeout)
|
t.Run("job_timeout", testJobTimeout)
|
||||||
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
t.Run("cli_register_daemon", testCLIRegisterDaemon)
|
||||||
t.Run("cli_once", testCLIOnce)
|
t.Run("cli_once", testCLIOnce)
|
||||||
t.Run("cli_labels_override", testCLILabelsOverride)
|
|
||||||
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
t.Run("cli_shutdown_drains", testCLIShutdownDrains)
|
||||||
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
t.Run("cli_shutdown_cancels", testCLIShutdownCancels)
|
||||||
t.Run("host_mode", testHostMode)
|
t.Run("host_mode", testHostMode)
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ func (a *GiteaAPI) CreateFiles(ctx context.Context, repo string, files map[strin
|
|||||||
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), body, nil)
|
return a.doJSON(ctx, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/contents", giteaAdminUser, repo), 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 {
|
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||||
body := map[string]any{"data": value}
|
body := map[string]any{"data": value}
|
||||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
||||||
|
|||||||
+14
-13
@@ -80,11 +80,11 @@ func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, string,
|
|||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
|
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (_ *GiteaFixture, err error) {
|
||||||
closeClient := true
|
f := &GiteaFixture{cli: cli}
|
||||||
defer func() {
|
defer func() {
|
||||||
if closeClient {
|
if err != nil {
|
||||||
_ = cli.Close()
|
_ = f.Close()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -111,7 +111,6 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
"GITEA__security__INSTALL_LOCK=true",
|
"GITEA__security__INSTALL_LOCK=true",
|
||||||
"GITEA__database__DB_TYPE=sqlite3",
|
"GITEA__database__DB_TYPE=sqlite3",
|
||||||
"GITEA__actions__ENABLED=true",
|
"GITEA__actions__ENABLED=true",
|
||||||
"GITEA__server__LFS_START_SERVER=true",
|
|
||||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -123,26 +122,21 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
|||||||
return nil, fmt.Errorf("create gitea container: %w", err)
|
return nil, fmt.Errorf("create gitea container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet, selfIP: selfIP}
|
f.id, f.image, f.baseURL, f.network, f.selfIP = resp.ID, image, baseURL, sharedNet, selfIP
|
||||||
closeClient = false
|
|
||||||
|
|
||||||
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("start gitea container: %w", err)
|
return nil, fmt.Errorf("start gitea container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.waitHealthy(ctx); err != nil {
|
if err := f.waitHealthy(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
|
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.bootstrapAdmin(ctx); err != nil {
|
if err := f.bootstrapAdmin(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
|
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := f.readVersion(ctx); err != nil {
|
if err := f.readVersion(ctx); err != nil {
|
||||||
_ = f.Close(ctx)
|
|
||||||
return nil, fmt.Errorf("read gitea version: %w", err)
|
return nil, fmt.Errorf("read gitea version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +243,7 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer context.AfterFunc(ctx, attached.Close)()
|
||||||
defer attached.Close()
|
defer attached.Close()
|
||||||
if stdin != nil {
|
if stdin != nil {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -258,7 +253,11 @@ func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
|
_, err = stdcopy.StdCopy(&out, &out, attached.Reader)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,10 +290,12 @@ func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody,
|
|||||||
return api.doJSON(ctx, method, path, reqBody, respBody)
|
return api.doJSON(ctx, method, path, reqBody, respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *GiteaFixture) Close(ctx context.Context) error {
|
func (f *GiteaFixture) Close() error {
|
||||||
if f.id == "" {
|
if f.id == "" {
|
||||||
return f.cli.Close()
|
return f.cli.Close()
|
||||||
}
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
return errors.Join(removeErr, f.cli.Close())
|
return errors.Join(removeErr, f.cli.Close())
|
||||||
}
|
}
|
||||||
+13
-7
@@ -55,7 +55,9 @@ func checkClusterLeaks() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil // the tests that needed the cluster already failed
|
return nil // the tests that needed the cluster already failed
|
||||||
}
|
}
|
||||||
if out, err := containerExec(context.Background(), dockerCLI, cluster.id, "", nil,
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if out, err := containerExec(ctx, dockerCLI, cluster.id, "", nil,
|
||||||
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
return fmt.Errorf("job pods or secrets left behind: %w: %s", err, out)
|
||||||
@@ -72,7 +74,7 @@ type kubeCluster struct {
|
|||||||
var (
|
var (
|
||||||
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
k3s = sync.OnceValues(startK3s) // independent of Gitea, so it can boot alongside it
|
||||||
clusterStarted atomic.Bool
|
clusterStarted atomic.Bool
|
||||||
clusterCleanup []func()
|
clusterCleanup []func(context.Context)
|
||||||
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
sharedCluster = sync.OnceValues(func() (*kubeCluster, error) {
|
||||||
cluster, err := k3s()
|
cluster, err := k3s()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,7 +111,9 @@ func stopCluster() {
|
|||||||
}
|
}
|
||||||
_, _ = k3s()
|
_, _ = k3s()
|
||||||
for _, cleanup := range slices.Backward(clusterCleanup) {
|
for _, cleanup := range slices.Backward(clusterCleanup) {
|
||||||
cleanup()
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
||||||
|
cleanup(ctx)
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +142,8 @@ func startK3s() (*kubeCluster, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create k3s container: %w", err)
|
return nil, fmt.Errorf("create k3s container: %w", err)
|
||||||
}
|
}
|
||||||
clusterCleanup = append(clusterCleanup, func() {
|
clusterCleanup = append(clusterCleanup, func(ctx context.Context) {
|
||||||
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
_, _ = cli.ContainerRemove(ctx, created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||||
})
|
})
|
||||||
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||||
return nil, fmt.Errorf("start k3s container: %w", err)
|
return nil, fmt.Errorf("start k3s container: %w", err)
|
||||||
@@ -170,7 +174,9 @@ func startK3s() (*kubeCluster, error) {
|
|||||||
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||||
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
if inspectErr != nil || !inspected.Container.State.Running || ctx.Err() != nil {
|
||||||
var logs bytes.Buffer
|
var logs bytes.Buffer
|
||||||
if reader, logsErr := cli.ContainerLogs(context.Background(), created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
logsCtx, logsCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer logsCancel()
|
||||||
|
if reader, logsErr := cli.ContainerLogs(logsCtx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||||
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||||
_ = reader.Close()
|
_ = reader.Close()
|
||||||
}
|
}
|
||||||
@@ -226,7 +232,7 @@ func preloadImages(ctx context.Context, k3sID string, images ...string) ([]strin
|
|||||||
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
if _, err := dockerCLI.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||||
return nil, fmt.Errorf("tag %s: %w", image, err)
|
return nil, fmt.Errorf("tag %s: %w", image, err)
|
||||||
}
|
}
|
||||||
clusterCleanup = append(clusterCleanup, func() { _, _ = dockerCLI.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
clusterCleanup = append(clusterCleanup, func(ctx context.Context) { _, _ = dockerCLI.ImageRemove(ctx, alias, mobyclient.ImageRemoveOptions{}) })
|
||||||
aliases = append(aliases, alias)
|
aliases = append(aliases, alias)
|
||||||
}
|
}
|
||||||
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
archive, err := dockerCLI.ImageSave(ctx, aliases)
|
||||||
|
|||||||
+6
-2
@@ -31,6 +31,8 @@ import (
|
|||||||
|
|
||||||
const runTimeout = 3 * time.Minute
|
const runTimeout = 3 * time.Minute
|
||||||
|
|
||||||
|
const cleanupTimeout = 2 * time.Minute
|
||||||
|
|
||||||
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
var nonRepoChars = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||||
|
|
||||||
var fixture *GiteaFixture
|
var fixture *GiteaFixture
|
||||||
@@ -42,8 +44,10 @@ var binDir string
|
|||||||
var dockerCLI mobyclient.APIClient
|
var dockerCLI mobyclient.APIClient
|
||||||
|
|
||||||
var runnerBinary = sync.OnceValues(func() (string, error) {
|
var runnerBinary = sync.OnceValues(func() (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||||
|
defer cancel()
|
||||||
bin := filepath.Join(binDir, "gitea-runner")
|
bin := filepath.Join(binDir, "gitea-runner")
|
||||||
if out, err := exec.Command(cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
if out, err := exec.CommandContext(ctx, cmp.Or(os.Getenv("GO"), "go"), "build", "-o", bin, "..").CombinedOutput(); err != nil {
|
||||||
return "", fmt.Errorf("build runner: %w\n%s", err, out)
|
return "", fmt.Errorf("build runner: %w\n%s", err, out)
|
||||||
}
|
}
|
||||||
return bin, nil
|
return bin, nil
|
||||||
@@ -80,7 +84,7 @@ func runSuite(m *testing.M) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fixture = f
|
fixture = f
|
||||||
defer func() { _ = fixture.Close(context.Background()) }()
|
defer func() { _ = fixture.Close() }()
|
||||||
|
|
||||||
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
fmt.Fprintf(os.Stderr, "gitea fixture: image=%s version=%s\n", fixture.image, fixture.version)
|
||||||
|
|
||||||
|
|||||||
+29
-15
@@ -9,6 +9,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
@@ -138,14 +139,31 @@ func daemonWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
func runExec(t *testing.T, env []string, args ...string) (stdout, stderr []byte, err error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
bin, err := runnerBinary()
|
bin, err := runnerBinary()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), runTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, bin, append([]string{"exec"}, args...)...)
|
||||||
|
cmd.Env = append(slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") }), env...)
|
||||||
|
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
|
||||||
|
cmd.WaitDelay = cleanupTimeout
|
||||||
|
var stdoutBuf, stderrBuf bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdoutBuf, &stderrBuf
|
||||||
|
err = cmd.Run()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
t.Fatalf("exec did not finish within %s\n%s%s", runTimeout, stdoutBuf.Bytes(), stderrBuf.Bytes())
|
||||||
|
}
|
||||||
|
return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
||||||
|
t.Helper()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
workdir := t.TempDir()
|
workdir := t.TempDir()
|
||||||
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
workflows := filepath.Join(workdir, ".gitea", "workflows")
|
||||||
if err := os.MkdirAll(workflows, 0o755); err != nil {
|
if err := os.MkdirAll(workflows, 0o755); err != nil {
|
||||||
@@ -170,12 +188,12 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"exec", "--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
|
args := []string{"--json", "-C", workdir, "-W", workflows, "--artifact-server-path", t.TempDir(), "--artifact-server-port", "0"}
|
||||||
env := slices.DeleteFunc(os.Environ(), func(kv string) bool { return strings.HasPrefix(kv, "ACTIONS_") })
|
var env []string
|
||||||
if m.kubernetes {
|
if m.kubernetes {
|
||||||
cluster := requireCluster(t)
|
cluster := requireCluster(t)
|
||||||
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
args = append(args, "-i", "kubernetes://"+cluster.jobImage)
|
||||||
env = append(env, "KUBECONFIG="+cluster.kubernetes.Kubeconfig)
|
env = []string{"KUBECONFIG=" + cluster.kubernetes.Kubeconfig}
|
||||||
} else {
|
} else {
|
||||||
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
|
args = append(args, "-i", os.Getenv("E2E_JOB_IMAGE"), "--network", fixture.network)
|
||||||
}
|
}
|
||||||
@@ -185,21 +203,17 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
|||||||
args = append(args, "--input", name+"="+value)
|
args = append(args, "--input", name+"="+value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd := exec.CommandContext(ctx, bin, append(args, spec.execArgs...)...)
|
stdout, stderr, runErr := runExec(t, env, append(args, spec.execArgs...)...)
|
||||||
cmd.Env = env
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
|
||||||
runErr := cmd.Run()
|
|
||||||
var exitErr *exec.ExitError
|
var exitErr *exec.ExitError
|
||||||
if runErr != nil && !errors.As(runErr, &exitErr) {
|
if runErr != nil && !errors.As(runErr, &exitErr) {
|
||||||
t.Fatalf("exec: %v\n%s", runErr, stderr.String())
|
t.Fatalf("exec: %v\n%s", runErr, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
|
result := &workflowResult{conclusion: "success", jobs: map[string]string{}}
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
result.conclusion = "failure"
|
result.conclusion = "failure"
|
||||||
}
|
}
|
||||||
scanner := bufio.NewScanner(&stdout)
|
scanner := bufio.NewScanner(bytes.NewReader(stdout))
|
||||||
scanner.Buffer(nil, 1<<20)
|
scanner.Buffer(nil, 1<<20)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
var entry struct {
|
var entry struct {
|
||||||
@@ -220,8 +234,8 @@ func execWorkflow(t *testing.T, m mode, spec workflowSpec) *workflowResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if result.conclusion != "success" && len(result.jobs) == 0 {
|
if len(result.jobs) == 0 {
|
||||||
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr.String())
|
t.Fatalf("exec ran no job: %v\n%s", runErr, stderr)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
Vendored
-61
@@ -1,61 +0,0 @@
|
|||||||
name: checkout
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
checkout:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- id: checkout
|
|
||||||
uses: builtin:checkout
|
|
||||||
- uses: builtin:checkout
|
|
||||||
with:
|
|
||||||
ref: ${{ github.ref_name }}
|
|
||||||
path: short
|
|
||||||
- run: test "$(git -C short rev-parse HEAD)" = "$GITHUB_SHA"
|
|
||||||
- if: github.ref_type == 'branch'
|
|
||||||
run: |
|
|
||||||
test "$(git symbolic-ref HEAD)" = "$GITHUB_REF"
|
|
||||||
test "${{ steps.checkout.outputs.ref }}" = "$GITHUB_REF"
|
|
||||||
test "${{ steps.checkout.outputs.commit }}" = "$GITHUB_SHA"
|
|
||||||
git -c user.name=e2e -c [email protected] commit --allow-empty -m pushed
|
|
||||||
git push
|
|
||||||
git tag moved
|
|
||||||
git push --force origin moved
|
|
||||||
git switch --orphan child
|
|
||||||
echo child > child.txt
|
|
||||||
git add child.txt
|
|
||||||
git -c user.name=e2e -c [email protected] commit -m child
|
|
||||||
git push origin child
|
|
||||||
git switch -c content main
|
|
||||||
git submodule add -b child "../${{ github.event.repository.name }}.git" sub
|
|
||||||
git lfs install --local
|
|
||||||
git lfs track '*.bin'
|
|
||||||
mkdir keep drop
|
|
||||||
echo big > keep/big.bin
|
|
||||||
echo big > drop/big.bin
|
|
||||||
git add .gitattributes keep drop
|
|
||||||
git -c user.name=e2e -c [email protected] commit -m content
|
|
||||||
git push origin content
|
|
||||||
- if: github.ref_type == 'branch'
|
|
||||||
uses: builtin:checkout
|
|
||||||
with:
|
|
||||||
ref: content
|
|
||||||
path: full
|
|
||||||
submodules: true
|
|
||||||
lfs: true
|
|
||||||
- if: github.ref_type == 'branch'
|
|
||||||
uses: builtin:checkout
|
|
||||||
with:
|
|
||||||
ref: content
|
|
||||||
path: sparse
|
|
||||||
sparse-checkout: keep
|
|
||||||
lfs: true
|
|
||||||
- if: github.ref_type == 'branch'
|
|
||||||
run: |
|
|
||||||
test "$(cat full/sub/child.txt)" = child
|
|
||||||
test "$(cat full/keep/big.bin)" = big
|
|
||||||
test "$(cat sparse/keep/big.bin)" = big
|
|
||||||
test ! -e sparse/drop
|
|
||||||
- if: github.ref_type == 'tag'
|
|
||||||
run: test "$(git describe --tags)" = "$GITHUB_REF_NAME"
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
name: cli-labels
|
|
||||||
on: push
|
|
||||||
jobs:
|
|
||||||
hello:
|
|
||||||
runs-on: e2e-override
|
|
||||||
steps:
|
|
||||||
- run: echo e2e-override-ran
|
|
||||||
@@ -16,7 +16,6 @@ This uses the recommended Docker-in-Docker image, see [image flavours](../../REA
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
environment:
|
environment:
|
||||||
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
|
# GITEA_RUNNER_REGISTRATION_TOKEN can be used to set a global runner registration token.
|
||||||
# The Gitea version must be v1.23 or higher.
|
|
||||||
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
# It's also possible to use GITEA_RUNNER_REGISTRATION_TOKEN_FILE to pass the location.
|
||||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,15 @@ docker run --privileged \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
|
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
|
||||||
|
|
||||||
|
#### Pull job images from a registry with a private CA
|
||||||
|
|
||||||
|
The bundled Docker daemon pulls job images, so it needs to trust the registry's CA itself. `runner.ca_file` doesn't cover this. Mount the CA where the daemon looks for it, naming the directory after the registry's host and port:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
--mount "type=bind,src=$PWD/ca.crt,dst=/etc/docker/certs.d/registry.example.com:5000/ca.crt,readonly"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--mount`, since `-v` rejects a container path with a port in it.
|
||||||
|
|
||||||
|
On `latest-dind-rootless`, use `/home/rootless/.config/docker/certs.d/registry.example.com:5000/ca.crt` as the target.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Running the runner with launchd on macOS
|
||||||
|
|
||||||
|
[`com.gitea.runner.plist`](./com.gitea.runner.plist) runs the runner in the
|
||||||
|
login session of the user that jobs should run as. Jobs then have that user's
|
||||||
|
login keychain and the GUI.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install the `gitea-runner` binary (e.g. to `/usr/local/bin/gitea-runner`).
|
||||||
|
2. Register as that user, with a host label so that jobs run on the Mac itself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/gitea-runner && cd ~/gitea-runner
|
||||||
|
gitea-runner register --instance https://gitea.example.com --token <token> --labels macos-latest:host
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Replace `/Users/gitea-runner` in the file with the user's home directory,
|
||||||
|
then install it and start the runner. From now on, it also starts whenever
|
||||||
|
the user logs in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Library/LaunchAgents
|
||||||
|
cp com.gitea.runner.plist ~/Library/LaunchAgents/
|
||||||
|
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.gitea.runner.plist
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Enable automatic login for the user in System Settings, so that the runner
|
||||||
|
starts after a restart too. macOS does not offer automatic login while
|
||||||
|
FileVault is on.
|
||||||
|
|
||||||
|
The runner does not get the environment of your shell. Set what jobs need, such
|
||||||
|
as proxy variables, in `EnvironmentVariables`.
|
||||||
|
|
||||||
|
## Local network access
|
||||||
|
|
||||||
|
macOS blocks a runner that was started this way from other hosts on the Mac's
|
||||||
|
own subnet, such as the Gitea instance or a shared cache server. The runner
|
||||||
|
then logs `no route to host`. Commands run in Terminal or over SSH are not
|
||||||
|
blocked, so registering works even when the running runner cannot connect. See
|
||||||
|
[local network privacy](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy).
|
||||||
|
|
||||||
|
macOS asks on the Mac's screen whether to allow the access. To allow it without
|
||||||
|
that prompt, exempt the subnet on macOS 15.5 or later and restart the Mac. On
|
||||||
|
Wi-Fi, the key is `AllowedWiFiLocalNetworkAddresses`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo defaults write com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array "192.168.1.0/24"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git credentials
|
||||||
|
|
||||||
|
Git on macOS saves credentials from remote URLs to the keychain, which can stop
|
||||||
|
a job at a dialog that nobody answers. To turn that off, set
|
||||||
|
`GIT_CONFIG_NOSYSTEM` to `1` in `EnvironmentVariables`.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.gitea.runner</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/local/bin/gitea-runner</string>
|
||||||
|
<string>daemon</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/Users/gitea-runner/gitea-runner</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||||
|
</dict>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/Users/gitea-runner/gitea-runner/runner.log</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -12,7 +12,7 @@ require (
|
|||||||
github.com/containerd/errdefs v1.0.0
|
github.com/containerd/errdefs v1.0.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v29.9.2+incompatible
|
github.com/docker/cli v29.9.3+incompatible
|
||||||
github.com/docker/go-connections v0.8.2
|
github.com/docker/go-connections v0.8.2
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/cli v29.9.2+incompatible h1:trup/vUxx5uUo2t+87Cd9AmITR+DvKUe4H2tTtZN8FY=
|
github.com/docker/cli v29.9.3+incompatible h1:SBaMp7ecohadEHMDrUJg22t9I3awqsAVhnJ7GOcWpfE=
|
||||||
github.com/docker/cli v29.9.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v29.9.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc=
|
||||||
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
|
case err != nil && ctx.Err() != nil && !client.IsRegistrationRejected(err):
|
||||||
return nil
|
return nil
|
||||||
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
|
case err != nil && connect.CodeOf(err) == connect.CodeUnimplemented:
|
||||||
logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v1.21 or later")
|
logging.Errorf("Your Gitea version is too old to support runner declare, please upgrade to v%s or later", ver.MinGiteaVersion)
|
||||||
return err
|
return err
|
||||||
case err != nil:
|
case err != nil:
|
||||||
logging.WithError(err).Error("fail to invoke Declare")
|
logging.WithError(err).Error("fail to invoke Declare")
|
||||||
|
|||||||
@@ -188,10 +188,14 @@ func (i *executeArgs) eventJSON() (string, error) {
|
|||||||
func (i *executeArgs) LoadEnvs() map[string]string {
|
func (i *executeArgs) LoadEnvs() map[string]string {
|
||||||
envs := parseKVAndFile(i.envs, i.Envfile())
|
envs := parseKVAndFile(i.envs, i.Envfile())
|
||||||
|
|
||||||
envs["ACTIONS_CACHE_URL"] = i.cacheHandler.ExternalURL() + "/"
|
cacheURL := i.cacheHandler.ExternalURL()
|
||||||
|
if cacheURL == "" {
|
||||||
|
return envs
|
||||||
|
}
|
||||||
|
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||||
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
// The same server answers cache v2, which docker buildx reads from the results origin alone.
|
||||||
if envs["ACTIONS_RESULTS_URL"] == "" {
|
if envs["ACTIONS_RESULTS_URL"] == "" {
|
||||||
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), i.cacheHandler.ExternalURL())
|
envs["ACTIONS_RESULTS_URL"] = cmp.Or(os.Getenv("ACTIONS_RESULTS_URL"), cacheURL)
|
||||||
}
|
}
|
||||||
envs[runner.CacheServiceV2Env] = "true"
|
envs[runner.CacheServiceV2Env] = "true"
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const registerTokenEnvVar = "GITEA_RUNNER_REGISTRATION_TOKEN"
|
|||||||
|
|
||||||
var defaultLabels = []string{
|
var defaultLabels = []string{
|
||||||
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
"ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest",
|
||||||
|
"ubuntu-26.04:docker://docker.gitea.com/runner-images:ubuntu-26.04",
|
||||||
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
"ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04",
|
||||||
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
"ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []stri
|
|||||||
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
|
setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy)
|
||||||
|
|
||||||
// no_proxy is merged rather than replaced: the hosts above are structural, and an
|
// no_proxy is merged rather than replaced: the hosts above are structural, and an
|
||||||
// operator cannot list the cache server's startup-assigned address in advance.
|
// operator cannot list the cache server's detected address in advance.
|
||||||
noProxy := appendNoProxy(cfg.NoProxy, direct...)
|
noProxy := appendNoProxy(cfg.NoProxy, direct...)
|
||||||
for _, name := range []string{"no_proxy", "NO_PROXY"} {
|
for _, name := range []string{"no_proxy", "NO_PROXY"} {
|
||||||
if existing, ok := jobEnvs[name]; ok {
|
if existing, ok := jobEnvs[name]; ok {
|
||||||
|
|||||||
+47
-26
@@ -73,12 +73,13 @@ type Runner struct {
|
|||||||
cacheHandler *artifactcache.Handler
|
cacheHandler *artifactcache.Handler
|
||||||
capabilities string
|
capabilities string
|
||||||
|
|
||||||
isolatedCacheContainer func() string
|
isolatedCacheContainer func(cacheURL string) string
|
||||||
instanceTransport http.RoundTripper
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
lastIdleCleanupUnixNano atomic.Int64
|
lastIdleCleanupUnixNano atomic.Int64
|
||||||
|
slots slotAllocator
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
healthCheckLast time.Time
|
healthCheckLast time.Time
|
||||||
healthCheckReady bool
|
healthCheckReady bool
|
||||||
@@ -118,7 +119,6 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
// go on
|
// go on
|
||||||
} else {
|
} else {
|
||||||
cacheHandler = handler
|
cacheHandler = handler
|
||||||
envs["ACTIONS_CACHE_URL"] = handler.ExternalURL() + "/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
now: time.Now,
|
now: time.Now,
|
||||||
runHealthCheck: executeHealthCheck,
|
runHealthCheck: executeHealthCheck,
|
||||||
}
|
}
|
||||||
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
runner.isolatedCacheContainer = lastResult(runner.detectIsolatedCacheContainer)
|
||||||
runner.instanceTransport = instanceTransport
|
runner.instanceTransport = instanceTransport
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cutoff := r.now().Add(-orphanDockerResourceAge)
|
cutoff := r.now().Add(-orphanDockerResourceAge)
|
||||||
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(), cutoff); err != nil {
|
if err := removeOrphanNetworks(ctx, r.uuid, r.isolatedCacheContainer(r.cacheHandler.ExternalURL()), cutoff); err != nil {
|
||||||
logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
logging.Warnf("failed to clean up networks left behind by earlier jobs: %v", err)
|
||||||
}
|
}
|
||||||
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
if err := removeOrphanJobVolumes(ctx, r.uuid, cutoff); err != nil {
|
||||||
@@ -452,6 +452,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
slot := r.slots.acquire()
|
||||||
|
defer r.slots.release(slot)
|
||||||
|
|
||||||
workflowCallInputs := restoreCalledWorkflowContext(task)
|
workflowCallInputs := restoreCalledWorkflowContext(task)
|
||||||
for _, line := range r.setupLines(task) {
|
for _, line := range r.setupLines(task) {
|
||||||
reporter.Logf("%s", line)
|
reporter.Logf("%s", line)
|
||||||
@@ -485,7 +488,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return fallbackPlatform()
|
return fallbackPlatform()
|
||||||
}
|
}
|
||||||
platform := platformPicker(job.RunsOn())
|
platform := platformPicker(job.RunsOn())
|
||||||
cacheURL, cacheContainer := r.cacheForJob(job, platform)
|
cacheURL, cacheContainer := r.cacheForJob(job, platform, reporter.Logf)
|
||||||
if cacheURL != "" {
|
if cacheURL != "" {
|
||||||
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||||
}
|
}
|
||||||
@@ -624,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
ContainerVolumes: r.cfg.Container.Volumes,
|
ContainerVolumes: r.cfg.Container.Volumes,
|
||||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||||
|
ToolCacheSlot: toolCacheSlot(r.cfg.Runner.ToolCacheMode, slot),
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
||||||
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||||
@@ -663,11 +667,24 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
return execErr
|
return execErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) builtInCacheURL() string {
|
func lastResult(compute func(key string) (string, bool)) func(key string) string {
|
||||||
if r.cacheHandler == nil {
|
var (
|
||||||
return ""
|
mu sync.Mutex
|
||||||
|
lastKey string
|
||||||
|
result string
|
||||||
|
)
|
||||||
|
return func(key string) string {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if key == lastKey {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
value, ok := compute(key)
|
||||||
|
if ok {
|
||||||
|
lastKey, result = key, value
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
return r.envs["ACTIONS_CACHE_URL"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
func (r *Runner) setResultsService(envs map[string]string, resultsURL string) {
|
||||||
@@ -698,7 +715,7 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
|
|||||||
// Safe with an empty token (older Gitea did not issue one).
|
// Safe with an empty token (older Gitea did not issue one).
|
||||||
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
|
||||||
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
|
||||||
if token == "" || r.cacheHandler == nil {
|
if token == "" || cred.PublicURL == "" {
|
||||||
return func() {}, ""
|
return func() {}, ""
|
||||||
}
|
}
|
||||||
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
|
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
|
||||||
@@ -929,35 +946,39 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
|
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
|
||||||
func (r *Runner) cacheForJob(job *model.Job, platform string) (string, string) {
|
func (r *Runner) cacheForJob(job *model.Job, platform string, logf func(string, ...any)) (string, string) {
|
||||||
|
cacheURL := r.cacheHandler.RefreshExternalURL()
|
||||||
jobContainer := job.Container()
|
jobContainer := job.Container()
|
||||||
cacheContainer := ""
|
if cacheURL == "" || strings.HasPrefix(platform, labels.SchemeKubernetes+"://") || (jobContainer == nil || jobContainer.Image == "") && platform == labels.SelfHostedPlatform {
|
||||||
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) {
|
return cacheURL, ""
|
||||||
cacheContainer = r.isolatedCacheContainer()
|
|
||||||
}
|
}
|
||||||
if cacheContainer == "" {
|
if cacheContainer := r.isolatedCacheContainer(cacheURL); cacheContainer != "" {
|
||||||
return strings.TrimSuffix(r.builtInCacheURL(), "/"), ""
|
isolatedURL, _ := url.Parse(cacheURL)
|
||||||
|
isolatedURL.Host = net.JoinHostPort(cacheContainer, isolatedURL.Port())
|
||||||
|
return isolatedURL.String(), cacheContainer
|
||||||
}
|
}
|
||||||
cacheURL, _ := url.Parse(r.cacheHandler.ExternalURL())
|
network, enableIPv6 := r.cfg.Container.Network, r.cfg.Container.NetworkCreateOptions.EnableIPv6
|
||||||
cacheURL.Host = net.JoinHostPort(cacheContainer, cacheURL.Port())
|
if addr, err := netip.ParseAddr(hostOf(cacheURL)); err == nil && addr.Is6() && r.cfg.Cache.Host == "" &&
|
||||||
return cacheURL.String(), cacheContainer
|
network != "host" && (network != "" || enableIPv6 == nil || !*enableIPv6) {
|
||||||
|
logf("::warning::%s", runner.EscapeCommandData("The runner's cache server is at the IPv6 address "+addr.String()+
|
||||||
|
", so cache and artifact steps fail if this job's network has no IPv6. Set cache.host to an address the job reaches, or enable IPv6 on the job network."))
|
||||||
|
}
|
||||||
|
return cacheURL, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) detectIsolatedCacheContainer() string {
|
func (r *Runner) detectIsolatedCacheContainer(cacheURL string) (string, bool) {
|
||||||
if r.cacheHandler == nil {
|
addr, err := netip.ParseAddr(hostOf(cacheURL))
|
||||||
return ""
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(hostOf(r.cacheHandler.ExternalURL()))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", true
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
cacheContainer, err := container.IsolatedCacheContainer(ctx, addr, r.cfg.Container.Network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
logging.Warnf("cannot check whether jobs reach the cache server: %v", err)
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
return cacheContainer
|
return cacheContainer, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskLogFields returns key/value pairs for logging.With.
|
// taskLogFields returns key/value pairs for logging.With.
|
||||||
|
|||||||
@@ -155,12 +155,11 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
}, &config.Registration{}, cli)
|
}, &config.Registration{}, cli)
|
||||||
t.Cleanup(func() { _ = r.Close() })
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
require.NotNil(t, r.cacheHandler)
|
require.NotNil(t, r.cacheHandler)
|
||||||
require.Equal(t, r.cacheHandler.ExternalURL()+"/", r.envs["ACTIONS_CACHE_URL"])
|
|
||||||
|
|
||||||
const publicURL = "http://a1b2c3d4e5f6:8088"
|
const publicURL = "http://a1b2c3d4e5f6:8088"
|
||||||
token := "external-task-token"
|
token := "external-task-token"
|
||||||
repo := "owner/repoX"
|
repo := "owner/repoX"
|
||||||
base := r.envs["ACTIONS_CACHE_URL"] + "_apis/artifactcache"
|
base := r.cacheHandler.ExternalURL() + "/_apis/artifactcache"
|
||||||
probe := func() int {
|
probe := func() int {
|
||||||
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
|
resp := cacheRequest(t, token, http.MethodGet, base+"/cache?keys=k&version=v", "", "", "")
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -213,7 +212,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
|
|||||||
require.NoError(t, decodeJSON(resp, &hit))
|
require.NoError(t, decodeJSON(resp, &hit))
|
||||||
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
|
||||||
|
|
||||||
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other"}, nil)
|
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other", PublicURL: publicURL}, nil)
|
||||||
defer unregisterOther()
|
defer unregisterOther()
|
||||||
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
|
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
|
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
|
||||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
|
||||||
r.OnIdle(t.Context())
|
r.OnIdle(t.Context())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
assert.Equal(t, swept, sweptVolumes)
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
@@ -353,7 +353,7 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
|||||||
dockerReachable = func(context.Context) bool { return false }
|
dockerReachable = func(context.Context) bool { return false }
|
||||||
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
t.Cleanup(func() { dockerReachable = origDockerReachable })
|
||||||
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
hostOnly := &Runner{uuid: "runner-2", cfg: &config.Config{Runner: cfg.Runner}, now: func() time.Time { return now }}
|
||||||
hostOnly.isolatedCacheContainer = func() string { return "" }
|
hostOnly.isolatedCacheContainer = func(_cacheURL string) string { return "" }
|
||||||
hostOnly.OnIdle(t.Context())
|
hostOnly.OnIdle(t.Context())
|
||||||
assert.Equal(t, []string{"runner-1"}, swept)
|
assert.Equal(t, []string{"runner-1"}, swept)
|
||||||
assert.Equal(t, swept, sweptVolumes)
|
assert.Equal(t, swept, sweptVolumes)
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
|||||||
|
|
||||||
require.NotContains(t, r.envs, "http_proxy")
|
require.NotContains(t, r.envs, "http_proxy")
|
||||||
require.NotContains(t, r.envs, "no_proxy")
|
require.NotContains(t, r.envs, "no_proxy")
|
||||||
proxyEnv := JobProxyEnv(r.envs, r.builtInCacheURL(), nil)
|
proxyEnv := JobProxyEnv(r.envs, r.cacheHandler.ExternalURL(), nil)
|
||||||
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
|
assert.Contains(t, proxyEnv["no_proxy"], "runner.local")
|
||||||
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
|
assert.NotContains(t, proxyEnv["no_proxy"], "cache.local")
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "the advertised results service serves no cache service")
|
||||||
assert.Equal(t, r.envs["ACTIONS_CACHE_URL"], r.builtInCacheURL(), "the address only the runner knows is bypassed for the operator")
|
assert.NotContains(t, r.envs, "ACTIONS_CACHE_URL")
|
||||||
|
|
||||||
envs := r.cloneEnvs()
|
envs := r.cloneEnvs()
|
||||||
r.setResultsService(envs, resultsURL)
|
r.setResultsService(envs, resultsURL)
|
||||||
@@ -390,10 +390,10 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
|
|
||||||
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, linux: {runs-on: linux}, containerized: {runs-on: native, container: alpine}, empty: {runs-on: native, container: ""}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
r.isolatedCacheContainer = func(_cacheURL string) string { return "a1b2c3d4e5f6" }
|
||||||
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
||||||
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
||||||
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()))
|
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()), t.Logf)
|
||||||
if isolated {
|
if isolated {
|
||||||
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
||||||
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
||||||
@@ -404,6 +404,41 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCacheForJobIPv6WarningAndIsolationRecheck(t *testing.T) {
|
||||||
|
handler, err := artifactcache.StartHandler(artifactcache.Options{OutboundIP: "2001:db8::1", Upstream: "http://cache.invalid"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer handler.Close()
|
||||||
|
var checked []string
|
||||||
|
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, isolatedCacheContainer: lastResult(func(cacheURL string) (string, bool) {
|
||||||
|
checked = append(checked, cacheURL)
|
||||||
|
return "", cacheURL != "http://unreachable"
|
||||||
|
})}
|
||||||
|
warnings := 0
|
||||||
|
logf := func(_format string, _args ...any) { warnings++ }
|
||||||
|
workflow, err := model.ReadWorkflow(strings.NewReader(`jobs: {native: {runs-on: native}, containerized: {runs-on: native, container: alpine}}`))
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, job := range []string{"native", "containerized", "containerized"} {
|
||||||
|
cacheURL, _ := r.cacheForJob(workflow.GetJob(job), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, handler.ExternalURL(), cacheURL)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 2, warnings)
|
||||||
|
assert.Equal(t, []string{handler.ExternalURL()}, checked)
|
||||||
|
|
||||||
|
r.cfg.Container.NetworkCreateOptions.EnableIPv6 = new(true)
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 2, warnings)
|
||||||
|
r.cfg.Container.Network = "bridge"
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 3, warnings)
|
||||||
|
r.cfg.Cache.Host = "2001:db8::1"
|
||||||
|
r.cacheForJob(workflow.GetJob("containerized"), labels.SelfHostedPlatform, logf)
|
||||||
|
assert.Equal(t, 3, warnings)
|
||||||
|
|
||||||
|
r.isolatedCacheContainer("http://unreachable")
|
||||||
|
r.isolatedCacheContainer("http://unreachable")
|
||||||
|
assert.Len(t, checked, 3)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestoreCalledWorkflowContext(t *testing.T) {
|
func TestRestoreCalledWorkflowContext(t *testing.T) {
|
||||||
newTask := func(workflowCall map[string]any) *runnerv1.Task {
|
newTask := func(workflowCall map[string]any) *runnerv1.Task {
|
||||||
values := map[string]any{
|
values := map[string]any{
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// slotAllocator hands out the lowest free slot, so slot numbers stay below capacity.
|
||||||
|
type slotAllocator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
used []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *slotAllocator) acquire() int {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
slot := slices.Index(a.used, false)
|
||||||
|
if slot < 0 {
|
||||||
|
slot = len(a.used)
|
||||||
|
a.used = append(a.used, false)
|
||||||
|
}
|
||||||
|
a.used[slot] = true
|
||||||
|
return slot
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *slotAllocator) release(slot int) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
a.used[slot] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolCacheSlot leaves slot 0 unnamed, so it keeps the cache a runner had before slots existed.
|
||||||
|
func toolCacheSlot(mode string, slot int) string {
|
||||||
|
if mode != config.ToolCacheModeShared || slot == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(slot)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.com/gitea/runner/internal/pkg/config"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToolCacheSlotReusesLowestFree(t *testing.T) {
|
||||||
|
var slots slotAllocator
|
||||||
|
require.Equal(t, 0, slots.acquire())
|
||||||
|
require.Equal(t, 1, slots.acquire())
|
||||||
|
require.Equal(t, 2, slots.acquire())
|
||||||
|
slots.release(1)
|
||||||
|
assert.Equal(t, 1, slots.acquire())
|
||||||
|
assert.Equal(t, 3, slots.acquire())
|
||||||
|
|
||||||
|
assert.Equal(t, "3", toolCacheSlot(config.ToolCacheModeShared, 3))
|
||||||
|
assert.Empty(t, toolCacheSlot(config.ToolCacheModeShared, 0))
|
||||||
|
assert.Empty(t, toolCacheSlot(config.ToolCacheModeNone, 3))
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
// Package action holds the native actions run by `uses: builtin:<name>`.
|
|
||||||
package action
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Context struct {
|
|
||||||
Container container.ExecutionsEnvironment
|
|
||||||
Github *model.GithubContext
|
|
||||||
Inputs map[string]string
|
|
||||||
HostWorkdir string
|
|
||||||
Workspace string // HostWorkdir as seen inside Container
|
|
||||||
BindWorkdir bool
|
|
||||||
NoSkipCheckout bool
|
|
||||||
UseGitIgnore bool
|
|
||||||
InsecureSkipTLS bool
|
|
||||||
ClientCertFile string
|
|
||||||
ClientKeyFile string
|
|
||||||
CAFile string // the runner CA as seen inside Container
|
|
||||||
Env map[string]string
|
|
||||||
}
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package checkout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"maps"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/common/git"
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/kballard/go-shellquote"
|
|
||||||
)
|
|
||||||
|
|
||||||
var inputNames = []string{
|
|
||||||
"repository", "ref", "token", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "persist-credentials", "path", "clean",
|
|
||||||
"filter", "sparse-checkout", "sparse-checkout-cone-mode", "fetch-depth", "fetch-tags", "show-progress", "lfs", "submodules",
|
|
||||||
"set-safe-directory",
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentialsInclude = "includeIf.gitdir:**.path" // the form actions/checkout removes from a repository it checks out again
|
|
||||||
|
|
||||||
func Main(ctx context.Context, c *action.Context) (err error) {
|
|
||||||
for name := range c.Inputs {
|
|
||||||
if !slices.Contains(inputNames, name) {
|
|
||||||
return fmt.Errorf("checkout: unsupported input %q", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runnerContext := c.Container.GetRunnerContext(ctx)
|
|
||||||
executionOS, _ := runnerContext["os"].(string)
|
|
||||||
windows := strings.EqualFold(executionOS, "windows")
|
|
||||||
dst, err := workspaceDestination(c.Workspace, c.Inputs["path"], windows)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
depth, err := fetchDepth(c.Inputs["fetch-depth"])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ghc := c.Github
|
|
||||||
repository := cmp.Or(c.Inputs["repository"], ghc.Repository)
|
|
||||||
ref := c.Inputs["ref"]
|
|
||||||
sshKey := strings.TrimSpace(c.Inputs["ssh-key"])
|
|
||||||
isWorkflowRepo := strings.EqualFold(repository, ghc.Repository)
|
|
||||||
local := isWorkflowRepo && slices.Contains([]string{"", strings.TrimPrefix(ghc.Ref, "refs/heads/"), ghc.Sha}, strings.TrimPrefix(ref, "refs/heads/")) && !c.NoSkipCheckout && sshKey == ""
|
|
||||||
if local && (!c.BindWorkdir || dst == c.Workspace) {
|
|
||||||
if !c.BindWorkdir {
|
|
||||||
if err := c.Container.CopyDir(dst, c.HostWorkdir+string(filepath.Separator)+".", c.UseGitIgnore, false)(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if output := filepath.ToSlash(c.Env["GITHUB_OUTPUT"]); output != "" {
|
|
||||||
return c.Container.Copy(container.JoinPath(output, ".."), &container.FileEntry{Name: path.Base(output), Mode: 0o666, Body: fmt.Sprintf("ref=%s\ncommit=%s\n", ghc.Ref, ghc.Sha)})(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
co := &checkout{
|
|
||||||
url: strings.TrimSuffix(ghc.ServerURL, "/") + "/" + repository,
|
|
||||||
dst: dst,
|
|
||||||
windows: windows,
|
|
||||||
ref: ref,
|
|
||||||
depth: depth,
|
|
||||||
fetchTags: boolInput(c.Inputs, "fetch-tags", false),
|
|
||||||
clean: boolInput(c.Inputs, "clean", true),
|
|
||||||
lfs: boolInput(c.Inputs, "lfs", false),
|
|
||||||
progress: boolInput(c.Inputs, "show-progress", true),
|
|
||||||
cone: boolInput(c.Inputs, "sparse-checkout-cone-mode", true),
|
|
||||||
filter: strings.TrimSpace(c.Inputs["filter"]),
|
|
||||||
output: c.Env["GITHUB_OUTPUT"],
|
|
||||||
}
|
|
||||||
for pattern := range strings.Lines(c.Inputs["sparse-checkout"]) {
|
|
||||||
if pattern = strings.TrimSpace(pattern); pattern != "" {
|
|
||||||
co.sparse = append(co.sparse, pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(co.sparse) > 0 {
|
|
||||||
co.filter = cmp.Or(co.filter, "blob:none")
|
|
||||||
}
|
|
||||||
if submodules := strings.ToLower(strings.TrimSpace(c.Inputs["submodules"])); submodules == "true" || submodules == "recursive" {
|
|
||||||
co.submodules = submodules
|
|
||||||
}
|
|
||||||
if sshKey != "" {
|
|
||||||
if co.url, err = sshURL(ghc, repository, strings.TrimSpace(c.Inputs["ssh-user"])); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.objectFormat = git.ObjectFormat(ref)
|
|
||||||
switch {
|
|
||||||
case co.objectFormat != "":
|
|
||||||
co.sha, co.ref = ref, ""
|
|
||||||
case ref == "" && isWorkflowRepo && strings.HasPrefix(ghc.Ref, "refs/"):
|
|
||||||
co.sha, co.ref = ghc.Sha, ghc.Ref
|
|
||||||
case ref == "" && isWorkflowRepo: // a merged pull request's ref is its merge commit
|
|
||||||
co.sha = ghc.Sha
|
|
||||||
case ref == "HEAD":
|
|
||||||
co.ref = ""
|
|
||||||
}
|
|
||||||
if isWorkflowRepo {
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(ghc.Sha))
|
|
||||||
}
|
|
||||||
|
|
||||||
// named like actions/checkout's, which create-pull-request knows to hide, and gone with the job
|
|
||||||
runnerTemp, _ := runnerContext["temp"].(string)
|
|
||||||
runnerTemp = filepath.ToSlash(runnerTemp)
|
|
||||||
digest := sha256.Sum256([]byte(dst))
|
|
||||||
credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8])
|
|
||||||
co.credentials = container.JoinPath(runnerTemp, credentials)
|
|
||||||
persist := boolInput(c.Inputs, "persist-credentials", true)
|
|
||||||
config, remoteConfig := serverConfig(ghc.ServerURL, append(git.RemoteConfig(cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS), git.SSLConfig("http.", "", "", c.CAFile, executionOS)...), co.submodules != "" && sshKey == "")
|
|
||||||
if boolInput(c.Inputs, "set-safe-directory", true) {
|
|
||||||
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
|
|
||||||
}
|
|
||||||
if c.ClientCertFile != "" { // only for this step, later steps never get the runner's key
|
|
||||||
var certPEM, keyPEM []byte
|
|
||||||
if certPEM, err = os.ReadFile(c.ClientCertFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if keyPEM, err = os.ReadFile(c.ClientKeyFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
cert, key := fmt.Sprintf("git-client-cert-%x.pem", digest[:8]), fmt.Sprintf("git-client-key-%x.pem", digest[:8])
|
|
||||||
defer func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
|
|
||||||
}()
|
|
||||||
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remoteConfig = append(remoteConfig, git.SSLConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), "", executionOS)...)
|
|
||||||
}
|
|
||||||
env := gitEnv(c, remoteConfig)
|
|
||||||
env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go
|
|
||||||
if sshKey != "" {
|
|
||||||
key := fmt.Sprintf("git-ssh-key-%x", digest[:8])
|
|
||||||
files := []*container.FileEntry{{Name: key, Mode: 0o600, Body: sshKey + "\n"}}
|
|
||||||
ssh := []string{"ssh", "-i", container.JoinPath(runnerTemp, key)}
|
|
||||||
if boolInput(c.Inputs, "ssh-strict", true) {
|
|
||||||
ssh = append(ssh, "-o", "StrictHostKeyChecking=yes", "-o", "CheckHostIP=no")
|
|
||||||
}
|
|
||||||
if knownHosts := strings.TrimSpace(c.Inputs["ssh-known-hosts"]); knownHosts != "" {
|
|
||||||
name := fmt.Sprintf("git-ssh-known-hosts-%x", digest[:8])
|
|
||||||
files = append(files, &container.FileEntry{Name: name, Mode: 0o600, Body: knownHosts + "\n"})
|
|
||||||
ssh = append(ssh, "-o", "UserKnownHostsFile=~/.ssh/known_hosts "+strconv.Quote(container.JoinPath(runnerTemp, name)))
|
|
||||||
}
|
|
||||||
if !persist {
|
|
||||||
defer func() {
|
|
||||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
if err := c.Container.Copy(runnerTemp, files...)(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
env["GIT_SSH_COMMAND"] = shellquote.Join(ssh...)
|
|
||||||
config += "[core]\n\tsshCommand = " + strconv.Quote(env["GIT_SSH_COMMAND"]) + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
if co.objectFormat == "" || co.sha == "" && !strings.HasPrefix(co.ref, "refs/") {
|
|
||||||
advertised, err := lsRemote(ctx, c, env, co.url, co.ref)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checkout: %s@%s: %w", repository, ref, err)
|
|
||||||
}
|
|
||||||
if co.resolve(advertised); co.ref == "" && co.sha == "" {
|
|
||||||
return fmt.Errorf("checkout: %s has no ref %q", repository, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.outputRef = co.ref
|
|
||||||
if ref != co.sha {
|
|
||||||
co.outputRef = cmp.Or(ref, co.ref) // as given, like actions/checkout
|
|
||||||
}
|
|
||||||
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600})(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
clone := func(commands [][]string) error {
|
|
||||||
if err := run(ctx, c, env, commands); err != nil {
|
|
||||||
return fmt.Errorf("checkout: clone %s@%s: %w", repository, cmp.Or(co.ref, co.sha), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
commands, fetched := co.commands()
|
|
||||||
if co.sha != "" && strings.HasPrefix(co.ref, "refs/tags/") {
|
|
||||||
if err := clone(commands[:fetched]); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tagged, err := output(ctx, c, env, "git", "-C", dst, "rev-parse", "--verify", "--quiet", co.ref+"^{commit}")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("checkout: %s: %w", co.ref, err)
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(strings.TrimSpace(tagged), co.sha) {
|
|
||||||
return fmt.Errorf("checkout: %s does not point to the expected commit %s, it may have been moved after the workflow was triggered", co.ref, co.sha)
|
|
||||||
}
|
|
||||||
commands = commands[fetched:]
|
|
||||||
}
|
|
||||||
if err := clone(commands); err != nil || !persist {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.Container.Copy(runnerTemp, &container.FileEntry{Name: credentials, Mode: 0o600, Body: config})(ctx) // for later steps, the step itself authenticates through env
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitEnv(c *action.Context, config []string) map[string]string {
|
|
||||||
env := map[string]string{"GIT_TERMINAL_PROMPT": "0"}
|
|
||||||
maps.Copy(env, c.Env)
|
|
||||||
maps.DeleteFunc(env, func(name, _ string) bool { return slices.Contains(git.LocalEnvVars, name) })
|
|
||||||
maps.Copy(env, git.ConfigEnv(append([]string{"protocol.version=2", "init.defaultBranch=main", "advice.detachedHead=false", "maintenance.auto=false", "core.longpaths=true"}, config...)...))
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(ctx context.Context, c *action.Context, env map[string]string, commands [][]string) error {
|
|
||||||
if _, host := c.Container.(*container.HostEnvironment); !host || runtime.GOOS != "windows" {
|
|
||||||
script := []string{"set -e", `command -v git >/dev/null || { echo "builtin:checkout needs git on PATH" >&2; exit 127; }`}
|
|
||||||
for _, command := range commands {
|
|
||||||
script = append(script, shellquote.Join(command...))
|
|
||||||
}
|
|
||||||
return c.Container.Exec([]string{"sh", "-c", strings.Join(script, "\n")}, env, "", "")(ctx)
|
|
||||||
}
|
|
||||||
for _, command := range commands {
|
|
||||||
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// output runs where the job runs, so the job's network and git config apply as for the fetch.
|
|
||||||
func output(ctx context.Context, c *action.Context, env map[string]string, command ...string) (string, error) {
|
|
||||||
var out strings.Builder
|
|
||||||
stdout, stderr := c.Container.ReplaceLogWriter(&out, &out)
|
|
||||||
defer c.Container.ReplaceLogWriter(stdout, stderr)
|
|
||||||
if err := c.Container.Exec(command, env, "", "")(ctx); err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(out.String()))
|
|
||||||
}
|
|
||||||
return out.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func lsRemote(ctx context.Context, c *action.Context, env map[string]string, url, ref string) (string, error) {
|
|
||||||
patterns := []string{ref}
|
|
||||||
switch {
|
|
||||||
case ref == "":
|
|
||||||
patterns = []string{"HEAD"}
|
|
||||||
case !strings.HasPrefix(ref, "refs/"):
|
|
||||||
patterns = []string{"refs/heads/" + ref, "refs/tags/" + ref}
|
|
||||||
}
|
|
||||||
return output(ctx, c, env, append([]string{"git", "ls-remote", "--symref", url}, patterns...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// serverConfig scopes the token to the server, so no other host like a repository's LFS endpoint gets it.
|
|
||||||
func serverConfig(serverURL string, settings []string, rewriteSSH bool) (file string, env []string) {
|
|
||||||
scope := strings.TrimSuffix(serverURL, "/") + "/"
|
|
||||||
file = fmt.Sprintf("[http %q]\n", scope)
|
|
||||||
for _, entry := range settings {
|
|
||||||
setting := strings.TrimPrefix(entry, "http.")
|
|
||||||
key, value, _ := strings.Cut(setting, "=")
|
|
||||||
file += "\t" + key + " = " + strconv.Quote(value) + "\n"
|
|
||||||
env = append(env, "http."+scope+"."+setting)
|
|
||||||
}
|
|
||||||
if server, err := url.Parse(serverURL); rewriteSSH && err == nil && server.Hostname() != "" { // submodules on the server fetch over HTTP with the token
|
|
||||||
file += fmt.Sprintf("[url %q]\n\tinsteadOf = git@%s:\n", scope, server.Hostname())
|
|
||||||
env = append(env, "url."+scope+".insteadOf=git@"+server.Hostname()+":")
|
|
||||||
}
|
|
||||||
return file, env
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshURL(ghc *model.GithubContext, repository, user string) (string, error) {
|
|
||||||
endpoint := &url.URL{Scheme: "ssh"}
|
|
||||||
if advertised, _ := model.NestedMapLookup(ghc.Event, "repository", "ssh_url").(string); advertised != "" {
|
|
||||||
if colon := strings.LastIndex(advertised, ":"); !strings.HasPrefix(advertised, "ssh://") && colon >= 0 {
|
|
||||||
advertised = "ssh://" + advertised[:colon] + "/" + advertised[colon+1:] // scp-like git@host:owner/repo.git
|
|
||||||
}
|
|
||||||
var err error
|
|
||||||
if endpoint, err = url.Parse(advertised); err != nil {
|
|
||||||
return "", fmt.Errorf("checkout: parse the event's ssh_url: %w", err)
|
|
||||||
}
|
|
||||||
} else if server, err := url.Parse(ghc.ServerURL); err == nil {
|
|
||||||
endpoint.Host = server.Hostname()
|
|
||||||
}
|
|
||||||
endpoint.User = url.User(cmp.Or(user, endpoint.User.Username(), "git"))
|
|
||||||
endpoint.Path = "/" + repository + ".git"
|
|
||||||
return endpoint.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkout struct {
|
|
||||||
url, dst, objectFormat, credentials, output, outputRef string
|
|
||||||
ref, sha string // ref is fully qualified or empty, sha pins it when known
|
|
||||||
filter, submodules string
|
|
||||||
sparse []string
|
|
||||||
depth int
|
|
||||||
fetchTags, clean, lfs, progress, cone, windows bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullDepth = "2147483647" // git's INFINITE_DEPTH, also deepens a shallow dst
|
|
||||||
|
|
||||||
// commands checks out co.dst, the first `fetched` of them fetch so a fetched tag can be verified before the rest.
|
|
||||||
func (co *checkout) commands() (commands [][]string, fetched int) {
|
|
||||||
repo := func(args ...string) []string { return append([]string{"git", "-C", co.dst}, args...) }
|
|
||||||
commands = [][]string{
|
|
||||||
{"git", "init", "--quiet", "--object-format=" + co.objectFormat, co.dst},
|
|
||||||
repo("config", "remote.origin.url", co.url), // `remote add` fails on a repeated checkout
|
|
||||||
repo("config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"),
|
|
||||||
repo("config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials),
|
|
||||||
}
|
|
||||||
if co.windows {
|
|
||||||
commands = append(commands, repo("config", "core.longpaths", "true")) // later steps run git on the checkout too
|
|
||||||
}
|
|
||||||
if co.lfs {
|
|
||||||
commands = append(commands, repo("lfs", "install", "--local"))
|
|
||||||
}
|
|
||||||
|
|
||||||
depth := strconv.Itoa(co.depth)
|
|
||||||
var refSpecs []string
|
|
||||||
if co.depth == 0 {
|
|
||||||
depth = fullDepth
|
|
||||||
refSpecs = append(refSpecs, "+refs/heads/*:refs/remotes/origin/*")
|
|
||||||
}
|
|
||||||
fetch := []string{"fetch", "--force", "--no-tags", "--no-recurse-submodules", "--depth", depth}
|
|
||||||
if co.progress {
|
|
||||||
fetch = append(fetch, "--progress")
|
|
||||||
} else {
|
|
||||||
fetch = append(fetch, "--no-progress")
|
|
||||||
}
|
|
||||||
if co.filter != "" {
|
|
||||||
fetch = append(fetch, "--filter="+co.filter)
|
|
||||||
}
|
|
||||||
if co.fetchTags || co.depth == 0 {
|
|
||||||
refSpecs = append(refSpecs, "+refs/tags/*:refs/tags/*")
|
|
||||||
}
|
|
||||||
tag := strings.HasPrefix(co.ref, "refs/tags/") // fetched itself, which keeps an annotated tag's object
|
|
||||||
pinned := co.sha != "" && co.ref != "" && !tag
|
|
||||||
if co.ref != "" && !pinned {
|
|
||||||
refSpecs = append(refSpecs, "+"+co.ref+":"+trackingRef(co.ref))
|
|
||||||
}
|
|
||||||
if co.sha != "" && !tag {
|
|
||||||
refSpecs = append(refSpecs, co.sha)
|
|
||||||
}
|
|
||||||
commands = append(commands, repo(append(append(fetch, "origin"), refSpecs...)...))
|
|
||||||
if pinned {
|
|
||||||
commands = append(commands, repo("update-ref", trackingRef(co.ref), co.sha))
|
|
||||||
}
|
|
||||||
fetched = len(commands)
|
|
||||||
|
|
||||||
if len(co.sparse) > 0 {
|
|
||||||
mode := "--cone"
|
|
||||||
if !co.cone {
|
|
||||||
mode = "--no-cone"
|
|
||||||
}
|
|
||||||
commands = append(commands, repo("sparse-checkout", "init", mode), repo(append([]string{"sparse-checkout", "set", "--"}, co.sparse...)...))
|
|
||||||
}
|
|
||||||
if branch, ok := strings.CutPrefix(co.ref, "refs/heads/"); ok {
|
|
||||||
commands = append(commands, repo("checkout", "--force", "--ignore-other-worktrees", "-B", branch, "--track", trackingRef(co.ref)))
|
|
||||||
} else {
|
|
||||||
commands = append(commands, repo("checkout", "--force", cmp.Or(co.sha, trackingRef(co.ref))))
|
|
||||||
}
|
|
||||||
if len(co.sparse) == 0 { // after the checkout, so a sparse dst of another repository needs nothing of it
|
|
||||||
commands = append(commands, repo("sparse-checkout", "disable"), repo("config", "--local", "--unset-all", "extensions.worktreeConfig"))
|
|
||||||
}
|
|
||||||
if co.lfs && len(co.sparse) == 0 {
|
|
||||||
commands = append(commands, repo("lfs", "pull", "origin"))
|
|
||||||
}
|
|
||||||
if co.clean {
|
|
||||||
commands = append(commands, repo("clean", "-ffdx"))
|
|
||||||
}
|
|
||||||
if co.submodules != "" {
|
|
||||||
commands = append(commands, co.submoduleCommands(repo)...)
|
|
||||||
}
|
|
||||||
if co.output != "" {
|
|
||||||
commands = append(commands, repo("log", "-1", "--format=commit=%H%nref="+strings.ReplaceAll(co.outputRef, "%", "%%"), "--output="+co.output))
|
|
||||||
}
|
|
||||||
return commands, fetched
|
|
||||||
}
|
|
||||||
|
|
||||||
func (co *checkout) submoduleCommands(repo func(args ...string) []string) [][]string {
|
|
||||||
var recursive []string
|
|
||||||
if co.submodules == "recursive" {
|
|
||||||
recursive = []string{"--recursive"}
|
|
||||||
}
|
|
||||||
foreach := func(commands ...[]string) []string {
|
|
||||||
joined := make([]string, len(commands))
|
|
||||||
for i, command := range commands {
|
|
||||||
joined[i] = shellquote.Join(command...)
|
|
||||||
}
|
|
||||||
return repo(append(append([]string{"submodule", "foreach"}, recursive...), strings.Join(joined, " && "))...)
|
|
||||||
}
|
|
||||||
commands := [][]string{repo(append([]string{"submodule", "sync"}, recursive...)...)}
|
|
||||||
if co.clean {
|
|
||||||
commands = append(commands, foreach([]string{"git", "clean", "-ffdx"}))
|
|
||||||
}
|
|
||||||
update := append([]string{"submodule", "update", "--init", "--force"}, recursive...)
|
|
||||||
if co.depth > 0 {
|
|
||||||
update = append(update, "--depth="+strconv.Itoa(co.depth))
|
|
||||||
}
|
|
||||||
include := []string{"git", "config", "--replace-all", "--fixed-value", credentialsInclude, co.credentials, co.credentials}
|
|
||||||
if co.lfs {
|
|
||||||
return append(commands, repo(update...), foreach([]string{"git", "lfs", "install", "--local"}, []string{"git", "lfs", "pull", "origin"}, include))
|
|
||||||
}
|
|
||||||
return append(commands, repo(update...), foreach(include))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (co *checkout) resolve(advertised string) {
|
|
||||||
ref := co.ref
|
|
||||||
if !strings.HasPrefix(ref, "refs/") {
|
|
||||||
co.ref = ""
|
|
||||||
}
|
|
||||||
for line := range strings.Lines(advertised) {
|
|
||||||
value, name, _ := strings.Cut(strings.TrimSpace(line), "\t")
|
|
||||||
if target, ok := strings.CutPrefix(value, "ref: "); ok {
|
|
||||||
if name == "HEAD" {
|
|
||||||
co.ref = target
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, git.ObjectFormat(value))
|
|
||||||
switch name {
|
|
||||||
case "HEAD":
|
|
||||||
if co.ref == "" {
|
|
||||||
co.sha = value
|
|
||||||
}
|
|
||||||
case "refs/heads/" + ref:
|
|
||||||
co.ref = name
|
|
||||||
case "refs/tags/" + ref:
|
|
||||||
co.ref = cmp.Or(co.ref, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
co.objectFormat = cmp.Or(co.objectFormat, "sha1")
|
|
||||||
}
|
|
||||||
|
|
||||||
func trackingRef(ref string) string {
|
|
||||||
if branch, ok := strings.CutPrefix(ref, "refs/heads/"); ok {
|
|
||||||
return "refs/remotes/origin/" + branch
|
|
||||||
}
|
|
||||||
if pull, ok := strings.CutPrefix(ref, "refs/pull/"); ok {
|
|
||||||
return "refs/remotes/pull/" + pull
|
|
||||||
}
|
|
||||||
return ref
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolInput(inputs map[string]string, name string, fallback bool) bool {
|
|
||||||
value := strings.TrimSpace(inputs[name])
|
|
||||||
return strings.EqualFold(value, "true") || value == "" && fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceDestination(workspace, checkoutPath string, windows bool) (string, error) {
|
|
||||||
cleanPath := strings.TrimSpace(checkoutPath)
|
|
||||||
if windows {
|
|
||||||
cleanPath = strings.ReplaceAll(cleanPath, `\`, "/")
|
|
||||||
}
|
|
||||||
cleanPath = path.Clean(cleanPath)
|
|
||||||
if path.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || windows && len(cleanPath) > 1 && cleanPath[1] == ':' {
|
|
||||||
return "", fmt.Errorf("checkout: path %q must be relative to the workspace", checkoutPath)
|
|
||||||
}
|
|
||||||
return path.Join(workspace, cleanPath), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchDepth(input string) (int, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
if input == "" {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
depth, err := strconv.Atoi(input)
|
|
||||||
if err != nil || depth < 0 {
|
|
||||||
return 0, fmt.Errorf("checkout: fetch-depth %q must be a non-negative integer", input)
|
|
||||||
}
|
|
||||||
return depth, nil
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package checkout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.com/gitea/runner/act/container"
|
|
||||||
"gitea.com/gitea/runner/internal/pkg/action"
|
|
||||||
|
|
||||||
"gitea.dev/actionslib/pkg/model"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func runGit(t *testing.T, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := exec.Command("git", args...)
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GIT_AUTHOR_NAME=test", "[email protected]",
|
|
||||||
"GIT_COMMITTER_NAME=test", "[email protected]",
|
|
||||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
|
||||||
)
|
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
require.NoError(t, err, string(out))
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitLog(dir string) []string {
|
|
||||||
out, err := exec.Command("git", "--git-dir", filepath.Join(dir, ".git"), "log", "--format=%s").Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return strings.Fields(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckout(t *testing.T) {
|
|
||||||
server := t.TempDir()
|
|
||||||
serverURL := (&url.URL{Scheme: "file", Path: "/" + strings.TrimPrefix(filepath.ToSlash(server), "/")}).String()
|
|
||||||
var sha string
|
|
||||||
lfsPath, lfsErr := exec.LookPath("git-lfs")
|
|
||||||
require.True(t, t.Run("fixtures", func(t *testing.T) {
|
|
||||||
t.Run("history", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
work := t.TempDir()
|
|
||||||
runGit(t, "init", "--template=", "--initial-branch=main", work)
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c1")
|
|
||||||
sha = runGit(t, "-C", work, "rev-parse", "HEAD")
|
|
||||||
runGit(t, "-C", work, "tag", "v1")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "c2")
|
|
||||||
runGit(t, "-C", work, "checkout", "-b", "feature")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "f1")
|
|
||||||
runGit(t, "-C", work, "checkout", "--orphan", "trunk")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t1")
|
|
||||||
runGit(t, "-C", work, "commit", "--allow-empty", "-m", "t2")
|
|
||||||
runGit(t, "-C", work, "tag", "trunk", "main")
|
|
||||||
runGit(t, "clone", "--bare", "--branch", "main", work, filepath.Join(server, "org", "repo"))
|
|
||||||
runGit(t, "clone", "--bare", "--branch", "trunk", work, filepath.Join(server, "other", "repo"))
|
|
||||||
runGit(t, "-C", filepath.Join(server, "other", "repo"), "symbolic-ref", "refs/remotes/origin/HEAD", "refs/heads/main")
|
|
||||||
})
|
|
||||||
t.Run("sha256", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
sha256Repo := filepath.Join(server, "org", "sha256")
|
|
||||||
runGit(t, "init", "--template=", "--object-format=sha256", "--initial-branch=main", sha256Repo)
|
|
||||||
runGit(t, "-C", sha256Repo, "commit", "--allow-empty", "-m", "s1")
|
|
||||||
runGit(t, "-C", sha256Repo, "checkout", "--detach")
|
|
||||||
})
|
|
||||||
t.Run("submodules", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
publish(t, server, "org/nested", map[string]string{"nested.txt": "nested"}, nil)
|
|
||||||
publish(t, server, "org/child", map[string]string{"child.txt": "child"}, map[string]string{"nested": "../nested"})
|
|
||||||
publish(t, server, "org/content", map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b"}, map[string]string{"sub": "../child"})
|
|
||||||
runGit(t, "-C", filepath.Join(server, "org", "content"), "config", "uploadpack.allowFilter", "true")
|
|
||||||
})
|
|
||||||
t.Run("lfs", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
oid := fmt.Sprintf("%x", sha256.Sum256([]byte("big")))
|
|
||||||
pointer := "version https://git-lfs.github.com/spec/v1\noid sha256:" + oid + "\nsize 3\n"
|
|
||||||
publish(t, server, "org/lfs", map[string]string{".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", "keep/big.bin": pointer, "drop/big.bin": pointer}, nil)
|
|
||||||
object := filepath.Join(server, "org", "lfs", "lfs", "objects", oid[:2], oid[2:4], oid)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(object), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(object, []byte("big"), 0o644))
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
host := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(host, "marker"), []byte("marker"), 0o644))
|
|
||||||
tools := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(tools, "ssh"), []byte(`#!/bin/sh
|
|
||||||
test -s "$2" || exit 1
|
|
||||||
printf '%s\n' "$@" > "$SSH_ARGS"
|
|
||||||
for last; do :; done
|
|
||||||
exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git upload-pack '$SSH_SERVER/\1'#")"
|
|
||||||
`), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(tools, "git-lfs"), []byte(`#!/bin/sh
|
|
||||||
git config --get-regexp extraheader > "$LFS_HEADERS"
|
|
||||||
exec "$LFS" "$@"
|
|
||||||
`), 0o755))
|
|
||||||
globalConfig := filepath.Join(t.TempDir(), "gitconfig")
|
|
||||||
require.NoError(t, os.WriteFile(globalConfig, []byte("[protocol \"file\"]\n\tallow = always\n"), 0o644))
|
|
||||||
sshEvent := func(url string) map[string]any { return map[string]any{"repository": map[string]any{"ssh_url": url}} }
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
bind, noSkip bool
|
|
||||||
eventRef string
|
|
||||||
event map[string]any
|
|
||||||
before, inputs map[string]string
|
|
||||||
dst, head, err string
|
|
||||||
log, refs []string
|
|
||||||
files, config map[string]string
|
|
||||||
}{
|
|
||||||
{name: "bound workspace is skipped", bind: true},
|
|
||||||
{name: "workflow ref copies host workdir", inputs: map[string]string{"ref": "main", "path": "a/../src"}, dst: "src", files: map[string]string{"marker": "marker"}},
|
|
||||||
{name: "bound path checks out the event branch", bind: true, inputs: map[string]string{"path": "src"}, dst: "src", head: "main", log: []string{"c1"}},
|
|
||||||
{name: "no-skip checks out the event branch", noSkip: true, head: "main", log: []string{"c1"}, refs: []string{"refs/remotes/origin/main"}},
|
|
||||||
{name: "event tag is kept", noSkip: true, eventRef: "refs/tags/v1", log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
|
|
||||||
{name: "moved event tag fails", noSkip: true, eventRef: "refs/tags/trunk", err: "does not point to the expected commit " + sha},
|
|
||||||
{name: "deleted event tag fails", noSkip: true, eventRef: "refs/tags/deleted", err: "checkout: clone"},
|
|
||||||
{
|
|
||||||
name: "sha input removes previously persisted credentials without clean", noSkip: true, before: map[string]string{"ref": sha},
|
|
||||||
inputs: map[string]string{"ref": sha, "persist-credentials": "false", "clean": "false"},
|
|
||||||
log: []string{"c1"},
|
|
||||||
},
|
|
||||||
{name: "ref clones shallow", inputs: map[string]string{"ref": "refs/heads/feature"}, head: "feature", log: []string{"f1"}},
|
|
||||||
{name: "tag ref wins over same-named branch and is kept", inputs: map[string]string{"ref": "refs/tags/trunk"}, log: []string{"c2"}, refs: []string{"refs/tags/trunk"}},
|
|
||||||
{name: "branch ref wins over same-named tag", inputs: map[string]string{"ref": "refs/heads/trunk"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{
|
|
||||||
name: "short branch wins over same-named tag and fetch-tags fetches every tag", inputs: map[string]string{"ref": "trunk", "fetch-tags": "true"},
|
|
||||||
head: "trunk", log: []string{"t2"}, refs: []string{"refs/tags/trunk", "refs/tags/v1"},
|
|
||||||
},
|
|
||||||
{name: "short tag", inputs: map[string]string{"ref": "v1"}, log: []string{"c1"}, refs: []string{"refs/tags/v1"}},
|
|
||||||
{
|
|
||||||
name: "fetch-depth 0 deepens a shallow checkout to every branch and tag", before: map[string]string{"ref": "feature"},
|
|
||||||
inputs: map[string]string{"ref": "refs/heads/feature", "fetch-depth": "0"}, head: "feature", log: []string{"f1", "c2", "c1"},
|
|
||||||
refs: []string{"refs/remotes/origin/feature", "refs/remotes/origin/main", "refs/remotes/origin/trunk", "refs/tags/trunk", "refs/tags/v1"},
|
|
||||||
},
|
|
||||||
{name: "other repository defaults to its default branch", inputs: map[string]string{"repository": "other/repo"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{name: "explicit HEAD checks out the default branch", inputs: map[string]string{"repository": "other/repo", "ref": "HEAD"}, head: "trunk", log: []string{"t2"}},
|
|
||||||
{name: "detached sha256 HEAD is checked out", inputs: map[string]string{"repository": "org/sha256"}, log: []string{"s1"}},
|
|
||||||
{
|
|
||||||
name: "ssh key with the advertised endpoint persists", event: sshEvent("ssh://[email protected]:2222/org/repo.git"),
|
|
||||||
inputs: map[string]string{"ssh-key": "KEY", "ssh-user": "me", "ssh-known-hosts": "ssh.example ssh-ed25519 AAAA"},
|
|
||||||
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]:2222/org/repo.git"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ssh key without persisting is emptied", event: sshEvent("[email protected]:org/repo.git"),
|
|
||||||
inputs: map[string]string{"ssh-key": "KEY", "persist-credentials": "false"},
|
|
||||||
head: "main", log: []string{"c1"}, config: map[string]string{"remote.origin.url": "ssh://[email protected]/org/repo.git"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sparse cone checkout filters blobs", inputs: map[string]string{"repository": "org/content", "sparse-checkout": "a\n", "show-progress": "false"},
|
|
||||||
head: "main", log: []string{"content"}, files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": ""},
|
|
||||||
config: map[string]string{"remote.origin.partialclonefilter": "blob:none"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-cone sparse checkout with an explicit filter", head: "main", log: []string{"content"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "sparse-checkout": "b/b.txt", "sparse-checkout-cone-mode": "false", "filter": "blob:limit=1024"},
|
|
||||||
files: map[string]string{"root.txt": "", "a/a.txt": "", "b/b.txt": "b"}, config: map[string]string{"remote.origin.partialclonefilter": "blob:limit=1024"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "full checkout after a sparse one restores every file", before: map[string]string{"repository": "org/content", "sparse-checkout": "a"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "set-safe-directory": "false"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"root.txt": "root", "a/a.txt": "a", "b/b.txt": "b", "sub/child.txt": ""},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recursive submodules after plain ones", before: map[string]string{"repository": "org/content", "submodules": "true"},
|
|
||||||
inputs: map[string]string{"repository": "org/content", "submodules": "Recursive"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"sub/child.txt": "child", "sub/nested/nested.txt": "nested"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "lfs content stays when narrowing a full checkout to a sparse one", before: map[string]string{"repository": "org/lfs", "lfs": "true"},
|
|
||||||
inputs: map[string]string{"repository": "org/lfs", "lfs": "true", "sparse-checkout": "keep"}, head: "main", log: []string{"content"},
|
|
||||||
files: map[string]string{"keep/big.bin": "big", "drop/big.bin": ""},
|
|
||||||
},
|
|
||||||
{name: "unknown ref", inputs: map[string]string{"ref": "missing"}, err: "has no ref"},
|
|
||||||
{name: "unsupported input", inputs: map[string]string{"github-server-url": "https://example.com"}, err: "unsupported input"},
|
|
||||||
{name: "relative path escaping workspace", inputs: map[string]string{"path": "../x"}, err: "must be relative"},
|
|
||||||
{name: "absolute path", inputs: map[string]string{"path": "/x"}, err: "must be relative"},
|
|
||||||
{name: "negative fetch-depth", inputs: map[string]string{"fetch-depth": "-1"}, err: "non-negative"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if tc.inputs["lfs"] == "true" && lfsErr != nil {
|
|
||||||
t.Skip("needs git-lfs")
|
|
||||||
}
|
|
||||||
if tc.inputs["ssh-key"] != "" && runtime.GOOS == "windows" {
|
|
||||||
t.Skip("Git for Windows puts its own ssh ahead of the fake one on PATH")
|
|
||||||
}
|
|
||||||
workspace, runnerTemp := t.TempDir(), t.TempDir()
|
|
||||||
output := filepath.Join(runnerTemp, "output")
|
|
||||||
checkout := func(inputs map[string]string) error {
|
|
||||||
return Main(t.Context(), &action.Context{
|
|
||||||
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
|
|
||||||
Github: &model.GithubContext{
|
|
||||||
Repository: "org/repo", Ref: cmp.Or(tc.eventRef, "refs/heads/main"), Sha: sha, ServerURL: serverURL, Token: "tok", Event: tc.event,
|
|
||||||
},
|
|
||||||
Inputs: inputs,
|
|
||||||
HostWorkdir: host,
|
|
||||||
Workspace: workspace,
|
|
||||||
BindWorkdir: tc.bind,
|
|
||||||
NoSkipCheckout: tc.noSkip,
|
|
||||||
CAFile: "ca.crt",
|
|
||||||
Env: map[string]string{
|
|
||||||
"PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1",
|
|
||||||
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
|
|
||||||
"LFS": lfsPath, "LFS_HEADERS": filepath.Join(runnerTemp, "lfs-headers"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
dst := filepath.Join(workspace, tc.dst)
|
|
||||||
if tc.err != "" {
|
|
||||||
require.ErrorContains(t, checkout(tc.inputs), tc.err)
|
|
||||||
if tc.eventRef == "" {
|
|
||||||
entries, _ := os.ReadDir(workspace)
|
|
||||||
assert.Empty(t, entries)
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, gitLog(dst))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
first := tc.before
|
|
||||||
if first == nil {
|
|
||||||
first = tc.inputs
|
|
||||||
}
|
|
||||||
require.NoError(t, checkout(first))
|
|
||||||
repos := []string{dst}
|
|
||||||
if fileExists(filepath.Join(dst, "sub", ".git")) {
|
|
||||||
repos = append(repos, filepath.Join(dst, "sub"))
|
|
||||||
}
|
|
||||||
for _, repo := range repos {
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repo, "untracked"), nil, 0o644))
|
|
||||||
}
|
|
||||||
require.NoError(t, checkout(tc.inputs))
|
|
||||||
outputs, err := os.ReadFile(output)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, tc.log, gitLog(dst))
|
|
||||||
for name, content := range tc.files {
|
|
||||||
body, err := os.ReadFile(filepath.Join(dst, name))
|
|
||||||
assert.Equal(t, content, strings.TrimSpace(string(body)), name)
|
|
||||||
assert.Equal(t, content == "", os.IsNotExist(err), name)
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.files["marker"] != "", fileExists(filepath.Join(dst, "marker")))
|
|
||||||
for key, value := range tc.config {
|
|
||||||
assert.Equal(t, value, gitOutput(dst, "config", key), key)
|
|
||||||
}
|
|
||||||
if tc.refs != nil {
|
|
||||||
assert.Subset(t, strings.Fields(runGit(t, "-C", dst, "for-each-ref", "--format=%(refname)")), tc.refs)
|
|
||||||
}
|
|
||||||
if tc.log == nil {
|
|
||||||
assert.Contains(t, string(outputs), "commit="+sha+"\n")
|
|
||||||
assert.Contains(t, string(outputs), "ref="+cmp.Or(tc.eventRef, "refs/heads/main")+"\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
headArgs := []string{"-C", dst, "rev-parse", "HEAD", "--symbolic-full-name", "HEAD"}
|
|
||||||
if tc.head != "" {
|
|
||||||
headArgs = append(headArgs, "--abbrev-ref", "@{u}")
|
|
||||||
}
|
|
||||||
head := strings.Fields(runGit(t, headArgs...))
|
|
||||||
if tc.head == "" {
|
|
||||||
require.Len(t, head, 2)
|
|
||||||
assert.Equal(t, "HEAD", head[1])
|
|
||||||
} else {
|
|
||||||
require.Len(t, head, 3)
|
|
||||||
assert.Equal(t, "refs/heads/"+tc.head, head[1])
|
|
||||||
assert.Equal(t, "origin/"+tc.head, head[2])
|
|
||||||
}
|
|
||||||
assert.Contains(t, string(outputs), "commit="+head[0]+"\n")
|
|
||||||
if inputRef := tc.inputs["ref"]; inputRef != "" && inputRef != sha {
|
|
||||||
assert.Contains(t, string(outputs), "ref="+inputRef+"\n")
|
|
||||||
}
|
|
||||||
persisted := tc.inputs["persist-credentials"] != "false"
|
|
||||||
for _, repo := range repos {
|
|
||||||
var credentialIncludes []string
|
|
||||||
var worktreeConfig string
|
|
||||||
for entry := range strings.SplitSeq(runGit(t, "-C", repo, "config", "--local", "--no-includes", "--null", "--list"), "\x00") {
|
|
||||||
key, value, _ := strings.Cut(entry, "\n")
|
|
||||||
switch key {
|
|
||||||
case "extensions.worktreeconfig":
|
|
||||||
worktreeConfig = value
|
|
||||||
case strings.ToLower(credentialsInclude):
|
|
||||||
credentialIncludes = append(credentialIncludes, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if repo == dst {
|
|
||||||
assert.Equal(t, tc.inputs["sparse-checkout"] != "", strings.TrimSpace(worktreeConfig) == "true")
|
|
||||||
}
|
|
||||||
assert.Equal(t, tc.inputs["clean"] == "false", fileExists(filepath.Join(repo, "untracked")), repo)
|
|
||||||
assert.Len(t, strings.Fields(strings.Join(credentialIncludes, "\n")), 1, repo)
|
|
||||||
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
|
|
||||||
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
|
|
||||||
assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo)
|
|
||||||
}
|
|
||||||
if tc.inputs["lfs"] == "true" && runtime.GOOS != "windows" {
|
|
||||||
headers, _ := os.ReadFile(filepath.Join(runnerTemp, "lfs-headers"))
|
|
||||||
assert.Equal(t, 1, strings.Count(string(headers), "Authorization"), string(headers))
|
|
||||||
}
|
|
||||||
if tc.inputs["ssh-key"] != "" {
|
|
||||||
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-ssh-key-*"))
|
|
||||||
require.Len(t, keys, 1)
|
|
||||||
key, _ := os.ReadFile(keys[0])
|
|
||||||
if persisted {
|
|
||||||
assert.Equal(t, "KEY\n", string(key))
|
|
||||||
} else {
|
|
||||||
assert.Empty(t, key)
|
|
||||||
}
|
|
||||||
args, _ := os.ReadFile(filepath.Join(runnerTemp, "ssh-args"))
|
|
||||||
assert.Contains(t, string(args), "StrictHostKeyChecking=yes")
|
|
||||||
assert.Equal(t, tc.inputs["ssh-known-hosts"] != "", strings.Contains(string(args), "UserKnownHostsFile="))
|
|
||||||
assert.Equal(t, persisted, gitOutput(dst, "config", "core.sshCommand") != "")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testing.T) {
|
|
||||||
var gotCert bool
|
|
||||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotCert = len(r.TLS.PeerCertificates) > 0
|
|
||||||
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, workspace, runnerTemp := t.TempDir(), t.TempDir(), 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))
|
|
||||||
|
|
||||||
require.ErrorContains(t, Main(t.Context(), &action.Context{
|
|
||||||
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
|
|
||||||
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL},
|
|
||||||
Inputs: map[string]string{"repository": "other/repo"},
|
|
||||||
Workspace: workspace,
|
|
||||||
ClientCertFile: certFile,
|
|
||||||
ClientKeyFile: keyFile,
|
|
||||||
CAFile: certFile,
|
|
||||||
Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"},
|
|
||||||
}), "checkout: other/repo@")
|
|
||||||
require.True(t, gotCert)
|
|
||||||
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*"))
|
|
||||||
require.Len(t, keys, 1)
|
|
||||||
key, err := os.ReadFile(keys[0])
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Empty(t, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func publish(t *testing.T, server, name string, files, submodules map[string]string) {
|
|
||||||
work := t.TempDir()
|
|
||||||
runGit(t, "init", "--template=", "--initial-branch=main", work)
|
|
||||||
for file, content := range files {
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(work, file)), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(work, file), []byte(content), 0o644))
|
|
||||||
}
|
|
||||||
runGit(t, "-C", work, "add", ".")
|
|
||||||
var moduleConfig []byte
|
|
||||||
for path, url := range submodules {
|
|
||||||
moduleConfig = fmt.Appendf(moduleConfig, "[submodule %q]\n\tpath = %q\n\turl = %q\n", path, path, url)
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(work, ".gitmodules"), moduleConfig, 0o644))
|
|
||||||
commit := runGit(t, "--git-dir", filepath.Join(server, name, url), "rev-parse", "main")
|
|
||||||
runGit(t, "-C", work, "update-index", "--add", "--cacheinfo", "160000,"+commit+","+path)
|
|
||||||
runGit(t, "-C", work, "add", ".gitmodules")
|
|
||||||
}
|
|
||||||
runGit(t, "-C", work, "commit", "-m", "content")
|
|
||||||
runGit(t, "clone", "--bare", "--quiet", work, filepath.Join(server, name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitOutput(dir string, args ...string) string {
|
|
||||||
out, _ := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(name string) bool {
|
|
||||||
_, err := os.Stat(name)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspaceDestination(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
path string
|
|
||||||
windows bool
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{`..\x`, true, ""},
|
|
||||||
{`C:x`, true, ""},
|
|
||||||
{`a\b`, true, "/w/a/b"},
|
|
||||||
{`..\x`, false, `/w/..\x`},
|
|
||||||
{`a:b`, false, "/w/a:b"},
|
|
||||||
} {
|
|
||||||
got, err := workspaceDestination("/w", tc.path, tc.windows)
|
|
||||||
if tc.want == "" {
|
|
||||||
assert.ErrorContains(t, err, "must be relative", tc.path)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err, tc.path)
|
|
||||||
assert.Equal(t, tc.want, got, tc.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,7 @@ runner:
|
|||||||
#client_cert_file: /etc/gitea-runner/client.crt
|
#client_cert_file: /etc/gitea-runner/client.crt
|
||||||
#client_key_file: /etc/gitea-runner/client.key
|
#client_key_file: /etc/gitea-runner/client.key
|
||||||
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
|
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
|
||||||
# Runner-side git and builtin:checkout use it instead of the system's bundle for the instance.
|
# Runner-side git uses it instead of the system's bundle for the instance.
|
||||||
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
|
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
|
||||||
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
|
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
|
||||||
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
|
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
|
||||||
@@ -119,6 +119,7 @@ runner:
|
|||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
#labels:
|
#labels:
|
||||||
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# - "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
|
# - "ubuntu-26.04:docker://docker.gitea.com/runner-images:ubuntu-26.04"
|
||||||
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
# - "ubuntu-24.04:docker://docker.gitea.com/runner-images:ubuntu-24.04"
|
||||||
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
# - "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||||
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
# Allocate a pseudo-TTY for each step's process. Applies to both host and docker backends.
|
||||||
@@ -131,8 +132,8 @@ runner:
|
|||||||
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
|
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
|
||||||
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
|
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
|
||||||
# either way what it installs is gone when the job ends.
|
# either way what it installs is gone when the job ends.
|
||||||
# shared: one tool cache every job reuses. Two jobs writing the same tool version at once
|
# shared: one tool cache per capacity slot, reused by later jobs. It hides the tools later job
|
||||||
# corrupt it, so use it only with capacity 1.
|
# images ship, so use images without them.
|
||||||
#tool_cache_mode: none
|
#tool_cache_mode: none
|
||||||
# Optional executable on the host, run once after each task's built-in cleanup
|
# Optional executable on the host, run once after each task's built-in cleanup
|
||||||
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
# (post-steps, container teardown, bind-workdir removal). Additive only.
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ type Container struct {
|
|||||||
Sweep bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends.
|
Sweep bool `yaml:"sweep"` // Sweep removes the containers, networks and volumes a job creates through its Docker proxy when the job ends.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one that every job reuses.
|
// Values of Runner.ToolCacheMode: the runner mounts no tool cache, or one per capacity slot that its jobs reuse.
|
||||||
const (
|
const (
|
||||||
ToolCacheModeNone = "none"
|
ToolCacheModeNone = "none"
|
||||||
ToolCacheModeShared = "shared"
|
ToolCacheModeShared = "shared"
|
||||||
@@ -454,10 +454,6 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate and fix invalid config combinations to prevent confusing behavior.
|
// Validate and fix invalid config combinations to prevent confusing behavior.
|
||||||
if cfg.Runner.ToolCacheMode == ToolCacheModeShared && cfg.Runner.Capacity > 1 {
|
|
||||||
logging.Warnf("runner.tool_cache_mode %q with capacity %d: two jobs writing the same tool version at once corrupt it",
|
|
||||||
ToolCacheModeShared, cfg.Runner.Capacity)
|
|
||||||
}
|
|
||||||
if cfg.Runner.FetchTimeout > RequestTimeout {
|
if cfg.Runner.FetchTimeout > RequestTimeout {
|
||||||
logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout)
|
logging.Warnf("fetch_timeout (%v) exceeds the RPC timeout (%v), capping it", cfg.Runner.FetchTimeout, RequestTimeout)
|
||||||
cfg.Runner.FetchTimeout = RequestTimeout
|
cfg.Runner.FetchTimeout = RequestTimeout
|
||||||
|
|||||||
@@ -691,22 +691,6 @@ func (r *Reporter) Close(lastWords string) error {
|
|||||||
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
|
logging.Errorf("No Response from RunDaemon for %s, continue best effort", r.daemonWait)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gitea's UpdateLog short-circuits on len(Rows)==0 before honoring NoMore,
|
|
||||||
// so a final empty request never runs TransferLogs and dbfs_data leaks.
|
|
||||||
// Inject a sentinel row after the daemon has exited so it can't be flushed
|
|
||||||
// before ReportLog(true).
|
|
||||||
// TODO: Remove after https://github.com/go-gitea/gitea/pull/37631 is in all
|
|
||||||
// supported branches, e.g. v1.28+.
|
|
||||||
r.stateMu.Lock()
|
|
||||||
if len(r.logRows) == 0 {
|
|
||||||
// Not appendLogRow: the sentinel is not job output and has no place in job.log.
|
|
||||||
r.logRows = append(r.logRows, &runnerv1.LogRow{
|
|
||||||
Time: timestamppb.Now(),
|
|
||||||
Content: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
r.stateMu.Unlock()
|
|
||||||
|
|
||||||
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
// Separate budgets so a slow ReportLog can't starve the ReportState that
|
||||||
// carries the cancel acknowledgement.
|
// carries the cancel acknowledgement.
|
||||||
err := errors.Join(
|
err := errors.Join(
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func TestReporter_StateNotifyFlush(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
func TestReporter_CloseFinalizesLogWithoutRows(t *testing.T) {
|
||||||
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
|
var lastReq atomic.Pointer[runnerv1.UpdateLogRequest]
|
||||||
var noMoreCalls atomic.Int64
|
var noMoreCalls atomic.Int64
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ func TestReporter_CloseAlwaysSendsRowsWithNoMore(t *testing.T) {
|
|||||||
final := lastReq.Load()
|
final := lastReq.Load()
|
||||||
require.NotNil(t, final)
|
require.NotNil(t, final)
|
||||||
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
|
assert.True(t, final.NoMore, "final UpdateLog must carry NoMore=true")
|
||||||
assert.NotEmpty(t, final.Rows, "final UpdateLog must carry at least one row")
|
assert.Empty(t, final.Rows, "a job without output must not get a log row")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReporter_StateHeartbeat(t *testing.T) {
|
func TestReporter_StateHeartbeat(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ package ver
|
|||||||
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
|
// go build -ldflags "-X gitea.com/gitea/runner/internal/pkg/ver.version=1.2.3"
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
const MinGiteaVersion = "1.26.2" // also in the README.md prerequisites
|
||||||
|
|
||||||
func Version() string {
|
func Version() string {
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user