mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 06:45:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddee2e054a | ||
|
|
629e062f86 | ||
|
|
73e9552f06 | ||
|
|
05162998eb | ||
|
|
568f89d186 | ||
|
|
d93b7d278a | ||
|
|
0cedd98051 | ||
|
|
07ca4bfa7f | ||
|
|
78610b01f6 | ||
|
|
4a8ee4ce57 | ||
|
|
bf03f36d5a | ||
|
|
5a38a3d0c4 | ||
|
|
6618e67645 | ||
|
|
2b599fae7f | ||
|
|
990ebaa2ac |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: pre-pull test images
|
||||
env:
|
||||
TEST_JOB_IMAGE: node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:62ff2089abf5a9ed33bd232895bef5e22f7bb4b200675cec49a5ebc48e3d4ac8 # renovate: datasource=docker
|
||||
TEST_SERVICE_IMAGE: nginx:alpine@sha256:df221db836e1754089190208cee7eeda94f233197056426eda74a43ab1abeac2 # renovate: datasource=docker
|
||||
run: |
|
||||
for image in "$TEST_JOB_IMAGE" "$TEST_SERVICE_IMAGE"; do
|
||||
for attempt in 1 2 3; do
|
||||
|
||||
@@ -6,12 +6,13 @@ SHASUM ?= shasum -a 256
|
||||
HAS_GO = $(shell hash $(GO) > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/[email protected] # renovate: datasource=go
|
||||
XGO_VERSION := go-1.27.x
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]6 # renovate: datasource=go
|
||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/[email protected]7 # 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
|
||||
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
|
||||
@@ -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`
|
||||
|
||||
@@ -79,53 +79,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
|
||||
|
||||
@@ -201,10 +172,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 +183,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 +203,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).
|
||||
If a job's `runs-on` matches none of the runner's labels, or sets no `runs-on` at all, it still runs in `runner.default_image`: as a container when the runner has `docker` labels, as a pod when it has `kubernetes` labels, else as a container where docker is available and on the host where it is not. Images maintained for this purpose are listed at [gitea/runner-images](https://gitea.com/gitea/runner-images).
|
||||
|
||||
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.
|
||||
|
||||
@@ -298,7 +275,7 @@ These hosts are added to `no_proxy` for jobs, so they are always reached directl
|
||||
|
||||
Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly.
|
||||
|
||||
To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
For one step, set it in the step's `env:`, for the rest of a job, write it to `$GITHUB_ENV`. Setting it at workflow or job level or in `container.env` has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it.
|
||||
|
||||
Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not.
|
||||
|
||||
@@ -380,6 +357,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 +382,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`)
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
nethttp "net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/internal/pkg/lock"
|
||||
@@ -22,6 +24,8 @@ import (
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/storer"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/mattn/go-isatty"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -38,6 +42,20 @@ var (
|
||||
ErrShortRef = errors.New("short SHA references are not supported")
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpClient := newHTTPClient(&nethttp.HTTP2Config{SendPingTimeout: 30 * time.Second})
|
||||
client.InstallProtocol("http", httpClient)
|
||||
client.InstallProtocol("https", httpClient)
|
||||
}
|
||||
|
||||
// newHTTPClient pings idle HTTP/2 connections to drop stalled ones.
|
||||
func newHTTPClient(h2 *nethttp.HTTP2Config) transport.Transport {
|
||||
defaultTransport, _ := nethttp.DefaultTransport.(*nethttp.Transport)
|
||||
httpTransport := defaultTransport.Clone()
|
||||
httpTransport.HTTP2 = h2
|
||||
return http.NewClient(&nethttp.Client{Transport: httpTransport})
|
||||
}
|
||||
|
||||
// AcquireCloneLock returns an unlock function after locking the per-directory mutex for dir.
|
||||
// Only concurrent operations targeting the same directory are serialized; clones into different directories run in parallel.
|
||||
// Callers reading files inside dir (e.g. tarring a checked-out action into a job container) must hold this lock too,
|
||||
|
||||
@@ -6,7 +6,9 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -756,3 +758,25 @@ func TestNewGitCloneExecutorFetchHonoursContext(t *testing.T) {
|
||||
t.Fatal("fetch ignored context cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGitCloneExecutorFailsOnSilentHTTP2Connection(t *testing.T) {
|
||||
server := httptest.NewUnstartedServer(nil)
|
||||
server.EnableHTTP2 = true
|
||||
server.Config.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){
|
||||
"h2": func(_ *http.Server, conn *tls.Conn, _ http.Handler) { _, _ = io.Copy(io.Discard, conn) },
|
||||
}
|
||||
server.StartTLS()
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
previous := gogitclient.Protocols["https"]
|
||||
gogitclient.InstallProtocol("https", newHTTPClient(&http.HTTP2Config{SendPingTimeout: 50 * time.Millisecond, PingTimeout: 50 * time.Millisecond}))
|
||||
t.Cleanup(func() { gogitclient.InstallProtocol("https", previous) })
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := NewGitCloneExecutor(NewGitCloneExecutorInput{
|
||||
URL: server.URL + "/action", Ref: "main", Dir: filepath.Join(t.TempDir(), "action"), InsecureSkipTLS: true,
|
||||
})(ctx)
|
||||
require.Error(t, err)
|
||||
require.NoError(t, ctx.Err())
|
||||
}
|
||||
+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
|
||||
|
||||
+12
-62
@@ -7,7 +7,6 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -25,16 +24,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"
|
||||
@@ -593,7 +588,8 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
|
||||
// If the `privileged` config has been disabled, `copts.privileged` need to be forced to false,
|
||||
// even if the user specifies `--privileged` in the options string.
|
||||
if !hostConfig.Privileged {
|
||||
if !hostConfig.Privileged && copts.privileged {
|
||||
logger.Warnf("container option %q is not allowed when privileged mode is disabled and will be ignored", "--privileged")
|
||||
copts.privileged = false
|
||||
}
|
||||
|
||||
@@ -629,6 +625,7 @@ func (cr *containerReference) mergeContainerConfigs(ctx context.Context, config
|
||||
|
||||
logger.Debugf("Custom container.HostConfig from options ==> %+v", containerConfig.HostConfig)
|
||||
|
||||
cr.sanitizeConfig(ctx, nil, containerConfig.HostConfig) // a dropped volume must not displace the runner's own
|
||||
overlayVolumes(hostConfig, containerConfig.HostConfig)
|
||||
binds := hostConfig.Binds
|
||||
mounts := hostConfig.Mounts
|
||||
@@ -980,42 +977,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
|
||||
}
|
||||
|
||||
@@ -1040,30 +1002,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,
|
||||
@@ -1261,6 +1205,12 @@ func (cr *containerReference) sanitizeConfig(ctx context.Context, config *contai
|
||||
return config, hostConfig
|
||||
}
|
||||
|
||||
func SanitizeBinds(ctx context.Context, validVolumes, binds []string) []string {
|
||||
cr := &containerReference{input: &NewContainerInput{ValidVolumes: validVolumes}}
|
||||
_, hostConfig := cr.sanitizeConfig(ctx, nil, &container.HostConfig{Binds: binds})
|
||||
return hostConfig.Binds
|
||||
}
|
||||
|
||||
// bindTarget returns the container path a bind mounts onto, empty if it cannot be parsed.
|
||||
func bindTarget(bind string) string {
|
||||
parsed, err := loader.ParseVolume(bind)
|
||||
|
||||
@@ -682,6 +682,7 @@ func mergeOptions(t *testing.T, runnerOptions, workflowOptions string, privilege
|
||||
WorkflowOptions: workflowOptions,
|
||||
NetworkMode: "bridge",
|
||||
UsernsMode: "private",
|
||||
ValidVolumes: []string{"**"},
|
||||
}}
|
||||
|
||||
_, hostConfig, err := cr.mergeContainerConfigs(common.WithLogger(context.Background(), logger), &container.Config{}, &container.HostConfig{
|
||||
@@ -844,8 +845,10 @@ func TestMergeContainerConfigsVolumesReplaceRunnerMounts(t *testing.T) {
|
||||
ctx := common.WithLogger(context.Background(), logger)
|
||||
cr := &containerReference{
|
||||
input: &NewContainerInput{
|
||||
NetworkMode: "bridge",
|
||||
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
NetworkMode: "bridge",
|
||||
RunnerOptions: "--volume /host/tools:/opt/hostedtoolcache",
|
||||
WorkflowOptions: "--volume /unapproved:/var/run/docker.sock",
|
||||
ValidVolumes: []string{"/host/tools"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -867,6 +870,7 @@ func TestMergeContainerConfigsWarnsOnlyAboutOptionsThatWereGiven(t *testing.T) {
|
||||
assert.Zero(t, warnings("--volume /host/tools:/opt/hostedtoolcache", ""))
|
||||
assert.Zero(t, warnings("", "--shm-size 1g"))
|
||||
assert.Equal(t, 1, warnings("--network host", ""))
|
||||
assert.Equal(t, 1, warnings("", "--privileged"))
|
||||
}
|
||||
|
||||
func TestMergeContainerConfigsKeepsNetworkAliasesFromOptions(t *testing.T) {
|
||||
|
||||
@@ -107,6 +107,10 @@ func RemoveOrphanNetworks(_ context.Context, _, _ string, _ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func SanitizeBinds(_ context.Context, _, binds []string) []string {
|
||||
return binds
|
||||
}
|
||||
|
||||
func CreateJobVolumes(ctx context.Context, runnerUUID string, volumeNames []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// KubernetesOptions configures the pod a job runs in.
|
||||
type KubernetesOptions struct {
|
||||
Kubeconfig string
|
||||
Namespace string
|
||||
PodTemplates []map[string]any // merged in order, a later one winning
|
||||
MaxLifetime time.Duration
|
||||
ForcePull bool
|
||||
RunnerUUID string
|
||||
}
|
||||
|
||||
// KubernetesPod runs a job container and its service containers as one pod.
|
||||
type KubernetesPod struct {
|
||||
options KubernetesOptions
|
||||
client *kubeClient
|
||||
name, secretName string
|
||||
job *kubernetesContainer
|
||||
services []*kubernetesContainer
|
||||
arch, path string
|
||||
}
|
||||
|
||||
type kubernetesContainer struct {
|
||||
LinuxContainerEnvironmentExtensions
|
||||
pod *KubernetesPod
|
||||
id string // the workflow's service id, name is its DNS label form
|
||||
name string
|
||||
input *NewContainerInput
|
||||
probe map[string]any
|
||||
}
|
||||
|
||||
type containerStatus struct {
|
||||
Name string
|
||||
Ready bool
|
||||
State struct {
|
||||
Waiting *struct{ Reason, Message string }
|
||||
Running *struct{}
|
||||
Terminated *struct {
|
||||
ExitCode int
|
||||
Message string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type podStatus struct {
|
||||
Spec struct{ Containers []struct{} }
|
||||
Status struct {
|
||||
Phase, Reason, Message string
|
||||
Conditions []struct{ Type, Status, Message string }
|
||||
InitContainerStatuses, ContainerStatuses []containerStatus
|
||||
}
|
||||
}
|
||||
|
||||
func NewKubernetesPod(options KubernetesOptions) (*KubernetesPod, error) {
|
||||
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to Kubernetes: %w", err)
|
||||
}
|
||||
return &KubernetesPod{options: options, client: client}, nil
|
||||
}
|
||||
|
||||
// JobContainer returns the container of the pod the job's steps run in.
|
||||
func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnvironment {
|
||||
p.job = &kubernetesContainer{pod: p, name: "job", input: input}
|
||||
return p.job
|
||||
}
|
||||
|
||||
var invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
// KubernetesServiceName returns the container name and hostname of service id, made a DNS label as both must be.
|
||||
func KubernetesServiceName(id string) string {
|
||||
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
|
||||
return strings.TrimRight(name[:min(63, len(name))], "-")
|
||||
}
|
||||
|
||||
// ServiceContainer returns a container of the pod the job reaches at hostname KubernetesServiceName(id).
|
||||
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
|
||||
service := &kubernetesContainer{pod: p, id: id, name: KubernetesServiceName(id), input: input}
|
||||
p.services = append(p.services, service)
|
||||
return service
|
||||
}
|
||||
|
||||
func (p *KubernetesPod) create(ctx context.Context) error {
|
||||
prefix := strings.Trim(strings.ToLower(p.job.input.Name), "-")
|
||||
prefix = cmp.Or(strings.TrimRight(prefix[:min(40, len(prefix))], "-"), "gitea-runner") + "-"
|
||||
name := prefix + strings.ToLower(rand.Text()[:8]) // chosen here, so cleanup knows it even when a create response is lost
|
||||
labels := map[string]any{"app.kubernetes.io/managed-by": "gitea-runner"}
|
||||
if p.options.RunnerUUID != "" {
|
||||
labels[runnerUUIDLabel] = p.options.RunnerUUID
|
||||
}
|
||||
metadata := map[string]any{"name": name, "labels": labels}
|
||||
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
|
||||
var runner struct{ Metadata struct{ Name, UID string } }
|
||||
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
|
||||
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
|
||||
} else {
|
||||
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
|
||||
}
|
||||
}
|
||||
secretData := map[string]string{}
|
||||
manifest, err := p.manifest(name, secretData, metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, service := range p.services {
|
||||
if !strings.EqualFold(service.id, service.name) {
|
||||
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
|
||||
}
|
||||
}
|
||||
if len(secretData) > 0 {
|
||||
p.secretName = name // named like the pod, RemoveOrphanKubernetesResources finds it by that
|
||||
if err := p.client.do(ctx, http.MethodPost, p.client.path("secrets"), map[string]any{"metadata": metadata, "stringData": secretData}, nil); err != nil {
|
||||
return fmt.Errorf("create secret: %w", err)
|
||||
}
|
||||
}
|
||||
p.name = name
|
||||
if err := p.client.do(ctx, http.MethodPost, p.client.path("pods"), manifest, nil); err != nil {
|
||||
return fmt.Errorf("create pod: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *KubernetesPod) manifest(secret string, secretData map[string]string, metadata map[string]any) (any, error) {
|
||||
template := map[string]any{"spec": map[string]any{
|
||||
"automountServiceAccountToken": false,
|
||||
"enableServiceLinks": false,
|
||||
"terminationGracePeriodSeconds": 0, // the job container's tail ignores SIGTERM
|
||||
}}
|
||||
for _, layer := range p.options.PodTemplates {
|
||||
template, _ = mergeManifest(template, layer, "").(map[string]any)
|
||||
}
|
||||
templateSpec, _ := template["spec"].(map[string]any)
|
||||
templateContainers, _ := templateSpec["containers"].([]any)
|
||||
templateVolumes, _ := templateSpec["volumes"].([]any)
|
||||
var serviceTemplate any
|
||||
if index := slices.IndexFunc(templateContainers, func(container any) bool { return manifestField(container, "name") == "$services" }); index >= 0 {
|
||||
serviceTemplate = templateContainers[index]
|
||||
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
|
||||
}
|
||||
var containers, hostnames, volumes []any
|
||||
names := map[string]bool{}
|
||||
for _, container := range templateContainers {
|
||||
names[manifestField(container, "name")] = true
|
||||
}
|
||||
for _, c := range append([]*kubernetesContainer{p.job}, p.services...) {
|
||||
if c != p.job && (c.name == "" || names[c.name]) {
|
||||
return nil, fmt.Errorf("service %s maps to container %q, which is empty or named like another container of the pod", c.id, c.name)
|
||||
}
|
||||
names[c.name] = true
|
||||
spec, err := c.spec(secret, secretData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == p.job {
|
||||
containers = append(containers, spec)
|
||||
continue
|
||||
}
|
||||
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
|
||||
hostnames = append(hostnames, c.name)
|
||||
}
|
||||
for _, name := range []string{"workspace", "act"} {
|
||||
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
|
||||
volumes = append(volumes, map[string]any{"name": name, "emptyDir": map[string]any{}})
|
||||
}
|
||||
}
|
||||
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
|
||||
if len(hostnames) > 0 {
|
||||
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
|
||||
}
|
||||
if p.options.MaxLifetime > 0 {
|
||||
spec["activeDeadlineSeconds"] = max(1, int64(p.options.MaxLifetime.Seconds()))
|
||||
}
|
||||
return mergeManifest(template, map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}, ""), nil
|
||||
}
|
||||
|
||||
// mergeManifest merges over onto base like a strategic merge patch: maps key by key, map list items by name or volumeMounts by mountPath, env entries, volumes and other values replaced.
|
||||
func mergeManifest(base, over any, field string) any {
|
||||
switch over := over.(type) {
|
||||
case map[string]any:
|
||||
merged := map[string]any{}
|
||||
if base, ok := base.(map[string]any); ok {
|
||||
maps.Copy(merged, base)
|
||||
}
|
||||
for key, value := range over {
|
||||
merged[key] = mergeManifest(merged[key], value, key)
|
||||
}
|
||||
return merged
|
||||
case []any:
|
||||
key := cmp.Or(map[string]string{"volumeMounts": "mountPath"}[field], "name")
|
||||
baseList, _ := base.([]any)
|
||||
merged := slices.Clone(baseList)
|
||||
for _, item := range over {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return over
|
||||
}
|
||||
name := manifestField(item, key)
|
||||
index := slices.IndexFunc(merged, func(existing any) bool { return name != "" && manifestField(existing, key) == name })
|
||||
switch {
|
||||
case index < 0:
|
||||
merged = append(merged, item)
|
||||
case field == "env" || field == "volumes": // each holds one source, merging two makes a pod the API rejects
|
||||
merged[index] = item
|
||||
default:
|
||||
merged[index] = mergeManifest(merged[index], item, "")
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
return over
|
||||
}
|
||||
|
||||
func manifestField(item any, key string) string {
|
||||
fields, _ := item.(map[string]any)
|
||||
value, _ := fields[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) spec(secret string, secretData map[string]string) (map[string]any, error) {
|
||||
env := []any{}
|
||||
for index, entry := range c.input.Env {
|
||||
key, value, _ := strings.Cut(entry, "=")
|
||||
if c == c.pod.job {
|
||||
env = append(env, map[string]any{"name": key, "value": value})
|
||||
continue
|
||||
}
|
||||
secretKey := fmt.Sprintf("%s.%d", c.name, index)
|
||||
secretData[secretKey] = value
|
||||
env = append(env, map[string]any{"name": key, "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": secret, "key": secretKey}}})
|
||||
}
|
||||
spec := map[string]any{
|
||||
"name": c.name, "image": c.input.Image, "command": escapeExpansion(c.input.Entrypoint), "args": escapeExpansion(c.input.Cmd),
|
||||
"workingDir": c.input.WorkingDir, "env": env,
|
||||
}
|
||||
if c.pod.options.ForcePull {
|
||||
spec["imagePullPolicy"] = "Always"
|
||||
}
|
||||
if c == c.pod.job {
|
||||
spec["volumeMounts"] = []any{
|
||||
map[string]any{"name": "workspace", "mountPath": c.input.WorkingDir},
|
||||
map[string]any{"name": "act", "mountPath": c.GetActPath()},
|
||||
}
|
||||
}
|
||||
if err := c.parseOptions(); err != nil {
|
||||
return nil, fmt.Errorf("service %s: %w", c.name, err)
|
||||
}
|
||||
if c.probe != nil {
|
||||
spec["startupProbe"] = c.probe
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// escapeExpansion keeps kubelet from expanding $(VAR) and $$ in command and args, which docker passes as is.
|
||||
func escapeExpansion(args []string) []string {
|
||||
args = slices.Clone(args)
|
||||
for index := range args {
|
||||
args[index] = strings.ReplaceAll(args[index], "$", "$$")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// parseOptions turns docker health options, the only ones a pod supports, into a startup probe, which like docker gives up after the retries.
|
||||
func (c *kubernetesContainer) parseOptions() error {
|
||||
args, err := shellquote.Split(c.input.WorkflowOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags := pflag.NewFlagSet("options", pflag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
command := flags.String("health-cmd", "", "")
|
||||
interval := flags.Duration("health-interval", 0, "")
|
||||
timeout := flags.Duration("health-timeout", 0, "")
|
||||
retries := flags.Int("health-retries", 0, "")
|
||||
startPeriod := flags.Duration("health-start-period", 0, "")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return fmt.Errorf("options %q: %w, only --health-* options are supported on Kubernetes", c.input.WorkflowOptions, err)
|
||||
}
|
||||
if *command == "" {
|
||||
return nil
|
||||
}
|
||||
period := max(1, int(cmp.Or(*interval, 30*time.Second).Seconds()))
|
||||
c.probe = map[string]any{
|
||||
"exec": map[string]any{"command": []string{"/bin/sh", "-c", *command}},
|
||||
"periodSeconds": period,
|
||||
"timeoutSeconds": max(1, int(cmp.Or(*timeout, 30*time.Second).Seconds())),
|
||||
"failureThreshold": max(1, cmp.Or(*retries, 3)) + int(startPeriod.Seconds())/period, // docker does not count failures in the start period
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *KubernetesPod) get(ctx context.Context) (*podStatus, error) {
|
||||
var pod podStatus
|
||||
return &pod, p.client.do(ctx, http.MethodGet, p.client.path("pods/"+p.name), nil, &pod)
|
||||
}
|
||||
|
||||
func (p *KubernetesPod) waitStarted(ctx context.Context) error {
|
||||
var unscheduled string
|
||||
for delay := 100 * time.Millisecond; ; delay = min(2*delay, time.Second) {
|
||||
pod, err := p.get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pod.Status.Phase == "Failed" {
|
||||
return fmt.Errorf("pod %s failed: %s", p.name, cmp.Or(pod.Status.Message, pod.Status.Reason))
|
||||
}
|
||||
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
|
||||
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
||||
if waiting := status.State.Waiting; waiting != nil {
|
||||
switch waiting.Reason {
|
||||
case "ErrImagePull", "ImagePullBackOff", "InvalidImageName", "ErrImageNeverPull", "CreateContainerConfigError", "CreateContainerError", "RunContainerError":
|
||||
return fmt.Errorf("container %s of pod %s: %s: %s", status.Name, p.name, waiting.Reason, waiting.Message)
|
||||
}
|
||||
started = false
|
||||
}
|
||||
}
|
||||
if started {
|
||||
return nil
|
||||
}
|
||||
for _, condition := range pod.Status.Conditions {
|
||||
if condition.Type == "PodScheduled" && condition.Status == "False" && condition.Message != unscheduled {
|
||||
unscheduled = condition.Message
|
||||
common.Logger(ctx).Infof("Waiting for pod %s to be scheduled: %s", p.name, unscheduled)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Create(_, _ []string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if c != c.pod.job {
|
||||
return nil
|
||||
}
|
||||
return c.pod.create(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Start(_ bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if c != c.pod.job {
|
||||
return nil
|
||||
}
|
||||
if err := c.pod.waitStarted(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
var stdout bytes.Buffer
|
||||
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `printf '%s\n%s' "$(uname -m)" "$PATH"`}, nil, &stdout, io.Discard); err != nil {
|
||||
return err
|
||||
}
|
||||
machine, path, _ := strings.Cut(stdout.String(), "\n")
|
||||
c.pod.arch, c.pod.path = goArchToActionArch(machine), path
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (*kubernetesContainer) Pull(bool) common.Executor {
|
||||
return func(context.Context) error { return nil }
|
||||
}
|
||||
|
||||
var (
|
||||
shellName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
bashReadonly = []string{"BASHOPTS", "BASH_VERSINFO", "EUID", "PPID", "SHELLOPTS", "UID"} // bash as sh cannot export these
|
||||
)
|
||||
|
||||
// Exec feeds the command to sh on stdin, which exports the env so it stays out of the request URL and, where sh can export a name, out of process arguments.
|
||||
func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
script, args := "", []string{"env", "--"}
|
||||
for name, value := range env {
|
||||
if shellName.MatchString(name) && !slices.Contains(bashReadonly, name) {
|
||||
script += "command export " + shellquote.Join(name+"="+value) + "\n"
|
||||
} else {
|
||||
args = append(args, name+"="+value)
|
||||
}
|
||||
}
|
||||
script += "exec " + shellquote.Join(append(args, command...)...)
|
||||
if workdir != "" {
|
||||
script = "cd " + shellquote.Join(workdir) + " || exit\n" + script
|
||||
}
|
||||
defer common.FlushWriter(c.input.Stdout)
|
||||
defer common.FlushWriter(c.input.Stderr)
|
||||
return c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh"}, strings.NewReader(script), c.input.Stdout, c.input.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, write func(io.Writer) error) error {
|
||||
reader, writer := io.Pipe()
|
||||
defer reader.Close()
|
||||
go func() { writer.CloseWithError(write(writer)) }()
|
||||
var stderr bytes.Buffer
|
||||
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `mkdir -p "$1" && tar -xf - -C "$1"`, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
|
||||
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return c.extract(ctx, destPath, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
return c.extract(ctx, "/", func(writer io.Writer) error {
|
||||
return writeDirTar(ctx, writer, destPath, srcPath, useGitIgnore, skipGitDir, 0, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
|
||||
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
||||
reader, writer := io.Pipe()
|
||||
go func() {
|
||||
var stderr bytes.Buffer
|
||||
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
writer.CloseWithError(err)
|
||||
}()
|
||||
buffered := bufio.NewReader(reader)
|
||||
if _, err := buffered.Peek(1); err != nil {
|
||||
reader.Close()
|
||||
return nil, err
|
||||
}
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{buffered, reader}, nil
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
||||
return parseEnvFile(c, srcPath, env)
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
||||
return func(context.Context) error {
|
||||
(*env)["PATH"] = cmp.Or((*env)["PATH"], c.pod.path)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Inspect(ctx context.Context) (*Info, error) {
|
||||
if c.pod.name == "" {
|
||||
return nil, fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
||||
}
|
||||
pod, err := c.pod.get(ctx)
|
||||
if isNotFound(err) {
|
||||
return nil, fmt.Errorf("pod %s %w", c.pod.name, ErrContainerNotFound)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &Info{ID: c.pod.name + "/" + c.name, State: "created", Health: HealthNone, Ports: map[string]string{}}
|
||||
for port := range c.input.ExposedPorts {
|
||||
info.Ports[port.Port()] = port.Port() // services share the pod's network
|
||||
}
|
||||
for _, status := range pod.Status.ContainerStatuses {
|
||||
if status.Name != c.name {
|
||||
continue
|
||||
}
|
||||
if status.State.Running != nil {
|
||||
info.State = StateRunning
|
||||
} else if terminated := status.State.Terminated; terminated != nil {
|
||||
info.State, info.ExitCode, info.HealthOutput = "exited", terminated.ExitCode, terminated.Message
|
||||
}
|
||||
if c.probe != nil {
|
||||
switch {
|
||||
case status.Ready:
|
||||
info.Health = HealthHealthy
|
||||
case info.State == "exited":
|
||||
info.Health = HealthUnhealthy
|
||||
default:
|
||||
info.Health = HealthStarting
|
||||
}
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) DumpLogs(ctx context.Context) error {
|
||||
if c.pod.name == "" {
|
||||
return fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
||||
}
|
||||
defer common.FlushWriter(c.input.Stdout)
|
||||
return c.pod.client.do(ctx, http.MethodGet, c.pod.client.path("pods/"+c.pod.name+"/log?container="+c.name), nil, c.input.Stdout)
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Remove() common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
if c != c.pod.job {
|
||||
return nil
|
||||
}
|
||||
return errors.Join(c.pod.client.delete(ctx, "pods", c.pod.name), c.pod.client.delete(ctx, "secrets", c.pod.secretName))
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveOrphanKubernetesResources removes this runner's job pods created before createdBefore and the Secrets named like them, as listing Secrets would reveal all of the namespace's.
|
||||
func RemoveOrphanKubernetesResources(ctx context.Context, options KubernetesOptions, createdBefore time.Time) error {
|
||||
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to Kubernetes: %w", err)
|
||||
}
|
||||
defer client.http.CloseIdleConnections()
|
||||
var pods struct {
|
||||
Items []struct {
|
||||
Metadata struct {
|
||||
Name string
|
||||
CreationTimestamp time.Time
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.do(ctx, http.MethodGet, client.path("pods?labelSelector="+url.QueryEscape(runnerUUIDLabel+"="+options.RunnerUUID)), nil, &pods); err != nil {
|
||||
return fmt.Errorf("list pods: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
for _, item := range pods.Items {
|
||||
if !item.Metadata.CreationTimestamp.Before(createdBefore) {
|
||||
continue
|
||||
}
|
||||
if err := client.delete(ctx, "secrets", item.Metadata.Name); err != nil {
|
||||
errs = append(errs, err) // leave the pod for the next cleanup, it is the only way back to its Secret
|
||||
continue
|
||||
}
|
||||
errs = append(errs, client.delete(ctx, "pods", item.Metadata.Name))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) Close() common.Executor {
|
||||
return func(context.Context) error {
|
||||
c.pod.client.http.CloseIdleConnections()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
||||
oldStdout, oldStderr := c.input.Stdout, c.input.Stderr
|
||||
c.input.Stdout, c.input.Stderr = stdout, stderr
|
||||
return oldStdout, oldStderr
|
||||
}
|
||||
|
||||
func (c *kubernetesContainer) GetRunnerContext(_ context.Context) map[string]any {
|
||||
return map[string]any{"os": "Linux", "arch": c.pod.arch, "temp": "/tmp", "tool_cache": DefaultToolCache}
|
||||
}
|
||||
@@ -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,290 @@
|
||||
// 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"
|
||||
"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)
|
||||
|
||||
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()
|
||||
}
|
||||
+25
-2
@@ -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,12 +462,15 @@ 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))
|
||||
}
|
||||
|
||||
envList = append(envList, rc.runnerEnv(ctx)...)
|
||||
|
||||
binds, mounts := rc.GetBindsAndMounts()
|
||||
binds, mounts := rc.GetBindsAndMounts(ctx)
|
||||
networkMode := "container:" + rc.jobContainerName()
|
||||
if rc.IsHostEnv() {
|
||||
networkMode = "default"
|
||||
|
||||
@@ -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,10 +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)
|
||||
return inputs
|
||||
}
|
||||
|
||||
maps.Copy(inputs, rc.workflowCallInputs)
|
||||
maps.Copy(inputs, stepInputs)
|
||||
|
||||
switch ghc.EventName {
|
||||
case "workflow_dispatch":
|
||||
|
||||
@@ -392,6 +392,53 @@ jobs:
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetEvaluatorInputsResolvedByGitea(t *testing.T) {
|
||||
wf, err := model.ReadWorkflow(strings.NewReader(`
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
name: {type: string, default: callee}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name: {type: string}
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
config := &Config{Workdir: ".", WorkflowCallInputs: map[string]any{"name": "caller", "target": "dispatch"}}
|
||||
ghc := &model.GithubContext{EventName: "workflow_dispatch", Event: map[string]any{"inputs": map[string]any{"name": "dispatch"}}}
|
||||
|
||||
// the event's own inputs must not override those Gitea resolved
|
||||
job := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: wf}}
|
||||
assert.Equal(t, map[string]any{"name": "caller", "target": "dispatch"}, getEvaluatorInputs(job, nil, ghc))
|
||||
|
||||
composite := &RunContext{Config: config, Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{}}, Parent: job}
|
||||
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) {
|
||||
|
||||
+24
-14
@@ -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,36 +412,44 @@ 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
|
||||
}
|
||||
rc.jobRunDefaults = defaults.Run
|
||||
if rc.containerSpec.Image == "" {
|
||||
return nil
|
||||
}
|
||||
_, containerEnv := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||
return model.DecodeEvaluated("container env", containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
|
||||
return model.DecodeEvaluated("container env", rc.containerEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &rc.containerSpec.Env)
|
||||
}
|
||||
|
||||
func setJobOutputs(ctx context.Context, rc *RunContext) error {
|
||||
|
||||
+242
-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,7 +66,9 @@ type RunContext struct {
|
||||
ExprEval *expressionEvaluator
|
||||
JobContainer container.ExecutionsEnvironment
|
||||
serviceContainers []*serviceContainer
|
||||
containerSpec model.ContainerSpec // container:, resolved once with platformImage, zero without a job container
|
||||
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
|
||||
ActionPath string
|
||||
Parent *RunContext
|
||||
@@ -78,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
|
||||
@@ -113,6 +121,7 @@ type serviceContainer struct {
|
||||
name string
|
||||
image string
|
||||
container container.ExecutionsEnvironment
|
||||
created bool
|
||||
logsDumped bool
|
||||
info *container.Info // last poll, the source of the `job.services` entry
|
||||
}
|
||||
@@ -251,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)
|
||||
}
|
||||
@@ -334,12 +346,12 @@ func splitVolumes(specs []string) ([]string, map[string]string, map[string]bool)
|
||||
}
|
||||
|
||||
// Returns the binds and mounts for the container, resolving paths as appopriate
|
||||
func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[string]string) {
|
||||
name := rc.jobContainerName()
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
|
||||
// the runner's own mounts below yield to the targets the job claims
|
||||
binds, mounts, claimed := splitVolumes(rc.containerSpec.Volumes)
|
||||
binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes))
|
||||
|
||||
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
|
||||
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
|
||||
@@ -349,6 +361,9 @@ func (rc *RunContext) GetBindsAndMounts() ([]string, map[string]string) {
|
||||
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] {
|
||||
@@ -415,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
|
||||
@@ -429,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)
|
||||
}
|
||||
}
|
||||
@@ -468,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)
|
||||
|
||||
@@ -481,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)
|
||||
|
||||
@@ -493,75 +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, spec := range services {
|
||||
if spec == nil {
|
||||
return fmt.Errorf("service %s has no container definition", serviceID)
|
||||
}
|
||||
// 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)
|
||||
c := newContainer(&container.NewContainerInput{
|
||||
Name: serviceContainerName,
|
||||
Image: spec.Image,
|
||||
Username: serviceUsername,
|
||||
Password: servicePassword,
|
||||
Entrypoint: entrypoint,
|
||||
Cmd: cmd,
|
||||
Env: envs,
|
||||
Mounts: serviceMounts,
|
||||
Binds: serviceBinds,
|
||||
Stdout: logWriter,
|
||||
Stderr: logWriter,
|
||||
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})
|
||||
for _, serviceID := range slices.Sorted(maps0.Keys(services)) {
|
||||
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`
|
||||
@@ -570,7 +573,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
ext := container.LinuxContainerEnvironmentExtensions{}
|
||||
containerInput := &container.NewContainerInput{
|
||||
Cmd: nil,
|
||||
Entrypoint: []string{"/bin/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,
|
||||
@@ -602,7 +605,7 @@ func (rc *RunContext) startJobContainer() common.Executor {
|
||||
return err
|
||||
}
|
||||
rc.startDockerProxy(ctx)
|
||||
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts()
|
||||
containerInput.Binds, containerInput.Mounts = rc.GetBindsAndMounts(ctx)
|
||||
containerInput.ValidVolumes = rc.validVolumes()
|
||||
|
||||
rc.jobNetworkName = networkName
|
||||
@@ -618,31 +621,102 @@ 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))
|
||||
}
|
||||
|
||||
func rawLogLine(ctx context.Context) common.LineHandler {
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
return common.NewLineWriter(rc.commandHandler(ctx), func(line string) bool {
|
||||
return func(line string) bool {
|
||||
rawLogger.Infof("%s", line)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (rc *RunContext) cleanupJobResources(networkName string, createAndDeleteNetwork, preclean bool) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
logger := common.Logger(ctx)
|
||||
rc.printServiceLogs(ctx)
|
||||
errs := []error{rc.closeDockerProxy(ctx)}
|
||||
if rc.JobContainer != nil {
|
||||
errs = append(errs, rc.JobContainer.Remove()(ctx))
|
||||
@@ -773,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{}
|
||||
@@ -789,6 +871,7 @@ func (rc *RunContext) startServiceContainers() common.Executor {
|
||||
for _, svc := range rc.serviceContainers {
|
||||
execs = append(execs, common.NewPipelineExecutor(
|
||||
svc.container.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop),
|
||||
func(context.Context) error { svc.created = true; return nil },
|
||||
svc.container.Start(false),
|
||||
))
|
||||
}
|
||||
@@ -927,6 +1010,26 @@ func (svc *serviceContainer) dumpLogs(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
const serviceLogsTimeout = 15 * time.Second // cleanup gets one minute in total
|
||||
|
||||
func (rc *RunContext) printServiceLogs(ctx context.Context) {
|
||||
ctx, cancel := context.WithTimeout(ctx, serviceLogsTimeout)
|
||||
defer cancel()
|
||||
rawLogger := common.Logger(ctx).WithField(rawOutputField, true)
|
||||
for _, svc := range rc.serviceContainers {
|
||||
if !svc.created || svc.logsDumped {
|
||||
continue
|
||||
}
|
||||
rawLogger.Infof("::group::Print service container logs: %s", EscapeCommandData(svc.name))
|
||||
svc.dumpLogs(ctx)
|
||||
rawLogger.Infof("::endgroup::")
|
||||
if ctx.Err() != nil {
|
||||
common.Logger(ctx).Warnf("Timed out printing the service logs after %s", serviceLogsTimeout)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inspect also records the state for the `job.services` context.
|
||||
func (svc *serviceContainer) inspect(ctx context.Context) (*container.Info, error) {
|
||||
info, err := svc.container.Inspect(ctx)
|
||||
@@ -944,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 {
|
||||
@@ -1012,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 {
|
||||
@@ -1039,7 +1152,7 @@ func (rc *RunContext) cleanupFailedStart(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) IsHostEnv() bool {
|
||||
return strings.EqualFold(rc.platformImage, "-self-hosted")
|
||||
return rc.containerSpec.Image == "" && strings.EqualFold(rc.platformImage, "-self-hosted")
|
||||
}
|
||||
|
||||
func (rc *RunContext) stopContainer() common.Executor {
|
||||
@@ -1123,7 +1236,7 @@ func (rc *RunContext) Executor() (common.Executor, error) {
|
||||
}
|
||||
|
||||
func (rc *RunContext) runsOnImage(ctx context.Context) (string, error) {
|
||||
if rc.Run.Job().RunsOn() == nil {
|
||||
if rc.Run.Job().RunsOn() == nil && rc.containerSpec.Image == "" {
|
||||
common.Logger(ctx).Errorf("'runs-on' key not defined in %s", rc.String())
|
||||
}
|
||||
|
||||
@@ -1159,20 +1272,38 @@ func (rc *RunContext) runsOnPlatformNames(ctx context.Context) []string {
|
||||
return model.RunsOnFromNode(rawRunsOn)
|
||||
}
|
||||
|
||||
// resolvePlatformImage evaluates the job's container once for every consumer, ignoring one without an image as GitHub does.
|
||||
// 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 {
|
||||
withoutEnv, _ := splitContainerEnv(rc.Run.Job().RawContainer)
|
||||
var spec *model.ContainerSpec
|
||||
if err := model.DecodeEvaluated("container", withoutEnv, rc.ExprEval.shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||
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.NewExpressionEvaluatorWithEnv(ctx, rc.jobEnv).shared(ctx).EvaluateYamlNode, &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec != nil && spec.Image != "" {
|
||||
rc.containerSpec, rc.platformImage = *spec, spec.Image
|
||||
return nil
|
||||
spec.Image = strings.TrimPrefix(spec.Image, "docker://")
|
||||
if spec.Image != "" || imagelessContainer(withoutEnv) {
|
||||
rc.containerSpec, rc.containerEnv = spec, env
|
||||
}
|
||||
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.
|
||||
func imagelessContainer(container yaml.Node) bool {
|
||||
if container.Kind != yaml.MappingNode {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(container.Content); i += 2 {
|
||||
if container.Content[i].Value == "image" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitContainerEnv takes `env` out of a container mapping, since env reads the job env that resolves after the job environment starts.
|
||||
|
||||
+126
-34
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/moby/moby/api/types/volume"
|
||||
log "github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
assert "github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
require "github.com/stretchr/testify/require"
|
||||
@@ -250,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,
|
||||
@@ -342,12 +342,12 @@ jobs:
|
||||
require.Nil(t, redis.Cmd)
|
||||
}
|
||||
|
||||
func TestStartJobContainerEvaluatesContainersOnce(t *testing.T) {
|
||||
func TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) {
|
||||
inputs := startJobContainerInputs(t, `
|
||||
jobs:
|
||||
job:
|
||||
container: ${{ fromJSON('{"image":"node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
|
||||
services: ${{ fromJSON('{"redis":{"image":"redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
|
||||
container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
|
||||
services: ${{ fromJSON('{"redis":{"image":"docker://redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
|
||||
`, &Config{})
|
||||
redis, job := inputs[0], inputs[1]
|
||||
|
||||
@@ -358,8 +358,32 @@ jobs:
|
||||
require.Equal(t, []string{"/entry.sh"}, redis.Entrypoint)
|
||||
require.Equal(t, []string{"redis-server", "--port", "6380"}, redis.Cmd)
|
||||
require.Equal(t, "node:20", job.Image)
|
||||
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
|
||||
@@ -412,17 +436,14 @@ jobs:
|
||||
steps: []
|
||||
`, &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}})
|
||||
|
||||
env := map[string][]string{}
|
||||
for _, in := range inputs {
|
||||
env[in.Image] = in.Env
|
||||
}
|
||||
db, redis := inputs[0], inputs[1]
|
||||
|
||||
require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128")
|
||||
require.Contains(t, env["redis:latest"], "no_proxy=internal.example")
|
||||
require.Contains(t, redis.Env, "http_proxy=http://proxy:3128")
|
||||
require.Contains(t, redis.Env, "no_proxy=internal.example")
|
||||
// the service's own env wins over what the runner injected, without dropping the rest
|
||||
require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example")
|
||||
require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example")
|
||||
require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128")
|
||||
require.Contains(t, db.Env, "no_proxy=db-only.example")
|
||||
require.NotContains(t, db.Env, "no_proxy=internal.example")
|
||||
require.Contains(t, db.Env, "http_proxy=http://proxy:3128")
|
||||
}
|
||||
|
||||
// act builds Dockerfile actions through the API, which does not pre-populate the proxy
|
||||
@@ -483,7 +504,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
config := testcase.rc.Config
|
||||
config.Workdir = testcase.name
|
||||
config.BindWorkdir = bindWorkDir
|
||||
gotbind, gotmount := rctemplate.GetBindsAndMounts()
|
||||
gotbind, gotmount := rctemplate.GetBindsAndMounts(t.Context())
|
||||
|
||||
// Name binds/mounts are either/or
|
||||
if config.BindWorkdir {
|
||||
@@ -533,7 +554,8 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Config: &Config{
|
||||
BindWorkdir: false,
|
||||
BindWorkdir: false,
|
||||
ValidVolumes: []string{"**"},
|
||||
Secrets: map[string]string{
|
||||
"MAME": "/host/mame/roms",
|
||||
},
|
||||
@@ -544,7 +566,7 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(context.Background())
|
||||
require.NoError(t, rc.resolvePlatformImage(context.Background()))
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Contains(t, gotbind, "/host/mame/roms:/root/.mame/roms:ro")
|
||||
assert.NotContains(t, gotbind, "${{ secrets.MAME }}")
|
||||
assert.NotContains(t, gotmount, "${{ secrets.MAME }}")
|
||||
@@ -562,12 +584,13 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
Config: &Config{
|
||||
BindWorkdir: false,
|
||||
SharedToolCache: true, // so OverridesToolCache has a mount to displace
|
||||
ValidVolumes: []string{"**"},
|
||||
},
|
||||
containerSpec: model.ContainerSpec{Volumes: testcase.volumes},
|
||||
}
|
||||
rc.Run.JobID = "job1"
|
||||
|
||||
gotbind, gotmount := rc.GetBindsAndMounts()
|
||||
gotbind, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
|
||||
if len(testcase.wantbind) > 0 {
|
||||
assert.Contains(t, gotbind, testcase.wantbind)
|
||||
@@ -602,11 +625,12 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
Config: &Config{},
|
||||
}
|
||||
|
||||
_, gotmount := rc.GetBindsAndMounts()
|
||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
assert.NotContains(t, gotmount, sharedToolCacheVolume)
|
||||
|
||||
rc.Config.SharedToolCache = true
|
||||
_, gotmount = rc.GetBindsAndMounts()
|
||||
rc.containerSpec.Volumes = []string{"/unapproved:" + container.DefaultToolCache}
|
||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, container.DefaultToolCache, gotmount[sharedToolCacheVolume])
|
||||
})
|
||||
|
||||
@@ -616,18 +640,18 @@ func TestRunContext_GetBindsAndMounts(t *testing.T) {
|
||||
}
|
||||
rc := &RunContext{
|
||||
Run: &model.Run{JobID: "job1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job1": {}}}},
|
||||
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}},
|
||||
Config: &Config{BindWorkdir: true, Workdir: "/workspace/1/owner/repo", PresetGitHubContext: &model.GithubContext{}, ValidVolumes: []string{"claimed"}},
|
||||
}
|
||||
|
||||
gotbind, _ := rc.GetBindsAndMounts()
|
||||
gotbind, _ := rc.GetBindsAndMounts(t.Context())
|
||||
assert.True(t, slices.ContainsFunc(gotbind, func(bind string) bool { return strings.HasPrefix(bind, "/workspace/1:/workspace/1") }), gotbind)
|
||||
|
||||
rc.Config.BindWorkdir = false
|
||||
_, gotmount := rc.GetBindsAndMounts()
|
||||
_, gotmount := rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, "/workspace/1", gotmount[rc.jobContainerName()])
|
||||
|
||||
rc.containerSpec.Volumes = []string{"claimed:/workspace/1"}
|
||||
_, gotmount = rc.GetBindsAndMounts()
|
||||
_, gotmount = rc.GetBindsAndMounts(t.Context())
|
||||
assert.Equal(t, "/workspace/1/owner/repo", gotmount[rc.jobContainerName()])
|
||||
})
|
||||
}
|
||||
@@ -660,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) {
|
||||
@@ -728,6 +761,20 @@ 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()
|
||||
@@ -958,7 +1005,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{
|
||||
@@ -1035,9 +1081,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"),
|
||||
@@ -1272,12 +1321,13 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, testcase := range []struct {
|
||||
name, health string
|
||||
pullError error
|
||||
name, health string
|
||||
pullError, startError error
|
||||
}{
|
||||
{name: "healthy", health: container.HealthHealthy},
|
||||
{name: "unhealthy", health: container.HealthUnhealthy},
|
||||
{name: "pull failure", pullError: errors.New("pull failed")},
|
||||
{name: "start failure", startError: errors.New("start failed")},
|
||||
} {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
var operations []string
|
||||
@@ -1291,7 +1341,7 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
for name, instance := range map[string]*containerMock{"job": job, "service": service} {
|
||||
instance.On("Pull", true).Return(record(name+".Pull", map[string]error{"job": testcase.pullError}[name]))
|
||||
instance.On("Create", mock.Anything, mock.Anything).Return(record(name+".Create", nil))
|
||||
instance.On("Start", false).Return(record(name+".Start", nil))
|
||||
instance.On("Start", false).Return(record(name+".Start", map[string]error{"service": testcase.startError}[name]))
|
||||
instance.On("Remove").Return(record(name+".Remove", nil))
|
||||
instance.On("Close").Return(record(name+".Close", nil))
|
||||
}
|
||||
@@ -1302,10 +1352,14 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
service.On("Inspect", mock.Anything).Return(&container.Info{State: "running", Health: health}, nil).
|
||||
Run(func(mock.Arguments) { operations = append(operations, "service.Inspect") }).Once()
|
||||
}
|
||||
service.On("DumpLogs", mock.Anything).Return(nil).
|
||||
Run(func(mock.Arguments) { operations = append(operations, "service.DumpLogs") })
|
||||
stdouts := map[string]io.Writer{}
|
||||
service.On("DumpLogs", mock.Anything).Return(nil).Run(func(mock.Arguments) {
|
||||
operations = append(operations, "service.DumpLogs")
|
||||
fmt.Fprintln(stdouts["postgres:latest"], "::add-mask::leak")
|
||||
})
|
||||
origNewContainer := newContainer
|
||||
newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment {
|
||||
stdouts[input.Image] = input.Stdout
|
||||
return map[string]*containerMock{"postgres:latest": service, "node:20": job}[input.Image]
|
||||
}
|
||||
t.Cleanup(func() { newContainer = origNewContainer })
|
||||
@@ -1316,22 +1370,30 @@ func TestRunContext_cleanupFailedStart(t *testing.T) {
|
||||
Run: &model.Run{JobID: "job", Workflow: workflow},
|
||||
platformImage: "node:20",
|
||||
}
|
||||
ctx := common.WithDryrun(t.Context(), true)
|
||||
logger, hook := logrustest.NewNullLogger()
|
||||
ctx := common.WithLogger(common.WithDryrun(t.Context(), true), logger)
|
||||
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
||||
err = rc.startContainer().Then(rc.stopContainer()).Finally(rc.closeContainer())(ctx)
|
||||
want := "job.Remove service.Remove service.Close service.Pull job.Pull"
|
||||
if testcase.pullError != nil {
|
||||
switch {
|
||||
case testcase.pullError != nil:
|
||||
require.ErrorIs(t, err, testcase.pullError)
|
||||
} else {
|
||||
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect"
|
||||
case testcase.startError != nil:
|
||||
require.ErrorIs(t, err, testcase.startError)
|
||||
want += " service.Create service.Start service.DumpLogs"
|
||||
default:
|
||||
want += " service.Create service.Start service.Inspect job.Create job.Start job.Inspect job.Copy service.Inspect service.DumpLogs"
|
||||
if testcase.health == container.HealthUnhealthy {
|
||||
require.ErrorContains(t, err, "the service 'postgres' is unhealthy")
|
||||
want += " service.DumpLogs"
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, strings.Fields(want+" job.Remove service.Remove service.Close job.Close"), operations)
|
||||
assert.Empty(t, rc.Masks)
|
||||
assert.Equal(t, testcase.pullError == nil && testcase.health != container.HealthUnhealthy, slices.ContainsFunc(hook.AllEntries(), func(entry *log.Entry) bool {
|
||||
return strings.HasPrefix(entry.Message, "::group::Print service container logs")
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1587,12 +1649,42 @@ func TestImageOSFromImage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlatformImageAppliesImagelessContainerToRunsOnImage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, image string
|
||||
want model.ContainerSpec
|
||||
}{
|
||||
{"without image key", "", model.ContainerSpec{Volumes: []string{"/host/data:/data"}, Env: map[string]string{"KEY": "value"}}},
|
||||
{"with empty image", "image: '', ", model.ContainerSpec{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rc := createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, "runs-on: toolkit\ncontainer: {"+tc.image+"volumes: [/host/data:/data], env: {KEY: value}}", ""),
|
||||
})
|
||||
require.NoError(t, rc.resolvePlatformImage(t.Context()))
|
||||
require.NoError(t, evaluateJobEnvAndDefaults(t.Context(), rc))
|
||||
|
||||
assert.Equal(t, "ubuntu-latest", rc.platformImage)
|
||||
assert.Equal(t, tc.want, rc.containerSpec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createRunsOnRunContext(t *testing.T, runsOn string) *RunContext {
|
||||
return createIfTestRunContext(map[string]*model.Job{
|
||||
"job1": createJob(t, "runs-on: "+runsOn, ""),
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ type Config struct {
|
||||
NoActionPatch bool // run actions exactly as published, applying no compatibility patches, see patch_actions.go
|
||||
|
||||
PresetGitHubContext *model.GithubContext // overrides actor, ref, repository, token and related context fields
|
||||
WorkflowCallInputs map[string]any // the `inputs` Gitea resolved for a called workflow's job, nil for other jobs
|
||||
EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath
|
||||
ContainerNamePrefix string // the prefix of container name
|
||||
ContainerMaxLifetime time.Duration // the max lifetime of job containers
|
||||
@@ -74,6 +75,7 @@ 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
|
||||
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
|
||||
|
||||
@@ -221,7 +221,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 +433,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 +452,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{}{}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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`.
|
||||
+16
-10
@@ -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,14 +12,20 @@ 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
|
||||
|
||||
@@ -35,12 +41,12 @@ 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, under the same names and whether or not `metrics.enabled` is set. They are sent every export interval and once more on shutdown. If your collector only accepts traces, set `OTEL_METRICS_EXPORTER=none`.
|
||||
|
||||
## 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.
|
||||
@@ -31,6 +31,7 @@ func TestCompatibility(t *testing.T) {
|
||||
t.Run("cancellation_and_log_streaming", testRunCancellation)
|
||||
t.Run("dispatch", testWorkflowDispatch)
|
||||
t.Run("ephemeral", testEphemeralRunner)
|
||||
t.Run("kubernetes", testKubernetes)
|
||||
}
|
||||
|
||||
func testPayloads(t *testing.T) {
|
||||
@@ -69,6 +70,7 @@ func testPayloads(t *testing.T) {
|
||||
"cell-a-2",
|
||||
"cell-b-1",
|
||||
"cell-b-2",
|
||||
"imageless env=imageless-env-value host=imageless-host",
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Errorf("stored logs are missing %q", want)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+17
-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"
|
||||
@@ -306,9 +307,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 +322,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
|
||||
}
|
||||
+12
-5
@@ -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,7 +64,11 @@ 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)
|
||||
|
||||
+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/
|
||||
Vendored
+5
@@ -3,6 +3,10 @@ on: push
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
env:
|
||||
IMAGELESS_ENV: imageless-env-value
|
||||
options: --hostname imageless-host
|
||||
services:
|
||||
web:
|
||||
image: ${{ vars.E2E_SERVICE_IMAGE }}
|
||||
@@ -19,6 +23,7 @@ jobs:
|
||||
echo '::notice::encoded-first%0Aencoded-second'
|
||||
echo "the variable is ${{ vars.GREETING }}"
|
||||
echo "the secret is ${{ secrets.FOO }}"
|
||||
echo "imageless env=$IMAGELESS_ENV host=$(hostname)"
|
||||
curl --connect-timeout 1 --max-time 2 --retry 30 --retry-delay 1 --retry-max-time 30 --retry-all-errors -fsS -o /dev/null http://web/
|
||||
produce:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
### Running `gitea-runner` using `docker-compose`
|
||||
|
||||
This uses the recommended Docker-in-Docker image, see [image flavours](../../README.md#image-flavours).
|
||||
|
||||
```yml
|
||||
...
|
||||
gitea:
|
||||
@@ -19,8 +21,9 @@
|
||||
# - GITEA_RUNNER_REGISTRATION_TOKEN=<user-defined registration token>
|
||||
|
||||
runner:
|
||||
image: gitea/runner
|
||||
image: gitea/runner:latest-dind
|
||||
restart: always
|
||||
privileged: true
|
||||
depends_on:
|
||||
gitea:
|
||||
# required so runner can attach to gitea, see "healthcheck"
|
||||
@@ -28,7 +31,6 @@
|
||||
restart: true
|
||||
volumes:
|
||||
- ./data/runner:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=<instance url>
|
||||
# When using Docker Secrets, it's also possible to use
|
||||
@@ -38,10 +40,9 @@
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=<registration token>
|
||||
```
|
||||
|
||||
### Running `gitea-runner` using Docker-in-Docker (DIND)
|
||||
### Running `gitea-runner` using rootless Docker-in-Docker
|
||||
|
||||
- `privileged` has to be set to `true` because in-container Docker daemon requires a lot of kernel capabilities and file system mounts like `procfs` and `sysfs`
|
||||
- `security_opt` sets the `apparmor` profile to `rootlesskit` for hosts running AppArmor (e.g. Ubuntu, Debian), where the kernel might otherwise block user namespace changes that Docker daemon requires for startup. The `rootlesskit` profile is provided by the `docker-ce-rootless-extras` package and is present on hosts where Docker was installed via the official installer or distro packages
|
||||
On AppArmor hosts such as Ubuntu and Debian, `security_opt` is needed for the daemon to start. Docker's `docker-ce-rootless-extras` package provides the `rootlesskit` profile.
|
||||
|
||||
```yml
|
||||
...
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
### Run `gitea-runner` in a Docker Container
|
||||
|
||||
The recommended Docker-in-Docker image runs its own Docker daemon for jobs, so they never get the host's Docker socket:
|
||||
|
||||
```sh
|
||||
docker run -e GITEA_INSTANCE_URL=http://192.168.8.18:3000 -e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> -v /var/run/docker.sock:/var/run/docker.sock -v $PWD/data:/data --name my_runner gitea/runner:nightly
|
||||
docker run --privileged \
|
||||
-e GITEA_INSTANCE_URL=http://192.168.8.18:3000 \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<runner_token> \
|
||||
-v "$PWD/data:/data" \
|
||||
-v runner-docker:/var/lib/docker \
|
||||
--name my_runner gitea/runner:nightly-dind
|
||||
```
|
||||
|
||||
The `/data` directory inside the docker container contains the runner API keys after registration.
|
||||
It must be persisted, otherwise the runner would try to register again, using the same, now defunct registration token.
|
||||
The volumes keep the runner's registration and the job image cache. See [image flavours](../../README.md#image-flavours) for alternatives.
|
||||
@@ -1,25 +1,8 @@
|
||||
## Kubernetes Docker in Docker Deployment with `gitea-runner`
|
||||
## Kubernetes deployments of `gitea-runner`
|
||||
|
||||
NOTE: Docker in Docker (dind) requires elevated privileges on Kubernetes. The current way to achieve this is to set the pod `SecurityContext` to `privileged`. Keep in mind that this is a potential security issue that has the potential for a malicious application to break out of the container context.
|
||||
Jobs can run on Kubernetes in two ways:
|
||||
|
||||
NOTE: `dind-docker.yaml` uses the native sidecar pattern (init container with `restartPolicy: Always`), which requires Kubernetes 1.29+ (or 1.28 with the `SidecarContainers` feature gate).
|
||||
- **Job pods**: each job runs as its own pod, without privileged containers and under the cluster's quotas and policies. Docker actions and `docker://` steps are not supported. Deploy with [`kubernetes-pods.yaml`](kubernetes-pods.yaml) and see [docs/kubernetes.md](../../docs/kubernetes.md).
|
||||
- **Docker in Docker**: jobs run in a Docker daemon next to the runner. Every workflow feature works, but the daemon is privileged, so a job can break out to the node. Deploy with [`dind-docker.yaml`](dind-docker.yaml), [`statefulset-dind.yaml`](statefulset-dind.yaml) for several replicas, [`rootless-docker.yaml`](rootless-docker.yaml) for a rootless daemon, or the [Helm chart](https://gitea.com/gitea/helm-actions). The sidecars in the first two need Kubernetes 1.29 or later.
|
||||
|
||||
NOTE: A helm chart for `gitea-runner` also exists for easier deployments https://gitea.com/gitea/helm-actions
|
||||
|
||||
Each example persists **two** things, and it is worth knowing which is which:
|
||||
|
||||
- `/data` is the runner's working directory. It holds the `.runner` registration file and, optionally, the config file — so the runner re-attaches to the server instead of registering again.
|
||||
- The Docker daemon's data root holds the images pulled for jobs (`/var/lib/docker` for the dind sidecar, `/home/rootless/.local/share/docker` for `dind-rootless`). It is *not* under `/data`. If you drop this volume, the examples still work, but the image cache is discarded whenever the pod is recreated and every job re-pulls its images.
|
||||
|
||||
- Kubernetes SIGKILLs a pod 30s after SIGTERM by default, long before a job finishes and reports its result, which leaves tasks the server can only reap as zombies. The manifests raise `terminationGracePeriodSeconds` to three hours, matching the systemd example and the `runner.timeout` job ceiling; set `runner.shutdown_timeout` below that so the runner drains jobs within the window rather than being killed mid-cleanup.
|
||||
|
||||
Files in this directory:
|
||||
|
||||
- [`dind-docker.yaml`](dind-docker.yaml)
|
||||
How to create a Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||
|
||||
- [`rootless-docker.yaml`](rootless-docker.yaml)
|
||||
How to create a rootless Deployment and Persistent Volume for Kubernetes to act as a runner. The Docker credentials are re-generated each time the pod connects and does not need to be persisted.
|
||||
|
||||
- [`statefulset-dind.yaml`](statefulset-dind.yaml)
|
||||
StatefulSet variant of the dind example. Each replica gets a stable identity and its own persistent volume via `volumeClaimTemplates`, so the runner keeps its `.runner` registration across restarts and reschedules instead of trying to register again.
|
||||
All examples read the registration token from the `runner-secret` Secret and the Gitea URL from `GITEA_INSTANCE_URL`, and keep the registration in `/data`. They give a stopping pod three hours to finish its jobs, so keep `runner.shutdown_timeout` below `terminationGracePeriodSeconds`.
|
||||
@@ -0,0 +1,83 @@
|
||||
apiVersion: v1
|
||||
data:
|
||||
token: << base64 encoded registration token >>
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: runner-secret
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: runner
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: runner
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "list", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/exec"]
|
||||
verbs: ["create", "get"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: runner
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: runner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: runner
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: runner
|
||||
spec:
|
||||
serviceName: runner
|
||||
selector:
|
||||
matchLabels:
|
||||
app: runner
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: runner
|
||||
spec:
|
||||
serviceAccountName: runner
|
||||
terminationGracePeriodSeconds: 10800 # keep above runner.shutdown_timeout, see README
|
||||
containers:
|
||||
- name: runner
|
||||
image: gitea/runner:nightly
|
||||
env:
|
||||
- name: GITEA_INSTANCE_URL
|
||||
value: http://gitea-http.gitea.svc.cluster.local:3000
|
||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: runner-secret
|
||||
key: token
|
||||
- name: GITEA_RUNNER_LABELS
|
||||
value: ubuntu-latest:kubernetes://docker.gitea.com/runner-images:ubuntu-latest
|
||||
volumeMounts:
|
||||
- name: runner-data
|
||||
mountPath: /data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: runner-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -9,7 +9,8 @@ require (
|
||||
dario.cat/mergo v1.0.2
|
||||
gitea.dev/actionslib v1.2.1
|
||||
github.com/avast/retry-go/v5 v5.0.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/containerd/errdefs v1.0.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/distribution/reference v0.6.0
|
||||
|
||||
@@ -47,14 +47,16 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cevatbarisyilmaz/ara v0.0.4 h1:SGH10hXpBJhhTlObuZzTuFn1rrdmjQImITXnZVPSodc=
|
||||
github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts=
|
||||
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
|
||||
@@ -27,6 +27,19 @@ func loadConfigCmd(configFile *string) *cobra.Command {
|
||||
|
||||
configCmd.AddCommand(loadGenerateConfigCmd("generate"))
|
||||
configCmd.AddCommand(loadInitConfigCmd(configFile))
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "runner-file",
|
||||
Short: "Print the registration file path used by the runner",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadDefault(*configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), cfg.Runner.File)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "get <key>",
|
||||
|
||||
@@ -97,3 +97,14 @@ func TestConfigCmdResolvesTheConfigFile(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "--config")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigCmdRunnerFileUsesConfiguredPathOrDefault(t *testing.T) {
|
||||
out, _, err := runConfigCmd(t, "", "runner-file")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ".runner\n", out)
|
||||
file := filepath.Join(t.TempDir(), "config.yaml")
|
||||
require.NoError(t, os.WriteFile(file, []byte("runner:\n file: 'state dir/registration.json'\n"), 0o600))
|
||||
out, _, err = runConfigCmd(t, file, "runner-file")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "state dir/registration.json\n", out)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
defer func() { _ = releaseLock() }()
|
||||
}
|
||||
|
||||
shutdownTelemetry, err := telemetry.Setup(ctx, reg.UUID, reg.Name)
|
||||
shutdownTelemetry, err := telemetry.Setup(ctx, reg.UUID, reg.Name, metrics.Registry)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("OpenTelemetry export failed to start")
|
||||
}
|
||||
@@ -191,6 +191,9 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
return err
|
||||
case err != nil:
|
||||
log.WithError(err).Error("fail to invoke Declare")
|
||||
if client.IsRegistrationRejected(err) {
|
||||
removeEphemeralRegistration(reg, cfg.Runner.File)
|
||||
}
|
||||
return err
|
||||
default:
|
||||
log.Infof("runner: %s, with version: %s, with labels: %v, declare successfully",
|
||||
@@ -200,12 +203,12 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
|
||||
poller := poll.New(cfg, cli, runner)
|
||||
|
||||
metrics.Init() // OTLP exports the registry even without the server
|
||||
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
||||
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
||||
metrics.RegisterUptimeFunc(time.Now())
|
||||
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
||||
if cfg.Metrics.Enabled {
|
||||
metrics.Init()
|
||||
metrics.RunnerInfo.WithLabelValues(ver.Version(), resp.Msg.Runner.Name).Set(1)
|
||||
metrics.RunnerCapacity.Set(float64(cfg.Runner.Capacity))
|
||||
metrics.RegisterUptimeFunc(time.Now())
|
||||
metrics.RegisterRunningJobsFunc(runner.RunningCount, cfg.Runner.Capacity)
|
||||
metrics.StartServer(ctx, cfg.Metrics.Addr, func() (bool, string) {
|
||||
return poller.Ready(cfg.Metrics.ReadinessGrace)
|
||||
})
|
||||
@@ -215,7 +218,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
poller.PollOnce()
|
||||
poller.PollOnce(func() { removeEphemeralRegistration(reg, cfg.Runner.File) })
|
||||
}()
|
||||
|
||||
// shutdown when we complete a job or cancel is requested
|
||||
@@ -244,6 +247,10 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
log.Warnf("runner: %s cancelled in progress jobs during shutdown", resp.Msg.Runner.Name)
|
||||
}
|
||||
|
||||
if poller.RegistrationRejected() {
|
||||
removeEphemeralRegistration(reg, cfg.Runner.File)
|
||||
}
|
||||
|
||||
if poller.Unregistered() {
|
||||
return errors.New("runner is no longer registered with the server; please register it again")
|
||||
}
|
||||
@@ -252,6 +259,17 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
||||
}
|
||||
}
|
||||
|
||||
func removeEphemeralRegistration(reg *config.Registration, file string) {
|
||||
if !reg.Ephemeral {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(file); err == nil {
|
||||
log.Infof("removed the spent ephemeral registration %q", file)
|
||||
} else if !os.IsNotExist(err) {
|
||||
log.WithError(err).Warn("failed to remove the spent ephemeral registration")
|
||||
}
|
||||
}
|
||||
|
||||
type daemonArgs struct {
|
||||
Once bool
|
||||
Labels string
|
||||
|
||||
@@ -4,13 +4,24 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/report"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
func TestResolveLabels(t *testing.T) {
|
||||
@@ -70,3 +81,79 @@ func TestInitLoggingSetsLevelAndCaller(t *testing.T) {
|
||||
// act plans a job on this logger, so a live task's secrets have to be masked out of it
|
||||
require.IsType(t, report.MaskingFormatter(nil), log.StandardLogger().Formatter)
|
||||
}
|
||||
|
||||
var errUnregisteredRunner = errors.New("rpc error: code = Unauthenticated desc = unregistered runner")
|
||||
|
||||
type daemonTestService struct {
|
||||
runnerv1connect.UnimplementedRunnerServiceHandler
|
||||
declareErr, fetchErr error
|
||||
beforeTask bool
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (s *daemonTestService) Declare(context.Context, *connect.Request[runnerv1.DeclareRequest]) (*connect.Response[runnerv1.DeclareResponse], error) {
|
||||
if s.declareErr != nil {
|
||||
return nil, s.declareErr
|
||||
}
|
||||
return connect.NewResponse(&runnerv1.DeclareResponse{Runner: &runnerv1.Runner{Name: "test"}}), nil
|
||||
}
|
||||
|
||||
func (s *daemonTestService) FetchTask(context.Context, *connect.Request[runnerv1.FetchTaskRequest]) (*connect.Response[runnerv1.FetchTaskResponse], error) {
|
||||
if s.fetchErr != nil {
|
||||
return nil, s.fetchErr
|
||||
}
|
||||
if s.beforeTask {
|
||||
s.cancel()
|
||||
return nil, context.Canceled
|
||||
}
|
||||
return connect.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1, Context: &structpb.Struct{}, WorkflowPayload: []byte("invalid: [")}}), nil
|
||||
}
|
||||
|
||||
func (*daemonTestService) UpdateTask(_ context.Context, req *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error) {
|
||||
return connect.NewResponse(&runnerv1.UpdateTaskResponse{State: req.Msg.State}), nil
|
||||
}
|
||||
|
||||
func (*daemonTestService) UpdateLog(_ context.Context, req *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error) {
|
||||
return connect.NewResponse(&runnerv1.UpdateLogResponse{AckIndex: req.Msg.Index + int64(len(req.Msg.Rows))}), nil
|
||||
}
|
||||
|
||||
func TestDaemonRemovesOnlyConsumedEphemeralRegistration(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ephemeral, beforeTask bool
|
||||
declareErr, fetchErr error
|
||||
removed bool
|
||||
}{
|
||||
{"ephemeral failed task", true, false, nil, nil, true},
|
||||
{"ephemeral interrupted before task", true, true, nil, nil, false},
|
||||
{"persistent once", false, false, nil, nil, false},
|
||||
{"ephemeral rejected on fetch", true, false, nil, errUnregisteredRunner, true},
|
||||
{"ephemeral proxy 401 on fetch", true, false, nil, connect.NewError(connect.CodeUnauthenticated, errors.New("HTTP status 401 Unauthorized")), false},
|
||||
{"ephemeral rejected on declare", true, false, errUnregisteredRunner, nil, true},
|
||||
{"persistent rejected on declare", false, false, errUnregisteredRunner, nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, handler := runnerv1connect.NewRunnerServiceHandler(&daemonTestService{declareErr: tc.declareErr, fetchErr: tc.fetchErr, beforeTask: tc.beforeTask, cancel: cancel})
|
||||
server := httptest.NewServer(http.StripPrefix("/api/actions", handler))
|
||||
defer server.Close()
|
||||
dir := t.TempDir()
|
||||
regFile := filepath.Join(dir, "registration.json")
|
||||
configFile := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(configFile, []byte("runner:\n file: "+regFile+"\n idle_cleanup_interval: 0s\ncache:\n enabled: false\n"), 0o600))
|
||||
require.NoError(t, config.SaveRegistration(regFile, &config.Registration{Address: server.URL, UUID: "test", Name: "test", Token: "test", Labels: []string{"host:host"}, Ephemeral: tc.ephemeral}))
|
||||
err := runDaemon(ctx, &daemonArgs{Once: true}, &configFile)(nil, nil)
|
||||
if tc.declareErr == nil && tc.fetchErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
}
|
||||
if tc.removed {
|
||||
require.NoFileExists(t, regFile)
|
||||
} else {
|
||||
require.FileExists(t, regFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -618,7 +618,7 @@ func loadExecCmd(ctx context.Context) *cobra.Command {
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.noSkipCheckout, "no-skip-checkout", "", false, "Do not skip actions/checkout")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.debug, "debug", "d", false, "enable debug log")
|
||||
execCmd.PersistentFlags().BoolVarP(&execArg.dryrun, "dryrun", "n", false, "dryrun mode")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"-self-hosted\" to run directly on the host.")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.image, "image", "i", config.DefaultImage, "Docker image to use. Use \"kubernetes://<image>\" to run it as a pod, \"-self-hosted\" to run directly on the host.")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.toolCacheMode, "tool-cache-mode", "", config.ToolCacheModeNone, "What to mount at RUNNER_TOOL_CACHE: none, or shared to reuse one tool cache across runs")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.network, "network", "", "", "Specify the network to which the container will connect")
|
||||
execCmd.PersistentFlags().StringVarP(&execArg.githubInstance, "gitea-instance", "", "", "Gitea instance to use.")
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"os/signal"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/app/run"
|
||||
"gitea.com/gitea/runner/internal/pkg/client"
|
||||
@@ -370,28 +369,12 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
||||
config.RequestTimeout,
|
||||
)
|
||||
|
||||
for {
|
||||
_, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{
|
||||
Data: inputs.RunnerName,
|
||||
}))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.WithError(err).
|
||||
Errorln("Cannot ping the Gitea instance server")
|
||||
// TODO: if ping failed, retry or exit
|
||||
time.Sleep(time.Second)
|
||||
} else {
|
||||
log.Debugln("Successfully pinged the Gitea instance server")
|
||||
break
|
||||
}
|
||||
if _, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{
|
||||
Data: inputs.RunnerName,
|
||||
})); err != nil {
|
||||
return fmt.Errorf("cannot ping the Gitea instance server: %w", err)
|
||||
}
|
||||
log.Debugln("Successfully pinged the Gitea instance server")
|
||||
|
||||
reg := &config.Registration{
|
||||
Name: inputs.RunnerName,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
@@ -246,3 +250,18 @@ func TestInitInputs(t *testing.T) {
|
||||
require.Nil(t, inputs.Labels)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoRegisterReturnsPingErrorWithoutRetrying(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Runner.File = filepath.Join(t.TempDir(), ".runner")
|
||||
err := doRegister(t.Context(), cfg, ®isterInputs{InstanceAddr: server.URL, RunnerName: "runner"})
|
||||
assert.ErrorContains(t, err, "cannot ping the Gitea instance server")
|
||||
assert.Equal(t, int32(1), requests.Load())
|
||||
}
|
||||
@@ -52,6 +52,8 @@ type Poller struct {
|
||||
|
||||
done chan struct{}
|
||||
|
||||
registrationRejected atomic.Bool
|
||||
|
||||
// unregistered is set when the server rejects the runner with an
|
||||
// Unauthenticated response, meaning the runner is no longer registered.
|
||||
unregistered atomic.Bool
|
||||
@@ -151,7 +153,7 @@ func (p *Poller) Poll() {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) PollOnce() {
|
||||
func (p *Poller) PollOnce(onTaskFetched func()) {
|
||||
defer close(p.done)
|
||||
s := &workerState{}
|
||||
for {
|
||||
@@ -173,6 +175,7 @@ func (p *Poller) PollOnce() {
|
||||
continue
|
||||
}
|
||||
s.resetBackoff()
|
||||
onTaskFetched()
|
||||
p.runTaskWithRecover(p.jobsCtx, task)
|
||||
return
|
||||
}
|
||||
@@ -191,6 +194,11 @@ func (p *Poller) Unregistered() bool {
|
||||
return p.unregistered.Load()
|
||||
}
|
||||
|
||||
// RegistrationRejected reports whether polling stopped on an error matching client.IsRegistrationRejected.
|
||||
func (p *Poller) RegistrationRejected() bool {
|
||||
return p.registrationRejected.Load()
|
||||
}
|
||||
|
||||
// Ready reports whether the daemon can currently communicate with Gitea and
|
||||
// accept work, reusing the availability the poll loop last observed rather than
|
||||
// re-running the check. Transient transport failures are tolerated for grace.
|
||||
@@ -368,9 +376,11 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
||||
// An Unauthenticated response means the server no longer knows this
|
||||
// runner (e.g. it was deleted). Retrying forever is pointless, so stop
|
||||
// polling and let the daemon exit with an error instead of spinning.
|
||||
if isUnregistered(err) {
|
||||
rejected := client.IsRegistrationRejected(err)
|
||||
if rejected || connect.CodeOf(err) == connect.CodeUnauthenticated {
|
||||
log.WithError(err).Error("server rejected the runner as unregistered, stopping poller")
|
||||
p.unregistered.Store(true)
|
||||
p.registrationRejected.Store(rejected)
|
||||
p.shutdownPolling()
|
||||
return nil, false
|
||||
}
|
||||
@@ -415,12 +425,6 @@ func (p *Poller) fetchTask(ctx context.Context, s *workerState) (*runnerv1.Task,
|
||||
return resp.Msg.Task, true
|
||||
}
|
||||
|
||||
func isUnregistered(err error) bool {
|
||||
var connectErr *connect.Error
|
||||
return errors.As(err, &connectErr) && (connectErr.Code() == connect.CodeUnauthenticated ||
|
||||
connectErr.Code() == connect.CodeUnknown && connect.IsWireError(err) && connectErr.Message() == "rpc error: code = Unauthenticated desc = unregistered runner")
|
||||
}
|
||||
|
||||
func (p *Poller) markHealthyPoll() {
|
||||
p.lastHealthyPoll.Store(time.Now().UnixNano())
|
||||
p.lastPollFailed.Store(false)
|
||||
|
||||
@@ -106,13 +106,16 @@ func TestPoller_FetchTimeoutIsNoSignal(t *testing.T) {
|
||||
// response marks the runner as unregistered and cancels the polling context so
|
||||
// the daemon can exit instead of retrying forever.
|
||||
func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
|
||||
for name, fetchErr := range map[string]error{
|
||||
"connect": connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")),
|
||||
"gitea": connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")),
|
||||
for name, tc := range map[string]struct {
|
||||
fetchErr error
|
||||
rejected bool
|
||||
}{
|
||||
"connect": {connect_go.NewError(connect_go.CodeUnauthenticated, errors.New("unregistered runner")), false},
|
||||
"gitea": {connect_go.NewWireError(connect_go.CodeUnknown, errors.New("rpc error: code = Unauthenticated desc = unregistered runner")), true},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
client := mocks.NewClient(t)
|
||||
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, fetchErr)
|
||||
client.On("FetchTask", mock.Anything, mock.Anything).Return(nil, tc.fetchErr)
|
||||
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
@@ -122,6 +125,7 @@ func TestPoller_FetchUnauthenticatedStopsPolling(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
|
||||
assert.True(t, p.Unregistered(), "runner should be marked unregistered")
|
||||
assert.Equal(t, tc.rejected, p.RegistrationRejected())
|
||||
assert.Equal(t, int64(0), s.consecutiveErrors, "unauthenticated must not drive error backoff")
|
||||
select {
|
||||
case <-p.pollingCtx.Done():
|
||||
@@ -330,7 +334,7 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
|
||||
poller := New(cfg, cli, runner)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(poller.PollOnce)
|
||||
wg.Go(func() { poller.PollOnce(func() {}) })
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return runner.idleCalls.Load() > 0
|
||||
@@ -342,6 +346,18 @@ func TestPollerPollOnceCallsOnIdle(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestPollerPollOnceCallsOnTaskFetchedBeforeRunningIt(t *testing.T) {
|
||||
cli := mocks.NewClient(t)
|
||||
cli.On("FetchTask", mock.Anything, mock.Anything).Return(connect_go.NewResponse(&runnerv1.FetchTaskResponse{Task: &runnerv1.Task{Id: 1}}), nil)
|
||||
cfg, err := config.LoadDefault("")
|
||||
require.NoError(t, err)
|
||||
runner := &mockRunner{}
|
||||
runsSeenByCallback := int64(-1)
|
||||
New(cfg, cli, runner).PollOnce(func() { runsSeenByCallback = runner.running.Load() + runner.totalCompleted.Load() })
|
||||
assert.Zero(t, runsSeenByCallback)
|
||||
assert.Equal(t, int64(1), runner.totalCompleted.Load())
|
||||
}
|
||||
|
||||
// TestPoller_ConcurrencyLimitedByCapacity verifies that with capacity=3 and
|
||||
// 6 available tasks, at most 3 tasks run concurrently, and FetchTask is
|
||||
// never called concurrently (single poller).
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json/v2"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -152,6 +153,8 @@ var removeOrphanNetworks = container.RemoveOrphanNetworks
|
||||
|
||||
var removeOrphanJobVolumes = container.RemoveOrphanJobVolumes
|
||||
|
||||
var removeOrphanKubernetesResources = container.RemoveOrphanKubernetesResources
|
||||
|
||||
// OnIdle performs lightweight maintenance during polling idle windows.
|
||||
// It runs synchronously on the poller goroutine; shouldRunIdleCleanup
|
||||
// throttles invocations to runner.idle_cleanup_interval so the impact on
|
||||
@@ -174,6 +177,13 @@ func (r *Runner) OnIdle(ctx context.Context) {
|
||||
r.cleanupStaleDirs(ctx, hostRoot, isHostScratchDir)
|
||||
}
|
||||
r.cleanupOrphanDockerResources(ctx)
|
||||
if r.uuid != "" && r.usesKubernetes() {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
if err := removeOrphanKubernetesResources(ctx, r.kubernetesOptions(nil), r.now().Add(-r.cfg.Runner.WorkdirCleanupAge)); err != nil {
|
||||
log.Warnf("failed to clean up job pods left behind by earlier jobs: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) cleanupOrphanDockerResources(ctx context.Context) {
|
||||
@@ -350,6 +360,25 @@ func (r *Runner) cloneEnvs() map[string]string {
|
||||
return envs
|
||||
}
|
||||
|
||||
// restoreCalledWorkflowContext undoes Gitea's rewrite of `event_name` and `event.inputs` for older runners,
|
||||
// and returns the job's `inputs` from `gitea_workflow_call`, nil if it is not a called workflow's job.
|
||||
// See https://github.com/go-gitea/gitea/pull/39452
|
||||
func restoreCalledWorkflowContext(task *runnerv1.Task) map[string]any {
|
||||
workflowCall := task.GetContext().GetFields()["gitea_workflow_call"].GetStructValue().GetFields()
|
||||
if workflowCall == nil {
|
||||
return nil
|
||||
}
|
||||
task.Context.Fields["event_name"] = workflowCall["original_event_name"]
|
||||
if event := task.Context.Fields["event"].GetStructValue(); event != nil {
|
||||
if inputs, ok := workflowCall["original_event_inputs"]; ok {
|
||||
event.Fields["inputs"] = inputs
|
||||
} else {
|
||||
delete(event.Fields, "inputs")
|
||||
}
|
||||
}
|
||||
return workflowCall["inputs"].GetStructValue().AsMap()
|
||||
}
|
||||
|
||||
// getDefaultActionsURL
|
||||
// when DEFAULT_ACTIONS_URL == "https://github.com" and GithubMirror is not blank,
|
||||
// it should be set to GithubMirror first.
|
||||
@@ -382,10 +411,36 @@ func (r *Runner) requiresDocker() bool {
|
||||
return r.labels.RequireDocker() || r.cfg.Container.RequireDocker
|
||||
}
|
||||
|
||||
func (r *Runner) usesKubernetes() bool {
|
||||
return slices.ContainsFunc(r.labels, func(label *labels.Label) bool { return label.Schema == labels.SchemeKubernetes })
|
||||
}
|
||||
|
||||
// kubernetesOptions layers the pod templates of the runner labels a job runs on, in runs-on order, over the shared one.
|
||||
func (r *Runner) kubernetesOptions(runsOn []string) container.KubernetesOptions {
|
||||
options := container.KubernetesOptions{
|
||||
Kubeconfig: r.cfg.Kubernetes.Kubeconfig,
|
||||
Namespace: r.cfg.Kubernetes.Namespace,
|
||||
PodTemplates: []map[string]any{r.cfg.Kubernetes.PodTemplate},
|
||||
RunnerUUID: r.uuid,
|
||||
}
|
||||
names := r.labels.Names()
|
||||
for index, name := range runsOn {
|
||||
if template, ok := r.cfg.Kubernetes.PodTemplates[name]; ok && slices.Contains(names, name) && !slices.Contains(runsOn[:index], name) {
|
||||
options.PodTemplates = append(options.PodTemplates, template)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// fallbackPlatform is where a job runs whose runs-on matches no label, as any job without a
|
||||
// runs-on does, since Gitea sends those to every runner.
|
||||
func (r *Runner) fallbackPlatform(ctx context.Context) string {
|
||||
if r.requiresDocker() || dockerReachable(ctx) {
|
||||
switch {
|
||||
case r.requiresDocker():
|
||||
return r.cfg.Runner.DefaultImage
|
||||
case r.usesKubernetes():
|
||||
return labels.SchemeKubernetes + "://" + r.cfg.Runner.DefaultImage
|
||||
case dockerReachable(ctx):
|
||||
return r.cfg.Runner.DefaultImage
|
||||
}
|
||||
return labels.SelfHostedPlatform
|
||||
@@ -398,12 +453,17 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
}
|
||||
}()
|
||||
|
||||
workflowCallInputs := restoreCalledWorkflowContext(task)
|
||||
r.reportSetup(reporter, task)
|
||||
|
||||
workflow, jobID, err := generateWorkflow(task)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.WithFields(taskLogFields(task, jobID)).
|
||||
WithField("actions_url", r.getDefaultActionsURL(task)).
|
||||
WithField("address", r.client.Address()).
|
||||
Info("task started")
|
||||
|
||||
plan, err := model.CombineWorkflowPlanner(workflow).PlanJob(jobID)
|
||||
if err != nil {
|
||||
@@ -424,7 +484,8 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
}
|
||||
return fallbackPlatform()
|
||||
}
|
||||
cacheURL, cacheContainer := r.cacheForJob(job, platformPicker)
|
||||
platform := platformPicker(job.RunsOn())
|
||||
cacheURL, cacheContainer := r.cacheForJob(job, platform)
|
||||
if cacheURL != "" {
|
||||
envs["ACTIONS_CACHE_URL"] = cacheURL + "/"
|
||||
}
|
||||
@@ -433,7 +494,13 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
// act reaches them by their workflow key.
|
||||
var services map[string]yaml.Node
|
||||
_ = job.RawServices.Decode(&services) // a whole-value expression names no service before evaluation
|
||||
proxyEnv := JobProxyEnv(envs, cacheURL, slices.Sorted(maps.Keys(services)))
|
||||
serviceNames := slices.Sorted(maps.Keys(services))
|
||||
if strings.HasPrefix(platform, labels.SchemeKubernetes+"://") {
|
||||
for index, name := range serviceNames {
|
||||
serviceNames[index] = container.KubernetesServiceName(name)
|
||||
}
|
||||
}
|
||||
proxyEnv := JobProxyEnv(envs, cacheURL, serviceNames)
|
||||
maps.Copy(envs, proxyEnv)
|
||||
|
||||
if r.capabilities != "" {
|
||||
@@ -444,10 +511,6 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
}
|
||||
telemetry.AddJobEnv(ctx, envs)
|
||||
|
||||
log.Infof("task %v repo is %v %v %v", task.Id, taskContext["repository"].GetStringValue(),
|
||||
r.getDefaultActionsURL(task),
|
||||
r.client.Address())
|
||||
|
||||
preset := model.GithubContextFromMap(task.Context.AsMap())
|
||||
if t := task.Secrets["GITEA_TOKEN"]; t != "" {
|
||||
preset.Token = t
|
||||
@@ -536,6 +599,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
NoSkipCheckout: true,
|
||||
DisableActEnv: r.cfg.Runner.SetActEnv != nil && !*r.cfg.Runner.SetActEnv,
|
||||
PresetGitHubContext: preset,
|
||||
WorkflowCallInputs: workflowCallInputs,
|
||||
EventJSON: string(eventJSON),
|
||||
ContainerNamePrefix: fmt.Sprintf("GITEA-ACTIONS-TASK-%d", task.Id),
|
||||
ContainerMaxLifetime: maxLifetime,
|
||||
@@ -563,6 +627,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
RunnerName: r.name,
|
||||
KubernetesPicker: r.kubernetesOptions,
|
||||
}
|
||||
|
||||
rr, err := runner.New(runnerConfig)
|
||||
@@ -845,10 +910,10 @@ func warnIgnoredCacheSecret(cfg *config.Config) {
|
||||
}
|
||||
|
||||
// cacheForJob returns the cache address for the job, and the container to attach to its network to reach it there.
|
||||
func (r *Runner) cacheForJob(job *model.Job, pickPlatform func([]string) string) (string, string) {
|
||||
func (r *Runner) cacheForJob(job *model.Job, platform string) (string, string) {
|
||||
jobContainer := job.Container()
|
||||
cacheContainer := ""
|
||||
if jobContainer != nil && jobContainer.Image != "" || pickPlatform(job.RunsOn()) != labels.SelfHostedPlatform {
|
||||
if !strings.HasPrefix(platform, labels.SchemeKubernetes+"://") && (jobContainer != nil && jobContainer.Image != "" || platform != labels.SelfHostedPlatform) {
|
||||
cacheContainer = r.isolatedCacheContainer()
|
||||
}
|
||||
if cacheContainer == "" {
|
||||
@@ -875,3 +940,22 @@ func (r *Runner) detectIsolatedCacheContainer() string {
|
||||
}
|
||||
return cacheContainer
|
||||
}
|
||||
|
||||
func taskLogFields(task *runnerv1.Task, job string) log.Fields {
|
||||
taskContext := task.GetContext().GetFields()
|
||||
fields := log.Fields{
|
||||
"task_id": task.Id,
|
||||
"job": job,
|
||||
"repository": taskContext["repository"].GetStringValue(),
|
||||
"run_id": taskContext["run_id"].GetStringValue(),
|
||||
"run_number": taskContext["run_number"].GetStringValue(),
|
||||
}
|
||||
if parts := strings.Split(taskContext["gitea_runtime_token"].GetStringValue(), "."); len(parts) == 3 {
|
||||
var claims struct{ JobID int64 } // Gitea's actionsClaims fields are untagged, so the claim key is "JobID"
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err == nil && json.Unmarshal(payload, &claims) == nil && claims.JobID > 0 {
|
||||
fields["job_id"] = claims.JobID
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/act/container"
|
||||
"gitea.com/gitea/runner/internal/pkg/config"
|
||||
"gitea.com/gitea/runner/internal/pkg/labels"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -329,8 +331,20 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { removeOrphanJobVolumes = origRemoveOrphanJobVolumes })
|
||||
var sweptPods []string
|
||||
origRemoveOrphanKubernetesResources := removeOrphanKubernetesResources
|
||||
removeOrphanKubernetesResources = func(ctx context.Context, options container.KubernetesOptions, createdBefore time.Time) error {
|
||||
_, hasDeadline := ctx.Deadline()
|
||||
assert.True(t, hasDeadline)
|
||||
sweptPods = append(sweptPods, options.RunnerUUID)
|
||||
assert.Equal(t, now.Add(-24*time.Hour), createdBefore)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() { removeOrphanKubernetesResources = origRemoveOrphanKubernetesResources })
|
||||
kubernetesLabel, err := labels.Parse("k8s:kubernetes://node")
|
||||
require.NoError(t, err)
|
||||
|
||||
r := &Runner{uuid: "runner-1", cfg: cfg, now: func() time.Time { return now }}
|
||||
r := &Runner{uuid: "runner-1", cfg: cfg, labels: labels.Labels{kubernetesLabel}, now: func() time.Time { return now }}
|
||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
||||
r.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1"}, swept)
|
||||
@@ -353,4 +367,5 @@ func TestRunnerOnIdleRemovesOrphanNetworks(t *testing.T) {
|
||||
hostOnly.OnIdle(context.Background())
|
||||
assert.Equal(t, []string{"runner-1", "runner-2"}, sweptVolumes)
|
||||
assert.Equal(t, []string{"a1b2c3d4e5f6", ""}, detached)
|
||||
assert.Equal(t, []string{"runner-1"}, sweptPods)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
"connectrpc.com/connect"
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -243,6 +245,16 @@ func TestRunnerFallbackPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerKubernetesOptionsLayerTheLabelPodTemplatesInRunsOnOrder(t *testing.T) {
|
||||
cfg := &config.Config{Kubernetes: config.Kubernetes{
|
||||
PodTemplate: map[string]any{"base": true},
|
||||
PodTemplates: map[string]map[string]any{"gpu": {"gpu": true}, "large": {"large": true}, "other": {"other": true}},
|
||||
}}
|
||||
r := &Runner{cfg: cfg, labels: labels.Labels{{Name: "linux"}, {Name: "gpu"}, {Name: "large"}}}
|
||||
|
||||
require.Equal(t, []map[string]any{{"base": true}, {"large": true}, {"gpu": true}}, r.kubernetesOptions([]string{"linux", "large", "other", "gpu", "large"}).PodTemplates)
|
||||
}
|
||||
|
||||
// Proxy variables are assembled per task, because a job's service containers have to be
|
||||
// reached directly and they are only known once the workflow is parsed.
|
||||
func TestNewRunnerLeavesProxyToTheTask(t *testing.T) {
|
||||
@@ -348,7 +360,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
||||
r.isolatedCacheContainer = func() string { return "a1b2c3d4e5f6" }
|
||||
pickPlatform := func(runsOn []string) string { return map[string]string{"native": labels.SelfHostedPlatform}[runsOn[0]] }
|
||||
for job, isolated := range map[string]bool{"native": false, "linux": true, "containerized": true, "empty": false} {
|
||||
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform)
|
||||
cacheURL, cacheContainer := r.cacheForJob(workflow.GetJob(job), pickPlatform(workflow.GetJob(job).RunsOn()))
|
||||
if isolated {
|
||||
assert.Equal(t, "a1b2c3d4e5f6", cacheContainer, job)
|
||||
assert.Equal(t, strings.Replace(r.cacheHandler.ExternalURL(), "127.0.0.1", cacheContainer, 1), cacheURL, job)
|
||||
@@ -358,3 +370,60 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreCalledWorkflowContext(t *testing.T) {
|
||||
newTask := func(workflowCall map[string]any) *runnerv1.Task {
|
||||
values := map[string]any{
|
||||
"event_name": "workflow_call",
|
||||
"event": map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"env": "leaf"}},
|
||||
}
|
||||
if workflowCall != nil {
|
||||
values["gitea_workflow_call"] = workflowCall
|
||||
}
|
||||
taskCtx, err := structpb.NewStruct(values)
|
||||
require.NoError(t, err)
|
||||
return &runnerv1.Task{Context: taskCtx}
|
||||
}
|
||||
|
||||
t.Run("dispatched run", func(t *testing.T) {
|
||||
task := newTask(map[string]any{
|
||||
"original_event_name": "workflow_dispatch",
|
||||
"original_event_inputs": map[string]any{"target": "prod"},
|
||||
"inputs": map[string]any{"target": "prod", "env": "leaf"},
|
||||
})
|
||||
assert.Equal(t, map[string]any{"target": "prod", "env": "leaf"}, restoreCalledWorkflowContext(task))
|
||||
taskCtx := task.Context.AsMap()
|
||||
assert.Equal(t, "workflow_dispatch", taskCtx["event_name"])
|
||||
assert.Equal(t, map[string]any{"ref": "refs/heads/main", "inputs": map[string]any{"target": "prod"}}, taskCtx["event"])
|
||||
assert.Equal(t, []string{"::group::Inputs", "env: leaf", "target: prod", "::endgroup::"}, inputLines(task.Context.Fields))
|
||||
})
|
||||
|
||||
t.Run("event without inputs", func(t *testing.T) {
|
||||
task := newTask(map[string]any{
|
||||
"original_event_name": "push",
|
||||
"inputs": map[string]any{"env": "leaf"},
|
||||
})
|
||||
assert.Equal(t, map[string]any{"env": "leaf"}, restoreCalledWorkflowContext(task))
|
||||
taskCtx := task.Context.AsMap()
|
||||
assert.Equal(t, "push", taskCtx["event_name"])
|
||||
assert.Equal(t, map[string]any{"ref": "refs/heads/main"}, taskCtx["event"])
|
||||
})
|
||||
|
||||
t.Run("not a called workflow's job", func(t *testing.T) {
|
||||
task := newTask(nil)
|
||||
assert.Nil(t, restoreCalledWorkflowContext(task))
|
||||
assert.Equal(t, "workflow_call", task.Context.AsMap()["event_name"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskLogFields(t *testing.T) {
|
||||
fields := func(token string) log.Fields {
|
||||
taskContext, err := structpb.NewStruct(map[string]any{"repository": "owner/repo", "run_id": "159", "run_number": "14", "gitea_runtime_token": token})
|
||||
require.NoError(t, err)
|
||||
return taskLogFields(&runnerv1.Task{Id: 72, Context: taskContext}, "build")
|
||||
}
|
||||
want := log.Fields{"task_id": int64(72), "job": "build", "repository": "owner/repo", "run_id": "159", "run_number": "14"}
|
||||
assert.Equal(t, want, fields("malformed"))
|
||||
want["job_id"] = int64(221)
|
||||
assert.Equal(t, want, fields("header."+base64.RawURLEncoding.EncodeToString([]byte(`{"TaskID":72,"JobID":221}`))+".signature"))
|
||||
}
|
||||
@@ -56,10 +56,15 @@ func (r *Runner) setupLines(task *runnerv1.Task) []string {
|
||||
return append(lines, "::endgroup::")
|
||||
}
|
||||
|
||||
// inputLines lists the inputs the run was triggered with, as workflow_dispatch and workflow_call
|
||||
// carry them in the event payload. Empty when the event has none.
|
||||
// inputLines lists the inputs of the job: those Gitea resolved for a called workflow's job,
|
||||
// or the ones a workflow_dispatch event carries in its payload. Empty when there are none.
|
||||
func inputLines(fields map[string]*structpb.Value) []string {
|
||||
inputs := fields["event"].GetStructValue().GetFields()["inputs"].GetStructValue().GetFields()
|
||||
// For a called workflow's job, restore its inputs from `gitea_workflow_call`.
|
||||
// See https://github.com/go-gitea/gitea/pull/39452.
|
||||
if workflowCall := fields["gitea_workflow_call"].GetStructValue(); workflowCall != nil {
|
||||
inputs = workflowCall.GetFields()["inputs"].GetStructValue().GetFields()
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"gitea.dev/actionslib/runner/v1"
|
||||
@@ -18,3 +19,10 @@ type Client interface {
|
||||
UpdateLog(context.Context, *connect.Request[runnerv1.UpdateLogRequest]) (*connect.Response[runnerv1.UpdateLogResponse], error)
|
||||
UpdateTask(context.Context, *connect.Request[runnerv1.UpdateTaskRequest]) (*connect.Response[runnerv1.UpdateTaskResponse], error)
|
||||
}
|
||||
|
||||
// IsRegistrationRejected reports whether Gitea itself answered "unregistered runner", unlike a proxy's bare 401.
|
||||
func IsRegistrationRejected(err error) bool {
|
||||
var connectErr *connect.Error
|
||||
return errors.As(err, &connectErr) && connectErr.Code() == connect.CodeUnknown && connect.IsWireError(err) &&
|
||||
connectErr.Message() == "rpc error: code = Unauthenticated desc = unregistered runner"
|
||||
}
|
||||
@@ -53,12 +53,13 @@ runner:
|
||||
# scratch directories (left behind when a host cleanup delete stalls) older than
|
||||
# this duration. Setting either workdir_cleanup_age or idle_cleanup_interval to 0
|
||||
# (or any non-positive value) disables stale-directory cleanup entirely, along with
|
||||
# the docker network and volume cleanup below.
|
||||
# the docker and Kubernetes cleanup below.
|
||||
#workdir_cleanup_age: 24h
|
||||
# Cadence for the idle cleanup pass. Besides the directories above, on runners that use
|
||||
# docker it removes the per-job networks and volumes of jobs this runner did not live to
|
||||
# tear down, which would otherwise hold a subnet of the daemon address pool and the job's
|
||||
# workspace contents until the host is rebuilt.
|
||||
# workspace contents until the host is rebuilt. On runners with kubernetes labels it
|
||||
# removes such jobs' pods and Secrets.
|
||||
#idle_cleanup_interval: 10m
|
||||
# The base interval for periodic log flush to the Gitea instance.
|
||||
# Logs may be sent earlier if the buffer reaches log_report_batch_size
|
||||
@@ -106,8 +107,7 @@ runner:
|
||||
# terminal; tools like `docker build` emit redrawing progress frames into the captured log
|
||||
# when a TTY is present.
|
||||
#allocate_pty: false
|
||||
# Image for a job whose runs-on matches none of the labels above. A runner without a
|
||||
# docker daemon runs such a job on the host instead.
|
||||
# Image for a job whose runs-on matches none of the labels above, see the Labels section of the README for where it runs.
|
||||
#default_image: "docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# What to mount at RUNNER_TOOL_CACHE (/opt/hostedtoolcache), where setup actions install tools:
|
||||
# none: nothing. A docker job sees what its image ships there, a host job an empty dir, and
|
||||
@@ -280,6 +280,14 @@ host:
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
#workdir_parent:
|
||||
|
||||
# Jobs that run as Kubernetes pods, see docs/kubernetes.md.
|
||||
kubernetes:
|
||||
#kubeconfig: ""
|
||||
#namespace: ""
|
||||
#pod_template: {}
|
||||
# Pod templates merged over pod_template, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
||||
#pod_templates: {}
|
||||
|
||||
# Optional local task-admission checks. Disabled by default. When enabled, low
|
||||
# disk space or a failing script pauses new task fetching; existing jobs continue.
|
||||
# No health checks run while any job is active; the last result is reused until idle.
|
||||
|
||||
@@ -63,8 +63,8 @@ type Runner struct {
|
||||
FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources.
|
||||
FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources.
|
||||
FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories, orphaned host-mode scratch dirs and orphaned docker job resources older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories and orphaned docker networks and volumes) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
WorkdirCleanupAge time.Duration `yaml:"workdir_cleanup_age"` // WorkdirCleanupAge removes stale bind-workdir task directories, orphaned host-mode scratch dirs and orphaned docker and Kubernetes job resources older than this duration during idle cleanup.
|
||||
IdleCleanupInterval time.Duration `yaml:"idle_cleanup_interval"` // IdleCleanupInterval runs the idle cleanup (stale directories, orphaned docker networks and volumes, orphaned job pods) periodically while the runner is idle. Set to 0 to disable cleanup cadence.
|
||||
LogReportInterval time.Duration `yaml:"log_report_interval"` // LogReportInterval specifies the base interval for periodic log flush.
|
||||
LogReportMaxLatency time.Duration `yaml:"log_report_max_latency"` // LogReportMaxLatency specifies the max time a log row can wait before being sent.
|
||||
LogReportBatchSize int `yaml:"log_report_batch_size"` // LogReportBatchSize triggers immediate log flush when buffer reaches this size.
|
||||
@@ -194,6 +194,14 @@ type Host struct {
|
||||
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the host's working directory.
|
||||
}
|
||||
|
||||
// Kubernetes configures the pods jobs run in.
|
||||
type Kubernetes struct {
|
||||
Kubeconfig string `yaml:"kubeconfig"` // Kubeconfig to use instead of the in-cluster service account.
|
||||
Namespace string `yaml:"namespace"` // Namespace of job pods, empty uses the service account's, else the kubeconfig context's, else default.
|
||||
PodTemplate map[string]any `yaml:"pod_template"` // PodTemplate is a pod manifest the runner merges its own onto.
|
||||
PodTemplates map[string]map[string]any `yaml:"pod_templates"` // PodTemplates are merged over PodTemplate, in runs-on order, for jobs whose runs-on has the label they are keyed by.
|
||||
}
|
||||
|
||||
// Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||
type Metrics struct {
|
||||
Enabled bool `yaml:"enabled"` // Enabled indicates whether the metrics endpoint is exposed.
|
||||
@@ -220,6 +228,7 @@ type Config struct {
|
||||
Host Host `yaml:"host"` // Host represents the configuration for the host.
|
||||
Metrics Metrics `yaml:"metrics"` // Metrics represents the configuration for the Prometheus metrics endpoint.
|
||||
HealthCheck HealthCheck `yaml:"health_check"` // HealthCheck controls opt-in local task-admission checks.
|
||||
Kubernetes Kubernetes `yaml:"kubernetes"`
|
||||
}
|
||||
|
||||
// LoadDefault returns the default configuration.
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemeHost = "host"
|
||||
SchemeDocker = "docker"
|
||||
SchemeHost = "host"
|
||||
SchemeDocker = "docker"
|
||||
SchemeKubernetes = "kubernetes"
|
||||
|
||||
// SelfHostedPlatform is the platform marker act treats as "run on the host".
|
||||
SelfHostedPlatform = "-self-hosted"
|
||||
@@ -42,8 +43,9 @@ func Parse(str string) (*Label, error) {
|
||||
if len(splits) >= 3 {
|
||||
label.Arg = splits[2]
|
||||
}
|
||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker {
|
||||
// Not a schema we know: the colon belongs to the label name itself.
|
||||
kubernetes := label.Schema == SchemeKubernetes && strings.HasPrefix(label.Arg, "//") && len(label.Arg) > 2
|
||||
if label.Schema != SchemeHost && label.Schema != SchemeDocker && !kubernetes {
|
||||
// Not a schema we know, or kubernetes without an image: the colon belongs to the label name itself.
|
||||
return &Label{
|
||||
Name: str,
|
||||
Schema: SchemeHost,
|
||||
@@ -72,10 +74,11 @@ func (l Labels) PickPlatform(runsOn []string) string {
|
||||
case SchemeDocker:
|
||||
// "//" will be ignored
|
||||
platforms[label.Name] = strings.TrimPrefix(label.Arg, "//")
|
||||
case SchemeKubernetes:
|
||||
platforms[label.Name] = SchemeKubernetes + ":" + label.Arg
|
||||
case SchemeHost:
|
||||
platforms[label.Name] = SelfHostedPlatform
|
||||
default:
|
||||
// unreachable: Parse only produces host or docker schemas
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ func TestPickPlatform(t *testing.T) {
|
||||
ls := mustParse(t,
|
||||
"ubuntu:docker://node:18",
|
||||
"self-hosted:host",
|
||||
"k8s:kubernetes://node:24",
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
@@ -123,6 +124,7 @@ func TestPickPlatform(t *testing.T) {
|
||||
want string
|
||||
}{
|
||||
{"docker strips leading slashes", []string{"ubuntu"}, "node:18"},
|
||||
{"kubernetes keeps its scheme", []string{"k8s"}, "kubernetes://node:24"},
|
||||
{"host maps to self-hosted marker", []string{"self-hosted"}, SelfHostedPlatform},
|
||||
{"first match wins", []string{"self-hosted", "ubuntu"}, SelfHostedPlatform},
|
||||
{"unknown label picks nothing", []string{"windows"}, ""},
|
||||
|
||||
@@ -176,7 +176,7 @@ func Init() {
|
||||
|
||||
// RegisterUptimeFunc registers a GaugeFunc that reports seconds since startTime.
|
||||
func RegisterUptimeFunc(startTime time.Time) {
|
||||
Registry.MustRegister(prometheus.NewGaugeFunc(
|
||||
register(prometheus.NewGaugeFunc(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Name: "uptime_seconds",
|
||||
@@ -190,7 +190,7 @@ func RegisterUptimeFunc(startTime time.Time) {
|
||||
// capacity utilisation ratio, evaluated lazily at Prometheus scrape time.
|
||||
func RegisterRunningJobsFunc(countFn func() int64, capacity int) {
|
||||
capF := float64(capacity)
|
||||
Registry.MustRegister(prometheus.NewGaugeFunc(
|
||||
register(prometheus.NewGaugeFunc(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: "job",
|
||||
@@ -199,7 +199,7 @@ func RegisterRunningJobsFunc(countFn func() int64, capacity int) {
|
||||
},
|
||||
func() float64 { return float64(countFn()) },
|
||||
))
|
||||
Registry.MustRegister(prometheus.NewGaugeFunc(
|
||||
register(prometheus.NewGaugeFunc(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: "job",
|
||||
@@ -214,3 +214,9 @@ func RegisterRunningJobsFunc(countFn func() int64, capacity int) {
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// register replaces an earlier registration, as the daemon can run more than once per process.
|
||||
func register(collector prometheus.Collector) {
|
||||
Registry.Unregister(collector)
|
||||
Registry.MustRegister(collector)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
|
||||
metricpb "go.opentelemetry.io/proto/otlp/metrics/v1"
|
||||
resourcepb "go.opentelemetry.io/proto/otlp/resource/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const cumulative = metricpb.AggregationTemporality_AGGREGATION_TEMPORALITY_CUMULATIVE
|
||||
|
||||
// pushMetrics also pushes on shutdown so short-lived runners report.
|
||||
func pushMetrics(exporter *client, res *resource.Resource, gatherer prometheus.Gatherer) func(context.Context) error {
|
||||
attrs := make([]*commonpb.KeyValue, 0, res.Len())
|
||||
for _, kv := range res.Attributes() {
|
||||
attrs = append(attrs, keyValue(string(kv.Key), kv.Value.String()))
|
||||
}
|
||||
interval, timeout := millis("OTEL_METRIC_EXPORT_INTERVAL", time.Minute, time.Minute), millis("OTEL_METRIC_EXPORT_TIMEOUT", 30*time.Second, noLimit)
|
||||
push := func(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
families, err := gatherer.Gather()
|
||||
return errors.Join(err, exporter.upload(ctx, &metricpb.MetricsData{ResourceMetrics: []*metricpb.ResourceMetrics{{
|
||||
Resource: &resourcepb.Resource{Attributes: attrs},
|
||||
ScopeMetrics: []*metricpb.ScopeMetrics{{Scope: &commonpb.InstrumentationScope{Name: scope}, Metrics: convert(families)}},
|
||||
SchemaUrl: res.SchemaURL(),
|
||||
}}}))
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := push(ctx); err != nil && ctx.Err() == nil {
|
||||
log.WithError(err).Warn("OTLP metrics export failed, set OTEL_METRICS_EXPORTER=none to disable it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func(ctx context.Context) error {
|
||||
cancel()
|
||||
<-done
|
||||
return push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
var ucum = map[string]string{
|
||||
"days": "d", "hours": "h", "minutes": "min", "seconds": "s", "milliseconds": "ms", "microseconds": "us", "nanoseconds": "ns",
|
||||
"bytes": "By", "kibibytes": "KiBy", "mebibytes": "MiBy", "gibibytes": "GiBy", "tebibytes": "TiBy",
|
||||
"kilobytes": "kBy", "megabytes": "MBy", "gigabytes": "GBy", "terabytes": "TBy",
|
||||
"meters": "m", "volts": "V", "amperes": "A", "joules": "J", "watts": "W", "grams": "g", "celsius": "Cel", "hertz": "Hz", "percent": "%",
|
||||
}
|
||||
|
||||
// convert follows https://opentelemetry.io/docs/specs/otel/compatibility/prometheus_and_openmetrics/#prometheus-metric-points-to-otlp
|
||||
func convert(families []*dto.MetricFamily) []*metricpb.Metric {
|
||||
now := uint64(time.Now().UnixNano())
|
||||
metrics := make([]*metricpb.Metric, 0, len(families))
|
||||
for _, family := range families {
|
||||
metric := &metricpb.Metric{
|
||||
Name: family.GetName(), Description: family.GetHelp(), Unit: cmp.Or(ucum[family.GetUnit()], family.GetUnit()),
|
||||
Metadata: []*commonpb.KeyValue{keyValue("prometheus.type", strings.ToLower(family.GetType().String()))},
|
||||
}
|
||||
switch family.GetType() {
|
||||
case dto.MetricType_COUNTER:
|
||||
sum := &metricpb.Sum{AggregationTemporality: cumulative, IsMonotonic: true}
|
||||
for _, series := range family.GetMetric() {
|
||||
sum.DataPoints = append(sum.DataPoints, number(series, unixNano(series.GetCounter().GetCreatedTimestamp()), now, series.GetCounter().GetValue()))
|
||||
}
|
||||
metric.Data = &metricpb.Metric_Sum{Sum: sum}
|
||||
case dto.MetricType_GAUGE:
|
||||
gauge := &metricpb.Gauge{}
|
||||
for _, series := range family.GetMetric() {
|
||||
gauge.DataPoints = append(gauge.DataPoints, number(series, 0, now, series.GetGauge().GetValue()))
|
||||
}
|
||||
metric.Data = &metricpb.Metric_Gauge{Gauge: gauge}
|
||||
case dto.MetricType_HISTOGRAM:
|
||||
histogram := &metricpb.Histogram{AggregationTemporality: cumulative}
|
||||
for _, series := range family.GetMetric() {
|
||||
histogram.DataPoints = append(histogram.DataPoints, histogramPoint(series, now))
|
||||
}
|
||||
metric.Data = &metricpb.Metric_Histogram{Histogram: histogram}
|
||||
case dto.MetricType_SUMMARY:
|
||||
summary := &metricpb.Summary{}
|
||||
for _, series := range family.GetMetric() {
|
||||
point := &metricpb.SummaryDataPoint{
|
||||
Attributes: labels(series), StartTimeUnixNano: unixNano(series.GetSummary().GetCreatedTimestamp()), TimeUnixNano: now,
|
||||
Count: series.GetSummary().GetSampleCount(), Sum: series.GetSummary().GetSampleSum(),
|
||||
}
|
||||
for _, quantile := range series.GetSummary().GetQuantile() {
|
||||
point.QuantileValues = append(point.QuantileValues, &metricpb.SummaryDataPoint_ValueAtQuantile{Quantile: quantile.GetQuantile(), Value: quantile.GetValue()})
|
||||
}
|
||||
summary.DataPoints = append(summary.DataPoints, point)
|
||||
}
|
||||
metric.Data = &metricpb.Metric_Summary{Summary: summary}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, metric)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func number(series *dto.Metric, start, now uint64, value float64) *metricpb.NumberDataPoint {
|
||||
return &metricpb.NumberDataPoint{Attributes: labels(series), StartTimeUnixNano: start, TimeUnixNano: now, Value: &metricpb.NumberDataPoint_AsDouble{AsDouble: value}}
|
||||
}
|
||||
|
||||
// unixNano maps a nil timestamp to 0, leaving the start time unset as the spec asks.
|
||||
func unixNano(timestamp *timestamppb.Timestamp) uint64 {
|
||||
return uint64(timestamp.AsTime().UnixNano())
|
||||
}
|
||||
|
||||
func histogramPoint(series *dto.Metric, now uint64) *metricpb.HistogramDataPoint {
|
||||
histogram := series.GetHistogram()
|
||||
point := &metricpb.HistogramDataPoint{
|
||||
Attributes: labels(series), StartTimeUnixNano: unixNano(histogram.GetCreatedTimestamp()), TimeUnixNano: now,
|
||||
Count: histogram.GetSampleCount(), Sum: histogram.SampleSum,
|
||||
BucketCounts: make([]uint64, len(histogram.GetBucket())+1),
|
||||
}
|
||||
var below uint64
|
||||
for i, bucket := range histogram.GetBucket() {
|
||||
point.ExplicitBounds = append(point.ExplicitBounds, bucket.GetUpperBound())
|
||||
point.BucketCounts[i], below = bucket.GetCumulativeCount()-below, bucket.GetCumulativeCount()
|
||||
}
|
||||
point.BucketCounts[len(histogram.GetBucket())] = histogram.GetSampleCount() - below
|
||||
return point
|
||||
}
|
||||
|
||||
func labels(series *dto.Metric) []*commonpb.KeyValue {
|
||||
attrs := make([]*commonpb.KeyValue, 0, len(series.GetLabel()))
|
||||
for _, label := range series.GetLabel() {
|
||||
attrs = append(attrs, keyValue(label.GetName(), label.GetValue()))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func keyValue(key, value string) *commonpb.KeyValue {
|
||||
return &commonpb.KeyValue{Key: key, Value: &commonpb.AnyValue{Value: &commonpb.AnyValue_StringValue{StringValue: value}}}
|
||||
}
|
||||
@@ -6,22 +6,29 @@ package telemetry
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.com/gitea/runner/internal/pkg/ver"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"github.com/avast/retry-go/v5"
|
||||
"github.com/docker/go-connections/tlsconfig"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
@@ -32,6 +39,7 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -51,17 +59,48 @@ func SetTracerProvider(provider trace.TracerProvider) {
|
||||
tracer = provider.Tracer(scope)
|
||||
}
|
||||
|
||||
func Setup(ctx context.Context, uuid, name string) (func(context.Context) error, error) {
|
||||
func Setup(ctx context.Context, uuid, name string, gatherer prometheus.Gatherer) (func(context.Context) error, error) {
|
||||
noop := func(context.Context) error { return nil }
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
if base := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); endpoint == "" && base != "" {
|
||||
endpoint = strings.TrimSuffix(base, "/") + "/v1/traces"
|
||||
}
|
||||
if endpoint == "" || strings.EqualFold(os.Getenv("OTEL_SDK_DISABLED"), "true") || strings.EqualFold(os.Getenv("OTEL_TRACES_EXPORTER"), "none") {
|
||||
if strings.EqualFold(os.Getenv("OTEL_SDK_DISABLED"), "true") {
|
||||
return noop, nil
|
||||
}
|
||||
if protocol := cmp.Or(os.Getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"), os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL")); protocol != "" && !strings.EqualFold(protocol, "http/protobuf") {
|
||||
return noop, fmt.Errorf("OTLP protocol %q is not supported, only http/protobuf is", protocol)
|
||||
clients := map[string]*client{}
|
||||
for _, signal := range []string{"TRACES", "METRICS"} {
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_" + signal + "_ENDPOINT")
|
||||
if base := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); endpoint == "" && base != "" {
|
||||
endpoint = strings.TrimSuffix(base, "/") + "/v1/" + strings.ToLower(signal)
|
||||
}
|
||||
if endpoint == "" || !exportsOTLP(signal) {
|
||||
continue
|
||||
}
|
||||
if protocol := os.Getenv(otlpKey(signal, "PROTOCOL")); protocol != "" && !strings.EqualFold(protocol, "http/protobuf") {
|
||||
return noop, fmt.Errorf("OTLP protocol %q is not supported, only http/protobuf is", protocol)
|
||||
}
|
||||
headers := http.Header{}
|
||||
for _, key := range []string{"OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_" + signal + "_HEADERS"} {
|
||||
for pair := range strings.SplitSeq(os.Getenv(key), ",") {
|
||||
key, value, found := strings.Cut(pair, "=")
|
||||
if decoded, err := url.PathUnescape(strings.TrimSpace(value)); found && err == nil {
|
||||
headers.Set(strings.TrimSpace(key), decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
compressionKey := otlpKey(signal, "COMPRESSION")
|
||||
compression := os.Getenv(compressionKey)
|
||||
if !slices.Contains([]string{"", "none", "gzip"}, strings.ToLower(compression)) {
|
||||
log.Warnf("ignoring unsupported %s=%q", compressionKey, compression)
|
||||
}
|
||||
httpClient, err := newHTTPClient(signal)
|
||||
if err != nil {
|
||||
return noop, err
|
||||
}
|
||||
clients[signal] = &client{
|
||||
endpoint: endpoint, headers: headers, timeout: millis(otlpKey(signal, "TIMEOUT"), 10*time.Second, noLimit),
|
||||
gzip: strings.EqualFold(compression, "gzip"), httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return noop, nil
|
||||
}
|
||||
res, err := resource.New(ctx, resource.WithTelemetrySDK(), resource.WithAttributes(
|
||||
semconv.ServiceName("gitea-runner"),
|
||||
@@ -73,26 +112,90 @@ func Setup(ctx context.Context, uuid, name string) (func(context.Context) error,
|
||||
if err != nil {
|
||||
return noop, err
|
||||
}
|
||||
headers := http.Header{}
|
||||
for _, key := range []string{"OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS"} {
|
||||
for pair := range strings.SplitSeq(os.Getenv(key), ",") {
|
||||
key, value, found := strings.Cut(pair, "=")
|
||||
if decoded, err := url.PathUnescape(strings.TrimSpace(value)); found && err == nil {
|
||||
headers.Set(strings.TrimSpace(key), decoded)
|
||||
}
|
||||
}
|
||||
var shutdown []func(context.Context) error
|
||||
if c := clients["TRACES"]; c != nil {
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(otlptrace.NewUnstarted(c)), sdktrace.WithResource(res))
|
||||
SetTracerProvider(provider)
|
||||
shutdown = append(shutdown, provider.Shutdown)
|
||||
}
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(otlptrace.NewUnstarted(&client{endpoint, headers})), sdktrace.WithResource(res))
|
||||
SetTracerProvider(provider)
|
||||
return provider.Shutdown, nil
|
||||
if c := clients["METRICS"]; c != nil {
|
||||
shutdown = append(shutdown, pushMetrics(c, res, gatherer))
|
||||
}
|
||||
return func(ctx context.Context) error {
|
||||
var group errgroup.Group
|
||||
for _, fn := range shutdown {
|
||||
group.Go(func() error { return fn(ctx) })
|
||||
}
|
||||
return group.Wait()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// client replaces otlptracehttp, which links gRPC (+7 MB): https://github.com/open-telemetry/opentelemetry-go/issues/2579
|
||||
type client struct {
|
||||
endpoint string
|
||||
headers http.Header
|
||||
func exportsOTLP(signal string) bool {
|
||||
key := "OTEL_" + signal + "_EXPORTER"
|
||||
otlp, known := false, false
|
||||
for value := range strings.SplitSeq(os.Getenv(key), ",") {
|
||||
switch value = strings.TrimSpace(value); strings.ToLower(value) {
|
||||
case "":
|
||||
case "otlp":
|
||||
otlp, known = true, true
|
||||
case "none":
|
||||
known = true
|
||||
default:
|
||||
log.Warnf("ignoring unsupported %s value %q", key, value)
|
||||
}
|
||||
}
|
||||
return otlp || !known
|
||||
}
|
||||
|
||||
func otlpKey(signal, name string) string {
|
||||
if key := "OTEL_EXPORTER_OTLP_" + signal + "_" + name; os.Getenv(key) != "" {
|
||||
return key
|
||||
}
|
||||
return "OTEL_EXPORTER_OTLP_" + name
|
||||
}
|
||||
|
||||
const noLimit = math.MaxInt32 * time.Millisecond // the spec's stand-in for a 0 timeout
|
||||
|
||||
func millis(key string, fallback, zero time.Duration) time.Duration {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
ms, err := strconv.ParseInt(value, 10, 32)
|
||||
if err != nil || ms < 0 {
|
||||
log.Warnf("ignoring %s=%q, it must be a non-negative number of milliseconds", key, value)
|
||||
return fallback
|
||||
}
|
||||
return cmp.Or(time.Duration(ms)*time.Millisecond, zero)
|
||||
}
|
||||
|
||||
// newHTTPClient applies the OTLP certificate variables, a configured CA replaces the system roots like in the Go SDK.
|
||||
func newHTTPClient(signal string) (*http.Client, error) {
|
||||
caFile, certFile, keyFile := os.Getenv(otlpKey(signal, "CERTIFICATE")), os.Getenv(otlpKey(signal, "CLIENT_CERTIFICATE")), os.Getenv(otlpKey(signal, "CLIENT_KEY"))
|
||||
if caFile == "" && certFile == "" && keyFile == "" {
|
||||
return exportClient, nil
|
||||
}
|
||||
config, err := tlsconfig.Client(tlsconfig.Options{CAFile: caFile, CertFile: certFile, KeyFile: keyFile, ExclusiveRootPools: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport, _ := http.DefaultTransport.(*http.Transport)
|
||||
transport = transport.Clone()
|
||||
transport.TLSClientConfig = config
|
||||
return &http.Client{Transport: transport, CheckRedirect: exportClient.CheckRedirect}, nil
|
||||
}
|
||||
|
||||
// client replaces the otlp*http exporters, which link gRPC (+7 MB): https://github.com/open-telemetry/opentelemetry-go/issues/2579
|
||||
type client struct {
|
||||
endpoint string
|
||||
headers http.Header
|
||||
timeout time.Duration
|
||||
gzip bool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var gzipWriters = sync.Pool{New: func() any { return gzip.NewWriter(io.Discard) }}
|
||||
|
||||
// exportClient refuses redirects, which would carry the collector's header credentials to another host.
|
||||
var exportClient = &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
|
||||
@@ -101,18 +204,36 @@ func (*client) Start(context.Context) error { return nil }
|
||||
func (*client) Stop(context.Context) error { return nil }
|
||||
|
||||
func (c *client) UploadTraces(ctx context.Context, spans []*tracepb.ResourceSpans) error {
|
||||
body, err := proto.Marshal(&tracepb.TracesData{ResourceSpans: spans})
|
||||
return c.upload(ctx, &tracepb.TracesData{ResourceSpans: spans})
|
||||
}
|
||||
|
||||
func (c *client) upload(ctx context.Context, data proto.Message) error {
|
||||
body, err := proto.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return retry.New(retry.Context(ctx), retry.Attempts(5), retry.LastErrorOnly(true)).Do(func() error {
|
||||
if c.gzip {
|
||||
var buffer bytes.Buffer
|
||||
writer, _ := gzipWriters.Get().(*gzip.Writer)
|
||||
writer.Reset(&buffer)
|
||||
_, _ = writer.Write(body)
|
||||
_ = writer.Close()
|
||||
gzipWriters.Put(writer)
|
||||
body = buffer.Bytes()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return retry.New(retry.Context(ctx), retry.Attempts(5), retry.LastErrorOnly(true), retry.DelayType(retryDelay)).Do(func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return retry.Unrecoverable(err)
|
||||
}
|
||||
req.Header = c.headers.Clone()
|
||||
req.Header.Set("Content-Type", "application/x-protobuf")
|
||||
resp, err := exportClient.Do(req)
|
||||
if c.gzip {
|
||||
req.Header.Set("Content-Encoding", "gzip")
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +242,7 @@ func (c *client) UploadTraces(ctx context.Context, spans []*tracepb.ResourceSpan
|
||||
if resp.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
err = fmt.Errorf("OTLP export failed: %s", resp.Status)
|
||||
err = &statusError{resp.Status, resp.Header.Get("Retry-After")}
|
||||
if slices.Contains([]int{http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout}, resp.StatusCode) {
|
||||
return err
|
||||
}
|
||||
@@ -129,6 +250,19 @@ func (c *client) UploadTraces(ctx context.Context, spans []*tracepb.ResourceSpan
|
||||
})
|
||||
}
|
||||
|
||||
type statusError struct{ status, retryAfter string }
|
||||
|
||||
func (e *statusError) Error() string { return "OTLP export failed: " + e.status }
|
||||
|
||||
func retryDelay(attempt uint, err error, config retry.DelayContext) time.Duration {
|
||||
if status := (*statusError)(nil); errors.As(err, &status) {
|
||||
if seconds, err := strconv.ParseUint(status.retryAfter, 10, 31); err == nil {
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
}
|
||||
return retry.CombineDelay(retry.BackOffDelay, retry.RandomDelay)(attempt, err, config)
|
||||
}
|
||||
|
||||
func StartJob(ctx context.Context, task *runnerv1.Task) (context.Context, func(runnerv1.Result)) {
|
||||
field := func(key string) string { return task.GetContext().GetFields()[key].GetStringValue() }
|
||||
pipeline, owner, repo := field("workflow"), field("repository_owner"), field("repository")
|
||||
|
||||
@@ -4,16 +4,27 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/actionslib/pkg/model"
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -23,6 +34,8 @@ import (
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
|
||||
metricpb "go.opentelemetry.io/proto/otlp/metrics/v1"
|
||||
tracepb "go.opentelemetry.io/proto/otlp/trace/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
@@ -30,66 +43,153 @@ import (
|
||||
|
||||
func setEnv(t *testing.T, env map[string]string) {
|
||||
t.Cleanup(func() { SetTracerProvider(tracenoop.NewTracerProvider()) })
|
||||
for _, key := range []string{
|
||||
"OTEL_SDK_DISABLED", "OTEL_TRACES_EXPORTER", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
} {
|
||||
t.Setenv(key, env[key])
|
||||
for _, variable := range os.Environ() {
|
||||
if key, _, _ := strings.Cut(variable, "="); strings.HasPrefix(key, "OTEL_") {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
}
|
||||
for key, value := range env {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
env map[string]string
|
||||
enabled bool
|
||||
err string
|
||||
env map[string]string
|
||||
traces, metrics bool
|
||||
err string
|
||||
}{
|
||||
{},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://collector:4318/v1/traces"}, enabled: true},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_TRACES_EXPORTER": "NONE"}},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": "http://collector:4318/v1/traces"}, traces: true},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL, "OTEL_TRACES_EXPORTER": "NONE"}, metrics: true},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL, "OTEL_TRACES_EXPORTER": "zipkin, OTLP", "OTEL_METRICS_EXPORTER": "console"}, traces: true, metrics: true},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_TRACES_EXPORTER": "NONE", "OTEL_METRICS_EXPORTER": "none"}},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_SDK_DISABLED": "TRUE"}},
|
||||
{env: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "http://collector:4318", "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"}, err: `OTLP protocol "grpc" is not supported, only http/protobuf is`},
|
||||
} {
|
||||
t.Run(fmt.Sprint(tc.env), func(t *testing.T) {
|
||||
setEnv(t, tc.env)
|
||||
shutdown, err := Setup(t.Context(), "uuid", "runner")
|
||||
gathered := false
|
||||
shutdown, err := Setup(t.Context(), "uuid", "runner", prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) {
|
||||
gathered = true
|
||||
return nil, nil
|
||||
}))
|
||||
if tc.err != "" {
|
||||
require.EqualError(t, err, tc.err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, disabled := tracer.(tracenoop.Tracer)
|
||||
assert.Equal(t, tc.enabled, !disabled)
|
||||
assert.Equal(t, tc.traces, !disabled)
|
||||
require.NoError(t, shutdown(t.Context()))
|
||||
assert.Equal(t, tc.metrics, gathered)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRetriesAndMatchesOTLP(t *testing.T) {
|
||||
bodies := make(chan []byte, 2)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/otlp/v1/traces", r.URL.Path)
|
||||
traceBodies, metricBodies := make(chan []byte, 2), make(chan []byte, 1)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Len(t, r.TLS.PeerCertificates, 1)
|
||||
assert.Equal(t, "Bearer token", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "gzip", r.Header.Get("Content-Encoding"))
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies <- body
|
||||
if len(bodies) == 1 {
|
||||
if r.URL.Path == "/otlp/v1/metrics" {
|
||||
select {
|
||||
case metricBodies <- body:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.Equal(t, "/otlp/v1/traces", r.URL.Path)
|
||||
traceBodies <- body
|
||||
if len(traceBodies) == 1 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
}))
|
||||
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
setEnv(t, map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL + "/otlp/", "OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer%20token"})
|
||||
certificate := server.TLS.Certificates[0]
|
||||
key, err := x509.MarshalPKCS8PrivateKey(certificate.PrivateKey)
|
||||
require.NoError(t, err)
|
||||
certificatePath, keyPath := filepath.Join(t.TempDir(), "cert.pem"), filepath.Join(t.TempDir(), "key.pem")
|
||||
require.NoError(t, os.WriteFile(certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Certificate[0]}), 0o600))
|
||||
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}), 0o600))
|
||||
setEnv(t, map[string]string{
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT": server.URL + "/otlp/", "OTEL_EXPORTER_OTLP_HEADERS": "authorization=Bearer%20token",
|
||||
"OTEL_EXPORTER_OTLP_COMPRESSION": "gzip", "OTEL_METRIC_EXPORT_INTERVAL": "1", "OTEL_EXPORTER_OTLP_CERTIFICATE": certificatePath,
|
||||
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE": certificatePath, "OTEL_EXPORTER_OTLP_CLIENT_KEY": keyPath,
|
||||
})
|
||||
registry := prometheus.NewRegistry()
|
||||
registry.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{Name: "running"}, func() float64 { return 2 }))
|
||||
|
||||
shutdown, err := Setup(t.Context(), "uuid", "runner")
|
||||
shutdown, err := Setup(t.Context(), "uuid", "runner", registry)
|
||||
require.NoError(t, err)
|
||||
_, span := tracer.Start(t.Context(), "op")
|
||||
span.End()
|
||||
gunzip := func(body []byte) []byte {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
decoded, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
return decoded
|
||||
}
|
||||
var metricsData metricpb.MetricsData
|
||||
select {
|
||||
case body := <-metricBodies:
|
||||
require.NoError(t, proto.Unmarshal(gunzip(body), &metricsData))
|
||||
case <-time.After(30 * time.Second):
|
||||
require.FailNow(t, "no periodic metrics export")
|
||||
}
|
||||
require.NoError(t, shutdown(t.Context()))
|
||||
|
||||
require.Len(t, bodies, 2)
|
||||
<-bodies
|
||||
var decoded tracepb.TracesData
|
||||
require.NoError(t, proto.Unmarshal(<-bodies, &decoded))
|
||||
assert.Equal(t, "op", decoded.GetResourceSpans()[0].GetScopeSpans()[0].GetSpans()[0].GetName())
|
||||
require.Len(t, traceBodies, 2)
|
||||
<-traceBodies
|
||||
var tracesData tracepb.TracesData
|
||||
require.NoError(t, proto.Unmarshal(gunzip(<-traceBodies), &tracesData))
|
||||
assert.Equal(t, 2*time.Second, retryDelay(1, &statusError{"429 Too Many Requests", "2"}, nil))
|
||||
assert.Equal(t, "op", tracesData.GetResourceSpans()[0].GetScopeSpans()[0].GetSpans()[0].GetName())
|
||||
|
||||
resourceMetrics := metricsData.GetResourceMetrics()[0]
|
||||
assert.True(t, slices.ContainsFunc(resourceMetrics.GetResource().GetAttributes(), func(kv *commonpb.KeyValue) bool {
|
||||
return proto.Equal(kv, keyValue("service.name", "gitea-runner"))
|
||||
}))
|
||||
assert.InDelta(t, 2, resourceMetrics.GetScopeMetrics()[0].GetMetrics()[0].GetGauge().GetDataPoints()[0].GetAsDouble(), 0)
|
||||
}
|
||||
|
||||
func TestConvert(t *testing.T) {
|
||||
registry := prometheus.NewRegistry()
|
||||
counter := prometheus.NewCounterVec(prometheus.CounterOpts{Name: "jobs_total", Help: "Jobs."}, []string{"status"})
|
||||
histogram := prometheus.NewHistogram(prometheus.HistogramOpts{Name: "duration_seconds", Unit: "seconds", Buckets: []float64{1, 2}})
|
||||
summary := prometheus.NewSummary(prometheus.SummaryOpts{Name: "gc_seconds", Objectives: map[float64]float64{0.5: 0.05}})
|
||||
registry.MustRegister(counter, histogram, summary)
|
||||
counter.WithLabelValues("success").Add(3)
|
||||
for _, value := range []float64{0.5, 1.5, 3} {
|
||||
histogram.Observe(value)
|
||||
}
|
||||
summary.Observe(4)
|
||||
families, err := registry.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
exported := convert(families)
|
||||
assert.Equal(t, "s", exported[0].GetUnit())
|
||||
buckets := exported[0].GetHistogram().GetDataPoints()[0]
|
||||
assert.Equal(t, []float64{1, 2}, buckets.GetExplicitBounds())
|
||||
assert.Equal(t, []uint64{1, 1, 1}, buckets.GetBucketCounts())
|
||||
assert.Equal(t, uint64(3), buckets.GetCount())
|
||||
assert.InDelta(t, 5, buckets.GetSum(), 0)
|
||||
assert.InDelta(t, 4, exported[1].GetSummary().GetDataPoints()[0].GetQuantileValues()[0].GetValue(), 0)
|
||||
sum := exported[2].GetSum()
|
||||
assert.Equal(t, "Jobs.", exported[2].GetDescription())
|
||||
assert.True(t, proto.Equal(keyValue("prometheus.type", "counter"), exported[2].GetMetadata()[0]))
|
||||
assert.NotZero(t, sum.GetDataPoints()[0].GetStartTimeUnixNano())
|
||||
assert.True(t, sum.GetIsMonotonic())
|
||||
assert.Equal(t, cumulative, sum.GetAggregationTemporality())
|
||||
assert.InDelta(t, 3, sum.GetDataPoints()[0].GetAsDouble(), 0)
|
||||
assert.True(t, proto.Equal(keyValue("status", "success"), sum.GetDataPoints()[0].GetAttributes()[0]))
|
||||
}
|
||||
|
||||
func TestExportRefusesRedirects(t *testing.T) {
|
||||
@@ -97,7 +197,14 @@ func TestExportRefusesRedirects(t *testing.T) {
|
||||
defer target.Close()
|
||||
origin := httptest.NewServer(http.RedirectHandler(target.URL, http.StatusTemporaryRedirect))
|
||||
defer origin.Close()
|
||||
require.EqualError(t, (&client{origin.URL, http.Header{}}).UploadTraces(t.Context(), nil), "OTLP export failed: 307 Temporary Redirect")
|
||||
require.EqualError(t, (&client{endpoint: origin.URL, headers: http.Header{}, timeout: time.Second, httpClient: exportClient}).UploadTraces(t.Context(), nil), "OTLP export failed: 307 Temporary Redirect")
|
||||
}
|
||||
|
||||
func TestMillis(t *testing.T) {
|
||||
for value, want := range map[string]time.Duration{"": time.Minute, "1500": 1500 * time.Millisecond, "0": noLimit, "-1": time.Minute, "1s": time.Minute, "2147483648": time.Minute} {
|
||||
t.Setenv("OTEL_METRIC_EXPORT_TIMEOUT", value)
|
||||
assert.Equal(t, want, millis("OTEL_METRIC_EXPORT_TIMEOUT", time.Minute, noLimit), value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJob(t *testing.T) {
|
||||
|
||||
+10
-16
@@ -6,12 +6,11 @@ fi
|
||||
|
||||
cd /data
|
||||
|
||||
RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-'.runner'}
|
||||
|
||||
CONFIG_ARG=""
|
||||
if [[ ! -z "${CONFIG_FILE}" ]]; then
|
||||
CONFIG_ARG="--config ${CONFIG_FILE}"
|
||||
fi
|
||||
RUNNER_STATE_FILE=${RUNNER_STATE_FILE:-$(gitea-runner ${CONFIG_ARG} config runner-file)}
|
||||
LABEL_ARGS=""
|
||||
if [[ ! -z "${GITEA_RUNNER_LABELS}" ]]; then
|
||||
LABEL_ARGS="--labels ${GITEA_RUNNER_LABELS}"
|
||||
@@ -37,28 +36,23 @@ fi
|
||||
test -f "$RUNNER_STATE_FILE" || echo "$RUNNER_STATE_FILE is missing or not a regular file"
|
||||
|
||||
if [[ ! -s "$RUNNER_STATE_FILE" ]]; then
|
||||
try=$((try + 1))
|
||||
success=0
|
||||
|
||||
# The point of this loop is to make it simple, when running both runner and gitea in docker,
|
||||
# for the runner to wait a moment for gitea to become available before erroring out. Within
|
||||
# the context of a single docker-compose, something similar could be done via healthchecks, but
|
||||
# this is more flexible.
|
||||
while [[ $success -eq 0 ]] && [[ $try -lt ${GITEA_MAX_REG_ATTEMPTS:-10} ]]; do
|
||||
for ((try = 1; ; try++)); do # waits for Gitea like the daemon does, a container may have no restart policy
|
||||
gitea-runner register \
|
||||
--instance "${GITEA_INSTANCE_URL}" \
|
||||
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
|
||||
--name "${GITEA_RUNNER_NAME:-`hostname`}" \
|
||||
${CONFIG_ARG} ${EXTRA_ARGS} --no-interactive 2>&1 | tee /tmp/reg.log
|
||||
|
||||
cat /tmp/reg.log | grep 'Runner registered successfully' > /dev/null
|
||||
if [[ $? -eq 0 ]]; then
|
||||
if grep -q 'Runner registered successfully' /tmp/reg.log; then
|
||||
echo "SUCCESS"
|
||||
success=1
|
||||
else
|
||||
echo "Waiting to retry ..."
|
||||
sleep 5
|
||||
break
|
||||
fi
|
||||
if [[ ${GITEA_MAX_REG_ATTEMPTS:-0} -gt 0 ]] && [[ $try -ge $GITEA_MAX_REG_ATTEMPTS ]]; then
|
||||
echo "Registration failed after ${try} attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting to retry ..."
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
# Prevent reading the token from the gitea-runner process
|
||||
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec /etc/s6/.s6-svscan/SIGTERM
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
(set -C; echo 0 > "$(/etc/s6/.s6-svscan/exitcode-path)") 2> /dev/null
|
||||
s6-svc -wD -d /etc/s6/gitea-runner
|
||||
exec s6-svscanctl -t /etc/s6
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "/etc/s6/.s6-svscan/exitcode.$(cat /proc/sys/kernel/random/boot_id).$(cut -d ' ' -f 22 /proc/1/stat)" # unique per container start, so a killed run's record is ignored
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exitcode=$(cat "$(/etc/s6/.s6-svscan/exitcode-path)" 2> /dev/null)
|
||||
rm -f /etc/s6/.s6-svscan/exitcode.*
|
||||
exit "${exitcode:-0}"
|
||||
@@ -1,4 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-svscanctl -t /etc/s6
|
||||
if [ "$1" -eq 256 ]; then
|
||||
set -- "$((128 + $2))"
|
||||
fi
|
||||
(set -C; echo "$1" > "$(/etc/s6/.s6-svscan/exitcode-path)") 2> /dev/null
|
||||
|
||||
s6-svscanctl -t /etc/s6 && exit 125 # 125 keeps s6 from restarting the service during teardown
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-notifyoncheck dockerd-entrypoint.sh
|
||||
exec s6-notifyoncheck -n 0 dockerd-entrypoint.sh
|
||||
@@ -1,3 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
exec s6-svscanctl -t /etc/s6
|
||||
if [ "$1" -eq 256 ]; then
|
||||
set -- "$((128 + $2))"
|
||||
fi
|
||||
(set -C; echo "$1" > "$(/etc/s6/.s6-svscan/exitcode-path)") 2> /dev/null
|
||||
|
||||
s6-svscanctl -t /etc/s6 && exit 125 # 125 keeps s6 from restarting the service during teardown
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
while [ ! -d /etc/s6/docker/supervise ]; do
|
||||
sleep 0.1
|
||||
until /etc/s6/docker/data/check &> /dev/null; do # s6-svwait can read missing or stale readiness status
|
||||
sleep 1
|
||||
done
|
||||
|
||||
s6-svwait -U /etc/s6/docker
|
||||
|
||||
exec run.sh
|
||||
+29
-3
@@ -10,8 +10,8 @@
|
||||
# Usage: scripts/test-dind.sh [target] [-- go-test-args...]
|
||||
# target: dind (default), dind-rootless, or podman to run PODMAN_TEST_IMAGE's API service instead
|
||||
# go-test-args: passed verbatim to `go test`. Defaults cover image env extraction,
|
||||
# symlink copying and a mounted Docker job using cached images, or the
|
||||
# Docker proxy probe for podman.
|
||||
# symlink copying, a mounted Docker job using cached images and the
|
||||
# image's s6 supervision, or the Docker proxy probe for podman.
|
||||
#
|
||||
# Env:
|
||||
# DIND_TEST_PORT host port for the daemon (default 32375)
|
||||
@@ -33,7 +33,7 @@ host_docker="${DOCKER_HOST:-$(docker context inspect --format '{{.Endpoints.dock
|
||||
test_dir=""
|
||||
|
||||
cleanup() {
|
||||
docker -H "$host_docker" rm -fv "$name" >/dev/null 2>&1 || true
|
||||
docker -H "$host_docker" rm -fv "$name" "$name-s6" >/dev/null 2>&1 || true
|
||||
if [ -n "$test_dir" ]; then
|
||||
rm -rf "$test_dir"
|
||||
fi
|
||||
@@ -147,4 +147,30 @@ if [ "$default_tests" = true ]; then
|
||||
echo "==> Running mounted Docker job in a container given the ${target} socket, expecting proxy access"
|
||||
docker -H "$host_docker" exec -e DOCKER_HOST="$socket" "$name" docker run --rm -v "${socket#unix://}:/var/run/docker.sock" -v /tmp/gitea-runner-proxy-test:/data -w /data \
|
||||
-e ACT_TEST_DOCKER_PROXY=proxy -e ACT_TEST_IMAGE="$job_image" "$job_image" ./runner.test -test.v -test.run '^TestDockerProxyMountedJob$' -test.timeout 3m
|
||||
|
||||
supervised="$name-s6"
|
||||
supervised_exit_is() {
|
||||
if [ "$(docker -H "$host_docker" inspect -f '{{.State.ExitCode}}' "$supervised")" != "$1" ] ||
|
||||
! docker -H "$host_docker" logs "$supervised" 2>&1 | grep -F "$2" >/dev/null; then
|
||||
docker -H "$host_docker" logs "$supervised" >&2
|
||||
echo "${target} supervision: expected exit $1 after \"$2\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
echo "==> Checking that a runner failure sets the ${target} container exit code"
|
||||
docker -H "$host_docker" run -d --privileged --name "$supervised" -e DOCKER_TLS_CERTDIR= -e RUNNER_STATE_FILE=/etc/hostname "$image" >/dev/null
|
||||
for _ in $(seq 1 120); do
|
||||
[ "$(docker -H "$host_docker" inspect -f '{{.State.Running}}' "$supervised")" = false ] && break
|
||||
sleep 1
|
||||
done
|
||||
supervised_exit_is 1 'registration file not found'
|
||||
docker -H "$host_docker" rm -fv "$supervised" >/dev/null
|
||||
echo "==> Checking that stopping a started ${target} runner exits 0"
|
||||
docker -H "$host_docker" run -d --privileged --name "$supervised" -e DOCKER_TLS_CERTDIR= "$image" >/dev/null
|
||||
for _ in $(seq 1 120); do
|
||||
docker -H "$host_docker" logs "$supervised" 2>&1 | grep -F 'is missing or not a regular file' >/dev/null && break
|
||||
sleep 1
|
||||
done
|
||||
docker -H "$host_docker" stop "$supervised" >/dev/null
|
||||
supervised_exit_is 0 'is missing or not a regular file'
|
||||
fi
|
||||
@@ -4,10 +4,12 @@ set -euo pipefail
|
||||
: "${E2E_CONCURRENCY:?E2E_CONCURRENCY is required}"
|
||||
: "${E2E_JOB_IMAGE:?E2E_JOB_IMAGE is required}"
|
||||
: "${E2E_GITEA_IMAGE:?E2E_GITEA_IMAGE is required}"
|
||||
: "${E2E_K3S_IMAGE:?E2E_K3S_IMAGE is required}"
|
||||
: "${SERVICE_IMAGE:?SERVICE_IMAGE is required}"
|
||||
|
||||
docker pull "$E2E_JOB_IMAGE"
|
||||
docker pull "$SERVICE_IMAGE"
|
||||
docker pull "$E2E_GITEA_IMAGE"
|
||||
docker pull "$E2E_K3S_IMAGE"
|
||||
|
||||
exec "${GO:-go}" test -tags e2e -count=1 -parallel "$E2E_CONCURRENCY" -timeout 20m -v ./e2e/...
|
||||
Reference in new issue
Block a user