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>
867 lines
26 KiB
Go
867 lines
26 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package plugin
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.forgejo.org/forgejo/runner/v13/act/container"
|
|
pluginv1alpha "code.forgejo.org/forgejo/runner/v13/act/plugin/proto/v1alpha"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/health"
|
|
"google.golang.org/grpc/health/grpc_health_v1"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const bufSize = 1024 * 1024
|
|
|
|
type mockPluginServer struct {
|
|
pluginv1alpha.UnimplementedBackendPluginServer
|
|
|
|
createReq *pluginv1alpha.CreateRequest
|
|
removeCalled bool
|
|
|
|
startStdout string
|
|
startStderr string
|
|
startMissingStartComplete bool
|
|
startStreamError bool
|
|
|
|
execReq *pluginv1alpha.ExecRequest
|
|
execExitCode int32
|
|
execError string
|
|
execStdout string
|
|
execStderr string
|
|
|
|
copyInData []byte
|
|
copyInDest string
|
|
copyOutData []byte
|
|
startImageEnv map[string]string
|
|
}
|
|
|
|
func (s *mockPluginServer) Capabilities(_ context.Context, _ *pluginv1alpha.CapabilitiesRequest) (*pluginv1alpha.CapabilitiesResponse, error) {
|
|
return &pluginv1alpha.CapabilitiesResponse{
|
|
Name: "test-backend",
|
|
}, nil
|
|
}
|
|
|
|
func (s *mockPluginServer) Create(_ context.Context, req *pluginv1alpha.CreateRequest) (*pluginv1alpha.CreateResponse, error) {
|
|
s.createReq = req
|
|
return &pluginv1alpha.CreateResponse{
|
|
EnvironmentId: "test-env-123",
|
|
RootPath: "/test/root",
|
|
ActPath: "/test/root/act",
|
|
ToolCachePath: "/test/root/toolcache",
|
|
TempPath: "/tmp",
|
|
PathVariableName: proto.String("PATH"),
|
|
DefaultPathVariable: proto.String("/usr/bin:/bin"),
|
|
PathSeparator: proto.String(":"),
|
|
Os: "Linux",
|
|
Arch: "x86_64",
|
|
}, nil
|
|
}
|
|
|
|
func (s *mockPluginServer) Start(_ *pluginv1alpha.StartRequest, stream grpc.ServerStreamingServer[pluginv1alpha.StartOutput]) error {
|
|
if s.startStreamError {
|
|
return errors.New("startStreamError")
|
|
}
|
|
if s.startStdout != "" {
|
|
_ = stream.Send(&pluginv1alpha.StartOutput{
|
|
Output: &pluginv1alpha.StartOutput_Data{
|
|
Data: &pluginv1alpha.DataChunk{
|
|
Stream: pluginv1alpha.DataChunk_STDOUT,
|
|
Data: []byte(s.startStdout),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if s.startStderr != "" {
|
|
_ = stream.Send(&pluginv1alpha.StartOutput{
|
|
Output: &pluginv1alpha.StartOutput_Data{
|
|
Data: &pluginv1alpha.DataChunk{
|
|
Stream: pluginv1alpha.DataChunk_STDERR,
|
|
Data: []byte(s.startStderr),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if !s.startMissingStartComplete {
|
|
_ = stream.Send(&pluginv1alpha.StartOutput{
|
|
Output: &pluginv1alpha.StartOutput_StartComplete{
|
|
StartComplete: &pluginv1alpha.StartComplete{ImageEnv: s.startImageEnv},
|
|
},
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *mockPluginServer) Exec(req *pluginv1alpha.ExecRequest, stream grpc.ServerStreamingServer[pluginv1alpha.ExecOutput]) error {
|
|
s.execReq = req
|
|
if s.execStdout != "" {
|
|
_ = stream.Send(&pluginv1alpha.ExecOutput{
|
|
Output: &pluginv1alpha.ExecOutput_Data{
|
|
Data: &pluginv1alpha.DataChunk{
|
|
Stream: pluginv1alpha.DataChunk_STDOUT,
|
|
Data: []byte(s.execStdout),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if s.execStderr != "" {
|
|
_ = stream.Send(&pluginv1alpha.ExecOutput{
|
|
Output: &pluginv1alpha.ExecOutput_Data{
|
|
Data: &pluginv1alpha.DataChunk{
|
|
Stream: pluginv1alpha.DataChunk_STDERR,
|
|
Data: []byte(s.execStderr),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
if s.execError != "" {
|
|
_ = stream.Send(&pluginv1alpha.ExecOutput{
|
|
Output: &pluginv1alpha.ExecOutput_ExecFailed{
|
|
ExecFailed: &pluginv1alpha.ExecFailed{
|
|
ErrorMessage: s.execError,
|
|
},
|
|
},
|
|
})
|
|
} else {
|
|
_ = stream.Send(&pluginv1alpha.ExecOutput{
|
|
Output: &pluginv1alpha.ExecOutput_ExecComplete{
|
|
ExecComplete: &pluginv1alpha.ExecComplete{
|
|
ExitCode: s.execExitCode,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *mockPluginServer) CopyIn(stream grpc.ClientStreamingServer[pluginv1alpha.CopyInChunk, pluginv1alpha.CopyInResponse]) error {
|
|
var buf bytes.Buffer
|
|
for {
|
|
chunk, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.copyInDest == "" {
|
|
s.copyInDest = chunk.GetDestPath()
|
|
}
|
|
buf.Write(chunk.GetData())
|
|
}
|
|
s.copyInData = buf.Bytes()
|
|
return stream.SendAndClose(&pluginv1alpha.CopyInResponse{})
|
|
}
|
|
|
|
func (s *mockPluginServer) CopyOut(_ *pluginv1alpha.CopyOutRequest, stream grpc.ServerStreamingServer[pluginv1alpha.CopyOutChunk]) error {
|
|
if s.copyOutData != nil {
|
|
_ = stream.Send(&pluginv1alpha.CopyOutChunk{Data: s.copyOutData})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *mockPluginServer) Remove(_ context.Context, _ *pluginv1alpha.RemoveRequest) (*pluginv1alpha.RemoveResponse, error) {
|
|
s.removeCalled = true
|
|
return &pluginv1alpha.RemoveResponse{}, nil
|
|
}
|
|
|
|
func startMockServer(t *testing.T) (*mockPluginServer, *grpc.ClientConn) {
|
|
t.Helper()
|
|
|
|
lis := bufconn.Listen(bufSize)
|
|
srv := grpc.NewServer()
|
|
mock := &mockPluginServer{
|
|
execStdout: "hello\n",
|
|
}
|
|
pluginv1alpha.RegisterBackendPluginServer(srv, mock)
|
|
|
|
healthSrv := health.NewServer()
|
|
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
|
|
healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
|
|
|
|
go func() {
|
|
_ = srv.Serve(lis)
|
|
}()
|
|
t.Cleanup(srv.Stop)
|
|
|
|
conn, err := grpc.NewClient(
|
|
"passthrough:///bufconn",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return lis.DialContext(ctx)
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { conn.Close() })
|
|
|
|
return mock, conn
|
|
}
|
|
|
|
func newTestEnv(t *testing.T, conn *grpc.ClientConn) *pluginEnvironment {
|
|
t.Helper()
|
|
rpc := pluginv1alpha.NewBackendPluginClient(conn)
|
|
caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
|
|
require.NoError(t, err)
|
|
return &pluginEnvironment{
|
|
client: rpc,
|
|
caps: caps,
|
|
input: &container.NewContainerInput{
|
|
Image: "test:latest",
|
|
Name: "test-container",
|
|
Env: []string{"FOO=bar"},
|
|
WorkingDir: "/workspace",
|
|
DefaultPlatform: "linux/amd64",
|
|
},
|
|
backendOpts: map[string]string{"ns": "default"},
|
|
labelArg: "ubuntu-24.04",
|
|
timeout: 15 * time.Minute,
|
|
stdout: io.Discard,
|
|
stderr: io.Discard,
|
|
}
|
|
}
|
|
|
|
func TestPluginEnvironment_Capabilities(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
assert.Equal(t, "test-backend", env.BackendID())
|
|
assert.Equal(t, "test-backend", env.GetName())
|
|
assert.False(t, env.SupportsDockerContainerActions())
|
|
assert.True(t, env.ManagesOwnNetworking())
|
|
assert.Equal(t, "/some/path", env.ToContainerPath("/some/path"))
|
|
}
|
|
|
|
func TestPluginEnvironment_StateAccessBeforeStart(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
assert.Panics(t, func() { env.GetRoot() })
|
|
assert.Panics(t, func() { env.GetActPath() })
|
|
assert.Panics(t, func() { env.GetPathVariableName() })
|
|
assert.Panics(t, func() { env.DefaultPathVariable() })
|
|
assert.Panics(t, func() { env.JoinPathVariable("/a", "/b") })
|
|
assert.Panics(t, func() { env.IsEnvironmentCaseInsensitive() })
|
|
assert.Panics(t, func() { env.GetRunnerContext(t.Context()) })
|
|
}
|
|
|
|
func TestPluginEnvironment_StateDefaults(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
rpc := pluginv1alpha.NewBackendPluginClient(conn)
|
|
env := &pluginEnvironment{
|
|
client: rpc,
|
|
caps: &pluginv1alpha.CapabilitiesResponse{},
|
|
envCreated: &createdEnvironment{},
|
|
input: &container.NewContainerInput{},
|
|
stdout: io.Discard,
|
|
stderr: io.Discard,
|
|
}
|
|
|
|
assert.Equal(t, "PATH", env.GetPathVariableName())
|
|
assert.Equal(t, "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", env.DefaultPathVariable())
|
|
assert.Equal(t, "/a:/b", env.JoinPathVariable("/a", "/b"))
|
|
}
|
|
|
|
func TestPluginEnvironment_StateFromStart(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
rpc := pluginv1alpha.NewBackendPluginClient(conn)
|
|
env := &pluginEnvironment{
|
|
client: rpc,
|
|
caps: &pluginv1alpha.CapabilitiesResponse{},
|
|
envCreated: &createdEnvironment{
|
|
envID: "abc",
|
|
rootPath: "/my-root",
|
|
actPath: "/my-act-path",
|
|
toolCachePath: "/my-tool-cache-path",
|
|
tempPath: "/my-tmp-path",
|
|
pathVariableName: "MyPath",
|
|
defaultPathVariable: "C:\\Tmp",
|
|
pathSeparator: ";",
|
|
environmentOS: "plan9",
|
|
environmentArch: "riscv5",
|
|
isEnvironmentCaseInsensitive: true,
|
|
},
|
|
input: &container.NewContainerInput{},
|
|
stdout: io.Discard,
|
|
stderr: io.Discard,
|
|
}
|
|
|
|
assert.Equal(t, "/my-root", env.GetRoot())
|
|
assert.Equal(t, "/my-act-path", env.GetActPath())
|
|
assert.Equal(t, "MyPath", env.GetPathVariableName())
|
|
assert.Equal(t, "C:\\Tmp", env.DefaultPathVariable())
|
|
assert.Equal(t, "/a;/b", env.JoinPathVariable("/a", "/b"))
|
|
assert.Equal(t, true, env.IsEnvironmentCaseInsensitive())
|
|
assert.Equal(t, map[string]any{
|
|
"arch": "riscv5",
|
|
"os": "plan9",
|
|
"temp": "/my-tmp-path",
|
|
"tool_cache": "/my-tool-cache-path",
|
|
}, env.GetRunnerContext(t.Context()))
|
|
}
|
|
|
|
func TestPluginEnvironment_CreatePassesInput(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
env.AddServiceContainerRaw("redis", "redis:7", map[string]string{"REDIS_PASS": "secret"}, []string{"6379"})
|
|
|
|
err := env.Create([]string{"NET_ADMIN"}, []string{"MKNOD"})(t.Context())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, env.envCreated)
|
|
assert.Equal(t, "test-env-123", env.envCreated.envID)
|
|
assert.Equal(t, "/test/root", env.GetRoot())
|
|
assert.Equal(t, "/test/root/act", env.GetActPath())
|
|
rc := env.GetRunnerContext(t.Context())
|
|
assert.Equal(t, "Linux", rc["os"])
|
|
assert.Equal(t, "x86_64", rc["arch"])
|
|
assert.Equal(t, "/tmp", rc["temp"])
|
|
assert.Equal(t, "/test/root/toolcache", rc["tool_cache"])
|
|
|
|
req := mock.createReq
|
|
require.NotNil(t, req)
|
|
assert.Equal(t, "test:latest", req.Image)
|
|
assert.Equal(t, "test-container", req.Name)
|
|
assert.Equal(t, []string{"NET_ADMIN"}, req.CapAdd)
|
|
assert.Equal(t, []string{"MKNOD"}, req.CapDrop)
|
|
assert.Equal(t, "default", req.BackendOptions["ns"])
|
|
assert.Equal(t, "ubuntu-24.04", req.LabelArg)
|
|
assert.Equal(t, 15*time.Minute, req.EnvironmentTimeout.AsDuration())
|
|
|
|
require.Len(t, req.Services, 1)
|
|
assert.Equal(t, "redis", req.Services[0].Name)
|
|
assert.Equal(t, "redis:7", req.Services[0].Image)
|
|
assert.Equal(t, "secret", req.Services[0].Env["REDIS_PASS"])
|
|
assert.Equal(t, []string{"6379"}, req.Services[0].Ports)
|
|
|
|
assert.Contains(t, env.envCreated.envVariables, "RUNNER_TOOL_CACHE=/test/root/toolcache")
|
|
assert.Contains(t, env.envCreated.envVariables, "RUNNER_TEMP=/tmp")
|
|
}
|
|
|
|
func TestPluginEnvironment_CreateTimeout(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
maxLifetime time.Duration
|
|
jobTimeout time.Duration
|
|
runnerTimeout time.Duration
|
|
wantDeadline bool
|
|
}{
|
|
{name: "job timeout", maxLifetime: 179 * time.Minute, jobTimeout: 5 * time.Minute, runnerTimeout: 3 * time.Hour, wantDeadline: true},
|
|
{name: "runner timeout", maxLifetime: 3 * time.Hour, jobTimeout: 10 * time.Minute, runnerTimeout: 2 * time.Minute, wantDeadline: true},
|
|
{name: "maximum lifetime", maxLifetime: time.Minute, jobTimeout: 5 * time.Minute},
|
|
{name: "no deadline", maxLifetime: 3 * time.Hour},
|
|
{name: "no maximum lifetime", jobTimeout: 5 * time.Minute, wantDeadline: true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
env.timeout = tt.maxLifetime
|
|
|
|
ctx := t.Context()
|
|
if tt.runnerTimeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, tt.runnerTimeout)
|
|
defer cancel()
|
|
}
|
|
if tt.jobTimeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, tt.jobTimeout)
|
|
defer cancel()
|
|
}
|
|
|
|
before := time.Now()
|
|
require.NoError(t, env.Create(nil, nil)(ctx))
|
|
after := time.Now()
|
|
require.NotNil(t, mock.createReq)
|
|
timeout := mock.createReq.EnvironmentTimeout.AsDuration()
|
|
if tt.wantDeadline {
|
|
deadline, ok := ctx.Deadline()
|
|
require.True(t, ok)
|
|
assert.GreaterOrEqual(t, timeout, deadline.Sub(after))
|
|
assert.LessOrEqual(t, timeout, deadline.Sub(before))
|
|
} else {
|
|
assert.Equal(t, tt.maxLifetime, timeout)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPluginEnvironment_Lifecycle(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Start(false)(t.Context()))
|
|
|
|
var stdout bytes.Buffer
|
|
env.ReplaceLogWriter(&stdout, io.Discard)
|
|
|
|
require.NoError(t, env.Exec([]string{"echo", "hello"}, nil, "", "")(t.Context()))
|
|
assert.Equal(t, "hello\n", stdout.String())
|
|
|
|
wait, err := env.IsHealthy(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, time.Duration(0), wait)
|
|
|
|
require.NoError(t, env.Remove()(t.Context()))
|
|
assert.True(t, mock.removeCalled)
|
|
}
|
|
|
|
func TestPluginEnvironment_StartMixedOutput(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.startStdout = "out-line\n"
|
|
mock.startStderr = "err-line\n"
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
env.ReplaceLogWriter(&stdout, &stderr)
|
|
|
|
err := env.Start(false)(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "out-line\n", stdout.String())
|
|
assert.Equal(t, "err-line\n", stderr.String())
|
|
}
|
|
|
|
func TestPluginEnvironment_StartMissingStartComplete(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.startMissingStartComplete = true
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Start(false)(t.Context())
|
|
require.ErrorContains(t, err, "stream ended before completion signal")
|
|
}
|
|
|
|
func TestPluginEnvironment_StartStreamError(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.startStreamError = true
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Start(false)(t.Context())
|
|
require.ErrorContains(t, err, "stream error rpc error: code = Unknown desc = startStreamError")
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecRequest_Defaults(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Exec([]string{"cmd"}, nil, "", "")(t.Context()))
|
|
|
|
// Workdir & Env should contain the defaults from the create request (from newTestEnv)
|
|
require.NotNil(t, mock.execReq.Workdir)
|
|
assert.Equal(t, "/workspace", mock.execReq.Workdir)
|
|
foo, ok := mock.execReq.Env["FOO"]
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "bar", foo)
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecRequest_Overrides(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Exec([]string{"cmd"}, map[string]string{"FOO": "not bar"}, "", "/new-workdir")(t.Context()))
|
|
|
|
// Workdir & Env should contain the overrides from the Exec cmd
|
|
require.NotNil(t, mock.execReq.Workdir)
|
|
assert.Equal(t, "/new-workdir", mock.execReq.Workdir)
|
|
foo, ok := mock.execReq.Env["FOO"]
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "not bar", foo)
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecStderr(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.execStdout = ""
|
|
mock.execStderr = "warning: something\n"
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
var stderr bytes.Buffer
|
|
env.ReplaceLogWriter(io.Discard, &stderr)
|
|
|
|
require.NoError(t, env.Exec([]string{"cmd"}, nil, "", "")(t.Context()))
|
|
assert.Equal(t, "warning: something\n", stderr.String())
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecNonZeroExit(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.execStdout = ""
|
|
mock.execExitCode = 1
|
|
mock.execError = ""
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Exec([]string{"false"}, nil, "", "")(t.Context())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "exit code 1")
|
|
|
|
var execErr *ExecError
|
|
require.ErrorAs(t, err, &execErr)
|
|
assert.Equal(t, int32(1), execErr.ExitCode)
|
|
assert.Equal(t, "", execErr.Message)
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecErrorMessage(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.execStdout = ""
|
|
mock.execError = "exec: command not found"
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Exec([]string{"nonexistent"}, nil, "", "")(t.Context())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "command not found")
|
|
|
|
var execErr *ExecError
|
|
require.ErrorAs(t, err, &execErr)
|
|
assert.Equal(t, "exec: command not found", execErr.Message)
|
|
}
|
|
|
|
func TestPluginEnvironment_Copy(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Copy("/dest", &container.FileEntry{
|
|
Name: "hello.txt",
|
|
Mode: 0o644,
|
|
Body: "hello world",
|
|
})(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "/dest", mock.copyInDest)
|
|
require.NotEmpty(t, mock.copyInData)
|
|
|
|
tr := tar.NewReader(bytes.NewReader(mock.copyInData))
|
|
hdr, err := tr.Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello.txt", hdr.Name)
|
|
assert.Equal(t, int64(0o644), hdr.Mode)
|
|
|
|
body, err := io.ReadAll(tr)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hello world", string(body))
|
|
}
|
|
|
|
func TestPluginEnvironment_CopyTarStream(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
_ = tw.WriteHeader(&tar.Header{Name: "file.txt", Size: 4, Mode: 0o644})
|
|
_, _ = tw.Write([]byte("data"))
|
|
_ = tw.Close()
|
|
|
|
err := env.CopyTarStream(t.Context(), "/tar-dest", &buf)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/tar-dest", mock.copyInDest)
|
|
}
|
|
|
|
func TestPluginEnvironment_GetContainerArchive(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.copyOutData = []byte("tar-archive-bytes")
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
rc, err := env.GetContainerArchive(t.Context(), "/src/file.txt")
|
|
require.NoError(t, err)
|
|
defer rc.Close()
|
|
|
|
data, err := io.ReadAll(rc)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "tar-archive-bytes", string(data))
|
|
}
|
|
|
|
func TestPluginEnvironment_UpdateFromEnv(t *testing.T) {
|
|
var tarBuf bytes.Buffer
|
|
tw := tar.NewWriter(&tarBuf)
|
|
content := []byte("FOO=bar\nNEW=value\n")
|
|
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "env", Mode: 0o644, Size: int64(len(content))}))
|
|
_, _ = tw.Write(content)
|
|
require.NoError(t, tw.Close())
|
|
|
|
mock, conn := startMockServer(t)
|
|
mock.copyOutData = tarBuf.Bytes()
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
envMap := map[string]string{"FOO": "old"}
|
|
err := env.UpdateFromEnv("/path/to/env", &envMap)(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "bar", envMap["FOO"])
|
|
assert.Equal(t, "value", envMap["NEW"])
|
|
}
|
|
|
|
func TestPluginEnvironment_ReplaceLogWriter(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
var w1, w2 bytes.Buffer
|
|
env.stdout = &w1
|
|
env.stderr = &w2
|
|
|
|
var w3, w4 bytes.Buffer
|
|
oldOut, oldErr := env.ReplaceLogWriter(&w3, &w4)
|
|
assert.Equal(t, &w1, oldOut)
|
|
assert.Equal(t, &w2, oldErr)
|
|
|
|
env.mu.Lock()
|
|
assert.Equal(t, &w3, env.stdout)
|
|
assert.Equal(t, &w4, env.stderr)
|
|
env.mu.Unlock()
|
|
}
|
|
|
|
func TestPluginEnvironment_NoOpMethods(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
require.NoError(t, env.Pull(false)(t.Context()))
|
|
require.NoError(t, env.ConnectToNetwork("net")(t.Context()))
|
|
require.NoError(t, env.Close()(t.Context()))
|
|
|
|
envMap := map[string]string{}
|
|
require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))
|
|
}
|
|
|
|
func TestClient_HealthCheckAndCapabilities(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
|
|
healthClient := grpc_health_v1.NewHealthClient(conn)
|
|
resp, err := healthClient.Check(t.Context(), &grpc_health_v1.HealthCheckRequest{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, grpc_health_v1.HealthCheckResponse_SERVING, resp.GetStatus())
|
|
|
|
rpc := pluginv1alpha.NewBackendPluginClient(conn)
|
|
caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test-backend", caps.Name)
|
|
}
|
|
|
|
func TestClient_NewEnvironment(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
rpc := pluginv1alpha.NewBackendPluginClient(conn)
|
|
caps, err := rpc.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
|
|
require.NoError(t, err)
|
|
|
|
c := &Client{conn: conn, rpc: rpc, caps: caps}
|
|
input := &container.NewContainerInput{
|
|
Image: "img:latest",
|
|
Stdout: io.Discard,
|
|
Stderr: io.Discard,
|
|
}
|
|
env := c.NewEnvironment(input, map[string]string{"key": "val"}, "ubuntu-24.04", 15*time.Minute)
|
|
assert.Equal(t, "test-backend", env.BackendID())
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecMixedOutput(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.execStdout = "out-line\n"
|
|
mock.execStderr = "err-line\n"
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
env.ReplaceLogWriter(&stdout, &stderr)
|
|
|
|
err := env.Exec([]string{"cmd"}, map[string]string{"K": "V"}, "user", "/work")(t.Context())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "out-line\n", stdout.String())
|
|
assert.Equal(t, "err-line\n", stderr.String())
|
|
}
|
|
|
|
func TestPluginEnvironment_CopyMultipleFiles(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.Copy(
|
|
"/dest",
|
|
&container.FileEntry{Name: "a.txt", Mode: 0o644, Body: "aaa"},
|
|
&container.FileEntry{Name: "b.txt", Mode: 0o755, Body: "bbb"},
|
|
)(t.Context())
|
|
require.NoError(t, err)
|
|
|
|
tr := tar.NewReader(bytes.NewReader(mock.copyInData))
|
|
names := []string{}
|
|
for {
|
|
hdr, err := tr.Next()
|
|
if err != nil {
|
|
break
|
|
}
|
|
names = append(names, hdr.Name)
|
|
body, _ := io.ReadAll(tr)
|
|
switch hdr.Name {
|
|
case "a.txt":
|
|
assert.Equal(t, "aaa", string(body))
|
|
case "b.txt":
|
|
assert.Equal(t, "bbb", string(body))
|
|
}
|
|
}
|
|
assert.Equal(t, []string{"a.txt", "b.txt"}, names)
|
|
}
|
|
|
|
func TestPluginEnvironment_ServiceAdder(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
|
|
env.AddServiceContainerRaw("db", "postgres:16", map[string]string{"POSTGRES_PASSWORD": "pw"}, []string{"5432"})
|
|
env.AddServiceContainerRaw("cache", "redis:7", nil, []string{"6379"})
|
|
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
req := mock.createReq
|
|
require.Len(t, req.Services, 2)
|
|
assert.Equal(t, "db", req.Services[0].Name)
|
|
assert.Equal(t, "postgres:16", req.Services[0].Image)
|
|
assert.Equal(t, "pw", req.Services[0].Env["POSTGRES_PASSWORD"])
|
|
assert.Equal(t, "cache", req.Services[1].Name)
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecContextCancelled(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
err := env.Exec([]string{"sleep", "10"}, nil, "", "")(ctx)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), fmt.Sprintf("%v", context.Canceled))
|
|
}
|
|
|
|
func TestPluginEnvironment_UpdateFromImageEnv(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.startImageEnv = map[string]string{
|
|
"PATH": "/custom/bin:/usr/bin",
|
|
"GOPATH": "/go",
|
|
"LANG": "C.UTF-8",
|
|
}
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Start(false)(t.Context()))
|
|
|
|
envMap := map[string]string{"LANG": "en_US.UTF-8"}
|
|
require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))
|
|
|
|
assert.Equal(t, "/custom/bin:/usr/bin", envMap["PATH"])
|
|
assert.Equal(t, "/go", envMap["GOPATH"])
|
|
assert.Equal(t, "en_US.UTF-8", envMap["LANG"])
|
|
}
|
|
|
|
func TestPluginEnvironment_UpdateFromImageEnv_MergesPath(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
mock.startImageEnv = map[string]string{
|
|
"PATH": "/image/bin",
|
|
}
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Start(false)(t.Context()))
|
|
|
|
envMap := map[string]string{"PATH": "/existing/bin"}
|
|
require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))
|
|
|
|
assert.Equal(t, "/existing/bin:/image/bin", envMap["PATH"])
|
|
}
|
|
|
|
func TestPluginEnvironment_UpdateFromImageEnv_NilImageEnv(t *testing.T) {
|
|
_, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
require.NoError(t, env.Start(false)(t.Context()))
|
|
|
|
envMap := map[string]string{"FOO": "bar"}
|
|
require.NoError(t, env.UpdateFromImageEnv(&envMap)(t.Context()))
|
|
|
|
assert.Equal(t, map[string]string{"FOO": "bar"}, envMap)
|
|
}
|
|
|
|
// truncatingExecServer sends output but never Done, simulating a plugin crash mid-exec.
|
|
type truncatingExecServer struct {
|
|
pluginv1alpha.UnimplementedBackendPluginServer
|
|
}
|
|
|
|
func (truncatingExecServer) Capabilities(_ context.Context, _ *pluginv1alpha.CapabilitiesRequest) (*pluginv1alpha.CapabilitiesResponse, error) {
|
|
return &pluginv1alpha.CapabilitiesResponse{Name: "trunc"}, nil
|
|
}
|
|
|
|
func (truncatingExecServer) Create(_ context.Context, _ *pluginv1alpha.CreateRequest) (*pluginv1alpha.CreateResponse, error) {
|
|
return &pluginv1alpha.CreateResponse{EnvironmentId: "trunc", RootPath: "/r", ActPath: "/r/act"}, nil
|
|
}
|
|
|
|
func (truncatingExecServer) Exec(_ *pluginv1alpha.ExecRequest, stream grpc.ServerStreamingServer[pluginv1alpha.ExecOutput]) error {
|
|
_ = stream.Send(&pluginv1alpha.ExecOutput{
|
|
Output: &pluginv1alpha.ExecOutput_Data{
|
|
Data: &pluginv1alpha.DataChunk{
|
|
Stream: pluginv1alpha.DataChunk_STDOUT,
|
|
Data: []byte("partial"),
|
|
},
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func TestPluginEnvironment_ExecStreamTruncated(t *testing.T) {
|
|
lis := bufconn.Listen(bufSize)
|
|
srv := grpc.NewServer()
|
|
pluginv1alpha.RegisterBackendPluginServer(srv, truncatingExecServer{})
|
|
go func() { _ = srv.Serve(lis) }()
|
|
t.Cleanup(srv.Stop)
|
|
|
|
conn, err := grpc.NewClient(
|
|
"passthrough:///bufconn",
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return lis.DialContext(ctx)
|
|
}),
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { conn.Close() })
|
|
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err = env.Exec([]string{"cmd"}, nil, "", "")(t.Context())
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "stream ended before completion signal")
|
|
}
|
|
|
|
func TestPluginEnvironment_CopyEmpty(t *testing.T) {
|
|
mock, conn := startMockServer(t)
|
|
env := newTestEnv(t, conn)
|
|
require.NoError(t, env.Create(nil, nil)(t.Context()))
|
|
|
|
err := env.CopyTarStream(t.Context(), "/empty-dest", bytes.NewReader(nil))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/empty-dest", mock.copyInDest)
|
|
}
|