mirror of
https://gitea.com/gitea/runner.git
synced 2026-08-24 04:47:45 +00:00
8260e2def2
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>
366 lines
9.3 KiB
Go
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())
|
|
}
|