2024-04-10 22:39:55 +02:00
package run
import (
"context"
2025-07-14 20:46:51 +00:00
"errors"
"fmt"
2025-09-16 19:42:04 +00:00
"os"
2024-04-10 22:39:55 +02:00
"testing"
2025-08-20 19:46:03 +00:00
"time"
2024-04-10 22:39:55 +02:00
2025-08-20 19:46:03 +00:00
pingv1 "code.forgejo.org/forgejo/actions-proto/ping/v1"
2025-07-14 20:46:51 +00:00
runnerv1 "code.forgejo.org/forgejo/actions-proto/runner/v1"
2025-09-05 07:29:38 +00:00
"code.forgejo.org/forgejo/runner/v11/internal/pkg/config"
"code.forgejo.org/forgejo/runner/v11/internal/pkg/labels"
"code.forgejo.org/forgejo/runner/v11/internal/pkg/report"
2025-08-20 19:46:03 +00:00
"connectrpc.com/connect"
2025-09-15 15:20:53 +02:00
log "github.com/sirupsen/logrus"
2025-08-20 19:46:03 +00:00
"google.golang.org/protobuf/types/known/structpb"
2025-07-03 19:01:33 +02:00
2024-04-10 22:39:55 +02:00
"github.com/stretchr/testify/assert"
2025-08-20 19:46:03 +00:00
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
2024-04-10 22:39:55 +02:00
)
2025-09-15 15:26:16 +02:00
func init ( ) {
log . SetLevel ( log . TraceLevel )
}
2025-07-14 20:46:51 +00:00
func TestExplainFailedGenerateWorkflow ( t * testing . T ) {
logged := ""
log := func ( message string , args ... any ) {
logged += fmt . Sprintf ( message , args ... ) + "\n"
}
task := & runnerv1 . Task {
WorkflowPayload : [ ] byte ( "on: [push]\njobs:\n" ) ,
}
generateWorkflowError := errors . New ( "message 1\nmessage 2" )
err := explainFailedGenerateWorkflow ( task , log , generateWorkflowError )
assert . Error ( t , err )
2025-07-27 10:05:18 +00:00
assert . Equal ( t , " 1: on: [push]\n 2: jobs:\n 3: \nErrors were found and although they tend to be cryptic the line number they refer to gives a hint as to where the problem might be.\nmessage 1\nmessage 2\n" , logged )
2025-07-14 20:46:51 +00:00
}
2024-04-10 22:39:55 +02:00
func TestLabelUpdate ( t * testing . T ) {
ctx := context . Background ( )
ls := labels . Labels { }
initialLabel , err := labels . Parse ( "testlabel:docker://alpine" )
2024-04-10 23:20:34 +02:00
assert . NoError ( t , err )
2024-04-10 22:39:55 +02:00
ls = append ( ls , initialLabel )
newLs := labels . Labels { }
newLabel , err := labels . Parse ( "next label:host" )
2024-04-10 23:20:34 +02:00
assert . NoError ( t , err )
2024-04-10 22:39:55 +02:00
newLs = append ( newLs , initialLabel )
newLs = append ( newLs , newLabel )
runner := Runner {
labels : ls ,
}
assert . Contains ( t , runner . labels , initialLabel )
assert . NotContains ( t , runner . labels , newLabel )
runner . Update ( ctx , newLs )
assert . Contains ( t , runner . labels , initialLabel )
assert . Contains ( t , runner . labels , newLabel )
}
2025-08-20 19:46:03 +00:00
type forgejoClientMock struct {
mock . Mock
}
func ( m * forgejoClientMock ) Address ( ) string {
args := m . Called ( )
return args . String ( 0 )
}
func ( m * forgejoClientMock ) Insecure ( ) bool {
args := m . Called ( )
return args . Bool ( 0 )
}
func ( m * forgejoClientMock ) Ping ( ctx context . Context , request * connect . Request [ pingv1 . PingRequest ] ) ( * connect . Response [ pingv1 . PingResponse ] , error ) {
args := m . Called ( ctx , request )
if args . Get ( 0 ) == nil {
return nil , args . Error ( 1 )
}
return args . Get ( 0 ) . ( * connect . Response [ pingv1 . PingResponse ] ) , args . Error ( 1 )
}
func ( m * forgejoClientMock ) Register ( ctx context . Context , request * connect . Request [ runnerv1 . RegisterRequest ] ) ( * connect . Response [ runnerv1 . RegisterResponse ] , error ) {
args := m . Called ( ctx , request )
if args . Get ( 0 ) == nil {
return nil , args . Error ( 1 )
}
return args . Get ( 0 ) . ( * connect . Response [ runnerv1 . RegisterResponse ] ) , args . Error ( 1 )
}
func ( m * forgejoClientMock ) Declare ( ctx context . Context , request * connect . Request [ runnerv1 . DeclareRequest ] ) ( * connect . Response [ runnerv1 . DeclareResponse ] , error ) {
args := m . Called ( ctx , request )
if args . Get ( 0 ) == nil {
return nil , args . Error ( 1 )
}
return args . Get ( 0 ) . ( * connect . Response [ runnerv1 . DeclareResponse ] ) , args . Error ( 1 )
}
func ( m * forgejoClientMock ) FetchTask ( ctx context . Context , request * connect . Request [ runnerv1 . FetchTaskRequest ] ) ( * connect . Response [ runnerv1 . FetchTaskResponse ] , error ) {
args := m . Called ( ctx , request )
if args . Get ( 0 ) == nil {
return nil , args . Error ( 1 )
}
return args . Get ( 0 ) . ( * connect . Response [ runnerv1 . FetchTaskResponse ] ) , args . Error ( 1 )
}
func ( m * forgejoClientMock ) UpdateTask ( ctx context . Context , request * connect . Request [ runnerv1 . UpdateTaskRequest ] ) ( * connect . Response [ runnerv1 . UpdateTaskResponse ] , error ) {
args := m . Called ( ctx , request )
if args . Get ( 0 ) == nil {
return nil , args . Error ( 1 )
}
return args . Get ( 0 ) . ( * connect . Response [ runnerv1 . UpdateTaskResponse ] ) , args . Error ( 1 )
}
func ( m * forgejoClientMock ) UpdateLog ( ctx context . Context , request * connect . Request [ runnerv1 . UpdateLogRequest ] ) ( * connect . Response [ runnerv1 . UpdateLogResponse ] , error ) {
// Enable for log output from runs if needed.
// for _, row := range request.Msg.Rows {
// println(fmt.Sprintf("UpdateLog: %q", row.Content))
// }
args := m . Called ( ctx , request )
mockRetval := args . Get ( 0 )
mockError := args . Error ( 1 )
if mockRetval != nil {
return mockRetval . ( * connect . Response [ runnerv1 . UpdateLogResponse ] ) , mockError
} else if mockError != nil {
return nil , mockError
}
// Unless overridden by mock, default to returning indication that logs were received successfully
return connect . NewResponse ( & runnerv1 . UpdateLogResponse {
AckIndex : request . Msg . Index + int64 ( len ( request . Msg . Rows ) ) ,
} ) , nil
}
2025-09-26 10:52:59 +02:00
func TestRunner_getWriteIsolationKey ( t * testing . T ) {
t . Run ( "push" , func ( t * testing . T ) {
key , err := getWriteIsolationKey ( t . Context ( ) , "push" , "whatever" , nil )
require . NoError ( t , err )
assert . Empty ( t , key )
} )
t . Run ( "pull_request synchronized key is ref" , func ( t * testing . T ) {
expectedKey := "refs/pull/1/head"
actualKey , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , expectedKey , map [ string ] any {
"action" : "synchronized" ,
} )
require . NoError ( t , err )
assert . Equal ( t , expectedKey , actualKey )
} )
t . Run ( "pull_request synchronized ref is invalid" , func ( t * testing . T ) {
invalidKey := "refs/is/invalid"
key , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , invalidKey , map [ string ] any {
"action" : "synchronized" ,
} )
require . Empty ( t , key )
assert . ErrorContains ( t , err , invalidKey )
} )
t . Run ( "pull_request closed and not merged key is ref" , func ( t * testing . T ) {
expectedKey := "refs/pull/1/head"
actualKey , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , expectedKey , map [ string ] any {
"action" : "closed" ,
"pull_request" : map [ string ] any {
"merged" : false ,
} ,
} )
require . NoError ( t , err )
assert . Equal ( t , expectedKey , actualKey )
} )
t . Run ( "pull_request closed and merged key is empty" , func ( t * testing . T ) {
key , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , "whatever" , map [ string ] any {
"action" : "closed" ,
"pull_request" : map [ string ] any {
"merged" : true ,
} ,
} )
require . NoError ( t , err )
assert . Empty ( t , key )
} )
t . Run ( "pull_request missing event.pull_request" , func ( t * testing . T ) {
key , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , "whatever" , map [ string ] any {
"action" : "closed" ,
} )
require . Empty ( t , key )
assert . ErrorContains ( t , err , "event.pull_request is not a map" )
} )
t . Run ( "pull_request missing event.pull_request.merge" , func ( t * testing . T ) {
key , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , "whatever" , map [ string ] any {
"action" : "closed" ,
"pull_request" : map [ string ] any { } ,
} )
require . Empty ( t , key )
assert . ErrorContains ( t , err , "event.pull_request.merged is not a bool" )
} )
t . Run ( "pull_request with event.pull_request.merge of an unexpected type" , func ( t * testing . T ) {
key , err := getWriteIsolationKey ( t . Context ( ) , "pull_request" , "whatever" , map [ string ] any {
"action" : "closed" ,
"pull_request" : map [ string ] any {
"merged" : "string instead of bool" ,
} ,
} )
require . Empty ( t , key )
assert . ErrorContains ( t , err , "not a bool but string" )
} )
}
2025-08-20 19:46:03 +00:00
func TestRunnerCacheConfiguration ( t * testing . T ) {
if testing . Short ( ) {
t . Skip ( "skipping integration test" )
}
forgejoClient := & forgejoClientMock { }
forgejoClient . On ( "Address" ) . Return ( "https://127.0.0.1:8080" ) // not expected to be used in this test
forgejoClient . On ( "UpdateLog" , mock . Anything , mock . Anything ) . Return ( nil , nil )
forgejoClient . On ( "UpdateTask" , mock . Anything , mock . Anything ) .
Return ( connect . NewResponse ( & runnerv1 . UpdateTaskResponse { } ) , nil )
runner := NewRunner (
& config . Config {
Cache : config . Cache {
// Note that this test requires that containers on the local dev environment can access localhost to
// reach the cache proxy, and that the cache proxy can access localhost to reach the cache, both of
// which are embedded servers that the Runner will start. If any specific firewall config is needed
// it's easier to do that with statically configured ports, so...
Port : 40713 ,
ProxyPort : 40714 ,
Dir : t . TempDir ( ) ,
} ,
Host : config . Host {
WorkdirParent : t . TempDir ( ) ,
} ,
} ,
& config . Registration {
Labels : [ ] string { "ubuntu-latest:docker://code.forgejo.org/oci/node:20-bookworm" } ,
} ,
forgejoClient )
require . NotNil ( t , runner )
// Must set up cache for our test
require . NotNil ( t , runner . cacheProxy )
2025-08-17 16:36:14 -06:00
runWorkflow := func ( ctx context . Context , cancel context . CancelFunc , yamlContent , eventName , ref , description string ) {
2025-08-20 19:46:03 +00:00
task := & runnerv1 . Task {
WorkflowPayload : [ ] byte ( yamlContent ) ,
Context : & structpb . Struct {
Fields : map [ string ] * structpb . Value {
"token" : structpb . NewStringValue ( "some token here" ) ,
"forgejo_default_actions_url" : structpb . NewStringValue ( "https://data.forgejo.org" ) ,
"repository" : structpb . NewStringValue ( "runner" ) ,
"event_name" : structpb . NewStringValue ( eventName ) ,
"ref" : structpb . NewStringValue ( ref ) ,
} ,
} ,
}
reporter := report . NewReporter ( ctx , cancel , forgejoClient , task , time . Second )
err := runner . run ( ctx , task , reporter )
reporter . Close ( nil )
require . NoError ( t , err , description )
}
2025-08-17 16:36:14 -06:00
t . Run ( "Cache accessible" , func ( t * testing . T ) {
2025-08-21 09:21:20 -06:00
ctx , cancel := context . WithCancel ( t . Context ( ) )
2025-08-17 16:36:14 -06:00
defer cancel ( )
// Step 1: Populate shared cache with push workflow
populateYaml := `
2025-08-20 19:46:03 +00:00
name : Cache Testing Action
on :
push :
jobs :
job - cache - check - 1 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 1
- run : |
mkdir - p cache_path_1
echo "Hello from push workflow!" > cache_path_1 / cache_content_1
`
2025-08-17 16:36:14 -06:00
runWorkflow ( ctx , cancel , populateYaml , "push" , "refs/heads/main" , "step 1: push cache populate expected to succeed" )
2025-08-20 19:46:03 +00:00
2025-08-17 16:36:14 -06:00
// Step 2: Validate that cache is accessible; mostly a sanity check that the test environment and mock context
// provides everything needed for the cache setup.
checkYaml := `
2025-08-20 19:46:03 +00:00
name : Cache Testing Action
on :
push :
jobs :
job - cache - check - 2 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 1
- run : |
[ [ - f cache_path_1 / cache_content_1 ] ] && echo "Step 2: cache file found." || echo "Step 2: cache file missing!"
[ [ - f cache_path_1 / cache_content_1 ] ] || exit 1
`
2025-08-17 16:36:14 -06:00
runWorkflow ( ctx , cancel , checkYaml , "push" , "refs/heads/main" , "step 2: push cache check expected to succeed" )
} )
t . Run ( "PR cache pollution prevented" , func ( t * testing . T ) {
2025-08-21 09:21:20 -06:00
ctx , cancel := context . WithCancel ( t . Context ( ) )
2025-08-17 16:36:14 -06:00
defer cancel ( )
// Step 1: Populate shared cache with push workflow
populateYaml := `
name : Cache Testing Action
on :
push :
jobs :
job - cache - check - 1 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 1
- run : |
mkdir - p cache_path_1
echo "Hello from push workflow!" > cache_path_1 / cache_content_1
`
runWorkflow ( ctx , cancel , populateYaml , "push" , "refs/heads/main" , "step 1: push cache populate expected to succeed" )
// Step 2: Check if pull_request can read push cache, should be available as it's a trusted cache.
checkPRYaml := `
name : Cache Testing Action
on :
pull_request :
jobs :
job - cache - check - 2 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 1
- run : |
[ [ - f cache_path_1 / cache_content_1 ] ] && echo "Step 2: cache file found." || echo "Step 2: cache file missing!"
[ [ - f cache_path_1 / cache_content_1 ] ] || exit 1
`
runWorkflow ( ctx , cancel , checkPRYaml , "pull_request" , "refs/pull/1234/head" , "step 2: PR should read push cache" )
// Step 3: Pull request writes to cache; here we need to use a new cache key because we'll get a cache-hit like we
// did in step #2 if we keep the same key, and then the cache contents won't be updated.
populatePRYaml := `
name : Cache Testing Action
on :
pull_request :
jobs :
job - cache - check - 3 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 2
- run : |
mkdir - p cache_path_1
echo "Hello from PR workflow!" > cache_path_1 / cache_content_2
`
runWorkflow ( ctx , cancel , populatePRYaml , "pull_request" , "refs/pull/1234/head" , "step 3: PR cache populate expected to succeed" )
// Step 4: Check if pull_request can read its own cache written by step #3.
checkPRKey2Yaml := `
name : Cache Testing Action
on :
pull_request :
jobs :
job - cache - check - 4 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 2
- run : |
[ [ - f cache_path_1 / cache_content_2 ] ] && echo "Step 4 cache file found." || echo "Step 4 cache file missing!"
[ [ - f cache_path_1 / cache_content_2 ] ] || exit 1
`
runWorkflow ( ctx , cancel , checkPRKey2Yaml , "pull_request" , "refs/pull/1234/head" , "step 4: PR should read its own cache" )
// Step 5: Check that the push workflow cannot access the isolated cache that was written by the pull_request in
// step #3, ensuring that it's not possible to pollute the cache by predicting cache keys.
checkKey2Yaml := `
name : Cache Testing Action
on :
push :
jobs :
job - cache - check - 6 :
runs - on : ubuntu - latest
steps :
- uses : actions / cache @ v4
with :
path : cache_path_1
key : cache - key - 2
- run : |
[ [ - f cache_path_1 / cache_content_2 ] ] && echo "Step 5 cache file found, oh no!" || echo "Step 5: cache file missing as expected."
[ [ - f cache_path_1 / cache_content_2 ] ] && exit 1 || exit 0
`
runWorkflow ( ctx , cancel , checkKey2Yaml , "push" , "refs/heads/main" , "step 5: push cache should not be polluted by PR" )
} )
2025-08-20 19:46:03 +00:00
}
2025-09-15 15:20:53 +02:00
func TestRunnerLXC ( t * testing . T ) {
if testing . Short ( ) {
t . Skip ( "skipping integration test" )
}
forgejoClient := & forgejoClientMock { }
forgejoClient . On ( "Address" ) . Return ( "https://127.0.0.1:8080" ) // not expected to be used in this test
forgejoClient . On ( "UpdateLog" , mock . Anything , mock . Anything ) . Return ( nil , nil )
forgejoClient . On ( "UpdateTask" , mock . Anything , mock . Anything ) .
Return ( connect . NewResponse ( & runnerv1 . UpdateTaskResponse { } ) , nil )
2025-09-16 19:42:04 +00:00
workdirParent := t . TempDir ( )
2025-09-15 15:20:53 +02:00
runner := NewRunner (
& config . Config {
Log : config . Log {
JobLevel : "trace" ,
} ,
Host : config . Host {
2025-09-16 19:42:04 +00:00
WorkdirParent : workdirParent ,
2025-09-15 15:20:53 +02:00
} ,
} ,
& config . Registration {
Labels : [ ] string { "lxc:lxc://debian:bookworm" } ,
} ,
forgejoClient )
require . NotNil ( t , runner )
runWorkflow := func ( ctx context . Context , cancel context . CancelFunc , yamlContent , eventName , ref , description string ) {
task := & runnerv1 . Task {
WorkflowPayload : [ ] byte ( yamlContent ) ,
Context : & structpb . Struct {
Fields : map [ string ] * structpb . Value {
"token" : structpb . NewStringValue ( "some token here" ) ,
"forgejo_default_actions_url" : structpb . NewStringValue ( "https://data.forgejo.org" ) ,
"repository" : structpb . NewStringValue ( "runner" ) ,
"event_name" : structpb . NewStringValue ( eventName ) ,
"ref" : structpb . NewStringValue ( ref ) ,
} ,
} ,
}
reporter := report . NewReporter ( ctx , cancel , forgejoClient , task , time . Second )
err := runner . run ( ctx , task , reporter )
reporter . Close ( nil )
require . NoError ( t , err , description )
2025-09-16 19:42:04 +00:00
// verify there are no leftovers
assertDirectoryEmpty := func ( t * testing . T , dir string ) {
f , err := os . Open ( dir )
require . NoError ( t , err )
defer f . Close ( )
names , err := f . Readdirnames ( - 1 )
require . NoError ( t , err )
assert . Empty ( t , names )
}
assertDirectoryEmpty ( t , workdirParent )
2025-09-15 15:20:53 +02:00
}
t . Run ( "OK" , func ( t * testing . T ) {
ctx , cancel := context . WithCancel ( t . Context ( ) )
defer cancel ( )
workflow := `
on :
push :
jobs :
job :
runs - on : lxc
steps :
- run : echo OK
`
runWorkflow ( ctx , cancel , workflow , "push" , "refs/heads/main" , "OK" )
} )
}