mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-03 13:57:16 +08:00
Adds a weekly and on-demand workflow that runs the cross-compiled tests on Windows Server 2025 Core in a [dockur/windows](https://github.com/dockur/windows) QEMU VM, and fixes the Windows bugs it found in host and UNC paths, env handling, shell quoting, post-task scripts, health checks, checkout, action symlinks, step summaries, Git client certificates and docker mode from a Windows host. Host-mode steps now emit UTF-8, `bash` falls back to Git's bash instead of the WSL launcher, the runner can run as a native Windows service, steps can keep background helpers with `RUNNER_TRACKING_ID`, and Windows arm64 binaries are released. Fixes: https://gitea.com/gitea/runner/issues/1339 Fixes: https://gitea.com/gitea/runner/issues/637 Fixes: https://gitea.com/gitea/runner/issues/1172 Fixes: https://gitea.com/gitea/runner/issues/347 Reviewed-on: https://gitea.com/gitea/runner/pulls/1348 Reviewed-by: bircni <bircni@icloud.com>
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, hostOS == "windows")
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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}
|
|
}
|