1
0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2026-10-24 16:59:19 +03:00
Files
runner/act/plugin/testplugin/server_test.go
Mathieu Fenniak fef7d81f38 fix(plugin): clarify content of ExecOutput to having one type of data at a time (#1705)
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>
2026-08-24 19:45:04 +00:00

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))
}