mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-11 18:34:09 +00:00
Removes `builtin:checkout`, `builtin:upload-artifact` and `builtin:download-artifact`, along with the container helpers only they used. Builtin actions are tied to the runner version, so users cannot pin them, and they diverge from the shareable actions ecosystem that tools like zizmor build on. Workflows should use `actions/checkout`, `actions/upload-artifact` and `actions/download-artifact`, or lightweight third-party actions such as https://github.com/taiki-e/checkout-action. **Breaking:** steps with `uses: builtin:checkout`, released in v4.0.0, now fail and need to switch to one of the actions above. The artifact actions were never released. Docs: https://gitea.com/gitea/docs/pulls/643 Fixes: https://gitea.com/gitea/runner/issues/1405 Co-authored-by: silverwind <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1407 Reviewed-by: silverwind <[email protected]> Co-authored-by: bircni <[email protected]>
302 lines
8.1 KiB
Go
302 lines
8.1 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"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
giteaAdminUser = "e2e-admin"
|
|
giteaAdminMail = "[email protected]"
|
|
)
|
|
|
|
type GiteaFixture struct {
|
|
cli mobyclient.APIClient
|
|
id string
|
|
image string
|
|
version string
|
|
baseURL string
|
|
network string // the user-defined network every container of the suite joins
|
|
selfIP string // the test process's address on network, which job containers can reach
|
|
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
|
|
}
|
|
|
|
// 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, 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, endpoint := range inspected.Container.NetworkSettings.Networks {
|
|
if name != "host" && name != "none" && name != "bridge" { // only user-defined nets have DNS
|
|
return name, endpoint.IPAddress.String(), true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
func StartGitea(ctx context.Context, cli mobyclient.APIClient) (_ *GiteaFixture, err error) {
|
|
f := &GiteaFixture{cli: cli}
|
|
defer func() {
|
|
if err != nil {
|
|
_ = f.Close()
|
|
}
|
|
}()
|
|
|
|
image := os.Getenv("E2E_GITEA_IMAGE")
|
|
if image == "" {
|
|
return nil, errors.New("E2E_GITEA_IMAGE is not set")
|
|
}
|
|
|
|
sharedNet, selfIP, ok := selfNetwork(ctx, cli)
|
|
if !ok {
|
|
return nil, errors.New("e2e tests must run in a container on a user-defined network, `make test-e2e` provides one")
|
|
}
|
|
name := fmt.Sprintf("gitea-runner-e2e-%d", time.Now().UnixNano())
|
|
baseURL := "http://" + net.JoinHostPort(name, "3000")
|
|
|
|
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 + "/",
|
|
},
|
|
},
|
|
HostConfig: &apicontainer.HostConfig{Tmpfs: map[string]string{"/data": "exec"}},
|
|
NetworkingConfig: &network.NetworkingConfig{EndpointsConfig: map[string]*network.EndpointSettings{sharedNet: {}}},
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create gitea container: %w", err)
|
|
}
|
|
|
|
f.id, f.image, f.baseURL, f.network, f.selfIP = resp.ID, image, baseURL, sharedNet, selfIP
|
|
|
|
if _, err := cli.ContainerStart(ctx, f.id, mobyclient.ContainerStartOptions{}); err != nil {
|
|
return nil, fmt.Errorf("start gitea container: %w", err)
|
|
}
|
|
|
|
if err := f.waitHealthy(ctx); err != nil {
|
|
return nil, fmt.Errorf("gitea did not become healthy: %w", err)
|
|
}
|
|
|
|
if err := f.bootstrapAdmin(ctx); err != nil {
|
|
return nil, fmt.Errorf("bootstrap gitea admin: %w", err)
|
|
}
|
|
|
|
if err := f.readVersion(ctx); err != nil {
|
|
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) {
|
|
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: user,
|
|
AttachStdin: stdin != nil,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
attached, err := cli.ExecAttach(ctx, created.ID, mobyclient.ExecAttachOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer context.AfterFunc(ctx, attached.Close)()
|
|
defer attached.Close()
|
|
if stdin != nil {
|
|
go func() {
|
|
_, _ = io.Copy(attached.Conn, stdin)
|
|
_ = attached.CloseWrite()
|
|
}()
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
_, err = stdcopy.StdCopy(&out, &out, attached.Reader)
|
|
if ctx.Err() != nil {
|
|
return "", ctx.Err()
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
inspected, err := 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() error {
|
|
if f.id == "" {
|
|
return f.cli.Close()
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
|
|
defer cancel()
|
|
_, removeErr := f.cli.ContainerRemove(ctx, f.id, mobyclient.ContainerRemoveOptions{Force: true, RemoveVolumes: true})
|
|
return errors.Join(removeErr, f.cli.Close())
|
|
}
|