Files
Runner/e2e/gitea_fixture.go
T
bircni 8260e2def2 test: add end-to-end Gitea compatibility suite (#1180)
Adds a real-Gitea compatibility suite against stable and nightly to catch runner and API drift before release.

The suite shares one Gitea and regular runner, using repository runners only for cache v1/v2 and ephemeral behavior. It covers registration, payload and log encoding, secrets, variables, services, artifacts, outputs, matrices, cache, dispatch, live logs, cancellation, and ephemeral teardown.

CI runs both images in parallel. Warm local timings:

| Image | Before | After |
| --- | ---: | ---: |
| `gitea/gitea:latest` | ~70s | 26.54s |
| `gitea/gitea:main-nightly` | ~54s | 22.53s |

The former serial matrix took about 2 minutes. Parallel suite execution is now bounded by the slower ~26.5-second variant.

Shared Renovate matcher: https://gitea.com/gitea/renovate-config/pulls/552

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1180
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: bircni <bircni@icloud.com>
2026-08-23 12:32:22 +00:00

366 lines
9.3 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
//go:build e2e
package e2e
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"strconv"
"strings"
"time"
"gitea.com/gitea/runner/act/container"
apicontainer "github.com/moby/moby/api/types/container"
"github.com/moby/moby/api/types/network"
mobyclient "github.com/moby/moby/client"
)
const (
giteaAdminUser = "e2e-admin"
giteaAdminMail = "e2e-admin@example.com"
)
type GiteaFixture struct {
cli mobyclient.APIClient
id string
image string
version string
baseURL string
network string // set in container mode; jobs must join it
adminToken string
}
func dockerClient(ctx context.Context) (mobyclient.APIClient, error) {
cli, err := container.GetDockerClient(ctx)
if err != nil {
return nil, err
}
if _, err := cli.Ping(ctx, mobyclient.PingOptions{}); err != nil {
return nil, fmt.Errorf("docker daemon unreachable: %w", err)
}
return cli, nil
}
// Prefer docker bridge gateway, then LAN IP; never 127.0.0.1 (ACTIONS_RUNTIME_URL must reach the host from job containers).
func hostAddress(ctx context.Context, cli mobyclient.APIClient) netip.Addr {
if gateway, ok := bridgeGateway(ctx, cli); ok && bindable(gateway) {
return gateway
}
loopback := netip.AddrFrom4([4]byte{127, 0, 0, 1})
conn, err := net.Dial("udp", "192.0.2.1:80") // TEST-NET-1: routing table only
if err != nil {
return loopback
}
defer conn.Close()
addr, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok {
return loopback
}
return addr.AddrPort().Addr().Unmap()
}
func bridgeGateway(ctx context.Context, cli mobyclient.APIClient) (netip.Addr, bool) {
inspected, err := cli.NetworkInspect(ctx, "bridge", mobyclient.NetworkInspectOptions{})
if err != nil {
return netip.Addr{}, false
}
for _, cfg := range inspected.Network.IPAM.Config {
if cfg.Gateway.Is4() {
return cfg.Gateway, true
}
}
return netip.Addr{}, false
}
// When tests run inside a container (sibling docker daemon), join that network and address by name.
func selfNetwork(ctx context.Context, cli mobyclient.APIClient) (string, bool) {
if _, err := os.Stat("/.dockerenv"); err != nil {
return "", false
}
id, err := os.ReadFile("/proc/sys/kernel/hostname")
if err != nil {
id, err = os.ReadFile("/etc/hostname")
}
if err != nil {
return "", false
}
inspected, err := cli.ContainerInspect(ctx, strings.TrimSpace(string(id)), mobyclient.ContainerInspectOptions{})
if err != nil || inspected.Container.NetworkSettings == nil {
return "", false
}
for name := range inspected.Container.NetworkSettings.Networks {
if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS
return name, true
}
}
return "", false
}
func bindable(addr netip.Addr) bool {
l, err := net.Listen("tcp", net.JoinHostPort(addr.String(), "0"))
if err != nil {
return false
}
_ = l.Close()
return true
}
func freeHostPort(host string) (int, error) {
l, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
return 0, err
}
defer l.Close()
addr, ok := l.Addr().(*net.TCPAddr)
if !ok {
return 0, fmt.Errorf("unexpected listener address type %T", l.Addr())
}
return addr.Port, nil
}
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (*GiteaFixture, error) {
closeClient := true
defer func() {
if closeClient {
_ = cli.Close()
}
}()
image := os.Getenv("E2E_GITEA_IMAGE")
if image == "" {
return nil, errors.New("E2E_GITEA_IMAGE is not set")
}
name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano())
containerPort := network.MustParsePort("3000/tcp")
var (
baseURL string
hostConfig = &apicontainer.HostConfig{}
netConfig *network.NetworkingConfig
sharedNet, _ = selfNetwork(ctx, cli)
)
if sharedNet != "" {
baseURL = fmt.Sprintf("http://%s:3000", name)
netConfig = &network.NetworkingConfig{
EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}},
}
} else {
host := hostAddress(ctx, cli)
port, err := freeHostPort(host.String())
if err != nil {
return nil, fmt.Errorf("find a free host port: %w", err)
}
baseURL = fmt.Sprintf("http://%s:%d", host, port)
hostConfig.PortBindings = network.PortMap{
containerPort: []network.PortBinding{{HostIP: host, HostPort: strconv.Itoa(port)}},
}
}
if _, err := cli.ImageInspect(ctx, image); err != nil {
return nil, fmt.Errorf("inspect gitea image %s: %w", image, err)
}
resp, err := cli.ContainerCreate(ctx, mobyclient.ContainerCreateOptions{
Config: &apicontainer.Config{
Image: image,
Env: []string{
"GITEA__security__INSTALL_LOCK=true",
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__actions__ENABLED=true",
"GITEA__server__ROOT_URL=" + baseURL + "/",
},
ExposedPorts: network.PortSet{containerPort: struct{}{}},
},
HostConfig: hostConfig,
NetworkingConfig: netConfig,
Name: name,
})
if err != nil {
return nil, fmt.Errorf("create gitea container: %w", err)
}
f := &GiteaFixture{cli: cli, id: resp.ID, image: image, baseURL: baseURL, network: sharedNet}
closeClient = false
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("start gitea container: %w", err)
}
if err := f.waitHealthy(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
}
if err := f.bootstrapAdmin(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
}
if err := f.readVersion(ctx); err != nil {
_ = f.Close(ctx)
return nil, fmt.Errorf("read gitea version: %w", err)
}
return f, nil
}
func (f *GiteaFixture) readVersion(ctx context.Context) error {
var body struct {
Version string `json:"version"`
}
if err := f.doJSON(ctx, http.MethodGet, "/api/v1/version", nil, &body); err != nil {
return err
}
f.version = body.Version
return nil
}
func (f *GiteaFixture) waitHealthy(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
url := f.baseURL + "/api/healthz"
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(pollInterval):
}
}
}
func (f *GiteaFixture) bootstrapAdmin(ctx context.Context) error {
password := randomToken(16)
if _, err := f.exec(ctx, []string{
"gitea", "admin", "user", "create",
"--username", giteaAdminUser,
"--password", password,
"--email", giteaAdminMail,
"--admin",
"--must-change-password=false",
}); err != nil {
return fmt.Errorf("create admin user: %w", err)
}
out, err := f.exec(ctx, []string{
"gitea", "admin", "user", "generate-access-token",
"--username", giteaAdminUser,
"--scopes", "all",
"-t", "e2e-admin-token",
})
if err != nil {
return fmt.Errorf("generate admin token: %w", err)
}
token := extractToken(out)
if token == "" {
return fmt.Errorf("could not parse access token from CLI output: %q", out)
}
f.adminToken = token
return nil
}
// Last field of `gitea admin user generate-access-token` output (format varies by release).
func extractToken(cliOutput string) string {
fields := strings.Fields(cliOutput)
if len(fields) == 0 {
return ""
}
return fields[len(fields)-1]
}
func randomToken(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func (f *GiteaFixture) exec(ctx context.Context, cmd []string) (string, error) {
created, err := f.cli.ExecCreate(ctx, f.id, mobyclient.ExecCreateOptions{
Cmd: cmd,
User: "git", // gitea refuses root
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return "", err
}
attached, err := f.cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
if err != nil {
return "", err
}
defer attached.Close()
var out bytes.Buffer
if _, err := io.Copy(&out, attached.Reader); err != nil {
return "", err
}
inspected, err := f.cli.ExecInspect(ctx, created.ID, mobyclient.ExecInspectOptions{})
if err != nil {
return "", err
}
if inspected.ExitCode != 0 {
return "", fmt.Errorf("exec %v exited %d: %s", cmd, inspected.ExitCode, out.String())
}
return out.String(), nil
}
func (f *GiteaFixture) RegistrationToken(ctx context.Context, repo string) (string, error) {
var body struct {
Token string `json:"token"`
}
path := "/api/v1/admin/actions/runners/registration-token"
if repo != "" {
path = fmt.Sprintf("/api/v1/repos/%s/%s/actions/runners/registration-token", giteaAdminUser, repo)
}
if err := f.doJSON(ctx, http.MethodPost, path, nil, &body); err != nil {
return "", err
}
return body.Token, nil
}
func (f *GiteaFixture) doJSON(ctx context.Context, method, path string, reqBody, respBody any) error {
api := &GiteaAPI{baseURL: f.baseURL, token: f.adminToken}
return api.doJSON(ctx, method, path, reqBody, respBody)
}
func (f *GiteaFixture) Close(ctx context.Context) error {
if f.id == "" {
return f.cli.Close()
}
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true})
return errors.Join(removeErr, f.cli.Close())
}