mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 14:35:32 +00:00
Adds a Kubernetes backend. A `name:kubernetes://image` label runs the job as one pod through the Kubernetes API, with services as containers of the same pod. Step env is exported by a script sent over exec stdin, keeping it out of the pod spec and process arguments, and service env goes into a per-job Secret. Docker actions and container options are rejected. See `docs/kubernetes.md`. Operators shape job pods with `kubernetes.pod_template`, with per-label `pod_templates` merged in `runs-on` order and a `$services` entry for service containers. The idle cleanup removes pods and Secrets a runner left behind. The client calls the Kubernetes API directly and runs step commands over `coder/websocket` (same dependency used in gitea) instead of pulling in the heavyweight `kubernetes/client-go`. Includes an e2e test against k3s. Replaces: https://gitea.com/gitea/runner/pulls/1000 Fixes: https://gitea.com/gitea/runner/issues/31 Assisted-by: Claude Code:claude-opus-5-5 Co-authored-by: Zettat123 <[email protected]> Co-authored-by: bircni <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1260 Reviewed-by: Lunny Xiao <[email protected]> Reviewed-by: Zettat123 <[email protected]> Co-authored-by: silverwind <[email protected]>
167 lines
5.8 KiB
Go
167 lines
5.8 KiB
Go
// 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
|
|
}
|