mirror of
https://gitea.com/gitea/act_runner
synced 2026-09-21 19:37:07 +02:00
1. Add `GITEA_DOCKER_WORKSPACE` which holds the workspace path as the daemon sees it, enabling `${GITEA_DOCKER_WORKSPACE:-.}/data:/app/data` in a compose file without having to resort to `bind_workdir` (which causes much more problems like breaking `actions/cache` because of unstable workspace paths).
2. Add container/network/volume cleanup for containers started within jobs, for example via `docker compose` inside a job. It works by running a lightweight docker socket proxy and injecting a `com.gitea.runner.job` label into every container creation and that label is used to remove containers started by that job at the end. Perf impact of this is near-zero.
Docs: https://gitea.com/gitea/docs/pulls/535
Assisted by Claude (Fable 5.1).
Co-authored-by: bircni <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1204
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
277 lines
7.9 KiB
Go
277 lines
7.9 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build !(WITHOUT_DOCKER || !(linux || darwin || windows || netbsd))
|
|
|
|
package container
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
|
|
cerrdefs "github.com/containerd/errdefs"
|
|
"github.com/moby/moby/api/types/container"
|
|
"github.com/moby/moby/api/types/mount"
|
|
"github.com/moby/moby/client"
|
|
)
|
|
|
|
const (
|
|
jobLabel = "com.gitea.runner.job"
|
|
maxCreateBody = 8 << 20
|
|
)
|
|
|
|
var (
|
|
createPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers|networks|volumes)/create$`)
|
|
rawStreamPath = regexp.MustCompile(`^(/v[0-9.]+)?/(containers/[^/]+/attach|exec/[^/]+/start)$`)
|
|
proxyProbe struct {
|
|
sync.Mutex
|
|
decided bool
|
|
dir string
|
|
}
|
|
)
|
|
|
|
// DockerProxyDir returns where job proxy sockets live, "" while undecided or when the daemon cannot open the runner's files.
|
|
func DockerProxyDir(ctx context.Context) string {
|
|
proxyProbe.Lock()
|
|
defer proxyProbe.Unlock()
|
|
if proxyProbe.decided {
|
|
return proxyProbe.dir
|
|
}
|
|
dir := filepath.Join(os.TempDir(), "gitea-runner-docker")
|
|
ok, err := daemonSeesDir(ctx, dir)
|
|
if err != nil {
|
|
common.Logger(ctx).Debugf("docker proxy probe postponed: %v", err)
|
|
return ""
|
|
}
|
|
proxyProbe.decided = true
|
|
if ok {
|
|
proxyProbe.dir = dir
|
|
} else {
|
|
common.Logger(ctx).Infof("the docker daemon cannot reach the runner's filesystem, jobs get the daemon socket directly")
|
|
}
|
|
return proxyProbe.dir
|
|
}
|
|
|
|
func daemonSeesDir(ctx context.Context, dir string) (bool, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return false, err
|
|
}
|
|
probe := filepath.Join(dir, "probe")
|
|
if err := os.WriteFile(probe, nil, 0o600); err != nil {
|
|
return false, err
|
|
}
|
|
cli, err := GetDockerClient(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer cli.Close()
|
|
images, err := cli.ImageList(ctx, client.ImageListOptions{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(images.Items) == 0 {
|
|
return false, errors.New("no image to probe with yet")
|
|
}
|
|
// creating validates that a bind source exists on the daemon's side, nothing is started
|
|
created, err := cli.ContainerCreate(ctx, client.ContainerCreateOptions{
|
|
Config: &container.Config{Image: images.Items[0].ID, Cmd: []string{"true"}},
|
|
HostConfig: &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeBind, Source: probe, Target: "/gitea-runner-probe"}}},
|
|
})
|
|
if cerrdefs.IsInvalidArgument(err) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_, err = cli.ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true})
|
|
return true, err
|
|
}
|
|
|
|
// StartDockerProxy serves a job's docker socket in dir, labelling what the job creates through it.
|
|
func StartDockerProxy(daemonSocket, dir, job string) (*DockerProxy, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return nil, err
|
|
}
|
|
digest := sha256.Sum256([]byte(job))
|
|
socket := filepath.Join(dir, hex.EncodeToString(digest[:8])+".sock")
|
|
_ = os.Remove(socket)
|
|
listener, err := net.Listen("unix", socket)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info, err := os.Stat(daemonSocket); err == nil {
|
|
_ = os.Chmod(socket, info.Mode().Perm())
|
|
}
|
|
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, "unix", daemonSocket)
|
|
}
|
|
transport := &http.Transport{DialContext: dial}
|
|
forward := &httputil.ReverseProxy{
|
|
Rewrite: func(r *httputil.ProxyRequest) {
|
|
r.Out.URL.Scheme = "http"
|
|
r.Out.URL.Host = "docker"
|
|
},
|
|
Transport: transport,
|
|
}
|
|
server := &http.Server{ReadHeaderTimeout: 30 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method != http.MethodPost:
|
|
case createPath.MatchString(r.URL.Path):
|
|
if err := addLabel(r, job); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
case rawStreamPath.MatchString(r.URL.Path):
|
|
tunnel(w, r, dial)
|
|
return
|
|
}
|
|
forward.ServeHTTP(w, r)
|
|
})}
|
|
go func() { _ = server.Serve(listener) }()
|
|
return &DockerProxy{Socket: socket, close: func(ctx context.Context) error {
|
|
err := removeJobResources(ctx, job)
|
|
_ = server.Close()
|
|
transport.CloseIdleConnections()
|
|
_ = os.Remove(socket)
|
|
return err
|
|
}}, nil
|
|
}
|
|
|
|
func addLabel(r *http.Request, job string) error {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxCreateBody+1))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(body) > maxCreateBody {
|
|
return errors.New("create request too large")
|
|
}
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
return nil
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &fields); err != nil {
|
|
return fmt.Errorf("invalid create request: %w", err)
|
|
}
|
|
key := "Labels"
|
|
for name := range fields {
|
|
if strings.EqualFold(name, key) {
|
|
key = name
|
|
break
|
|
}
|
|
}
|
|
labels := map[string]string{}
|
|
if raw := fields[key]; len(raw) > 0 && string(raw) != "null" {
|
|
if err := json.Unmarshal(raw, &labels); err != nil {
|
|
return fmt.Errorf("invalid create request: %w", err)
|
|
}
|
|
}
|
|
labels[jobLabel] = job
|
|
if fields[key], err = json.Marshal(labels); err != nil {
|
|
return err
|
|
}
|
|
if body, err = json.Marshal(fields); err != nil {
|
|
return err
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
r.ContentLength = int64(len(body))
|
|
r.TransferEncoding = nil
|
|
return nil
|
|
}
|
|
|
|
// tunnel splices attach and exec streams, which the daemon hijacks with or without an HTTP upgrade
|
|
func tunnel(w http.ResponseWriter, r *http.Request, dial func(context.Context, string, string) (net.Conn, error)) {
|
|
hijacker, ok := w.(http.Hijacker)
|
|
if !ok {
|
|
http.Error(w, "connection cannot be hijacked", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
upstream, err := dial(r.Context(), "", "")
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer upstream.Close()
|
|
if err := r.Write(upstream); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
downstream, buffered, err := hijacker.Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer downstream.Close()
|
|
if _, err := io.CopyN(upstream, buffered, int64(buffered.Reader.Buffered())); err != nil {
|
|
return
|
|
}
|
|
done := make(chan struct{}, 2)
|
|
go func() {
|
|
_, _ = io.Copy(upstream, downstream)
|
|
done <- struct{}{}
|
|
}()
|
|
go func() {
|
|
_, _ = io.Copy(downstream, upstream)
|
|
done <- struct{}{}
|
|
}()
|
|
<-done
|
|
}
|
|
|
|
func removeJobResources(ctx context.Context, job string) error {
|
|
cli, err := GetDockerClient(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cli.Close()
|
|
return removeLabelled(ctx, cli, job)
|
|
}
|
|
|
|
func removeLabelled(ctx context.Context, cli client.APIClient, job string) error {
|
|
logger := common.Logger(ctx)
|
|
filters := make(client.Filters).Add("label", jobLabel+"="+job)
|
|
containers, err := cli.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filters})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var errs []error
|
|
for _, c := range containers.Items {
|
|
logger.Infof("removing container %s the job left behind", strings.TrimPrefix(strings.Join(c.Names, ","), "/"))
|
|
errs = append(errs, (&containerReference{cli: cli, id: c.ID}).remove()(ctx))
|
|
}
|
|
networks, err := cli.NetworkList(ctx, client.NetworkListOptions{Filters: filters})
|
|
if err != nil {
|
|
return errors.Join(append(errs, err)...)
|
|
}
|
|
for _, n := range networks.Items {
|
|
if _, err := cli.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
|
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", n.Name, err))
|
|
}
|
|
}
|
|
volumes, err := cli.VolumeList(ctx, client.VolumeListOptions{Filters: filters})
|
|
if err != nil {
|
|
return errors.Join(append(errs, err)...)
|
|
}
|
|
for _, v := range volumes.Items {
|
|
if _, err := cli.VolumeRemove(ctx, v.Name, client.VolumeRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) {
|
|
errs = append(errs, fmt.Errorf("failed to remove volume %s: %w", v.Name, err))
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|