mirror of
https://code.forgejo.org/forgejo/runner.git
synced 2026-10-24 16:59:19 +03:00
Fixes #1678. Rather than returning a stream of chunks that may have stdout/stderr data, and may have an exit code, and may have an error message, separates them into three different messages and uses protobuf's `oneof` message type to allow one-and-only-one to be streamed at a time. Reviewed-on: https://code.forgejo.org/forgejo/runner/pulls/1705 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.code.forgejo.org>
393 lines
9.9 KiB
Go
393 lines
9.9 KiB
Go
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package testplugin
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"testing"
|
|
|
|
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/test/bufconn"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const bufSize = 1024 * 1024
|
|
|
|
func startServer(t *testing.T) (pluginv1alpha.BackendPluginClient, *Server) {
|
|
t.Helper()
|
|
|
|
lis := bufconn.Listen(bufSize)
|
|
srv := grpc.NewServer()
|
|
ts := New()
|
|
pluginv1alpha.RegisterBackendPluginServer(srv, ts)
|
|
t.Cleanup(func() {
|
|
srv.Stop()
|
|
ts.Cleanup()
|
|
})
|
|
|
|
go func() { _ = srv.Serve(lis) }()
|
|
|
|
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 pluginv1alpha.NewBackendPluginClient(conn), ts
|
|
}
|
|
|
|
func createEnv(t *testing.T, client pluginv1alpha.BackendPluginClient) string {
|
|
t.Helper()
|
|
resp, err := client.Create(t.Context(), &pluginv1alpha.CreateRequest{
|
|
Image: "test:latest",
|
|
Name: "test",
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = client.Start(t.Context(), &pluginv1alpha.StartRequest{EnvironmentId: resp.GetEnvironmentId()})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/shared", resp.GetRootPath())
|
|
assert.Equal(t, "/shared/act", resp.GetActPath())
|
|
assert.Equal(t, "/shared/toolcache", resp.GetToolCachePath())
|
|
assert.Equal(t, "/tmp", resp.GetTempPath())
|
|
return resp.GetEnvironmentId()
|
|
}
|
|
|
|
func TestCapabilities(t *testing.T) {
|
|
client, _ := startServer(t)
|
|
caps, err := client.Capabilities(t.Context(), &pluginv1alpha.CapabilitiesRequest{})
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "test", caps.GetName())
|
|
}
|
|
|
|
func TestCreateAndRemove(t *testing.T) {
|
|
client, ts := startServer(t)
|
|
envID := createEnv(t, client)
|
|
assert.Contains(t, ts.envs, envID)
|
|
|
|
_, err := client.Remove(t.Context(), &pluginv1alpha.RemoveRequest{EnvironmentId: envID})
|
|
require.NoError(t, err)
|
|
assert.NotContains(t, ts.envs, envID)
|
|
}
|
|
|
|
func TestExec_Echo(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("echo command differs on Windows")
|
|
}
|
|
client, _ := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
stream, err := client.Exec(t.Context(), &pluginv1alpha.ExecRequest{
|
|
EnvironmentId: envID,
|
|
Command: []string{"echo", "hello world"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var stdout bytes.Buffer
|
|
exitCode := int32(-1) // initialize to non-zero so that assert.Equal validates it is set to 0
|
|
for {
|
|
out, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
breakLoop := false
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.ExecOutput_Data:
|
|
if v.Data.GetStream() == pluginv1alpha.DataChunk_STDOUT {
|
|
stdout.Write(v.Data.GetData())
|
|
}
|
|
case *pluginv1alpha.ExecOutput_ExecComplete:
|
|
exitCode = v.ExecComplete.GetExitCode()
|
|
breakLoop = true
|
|
default:
|
|
assert.Failf(t, "unexpected output type", "did not expect %#v", v)
|
|
}
|
|
if breakLoop {
|
|
break
|
|
}
|
|
}
|
|
|
|
assert.Equal(t, int32(0), exitCode)
|
|
assert.Equal(t, "hello world\n", stdout.String())
|
|
}
|
|
|
|
func TestExec_FailingCommand(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("false command differs on Windows")
|
|
}
|
|
client, _ := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
stream, err := client.Exec(t.Context(), &pluginv1alpha.ExecRequest{
|
|
EnvironmentId: envID,
|
|
Command: []string{"false"},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
exitCode := int32(-1) // initialize to non-zero so that assert.Equal validates it is set to 0
|
|
for {
|
|
out, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
breakLoop := false
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.ExecOutput_Data:
|
|
case *pluginv1alpha.ExecOutput_ExecComplete:
|
|
exitCode = v.ExecComplete.GetExitCode()
|
|
breakLoop = true
|
|
default:
|
|
assert.Failf(t, "unexpected output type", "did not expect %#v", v)
|
|
}
|
|
if breakLoop {
|
|
break
|
|
}
|
|
}
|
|
assert.NotEqual(t, int32(0), exitCode)
|
|
}
|
|
|
|
func TestExec_EnvVars(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("env command differs on Windows")
|
|
}
|
|
client, _ := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
stream, err := client.Exec(t.Context(), &pluginv1alpha.ExecRequest{
|
|
EnvironmentId: envID,
|
|
Command: []string{"sh", "-c", "echo $TEST_VAR $EXTRA"},
|
|
Env: map[string]string{
|
|
"TEST_VAR": "hello",
|
|
"EXTRA": "world",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var stdout bytes.Buffer
|
|
for {
|
|
out, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
breakLoop := false
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.ExecOutput_Data:
|
|
if v.Data.GetStream() == pluginv1alpha.DataChunk_STDOUT {
|
|
stdout.Write(v.Data.GetData())
|
|
}
|
|
case *pluginv1alpha.ExecOutput_ExecComplete:
|
|
breakLoop = true
|
|
default:
|
|
assert.Failf(t, "unexpected output type", "did not expect %#v", v)
|
|
}
|
|
if breakLoop {
|
|
break
|
|
}
|
|
}
|
|
assert.Equal(t, "hello world\n", stdout.String())
|
|
}
|
|
|
|
func TestExec_TranslatesEnvironmentPaths(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell command differs on Windows")
|
|
}
|
|
client, _ := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
stream, err := client.Exec(t.Context(), &pluginv1alpha.ExecRequest{
|
|
EnvironmentId: envID,
|
|
Command: []string{"sh", "-c", `mkdir -p "$(dirname "$FORGEJO_OUTPUT")" && echo "result=value" >> "$FORGEJO_OUTPUT"`},
|
|
Env: map[string]string{
|
|
"FORGEJO_OUTPUT": "/shared/act/workflow/outputcmd.txt",
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
for {
|
|
out, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
breakLoop := false
|
|
switch v := out.GetOutput().(type) {
|
|
case *pluginv1alpha.ExecOutput_Data:
|
|
case *pluginv1alpha.ExecOutput_ExecComplete:
|
|
assert.Equal(t, int32(0), v.ExecComplete.GetExitCode())
|
|
breakLoop = true
|
|
default:
|
|
assert.Failf(t, "unexpected output type", "did not expect %#v", v)
|
|
}
|
|
if breakLoop {
|
|
break
|
|
}
|
|
}
|
|
|
|
copyOutStream, err := client.CopyOut(t.Context(), &pluginv1alpha.CopyOutRequest{
|
|
EnvironmentId: envID,
|
|
SrcPath: "/shared/act/workflow/outputcmd.txt",
|
|
})
|
|
require.NoError(t, err)
|
|
var tarBuf bytes.Buffer
|
|
for {
|
|
chunk, err := copyOutStream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
tarBuf.Write(chunk.GetData())
|
|
}
|
|
tr := tar.NewReader(&tarBuf)
|
|
_, err = tr.Next()
|
|
require.NoError(t, err)
|
|
content, err := io.ReadAll(tr)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "result=value\n", string(content))
|
|
}
|
|
|
|
func TestCopyIn_AndCopyOut(t *testing.T) {
|
|
client, _ := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
var tarBuf bytes.Buffer
|
|
tw := tar.NewWriter(&tarBuf)
|
|
content := []byte("test content 123")
|
|
_ = tw.WriteHeader(&tar.Header{Name: "test.txt", Mode: 0o644, Size: int64(len(content))})
|
|
_, _ = tw.Write(content)
|
|
tw.Close()
|
|
|
|
copyInStream, err := client.CopyIn(t.Context())
|
|
require.NoError(t, err)
|
|
err = copyInStream.Send(&pluginv1alpha.CopyInChunk{
|
|
EnvironmentId: proto.String(envID),
|
|
DestPath: proto.String("/shared/workdir"),
|
|
Data: tarBuf.Bytes(),
|
|
})
|
|
require.NoError(t, err)
|
|
_, err = copyInStream.CloseAndRecv()
|
|
require.NoError(t, err)
|
|
|
|
copyOutStream, err := client.CopyOut(t.Context(), &pluginv1alpha.CopyOutRequest{
|
|
EnvironmentId: envID,
|
|
SrcPath: "/shared/workdir/test.txt",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
var outBuf bytes.Buffer
|
|
for {
|
|
chunk, err := copyOutStream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
outBuf.Write(chunk.GetData())
|
|
}
|
|
|
|
tr := tar.NewReader(&outBuf)
|
|
hdr, err := tr.Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "test.txt", hdr.Name)
|
|
data, _ := io.ReadAll(tr)
|
|
assert.Equal(t, "test content 123", string(data))
|
|
}
|
|
|
|
func TestCopyPathsAreScopedToEnvironment(t *testing.T) {
|
|
client, _ := startServer(t)
|
|
firstEnvID := createEnv(t, client)
|
|
secondEnvID := createEnv(t, client)
|
|
|
|
writeFile := func(envID, content string) {
|
|
t.Helper()
|
|
var tarBuf bytes.Buffer
|
|
tw := tar.NewWriter(&tarBuf)
|
|
require.NoError(t, tw.WriteHeader(&tar.Header{Name: "value.txt", Mode: 0o644, Size: int64(len(content))}))
|
|
_, err := tw.Write([]byte(content))
|
|
require.NoError(t, err)
|
|
require.NoError(t, tw.Close())
|
|
|
|
stream, err := client.CopyIn(t.Context())
|
|
require.NoError(t, err)
|
|
require.NoError(t, stream.Send(&pluginv1alpha.CopyInChunk{
|
|
EnvironmentId: proto.String(envID),
|
|
DestPath: proto.String("/shared/workdir"),
|
|
Data: tarBuf.Bytes(),
|
|
}))
|
|
_, err = stream.CloseAndRecv()
|
|
require.NoError(t, err)
|
|
}
|
|
readFile := func(envID string) string {
|
|
t.Helper()
|
|
stream, err := client.CopyOut(t.Context(), &pluginv1alpha.CopyOutRequest{
|
|
EnvironmentId: envID,
|
|
SrcPath: "/shared/workdir/value.txt",
|
|
})
|
|
require.NoError(t, err)
|
|
var tarBuf bytes.Buffer
|
|
for {
|
|
chunk, err := stream.Recv()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
require.NoError(t, err)
|
|
tarBuf.Write(chunk.GetData())
|
|
}
|
|
tr := tar.NewReader(&tarBuf)
|
|
_, err = tr.Next()
|
|
require.NoError(t, err)
|
|
content, err := io.ReadAll(tr)
|
|
require.NoError(t, err)
|
|
return string(content)
|
|
}
|
|
|
|
writeFile(firstEnvID, "first")
|
|
writeFile(secondEnvID, "second")
|
|
|
|
assert.Equal(t, "first", readFile(firstEnvID))
|
|
assert.Equal(t, "second", readFile(secondEnvID))
|
|
}
|
|
|
|
func TestRemove_NotFound(t *testing.T) {
|
|
client, _ := startServer(t)
|
|
_, err := client.Remove(t.Context(), &pluginv1alpha.RemoveRequest{EnvironmentId: "bogus"})
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRemove_CleansUpTempDir(t *testing.T) {
|
|
client, ts := startServer(t)
|
|
envID := createEnv(t, client)
|
|
|
|
ts.mu.Lock()
|
|
root := ts.envs[envID].root
|
|
ts.mu.Unlock()
|
|
|
|
_, err := os.Stat(root)
|
|
require.NoError(t, err)
|
|
|
|
_, err = client.Remove(t.Context(), &pluginv1alpha.RemoveRequest{EnvironmentId: envID})
|
|
require.NoError(t, err)
|
|
|
|
_, err = os.Stat(root)
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|