mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2026-10-24 16:59:19 +03:00
Fixes #1676 Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1739 Reviewed-by: Mathieu Fenniak <mfenniak@noreply.code.forgejo.org>
613 lines
17 KiB
Go
613 lines
17 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"archive/tar"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.forgejo.org/forgejo/runner/v13/act/common"
|
|
"code.forgejo.org/forgejo/runner/v13/act/container"
|
|
pluginv1alpha "code.forgejo.org/forgejo/runner/v13/act/plugin/proto/v1alpha"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
)
|
|
|
|
const copyChunkSize = 256 * 1024 // 256 KB
|
|
|
|
// pluginEnvironment is not safe for concurrent use; one goroutine per env.
|
|
type pluginEnvironment struct {
|
|
client pluginv1alpha.BackendPluginClient
|
|
caps *pluginv1alpha.CapabilitiesResponse
|
|
backendOpts map[string]string
|
|
labelArg string
|
|
timeout time.Duration
|
|
input *container.NewContainerInput
|
|
services []*pluginv1alpha.ServiceContainer
|
|
imageEnv map[string]string
|
|
|
|
// State that is only initialized after an environment is created:
|
|
envCreated *createdEnvironment
|
|
|
|
// mu guards stdout/stderr swaps against in-flight Exec writes.
|
|
mu sync.Mutex
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
}
|
|
|
|
type createdEnvironment struct {
|
|
envID string
|
|
rootPath string
|
|
actPath string
|
|
toolCachePath string
|
|
tempPath string
|
|
envVariables []string // env variables defined by the runner after an image is created, to be passed into each exec
|
|
pathVariableName string
|
|
defaultPathVariable string
|
|
pathSeparator string
|
|
environmentOS string
|
|
environmentArch string
|
|
isEnvironmentCaseInsensitive bool
|
|
}
|
|
|
|
// ExecError carries the remote exit code and optional message from Exec.
|
|
type ExecError struct {
|
|
ExitCode int32
|
|
Message string
|
|
}
|
|
|
|
func (e *ExecError) Error() string {
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("plugin exec: %s", e.Message)
|
|
}
|
|
return fmt.Sprintf("plugin exec: exit code %d", e.ExitCode)
|
|
}
|
|
|
|
var (
|
|
_ container.ExecutionsEnvironment = (*pluginEnvironment)(nil)
|
|
_ container.ServiceAdder = (*pluginEnvironment)(nil)
|
|
)
|
|
|
|
func (p *pluginEnvironment) AddServiceContainerRaw(name, image string, env map[string]string, ports []string) {
|
|
p.services = append(p.services, &pluginv1alpha.ServiceContainer{
|
|
Name: name,
|
|
Image: image,
|
|
Env: env,
|
|
Ports: ports,
|
|
})
|
|
}
|
|
|
|
func (p *pluginEnvironment) BackendID() string {
|
|
return p.caps.GetName()
|
|
}
|
|
|
|
// SupportsDockerContainerActions is false: a plugin-managed environment does
|
|
// not run `uses: docker://` or container actions.
|
|
func (p *pluginEnvironment) SupportsDockerContainerActions() bool {
|
|
return false
|
|
}
|
|
|
|
// ManagesOwnNetworking is true: a plugin owns connectivity for its environment,
|
|
// so the runner does not create or attach a Docker-style network.
|
|
func (p *pluginEnvironment) ManagesOwnNetworking() bool {
|
|
return true
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetName() string {
|
|
return p.caps.GetName()
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetRoot() string {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.GetRoot() before environment creation")
|
|
}
|
|
return p.envCreated.rootPath
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetActPath() string {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.GetActPath() before environment creation")
|
|
}
|
|
return p.envCreated.actPath
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetPathVariableName() string {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.GetPathVariableName() before environment creation")
|
|
}
|
|
if v := p.envCreated.pathVariableName; v != "" {
|
|
return v
|
|
}
|
|
return "PATH"
|
|
}
|
|
|
|
func (p *pluginEnvironment) DefaultPathVariable() string {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.DefaultPathVariable() before environment creation")
|
|
}
|
|
if v := p.envCreated.defaultPathVariable; v != "" {
|
|
return v
|
|
}
|
|
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
}
|
|
|
|
func (p *pluginEnvironment) JoinPathVariable(paths ...string) string {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.JoinPathVariable() before environment creation")
|
|
}
|
|
sep := p.envCreated.pathSeparator
|
|
if sep == "" {
|
|
sep = ":"
|
|
}
|
|
return strings.Join(paths, sep)
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetRunnerContext(_ context.Context) map[string]any {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.GetRunnerContext() before environment creation")
|
|
}
|
|
return map[string]any{
|
|
"os": p.envCreated.environmentOS,
|
|
"arch": p.envCreated.environmentArch,
|
|
"temp": p.envCreated.tempPath,
|
|
"tool_cache": p.envCreated.toolCachePath,
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) IsEnvironmentCaseInsensitive() bool {
|
|
if p.envCreated == nil {
|
|
panic("accessed IsEnvironmentCaseInsensitive before environment creation")
|
|
}
|
|
return p.envCreated.isEnvironmentCaseInsensitive
|
|
}
|
|
|
|
func (p *pluginEnvironment) ToContainerPath(path string) string {
|
|
return path
|
|
}
|
|
|
|
// newCreateRequest maps the container input into a Create request. Callers set
|
|
// path-specific fields (cap add/drop, services) afterwards.
|
|
func newCreateRequest(input *container.NewContainerInput, backendOpts map[string]string, labelArg string, timeout time.Duration) *pluginv1alpha.CreateRequest {
|
|
return &pluginv1alpha.CreateRequest{
|
|
Image: input.Image,
|
|
Name: input.Name,
|
|
BackendOptions: backendOpts,
|
|
LabelArg: labelArg,
|
|
EnvironmentTimeout: durationpb.New(timeout),
|
|
}
|
|
}
|
|
|
|
// envSliceToMap converts docker-style KEY=VALUE entries into a map, last value
|
|
// winning on duplicate keys. An entry without '=' maps the whole entry to "".
|
|
func envSliceToMap(env []string) map[string]string {
|
|
if len(env) == 0 {
|
|
return nil
|
|
}
|
|
m := make(map[string]string, len(env))
|
|
for _, kv := range env {
|
|
k, v, _ := strings.Cut(kv, "=")
|
|
m[k] = v
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (p *pluginEnvironment) Create(capAdd, capDrop []string) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if p.envCreated != nil {
|
|
panic("Create()() invoked on a plugin environment that has already had Create invoked")
|
|
}
|
|
timeout := p.timeout
|
|
// The job deadline can be shorter than the runner's maximum lifetime.
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
if remaining := time.Until(deadline); timeout <= 0 || remaining < timeout {
|
|
timeout = remaining
|
|
}
|
|
}
|
|
req := newCreateRequest(p.input, p.backendOpts, p.labelArg, timeout)
|
|
req.CapAdd = capAdd
|
|
req.CapDrop = capDrop
|
|
req.Services = p.services
|
|
resp, err := p.client.Create(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("plugin create: %w", err)
|
|
}
|
|
p.envCreated = &createdEnvironment{
|
|
envID: resp.GetEnvironmentId(),
|
|
rootPath: resp.GetRootPath(),
|
|
actPath: resp.GetActPath(),
|
|
toolCachePath: resp.GetToolCachePath(),
|
|
tempPath: resp.GetTempPath(),
|
|
envVariables: []string{
|
|
fmt.Sprintf("RUNNER_TOOL_CACHE=%s", resp.GetToolCachePath()),
|
|
fmt.Sprintf("RUNNER_TEMP=%s", resp.GetTempPath()),
|
|
fmt.Sprintf("RUNNER_OS=%s", resp.GetOs()),
|
|
fmt.Sprintf("RUNNER_ARCH=%s", resp.GetArch()),
|
|
},
|
|
pathVariableName: resp.GetPathVariableName(),
|
|
defaultPathVariable: resp.GetDefaultPathVariable(),
|
|
pathSeparator: resp.GetPathSeparator(),
|
|
environmentOS: resp.GetOs(),
|
|
environmentArch: resp.GetArch(),
|
|
isEnvironmentCaseInsensitive: resp.GetEnvironmentCaseInsensitive(),
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) Start(_ bool) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.Start()(...) before environment creation")
|
|
}
|
|
|
|
// Cancel on early return so the client-side stream goroutines exit.
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
stream, err := p.client.Start(streamCtx, &pluginv1alpha.StartRequest{
|
|
EnvironmentId: p.envCreated.envID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("plugin start: request error %w", err)
|
|
}
|
|
|
|
done := false
|
|
for {
|
|
out, err := stream.Recv()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("plugin start: stream error %w", err)
|
|
}
|
|
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.StartOutput_Data:
|
|
p.mu.Lock()
|
|
switch v.Data.GetStream() {
|
|
case pluginv1alpha.DataChunk_STDOUT:
|
|
_, _ = p.stdout.Write(v.Data.GetData())
|
|
case pluginv1alpha.DataChunk_STDERR:
|
|
_, _ = p.stderr.Write(v.Data.GetData())
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
case *pluginv1alpha.StartOutput_StartComplete:
|
|
p.imageEnv = v.StartComplete.GetImageEnv()
|
|
done = true
|
|
|
|
default:
|
|
return fmt.Errorf("plugin start: unexpected stream chunk %#v", v)
|
|
}
|
|
if done {
|
|
break
|
|
}
|
|
}
|
|
|
|
if !done {
|
|
return fmt.Errorf("plugin start: stream ended before completion signal")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) Pull(_ bool) common.Executor {
|
|
return common.NewInfoExecutor("plugin manages image pull internally")
|
|
}
|
|
|
|
func (p *pluginEnvironment) ConnectToNetwork(_ string) common.Executor {
|
|
return common.NewInfoExecutor("plugin manages networking internally")
|
|
}
|
|
|
|
func (p *pluginEnvironment) Exec(command []string, env map[string]string, user, workdir string) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.Exec()(...) before environment creation")
|
|
}
|
|
|
|
// Cancel on early return so the client-side stream goroutines exit.
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Start with `envVariables` which are the static values the runner defines, and then add in the environment-level
|
|
// env vars, and then the specific env settings requested for this exec.
|
|
finalEnv := envSliceToMap(p.envCreated.envVariables)
|
|
maps.Copy(finalEnv, envSliceToMap(p.input.Env))
|
|
maps.Copy(finalEnv, env)
|
|
|
|
req := &pluginv1alpha.ExecRequest{
|
|
EnvironmentId: p.envCreated.envID,
|
|
Command: command,
|
|
Env: finalEnv,
|
|
}
|
|
if user != "" {
|
|
req.User = &user
|
|
}
|
|
if workdir != "" {
|
|
req.Workdir = workdir
|
|
} else {
|
|
req.Workdir = p.input.WorkingDir
|
|
}
|
|
stream, err := p.client.Exec(streamCtx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("plugin exec: %w", err)
|
|
}
|
|
|
|
var (
|
|
exitCode int32
|
|
errorMessage string
|
|
done bool
|
|
)
|
|
for {
|
|
out, err := stream.Recv()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("plugin exec stream: %w", err)
|
|
}
|
|
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.ExecOutput_Data:
|
|
p.mu.Lock()
|
|
switch v.Data.GetStream() {
|
|
case pluginv1alpha.DataChunk_STDOUT:
|
|
_, _ = p.stdout.Write(v.Data.GetData())
|
|
case pluginv1alpha.DataChunk_STDERR:
|
|
_, _ = p.stderr.Write(v.Data.GetData())
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
case *pluginv1alpha.ExecOutput_ExecComplete:
|
|
exitCode = v.ExecComplete.GetExitCode()
|
|
done = true
|
|
|
|
case *pluginv1alpha.ExecOutput_ExecFailed:
|
|
errorMessage = v.ExecFailed.GetErrorMessage()
|
|
done = true
|
|
|
|
default:
|
|
return fmt.Errorf("plugin exec: unexpected stream chunk %#v", v)
|
|
}
|
|
if done {
|
|
break
|
|
}
|
|
}
|
|
|
|
if !done {
|
|
return fmt.Errorf("plugin exec: stream ended before completion signal")
|
|
}
|
|
if exitCode != 0 || errorMessage != "" {
|
|
return &ExecError{ExitCode: exitCode, Message: errorMessage}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) Copy(destPath string, files ...*container.FileEntry) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
// Stream the tar so large payloads do not stay buffered in memory.
|
|
pr, pw := io.Pipe()
|
|
defer pr.Close()
|
|
go func() {
|
|
tw := tar.NewWriter(pw)
|
|
for _, f := range files {
|
|
if err := tw.WriteHeader(&tar.Header{
|
|
Name: f.Name,
|
|
Mode: f.Mode,
|
|
Size: int64(len(f.Body)),
|
|
}); err != nil {
|
|
pw.CloseWithError(fmt.Errorf("plugin copy tar header: %w", err))
|
|
return
|
|
}
|
|
if _, err := tw.Write([]byte(f.Body)); err != nil {
|
|
pw.CloseWithError(fmt.Errorf("plugin copy tar write: %w", err))
|
|
return
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
pw.CloseWithError(err)
|
|
return
|
|
}
|
|
pw.Close()
|
|
}()
|
|
return p.streamCopyIn(ctx, destPath, pr)
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) CopyDir(destPath, srcPath string, _ bool) common.Executor {
|
|
return func(ctx context.Context) error {
|
|
pr, pw := io.Pipe()
|
|
defer pr.Close()
|
|
go func() {
|
|
tw := tar.NewWriter(pw)
|
|
if err := tw.AddFS(os.DirFS(srcPath)); err != nil {
|
|
pw.CloseWithError(err)
|
|
return
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
pw.CloseWithError(err)
|
|
return
|
|
}
|
|
pw.Close()
|
|
}()
|
|
return p.streamCopyIn(ctx, destPath, pr)
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) CopyTarStream(ctx context.Context, destPath string, tarStream io.Reader) error {
|
|
return p.streamCopyIn(ctx, destPath, tarStream)
|
|
}
|
|
|
|
func (p *pluginEnvironment) streamCopyIn(ctx context.Context, destPath string, r io.Reader) error {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.streamCopyIn() before environment creation")
|
|
}
|
|
|
|
stream, err := p.client.CopyIn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("plugin copyin: %w", err)
|
|
}
|
|
|
|
// On client-streaming RPCs, Send returns io.EOF once the server has
|
|
// closed the stream; the real error only surfaces via CloseAndRecv.
|
|
sendChunk := func(chunk *pluginv1alpha.CopyInChunk) error {
|
|
if err := stream.Send(chunk); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
if _, recvErr := stream.CloseAndRecv(); recvErr != nil {
|
|
return fmt.Errorf("plugin copyin: %w", recvErr)
|
|
}
|
|
return fmt.Errorf("plugin copyin: stream closed unexpectedly")
|
|
}
|
|
return fmt.Errorf("plugin copyin send: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Send the header unconditionally so empty payloads still convey envID/dest.
|
|
if err := sendChunk(&pluginv1alpha.CopyInChunk{
|
|
EnvironmentId: &p.envCreated.envID,
|
|
DestPath: &destPath,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
buf := make([]byte, copyChunkSize)
|
|
for {
|
|
n, readErr := r.Read(buf)
|
|
if n > 0 {
|
|
if err := sendChunk(&pluginv1alpha.CopyInChunk{Data: buf[:n]}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return fmt.Errorf("plugin copyin read: %w", readErr)
|
|
}
|
|
}
|
|
|
|
if _, err := stream.CloseAndRecv(); err != nil {
|
|
return fmt.Errorf("plugin copyin close: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *pluginEnvironment) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) {
|
|
if p.envCreated == nil {
|
|
panic("accessed pluginEnvironment.streamCopyIn() before environment creation")
|
|
}
|
|
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
stream, err := p.client.CopyOut(streamCtx, &pluginv1alpha.CopyOutRequest{
|
|
EnvironmentId: p.envCreated.envID,
|
|
SrcPath: srcPath,
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("plugin copyout: %w", err)
|
|
}
|
|
|
|
pr, pw := io.Pipe()
|
|
go func() {
|
|
defer cancel()
|
|
for {
|
|
chunk, err := stream.Recv()
|
|
if errors.Is(err, io.EOF) {
|
|
pw.Close()
|
|
return
|
|
}
|
|
if err != nil {
|
|
pw.CloseWithError(fmt.Errorf("plugin copyout stream: %w", err))
|
|
return
|
|
}
|
|
if _, err := pw.Write(chunk.GetData()); err != nil {
|
|
// Reader closed: cancel via defer so stream.Recv unblocks.
|
|
pw.CloseWithError(err)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
return pr, nil
|
|
}
|
|
|
|
func (p *pluginEnvironment) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor {
|
|
// Read the env file out of the environment (CopyOut) and parse it here; the
|
|
// runner owns the merge rather than the plugin.
|
|
return container.ParseEnvFile(p, srcPath, env)
|
|
}
|
|
|
|
func (p *pluginEnvironment) UpdateFromImageEnv(env *map[string]string) common.Executor {
|
|
return func(_ context.Context) error {
|
|
if p.imageEnv == nil {
|
|
return nil
|
|
}
|
|
envMap := *env
|
|
pathVar := p.GetPathVariableName()
|
|
sep := p.envCreated.pathSeparator
|
|
if sep == "" {
|
|
sep = ":"
|
|
}
|
|
for k, v := range p.imageEnv {
|
|
if k == pathVar {
|
|
if envMap[k] == "" {
|
|
envMap[k] = v
|
|
} else {
|
|
envMap[k] += sep + v
|
|
}
|
|
} else if envMap[k] == "" {
|
|
envMap[k] = v
|
|
}
|
|
}
|
|
*env = envMap
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// IsHealthy is a no-op for plugins: the v1alpha protocol has no health RPC, so
|
|
// the runner does not poll liveness of a plugin-managed environment.
|
|
func (p *pluginEnvironment) IsHealthy(_ context.Context) (time.Duration, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (p *pluginEnvironment) Remove() common.Executor {
|
|
return func(ctx context.Context) error {
|
|
if p.envCreated == nil {
|
|
return nil
|
|
}
|
|
|
|
_, err := p.client.Remove(ctx, &pluginv1alpha.RemoveRequest{
|
|
EnvironmentId: p.envCreated.envID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("plugin remove: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) Close() common.Executor {
|
|
return func(_ context.Context) error {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *pluginEnvironment) ReplaceLogWriter(stdout, stderr io.Writer) (io.Writer, io.Writer) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
oldOut, oldErr := p.stdout, p.stderr
|
|
p.stdout = stdout
|
|
p.stderr = stderr
|
|
return oldOut, oldErr
|
|
}
|