mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-12 19:03:45 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b14a38d4a | ||
|
|
179e435cd0 | ||
|
|
f963e9cfe8 | ||
|
|
e5aa9add02 | ||
|
|
329115b148 | ||
|
|
ff30f39b1b | ||
|
|
3802feb3fe | ||
|
|
27e9b4de87 | ||
|
|
4bedadef9d | ||
|
|
83b63250a3 | ||
|
|
2e59141ae9 | ||
|
|
020f0d2861 | ||
|
|
711b72735b | ||
|
|
436d0d2942 | ||
|
|
a284a62d96 | ||
|
|
2b1e64e414 | ||
|
|
ddee2e054a | ||
|
|
629e062f86 | ||
|
|
73e9552f06 | ||
|
|
05162998eb | ||
|
|
568f89d186 | ||
|
|
d93b7d278a | ||
|
|
0cedd98051 | ||
|
|
07ca4bfa7f | ||
|
|
78610b01f6 |
No files matched your search
@@ -1,9 +1,7 @@
|
||||
name: checks
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# The runner image ships a stale docker.io login; point docker at an empty config so
|
||||
|
||||
@@ -9,9 +9,10 @@ XGO_VERSION := go-1.27.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected] # renovate: datasource=go
|
||||
|
||||
PODMAN_TEST_IMAGE ?= quay.io/containers/podman:v5.8.4-immutable@sha256:90405e3c6a86f33c5a6f8dbdfa8e02a98143a8ba4337b84bdaec5336d2ef008a # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= node:24-bookworm@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4 # renovate: datasource=docker
|
||||
E2E_JOB_IMAGE ?= docker.gitea.com/runner-images:ubuntu-latest@sha256:0e62e56b382ebf485bff1e51a05cbe27c708545a2e91425467fd31eb3e249521 # renovate: datasource=docker
|
||||
SERVICE_IMAGE ?= nginx:1.31.4-alpine@sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913 # renovate: datasource=docker
|
||||
E2E_GITEA_IMAGE ?= gitea/gitea:main-nightly
|
||||
E2E_K3S_IMAGE ?= rancher/k3s:v1.36.4-k3s1@sha256:edad48e12bf81c3a09ac1c05c0c0ffaaa22145980b989d6fae84543a76b83657 # renovate: datasource=docker
|
||||
|
||||
LINUX_ARCHS ?= linux/amd64,linux/arm64
|
||||
DARWIN_ARCHS ?= darwin-12/amd64,darwin-12/arm64
|
||||
@@ -23,7 +24,7 @@ DOCKER_TAG ?= nightly
|
||||
DOCKER_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)
|
||||
DOCKER_ROOTLESS_REF := $(DOCKER_IMAGE):$(DOCKER_TAG)-dind-rootless
|
||||
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]3.2 # renovate: datasource=go
|
||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/[email protected]4.0 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/[email protected] # renovate: datasource=go
|
||||
|
||||
GOTEST_FLAGS ?= -race -timeout 20m -parallel 8
|
||||
@@ -170,7 +171,7 @@ E2E_CONCURRENCY ?= 8
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e: ## run Gitea compatibility tests against E2E_GITEA_IMAGE
|
||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
||||
@E2E_CONCURRENCY=$(E2E_CONCURRENCY) E2E_GITEA_IMAGE=$(E2E_GITEA_IMAGE) E2E_K3S_IMAGE=$(E2E_K3S_IMAGE) E2E_JOB_IMAGE=$(E2E_JOB_IMAGE) GO=$(GO) SERVICE_IMAGE=$(SERVICE_IMAGE) ./tools/test-e2e.sh
|
||||
|
||||
.PHONY: install
|
||||
install: $(GOFILES) ## install the runner binary via `go install`
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Docker Engine Community version is required for docker mode. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||
On the runner:
|
||||
|
||||
- `git` 2.34.1 or newer on `PATH`, to download actions and reusable workflows.
|
||||
- `docker` or `podman` with `podman.socket` enabled, for docker mode, Docker actions and `docker://` steps. To install Docker CE, follow the official [install instructions](https://docs.docker.com/engine/install/).
|
||||
|
||||
Where steps run, in the job image or on the runner in host mode:
|
||||
|
||||
- `bash` or `sh` for `run` steps, `pwsh` or `powershell` on a Windows host.
|
||||
- `node` for JavaScript actions and `hashFiles()`.
|
||||
- `git` 2.34.1 or newer for [`builtin:checkout`](docs/builtin-actions.md).
|
||||
- `git-lfs` and `ssh` for `builtin:checkout`, only when its `lfs` or `ssh-key` input is used.
|
||||
|
||||
Kubernetes mode needs [a few more tools](docs/kubernetes.md) in job images.
|
||||
|
||||
### Download pre-built binary
|
||||
|
||||
@@ -79,53 +91,24 @@ If the registry succeed, it will run immediately. Next time, you could run the r
|
||||
|
||||
### Run with docker
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> -v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:nightly
|
||||
```
|
||||
|
||||
Mount a volume on `/data` if you want the registration file and optional config to survive container recreation (see [scripts/run.sh](scripts/run.sh)).
|
||||
|
||||
> **`/data` does not hold the image cache.** It is the runner's working directory and contains only the `.runner` registration file and, optionally, your config file. Images pulled for jobs live in the *Docker daemon's* data root, which for the `dind` flavours is inside the container (`/var/lib/docker`, or `/home/rootless/.local/share/docker` for `dind-rootless`). To keep the image cache across restarts, give that path its own volume as well — otherwise every new container re-pulls the job images. With the `basic` flavour the images live on whichever daemon you point the runner at, so there is nothing extra to persist.
|
||||
|
||||
### Image flavours
|
||||
|
||||
The image is published in three flavours, all built from the single multi-stage [Dockerfile](Dockerfile) in this repository. They differ only in how a Docker daemon is made available to the jobs the runner executes; the `gitea-runner` binary inside them is identical.
|
||||
|
||||
| Tag | Build target | Base image | Docker daemon | Process supervisor | Runs as |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `latest` (and `<version>`) | `basic` | `alpine` | none — uses an external daemon you provide | [`tini`](https://github.com/krallin/tini) | `root` |
|
||||
| `latest-dind` | `dind` | `docker:dind` | bundled, started inside the container | [`s6`](https://skarnet.org/software/s6/) | `root` (privileged) |
|
||||
| `latest-dind-rootless` | `dind-rootless` | `docker:dind-rootless` | bundled, started rootless inside the container | [`s6`](https://skarnet.org/software/s6/) | `rootless` (UID 1000) |
|
||||
|
||||
#### `latest` — basic
|
||||
|
||||
The default flavour ships only the runner on a minimal Alpine base. It contains **no Docker daemon of its own**: jobs that use `docker://` images need a daemon supplied from outside the container, typically by bind-mounting the host's socket:
|
||||
|
||||
```bash
|
||||
docker run -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock --name my_runner gitea/runner:latest
|
||||
```
|
||||
|
||||
`tini` is the entrypoint (it reaps zombie processes), and it just runs [`scripts/run.sh`](scripts/run.sh), which registers the runner on first start and then execs `gitea-runner daemon`. This flavour does not need `--privileged`. The trade-off is that jobs share the host's daemon, so they can see other containers and images on that daemon.
|
||||
|
||||
#### `latest-dind` — Docker-in-Docker
|
||||
|
||||
This flavour is based on the official `docker:dind` image and bundles its own Docker daemon, so it needs no external socket — only the `--privileged` flag that Docker-in-Docker requires:
|
||||
The recommended Docker-in-Docker image runs its own Docker daemon for jobs. Jobs never get the host's Docker socket, which is equivalent to root access on the host.
|
||||
|
||||
```bash
|
||||
docker run --privileged -e GITEA_INSTANCE_URL=https://your_gitea.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<your_token> \
|
||||
--name my_runner gitea/runner:latest-dind
|
||||
-v "$PWD/data:/data" -v runner-docker:/var/lib/docker --name my_runner gitea/runner:nightly-dind
|
||||
```
|
||||
|
||||
Two processes have to run side by side here (the Docker daemon and the runner), so the entrypoint is the [`s6`](https://skarnet.org/software/s6/) supervision tree under [`scripts/s6`](scripts/s6) instead of `tini`. `s6` starts `dockerd`, and the runner service waits for the daemon to come up (`s6-svwait`) before launching [`run.sh`](scripts/run.sh). Each container has a private daemon isolated from the host's, at the cost of running privileged.
|
||||
The volumes keep the runner's registration and the job image cache across container recreation.
|
||||
|
||||
#### `latest-dind-rootless` — rootless Docker-in-Docker
|
||||
### Image flavours
|
||||
|
||||
Same idea as `dind`, but built on `docker:dind-rootless` so the bundled daemon and the runner run as an unprivileged user (`rootless`, UID 1000) rather than `root`. `DOCKER_HOST` is preset to `unix:///run/user/1000/docker.sock` so the runner talks to the rootless daemon. This reduces the blast radius compared to the privileged `dind` flavour, but rootless Docker carries the usual rootless limitations (networking, cgroups, storage drivers, and some operations that need additional host configuration such as `/etc/subuid` / `/etc/subgid` mappings and unprivileged user-namespace support).
|
||||
| Tag | Docker daemon for jobs |
|
||||
| --- | --- |
|
||||
| `latest-dind` (recommended) | Bundled, needs `--privileged`. |
|
||||
| `latest-dind-rootless` | Bundled, runs as a non-root user (UID 1000) for stronger isolation. Needs `--privileged` and [rootless Docker](https://docs.docker.com/engine/security/rootless/) support on the host. Keeps its image cache in `/home/rootless/.local/share/docker`. |
|
||||
| `latest` | None. Mount the host's socket with `-v /var/run/docker.sock:/var/run/docker.sock`, or set `DOCKER_HOST`. |
|
||||
|
||||
> **The UID is fixed at 1000.** It comes from the `rootless` user baked into the upstream `docker:dind-rootless` base image, and the bundled daemon always listens on `/run/user/1000/docker.sock` inside the container, so running this flavour as a different user (`--user 1001`) does not work. If you need the runner to talk to a *host* rootless daemon that runs under some other UID, use the `basic` flavour instead and bind-mount that daemon's socket (see [examples/vm/rootless-docker.md](examples/vm/rootless-docker.md)); pointing `DOCKER_HOST` at a host socket from inside `dind-rootless` will not work. Changing the UID otherwise means rebuilding the image from a base with a different `rootless` user.
|
||||
|
||||
> **Note on Podman:** these images target the Docker daemon. The bundled `dind`/`dind-rootless` daemons are `dockerd`, not Podman, and the `basic` flavour expects a Docker-compatible socket. Running them under rootless Podman is not a supported configuration, though pointing the `basic` flavour at a Podman socket that emulates the Docker API may work for some workloads.
|
||||
Versioned and `nightly` tags carry the same suffixes, for example `nightly-dind`. The images target Docker, not Podman.
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -186,7 +169,7 @@ Of the [runner images](https://gitea.com/gitea/runner-images), the `-full` flavo
|
||||
|
||||
#### Environment variables
|
||||
|
||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
Earlier releases let a few environment variables (`GITEA_DEBUG`, `GITEA_TRACE`, `GITEA_RUNNER_CAPACITY`, `GITEA_RUNNER_FILE`, `GITEA_RUNNER_ENVIRON`, `GITEA_RUNNER_ENV_FILE`) override parts of the config. They are gone, use the YAML file for all settings. Config values can read the runner's environment as `${NAME}`, for example `capacity: ${RUNNER_CAPACITY}`. An unset `NAME` fails the load, and `$${NAME}` is a literal `${NAME}`. The Docker images still read their own variables, such as `RUNNER_STATE_FILE`, see [scripts/run.sh](scripts/run.sh) and the container documentation below.
|
||||
|
||||
### Labels
|
||||
|
||||
@@ -201,10 +184,10 @@ A label is written as:
|
||||
| Part | Meaning |
|
||||
| --- | --- |
|
||||
| `name` | The name a workflow refers to in `runs-on`, e.g. `ubuntu-latest`. |
|
||||
| `schema` | Either `docker` or `host`. Defaults to `host` when omitted. |
|
||||
| `args` | Only used by the `docker` schema: the image to run the job in. |
|
||||
| `schema` | `docker`, `kubernetes` or `host`. Defaults to `host` when omitted. |
|
||||
| `args` | Only used by the `docker` and `kubernetes` schemas: the image to run the job in. |
|
||||
|
||||
Two schemas are supported:
|
||||
Three schemas are supported:
|
||||
|
||||
- **`docker://<image>`** — the job runs inside a container created from `<image>`:
|
||||
|
||||
@@ -212,6 +195,12 @@ Two schemas are supported:
|
||||
ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
- **`kubernetes://<image>`** — the job runs as a Kubernetes pod created from `<image>`, see **[docs/kubernetes.md](docs/kubernetes.md)**:
|
||||
|
||||
```text
|
||||
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
- **`host`** — the job's steps run directly on the machine the runner is on, using the tools installed there:
|
||||
|
||||
```text
|
||||
@@ -226,9 +215,9 @@ ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,macos:host
|
||||
|
||||
a workflow with `runs-on: ubuntu-latest` is executed in the `runner-images:ubuntu-latest` container, and one with `runs-on: macos` is executed directly on the host.
|
||||
|
||||
Names may themselves contain a colon (for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`); only `host` and `docker` are treated as schemas.
|
||||
Names may themselves contain a colon, for example `pool:e57e18d4-10d4-406f-93bf-60f127221bdd`. Only `host`, `docker` and `kubernetes` are treated as schemas.
|
||||
|
||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs: in `runner.default_image` where docker is available, on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
Jobs run on `runner.default_image` when no runner label matches their `runs-on`: as a pod on runners with `kubernetes` labels, otherwise in a container, or on the host when docker is unavailable. Suitable images are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
Labels are chosen at registration time (`--labels`, or the interactive prompt) and can be changed afterwards by editing `runner.labels` in the config file, or in the Gitea UI under the runner's settings.
|
||||
|
||||
@@ -275,7 +264,7 @@ volumes:
|
||||
- ${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data
|
||||
```
|
||||
|
||||
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. The proxy removes containers, networks and volumes after post steps and the completed hook. Named volumes created through it are job-scoped. To retain resources, mount the daemon socket explicitly in `container.options`. Host jobs use their existing Docker access, where `.` works.
|
||||
Linux container jobs use a Docker proxy when socket sharing and permissions allow it. Other setups use the daemon socket directly. Host jobs use their existing Docker access, where `.` works. The proxy removes the containers, networks and volumes a job creates, `docker compose` services included, after post steps and the completed hook. Set `container.sweep: false` when jobs deploy services to the runner's Docker daemon.
|
||||
|
||||
#### Proxy
|
||||
|
||||
@@ -380,6 +369,7 @@ While the runner is idle it cleans up after earlier jobs:
|
||||
- only purely numeric subdirectories under `container.workdir_parent` are treated as task workspaces and may be removed
|
||||
- cleanup assumes `container.workdir_parent` is not shared across multiple runners
|
||||
- on runners that use docker, per-job networks left behind by jobs the runner did not live to tear down are removed, identified by the `com.gitea.runner.uuid` label carrying this runner's uuid
|
||||
- on runners with `kubernetes` labels, job pods with that label older than `runner.workdir_cleanup_age` are removed the same way, together with their Secrets
|
||||
- cleanup runs every `runner.idle_cleanup_interval` (default: `10m`; set `0` to disable), and setting either knob to `0` disables all of the above
|
||||
|
||||
#### Post-task script (`runner.post_task_script`)
|
||||
@@ -404,7 +394,7 @@ See **[docs/job-hooks.md](docs/job-hooks.md)** for the execution order, environm
|
||||
|
||||
#### OpenTelemetry (`OTEL_*`)
|
||||
|
||||
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
|
||||
Set `OTEL_EXPORTER_OTLP_ENDPOINT` in the runner's environment to send a trace of every job, and the runner's metrics, to an OpenTelemetry collector. See **[docs/telemetry.md](docs/telemetry.md)**.
|
||||
|
||||
#### Local job logs (`log.job.dir`)
|
||||
|
||||
|
||||
+213
-357
File diff suppressed because it is too large.
Load diff
+111
-141
@@ -6,6 +6,7 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,18 +16,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
gogitconfig "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitfile "github.com/go-git/go-git/v5/plumbing/transport/file"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -88,14 +83,27 @@ func cleanGitHooks(dir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
||||
func TestFindGitMetadataOfSHA256RepositoryUsesOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
basedir := t.TempDir()
|
||||
const remoteURL = "https://github.com/owner/repo.git"
|
||||
require.NoError(t, gitCmd("init", basedir))
|
||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", basedir))
|
||||
require.NoError(t, cleanGitHooks(basedir))
|
||||
require.NoError(t, gitCmd("-C", basedir, "commit", "--allow-empty", "-m", "init"))
|
||||
require.NoError(t, gitCmd("-C", basedir, "remote", "add", "origin", remoteURL))
|
||||
require.NoError(t, gitCmd("-C", basedir, "config", "--add", "remote.origin.url", "https://github.com/other/repo.git"))
|
||||
|
||||
url, err := findGitRemoteURL(context.Background(), basedir, "origin")
|
||||
shortSHA, sha, err := FindGitRevision(context.Background(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, gitRevParse(t, basedir, "HEAD"), sha)
|
||||
require.Len(t, sha, 64)
|
||||
require.Equal(t, sha[:7], shortSHA)
|
||||
|
||||
ref, err := FindGitRef(context.Background(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "refs/heads/main", ref)
|
||||
|
||||
url, err := findGitRemoteURL(context.Background(), basedir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, remoteURL, url)
|
||||
|
||||
@@ -105,6 +113,7 @@ func TestFindGithubRepoUsesOrigin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitFindRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
basedir := t.TempDir()
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
@@ -191,7 +200,9 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.sh"), []byte("#!/bin/sh\necho hi\n"), 0o755))
|
||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.sh"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v2"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v2"))
|
||||
@@ -205,6 +216,13 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
fullSha := strings.TrimSpace(string(out))
|
||||
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:refs/heads/"+fullSha[:4]))
|
||||
|
||||
hostConfig := filepath.Join(t.TempDir(), "gitconfig")
|
||||
require.NoError(t, os.WriteFile(hostConfig, []byte("[core]\n\tautocrlf = true\n[url \""+remoteDir+"\"]\n\tinsteadOf = https://example.invalid/action\n"), 0o644))
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfig)
|
||||
t.Setenv("GIT_DIR", t.TempDir())
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Err error
|
||||
Ref string
|
||||
@@ -225,26 +243,42 @@ func TestGitCloneExecutor(t *testing.T) {
|
||||
Err: &Error{ErrShortRef, fullSha},
|
||||
Ref: fullSha[:7],
|
||||
},
|
||||
"HEAD": {
|
||||
Ref: "HEAD",
|
||||
},
|
||||
"hex branch named like its own commit": {
|
||||
Ref: fullSha[:4],
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
URL: "https://example.invalid/action",
|
||||
Ref: tt.Ref,
|
||||
Dir: t.TempDir(),
|
||||
Dir: dir,
|
||||
})
|
||||
|
||||
err := clone(context.Background())
|
||||
if tt.Err != nil {
|
||||
assert.Error(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.Err, err)
|
||||
} else {
|
||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
assert.Equal(t, tt.Err, clone(context.Background()), "a retry on the cache keeps the error")
|
||||
return
|
||||
}
|
||||
assert.Empty(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
marker := filepath.Join(dir, "marker")
|
||||
require.NoError(t, os.WriteFile(marker, nil, 0o644))
|
||||
require.NoError(t, clone(context.Background()))
|
||||
assert.FileExists(t, marker, "an insteadOf rewrite must not evict the cache")
|
||||
script, err := os.ReadFile(filepath.Join(dir, "action.sh"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "#!/bin/sh\necho hi\n", string(script), "host core.autocrlf must not convert action files")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
createRemote := func(message string) string {
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -268,6 +302,11 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
nested := filepath.Join(cacheDir, "nested")
|
||||
require.NoError(t, os.Mkdir(nested, 0o755))
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: oldRemoteDir, Ref: "main", Dir: nested})(t.Context()))
|
||||
assert.DirExists(t, filepath.Join(nested, ".git"), "an empty cache dir inside a clone of the same URL gets its own repository")
|
||||
|
||||
markerPath := filepath.Join(cacheDir, "stale-marker")
|
||||
require.NoError(t, os.WriteFile(markerPath, []byte("stale"), 0o644))
|
||||
|
||||
@@ -277,7 +316,7 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
Dir: cacheDir,
|
||||
})(t.Context()))
|
||||
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir, "origin")
|
||||
originURL, err := findGitRemoteURL(t.Context(), cacheDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRemoteDir, originURL)
|
||||
|
||||
@@ -289,61 +328,36 @@ func TestGitCloneExecutorReclonesWhenOriginURLChanges(t *testing.T) {
|
||||
require.True(t, os.IsNotExist(err), "stale cached directory should be removed before recloning")
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorNonFastForwardRef(t *testing.T) {
|
||||
// Simulate the scenario where a remote ref (e.g. a GitHub PR head ref) changes
|
||||
// non-fast-forward between two fetches. Before the fix, the fetch used Force=false,
|
||||
// causing go-git to return ErrForceNeeded and short-circuit the checkout.
|
||||
|
||||
// Create a bare "remote" repo with an initial commit on main and a feature branch.
|
||||
func TestGitCloneExecutorFollowsRemoteRefChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
|
||||
// We need a working clone to push commits from.
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("clone", remoteDir, workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main"))
|
||||
|
||||
// Create a feature branch (simulates refs/pull/N/head).
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "feature"))
|
||||
|
||||
// First clone via the executor — should succeed and cache the repo.
|
||||
cloneDir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir,
|
||||
Ref: "main",
|
||||
Dir: cloneDir,
|
||||
})
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "main", Dir: dir})
|
||||
require.NoError(t, clone(context.Background()))
|
||||
|
||||
// Now force-push the feature branch to a non-fast-forward commit (simulates
|
||||
// a PR rebase). This makes refs/heads/feature non-fast-forward.
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "branch", "-D", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "reset", "--hard", "HEAD~1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "feature-rewritten"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "feature"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "--force", "origin", "main"))
|
||||
require.NoError(t, clone(context.Background()), "a non-fast-forward ref must update")
|
||||
assert.Equal(t, "feature-rewritten", gitHeadSubject(t, dir))
|
||||
|
||||
// Also advance main so we can verify the clone picks up the new commit.
|
||||
require.NoError(t, gitCmd("-C", workDir, "checkout", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "second"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
|
||||
// Second clone to the same directory — before the fix this returned ErrForceNeeded
|
||||
// and left the working tree at the old commit.
|
||||
err := clone(context.Background())
|
||||
require.NoError(t, err, "fetch with non-fast-forward refs must not fail when Force=true")
|
||||
|
||||
// Verify the working tree was actually updated to the latest main commit.
|
||||
out, err := exec.Command("git", "-C", cloneDir, "log", "--oneline", "-1", "--format=%s").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit")
|
||||
tagClone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remoteDir, Ref: "v9", Dir: t.TempDir()})
|
||||
require.Error(t, tagClone(context.Background()))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v9"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "v9"))
|
||||
require.NoError(t, tagClone(context.Background()), "a tag published after a failed lookup must be fetched")
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Build a local "remote" with a single commit on main.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -390,6 +404,7 @@ func TestGitCloneExecutorOfflineMode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
@@ -428,6 +443,7 @@ func TestGitCloneExecutorQuietDemotesCloneLine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Build a local "remote" with several commits on main plus a tag, so a full clone would pull noticeably more history than a shallow one.
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
@@ -471,12 +487,11 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
return remote
|
||||
}
|
||||
|
||||
t.Run("commit hash falls back to a full clone when the remote refuses unadvertised objects", func(t *testing.T) {
|
||||
t.Run("commit hash resolves when the remote accepts a direct shallow fetch", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: unadvertisedRemote(t, false), Ref: unadvertisedSHA, Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
assert.NoFileExists(t, shallowMarker(dir))
|
||||
assert.Equal(t, unadvertisedSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
@@ -503,76 +518,46 @@ func TestGitCloneExecutorShallow(t *testing.T) {
|
||||
assert.Equal(t, olderSHA, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
|
||||
t.Run("moving branch updates while staying shallow", func(t *testing.T) {
|
||||
t.Run("moving hexadecimal-named branch updates while staying shallow", func(t *testing.T) {
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "deadbeef", Dir: dir, Depth: 1,
|
||||
})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
require.Equal(t, "c3", gitHeadSubject(t, dir))
|
||||
|
||||
// Advance main on the remote, then reuse the existing shallow clone.
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c4"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "push", "origin", "main:deadbeef"))
|
||||
|
||||
require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: remoteDir, Ref: "main", Dir: dir, Depth: 1,
|
||||
})(t.Context()))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, "c4", gitHeadSubject(t, dir), "reused shallow clone should update to the new tip")
|
||||
assert.FileExists(t, shallowMarker(dir), "repo should remain shallow after update")
|
||||
assert.Equal(t, 1, gitRevCount(t, dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorTransportSessions(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--initial-branch=main", workDir))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "c1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "v1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "-a", "v2", "-m", "v2"))
|
||||
|
||||
for name, tt := range map[string]struct {
|
||||
Ref string
|
||||
Depth int
|
||||
}{
|
||||
"shallow branch": {"main", 1},
|
||||
"full clone branch": {"main", 0},
|
||||
"full lightweight tag": {"v1", 0},
|
||||
"full annotated tag": {"v2", 0},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
counter := installCountingTransport(t)
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: workDir, Ref: tt.Ref, Dir: dir, Depth: tt.Depth,
|
||||
})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Swap(0), "cold clone")
|
||||
assert.Equal(t, gitRevParse(t, workDir, tt.Ref+"^{commit}"), gitRevParse(t, dir, "HEAD"))
|
||||
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Swap(0), "unchanged warm cache")
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "action.yml"), []byte(name), 0o644))
|
||||
require.NoError(t, gitCmd("-C", workDir, "add", "action.yml"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "commit", "-m", name))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "v1"))
|
||||
require.NoError(t, gitCmd("-C", workDir, "tag", "--force", "-a", "v2", "-m", "v2"))
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("staged"), 0o644))
|
||||
require.NoError(t, gitCmd("-C", dir, "add", "action.yml"))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "action.yml"), []byte("unstaged"), 0o644))
|
||||
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, int64(1), counter.sessions.Load(), "updated warm cache")
|
||||
assert.Equal(t, gitRevParse(t, workDir, "HEAD"), gitRevParse(t, dir, "HEAD"))
|
||||
status, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, string(status))
|
||||
})
|
||||
}
|
||||
t.Run("sha256 full hash is cloned shallowly, reused without the remote, and its 40-character prefix is short", func(t *testing.T) {
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
require.NoError(t, gitCmd("init", "--bare", "--object-format=sha256", "--initial-branch=main", remote))
|
||||
work := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--object-format=sha256", "--initial-branch=main", work))
|
||||
require.NoError(t, gitCmd("-C", work, "remote", "add", "origin", remote))
|
||||
require.NoError(t, gitCmd("-C", work, "commit", "--allow-empty", "-m", "pinned"))
|
||||
require.NoError(t, gitCmd("-C", work, "push", "origin", "main"))
|
||||
sha := gitRevParse(t, work, "HEAD")
|
||||
dir := t.TempDir()
|
||||
clone := NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha, Dir: dir, Depth: 1})
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.FileExists(t, shallowMarker(dir))
|
||||
require.ErrorIs(t, NewGitCloneExecutor(NewGitCloneExecutorInput{URL: remote, Ref: sha[:40], Dir: dir, Depth: 1, OfflineMode: true})(t.Context()), ErrShortRef)
|
||||
require.NoError(t, os.RemoveAll(remote))
|
||||
require.NoError(t, clone(t.Context()))
|
||||
assert.Equal(t, sha, gitRevParse(t, dir, "HEAD"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
remoteDir := t.TempDir()
|
||||
require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir))
|
||||
workDir := t.TempDir()
|
||||
@@ -596,25 +581,6 @@ func TestGitCloneExecutorPinnedHashIgnoresShadowingRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type countingTransport struct {
|
||||
transport.Transport
|
||||
sessions atomic.Int64
|
||||
}
|
||||
|
||||
func (c *countingTransport) NewUploadPackSession(ep *transport.Endpoint, auth transport.AuthMethod) (transport.UploadPackSession, error) {
|
||||
c.sessions.Add(1)
|
||||
return c.Transport.NewUploadPackSession(ep, auth)
|
||||
}
|
||||
|
||||
func installCountingTransport(t *testing.T) *countingTransport {
|
||||
t.Helper()
|
||||
counter := &countingTransport{Transport: gogitfile.DefaultClient}
|
||||
gogitclient.InstallProtocol("file", counter)
|
||||
t.Cleanup(func() { gogitclient.InstallProtocol("file", gogitfile.DefaultClient) })
|
||||
|
||||
return counter
|
||||
}
|
||||
|
||||
func gitRevParse(t *testing.T, dir, rev string) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("git", "-C", dir, "rev-parse", rev).Output()
|
||||
@@ -715,12 +681,17 @@ func TestAcquireCloneLock(t *testing.T) {
|
||||
}
|
||||
|
||||
// An unresponsive remote must not pin a job: the refresh has to be interruptible.
|
||||
func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
func TestNewGitCloneExecutorFetchSendsTokenAndHonoursContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
block := make(chan struct{})
|
||||
reached := make(chan struct{})
|
||||
var once sync.Once
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||
once.Do(func() { close(reached) })
|
||||
var authorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
||||
once.Do(func() {
|
||||
authorization = request.Header.Get("Authorization")
|
||||
close(reached)
|
||||
})
|
||||
<-block
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
@@ -729,17 +700,15 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
})
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "cached-action")
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
require.NoError(t, err)
|
||||
_, err = repo.CreateRemote(&gogitconfig.RemoteConfig{Name: "origin", URLs: []string{server.URL}})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gitCmd("init", dir))
|
||||
require.NoError(t, gitCmd("-C", dir, "remote", "add", "origin", server.URL))
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir})(ctx)
|
||||
done <- NewGitCloneExecutor(NewGitCloneExecutorInput{URL: server.URL, Ref: "main", Dir: dir, Token: "secret"})(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -747,6 +716,7 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the executor never reached the remote")
|
||||
}
|
||||
assert.Equal(t, "Basic "+base64.StdEncoding.EncodeToString([]byte("token:secret")), authorization)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
|
||||
+40
-34
@@ -10,6 +10,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type interfaceIP struct {
|
||||
net.IP
|
||||
net.Interface
|
||||
}
|
||||
|
||||
// GetOutboundIP returns an outbound IP address of this machine.
|
||||
// It tries to access the internet and returns the local IP address of the connection.
|
||||
// If the machine cannot access the internet, it returns a preferred IP address from network interfaces.
|
||||
@@ -26,11 +31,7 @@ func GetOutboundIP() net.IP {
|
||||
|
||||
// So the machine cannot access the internet. Pick an IP address from network interfaces.
|
||||
if ifs, err := net.Interfaces(); err == nil {
|
||||
type IP struct {
|
||||
net.IP
|
||||
net.Interface
|
||||
}
|
||||
var ips []IP
|
||||
var ips []interfaceIP
|
||||
for _, i := range ifs {
|
||||
if addrs, err := i.Addrs(); err == nil {
|
||||
for _, addr := range addrs {
|
||||
@@ -42,40 +43,45 @@ func GetOutboundIP() net.IP {
|
||||
ip = v.IP
|
||||
}
|
||||
if ip.IsGlobalUnicast() {
|
||||
ips = append(ips, IP{ip, i})
|
||||
ips = append(ips, interfaceIP{ip, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ips) > 1 {
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
ifi := ips[i].Interface
|
||||
ifj := ips[j].Interface
|
||||
|
||||
// ethernet is preferred
|
||||
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
ipi := ips[i].IP
|
||||
ipj := ips[j].IP
|
||||
|
||||
// IPv4 is preferred
|
||||
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
// en0 is preferred to en1
|
||||
if ifi.Name != ifj.Name {
|
||||
return ifi.Name < ifj.Name
|
||||
}
|
||||
|
||||
// fallback
|
||||
return ipi.String() < ipj.String()
|
||||
})
|
||||
return ips[0].IP
|
||||
}
|
||||
return preferredIP(ips)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func preferredIP(ips []interfaceIP) net.IP {
|
||||
if len(ips) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Slice(ips, func(i, j int) bool {
|
||||
ifi := ips[i].Interface
|
||||
ifj := ips[j].Interface
|
||||
|
||||
// ethernet is preferred
|
||||
if vi, vj := strings.HasPrefix(ifi.Name, "e"), strings.HasPrefix(ifj.Name, "e"); vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
ipi := ips[i].IP
|
||||
ipj := ips[j].IP
|
||||
|
||||
// IPv4 is preferred
|
||||
if vi, vj := ipi.To4() != nil, ipj.To4() != nil; vi != vj {
|
||||
return vi
|
||||
}
|
||||
|
||||
// en0 is preferred to en1
|
||||
if ifi.Name != ifj.Name {
|
||||
return ifi.Name < ifj.Name
|
||||
}
|
||||
|
||||
// fallback
|
||||
return ipi.String() < ipj.String()
|
||||
})
|
||||
return ips[0].IP
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPreferredIP(t *testing.T) {
|
||||
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("192.0.2.1"), Name: "wlan0"},
|
||||
{IP: net.ParseIP("2001:db8::1"), Name: "eth0"},
|
||||
}))
|
||||
}
|
||||
@@ -75,6 +75,9 @@ const (
|
||||
HealthUnhealthy = string(container.Unhealthy)
|
||||
)
|
||||
|
||||
// marks what a runner creates for its jobs, telling its leftovers from another runner's
|
||||
const runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
|
||||
// ErrContainerNotFound reports a container the daemon no longer knows. Its text is a
|
||||
// fragment, missingContainerError composes it into the message every operation shares.
|
||||
var ErrContainerNotFound = errors.New("does not exist")
|
||||
|
||||
@@ -46,14 +46,13 @@ func RemoveImage(ctx context.Context, imageName string, force, pruneChildren boo
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
inspectImage, err := cli.ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
if _, err := cli.ImageInspect(ctx, imageName); cerrdefs.IsNotFound(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err = cli.ImageRemove(ctx, inspectImage.ID, client.ImageRemoveOptions{
|
||||
if _, err = cli.ImageRemove(ctx, imageName, client.ImageRemoveOptions{
|
||||
Force: force,
|
||||
PruneChildren: pruneChildren,
|
||||
}); err != nil {
|
||||
|
||||
@@ -24,10 +24,6 @@ import (
|
||||
const (
|
||||
networkCreateAttempts = 3
|
||||
networkCreateRetryDelay = time.Second
|
||||
|
||||
// marks the networks a runner creates for its jobs, so it can tell its own leftovers from
|
||||
// those of another runner sharing the daemon
|
||||
runnerUUIDLabel = "com.gitea.runner.uuid"
|
||||
)
|
||||
|
||||
// RemoveOrphanNetworks removes the networks this runner created for jobs whose teardown did
|
||||
@@ -91,45 +87,54 @@ func NewDockerNetworkCreateExecutor(name string, opts NewDockerNetworkCreateExec
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
return createDockerNetwork(ctx, cli, name, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
func createDockerNetwork(ctx context.Context, cli client.APIClient, name string, opts NewDockerNetworkCreateExecutorInput) error {
|
||||
// Only create the network if it doesn't exist
|
||||
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var options map[string]string
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
// the daemon's mtu only reaches its default bridge, Docker's default is left out to keep default-network-opts
|
||||
if mtu := n.Options["com.docker.network.driver.mtu"]; n.Name == network.NetworkBridge && mtu != "" && mtu != "1500" {
|
||||
options = map[string]string{"com.docker.network.driver.mtu": mtu}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Options: options,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
// For Gitea, reduce log noise
|
||||
// common.Logger(ctx).Debugf("%v", networks)
|
||||
for _, n := range networks.Items {
|
||||
if n.Name == name {
|
||||
common.Logger(ctx).Debugf("Network %v exists", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range networkCreateAttempts {
|
||||
if i > 0 {
|
||||
common.Logger(ctx).Infof("Waiting for a free docker address pool to create network %s", name)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(i) * networkCreateRetryDelay):
|
||||
}
|
||||
}
|
||||
if _, err = cli.NetworkCreate(ctx, name, client.NetworkCreateOptions{
|
||||
Driver: "bridge",
|
||||
Scope: "local",
|
||||
EnableIPv4: opts.EnableIPv4,
|
||||
EnableIPv6: opts.EnableIPv6,
|
||||
Labels: runnerLabels(opts.RunnerUUID),
|
||||
}); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isAddressPoolExhausted(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
return fmt.Errorf("docker has no address pool left for this job's network, lower runner.capacity or widen default-address-pools in the docker daemon config: %w", err)
|
||||
}
|
||||
|
||||
func runnerLabels(runnerUUID string) map[string]string {
|
||||
|
||||
@@ -57,6 +57,27 @@ func TestIsAddressPoolExhausted(t *testing.T) {
|
||||
assert.False(t, isAddressPoolExhausted(cerrdefs.ErrInvalidArgument.WithMessage("invalid subnet 10.0.0.0/8: it overlaps with an existing network")))
|
||||
}
|
||||
|
||||
func TestCreateDockerNetworkInheritsDaemonMTU(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
bridgeOptions, want map[string]string
|
||||
}{
|
||||
{
|
||||
map[string]string{"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1", "com.docker.network.driver.mtu": "1450"},
|
||||
map[string]string{"com.docker.network.driver.mtu": "1450"},
|
||||
},
|
||||
{map[string]string{"com.docker.network.driver.mtu": "1500"}, nil},
|
||||
} {
|
||||
client := &mockDockerClient{}
|
||||
client.On("NetworkList", ctx, mobyclient.NetworkListOptions{}).
|
||||
Return(mobyclient.NetworkListResult{Items: []network.Summary{{Name: network.NetworkBridge, Options: tc.bridgeOptions}}}, nil)
|
||||
client.On("NetworkCreate", ctx, "job-network", mobyclient.NetworkCreateOptions{Driver: "bridge", Scope: "local", Options: tc.want}).
|
||||
Return(mobyclient.NetworkCreateResult{}, nil)
|
||||
require.NoError(t, createDockerNetwork(ctx, client, "job-network", NewDockerNetworkCreateExecutorInput{}))
|
||||
client.AssertExpectations(t)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkConnect(ctx context.Context, id string, opts mobyclient.NetworkConnectOptions) (mobyclient.NetworkConnectResult, error) {
|
||||
return mobyclient.NetworkConnectResult{}, m.Called(ctx, id, opts).Error(0)
|
||||
}
|
||||
|
||||
+60
-94
@@ -7,8 +7,8 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -25,16 +25,12 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
|
||||
"dario.cat/mergo"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/connhelper"
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
@@ -982,42 +978,7 @@ func (cr *containerReference) copyDir(dstPath, srcPath string, useGitIgnore, ski
|
||||
logger.Error(err)
|
||||
}
|
||||
}(tarFile)
|
||||
tw := tar.NewWriter(tarFile)
|
||||
|
||||
srcPrefix := filepath.Dir(srcPath)
|
||||
if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) {
|
||||
srcPrefix += string(filepath.Separator)
|
||||
}
|
||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||
|
||||
var ignorer gitignore.Matcher
|
||||
if useGitIgnore {
|
||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||
if err != nil {
|
||||
logger.Debugf("Error loading .gitignore: %v", err)
|
||||
}
|
||||
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
|
||||
fc := &filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.TarCollector{
|
||||
TarWriter: tw,
|
||||
UID: cr.UID,
|
||||
GID: cr.GID,
|
||||
DstDir: dstPath[1:],
|
||||
},
|
||||
}
|
||||
|
||||
err = filepath.Walk(srcPath, fc.CollectFiles(ctx, []string{}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
if err := writeDirTar(ctx, tarFile, dstPath, srcPath, useGitIgnore, skipGitDir, cr.UID, cr.GID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1042,30 +1003,12 @@ func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) c
|
||||
if cr.id == "" {
|
||||
return cr.missingContainerError("copy to %s", dstPath)
|
||||
}
|
||||
logger := common.Logger(ctx)
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
for _, file := range files {
|
||||
logger.Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
|
||||
hdr := &tar.Header{
|
||||
Name: file.Name,
|
||||
Mode: file.Mode,
|
||||
Size: int64(len(file.Body)),
|
||||
Uid: cr.UID,
|
||||
Gid: cr.GID,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
if err := writeFilesTar(ctx, &buf, cr.UID, cr.GID, files...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debugf("Extracting content to '%s'", dstPath)
|
||||
common.Logger(ctx).Debugf("Extracting content to '%s'", dstPath)
|
||||
_, err := cr.cli.CopyToContainer(ctx, cr.id, client.CopyToContainerOptions{
|
||||
DestinationPath: dstPath,
|
||||
Content: &buf,
|
||||
@@ -1175,6 +1118,10 @@ func sanitizeOptionsHostConfig(logger logrus.FieldLogger, hostConfig, trusted *c
|
||||
// systempaths=unconfined lands in these two rather than in SecurityOpt
|
||||
resetOption(logger, "--security-opt", &hostConfig.MaskedPaths, trusted.MaskedPaths)
|
||||
resetOption(logger, "--security-opt", &hostConfig.ReadonlyPaths, trusted.ReadonlyPaths)
|
||||
resetOption(logger, "--log-driver", &hostConfig.LogConfig.Type, trusted.LogConfig.Type) // drivers run in the daemon, egressing from its network
|
||||
resetOption(logger, "--log-opt", &hostConfig.LogConfig.Config, trusted.LogConfig.Config)
|
||||
resetOption(logger, "--oom-score-adj", &hostConfig.OomScoreAdj, trusted.OomScoreAdj) // -1000 exempts it from the host OOM killer
|
||||
resetOption(logger, "--oom-kill-disable", &hostConfig.OomKillDisable, trusted.OomKillDisable)
|
||||
|
||||
// a driver mounts what it likes, e.g. local with device= binds any host path, which
|
||||
// valid_volumes never gets to see
|
||||
@@ -1216,9 +1163,9 @@ func parseOptionsHostConfig(options string) (*container.HostConfig, error) {
|
||||
// sanitizeConfig remove the invalid configurations from `config` and `hostConfig`
|
||||
func (cr *containerReference) sanitizeConfig(ctx context.Context, config *container.Config, hostConfig *container.HostConfig) (*container.Config, *container.HostConfig) {
|
||||
logger := common.Logger(ctx)
|
||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
||||
|
||||
if len(cr.input.ValidVolumes) > 0 {
|
||||
matcher := newValidVolumeMatcher(ctx, cr.input.ValidVolumes)
|
||||
// sanitize binds
|
||||
sanitizedBinds := make([]string, 0, len(hostConfig.Binds))
|
||||
for _, bind := range hostConfig.Binds {
|
||||
@@ -1232,33 +1179,37 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
continue
|
||||
}
|
||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type)) {
|
||||
if matcher.isValid(parsed.Source, mount.Type(parsed.Type), parsed.ReadOnly) {
|
||||
if matcher.needsNoCopy(parsed.Source, mount.Type(parsed.Type)) && (parsed.Volume == nil || !parsed.Volume.NoCopy) {
|
||||
bind += ",nocopy"
|
||||
}
|
||||
sanitizedBinds = append(sanitizedBinds, bind)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", parsed.Source)
|
||||
}
|
||||
}
|
||||
hostConfig.Binds = sanitizedBinds
|
||||
// sanitize mounts
|
||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
if matcher.isValid(mt.Source, mt.Type) {
|
||||
sanitizedMounts = append(sanitizedMounts, mt)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||
}
|
||||
}
|
||||
hostConfig.Mounts = sanitizedMounts
|
||||
} else {
|
||||
for _, bind := range hostConfig.Binds {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", bind)
|
||||
}
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
hostConfig.Binds = []string{}
|
||||
}
|
||||
// sanitize mounts
|
||||
sanitizedMounts := make([]mount.Mount, 0, len(hostConfig.Mounts))
|
||||
for _, mt := range hostConfig.Mounts {
|
||||
readOnly := mt.ReadOnly && (mt.BindOptions == nil || !mt.BindOptions.ReadOnlyNonRecursive) // bind-recursive=writable leaves submounts writable
|
||||
if matcher.isValid(mt.Source, mt.Type, readOnly) {
|
||||
if matcher.needsNoCopy(mt.Source, mt.Type) {
|
||||
mt.VolumeOptions = cmp.Or(mt.VolumeOptions, &mount.VolumeOptions{})
|
||||
mt.VolumeOptions.NoCopy = true
|
||||
}
|
||||
sanitizedMounts = append(sanitizedMounts, mt)
|
||||
} else {
|
||||
logger.Warnf("[%s] is not a valid volume, will be ignored", mt.Source)
|
||||
}
|
||||
hostConfig.Binds = []string{}
|
||||
hostConfig.Mounts = []mount.Mount{}
|
||||
}
|
||||
hostConfig.Mounts = sanitizedMounts
|
||||
|
||||
return config, hostConfig
|
||||
}
|
||||
@@ -1298,26 +1249,37 @@ func overlayVolumes(dst, src *container.HostConfig) {
|
||||
}
|
||||
|
||||
type validVolumeMatcher struct {
|
||||
allowAll bool
|
||||
named []string
|
||||
host []string
|
||||
allowAll bool
|
||||
allowAllReadOnly bool
|
||||
named []validVolumeRule
|
||||
host []validVolumeRule
|
||||
}
|
||||
|
||||
type validVolumeRule struct {
|
||||
pattern string
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolumeMatcher {
|
||||
logger := common.Logger(ctx)
|
||||
ret := validVolumeMatcher{
|
||||
named: make([]string, 0, len(validVolumes)),
|
||||
host: make([]string, 0, len(validVolumes)),
|
||||
named: make([]validVolumeRule, 0, len(validVolumes)),
|
||||
host: make([]validVolumeRule, 0, len(validVolumes)),
|
||||
}
|
||||
|
||||
for _, v := range validVolumes {
|
||||
v, readOnly := strings.CutSuffix(v, ":ro")
|
||||
if v == "**" {
|
||||
ret.allowAll = true
|
||||
if readOnly {
|
||||
ret.allowAllReadOnly = true
|
||||
} else {
|
||||
ret.allowAll = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isHostVolumePattern(v) {
|
||||
if doublestar.ValidatePattern(v) {
|
||||
ret.named = append(ret.named, v)
|
||||
ret.named = append(ret.named, validVolumeRule{pattern: v, readOnly: readOnly})
|
||||
} else {
|
||||
logger.Errorf("invalid volume pattern %s", v)
|
||||
}
|
||||
@@ -1329,7 +1291,7 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
||||
continue
|
||||
}
|
||||
if doublestar.ValidatePathPattern(normalized) {
|
||||
ret.host = append(ret.host, normalized)
|
||||
ret.host = append(ret.host, validVolumeRule{pattern: normalized, readOnly: readOnly})
|
||||
} else {
|
||||
logger.Errorf("invalid volume pattern %s", normalized)
|
||||
}
|
||||
@@ -1338,30 +1300,34 @@ func newValidVolumeMatcher(ctx context.Context, validVolumes []string) validVolu
|
||||
return ret
|
||||
}
|
||||
|
||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type) bool {
|
||||
if m.allowAll {
|
||||
func (m validVolumeMatcher) isValid(source string, sourceType mount.Type, readOnly bool) bool {
|
||||
if m.allowAll || (m.allowAllReadOnly && readOnly) {
|
||||
return true
|
||||
}
|
||||
if sourceType == mount.TypeTmpfs && source == "" { // backed by memory, exposes nothing from the host, same as --tmpfs
|
||||
return true
|
||||
}
|
||||
rules, match := m.named, doublestar.MatchUnvalidated
|
||||
if isHostVolumeSource(source, sourceType) {
|
||||
normalized, err := normalizeHostVolumePath(source)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range m.host {
|
||||
if doublestar.PathMatchUnvalidated(pattern, normalized) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
rules, match, source = m.host, doublestar.PathMatchUnvalidated, normalized
|
||||
}
|
||||
for _, pattern := range m.named {
|
||||
if doublestar.MatchUnvalidated(pattern, source) {
|
||||
for _, rule := range rules {
|
||||
if (readOnly || !rule.readOnly) && match(rule.pattern, source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// needsNoCopy reports a volume only `:ro` rules allow, as dockerd fills an empty volume from the image even when mounted read-only.
|
||||
func (m validVolumeMatcher) needsNoCopy(source string, sourceType mount.Type) bool {
|
||||
return sourceType == mount.TypeVolume && !m.isValid(source, sourceType, false)
|
||||
}
|
||||
|
||||
func isHostVolumePattern(pattern string) bool {
|
||||
return filepath.IsAbs(pattern) ||
|
||||
strings.HasPrefix(pattern, "."+string(filepath.Separator)) ||
|
||||
|
||||
@@ -137,6 +137,11 @@ func (m *mockDockerClient) NetworkList(ctx context.Context, opts mobyclient.Netw
|
||||
return args.Get(0).(mobyclient.NetworkListResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkCreate(ctx context.Context, name string, opts mobyclient.NetworkCreateOptions) (mobyclient.NetworkCreateResult, error) {
|
||||
args := m.Called(ctx, name, opts)
|
||||
return args.Get(0).(mobyclient.NetworkCreateResult), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockDockerClient) NetworkInspect(ctx context.Context, id string, opts mobyclient.NetworkInspectOptions) (mobyclient.NetworkInspectResult, error) {
|
||||
args := m.Called(ctx, id, opts)
|
||||
return args.Get(0).(mobyclient.NetworkInspectResult), args.Error(1)
|
||||
@@ -519,10 +524,12 @@ var _ ExecutionsEnvironment = &containerReference{}
|
||||
|
||||
func TestCheckVolumes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
desc string
|
||||
validVolumes []string
|
||||
binds []string
|
||||
expectedBinds []string
|
||||
mounts []mount.Mount
|
||||
expectedMounts []mount.Mount
|
||||
}{
|
||||
{
|
||||
desc: "match all volumes",
|
||||
@@ -578,6 +585,27 @@ func TestCheckVolumes(t *testing.T) {
|
||||
"/opt/app/cache:/cache",
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "read-only rules allow only read-only mounts and copy nothing into volumes",
|
||||
validVolumes: []string{"/etc/ssl:ro", "data:ro", "shared"},
|
||||
binds: []string{"/etc/ssl:/ssl:ro", "/etc/ssl:/ssl", "data:/data:ro", "data:/data", "shared:/shared:ro"},
|
||||
expectedBinds: []string{"/etc/ssl:/ssl:ro", "data:/data:ro,nocopy", "shared:/shared:ro"},
|
||||
mounts: []mount.Mount{
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true, BindOptions: &mount.BindOptions{ReadOnlyNonRecursive: true}},
|
||||
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true},
|
||||
},
|
||||
expectedMounts: []mount.Mount{
|
||||
{Type: mount.TypeBind, Source: "/etc/ssl", Target: "/ssl", ReadOnly: true},
|
||||
{Type: mount.TypeVolume, Source: "data", Target: "/data", ReadOnly: true, VolumeOptions: &mount.VolumeOptions{NoCopy: true}},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "read-only wildcard keeps writable rules writable",
|
||||
validVolumes: []string{"**:ro", "shared"},
|
||||
binds: []string{"/etc/ssl:/ssl:ro", "data:/data", "shared:/shared"},
|
||||
expectedBinds: []string{"/etc/ssl:/ssl:ro", "shared:/shared"},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
@@ -588,9 +616,10 @@ func TestCheckVolumes(t *testing.T) {
|
||||
ValidVolumes: tc.validVolumes,
|
||||
},
|
||||
}
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds})
|
||||
_, hostConf := cr.sanitizeConfig(ctx, &container.Config{}, &container.HostConfig{Binds: tc.binds, Mounts: tc.mounts})
|
||||
assert.Equal(t, tc.expectedBinds, hostConf.Binds)
|
||||
assert.Len(t, hook.AllEntries(), len(tc.binds)-len(tc.expectedBinds)) // every drop is warned about
|
||||
assert.ElementsMatch(t, tc.expectedMounts, hostConf.Mounts)
|
||||
assert.Len(t, hook.AllEntries(), len(tc.binds)+len(tc.mounts)-len(tc.expectedBinds)-len(tc.expectedMounts)) // every drop is warned about
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -666,6 +695,9 @@ func TestSanitizeOptionsHostConfig(t *testing.T) {
|
||||
DeviceCgroupRules: []string{"a *:* rwm"},
|
||||
DeviceRequests: []container.DeviceRequest{{Count: -1, Capabilities: [][]string{{"gpu"}}}},
|
||||
Sysctls: map[string]string{"net.ipv4.ip_forward": "1"},
|
||||
LogConfig: container.LogConfig{Type: "syslog", Config: map[string]string{"syslog-address": "tcp://169.254.169.254:514"}},
|
||||
OomScoreAdj: -1000,
|
||||
OomKillDisable: new(true),
|
||||
}
|
||||
|
||||
sanitizeOptionsHostConfig(logger, hostConfig, &container.HostConfig{})
|
||||
@@ -699,7 +731,9 @@ func TestMergeContainerConfigsStripsDangerousOptionsWhenUnprivileged(t *testing.
|
||||
const dangerousOptions = "--pid=host --ipc=host --uts=host --cgroupns=host " +
|
||||
"--userns=host --cap-add=ALL --security-opt seccomp=unconfined " +
|
||||
"--security-opt apparmor=unconfined --volumes-from other --isolation process " +
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1"
|
||||
"--runtime runc --cgroup-parent /custom --sysctl net.ipv4.ip_forward=1 " +
|
||||
"--log-driver=syslog --log-opt syslog-address=tcp://169.254.169.254:514 " +
|
||||
"--oom-score-adj=-1000 --oom-kill-disable"
|
||||
|
||||
// whatever the workflow adds, an unprivileged container comes out exactly as the runner's
|
||||
// own options alone describe it, field for field
|
||||
@@ -762,6 +796,22 @@ func TestCheckVolumesRejectsEscapingHostPaths(t *testing.T) {
|
||||
assert.Empty(t, hostConf.Binds)
|
||||
}
|
||||
|
||||
func TestSanitizeConfigKeepsTmpfsMounts(t *testing.T) {
|
||||
hostConfig, _ := mergeOptions(t, "", "--mount type=tmpfs,dst=/var/lib/mysql", false)
|
||||
require.Len(t, hostConfig.Mounts, 1)
|
||||
|
||||
for _, validVolumes := range [][]string{nil, {"data"}, {"**:ro"}} {
|
||||
logger, hook := test.NewNullLogger()
|
||||
_, sanitized := (&containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}).sanitizeConfig(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{Mounts: []mount.Mount{
|
||||
hostConfig.Mounts[0],
|
||||
{Type: mount.TypeVolume, Target: "/anonymous"},
|
||||
{Type: mount.TypeTmpfs, Source: "/etc", Target: "/etc-copy"},
|
||||
}})
|
||||
assert.Equal(t, hostConfig.Mounts, sanitized.Mounts)
|
||||
assert.Len(t, hook.AllEntries(), 2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerInfoFromInspect(t *testing.T) {
|
||||
t.Run("reports no healthcheck when the image declares none", func(t *testing.T) {
|
||||
info := containerInfoFromInspect(container.InspectResponse{
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,400 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||
execProtocol = "v5.channel.k8s.io"
|
||||
)
|
||||
|
||||
type kubeClient struct {
|
||||
server, namespace, token string
|
||||
tokenFile string // re-read on every request, service account tokens rotate
|
||||
inCluster bool
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
code int
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string { return e.message }
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
var apiErr *apiError
|
||||
return errors.As(err, &apiErr) && apiErr.code == http.StatusNotFound
|
||||
}
|
||||
|
||||
func newKubeClient(kubeconfig, namespace string) (*kubeClient, error) {
|
||||
client := &kubeClient{namespace: namespace}
|
||||
transport := &http.Transport{Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
|
||||
if host := os.Getenv("KUBERNETES_SERVICE_HOST"); host != "" && kubeconfig == "" {
|
||||
if err := client.loadInCluster(host, transport.TLSClientConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := client.loadKubeconfig(kubeconfig, transport); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.http = &http.Client{Transport: transport}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *kubeClient) loadInCluster(host string, tlsConfig *tls.Config) error {
|
||||
c.server = "https://" + net.JoinHostPort(host, os.Getenv("KUBERNETES_SERVICE_PORT"))
|
||||
c.tokenFile = serviceAccountDir + "/token"
|
||||
ca, err := os.ReadFile(serviceAccountDir + "/ca.crt")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
|
||||
if c.namespace == "" {
|
||||
namespace, err := os.ReadFile(serviceAccountDir + "/namespace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.namespace = strings.TrimSpace(string(namespace))
|
||||
}
|
||||
c.inCluster = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *kubeClient) loadKubeconfig(path string, transport *http.Transport) error {
|
||||
if paths := filepath.SplitList(os.Getenv("KUBECONFIG")); path == "" && len(paths) > 0 {
|
||||
path = paths[0]
|
||||
}
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.Join(home, ".kube", "config")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no in-cluster service account and no kubeconfig: %w", err)
|
||||
}
|
||||
var config struct {
|
||||
CurrentContext string `yaml:"current-context"`
|
||||
Contexts []struct {
|
||||
Name string
|
||||
Context struct{ Cluster, User, Namespace string }
|
||||
}
|
||||
Clusters []struct {
|
||||
Name string
|
||||
Cluster struct {
|
||||
Server string
|
||||
TLSServerName string `yaml:"tls-server-name"`
|
||||
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify"`
|
||||
ProxyURL string `yaml:"proxy-url"`
|
||||
CertificateAuthority string `yaml:"certificate-authority"`
|
||||
CertificateAuthorityData string `yaml:"certificate-authority-data"`
|
||||
}
|
||||
}
|
||||
Users []struct {
|
||||
Name string
|
||||
User struct {
|
||||
Token string
|
||||
TokenFile string `yaml:"tokenFile"`
|
||||
ClientCertificate string `yaml:"client-certificate"`
|
||||
ClientCertificateData string `yaml:"client-certificate-data"`
|
||||
ClientKey string `yaml:"client-key"`
|
||||
ClientKeyData string `yaml:"client-key-data"`
|
||||
Exec any
|
||||
AuthProvider any `yaml:"auth-provider"`
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := yaml.Unmarshal(content, &config); err != nil {
|
||||
return fmt.Errorf("parse kubeconfig %s: %w", path, err)
|
||||
}
|
||||
var current struct{ Cluster, User, Namespace string }
|
||||
for _, entry := range config.Contexts {
|
||||
if entry.Name == config.CurrentContext {
|
||||
current = entry.Context
|
||||
}
|
||||
}
|
||||
if current.Cluster == "" {
|
||||
return fmt.Errorf("kubeconfig %s has no current context", path)
|
||||
}
|
||||
c.namespace = cmp.Or(c.namespace, current.Namespace, "default")
|
||||
|
||||
resolve := func(file string) string {
|
||||
if file == "" || filepath.IsAbs(file) {
|
||||
return file
|
||||
}
|
||||
return filepath.Join(filepath.Dir(path), file)
|
||||
}
|
||||
read := func(data, file string) ([]byte, error) {
|
||||
if data != "" {
|
||||
return base64.StdEncoding.DecodeString(data)
|
||||
}
|
||||
if file == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return os.ReadFile(resolve(file))
|
||||
}
|
||||
|
||||
tlsConfig := transport.TLSClientConfig
|
||||
for _, cluster := range config.Clusters {
|
||||
if cluster.Name != current.Cluster {
|
||||
continue
|
||||
}
|
||||
c.server = strings.TrimSuffix(cluster.Cluster.Server, "/")
|
||||
tlsConfig.ServerName, tlsConfig.InsecureSkipVerify = cluster.Cluster.TLSServerName, cluster.Cluster.InsecureSkipTLSVerify
|
||||
if cluster.Cluster.ProxyURL != "" {
|
||||
proxy, err := url.Parse(cluster.Cluster.ProxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kubeconfig %s: %w", path, err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(proxy)
|
||||
}
|
||||
ca, err := read(cluster.Cluster.CertificateAuthorityData, cluster.Cluster.CertificateAuthority)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ca != nil {
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
tlsConfig.RootCAs.AppendCertsFromPEM(ca)
|
||||
}
|
||||
}
|
||||
for _, user := range config.Users {
|
||||
if user.Name != current.User {
|
||||
continue
|
||||
}
|
||||
if user.User.Exec != nil || user.User.AuthProvider != nil {
|
||||
return fmt.Errorf("kubeconfig %s: user %q authenticates through a credential plugin, which the runner does not support, use a token or client certificate", path, user.Name)
|
||||
}
|
||||
c.token, c.tokenFile = user.User.Token, resolve(user.User.TokenFile)
|
||||
cert, err := read(user.User.ClientCertificateData, user.User.ClientCertificate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := read(user.User.ClientKeyData, user.User.ClientKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cert != nil {
|
||||
pair, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{pair}
|
||||
}
|
||||
}
|
||||
if c.server == "" {
|
||||
return fmt.Errorf("kubeconfig %s names no server for context %q", path, config.CurrentContext)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *kubeClient) authorize(header http.Header) error {
|
||||
token := c.token
|
||||
if c.tokenFile != "" {
|
||||
content, err := os.ReadFile(c.tokenFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = strings.TrimSpace(string(content))
|
||||
}
|
||||
if token != "" {
|
||||
header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *kubeClient) path(resource string) string {
|
||||
return "/api/v1/namespaces/" + c.namespace + "/" + resource
|
||||
}
|
||||
|
||||
func (c *kubeClient) do(ctx context.Context, method, path string, in, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
content, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(content)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.server+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.authorize(req.Header); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return statusError(resp)
|
||||
}
|
||||
if out == nil {
|
||||
out = io.Discard // draining lets the connection be reused
|
||||
}
|
||||
if writer, ok := out.(io.Writer); ok {
|
||||
_, err = io.Copy(writer, resp.Body)
|
||||
return err
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (c *kubeClient) delete(ctx context.Context, resource, name string) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if err := c.do(ctx, http.MethodDelete, c.path(resource+"/"+name), nil, nil); err != nil && !isNotFound(err) {
|
||||
return fmt.Errorf("delete %s %s: %w", resource, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func statusError(resp *http.Response) error {
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
var status struct{ Message string }
|
||||
if json.Unmarshal(content, &status) != nil || status.Message == "" {
|
||||
status.Message = strings.TrimSpace(string(content))
|
||||
}
|
||||
return &apiError{code: resp.StatusCode, message: status.Message}
|
||||
}
|
||||
|
||||
func (c *kubeClient) exec(ctx context.Context, pod, containerName string, command []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
||||
query := url.Values{"container": {containerName}, "command": command, "stdout": {"true"}, "stderr": {"true"}}
|
||||
if stdin != nil {
|
||||
query.Set("stdin", "true")
|
||||
}
|
||||
header := http.Header{}
|
||||
if err := c.authorize(header); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
conn, resp, err := websocket.Dial(ctx, c.server+c.path("pods/"+pod+"/exec?")+query.Encode(), &websocket.DialOptions{
|
||||
HTTPClient: c.http,
|
||||
HTTPHeader: header,
|
||||
Subprotocols: []string{execProtocol},
|
||||
})
|
||||
if err != nil {
|
||||
if resp != nil && resp.StatusCode >= http.StatusMultipleChoices {
|
||||
defer resp.Body.Close()
|
||||
err = statusError(resp)
|
||||
}
|
||||
return fmt.Errorf("exec in pod %s: %w", pod, err)
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
if conn.Subprotocol() != execProtocol {
|
||||
return fmt.Errorf("the Kubernetes API server does not speak %s, which needs Kubernetes 1.30 or later", execProtocol)
|
||||
}
|
||||
conn.SetReadLimit(-1)
|
||||
|
||||
if stdin != nil {
|
||||
go func() {
|
||||
if err := sendStdin(ctx, conn, stdin); err != nil {
|
||||
cancel(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if conn.Ping(ctx) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, frame, err := conn.Read(ctx)
|
||||
if err != nil {
|
||||
return cmp.Or(context.Cause(ctx), fmt.Errorf("exec in pod %s ended without an exit status: %w", pod, err))
|
||||
}
|
||||
if len(frame) < 2 {
|
||||
continue
|
||||
}
|
||||
switch frame[0] {
|
||||
case 1:
|
||||
_, err = stdout.Write(frame[1:])
|
||||
case 2:
|
||||
_, err = stderr.Write(frame[1:])
|
||||
case 3:
|
||||
return cmp.Or(context.Cause(ctx), execStatus(frame[1:]))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendStdin(ctx context.Context, conn *websocket.Conn, stdin io.Reader) error {
|
||||
frame := make([]byte, 1+32*1024) // frame[0] is the stdin channel, 0
|
||||
for {
|
||||
n, err := stdin.Read(frame[1:])
|
||||
if n > 0 {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, frame[:1+n]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return conn.Write(ctx, websocket.MessageBinary, []byte{255, 0}) // v5 close signal for stdin
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func execStatus(content []byte) error {
|
||||
var status struct {
|
||||
Status, Message string
|
||||
Details struct {
|
||||
Causes []struct{ Reason, Message string }
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(content, &status); err != nil {
|
||||
return fmt.Errorf("decode exec status: %w", err)
|
||||
}
|
||||
if status.Status == "Success" {
|
||||
return nil
|
||||
}
|
||||
for _, cause := range status.Details.Causes {
|
||||
if code, err := strconv.Atoi(cause.Message); cause.Reason == "ExitCode" && err == nil {
|
||||
return ExitCodeError(code)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("exec failed: %s", status.Message)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type podManifest struct {
|
||||
Metadata struct{ Labels map[string]string }
|
||||
Spec struct {
|
||||
AutomountServiceAccountToken bool
|
||||
EnableServiceLinks bool
|
||||
HostAliases []struct{ Hostnames []string }
|
||||
Volumes []map[string]any
|
||||
Containers []struct {
|
||||
Name, ImagePullPolicy string
|
||||
Resources map[string]any
|
||||
StartupProbe *struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCluster struct {
|
||||
mu sync.Mutex
|
||||
requests map[string][]string
|
||||
}
|
||||
|
||||
func (f *fakeCluster) record(request, content string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.requests[request] = append(f.requests[request], content)
|
||||
}
|
||||
|
||||
func fakeKubernetes(t *testing.T, podStatus string) (string, *fakeCluster) {
|
||||
cluster := &fakeCluster{requests: map[string][]string{}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
cluster.record(r.Method+" "+r.URL.Path, string(body))
|
||||
pods, secrets := "/api/v1/namespaces/ci/pods", "/api/v1/namespaces/ci/secrets"
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == pods:
|
||||
cluster.record("list", r.URL.Query().Get("labelSelector"))
|
||||
_, _ = io.WriteString(w, `{"items":[{"metadata":{"name":"old","creationTimestamp":"2020-01-01T00:00:00Z"}},{"metadata":{"name":"new","creationTimestamp":"2999-01-01T00:00:00Z"}}]}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/exec"):
|
||||
cluster.record("exec", r.URL.RawQuery)
|
||||
serveLocalExec(t, w, r)
|
||||
case strings.HasSuffix(r.URL.Path, "/log"):
|
||||
_, _ = io.WriteString(w, "log of "+r.URL.Query().Get("container"))
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, pods+"/"):
|
||||
_, _ = io.WriteString(w, podStatus)
|
||||
case r.Method == http.MethodPost && (r.URL.Path == pods || r.URL.Path == secrets), r.Method == http.MethodDelete:
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
kubeconfig := filepath.Join(t.TempDir(), "kubeconfig")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(kubeconfig), "token"), []byte("test-token\n"), 0o600))
|
||||
require.NoError(t, os.WriteFile(kubeconfig, fmt.Appendf(nil, `
|
||||
current-context: test
|
||||
contexts: [{name: test, context: {cluster: test, user: test, namespace: ci}}]
|
||||
clusters: [{name: test, cluster: {server: "http://kubernetes.invalid", proxy-url: %q}}]
|
||||
users: [{name: test, user: {tokenFile: token}}]
|
||||
`, server.URL), 0o600))
|
||||
return kubeconfig, cluster
|
||||
}
|
||||
|
||||
func serveLocalExec(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{Subprotocols: []string{execProtocol}})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
conn.SetReadLimit(-1)
|
||||
command := r.URL.Query()["command"]
|
||||
var stdin bytes.Buffer
|
||||
for r.URL.Query().Get("stdin") == "true" {
|
||||
_, frame, err := conn.Read(r.Context())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if frame[0] == 255 {
|
||||
break
|
||||
}
|
||||
stdin.Write(frame[1:])
|
||||
}
|
||||
cmd := exec.Command(command[0], command[1:]...)
|
||||
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = &stdin, &stdout, &stderr
|
||||
status := `{"status":"Success"}`
|
||||
var exitErr *exec.ExitError
|
||||
if err := cmd.Run(); errors.As(err, &exitErr) {
|
||||
status = fmt.Sprintf(`{"status":"Failure","reason":"NonZeroExitCode","details":{"causes":[{"reason":"ExitCode","message":"%d"}]}}`, exitErr.ExitCode())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
for _, frame := range [][]byte{append([]byte{1}, stdout.Bytes()...), append([]byte{2}, stderr.Bytes()...), append([]byte{3}, status...)} {
|
||||
_ = conn.Write(r.Context(), websocket.MessageBinary, frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubernetesPodRunsJobThroughAPI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell")
|
||||
}
|
||||
ctx := t.Context()
|
||||
kubeconfig, cluster := fakeKubernetes(t, `{"spec":{"containers":[{},{}]},"status":{"phase":"Running","containerStatuses":[
|
||||
{"name":"job","ready":true,"state":{"running":{}}},
|
||||
{"name":"my-db","ready":true,"state":{"running":{}}}]}}`)
|
||||
workdir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
options := KubernetesOptions{Kubeconfig: kubeconfig, RunnerUUID: "runner-uuid", PodTemplates: []map[string]any{
|
||||
{"spec": map[string]any{
|
||||
"automountServiceAccountToken": true,
|
||||
"containers": []any{
|
||||
map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"cpu": "2"}}},
|
||||
map[string]any{
|
||||
"name": "$services", "imagePullPolicy": "Always", "resources": map[string]any{"limits": map[string]any{"memory": "256Mi"}},
|
||||
"env": []any{map[string]any{"name": "POSTGRES_PASSWORD", "value": "template"}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
{"spec": map[string]any{
|
||||
"volumes": []any{map[string]any{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}}},
|
||||
"containers": []any{map[string]any{"name": "job", "resources": map[string]any{"limits": map[string]any{"memory": "1Gi"}}}},
|
||||
}},
|
||||
}}
|
||||
pod, err := NewKubernetesPod(options)
|
||||
require.NoError(t, err)
|
||||
var jobLog, serviceLog bytes.Buffer
|
||||
job := pod.JobContainer(&NewContainerInput{Name: "Job-Name", Image: "node", WorkingDir: workdir, Stdout: &jobLog, Stderr: &jobLog})
|
||||
service := pod.ServiceContainer("My_DB", &NewContainerInput{
|
||||
Image: "postgres",
|
||||
Cmd: []string{"postgres", "-D", "$(PGDATA)"},
|
||||
Env: []string{"POSTGRES_PASSWORD=service-secret"},
|
||||
WorkflowOptions: "--health-cmd pg_isready --health-interval 2s --health-start-period 4s",
|
||||
Stdout: &serviceLog,
|
||||
})
|
||||
|
||||
require.NoError(t, job.Create(nil, nil)(ctx))
|
||||
var manifest podManifest
|
||||
require.NoError(t, json.Unmarshal([]byte(cluster.requests["POST /api/v1/namespaces/ci/pods"][0]), &manifest))
|
||||
assert.Equal(t, "gitea-runner", manifest.Metadata.Labels["app.kubernetes.io/managed-by"])
|
||||
assert.Equal(t, "runner-uuid", manifest.Metadata.Labels[runnerUUIDLabel])
|
||||
assert.True(t, manifest.Spec.AutomountServiceAccountToken)
|
||||
assert.False(t, manifest.Spec.EnableServiceLinks)
|
||||
assert.Equal(t, []string{"my-db"}, manifest.Spec.HostAliases[0].Hostnames)
|
||||
require.Len(t, manifest.Spec.Containers, 2)
|
||||
assert.Equal(t, map[string]any{"limits": map[string]any{"cpu": "2", "memory": "1Gi"}}, manifest.Spec.Containers[0].Resources)
|
||||
assert.Empty(t, manifest.Spec.Containers[0].ImagePullPolicy)
|
||||
assert.Equal(t, "my-db", manifest.Spec.Containers[1].Name)
|
||||
assert.Equal(t, "Always", manifest.Spec.Containers[1].ImagePullPolicy)
|
||||
assert.Equal(t, map[string]any{"limits": map[string]any{"memory": "256Mi"}}, manifest.Spec.Containers[1].Resources)
|
||||
assert.Equal(t, []map[string]any{
|
||||
{"name": "workspace", "emptyDir": map[string]any{"sizeLimit": "1Gi"}},
|
||||
{"name": "act", "emptyDir": map[string]any{}},
|
||||
}, manifest.Spec.Volumes)
|
||||
assert.Equal(t, struct{ PeriodSeconds, TimeoutSeconds, FailureThreshold int }{2, 30, 5}, *manifest.Spec.Containers[1].StartupProbe)
|
||||
assert.NotContains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], "service-secret")
|
||||
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], "service-secret")
|
||||
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/secrets"][0], `"com.gitea.runner.uuid":"runner-uuid"`)
|
||||
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `{"name":"POSTGRES_PASSWORD","valueFrom":{"secretKeyRef":{"key":"my-db.0","name":"`+pod.secretName+`"}}}`)
|
||||
assert.Equal(t, []any{"new"}, mergeManifest([]any{"old"}, []any{"new"}, ""))
|
||||
mount := func(path string) any { return map[string]any{"name": "config", "mountPath": path} }
|
||||
assert.Equal(t, []any{mount("/a"), mount("/b")}, mergeManifest([]any{mount("/a")}, []any{mount("/b")}, "volumeMounts"))
|
||||
tmpfs := map[string]any{"name": "workspace", "emptyDir": map[string]any{"medium": "Memory"}}
|
||||
ephemeral := map[string]any{"name": "workspace", "ephemeral": map[string]any{}}
|
||||
assert.Equal(t, []any{ephemeral}, mergeManifest([]any{tmpfs}, []any{ephemeral}, "volumes"))
|
||||
assert.Contains(t, cluster.requests["POST /api/v1/namespaces/ci/pods"][0], `"args":["postgres","-D","$$(PGDATA)"]`)
|
||||
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workdir, "env"), []byte("#!/bin/sh\necho \"$@\" > \"$0-args\"\nexec /usr/bin/env \"$@\"\n"), 0o755))
|
||||
t.Setenv("PATH", workdir+":"+os.Getenv("PATH"))
|
||||
require.NoError(t, job.Start(false)(ctx))
|
||||
|
||||
stepEnv := map[string]string{"INPUT_WHO-TO-GREET": "it's\nme", "SECRET": "hunter2", "UID": "5"}
|
||||
require.NoError(t, job.Exec([]string{"printenv", "SECRET"}, stepEnv, "", "")(ctx))
|
||||
envArgs, err := os.ReadFile(filepath.Join(workdir, "env-args"))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(envArgs), "hunter2")
|
||||
require.NoError(t, job.Exec([]string{"printenv", "INPUT_WHO-TO-GREET"}, stepEnv, "", "")(ctx))
|
||||
require.NoError(t, job.Exec([]string{"printenv", "UID"}, stepEnv, "", "")(ctx))
|
||||
require.NoError(t, job.Exec([]string{"pwd", "-P"}, nil, "", workdir)(ctx))
|
||||
assert.Equal(t, "hunter2\nit's\nme\n5\n"+workdir+"\n", jobLog.String())
|
||||
assert.Equal(t, ExitCodeError(3), job.Exec([]string{"sh", "-c", "echo failing >&2; exit 3"}, nil, "", "")(ctx))
|
||||
assert.Contains(t, jobLog.String(), "failing\n")
|
||||
for _, query := range cluster.requests["exec"] {
|
||||
assert.NotContains(t, query, "hunter2")
|
||||
}
|
||||
|
||||
require.NoError(t, job.Copy(workdir+"/act/", &FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: `{"ref":"main"}`})(ctx))
|
||||
archive, err := job.GetContainerArchive(ctx, workdir+"/act/workflow/event.json")
|
||||
require.NoError(t, err)
|
||||
reader := tar.NewReader(archive)
|
||||
header, err := reader.Next()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "event.json", header.Name)
|
||||
assert.JSONEq(t, `{"ref":"main"}`, string(content))
|
||||
require.NoError(t, archive.Close())
|
||||
_, err = job.GetContainerArchive(ctx, workdir+"/missing")
|
||||
require.Error(t, err)
|
||||
|
||||
actionDir := filepath.Join(t.TempDir(), "action")
|
||||
require.NoError(t, os.MkdirAll(actionDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(actionDir, "index.js"), []byte("main()"), 0o644))
|
||||
require.NoError(t, job.CopyDir(workdir+"/act/actions/sha/", actionDir+"/", false, false)(ctx))
|
||||
script, err := os.ReadFile(workdir + "/act/actions/sha/index.js")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "main()", string(script))
|
||||
assert.Contains(t, cluster.requests["exec"][len(cluster.requests["exec"])-1], "command="+url.QueryEscape(workdir+"/act/actions/sha/"))
|
||||
|
||||
pathEnv := map[string]string{}
|
||||
require.NoError(t, job.UpdateFromImageEnv(&pathEnv)(ctx))
|
||||
assert.Equal(t, os.Getenv("PATH"), pathEnv["PATH"])
|
||||
|
||||
info, err := service.Inspect(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, HealthHealthy, info.Health)
|
||||
require.NoError(t, service.DumpLogs(ctx))
|
||||
assert.Equal(t, "log of my-db", serviceLog.String())
|
||||
|
||||
require.NoError(t, job.Remove()(ctx))
|
||||
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/"+pod.name], 1)
|
||||
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/"+pod.secretName], 1)
|
||||
|
||||
require.NoError(t, RemoveOrphanKubernetesResources(ctx, options, time.Now()))
|
||||
assert.Equal(t, []string{runnerUUIDLabel + "=runner-uuid"}, cluster.requests["list"])
|
||||
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/old"], 1)
|
||||
assert.Len(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/old"], 1)
|
||||
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/pods/new"])
|
||||
assert.Empty(t, cluster.requests["DELETE /api/v1/namespaces/ci/secrets/new"])
|
||||
}
|
||||
|
||||
func TestKubernetesPodFailsBeforeSteps(t *testing.T) {
|
||||
kubeconfig, _ := fakeKubernetes(t, `{"spec":{"containers":[{}]},"status":{"containerStatuses":[
|
||||
{"name":"job","state":{"waiting":{"reason":"ErrImagePull","message":"no such image"}}}]}}`)
|
||||
template := map[string]any{"spec": map[string]any{"containers": []any{map[string]any{"name": "sidecar"}}}}
|
||||
for _, test := range []struct {
|
||||
services []string
|
||||
options, err string
|
||||
}{
|
||||
{[]string{"db"}, "--hostname db", "only --health-* options are supported"},
|
||||
{[]string{"sidecar"}, "", "named like another container"},
|
||||
{[]string{"my_db", "My-DB"}, "", `service My-DB maps to container "my-db"`},
|
||||
} {
|
||||
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci", PodTemplates: []map[string]any{template}})
|
||||
require.NoError(t, err)
|
||||
job := pod.JobContainer(&NewContainerInput{Image: "node"})
|
||||
for _, service := range test.services {
|
||||
pod.ServiceContainer(service, &NewContainerInput{Image: "postgres", WorkflowOptions: test.options})
|
||||
}
|
||||
require.ErrorContains(t, job.Create(nil, nil)(t.Context()), test.err)
|
||||
}
|
||||
pod, err := NewKubernetesPod(KubernetesOptions{Kubeconfig: kubeconfig, Namespace: "ci"})
|
||||
require.NoError(t, err)
|
||||
job := pod.JobContainer(&NewContainerInput{Image: "missing"})
|
||||
require.NoError(t, job.Create(nil, nil)(t.Context()))
|
||||
require.ErrorContains(t, job.Start(false)(t.Context()), "ErrImagePull: no such image")
|
||||
|
||||
plugin := filepath.Join(t.TempDir(), "kubeconfig")
|
||||
require.NoError(t, os.WriteFile(plugin, []byte(`
|
||||
current-context: eks
|
||||
contexts: [{name: eks, context: {cluster: eks, user: eks}}]
|
||||
clusters: [{name: eks, cluster: {server: "https://eks.example"}}]
|
||||
users: [{name: eks, user: {exec: {command: aws}}}]
|
||||
`), 0o600))
|
||||
_, err = NewKubernetesPod(KubernetesOptions{Kubeconfig: plugin})
|
||||
require.ErrorContains(t, err, "credential plugin")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// Copyright 2020 The nektos/act Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/filecollector"
|
||||
|
||||
"github.com/go-git/go-billy/v5/helper/polyfill"
|
||||
"github.com/go-git/go-billy/v5/osfs"
|
||||
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
|
||||
)
|
||||
|
||||
func writeFilesTar(ctx context.Context, w io.Writer, uid, gid int, files ...*FileEntry) error {
|
||||
tw := tar.NewWriter(w)
|
||||
for _, file := range files {
|
||||
common.Logger(ctx).Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body))
|
||||
if err := tw.WriteHeader(&tar.Header{
|
||||
Name: file.Name,
|
||||
Mode: file.Mode,
|
||||
Size: int64(len(file.Body)),
|
||||
Uid: uid,
|
||||
Gid: gid,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write([]byte(file.Body)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
|
||||
// writeDirTar prefixes entries with dstPath for extraction at root.
|
||||
func writeDirTar(ctx context.Context, w io.Writer, dstPath, srcPath string, useGitIgnore, skipGitDir bool, uid, gid int) error {
|
||||
logger := common.Logger(ctx)
|
||||
tw := tar.NewWriter(w)
|
||||
srcPrefix := strings.TrimSuffix(filepath.Dir(srcPath), string(filepath.Separator)) + string(filepath.Separator)
|
||||
logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath)
|
||||
|
||||
var ignorer gitignore.Matcher
|
||||
if useGitIgnore {
|
||||
ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil)
|
||||
if err != nil {
|
||||
logger.Debugf("Error loading .gitignore: %v", err)
|
||||
}
|
||||
|
||||
ignorer = gitignore.NewMatcher(ps)
|
||||
}
|
||||
|
||||
if err := filepath.Walk(srcPath, (&filecollector.FileCollector{
|
||||
Ignorer: ignorer,
|
||||
SrcPath: srcPath,
|
||||
SrcPrefix: srcPrefix,
|
||||
SkipGitDir: skipGitDir,
|
||||
Handler: &filecollector.TarCollector{TarWriter: tw, UID: uid, GID: gid, DstDir: dstPath[1:]},
|
||||
}).CollectFiles(ctx, []string{})); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
+24
-1
@@ -6,6 +6,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
@@ -287,11 +288,30 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin
|
||||
return strings.ToLower(image)
|
||||
}
|
||||
|
||||
// One image per step and job: its stages share a build, and concurrent jobs never run each other's.
|
||||
func (rc *RunContext) dockerActionImage(ctx context.Context, step actionStep, actionName string, localAction bool) string {
|
||||
job := rc.topLevelRunContext()
|
||||
if job.dockerActionImages == nil {
|
||||
job.dockerActionImages = map[*model.Step]string{}
|
||||
}
|
||||
image, ok := job.dockerActionImages[step.getStepModel()]
|
||||
if !ok {
|
||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) + "-" + strings.ToLower(rand.Text())
|
||||
if !common.Dryrun(ctx) { // a dry run builds nothing to remove
|
||||
job.dockerActionImages[step.getStepModel()] = image
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
// TODO: break out parts of function to reduce complexicity
|
||||
func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc := step.getRunContext()
|
||||
action := step.getActionModel()
|
||||
if rc.kubernetes {
|
||||
return errKubernetesDocker
|
||||
}
|
||||
|
||||
var prepImage common.Executor
|
||||
var image string
|
||||
@@ -301,7 +321,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b
|
||||
// Apply forcePull only for prebuild docker images
|
||||
forcePull = rc.Config.ForcePull
|
||||
} else {
|
||||
image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction)
|
||||
image = rc.dockerActionImage(ctx, step, actionName, localAction)
|
||||
contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image))
|
||||
|
||||
anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any")
|
||||
@@ -442,6 +462,9 @@ func newStepContainer(ctx context.Context, step step, image string, cmd, entrypo
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
envList := make([]string, 0)
|
||||
for k, v := range *step.getEnv() {
|
||||
if rc.IsHostEnv() && strings.EqualFold(k, rc.JobContainer.GetPathVariableName()) && v == rc.JobContainer.DefaultPathVariable() { // the runner's own PATH stays out, as on GitHub
|
||||
continue
|
||||
}
|
||||
envList = append(envList, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func evaluateCompositeInputAndEnv(ctx context.Context, parent *RunContext, step
|
||||
}
|
||||
}
|
||||
|
||||
ee := parent.NewActionInputsExpressionEvaluator(ctx, step)
|
||||
ee := parent.NewStepExpressionEvaluator(ctx, step)
|
||||
|
||||
for inputID, input := range step.getActionModel().Inputs {
|
||||
envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_")
|
||||
@@ -104,6 +104,7 @@ func newCompositeRunContext(ctx context.Context, parent *RunContext, step action
|
||||
Parent: parent,
|
||||
EventJSON: parent.EventJSON,
|
||||
platformImage: parent.platformImage,
|
||||
kubernetes: parent.kubernetes,
|
||||
jobIndex: parent.jobIndex,
|
||||
jobTotal: parent.jobTotal,
|
||||
}
|
||||
|
||||
@@ -513,6 +513,17 @@ func TestDockerActionImageTag(t *testing.T) {
|
||||
dockerActionImageTag("owner/repo", "./", true),
|
||||
dockerActionImageTag("owner/repo", "./sub", true),
|
||||
)
|
||||
|
||||
workflow := &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}
|
||||
job := newTestRC(workflow, nil)
|
||||
step := &stepActionRemote{Step: &model.Step{}, RunContext: job}
|
||||
image := job.dockerActionImage(t.Context(), step, "abc123", false)
|
||||
assert.True(t, strings.HasPrefix(image, "act-abc123-dockeraction:latest-"))
|
||||
assert.Equal(t, image, (&RunContext{Parent: job}).dockerActionImage(t.Context(), step, "abc123", false))
|
||||
assert.NotEqual(t, image, job.dockerActionImage(t.Context(), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false))
|
||||
assert.NotEqual(t, image, newTestRC(workflow, nil).dockerActionImage(t.Context(), step, "abc123", false))
|
||||
job.dockerActionImage(common.WithDryrun(t.Context(), true), &stepActionRemote{Step: &model.Step{}, RunContext: job}, "abc123", false)
|
||||
assert.Len(t, job.dockerActionImages, 2)
|
||||
}
|
||||
|
||||
func TestExecAsDockerStageEntrypoint(t *testing.T) {
|
||||
|
||||
@@ -246,18 +246,19 @@ func inputsFromEnv(env map[string]string) map[string]any {
|
||||
}
|
||||
|
||||
func getEvaluatorInputs(rc *RunContext, stepInputs map[string]any, ghc *model.GithubContext) map[string]any {
|
||||
if stepInputs != nil { // an action's own inputs, which workflow inputs must not shadow
|
||||
return maps.Clone(stepInputs)
|
||||
}
|
||||
inputs := map[string]any{}
|
||||
|
||||
// only a called workflow's job itself takes the inputs Gitea resolved,
|
||||
// composite actions copy its Config but have their own `inputs`
|
||||
if rc.Config.WorkflowCallInputs != nil && rc.Parent == nil {
|
||||
maps.Copy(inputs, rc.Config.WorkflowCallInputs)
|
||||
maps.Copy(inputs, stepInputs)
|
||||
return inputs
|
||||
}
|
||||
|
||||
maps.Copy(inputs, rc.workflowCallInputs)
|
||||
maps.Copy(inputs, stepInputs)
|
||||
|
||||
switch ghc.EventName {
|
||||
case "workflow_dispatch":
|
||||
|
||||
@@ -414,6 +414,31 @@ on:
|
||||
assert.Equal(t, map[string]any{"arg": "x"}, getEvaluatorInputs(composite, map[string]any{"arg": "x"}, ghc))
|
||||
}
|
||||
|
||||
func TestActionInputsShadowDispatchInputs(t *testing.T) {
|
||||
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
message: {type: string}
|
||||
jobs:
|
||||
job1: {}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
rc := newTestRC(wf, nil)
|
||||
rc.Config.EventName = "workflow_dispatch"
|
||||
rc.EventJSON = `{"inputs":{"message":"workflow"}}`
|
||||
step := &stepActionRemote{RunContext: rc, env: map[string]string{"INPUT_MESSAGE": "step"}}
|
||||
assert.Equal(t, "step", rc.NewActionInputsExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||
assert.Equal(t, "workflow", rc.NewStepExpressionEvaluator(t.Context(), step).InterpolateName(t.Context(), "${{ inputs.message }}"))
|
||||
enabled, err := isStepEnabled(t.Context(), "inputs.message == 'workflow'", step, stepStageMain)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled)
|
||||
step.Step, step.action = &model.Step{}, &model.Action{Inputs: map[string]model.Input{"fallback": {Default: "${{ inputs.message }}"}}}
|
||||
env, err := evaluateCompositeInputAndEnv(t.Context(), rc, step)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "workflow", env["INPUT_FALLBACK"])
|
||||
}
|
||||
|
||||
func TestResolveWorkflowCallToleratesMismatchesAndPassesInheritedSecretsVerbatim(t *testing.T) {
|
||||
callerWorkflow, err := model.ReadWorkflow(strings.NewReader(`
|
||||
jobs:
|
||||
|
||||
@@ -5,6 +5,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
@@ -40,6 +41,16 @@ func requireDocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func requireDockerSharingFiles(t *testing.T) {
|
||||
t.Helper()
|
||||
requireDocker(t)
|
||||
for _, marker := range []string{"/.dockerenv", "/run/.containerenv"} {
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Skip("skipping: inside a container, the docker daemon cannot bind-mount this process's files")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireHostTools skips the test unless every named executable is on PATH. Used by the
|
||||
// self-hosted (host environment) suite, which runs steps directly on the host.
|
||||
func requireHostTools(t *testing.T, tools ...string) {
|
||||
|
||||
+33
-10
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
@@ -258,6 +259,7 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
|
||||
logger.Errorf("##[error]%s", EscapeCommandData("Error while stop job container: "+err.Error()))
|
||||
}
|
||||
setJobResult(ctx, info, rc, jobError == nil)
|
||||
rc.removeDockerActionImages(ctx)
|
||||
|
||||
return err
|
||||
})
|
||||
@@ -410,26 +412,38 @@ func setJobResult(ctx context.Context, info jobInfo, rc *RunContext, success boo
|
||||
logger.WithField("jobResult", jobResult).Infof("Job %s", jobResultMessage)
|
||||
}
|
||||
|
||||
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
// evaluateJobEnv returns the workflow and job env evaluated, as far as it got on error, leaving rc.Env raw.
|
||||
func evaluateJobEnv(ctx context.Context, rc *RunContext) (map[string]string, error) {
|
||||
env := rc.GetEnv()
|
||||
evaluated := maps.Clone(env)
|
||||
evaluator := rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||
var workflowEnv map[string]string
|
||||
if rc.Run.Workflow.Env == nil {
|
||||
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||
return err
|
||||
if err := model.DecodeEvaluated("workflow env", rc.Run.Workflow.RawEnv, evaluator.shared(ctx).EvaluateYamlNode, &workflowEnv); err != nil {
|
||||
return evaluated, err
|
||||
}
|
||||
}
|
||||
if workflowEnv != nil {
|
||||
rc.Env = mergeMaps(workflowEnv, env)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
evaluated = mergeMaps(workflowEnv, env)
|
||||
evaluator = rc.NewExpressionEvaluatorWithEnv(ctx, evaluated)
|
||||
}
|
||||
var err error
|
||||
for k, v := range env {
|
||||
if rc.Env[k], err = rc.ExprEval.Interpolate(ctx, v); err != nil {
|
||||
return fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
if evaluated[k], err = evaluator.Interpolate(ctx, v); err != nil {
|
||||
return evaluated, fmt.Errorf("unable to interpolate env %s: %w", k, err)
|
||||
}
|
||||
}
|
||||
return evaluated, nil
|
||||
}
|
||||
|
||||
// evaluateJobEnvAndDefaults resolves the job's env, defaults.run and container env once, as GitHub does at job setup.
|
||||
func evaluateJobEnvAndDefaults(ctx context.Context, rc *RunContext) error {
|
||||
env, err := evaluateJobEnv(ctx, rc)
|
||||
rc.Env = env
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var defaults model.Defaults
|
||||
if err := model.DecodeEvaluated("defaults", rc.Run.Job().RawDefaults, rc.ExprEval.shared(ctx).EvaluateYamlNode, &defaults); err != nil {
|
||||
return err
|
||||
@@ -518,7 +532,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||
actPath := rc.JobContainer.GetActPath()
|
||||
// Reuse a single client across all step uploads so connections can be pooled.
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout, Transport: rc.Config.instanceTransportFor(base)}
|
||||
for i := range rc.Run.Job().Steps {
|
||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||
@@ -530,6 +544,15 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// instanceTransportFor returns InstanceTransport only for URLs on the registered address, as steps can rewrite the summary URL through GITHUB_ENV.
|
||||
func (c Config) instanceTransportFor(rawURL string) http.RoundTripper {
|
||||
if !strings.HasPrefix(rawURL, c.GitHubInstance+"/") {
|
||||
return nil
|
||||
}
|
||||
return c.InstanceTransport
|
||||
}
|
||||
|
||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||
|
||||
@@ -858,7 +858,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
||||
cm.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t *testing.T) {
|
||||
runtimeToken := fakeRuntimeToken(34)
|
||||
|
||||
type upload struct {
|
||||
@@ -866,7 +866,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
body string
|
||||
}
|
||||
var got []upload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
got = append(got, upload{r.URL.Path, string(body)})
|
||||
@@ -896,6 +896,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||
"GITEA_RUN_ID": "12",
|
||||
}, cm, 3)
|
||||
rc.Config.GitHubInstance, rc.Config.InstanceTransport = server.URL, server.Client().Transport
|
||||
|
||||
tryUploadJobSummary(ctx, rc)
|
||||
|
||||
|
||||
@@ -39,11 +39,8 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||
|
||||
// If the repository is private, we need a token to clone it
|
||||
token := rc.Config.GetToken()
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||
)
|
||||
}
|
||||
@@ -60,10 +57,8 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||
|
||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||
)
|
||||
}
|
||||
@@ -76,20 +71,19 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
||||
//
|
||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||
}
|
||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
return git.NewGitCloneExecutor(rc.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||
URL: interpolatedURL,
|
||||
Ref: ref,
|
||||
Dir: targetDirectory,
|
||||
Token: token,
|
||||
OfflineMode: rc.Config.ActionOfflineMode,
|
||||
Depth: rc.Config.ActionCloneDepth,
|
||||
})(ctx)
|
||||
}, rc.Config.GetToken()))(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,13 +202,18 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
||||
// otherwise returns an empty string
|
||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
||||
return ""
|
||||
// withInstanceAuth passes instance credentials only to clones from this Gitea instance, and runner.insecure only to its registered address.
|
||||
func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token string) git.NewGitCloneExecutorInput {
|
||||
u, err := url.Parse(input.URL)
|
||||
if err != nil || !onInstanceHost(u.Host, c.GitHubInstance, c.trustedActionInstance()) {
|
||||
return input
|
||||
}
|
||||
return conf.GetToken()
|
||||
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
||||
input.InsecureSkipTLS = c.InsecureSkipTLS && onInstanceHost(u.Host, c.GitHubInstance)
|
||||
if u.User == nil {
|
||||
input.Token = token
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
@@ -228,26 +227,16 @@ func (c Config) trustedActionInstance() string {
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
||||
// 2. the cloneURL does not have basic auth embedded
|
||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
||||
u2, err := url.Parse(cloneURL)
|
||||
if err != nil || u2.User != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
||||
// onInstanceHost reports whether host is that of one of the instance URLs, which may lack a scheme.
|
||||
func onInstanceHost(host string, instanceURLs ...string) bool {
|
||||
for _, candidate := range instanceURLs {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "http://") &&
|
||||
!strings.HasPrefix(candidate, "https://") {
|
||||
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") {
|
||||
candidate = "https://" + candidate
|
||||
}
|
||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
||||
if u, err := url.Parse(candidate); err == nil && u.Host == host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -59,7 +64,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
||||
}
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v1"), string(got))
|
||||
@@ -69,7 +74,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
||||
gitMust(t, workDir, "commit", "-am", "v2")
|
||||
gitMust(t, workDir, "push", "origin", "master")
|
||||
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||
@@ -123,107 +128,79 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.example.net",
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
|
||||
var gotCert bool
|
||||
var gotToken string
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotCert = len(r.TLS.PeerCertificates) > 0
|
||||
_, gotToken, _ = r.BasicAuth()
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
pair := server.TLS.Certificates[0]
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
dir := t.TempDir()
|
||||
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
||||
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||
rc := &RunContext{
|
||||
Config: &Config{
|
||||
GitHubInstance: server.URL,
|
||||
Secrets: map[string]string{"GITEA_TOKEN": "token"},
|
||||
InsecureSkipTLS: true,
|
||||
ClientCertFile: certFile,
|
||||
ClientKeyFile: keyFile,
|
||||
},
|
||||
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
require.Error(t, cloneRemoteReusableWorkflow(rc, server.URL+"/owner/repo", "main", t.TempDir())(t.Context()))
|
||||
require.True(t, gotCert)
|
||||
require.Equal(t, "token", gotToken)
|
||||
}
|
||||
|
||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
||||
// actions against AppURL on a different hostname for the same instance.
|
||||
conf := &Config{
|
||||
GitHubInstance: "gitea.local",
|
||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
||||
DefaultActionInstanceIsSelfHosted: true,
|
||||
Secrets: map[string]string{
|
||||
"GITEA_TOKEN": "token-value",
|
||||
},
|
||||
}
|
||||
|
||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
||||
|
||||
require.Equal(t, "token-value", token)
|
||||
}
|
||||
|
||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceURL string
|
||||
trustedActionInstance string
|
||||
cloneURL string
|
||||
want bool
|
||||
func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, instanceURL, trustedActionInstance, cloneURL string
|
||||
token, cert, insecure bool
|
||||
}{
|
||||
{
|
||||
name: "same host with schemaless instance",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://gitea.example.net/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "same host with schemaless instance and port",
|
||||
instanceURL: "gitea.example.net:3000",
|
||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different host",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://github.com/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "embedded basic auth",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "https://user:[email protected]/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid clone URL",
|
||||
instanceURL: "gitea.example.net",
|
||||
cloneURL: "://gitea.example.net/actions/tools",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
||||
// registered instance: the token must still be attached.
|
||||
name: "self-hosted action instance on different host",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
// embedded basic auth must still be rejected even when the host matches
|
||||
// the trusted action instance.
|
||||
name: "self-hosted action instance with embedded basic auth",
|
||||
instanceURL: "gitea.local",
|
||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
||||
cloneURL: "https://user:[email protected]/owner/action",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
||||
// empty in github mode, so an off-instance clone URL gets no token.
|
||||
name: "github mode does not trust mirror host",
|
||||
instanceURL: "gitea.local",
|
||||
cloneURL: "https://mirror.example.com/owner/action",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
{"same host with schemaless instance", "gitea.example.net", "", "https://gitea.example.net/actions/tools", true, true, true},
|
||||
{"same host with schemaless instance and port", "gitea.example.net:3000", "", "https://gitea.example.net:3000/actions/tools", true, true, true},
|
||||
{"different host", "gitea.example.net", "", "https://github.com/actions/tools", false, false, false},
|
||||
{"embedded basic auth keeps its own credentials", "gitea.example.net", "", "https://user:pass@gitea.example.net/actions/tools", false, true, true},
|
||||
{"invalid clone URL", "gitea.example.net", "", "://gitea.example.net/actions/tools", false, false, false},
|
||||
{"self-hosted action instance on different host skips no verification", "gitea.local", "https://gitea.my-nas.lan", "https://gitea.my-nas.lan/owner/action", true, true, false},
|
||||
{"self-hosted action instance with embedded basic auth", "gitea.local", "https://gitea.my-nas.lan", "https://user:[email protected]/owner/action", false, true, false},
|
||||
{"github mode does not trust mirror host", "gitea.local", "", "https://mirror.example.com/owner/action", false, false, false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
||||
conf := Config{
|
||||
GitHubInstance: tt.instanceURL,
|
||||
DefaultActionInstance: tt.trustedActionInstance,
|
||||
DefaultActionInstanceIsSelfHosted: tt.trustedActionInstance != "",
|
||||
InsecureSkipTLS: true,
|
||||
ClientCertFile: "/cert",
|
||||
ClientKeyFile: "/key",
|
||||
}
|
||||
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
||||
if tt.token {
|
||||
want.Token = "token"
|
||||
}
|
||||
if tt.cert {
|
||||
want.ClientCertFile, want.ClientKeyFile = conf.ClientCertFile, conf.ClientKeyFile
|
||||
}
|
||||
require.Equal(t, want, conf.withInstanceAuth(git.NewGitCloneExecutorInput{URL: tt.cloneURL}, "token"))
|
||||
})
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
conf := Config{GitHubInstance: "https://gitea.local", InstanceTransport: transport}
|
||||
require.Same(t, transport, conf.instanceTransportFor("https://gitea.local/api/actions_pipeline/"))
|
||||
require.Nil(t, conf.instanceTransportFor("https://gitea.local.example/api/actions_pipeline/"))
|
||||
require.Nil(t, conf.instanceTransportFor("https://other.example/x#/api/actions_pipeline/"))
|
||||
}
|
||||
|
||||
func gitMust(t *testing.T, dir string, args ...string) {
|
||||
|
||||
+188
-111
@@ -7,6 +7,7 @@ package runner
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -64,6 +66,7 @@ type RunContext struct {
|
||||
ExprEval *expressionEvaluator
|
||||
JobContainer container.ExecutionsEnvironment
|
||||
serviceContainers []*serviceContainer
|
||||
kubernetes bool
|
||||
containerSpec model.ContainerSpec // container:, resolved once with platformImage
|
||||
containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves
|
||||
JobName string
|
||||
@@ -79,6 +82,10 @@ type RunContext struct {
|
||||
jobRunDefaults model.RunDefaults // defaults.run, resolved once at job setup as GitHub does
|
||||
platformImage string // container.image or the runs-on pick, resolved once by isEnabled
|
||||
jobIndex, jobTotal int // strategy.job-index and job-total, total 0 when unknown
|
||||
|
||||
jobEnv map[string]string // workflow and job env as evaluated for the container and service specs
|
||||
dockerActionImages map[*model.Step]string // Dockerfile action images built for this job, removed when it ends
|
||||
|
||||
// summaryFileInitialized tracks which per-step summary files (workflow/step-summary-N.md)
|
||||
// have already been created on the JobContainer. The runner sets up file-command files
|
||||
// via JobContainer.Copy at the start of every phase, which truncates them — fine for
|
||||
@@ -253,6 +260,9 @@ const sharedToolCacheVolume = "act-toolcache" // mounted only when the tool cach
|
||||
func (rc *RunContext) validVolumes() []string {
|
||||
name := rc.jobContainerName()
|
||||
volumes := slices.Clone(rc.Config.ValidVolumes)
|
||||
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||
volumes = append(volumes, hostEnv.Path, hostEnv.GetActPath())
|
||||
}
|
||||
if rc.Config.SharedToolCache {
|
||||
volumes = append(volumes, sharedToolCacheVolume)
|
||||
}
|
||||
@@ -351,6 +361,9 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
|
||||
mounts[sharedToolCacheVolume] = toolCache
|
||||
}
|
||||
}
|
||||
if hostEnv, ok := rc.JobContainer.(*container.HostEnvironment); ok {
|
||||
binds = append(binds, hostEnv.Path+":"+hostEnv.Path, hostEnv.GetActPath()+":"+hostEnv.GetActPath())
|
||||
}
|
||||
mounts[name+"-env"] = ext.GetActPath() // runner-internal, never overridable
|
||||
|
||||
if workdir := ext.ToContainerPath(rc.Config.Workdir); !claimed[workdir] {
|
||||
@@ -417,11 +430,7 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
}
|
||||
rc.cleanUpJobContainer = rc.JobContainer.Remove()
|
||||
for k, v := range rc.getRunnerContext(ctx) {
|
||||
if v, ok := v.(string); ok {
|
||||
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
rc.setRunnerContextEnv(ctx)
|
||||
for _, env := range os.Environ() {
|
||||
if k, v, ok := strings.Cut(env, "="); ok {
|
||||
// don't override
|
||||
@@ -431,16 +440,70 @@ func (rc *RunContext) startHostEnvironment() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
return rc.copyWorkflowFiles()(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) setRunnerContextEnv(ctx context.Context) {
|
||||
for k, v := range rc.getRunnerContext(ctx) {
|
||||
if v, ok := v.(string); ok {
|
||||
rc.Env["RUNNER_"+strings.ToUpper(k)] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errKubernetesDocker = errors.New("docker actions and docker:// steps are not supported on Kubernetes")
|
||||
|
||||
func (rc *RunContext) startKubernetesPod() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if rc.containerSpec.Options != "" || len(rc.containerSpec.Volumes) > 0 || rc.containerSpec.Credentials != nil {
|
||||
return errors.New("container options, volumes and credentials are not supported on Kubernetes")
|
||||
}
|
||||
var options container.KubernetesOptions
|
||||
if rc.Config.KubernetesPicker != nil {
|
||||
options = rc.Config.KubernetesPicker(rc.runsOnPlatformNames(ctx))
|
||||
}
|
||||
options.MaxLifetime, options.ForcePull = rc.Config.ContainerMaxLifetime, rc.Config.ForcePull
|
||||
pod, err := container.NewKubernetesPod(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services, err := rc.jobServices(ctx, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
|
||||
input := services[serviceID]
|
||||
if input.Username != "" || len(input.Binds) > 0 || len(input.Mounts) > 0 {
|
||||
return fmt.Errorf("service %s: volumes and credentials are not supported on Kubernetes", serviceID)
|
||||
}
|
||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: pod.ServiceContainer(serviceID, input)})
|
||||
}
|
||||
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
rc.JobContainer = pod.JobContainer(&container.NewContainerInput{
|
||||
Entrypoint: []string{"tail", "-f", "/dev/null"},
|
||||
WorkingDir: (&container.LinuxContainerEnvironmentExtensions{}).ToContainerPath(rc.Config.Workdir),
|
||||
Image: rc.platformImage,
|
||||
Name: rc.jobContainerName(),
|
||||
Env: []string{"LANG=C.UTF-8"},
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
})
|
||||
rc.cleanUpJobContainer = func(ctx context.Context) error {
|
||||
rc.printServiceLogs(ctx)
|
||||
return rc.JobContainer.Remove()(ctx)
|
||||
}
|
||||
defer printStartJobContainerGroup(ctx, rc.platformImage, rc.jobContainerName(), "")()
|
||||
return common.NewPipelineExecutor(
|
||||
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
|
||||
Name: "workflow/event.json",
|
||||
Mode: 0o644,
|
||||
Body: rc.EventJSON,
|
||||
}, &container.FileEntry{
|
||||
Name: "workflow/envs.txt",
|
||||
Mode: 0o666,
|
||||
Body: "",
|
||||
}),
|
||||
rc.JobContainer.Create(nil, nil),
|
||||
rc.startServiceContainers(),
|
||||
rc.JobContainer.Start(false),
|
||||
func(ctx context.Context) error { rc.setRunnerContextEnv(ctx); return nil },
|
||||
rc.captureJobContainerInfo(),
|
||||
rc.copyWorkflowFiles(),
|
||||
rc.reportUnstartedServices(),
|
||||
rc.waitForServiceContainers(),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
@@ -470,7 +533,6 @@ func WithJobVolumeCleanup(ctx context.Context, deferCleanup func(common.Executor
|
||||
func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
rc.deferVolumeCleanup, _ = ctx.Value(jobVolumeCleanupKey{}).(func(common.Executor))
|
||||
logger := common.Logger(ctx)
|
||||
image := rc.platformImage
|
||||
logWriter := rc.commandLogWriter(ctx)
|
||||
|
||||
@@ -483,6 +545,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
// For gitea, to support --volumes-from <container_name_or_id> in options.
|
||||
// We need to set the container name to the environment variable.
|
||||
rc.Env["JOB_CONTAINER_NAME"] = name
|
||||
delete(rc.jobEnv, "JOB_CONTAINER_NAME") // the runtime name wins over a workflow env of the same name
|
||||
|
||||
envList := make([]string, 0)
|
||||
|
||||
@@ -495,78 +558,13 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
networkName, createAndDeleteNetwork := rc.networkNameForGitea()
|
||||
rc.cleanUpJobContainer = rc.cleanupJobResources(networkName, createAndDeleteNetwork, false)
|
||||
|
||||
var services map[string]*model.ContainerSpec
|
||||
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.ExprEval.shared(ctx).EvaluateYamlNode, &services); err != nil {
|
||||
services, err := rc.jobServices(ctx, networkName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
|
||||
spec := services[serviceID]
|
||||
if spec == nil {
|
||||
return fmt.Errorf("service %s has no container definition", serviceID)
|
||||
}
|
||||
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
if spec.Image == "" {
|
||||
logger.Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||
continue
|
||||
}
|
||||
envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
|
||||
// a service reaches the internet the way the job does; its own env still wins
|
||||
for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
|
||||
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
// keep these local: reusing username/password would overwrite the
|
||||
// credentials the job container is pulled with further down
|
||||
serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||
}
|
||||
cmd := spec.Cmd
|
||||
if len(cmd) == 0 && spec.Command != "" {
|
||||
if cmd, err = shellquote.Split(spec.Command); err != nil {
|
||||
return fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
|
||||
}
|
||||
}
|
||||
var entrypoint []string
|
||||
if spec.Entrypoint != "" {
|
||||
entrypoint = []string{spec.Entrypoint}
|
||||
}
|
||||
|
||||
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
|
||||
|
||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||
}
|
||||
|
||||
serviceContainerName := createContainerName(rc.jobContainerName(), serviceID)
|
||||
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state
|
||||
c := newContainer(&container.NewContainerInput{
|
||||
Name: serviceContainerName,
|
||||
Image: spec.Image,
|
||||
Username: serviceUsername,
|
||||
Password: servicePassword,
|
||||
Entrypoint: entrypoint,
|
||||
Cmd: cmd,
|
||||
Env: envs,
|
||||
Mounts: serviceMounts,
|
||||
Binds: serviceBinds,
|
||||
Stdout: serviceLog,
|
||||
Stderr: serviceLog,
|
||||
Privileged: rc.Config.Privileged,
|
||||
UsernsMode: rc.Config.UsernsMode,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||
WorkflowOptions: spec.Options,
|
||||
NetworkMode: networkName,
|
||||
NetworkAliases: []string{serviceID},
|
||||
ExposedPorts: exposedPorts,
|
||||
PortBindings: portBindings,
|
||||
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
})
|
||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: spec.Image, container: c})
|
||||
input := services[serviceID]
|
||||
rc.serviceContainers = append(rc.serviceContainers, &serviceContainer{name: serviceID, image: input.Image, container: newContainer(input)})
|
||||
}
|
||||
|
||||
// For Gitea, `jobContainerNetwork` should be the same as `networkName`
|
||||
@@ -575,7 +573,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
containerInput := &container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"sleep", fmt.Sprint(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds())},
|
||||
Entrypoint: []string{"sleep", strconv.FormatInt(int64(rc.Config.ContainerMaxLifetime.Round(time.Second).Seconds()), 10)},
|
||||
WorkingDir: ext.ToContainerPath(rc.Config.Workdir),
|
||||
Image: image,
|
||||
Username: username,
|
||||
@@ -623,20 +621,86 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
rc.JobContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
rc.JobContainer.Start(false),
|
||||
rc.captureJobContainerInfo(),
|
||||
rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", &container.FileEntry{
|
||||
Name: "workflow/event.json",
|
||||
Mode: 0o644,
|
||||
Body: rc.EventJSON,
|
||||
}, &container.FileEntry{
|
||||
Name: "workflow/envs.txt",
|
||||
Mode: 0o666,
|
||||
Body: "",
|
||||
}),
|
||||
rc.copyWorkflowFiles(),
|
||||
rc.waitForServiceContainers(),
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[string]*container.NewContainerInput, error) {
|
||||
var services map[string]*model.ContainerSpec
|
||||
if err := model.DecodeEvaluated("job services", rc.Run.Job().RawServices, rc.NewExpressionEvaluatorWithEnv(ctx, mergeMaps(rc.GetEnv(), rc.jobEnv)).shared(ctx).EvaluateYamlNode, &services); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inputs := map[string]*container.NewContainerInput{}
|
||||
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
|
||||
spec := services[serviceID]
|
||||
if spec == nil {
|
||||
return nil, fmt.Errorf("service %s has no container definition", serviceID)
|
||||
}
|
||||
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
||||
// GitHub compatibility: skip services whose image evaluates to an
|
||||
// empty string, enabling conditional services via expressions
|
||||
if spec.Image == "" {
|
||||
common.Logger(ctx).Infof("The service '%s' will not be started because the container definition has an empty image.", serviceID)
|
||||
continue
|
||||
}
|
||||
envs := make([]string, 0, len(spec.Env)+len(rc.Config.ProxyEnv))
|
||||
// a service reaches the internet the way the job does; its own env still wins
|
||||
for k, v := range mergeMaps(rc.Config.ProxyEnv, spec.Env) {
|
||||
envs = append(envs, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
serviceUsername, servicePassword, err := registryCredentials(spec.Credentials, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to handle service %s credentials: %w", serviceID, err)
|
||||
}
|
||||
cmd := spec.Cmd
|
||||
if len(cmd) == 0 && spec.Command != "" {
|
||||
if cmd, err = shellquote.Split(spec.Command); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse service %s command: %w", serviceID, err)
|
||||
}
|
||||
}
|
||||
var entrypoint []string
|
||||
if spec.Entrypoint != "" {
|
||||
entrypoint = []string{spec.Entrypoint}
|
||||
}
|
||||
|
||||
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
|
||||
|
||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse service %s ports: %w", serviceID, err)
|
||||
}
|
||||
|
||||
serviceLog := common.NewLineWriter(rawLogLine(ctx)) // raw, so a service's output cannot set the job's env, outputs or state
|
||||
inputs[serviceID] = &container.NewContainerInput{
|
||||
Name: createContainerName(rc.jobContainerName(), serviceID),
|
||||
Image: spec.Image,
|
||||
Username: serviceUsername,
|
||||
Password: servicePassword,
|
||||
Entrypoint: entrypoint,
|
||||
Cmd: cmd,
|
||||
Env: envs,
|
||||
Mounts: serviceMounts,
|
||||
Binds: serviceBinds,
|
||||
Stdout: serviceLog,
|
||||
Stderr: serviceLog,
|
||||
Privileged: rc.Config.Privileged,
|
||||
UsernsMode: rc.Config.UsernsMode,
|
||||
Platform: rc.Config.ContainerArchitecture,
|
||||
AutoRemove: false, // so a dead service's log survives, cleanupJobResources removes it
|
||||
WorkflowOptions: spec.Options,
|
||||
NetworkMode: networkName,
|
||||
NetworkAliases: []string{serviceID},
|
||||
ExposedPorts: exposedPorts,
|
||||
PortBindings: portBindings,
|
||||
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
|
||||
AllocatePTY: rc.Config.AllocatePTY,
|
||||
}
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func (rc *RunContext) commandLogWriter(ctx context.Context) io.Writer {
|
||||
return common.NewLineWriter(rc.commandHandler(ctx), rawLogLine(ctx))
|
||||
}
|
||||
@@ -661,7 +725,7 @@ func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNet
|
||||
logger.Infof("Cleaning up services for job %s", rc.JobName)
|
||||
errs = append(errs, rc.stopServiceContainers()(ctx))
|
||||
}
|
||||
if !common.Dryrun(ctx) && (preclean || rc.hadDockerProxy) {
|
||||
if !common.Dryrun(ctx) && !rc.Config.NoSweep && (preclean || rc.hadDockerProxy) {
|
||||
errs = append(errs, container.RemoveDockerJobResources(ctx, rc.jobContainerName()))
|
||||
}
|
||||
if preclean || rc.deferVolumeCleanup == nil {
|
||||
@@ -783,6 +847,14 @@ func (rc *RunContext) stopJobContainer() common.Executor {
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) removeDockerActionImages(ctx context.Context) {
|
||||
for _, image := range rc.dockerActionImages {
|
||||
if _, err := container.RemoveImage(ctx, image, true, false); err != nil {
|
||||
common.Logger(ctx).Warnf("failed to remove action image %s: %v", image, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) pullServicesImages(forcePull bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
execs := []common.Executor{}
|
||||
@@ -975,6 +1047,13 @@ func (svc *serviceContainer) healthOutputSuffix() string {
|
||||
return ": " + svc.info.HealthOutput
|
||||
}
|
||||
|
||||
func (rc *RunContext) copyWorkflowFiles() common.Executor {
|
||||
return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/",
|
||||
&container.FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON},
|
||||
&container.FileEntry{Name: "workflow/envs.txt", Mode: 0o666},
|
||||
)
|
||||
}
|
||||
|
||||
// captureJobContainerInfo is a convenience: failing to describe the container must not
|
||||
// fail the job.
|
||||
func (rc *RunContext) captureJobContainerInfo() common.Executor {
|
||||
@@ -1043,9 +1122,12 @@ func (rc *RunContext) interpolateOutputs() common.Executor {
|
||||
func (rc *RunContext) startContainer() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
var err error
|
||||
if rc.IsHostEnv() {
|
||||
switch {
|
||||
case rc.kubernetes:
|
||||
err = rc.startKubernetesPod()(ctx)
|
||||
case rc.IsHostEnv():
|
||||
err = rc.startHostEnvironment()(ctx)
|
||||
} else {
|
||||
default:
|
||||
err = rc.startJobContainer()(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -1154,10 +1236,6 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||
}
|
||||
|
||||
runsOn := rc.Run.Job().RunsOn()
|
||||
for i, v := range runsOn {
|
||||
var err error
|
||||
@@ -1192,22 +1270,23 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
|
||||
// resolvePlatformImage evaluates the job's container once for every consumer, skipping one whose image is empty as GitHub does.
|
||||
func (rc *RunContext) resolvePlatformImage(ctx context.Context) error {
|
||||
rc.jobEnv, _ = evaluateJobEnv(ctx, rc) // a broken env fails the job at setup instead
|
||||
withoutEnv, env := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||
var spec model.ContainerSpec
|
||||
if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||
if err := model.DecodeEvaluated("container", withoutEnv, rc.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
||||
if spec.Image != "" || imagelessContainer(withoutEnv) {
|
||||
rc.containerSpec, rc.containerEnv = spec, env
|
||||
}
|
||||
if rc.containerSpec.Image != "" {
|
||||
rc.platformImage = rc.containerSpec.Image
|
||||
return nil
|
||||
}
|
||||
image, err := rc.runsOnImage(ctx)
|
||||
rc.platformImage = image
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
image, rc.kubernetes = strings.CutPrefix(image, "kubernetes://")
|
||||
rc.platformImage = cmp.Or(rc.containerSpec.Image, image)
|
||||
return nil
|
||||
}
|
||||
|
||||
// imagelessContainer reports a container: mapping without an image key, which configures the runs-on image.
|
||||
@@ -1625,9 +1704,7 @@ func parentDir(p string) string {
|
||||
// still names a release when the label is a rolling one such as ubuntu-latest.
|
||||
func (rc *RunContext) imageOS(ctx context.Context) string {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
// A composite action runs on a synthetic job, and resolving its image would only
|
||||
// log that runs-on is missing.
|
||||
return ""
|
||||
return "" // a composite action runs on a synthetic job without runs-on
|
||||
}
|
||||
if imageOS := imageOSFromImage(rc.platformImage); imageOS != "" {
|
||||
return imageOS
|
||||
|
||||
@@ -251,7 +251,6 @@ func startJobContainerInputs(t *testing.T, workflowYAML string, cfg *Config) []*
|
||||
rc := &RunContext{
|
||||
Name: "test",
|
||||
Config: cfg,
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job",
|
||||
Workflow: workflow,
|
||||
@@ -362,6 +361,29 @@ jobs:
|
||||
require.Equal(t, "sleep", job.Entrypoint[0])
|
||||
require.Equal(t, "--label ${{ github.job }}", job.WorkflowOptions)
|
||||
require.Equal(t, "${{ vars.TITLE }}", job.Password)
|
||||
|
||||
inputs = startJobContainerInputs(t, `
|
||||
env:
|
||||
REG_USER: ${{ vars.REG_USER }}
|
||||
TEXT: ${{ vars.TEXT }}
|
||||
JOB_CONTAINER_NAME: from-workflow
|
||||
jobs:
|
||||
job:
|
||||
container:
|
||||
image: node:20
|
||||
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||
services:
|
||||
redis:
|
||||
image: redis:latest
|
||||
credentials: {username: "${{ env.REG_USER }}", password: pass}
|
||||
env: {TEXT: "${{ env.TEXT }}"}
|
||||
options: --volumes-from ${{ env.JOB_CONTAINER_NAME }}
|
||||
`, &Config{Vars: map[string]string{"REG_USER": "user", "TEXT": "${{ 'literal' }}"}})
|
||||
redis, job = inputs[0], inputs[1]
|
||||
require.Equal(t, "user", job.Username)
|
||||
require.Equal(t, "user", redis.Username)
|
||||
require.Equal(t, []string{"TEXT=${{ 'literal' }}"}, redis.Env)
|
||||
require.Equal(t, "--volumes-from "+job.Name, redis.WorkflowOptions)
|
||||
}
|
||||
|
||||
// Only the workflow's options may be stripped later, so the two sources have to reach the
|
||||
@@ -662,6 +684,15 @@ func TestRunContextValidVolumes(t *testing.T) {
|
||||
assert.Contains(t, rc.validVolumes(), rc.Config.Workdir)
|
||||
rc.Config.PresetGitHubContext = &model.GithubContext{}
|
||||
assert.Contains(t, rc.validVolumes(), filepath.FromSlash("/workspace/1"))
|
||||
|
||||
hostEnv := &container.HostEnvironment{Path: t.TempDir(), ActPath: t.TempDir()}
|
||||
rc.JobContainer = hostEnv
|
||||
rc.Config.BindWorkdir = false
|
||||
rc.Config.SharedToolCache = true
|
||||
binds, mounts := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Subset(t, binds, []string{hostEnv.Path + ":" + hostEnv.Path, hostEnv.GetActPath() + ":" + hostEnv.GetActPath()})
|
||||
assert.Contains(t, mounts, sharedToolCacheVolume)
|
||||
assert.Equal(t, binds, container.SanitizeBinds(t.Context(), rc.validVolumes(), binds))
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesCleansServicesWithoutJobContainer(t *testing.T) {
|
||||
@@ -730,12 +761,27 @@ func TestCleanupJobVolumesReapsAbandonedDeferredCleanup(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"became-active", "remove-failed", "foreign", "fresh", "unknown-age"}, slices.Collect(maps.Keys(volumes)))
|
||||
}
|
||||
|
||||
func TestRemoveDockerActionImagesByTag(t *testing.T) {
|
||||
var removed []string
|
||||
fakeDockerDaemon(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method == http.MethodDelete {
|
||||
removed = append(removed, strings.TrimPrefix(request.URL.Path, "/v1.47/images/"))
|
||||
_, _ = io.WriteString(writer, "[]")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(writer, `{"Id":"sha256:shared"}`)
|
||||
})
|
||||
(&RunContext{dockerActionImages: map[*model.Step]string{{}: "act-action-dockeraction:latest-job"}}).removeDockerActionImages(t.Context())
|
||||
assert.Equal(t, []string{"act-action-dockeraction:latest-job"}, removed)
|
||||
}
|
||||
|
||||
func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
t.Setenv("TMPDIR", "/tmp")
|
||||
proxyDir := t.TempDir()
|
||||
for _, name := range []string{"synchronous", "proxy", "closed proxy", "deferred", "preclean deferred", "canceled"} {
|
||||
for _, name := range []string{"synchronous", "proxy", "closed proxy", "kept proxy", "deferred", "preclean deferred", "canceled"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
proxy, preclean, deferred := strings.HasSuffix(name, "proxy"), strings.HasPrefix(name, "preclean"), strings.HasSuffix(name, "deferred")
|
||||
removesLabelled := (proxy || preclean) && name != "kept proxy"
|
||||
if proxy && runtime.GOOS == "windows" {
|
||||
t.Skip("Unix socket ownership is unavailable on Windows")
|
||||
}
|
||||
@@ -745,7 +791,7 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
service.On("Remove").Return(func(context.Context) error { return removeError }).Once()
|
||||
service.On("Close").Return(func(context.Context) error { return closeError }).Once()
|
||||
rc := &RunContext{
|
||||
Config: &Config{CacheContainer: "cache-container"},
|
||||
Config: &Config{CacheContainer: "cache-container", NoSweep: name == "kept proxy"},
|
||||
Run: &model.Run{Workflow: &model.Workflow{Name: "wf"}, JobID: "job"},
|
||||
JobContainer: job,
|
||||
serviceContainers: []*serviceContainer{{name: "svc", container: service}},
|
||||
@@ -801,8 +847,8 @@ func TestCleanupJobResourcesContinuesAfterFailure(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, 2, volumeRemovals)
|
||||
}
|
||||
assert.Equal(t, proxy || preclean, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||
if proxy || preclean {
|
||||
assert.Equal(t, removesLabelled, strings.Contains(err.Error(), "labelled GET /containers/json"))
|
||||
if removesLabelled {
|
||||
require.ErrorContains(t, err, "labelled GET /networks")
|
||||
require.ErrorContains(t, err, "labelled GET /volumes")
|
||||
}
|
||||
@@ -960,7 +1006,6 @@ func TestGetGithubContextRef(t *testing.T) {
|
||||
func createIfTestRunContext(jobs map[string]*model.Job) *RunContext {
|
||||
rc := &RunContext{
|
||||
Config: &Config{Workdir: ".", PlatformPicker: func([]string) string { return "ubuntu-latest" }},
|
||||
Env: map[string]string{},
|
||||
Run: &model.Run{
|
||||
JobID: "job1",
|
||||
Workflow: &model.Workflow{
|
||||
@@ -1037,9 +1082,12 @@ func TestRunContextIsEnabled(t *testing.T) {
|
||||
// success()
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest
|
||||
env:
|
||||
BROKEN: ${{ fromJSON('invalid') }}
|
||||
if: success()`, ""),
|
||||
})
|
||||
assertObject.True(rc.isEnabled(context.Background()))
|
||||
require.Error(t, evaluateJobEnvAndDefaults(context.Background(), rc))
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, `runs-on: ubuntu-latest`, "failure"),
|
||||
@@ -1629,10 +1677,13 @@ func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunContextContainerImageCannotSelectHost(t *testing.T) {
|
||||
func TestRunContextContainerImageCannotSelectBackend(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "container: -self-hosted", "")})
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
assert.False(t, rc.IsHostEnv())
|
||||
|
||||
rc = createIfTestRunContext(map[string]*model.Job{"job1": createJob(t, "runs-on: ${{ fromJSON('x') }}\ncontainer: node", "")})
|
||||
require.ErrorContains(t, rc.resolvePlatformImage(t.Context()), "runs-on")
|
||||
}
|
||||
|
||||
func TestRunContextImageOS(t *testing.T) {
|
||||
|
||||
@@ -7,8 +7,10 @@ package runner
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -75,13 +77,17 @@ type Config struct {
|
||||
// AppURL. It is never set for github.com or a GithubMirror, so the token stays on-instance.
|
||||
DefaultActionInstanceIsSelfHosted bool
|
||||
PlatformPicker func(labels []string) string
|
||||
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||
InstanceTransport http.RoundTripper
|
||||
JobLoggerLevel *log.Level // the level of job logger
|
||||
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
||||
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
||||
AllocatePTY bool // allocate a pseudo-TTY for each step's process
|
||||
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||
NoSweep bool // keep the containers, networks and volumes a job creates through its docker proxy
|
||||
RunnerName string // name this runner registered with, reported as `runner.name`, defaults to the hostname
|
||||
JobStartedHook string // script run inside the job environment before the job's first step; ACTIONS_RUNNER_HOOK_JOB_STARTED is read from Env when empty
|
||||
JobCompletedHook string // script run inside the job environment after the job's last step; ACTIONS_RUNNER_HOOK_JOB_COMPLETED is read from Env when empty
|
||||
@@ -172,6 +178,15 @@ func maxParallelFor(strategy *model.Strategy, combinations int) int {
|
||||
|
||||
// NewPlanExecutor ...
|
||||
func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor {
|
||||
for _, stage := range plan.Stages {
|
||||
for _, run := range stage.Runs {
|
||||
job := run.Job()
|
||||
if runsOn := job.RunsOn(); job.Uses == "" && len(job.Steps) > 0 && (len(runsOn) == 0 || slices.Contains(runsOn, "")) { // Gitea's needs placeholders carry no steps
|
||||
return common.NewErrorExecutor(fmt.Errorf("job %q requires a non-empty 'runs-on'", run.JobID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxJobNameLen := 0
|
||||
|
||||
stagePipeline := make([]common.Executor, 0)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
log "github.com/sirupsen/logrus"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -151,6 +152,21 @@ func TestGraphWithSomeMissing(t *testing.T) {
|
||||
log.SetOutput(out)
|
||||
}
|
||||
|
||||
func TestNewPlanExecutorRequiresRunsOnBeforeRunningAnyJob(t *testing.T) {
|
||||
for _, runsOn := range []string{"", "if: false", "runs-on: [ubuntu-latest, '']"} {
|
||||
workflow, err := model.ReadWorkflow(strings.NewReader("jobs:\n first:\n runs-on: ubuntu-latest\n steps: [run: echo]\n test:\n needs: first\n " + runsOn + "\n steps: [run: echo]\n"))
|
||||
require.NoError(t, err)
|
||||
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob("test")
|
||||
require.NoError(t, err)
|
||||
require.ErrorContains(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(plan)(t.Context()), `job "test" requires a non-empty 'runs-on'`, runsOn)
|
||||
assert.Empty(t, workflow.Jobs["first"].Result, runsOn)
|
||||
}
|
||||
|
||||
workflow := &model.Workflow{Jobs: map[string]*model.Job{"build": {Result: "success"}}}
|
||||
require.NoError(t, (&runnerImpl{config: &Config{}}).NewPlanExecutor(&model.Plan{Stages: []*model.Stage{{Runs: []*model.Run{{Workflow: workflow, JobID: "build"}}}}})(t.Context()))
|
||||
assert.Equal(t, "success", workflow.Jobs["build"].Result)
|
||||
}
|
||||
|
||||
func TestGraphEvent(t *testing.T) {
|
||||
planner, err := model.NewWorkflowPlanner("testdata/basic", true)
|
||||
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
||||
@@ -221,7 +237,6 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
|
||||
ContainerNamePrefix: strings.ReplaceAll(t.Name(), "/", "-") + "-" + testRunID,
|
||||
// 0 would run jobs runtime.NumCPU()-wide, making the network peak machine-dependent
|
||||
MaxParallel: 2,
|
||||
ForceRebuild: true,
|
||||
Env: cfg.Env,
|
||||
Secrets: cfg.Secrets,
|
||||
GitHubInstance: "github.com",
|
||||
@@ -434,6 +449,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
|
||||
tables = append(tables, []TestJobFileInfo{
|
||||
{workdir, "nix-prepend-path", "push", "", platforms, secrets},
|
||||
{workdir, "uses-docker-url", "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, "do-not-leak-step-env-in-composite", "push", "", platforms, secrets},
|
||||
@@ -451,6 +468,8 @@ func TestRunEventHostEnvironment(t *testing.T) {
|
||||
requireHostTools(t, "pwsh")
|
||||
case "nix-prepend-path":
|
||||
requireHostTools(t, "nix")
|
||||
case "uses-docker-url", "local-action-dockerfile":
|
||||
requireDockerSharingFiles(t)
|
||||
}
|
||||
t.Parallel()
|
||||
hostPlanSlots <- struct{}{}
|
||||
|
||||
@@ -38,6 +38,7 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
defer rawLogger.Infof("::endgroup::")
|
||||
|
||||
rc := sab.RunContext
|
||||
rc.ApplyExtraPath(ctx, &sab.env)
|
||||
return sab.run(ctx, &action.Context{
|
||||
Container: rc.JobContainer,
|
||||
Github: rc.getGithubContext(ctx),
|
||||
@@ -48,6 +49,9 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
||||
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
||||
UseGitIgnore: rc.Config.UseGitIgnore,
|
||||
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
||||
ClientCertFile: rc.Config.ClientCertFile,
|
||||
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||
Env: sab.env,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,11 +61,14 @@ func (sal *stepActionLocal) main() common.Executor {
|
||||
treader := tar.NewReader(tars)
|
||||
header, err := treader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
tars.Close()
|
||||
return nil, nil, os.ErrNotExist
|
||||
} else if err != nil {
|
||||
tars.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
|
||||
tars.Close()
|
||||
spath, err = symlinkJoin(spath, header.Linkname, containerActionPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
)
|
||||
|
||||
type stepActionRemote struct {
|
||||
@@ -68,36 +67,22 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
// empty token and clone the action anonymously (401 against the authenticated
|
||||
// instance). github.Token survives the composite config copy and matches the
|
||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
||||
token := ""
|
||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
||||
token = github.Token
|
||||
}
|
||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
||||
URL: cloneURL,
|
||||
// github.Token, unlike Config.GetToken, survives the composite config copy, which nils Config.Secrets.
|
||||
gitClone := stepActionRemoteNewCloneExecutor(sar.RunContext.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
||||
Ref: sar.remoteAction.Ref,
|
||||
Dir: actionDir,
|
||||
Token: token,
|
||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||
// printPrepareActions reports the download with its resolved commit.
|
||||
Quiet: true,
|
||||
|
||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
||||
})
|
||||
var ntErr common.Executor
|
||||
}, github.Token))
|
||||
if err := gitClone(ctx); err != nil {
|
||||
var refErr *git.Error
|
||||
switch {
|
||||
case errors.As(err, &refErr) && errors.Is(err, git.ErrShortRef):
|
||||
return fmt.Errorf("unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead",
|
||||
sar.Step.Uses, sar.remoteAction.Ref, refErr.Commit())
|
||||
case errors.Is(err, gogit.ErrForceNeeded): // TODO: figure out if it will be easy to shadow/alias go-git err's
|
||||
ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
@@ -115,15 +100,10 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return f, f, err
|
||||
}
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
ntErr,
|
||||
func(ctx context.Context) error {
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
)(ctx)
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader, os.WriteFile)
|
||||
sar.action = actionModel
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,22 +239,6 @@ func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
||||
return sar.compositeSteps
|
||||
}
|
||||
|
||||
// For Gitea
|
||||
// cloneSkipTLS returns true if the runner can clone an action from the Gitea instance
|
||||
func (sar *stepActionRemote) cloneSkipTLS() bool {
|
||||
if !sar.RunContext.Config.InsecureSkipTLS {
|
||||
// Return false if the Gitea instance is not an insecure instance
|
||||
return false
|
||||
}
|
||||
if sar.remoteAction.URL == "" {
|
||||
// Empty URL means the default action instance should be used
|
||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
||||
}
|
||||
|
||||
type remoteAction struct {
|
||||
URL string
|
||||
Org string
|
||||
|
||||
@@ -56,6 +56,9 @@ func (sd *stepDocker) runUsesContainer() common.Executor {
|
||||
step := sd.Step
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
if rc.kubernetes {
|
||||
return errKubernetesDocker
|
||||
}
|
||||
image := strings.TrimPrefix(step.Uses, "docker://")
|
||||
cmd, err := shellquote.Split(step.With["args"])
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -151,22 +152,35 @@ func TestStepDockerNewStepContainerAllocatePTY(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
func TestStepDockerNewStepContainerNetworkModeAndPath(t *testing.T) {
|
||||
inheritedPath := (&container.LinuxContainerEnvironmentExtensions{}).DefaultPathVariable()
|
||||
cases := []struct {
|
||||
name string
|
||||
platform string
|
||||
path string
|
||||
expectDefault bool
|
||||
keepsPath bool
|
||||
}{
|
||||
{
|
||||
name: "docker mode attaches to job container network",
|
||||
platform: "node:14",
|
||||
path: inheritedPath,
|
||||
expectDefault: false,
|
||||
keepsPath: true,
|
||||
},
|
||||
{
|
||||
name: "host mode uses default network",
|
||||
name: "host mode uses default network and the image's PATH",
|
||||
platform: "-self-hosted",
|
||||
path: inheritedPath,
|
||||
expectDefault: true,
|
||||
},
|
||||
{
|
||||
name: "host mode keeps a PATH that is not the runner's",
|
||||
platform: "-self-hosted",
|
||||
path: "/custom/bin",
|
||||
expectDefault: true,
|
||||
keepsPath: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -208,6 +222,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
ID: "1",
|
||||
Uses: "docker://alpine:3.20",
|
||||
},
|
||||
env: map[string]string{"PATH": tc.path},
|
||||
}
|
||||
sd.RunContext.ExprEval = sd.RunContext.NewExpressionEvaluator(ctx)
|
||||
|
||||
@@ -215,6 +230,7 @@ func TestStepDockerNewStepContainerNetworkMode(t *testing.T) {
|
||||
assert.Equal(t, tc.expectDefault, sd.RunContext.IsHostEnv(), "IsHostEnv mismatch for platform %q", tc.platform)
|
||||
|
||||
newStepContainer(ctx, sd, "alpine:3.20", []string{"echo", "hello"}, nil, "")
|
||||
assert.Equal(t, tc.keepsPath, slices.Contains(captured.Env, "PATH="+tc.path))
|
||||
|
||||
if tc.expectDefault {
|
||||
assert.Equal(t, "default", captured.NetworkMode,
|
||||
|
||||
+5
-2
@@ -5,6 +5,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: printf 'workspace=from-host-step\n' > workspace-marker
|
||||
- uses: docker://node:24-bookworm-slim
|
||||
with:
|
||||
somekey: somevalue
|
||||
@@ -16,8 +17,10 @@ jobs:
|
||||
with:
|
||||
entrypoint: /bin/sh
|
||||
args: -c -- uname -a
|
||||
- uses: docker://node:24-bookworm-slim
|
||||
- id: workspace
|
||||
uses: docker://node:24-bookworm-slim
|
||||
with:
|
||||
entrypoint: /bin/sh
|
||||
args: -c 'uname -a'
|
||||
args: -c 'uname -a && cat "$GITHUB_WORKSPACE/workspace-marker" >> "$GITHUB_OUTPUT"'
|
||||
- run: test '${{ steps.workspace.outputs.workspace }}' = 'from-host-step'
|
||||
|
||||
+22
-4
@@ -8,14 +8,32 @@ Built-in actions ship with the runner, so a job needs no action download and no
|
||||
|
||||
## checkout
|
||||
|
||||
Works like `actions/checkout` with these inputs. Any other input fails the step.
|
||||
Works like `actions/checkout` with these inputs. Any other input fails the step. It runs `git` where the job runs, see the [prerequisites](../README.md#prerequisites).
|
||||
|
||||
| Input | Default |
|
||||
| --- | --- |
|
||||
| `repository` | `github.repository` |
|
||||
| `ref` | the triggering commit, or the default branch of another repository |
|
||||
| `ref` | the triggering ref and commit, or the default branch of another repository |
|
||||
| `token` | `github.token` |
|
||||
| `ssh-key` | empty, a private key fetches over SSH instead |
|
||||
| `ssh-known-hosts` | empty, host keys trusted besides `~/.ssh/known_hosts` |
|
||||
| `ssh-strict` | `true`, rejects unknown host keys |
|
||||
| `ssh-user` | the user of the repository's SSH URL, else `git` |
|
||||
| `persist-credentials` | `true`, later steps of the job can fetch and push with `token` or `ssh-key` |
|
||||
| `path` | workspace root |
|
||||
| `fetch-depth` | `1`, `0` for full history |
|
||||
| `clean` | `true`, removes untracked files, also in submodules |
|
||||
| `filter` | empty, a partial clone filter such as `blob:none` |
|
||||
| `sparse-checkout` | empty, the paths to check out, one per line |
|
||||
| `sparse-checkout-cone-mode` | `true`, `false` reads `sparse-checkout` as patterns |
|
||||
| `fetch-depth` | `1`, `0` for the full history of every branch and tag |
|
||||
| `fetch-tags` | `false`, `true` fetches every tag |
|
||||
| `show-progress` | `true`, shows the fetch progress |
|
||||
| `lfs` | `false`, `true` downloads Git LFS files |
|
||||
| `submodules` | `false`, `true` checks out submodules, `recursive` also theirs |
|
||||
| `set-safe-directory` | `true`, lets checkout's `git` work in a directory another user owns |
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` is set.
|
||||
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
|
||||
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Jobs as Kubernetes pods
|
||||
|
||||
A `kubernetes` label runs each job as a pod, without a Docker daemon:
|
||||
|
||||
```text
|
||||
ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||
```
|
||||
|
||||
The pod has a `job` container from the label's image or the job's `container.image`, plus one container per service.
|
||||
|
||||
## Setup
|
||||
|
||||
In a cluster, the runner uses its pod's service account and namespace. [`examples/kubernetes/kubernetes-pods.yaml`](../examples/kubernetes/kubernetes-pods.yaml) deploys a runner with these permissions:
|
||||
|
||||
| Resource | Verbs |
|
||||
| --- | --- |
|
||||
| `pods` | `create`, `get`, `list`, `delete` |
|
||||
| `pods/exec` | `create`, `get` |
|
||||
| `pods/log` | `get` |
|
||||
| `secrets` | `create`, `delete` |
|
||||
|
||||
Outside a cluster, it uses the current context of the first file in `$KUBECONFIG`, or of `~/.kube/config`, which must authenticate with a token, token file or client certificate. The cluster must run Kubernetes 1.30 or later, and job images need `sh`, `tar`, `tail` and `env`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
kubernetes:
|
||||
kubeconfig: "" # instead of the service account
|
||||
namespace: "" # defaults to the service account's, the kubeconfig context's, or default
|
||||
pod_template: # merged under each job pod, a container named job merges into the job container, one named $services into each service container
|
||||
spec:
|
||||
containers:
|
||||
- name: job
|
||||
resources:
|
||||
limits:
|
||||
memory: 4Gi
|
||||
- name: $services
|
||||
resources:
|
||||
limits:
|
||||
memory: 1Gi
|
||||
pod_templates: # merged over pod_template for jobs whose runs-on has the label they are keyed by
|
||||
gpu:
|
||||
spec:
|
||||
nodeSelector:
|
||||
nvidia.com/gpu.present: "true"
|
||||
```
|
||||
|
||||
A job with `runs-on: [linux, gpu]` gets the `linux` and then the `gpu` entry of `pod_templates` merged over `pod_template`, while its image comes from the first of its labels the runner has. A template volume named `workspace` or `act` replaces the `emptyDir` the runner mounts at the workspace or at `/var/run/act`, for example to limit its size. Images are pulled by the cluster's `imagePullPolicy` default, a template's, or always with `container.force_pull`. Like every config value, templates expand `${NAME}` from the runner's environment, so a shell command in a template writes `$${NAME}` for the pod's own variable.
|
||||
|
||||
Job pods mount no service account token unless the template sets `automountServiceAccountToken: true`. Job pods and their Secrets are labeled `app.kubernetes.io/managed-by: gitea-runner` and `com.gitea.runner.uuid: <runner UUID>`. The idle cleanup removes those the runner did not get to remove, once they are older than `runner.workdir_cleanup_age`.
|
||||
|
||||
## Services
|
||||
|
||||
Services share the pod's network, so a step reaches one by its id or `localhost`, and two services cannot listen on the same port. An id is lowercased and has characters other than letters, digits and `-` replaced by `-`, so the service `my_db` is reached as `my-db`. Pass secrets to services through `env`, as `command` is visible in the pod spec. Of `options`, only `--health-*` is supported. They become a startup probe with Docker's defaults, which stops a service that fails its retries, so the job fails as on Docker.
|
||||
|
||||
## Unsupported
|
||||
|
||||
Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`.
|
||||
+33
-11
@@ -1,6 +1,6 @@
|
||||
# OpenTelemetry tracing
|
||||
# OpenTelemetry
|
||||
|
||||
The runner can send a trace of every job it runs to an OpenTelemetry collector or any backend that accepts OTLP. Each job becomes a trace with a span per step, so you can see where CI time goes and which steps fail. Tracing stays off until you set an endpoint.
|
||||
The runner can send a trace of every job it runs, and its metrics, to an OpenTelemetry collector or any backend that accepts OTLP. Each job becomes a trace with a span per step, so you can see where CI time goes and which steps fail. Export stays off until you set an endpoint.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -12,20 +12,26 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector base URL, traces go to `/v1/traces` under it |
|
||||
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL, takes precedence over the base URL |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_EXPORTER_OTLP_TRACES_HEADERS` | Request headers such as an API key, as comma-separated `key=value` pairs with URL-encoded values |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector base URL, traces go to `/v1/traces` and metrics to `/v1/metrics` under it |
|
||||
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | Full URL per signal, takes precedence over the base URL |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | Request headers such as an API key, as comma-separated `key=value` pairs with URL-encoded values |
|
||||
| `OTEL_EXPORTER_OTLP_TIMEOUT` | Export timeout in milliseconds, default 10000, 0 for none |
|
||||
| `OTEL_EXPORTER_OTLP_COMPRESSION` | `gzip` or `none`, default `none` |
|
||||
| `OTEL_EXPORTER_OTLP_CERTIFICATE` | PEM file of the CA to trust instead of the system ones |
|
||||
| `OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE`, `OTEL_EXPORTER_OTLP_CLIENT_KEY` | PEM files of the client certificate and key for mTLS |
|
||||
| `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES` | Resource attributes, the service name defaults to `gitea-runner` |
|
||||
| `OTEL_TRACES_SAMPLER`, `OTEL_BSP_*` | Sampling and batching, see the [SDK variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) |
|
||||
| `OTEL_SDK_DISABLED=true`, `OTEL_TRACES_EXPORTER=none` | Turn tracing off |
|
||||
| `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_METRIC_EXPORT_TIMEOUT` | Metrics export interval and timeout in milliseconds, default 60000 and 30000, a timeout of 0 for none |
|
||||
| `OTEL_SDK_DISABLED=true` | Turn all export off |
|
||||
| `OTEL_TRACES_EXPORTER=none`, `OTEL_METRICS_EXPORTER=none` | Turn traces or metrics off |
|
||||
|
||||
Only the `http/protobuf` protocol is supported.
|
||||
Except for the endpoint, each `OTEL_EXPORTER_OTLP_*` variable has `TRACES` and `METRICS` variants such as `OTEL_EXPORTER_OTLP_METRICS_HEADERS`, which take precedence for that signal. Only the `otlp` exporter over the `http/protobuf` protocol is supported.
|
||||
|
||||
## What is sent
|
||||
|
||||
- A span per job, named `RUN {workflow} {job}`, with the repository, ref, commit, run URL, run number, attempt and result.
|
||||
- A span per step, named as in Gitea's step list, including `Pre` and `Post` stages and the steps of composite actions, with the step's result.
|
||||
- The runner's name, UUID and version.
|
||||
- The runner's name, UUID, version, process ID and start time, and the Go version.
|
||||
|
||||
Logs, step output and secrets are never sent. Step names are taken from the workflow file as written, without expanding `${{ }}` expressions.
|
||||
|
||||
@@ -35,12 +41,28 @@ Jobs receive `TRACEPARENT`, so tools in a job that use OpenTelemetry can add the
|
||||
|
||||
## Metrics
|
||||
|
||||
Metrics stay on the Prometheus endpoint enabled by `metrics.enabled`. To get them into OpenTelemetry, scrape `/metrics` with the collector's `prometheus` receiver.
|
||||
The metrics of the Prometheus endpoint are also exported, whether or not `metrics.enabled` is set, except `gitea_runner_info`, `go_info` and `process_start_time_seconds`, which are sent as resource attributes. They are sent every export interval and once more on shutdown. To turn them off, set `OTEL_METRICS_EXPORTER=none`.
|
||||
|
||||
Metrics are named after the [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/) where one exists, and under `gitea.runner.*` otherwise. Other Go and process metrics, such as `go_memstats_heap_alloc_bytes`, keep their Prometheus names.
|
||||
|
||||
| Metric | Attributes |
|
||||
| --- | --- |
|
||||
| `cicd.worker.count` | `cicd.worker.state` |
|
||||
| `cicd.system.errors` | `cicd.system.component`, `rpc.method`, `error.type` |
|
||||
| `gitea.runner.jobs` | `cicd.pipeline.result` |
|
||||
| `gitea.runner.job.active`, `gitea.runner.job.limit`, `gitea.runner.job.utilization`, `gitea.runner.job.duration` | |
|
||||
| `gitea.runner.poll.fetches` | `gitea.runner.poll.result` |
|
||||
| `gitea.runner.uptime`, `gitea.runner.poll.backoff`, `gitea.runner.poll.fetch.duration` | |
|
||||
| `gitea.runner.report.log.requests`, `gitea.runner.report.state.requests` | `gitea.runner.report.result` |
|
||||
| `gitea.runner.report.log.buffer`, `gitea.runner.report.log.duration`, `gitea.runner.report.state.duration` | |
|
||||
| `go.memory.used` | `go.memory.type` |
|
||||
| `go.goroutine.count`, `go.memory.allocated`, `go.memory.gc.goal`, `go.memory.gc.cycles`, `go.memory.limit`, `go.config.gogc`, `go.processor.limit` | |
|
||||
| `process.memory.usage`, `process.memory.virtual`, `process.unix.file_descriptor.count` (`process.windows.handle.count` on Windows) | |
|
||||
|
||||
## Security
|
||||
|
||||
- OTLP has no authentication of its own. Send to a collector on the runner's host that holds the backend credentials, as the [OpenTelemetry security guide](https://opentelemetry.io/docs/security/config-best-practices/) recommends, or use `https` with a token in `OTEL_EXPORTER_OTLP_HEADERS`. Over plain `http`, headers travel unencrypted.
|
||||
- TLS certificates are verified against the system CA bundle, set `SSL_CERT_FILE` to use your own. Client certificates are not supported, use a local collector for mTLS. Redirects are not followed, so headers only reach the configured endpoint.
|
||||
- OTLP has no authentication of its own. Send to a collector on the runner's host that holds the backend credentials, as the [OpenTelemetry security guide](https://opentelemetry.io/docs/security/config-best-practices/) recommends, or use `https` with a token in `OTEL_EXPORTER_OTLP_HEADERS` or a client certificate. Over plain `http`, headers travel unencrypted.
|
||||
- TLS certificates are verified against the system CA bundle, or only against `OTEL_EXPORTER_OTLP_CERTIFICATE` when set. Redirects are not followed, so headers only reach the configured endpoint.
|
||||
- Jobs in containers never see the runner's `OTEL_*` variables. Jobs on the host inherit the runner's whole environment, headers included, and can read its files anyway since they run as the same user.
|
||||
- Every workflow can read `runner.envs`, so never put credentials there. Point jobs at a collector that holds them instead, keeping in mind that any workflow can then send data through it.
|
||||
- Traces carry the repository and step names of every job, private repositories included, so give the collector the same access controls as the runner's logs.
|
||||
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import "testing"
|
||||
|
||||
func testBuiltinCheckout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
api, repo := newScenario(t)
|
||||
pushWorkflow(t, api, repo, "checkout.yml")
|
||||
branchRun := waitForRun(t, api, repo)
|
||||
if err := api.CreateTag(t.Context(), repo, "v1"); err != nil {
|
||||
t.Fatalf("create tag: %v", err)
|
||||
}
|
||||
tagRun := waitForRunAfter(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, branchRun.ID)
|
||||
requireSuccess(t, api, repo, tagRun.ID)
|
||||
}
|
||||
@@ -30,7 +30,9 @@ func TestCompatibility(t *testing.T) {
|
||||
t.Run("artifact", testArtifactRoundTrip)
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", testWorkflowDispatch)
|
||||
t.Run("builtin_checkout", testBuiltinCheckout)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
t.Run("kubernetes", testKubernetes)
|
||||
}
|
||||
|
||||
func testPayloads(t *testing.T) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func testEphemeralRunner(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ephemeral runner was not deleted")
|
||||
}
|
||||
if !poller.Unregistered() {
|
||||
if !poller.RegistrationRejected() {
|
||||
t.Fatal("ephemeral runner stopped without being deleted")
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,12 @@ func (a *GiteaAPI) CreateFile(ctx context.Context, repo, path, content, message
|
||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateTag(ctx context.Context, repo, name string) error {
|
||||
body := map[string]any{"tag_name": name}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/tags", giteaAdminUser, repo)
|
||||
return a.doJSON(ctx, http.MethodPost, url, body, nil)
|
||||
}
|
||||
|
||||
func (a *GiteaAPI) CreateSecret(ctx context.Context, repo, name, value string) error {
|
||||
body := map[string]any{"data": value}
|
||||
url := fmt.Sprintf("/api/v1/repos/%s/%s/actions/secrets/%s", giteaAdminUser, repo, name)
|
||||
|
||||
+18
-5
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
apicontainer "github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
@@ -187,6 +188,7 @@ func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, e
|
||||
"GITEA__security__INSTALL_LOCK=true",
|
||||
"GITEA__database__DB_TYPE=sqlite3",
|
||||
"GITEA__actions__ENABLED=true",
|
||||
"GITEA__server__LFS_START_SERVER=true",
|
||||
"GITEA__server__ROOT_URL=" + baseURL + "/",
|
||||
},
|
||||
ExposedPorts: network.PortSet{containerPort: struct{}{}},
|
||||
@@ -306,9 +308,14 @@ func randomToken(n int) string {
|
||||
}
|
||||
|
||||
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
|
||||
created, err := f.cli.ExecCreate(ctx, f.id, mobyclient.ExecCreateOptions{
|
||||
return containerExec(ctx, f.cli, f.id, "git", nil, cmd...) // gitea refuses root
|
||||
}
|
||||
|
||||
func containerExec(ctx context.Context, cli mobyclient.APIClient, id, user string, stdin io.Reader, cmd ...string) (string, error) {
|
||||
created, err := cli.ExecCreate(ctx, id, mobyclient.ExecCreateOptions{
|
||||
Cmd: cmd,
|
||||
User: "git", // gitea refuses root
|
||||
User: user,
|
||||
AttachStdin: stdin != nil,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
@@ -316,18 +323,24 @@ func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
attached, err := f.cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
|
||||
attached, err := cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer attached.Close()
|
||||
if stdin != nil {
|
||||
go func() {
|
||||
_, _ = io.Copy(attached.Conn, stdin)
|
||||
_ = attached.CloseWrite()
|
||||
}()
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if _, err := io.Copy(&out, attached.Reader); err != nil {
|
||||
if _, err := stdcopy.StdCopy(&out, &out, attached.Reader); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
inspected, err := f.cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
|
||||
inspected, err := cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build e2e
|
||||
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
|
||||
"github.com/moby/moby/api/pkg/stdcopy"
|
||||
apicontainer "github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/network"
|
||||
mobyclient "github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
func testKubernetes(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := t.Context()
|
||||
k3sID, kubernetes := startK3s(t)
|
||||
|
||||
images := preloadImages(t, k3sID, os.Getenv("E2E_JOB_IMAGE"), os.Getenv("SERVICE_IMAGE"))
|
||||
|
||||
api, repo := newScenario(t)
|
||||
if err := api.CreateVariable(ctx, repo, "E2E_SERVICE_IMAGE", images[1]); err != nil {
|
||||
t.Fatalf("create service image variable: %v", err)
|
||||
}
|
||||
startRunner(t, repo, "e2e-kubernetes", runnerOptions{image: images[0], kubernetes: kubernetes})
|
||||
pushWorkflow(t, api, repo, "kubernetes.yml")
|
||||
|
||||
wfRun := waitForRun(t, api, repo)
|
||||
requireSuccess(t, api, repo, wfRun.ID)
|
||||
|
||||
if out, err := containerExec(ctx, fixture.cli, k3sID, "", nil,
|
||||
"kubectl", "wait", "--for=delete", "pods,secrets", "--selector=app.kubernetes.io/managed-by=gitea-runner", "--timeout=1m",
|
||||
); err != nil {
|
||||
t.Errorf("job pods or secrets left behind: %v: %s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func startK3s(t *testing.T) (string, *config.Kubernetes) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
cli := fixture.cli
|
||||
|
||||
name := fmt.Sprintf("gitea-runner-e2e-k3s-%d", time.Now().UnixNano())
|
||||
apiPort := network.MustParsePort("6443/tcp")
|
||||
server := "https://" + name + ":6443"
|
||||
hostConfig := &apicontainer.HostConfig{Privileged: true}
|
||||
var netConfig *network.NetworkingConfig
|
||||
if fixture.network != "" {
|
||||
netConfig = &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{fixture.network: {}}}
|
||||
} else {
|
||||
loopback := netip.MustParseAddr("127.0.0.1")
|
||||
port, err := freeHostPort(loopback.String())
|
||||
if err != nil {
|
||||
t.Fatalf("find a free host port: %v", err)
|
||||
}
|
||||
server = "https://" + net.JoinHostPort(loopback.String(), strconv.Itoa(port))
|
||||
hostConfig.PortBindings = network.PortMap{apiPort: []network.PortBinding{{HostIP: loopback, HostPort: strconv.Itoa(port)}}}
|
||||
}
|
||||
|
||||
created, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
|
||||
Config: &apicontainer.Config{
|
||||
Image: os.Getenv("E2E_K3S_IMAGE"),
|
||||
Cmd: []string{"server", "--disable=traefik,metrics-server,servicelb,local-storage,coredns", "--tls-san=" + name, "--kubelet-arg=fail-cgroupv1=false"},
|
||||
ExposedPorts: network.PortSet{apiPort: struct{}{}},
|
||||
},
|
||||
HostConfig: hostConfig,
|
||||
NetworkingConfig: netConfig,
|
||||
Name: name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create k3s container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = cli.ContainerRemove(context.Background(), created.ID, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
||||
})
|
||||
if _, err := cli.ContainerStart(ctx, created.ID, mobyclient.ContainerStartOptions{}); err != nil {
|
||||
t.Fatalf("start k3s container: %v", err)
|
||||
}
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
for {
|
||||
_, err := containerExec(waitCtx, cli, created.ID, "", nil, "kubectl", "get", "serviceaccount", "default")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
inspected, inspectErr := cli.ContainerInspect(ctx, created.ID, mobyclient.ContainerInspectOptions{})
|
||||
if inspectErr != nil || !inspected.Container.State.Running || waitCtx.Err() != nil {
|
||||
var logs bytes.Buffer
|
||||
if reader, logsErr := cli.ContainerLogs(ctx, created.ID, mobyclient.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Tail: "30"}); logsErr == nil {
|
||||
_, _ = stdcopy.StdCopy(&logs, &logs, reader)
|
||||
_ = reader.Close()
|
||||
}
|
||||
t.Fatalf("k3s did not start: %v, state %+v %v, last log lines:\n%s", err, inspected.Container.State, inspectErr, logs.String())
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
|
||||
kubeconfig, err := containerExec(ctx, cli, created.ID, "", nil, "cat", "/etc/rancher/k3s/k3s.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("read kubeconfig: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "kubeconfig")
|
||||
if err := os.WriteFile(path, []byte(strings.ReplaceAll(kubeconfig, "https://127.0.0.1:6443", server)), 0o600); err != nil {
|
||||
t.Fatalf("write kubeconfig: %v", err)
|
||||
}
|
||||
kubernetes := &config.Kubernetes{Kubeconfig: path, Namespace: "default"}
|
||||
|
||||
if fixture.network != "" {
|
||||
gitea, err := cli.ContainerInspect(ctx, fixture.id, mobyclient.ContainerInspectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("inspect gitea container: %v", err)
|
||||
}
|
||||
giteaURL, err := url.Parse(fixture.baseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse gitea url: %v", err)
|
||||
}
|
||||
kubernetes.PodTemplate = map[string]any{"spec": map[string]any{"hostAliases": []any{map[string]any{
|
||||
"ip": gitea.Container.NetworkSettings.Networks[fixture.network].IPAddress.String(),
|
||||
"hostnames": []any{giteaURL.Hostname()},
|
||||
}}}}
|
||||
}
|
||||
return created.ID, kubernetes
|
||||
}
|
||||
|
||||
func preloadImages(t *testing.T, k3sID string, images ...string) []string {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
var aliases []string
|
||||
for index, image := range images {
|
||||
alias := fmt.Sprintf("gitea-runner-e2e/preload-%d:%d", index, time.Now().UnixNano())
|
||||
if _, err := fixture.cli.ImageTag(ctx, mobyclient.ImageTagOptions{Source: image, Target: alias}); err != nil {
|
||||
t.Fatalf("tag %s: %v", image, err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = fixture.cli.ImageRemove(context.Background(), alias, mobyclient.ImageRemoveOptions{}) })
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
archive, err := fixture.cli.ImageSave(ctx, aliases)
|
||||
if err != nil {
|
||||
t.Fatalf("save %v: %v", images, err)
|
||||
}
|
||||
defer archive.Close()
|
||||
if out, err := containerExec(ctx, fixture.cli, k3sID, "", archive, "ctr", "--namespace=k8s.io", "images", "import", "-"); err != nil {
|
||||
t.Fatalf("import %v into k3s: %v: %s", images, err, out)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
+6
-1
@@ -141,6 +141,11 @@ func dumpRunLogs(t *testing.T, api *GiteaAPI, repo string, runID int64) {
|
||||
}
|
||||
|
||||
func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||
t.Helper()
|
||||
return waitForRunAfter(t, api, repo, 0)
|
||||
}
|
||||
|
||||
func waitForRunAfter(t *testing.T, api *GiteaAPI, repo string, previousID int64) *ActionRun {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
@@ -150,7 +155,7 @@ func waitForRun(t *testing.T, api *GiteaAPI, repo string) *ActionRun {
|
||||
if err != nil {
|
||||
t.Fatalf("get latest run: %v", err)
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
if len(runs) > 0 && runs[0].ID > previousID {
|
||||
return &runs[0]
|
||||
}
|
||||
select {
|
||||
|
||||
+14
-7
@@ -6,6 +6,7 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -24,10 +25,12 @@ import (
|
||||
)
|
||||
|
||||
type runnerOptions struct {
|
||||
capacity int
|
||||
ephemeral bool
|
||||
cacheV2 *bool
|
||||
cacheHost string
|
||||
capacity int
|
||||
ephemeral bool
|
||||
cacheV2 *bool
|
||||
cacheHost string
|
||||
image string
|
||||
kubernetes *config.Kubernetes
|
||||
}
|
||||
|
||||
func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *poll.Poller {
|
||||
@@ -61,14 +64,18 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
||||
cfg.Container.Network = fixture.network
|
||||
}
|
||||
|
||||
rawLabel := labelName + ":docker://" + os.Getenv("E2E_JOB_IMAGE")
|
||||
scheme := labels.SchemeDocker
|
||||
if options.kubernetes != nil {
|
||||
scheme, cfg.Kubernetes = labels.SchemeKubernetes, *options.kubernetes
|
||||
}
|
||||
rawLabel := labelName + ":" + scheme + "://" + cmp.Or(options.image, os.Getenv("E2E_JOB_IMAGE"))
|
||||
label, err := labels.Parse(rawLabel)
|
||||
if err != nil {
|
||||
t.Fatalf("parse label %q: %v", rawLabel, err)
|
||||
}
|
||||
labelNames := []string{label.Name}
|
||||
|
||||
pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout)
|
||||
pingCli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), "", "", config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||
if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil {
|
||||
t.Fatalf("ping %s: %v", fixture.baseURL, err)
|
||||
}
|
||||
@@ -98,7 +105,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
||||
Ephemeral: regResp.Msg.Runner.Ephemeral,
|
||||
}
|
||||
|
||||
cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout)
|
||||
cli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), reg.UUID, reg.Token, config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||
|
||||
runner := run.NewRunner(cfg, reg, cli)
|
||||
declResp, err := runner.Declare(ctx, labelNames)
|
||||
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
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"
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
name: kubernetes
|
||||
on: push
|
||||
jobs:
|
||||
pod:
|
||||
runs-on: e2e-kubernetes
|
||||
services:
|
||||
web:
|
||||
image: ${{ vars.E2E_SERVICE_IMAGE }}
|
||||
env:
|
||||
NGINX_ENTRYPOINT_QUIET_LOGS: 1
|
||||
options: --health-cmd "wget -q -O /dev/null http://localhost/" --health-interval 1s
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
test -f .gitea/workflows/kubernetes.yml
|
||||
echo "FROM_ENV=exported" >> "$GITHUB_ENV"
|
||||
- run: |
|
||||
test "$FROM_ENV" = exported
|
||||
test -n "$KUBERNETES_SERVICE_HOST" -a -n "${{ runner.arch }}"
|
||||
curl -fsS -o /dev/null http://web/
|
||||
Loaded 50 of 95 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user