mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-04 22:37:55 +00:00
The Kubernetes backend copies actions and workspaces into job containers with `tar` over `pods/exec`, so every byte goes through the API server. On managed clusters that often sits in other zones than the nodes, so each byte can cross zones twice, and the copies load the API server. With `kubernetes.transfer.mode: auto` or `direct`, an init container copies `gitea-runner` into the job pod, and the job container fetches directories and copies of 64 KiB or more gzipped from the runner over the pod network. `auto` falls back to the API server for a job pod that cannot reach the runner, `direct` fails the job. Copies can hold secrets, so each is served once, over TLS 1.3 pinned to a certificate the runner generates, only to the job pod's IPs. Copying setup-go@v5, cache@v4, setup-java@v4 and build-push-action@v6 sends 11.6 MB through the API server on main and 532 B with this PR. Co-authored-by: silverwind <[email protected]> Co-authored-by: silverwind <[email protected]> Reviewed-on: https://gitea.com/gitea/runner/pulls/1354 Reviewed-by: silverwind <[email protected]> Co-authored-by: Lunny Xiao <[email protected]>
700 lines
26 KiB
Go
700 lines
26 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package container
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"cmp"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/common/logging"
|
|
|
|
"github.com/kballard/go-shellquote"
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
// KubernetesOptions configures the pod a job runs in.
|
|
type KubernetesOptions struct {
|
|
Kubeconfig string
|
|
Namespace string
|
|
PodTemplates []map[string]any // merged in order, a later one winning
|
|
MaxLifetime time.Duration
|
|
ForcePull bool
|
|
RunnerUUID string
|
|
Transfer *KubernetesTransfer
|
|
}
|
|
|
|
// KubernetesPod runs a job container and its service containers as one pod.
|
|
type KubernetesPod struct {
|
|
options KubernetesOptions
|
|
client *kubeClient
|
|
name, secretName string
|
|
job *kubernetesContainer
|
|
services []*kubernetesContainer
|
|
arch, path string
|
|
ips []net.IP
|
|
transfer *transferServer
|
|
transferImage string
|
|
transferHelper string // empty when copies go through the API server
|
|
}
|
|
|
|
type kubernetesContainer struct {
|
|
LinuxContainerEnvironmentExtensions
|
|
pod *KubernetesPod
|
|
id string // the workflow's service id, name is its DNS label form
|
|
name string
|
|
input *NewContainerInput
|
|
probe map[string]any
|
|
gzip bool // the container can gunzip, so copies into it go compressed
|
|
}
|
|
|
|
type containerStatus struct {
|
|
Name string
|
|
Ready bool
|
|
State struct {
|
|
Waiting *struct{ Reason, Message string }
|
|
Running *struct{}
|
|
Terminated *struct {
|
|
ExitCode int
|
|
Message string
|
|
}
|
|
}
|
|
}
|
|
|
|
type podStatus struct {
|
|
Spec struct{ Containers []struct{} }
|
|
Status struct {
|
|
Phase, Reason, Message string
|
|
PodIPs []struct{ IP string }
|
|
Conditions []struct{ Type, Status, Message string }
|
|
InitContainerStatuses, ContainerStatuses []containerStatus
|
|
}
|
|
}
|
|
|
|
func NewKubernetesPod(options KubernetesOptions) (*KubernetesPod, error) {
|
|
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect to Kubernetes: %w", err)
|
|
}
|
|
return &KubernetesPod{options: options, client: client}, nil
|
|
}
|
|
|
|
// JobContainer returns the container of the pod the job's steps run in.
|
|
func (p *KubernetesPod) JobContainer(input *NewContainerInput) ExecutionsEnvironment {
|
|
p.job = &kubernetesContainer{pod: p, name: "job", input: input}
|
|
return p.job
|
|
}
|
|
|
|
var (
|
|
invalidLabelChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
|
dnsSubdomain = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
|
)
|
|
|
|
// kubernetesServiceName returns the container name of service id, made a DNS label as it must be.
|
|
func kubernetesServiceName(id string) string {
|
|
name := strings.Trim(invalidLabelChars.ReplaceAllString(strings.ToLower(id), "-"), "-")
|
|
return strings.TrimRight(name[:min(63, len(name))], "-")
|
|
}
|
|
|
|
// KubernetesServiceHostnames returns the hostnames of service id, its container name and, where hostAliases accept it as a DNS subdomain, id lowercased.
|
|
func KubernetesServiceHostnames(id string) []string {
|
|
name, host := kubernetesServiceName(id), strings.ToLower(id)
|
|
if host != name && len(host) <= 253 && dnsSubdomain.MatchString(host) { // https://github.com/kubernetes/apimachinery/blob/v0.35.1/pkg/util/validation/validation.go#L198-L207
|
|
return []string{name, host}
|
|
}
|
|
return []string{name}
|
|
}
|
|
|
|
// ServiceContainer returns a container of the pod the job reaches at KubernetesServiceHostnames(id).
|
|
func (p *KubernetesPod) ServiceContainer(id string, input *NewContainerInput) ExecutionsEnvironment {
|
|
service := &kubernetesContainer{pod: p, id: id, name: kubernetesServiceName(id), input: input}
|
|
p.services = append(p.services, service)
|
|
return service
|
|
}
|
|
|
|
func (p *KubernetesPod) create(ctx context.Context) error {
|
|
prefix := strings.Trim(strings.ToLower(p.job.input.Name), "-")
|
|
prefix = cmp.Or(strings.TrimRight(prefix[:min(40, len(prefix))], "-"), "gitea-runner") + "-"
|
|
name := prefix + strings.ToLower(rand.Text()[:8]) // chosen here, so cleanup knows it even when a create response is lost
|
|
labels := map[string]any{"app.kubernetes.io/managed-by": "gitea-runner"}
|
|
if p.options.RunnerUUID != "" {
|
|
labels[runnerUUIDLabel] = p.options.RunnerUUID
|
|
}
|
|
metadata := map[string]any{"name": name, "labels": labels}
|
|
var runnerImage string
|
|
if hostname, err := os.Hostname(); p.client.inCluster && err == nil {
|
|
var runner struct {
|
|
Metadata struct{ Name, UID string }
|
|
Spec struct{ Containers []struct{ Image string } }
|
|
}
|
|
if err := p.client.do(ctx, http.MethodGet, p.client.path("pods/"+hostname), nil, &runner); err == nil {
|
|
metadata["ownerReferences"] = []any{map[string]any{"apiVersion": "v1", "kind": "Pod", "name": runner.Metadata.Name, "uid": runner.Metadata.UID}}
|
|
if len(runner.Spec.Containers) == 1 {
|
|
runnerImage = runner.Spec.Containers[0].Image
|
|
}
|
|
} else {
|
|
common.Logger(ctx).Debugf("The runner is not pod %s, job pods get no owner: %v", hostname, err)
|
|
}
|
|
}
|
|
if err := p.setupTransfer(runnerImage); err != nil {
|
|
return err
|
|
}
|
|
secretData := map[string]string{}
|
|
manifest, err := p.manifest(name, secretData, metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, service := range p.services {
|
|
if !slices.ContainsFunc(KubernetesServiceHostnames(service.id), func(hostname string) bool { return strings.EqualFold(hostname, service.id) }) { // glibc and Go match /etc/hosts case-insensitively
|
|
common.Logger(ctx).Warnf("Service %s is reachable as %s or localhost, as a Kubernetes hostname cannot be %q", service.id, service.name, service.id)
|
|
}
|
|
}
|
|
if len(secretData) > 0 {
|
|
p.secretName = name // named like the pod, RemoveOrphanKubernetesResources finds it by that
|
|
if err := p.client.do(ctx, http.MethodPost, p.client.path("secrets"), map[string]any{"metadata": metadata, "stringData": secretData}, nil); err != nil {
|
|
return fmt.Errorf("create secret: %w", err)
|
|
}
|
|
}
|
|
p.name = name
|
|
if err := p.client.do(ctx, http.MethodPost, p.client.path("pods"), manifest, nil); err != nil {
|
|
return fmt.Errorf("create pod: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *KubernetesPod) manifest(secret string, secretData map[string]string, metadata map[string]any) (any, error) {
|
|
template := map[string]any{"spec": map[string]any{
|
|
"automountServiceAccountToken": false,
|
|
"enableServiceLinks": false,
|
|
"terminationGracePeriodSeconds": 0, // the job container's tail ignores SIGTERM
|
|
}}
|
|
for _, layer := range p.options.PodTemplates {
|
|
template, _ = mergeManifest(template, layer, "").(map[string]any)
|
|
}
|
|
templateSpec, _ := template["spec"].(map[string]any)
|
|
templateContainers, _ := templateSpec["containers"].([]any)
|
|
templateVolumes, _ := templateSpec["volumes"].([]any)
|
|
var serviceTemplate any
|
|
if index := slices.IndexFunc(templateContainers, func(container any) bool { return manifestField(container, "name") == "$services" }); index >= 0 {
|
|
serviceTemplate = templateContainers[index]
|
|
templateSpec["containers"] = slices.Delete(slices.Clone(templateContainers), index, index+1)
|
|
}
|
|
var containers, volumes []any
|
|
var hostnames []string
|
|
names := map[string]bool{}
|
|
for _, container := range templateContainers {
|
|
names[manifestField(container, "name")] = true
|
|
}
|
|
for _, c := range append([]*kubernetesContainer{p.job}, p.services...) {
|
|
if c != p.job && (c.name == "" || names[c.name]) {
|
|
return nil, fmt.Errorf("service %s maps to container %q, which is empty or named like another container of the pod", c.id, c.name)
|
|
}
|
|
names[c.name] = true
|
|
spec, err := c.spec(secret, secretData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c == p.job {
|
|
containers = append(containers, spec)
|
|
continue
|
|
}
|
|
containers = append(containers, mergeManifest(serviceTemplate, spec, ""))
|
|
hostnames = append(hostnames, KubernetesServiceHostnames(c.id)...)
|
|
}
|
|
for _, name := range []string{"workspace", "act"} {
|
|
if !slices.ContainsFunc(templateVolumes, func(volume any) bool { return manifestField(volume, "name") == name }) {
|
|
volumes = append(volumes, map[string]any{"name": name, "emptyDir": map[string]any{}})
|
|
}
|
|
}
|
|
spec := map[string]any{"restartPolicy": "Never", "containers": containers, "volumes": volumes}
|
|
if p.transferHelper != "" {
|
|
transfer := map[string]any{
|
|
"name": "gitea-runner-transfer", "image": p.transferImage, "terminationMessagePolicy": "FallbackToLogsOnError",
|
|
"command": []string{"gitea-runner", KubernetesTransferCommand, "install", p.transferHelper},
|
|
"volumeMounts": []any{map[string]any{"name": "act", "mountPath": p.job.GetActPath()}},
|
|
"securityContext": map[string]any{"allowPrivilegeEscalation": false, "capabilities": map[string]any{"drop": []string{"ALL"}}},
|
|
}
|
|
if p.options.ForcePull {
|
|
transfer["imagePullPolicy"] = "Always"
|
|
}
|
|
spec["initContainers"] = []any{transfer}
|
|
}
|
|
if len(hostnames) > 0 {
|
|
spec["hostAliases"] = []any{map[string]any{"ip": "127.0.0.1", "hostnames": hostnames}}
|
|
}
|
|
if p.options.MaxLifetime > 0 {
|
|
spec["activeDeadlineSeconds"] = max(1, int64(p.options.MaxLifetime.Seconds()))
|
|
}
|
|
return mergeManifest(template, map[string]any{"apiVersion": "v1", "kind": "Pod", "metadata": metadata, "spec": spec}, ""), nil
|
|
}
|
|
|
|
// mergeManifest merges over onto base like a strategic merge patch: maps key by key, map list items by name or volumeMounts by mountPath, env entries, volumes and other values replaced.
|
|
func mergeManifest(base, over any, field string) any {
|
|
switch over := over.(type) {
|
|
case map[string]any:
|
|
merged := map[string]any{}
|
|
if base, ok := base.(map[string]any); ok {
|
|
maps.Copy(merged, base)
|
|
}
|
|
for key, value := range over {
|
|
merged[key] = mergeManifest(merged[key], value, key)
|
|
}
|
|
return merged
|
|
case []any:
|
|
key := cmp.Or(map[string]string{"volumeMounts": "mountPath"}[field], "name")
|
|
baseList, _ := base.([]any)
|
|
merged := slices.Clone(baseList)
|
|
for _, item := range over {
|
|
if _, ok := item.(map[string]any); !ok {
|
|
return over
|
|
}
|
|
name := manifestField(item, key)
|
|
index := slices.IndexFunc(merged, func(existing any) bool { return name != "" && manifestField(existing, key) == name })
|
|
switch {
|
|
case index < 0:
|
|
merged = append(merged, item)
|
|
case field == "env" || field == "volumes": // each holds one source, merging two makes a pod the API rejects
|
|
merged[index] = item
|
|
default:
|
|
merged[index] = mergeManifest(merged[index], item, "")
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
return over
|
|
}
|
|
|
|
func manifestField(item any, key string) string {
|
|
fields, _ := item.(map[string]any)
|
|
value, _ := fields[key].(string)
|
|
return value
|
|
}
|
|
|
|
func (c *kubernetesContainer) spec(secret string, secretData map[string]string) (map[string]any, error) {
|
|
env := []any{}
|
|
for index, entry := range c.input.Env {
|
|
key, value, _ := strings.Cut(entry, "=")
|
|
if c == c.pod.job {
|
|
env = append(env, map[string]any{"name": key, "value": value})
|
|
continue
|
|
}
|
|
secretKey := fmt.Sprintf("%s.%d", c.name, index)
|
|
secretData[secretKey] = value
|
|
env = append(env, map[string]any{"name": key, "valueFrom": map[string]any{"secretKeyRef": map[string]any{"name": secret, "key": secretKey}}})
|
|
}
|
|
spec := map[string]any{
|
|
"name": c.name, "image": c.input.Image, "command": escapeExpansion(c.input.Entrypoint), "args": escapeExpansion(c.input.Cmd),
|
|
"workingDir": c.input.WorkingDir, "env": env,
|
|
}
|
|
if c.pod.options.ForcePull {
|
|
spec["imagePullPolicy"] = "Always"
|
|
}
|
|
if c == c.pod.job {
|
|
spec["volumeMounts"] = []any{
|
|
map[string]any{"name": "workspace", "mountPath": c.input.WorkingDir},
|
|
map[string]any{"name": "act", "mountPath": c.GetActPath()},
|
|
}
|
|
}
|
|
if err := c.parseOptions(); err != nil {
|
|
return nil, fmt.Errorf("service %s: %w", c.name, err)
|
|
}
|
|
if c.probe != nil {
|
|
spec["startupProbe"] = c.probe
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// escapeExpansion keeps kubelet from expanding $(VAR) and $$ in command and args, which docker passes as is.
|
|
func escapeExpansion(args []string) []string {
|
|
args = slices.Clone(args)
|
|
for index := range args {
|
|
args[index] = strings.ReplaceAll(args[index], "$", "$$")
|
|
}
|
|
return args
|
|
}
|
|
|
|
// parseOptions turns docker health options, the only ones a pod supports, into a startup probe, which like docker gives up after the retries.
|
|
func (c *kubernetesContainer) parseOptions() error {
|
|
args, err := shellquote.Split(c.input.WorkflowOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flags := pflag.NewFlagSet("options", pflag.ContinueOnError)
|
|
flags.SetOutput(io.Discard)
|
|
command := flags.String("health-cmd", "", "")
|
|
interval := flags.Duration("health-interval", 0, "")
|
|
timeout := flags.Duration("health-timeout", 0, "")
|
|
retries := flags.Int("health-retries", 0, "")
|
|
startPeriod := flags.Duration("health-start-period", 0, "")
|
|
if err := flags.Parse(args); err != nil {
|
|
return fmt.Errorf("options %q: %w, only --health-* options are supported on Kubernetes", c.input.WorkflowOptions, err)
|
|
}
|
|
if *command == "" {
|
|
return nil
|
|
}
|
|
period := max(1, int(cmp.Or(*interval, 30*time.Second).Seconds()))
|
|
c.probe = map[string]any{
|
|
"exec": map[string]any{"command": []string{"/bin/sh", "-c", *command}},
|
|
"periodSeconds": period,
|
|
"timeoutSeconds": max(1, int(cmp.Or(*timeout, 30*time.Second).Seconds())),
|
|
"failureThreshold": max(1, cmp.Or(*retries, 3)) + int(startPeriod.Seconds())/period, // docker does not count failures in the start period
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *KubernetesPod) get(ctx context.Context) (*podStatus, error) {
|
|
var pod podStatus
|
|
return &pod, p.client.do(ctx, http.MethodGet, p.client.path("pods/"+p.name), nil, &pod)
|
|
}
|
|
|
|
func (p *KubernetesPod) waitStarted(ctx context.Context) error {
|
|
var unscheduled string
|
|
for delay := 100 * time.Millisecond; ; delay = min(2*delay, time.Second) {
|
|
pod, err := p.get(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if pod.Status.Phase == "Failed" {
|
|
reason := cmp.Or(pod.Status.Message, pod.Status.Reason)
|
|
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
|
if terminated := status.State.Terminated; reason == "" && terminated != nil && terminated.ExitCode != 0 {
|
|
reason = fmt.Sprintf("container %s exited with %d: %s", status.Name, terminated.ExitCode, strings.TrimSpace(terminated.Message))
|
|
}
|
|
}
|
|
return fmt.Errorf("pod %s failed: %s", p.name, reason)
|
|
}
|
|
started := len(pod.Status.ContainerStatuses) == len(pod.Spec.Containers)
|
|
for _, status := range append(pod.Status.InitContainerStatuses, pod.Status.ContainerStatuses...) {
|
|
if waiting := status.State.Waiting; waiting != nil {
|
|
switch waiting.Reason {
|
|
case "ErrImagePull", "ImagePullBackOff", "InvalidImageName", "ErrImageNeverPull", "CreateContainerConfigError", "CreateContainerError", "RunContainerError":
|
|
return fmt.Errorf("container %s of pod %s: %s: %s", status.Name, p.name, waiting.Reason, waiting.Message)
|
|
}
|
|
started = false
|
|
}
|
|
}
|
|
if started {
|
|
for _, ip := range pod.Status.PodIPs {
|
|
p.ips = append(p.ips, net.ParseIP(ip.IP))
|
|
}
|
|
return nil
|
|
}
|
|
for _, condition := range pod.Status.Conditions {
|
|
if condition.Type == "PodScheduled" && condition.Status == "False" && condition.Message != unscheduled {
|
|
unscheduled = condition.Message
|
|
common.Logger(ctx).Infof("Waiting for pod %s to be scheduled: %s", p.name, unscheduled)
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(delay):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *kubernetesContainer) Create(_, _ []string) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if c != c.pod.job {
|
|
return nil
|
|
}
|
|
return c.pod.create(ctx)
|
|
}
|
|
}
|
|
|
|
func (c *kubernetesContainer) Start(_ bool) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if c != c.pod.job {
|
|
return nil
|
|
}
|
|
if err := c.pod.waitStarted(ctx); err != nil {
|
|
return err
|
|
}
|
|
output, err := c.pod.probe(ctx, c.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
probed := strings.SplitN(output, "\n", 3)
|
|
for len(probed) < 3 {
|
|
probed = append(probed, "")
|
|
}
|
|
c.pod.arch, c.gzip, c.pod.path = goArchToActionArch(probed[0]), probed[1] == "gzip", probed[2]
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// probe retries exec while it fails short of the container, as a new node runs pods before its kubelet has a serving certificate.
|
|
func (p *KubernetesPod) probe(ctx context.Context, container string) (string, error) {
|
|
var failure string
|
|
deadline := time.Now().Add(time.Minute)
|
|
for delay := 100 * time.Millisecond; ; delay = min(2*delay, 2*time.Second) {
|
|
var stdout bytes.Buffer
|
|
err := p.client.exec(ctx, p.name, container, []string{"sh", "-c", `printf '%s\n%s\n%s' "$(uname -m)" "$(command -v gzip >/dev/null && echo gzip)" "$PATH"`}, nil, &stdout, io.Discard)
|
|
var exitErr ExitCodeError
|
|
var apiErr *apiError
|
|
if err == nil || ctx.Err() != nil || errors.As(err, &exitErr) || time.Now().After(deadline) ||
|
|
errors.As(err, &apiErr) && apiErr.code < http.StatusInternalServerError {
|
|
return stdout.String(), err
|
|
}
|
|
if err.Error() != failure {
|
|
failure = err.Error()
|
|
common.Logger(ctx).Infof("Waiting for exec into pod %s: %v", p.name, err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(delay):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (*kubernetesContainer) Pull(bool) common.Executor {
|
|
return func(context.Context) error { return nil }
|
|
}
|
|
|
|
var (
|
|
shellName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
|
bashReadonly = []string{"BASHOPTS", "BASH_VERSINFO", "EUID", "PPID", "SHELLOPTS", "UID"} // bash as sh cannot export these
|
|
)
|
|
|
|
// Exec feeds the command to sh on stdin, which exports the env so it stays out of the request URL and, where sh can export a name, out of process arguments.
|
|
func (c *kubernetesContainer) Exec(command []string, env map[string]string, _, workdir string) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
script, args := "", []string{"env", "--"}
|
|
for name, value := range env {
|
|
if shellName.MatchString(name) && !slices.Contains(bashReadonly, name) {
|
|
script += "command export " + shellquote.Join(name+"="+value) + "\n"
|
|
} else {
|
|
args = append(args, name+"="+value)
|
|
}
|
|
}
|
|
script += "exec " + shellquote.Join(append(args, command...)...)
|
|
if workdir != "" {
|
|
script = "cd " + shellquote.Join(workdir) + " || exit\n" + script
|
|
}
|
|
defer common.FlushWriter(c.input.Stdout)
|
|
defer common.FlushWriter(c.input.Stderr)
|
|
return c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh"}, strings.NewReader(script), c.input.Stdout, c.input.Stderr)
|
|
}
|
|
}
|
|
|
|
// compressCopyMin is the size from which Copy compresses and uses the transfer helper: below it that saves next to nothing on the wire and costs a process in the pod.
|
|
const compressCopyMin = 64 << 10
|
|
|
|
// extract streams the tar write produces into destPath, gzipped with compress, as copied actions shrink about fivefold and API server traffic often crosses zones.
|
|
func (c *kubernetesContainer) extract(ctx context.Context, destPath string, compress bool, write func(io.Writer) error) error {
|
|
if compress && c.pod.transferHelper != "" {
|
|
request, done := c.pod.transfer.offer(c.pod.ips, write)
|
|
stdin, _ := json.Marshal(request)
|
|
var stderr bytes.Buffer
|
|
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{c.pod.transferHelper, KubernetesTransferCommand, "fetch", destPath}, bytes.NewReader(stdin), io.Discard, &stderr)
|
|
taken, writeErr := done()
|
|
if err == nil || ctx.Err() != nil {
|
|
return err
|
|
}
|
|
if taken || !c.pod.options.Transfer.Fallback {
|
|
return fmt.Errorf("copy to %s over the pod network: %w: %s", destPath, errors.Join(writeErr, err), strings.TrimSpace(stderr.String()))
|
|
}
|
|
c.pod.transferHelper = "" // a pod that cannot reach the runner once will not later
|
|
logging.Warnf("Copying into pod %s over the pod network failed, copying through the API server instead: %v: %s", c.pod.name, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
compress = compress && c.gzip
|
|
reader, writer := io.Pipe()
|
|
defer reader.Close()
|
|
go func() {
|
|
if compress {
|
|
writer.CloseWithError(writeGzip(writer, write))
|
|
} else {
|
|
writer.CloseWithError(write(writer))
|
|
}
|
|
}()
|
|
script := `mkdir -p "$1" && tar -xf - -C "$1"`
|
|
if compress {
|
|
script = `mkdir -p "$1" && gzip -dc | tar -xf - -C "$1"` // gzip -dc rather than tar -z, which GNU tar runs gzip for anyway
|
|
}
|
|
var stderr bytes.Buffer
|
|
if err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", script, "sh", destPath}, reader, io.Discard, &stderr); err != nil {
|
|
return fmt.Errorf("extract to %s: %w: %s", destPath, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeGzip gzips what write produces into w, at a level fast enough to not slow copies down and nearly as small as the default.
|
|
func writeGzip(w io.Writer, write func(io.Writer) error) error {
|
|
compressed, _ := gzip.NewWriterLevel(w, gzip.BestSpeed)
|
|
return errors.Join(write(compressed), compressed.Close())
|
|
}
|
|
|
|
func (c *kubernetesContainer) Copy(destPath string, files ...*FileEntry) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
size := 0
|
|
for _, file := range files {
|
|
size += len(file.Body)
|
|
}
|
|
return c.extract(ctx, destPath, size >= compressCopyMin, func(writer io.Writer) error { return writeFilesTar(ctx, writer, 0, 0, files...) })
|
|
}
|
|
}
|
|
|
|
func (c *kubernetesContainer) CopyDir(destPath, srcPath string, useGitIgnore, skipGitDir bool) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
return c.extract(ctx, destPath, true, func(writer io.Writer) error { // directories are actions and workspaces, megabytes of compressible sources
|
|
return writeDirTar(ctx, writer, "/", srcPath, useGitIgnore, skipGitDir, 0, 0)
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetContainerArchive streams the archive, checking the path first as tar writes an empty archive for a missing one.
|
|
func (c *kubernetesContainer) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
|
reader, writer := io.Pipe()
|
|
go func() {
|
|
var stderr bytes.Buffer
|
|
err := c.pod.client.exec(ctx, c.pod.name, c.name, []string{"sh", "-c", `test -e "$1" -o -L "$1" && exec tar -cf - -C "$2" "$3"`, "sh", srcPath, path.Dir(srcPath), path.Base(srcPath)}, nil, writer, &stderr)
|
|
if err != nil {
|
|
err = fmt.Errorf("archive %s: %w: %s", srcPath, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
writer.CloseWithError(err)
|
|
}()
|
|
buffered := bufio.NewReader(reader)
|
|
if _, err := buffered.Peek(1); err != nil {
|
|
reader.Close()
|
|
return nil, err
|
|
}
|
|
return struct {
|
|
io.Reader
|
|
io.Closer
|
|
}{buffered, reader}, nil
|
|
}
|
|
|
|
func (c *kubernetesContainer) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
|
return parseEnvFile(c, srcPath, env, false)
|
|
}
|
|
|
|
func (c *kubernetesContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
|
return func(context.Context) error {
|
|
(*env)["PATH"] = cmp.Or((*env)["PATH"], c.pod.path)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *kubernetesContainer) Inspect(ctx context.Context) (*Info, error) {
|
|
if c.pod.name == "" {
|
|
return nil, fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
|
}
|
|
pod, err := c.pod.get(ctx)
|
|
if isNotFound(err) {
|
|
return nil, fmt.Errorf("pod %s %w", c.pod.name, ErrContainerNotFound)
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
info := &Info{ID: c.pod.name + "/" + c.name, State: "created", Health: HealthNone, Ports: map[string]string{}}
|
|
for port := range c.input.ExposedPorts {
|
|
info.Ports[port.Port()] = port.Port() // services share the pod's network
|
|
}
|
|
for _, status := range pod.Status.ContainerStatuses {
|
|
if status.Name != c.name {
|
|
continue
|
|
}
|
|
if status.State.Running != nil {
|
|
info.State = StateRunning
|
|
} else if terminated := status.State.Terminated; terminated != nil {
|
|
info.State, info.ExitCode, info.HealthOutput = "exited", terminated.ExitCode, terminated.Message
|
|
}
|
|
if c.probe != nil {
|
|
switch {
|
|
case status.Ready:
|
|
info.Health = HealthHealthy
|
|
case info.State == "exited":
|
|
info.Health = HealthUnhealthy
|
|
default:
|
|
info.Health = HealthStarting
|
|
}
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (c *kubernetesContainer) DumpLogs(ctx context.Context) error {
|
|
if c.pod.name == "" {
|
|
return fmt.Errorf("container %s %w", c.name, ErrContainerNotFound)
|
|
}
|
|
defer common.FlushWriter(c.input.Stdout)
|
|
return c.pod.client.do(ctx, http.MethodGet, c.pod.client.path("pods/"+c.pod.name+"/log?container="+c.name), nil, c.input.Stdout)
|
|
}
|
|
|
|
func (c *kubernetesContainer) Remove() common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if c != c.pod.job {
|
|
return nil
|
|
}
|
|
return errors.Join(c.pod.client.delete(ctx, "pods", c.pod.name), c.pod.client.delete(ctx, "secrets", c.pod.secretName))
|
|
}
|
|
}
|
|
|
|
// RemoveOrphanKubernetesResources removes this runner's job pods created before createdBefore and the Secrets named like them, as listing Secrets would reveal all of the namespace's.
|
|
func RemoveOrphanKubernetesResources(ctx context.Context, options KubernetesOptions, createdBefore time.Time) error {
|
|
client, err := newKubeClient(options.Kubeconfig, options.Namespace)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to Kubernetes: %w", err)
|
|
}
|
|
defer client.http.CloseIdleConnections()
|
|
var pods struct {
|
|
Items []struct {
|
|
Metadata struct {
|
|
Name string
|
|
CreationTimestamp time.Time
|
|
}
|
|
}
|
|
}
|
|
if err := client.do(ctx, http.MethodGet, client.path("pods?labelSelector="+url.QueryEscape(runnerUUIDLabel+"="+options.RunnerUUID)), nil, &pods); err != nil {
|
|
return fmt.Errorf("list pods: %w", err)
|
|
}
|
|
var errs []error
|
|
for _, item := range pods.Items {
|
|
if !item.Metadata.CreationTimestamp.Before(createdBefore) {
|
|
continue
|
|
}
|
|
if err := client.delete(ctx, "secrets", item.Metadata.Name); err != nil {
|
|
errs = append(errs, err) // leave the pod for the next cleanup, it is the only way back to its Secret
|
|
continue
|
|
}
|
|
errs = append(errs, client.delete(ctx, "pods", item.Metadata.Name))
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
func (c *kubernetesContainer) Close() common.Executor {
|
|
return func(context.Context) error {
|
|
c.pod.client.http.CloseIdleConnections()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *kubernetesContainer) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
|
oldStdout, oldStderr := c.input.Stdout, c.input.Stderr
|
|
c.input.Stdout, c.input.Stderr = stdout, stderr
|
|
return oldStdout, oldStderr
|
|
}
|
|
|
|
func (c *kubernetesContainer) GetRunnerContext(_ context.Context) map[string]any {
|
|
return map[string]any{"os": "Linux", "arch": c.pod.arch, "temp": "/tmp", "tool_cache": DefaultToolCache}
|
|
}
|