sourcegraph/lib/batches/batch_spec_test.go
Erik Seliger d04e821ceb
Experiment: Natively run SSBC in docker (#44034)
This adds an experimental code path that I will use to test a docker-only execution mode for server-side batch changes. This code path is never executed for customers until we make the switch when we deem it ready. This will allow me to dogfood this while it's not available to customer instances yet.

Ultimately, the goal of this is to make executors simply be "the job runner platform through a generic interface". Today, this depends on src-cli to do a good bunch of the work. This is a blocker for going full docker-based with executors, which will ultimately be a requirement on the road to k8s-based executors.

As this removes the dependency on src-cli, nothing but the job interface and API endpoints tie executor and Sourcegraph instance together. Ultimately, this will allow us to support larger version spans between the two (pending executors going GA and being feature-complete).

Known issues/limitations:

Steps skipped in between steps that run don't work yet
Skipping steps dynamically is inefficient as we cannot tell the executor to skip a step IF X, so we replace the script by exit 0
It is unclear if all variants of file mounts still work. Basic cases do work. Files used to be read-only in src-cli, they aren't now, but content is still reset in between steps.
The assumption that everything operates in /work is broken here, because we need to use what executors give us to persist out-of-repo state in between containers (like the step result from the previous step)
It is unclear if workspace mounts work
Cache keys are not correctly computed if using workspace mounts - the metadataretriever is nil
We still use log outputs to transfer the AfterStepResults to the Sourcegraph instance, this should finally become an artifact instead. Then, we don't have to rely on the execution_log_entires anymore and can theoretically prune those after some time. This column is currently growing indefinitely.
It depends on tee being available in the docker images to capture the cmd.stdout/cmd.stderr properly for template variable rendering
Env-vars are not rendered in their evaluated form post-execution
File permissions are unclear and might be similarly broken to how they are now - or even worse
Disclaimer: It's not feature complete today! But it is also not hitting any default code paths either. As development on this goes on, we can eventually remove the feature flag and run the new job format on all instances. This PR handles fallback of rendering old records correctly in the UI already.
2022-11-10 00:20:43 +01:00

418 lines
9.5 KiB
Go

package batches
import (
"fmt"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)
func TestParseBatchSpec(t *testing.T) {
t.Run("valid", func(t *testing.T) {
const spec = `
name: hello-world
description: Add Hello World to READMEs
on:
- repositoriesMatchingQuery: file:README.md
steps:
- run: echo Hello World | tee -a $(find -name README.md)
container: alpine:3
changesetTemplate:
title: Hello World
body: My first batch change!
branch: hello-world
commit:
message: Append Hello World to all README.md files
published: false
`
_, err := ParseBatchSpec([]byte(spec))
if err != nil {
t.Fatalf("parsing valid spec returned error: %s", err)
}
})
t.Run("missing changesetTemplate", func(t *testing.T) {
const spec = `
name: hello-world
description: Add Hello World to READMEs
on:
- repositoriesMatchingQuery: file:README.md
steps:
- run: echo Hello World | tee -a $(find -name README.md)
container: alpine:3
`
_, err := ParseBatchSpec([]byte(spec))
if err == nil {
t.Fatal("no error returned")
}
wantErr := `batch spec includes steps but no changesetTemplate`
haveErr := err.Error()
if haveErr != wantErr {
t.Fatalf("wrong error. want=%q, have=%q", wantErr, haveErr)
}
})
t.Run("invalid batch change name", func(t *testing.T) {
const spec = `
name: this name is invalid cause it contains whitespace
description: Add Hello World to READMEs
on:
- repositoriesMatchingQuery: file:README.md
steps:
- run: echo Hello World | tee -a $(find -name README.md)
container: alpine:3
changesetTemplate:
title: Hello World
body: My first batch change!
branch: hello-world
commit:
message: Append Hello World to all README.md files
published: false
`
_, err := ParseBatchSpec([]byte(spec))
if err == nil {
t.Fatal("no error returned")
}
// We expect this error to be user-friendly, which is why we test for
// it specifically here.
wantErr := `The batch change name can only contain word characters, dots and dashes. No whitespace or newlines allowed.`
haveErr := err.Error()
if haveErr != wantErr {
t.Fatalf("wrong error. want=%q, have=%q", wantErr, haveErr)
}
})
t.Run("parsing if attribute", func(t *testing.T) {
const specTemplate = `
name: hello-world
description: Add Hello World to READMEs
on:
- repositoriesMatchingQuery: file:README.md
steps:
- run: echo Hello World | tee -a $(find -name README.md)
if: %s
container: alpine:3
changesetTemplate:
title: Hello World
body: My first batch change!
branch: hello-world
commit:
message: Append Hello World to all README.md files
published: false
`
for _, tt := range []struct {
raw string
want string
}{
{raw: `"true"`, want: "true"},
{raw: `"false"`, want: "false"},
{raw: `true`, want: "true"},
{raw: `false`, want: "false"},
{raw: `"${{ foobar }}"`, want: "${{ foobar }}"},
{raw: `${{ foobar }}`, want: "${{ foobar }}"},
{raw: `foobar`, want: "foobar"},
} {
spec := fmt.Sprintf(specTemplate, tt.raw)
batchSpec, err := ParseBatchSpec([]byte(spec))
if err != nil {
t.Fatal(err)
}
if batchSpec.Steps[0].IfCondition() != tt.want {
t.Fatalf("wrong IfCondition. want=%q, got=%q", tt.want, batchSpec.Steps[0].IfCondition())
}
}
})
t.Run("uses conflicting branch attributes", func(t *testing.T) {
const spec = `
name: hello-world
description: Add Hello World to READMEs
on:
- repository: github.com/foo/bar
branch: foo
branches: [bar]
steps:
- run: echo Hello World | tee -a $(find -name README.md)
container: alpine:3
changesetTemplate:
title: Hello World
body: My first batch change!
branch: hello-world
commit:
message: Append Hello World to all README.md files
published: false
`
_, err := ParseBatchSpec([]byte(spec))
if err == nil {
t.Fatal("no error returned")
}
wantErr := `3 errors occurred:
* on.0: Must validate one and only one schema (oneOf)
* on.0: Must validate at least one schema (anyOf)
* on.0: Must validate one and only one schema (oneOf)`
haveErr := err.Error()
if haveErr != wantErr {
t.Fatalf("wrong error. want=%q, have=%q", wantErr, haveErr)
}
})
t.Run("mount path contains comma", func(t *testing.T) {
const spec = `
name: test-spec
description: A test spec
steps:
- run: /tmp/sample.sh
container: alpine:3
mount:
- path: /foo,bar/
mountpoint: /tmp
changesetTemplate:
title: Test Mount
body: Test a mounted path
branch: test
commit:
message: Test
`
_, err := ParseBatchSpec([]byte(spec))
assert.Equal(t, "step 1 mount path contains invalid characters", err.Error())
})
t.Run("mount mountpoint contains comma", func(t *testing.T) {
const spec = `
name: test-spec
description: A test spec
steps:
- run: /tmp/foo,bar/sample.sh
container: alpine:3
mount:
- path: /valid/sample.sh
mountpoint: /tmp/foo,bar/sample.sh
changesetTemplate:
title: Test Mount
body: Test a mounted path
branch: test
commit:
message: Test
`
_, err := ParseBatchSpec([]byte(spec))
assert.Equal(t, "step 1 mount mountpoint contains invalid characters", err.Error())
})
}
func TestOnQueryOrRepository_Branches(t *testing.T) {
t.Run("success", func(t *testing.T) {
for name, tc := range map[string]struct {
input *OnQueryOrRepository
want []string
}{
"no branches": {
input: &OnQueryOrRepository{},
want: nil,
},
"single branch": {
input: &OnQueryOrRepository{Branch: "foo"},
want: []string{"foo"},
},
"single branch, non-nil but empty branches": {
input: &OnQueryOrRepository{
Branch: "foo",
Branches: []string{},
},
want: []string{"foo"},
},
"multiple branches": {
input: &OnQueryOrRepository{
Branches: []string{"foo", "bar"},
},
want: []string{"foo", "bar"},
},
} {
t.Run(name, func(t *testing.T) {
have, err := tc.input.GetBranches()
assert.Nil(t, err)
assert.Equal(t, tc.want, have)
})
}
})
t.Run("error", func(t *testing.T) {
_, err := (&OnQueryOrRepository{
Branch: "foo",
Branches: []string{"bar"},
}).GetBranches()
assert.Equal(t, ErrConflictingBranches, err)
})
}
func TestSkippedStepsForRepo(t *testing.T) {
tests := map[string]struct {
spec *BatchSpec
wantSkipped []int
}{
"no if": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1"},
},
},
wantSkipped: []int{},
},
"if has static true value": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1", If: "true"},
},
},
wantSkipped: []int{},
},
"one of many steps has if with static true value": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1"},
{Run: "echo 2", If: "true"},
{Run: "echo 3"},
},
},
wantSkipped: []int{},
},
"if has static non-true value": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1", If: "this is not true"},
},
},
wantSkipped: []int{0},
},
"one of many steps has if with static non-true value": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1"},
{Run: "echo 2", If: "every type system needs generics"},
{Run: "echo 3"},
},
},
wantSkipped: []int{1},
},
"if expression that can be partially evaluated to true": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1", If: `${{ matches repository.name "github.com/sourcegraph/src*" }}`},
},
},
wantSkipped: []int{},
},
"if expression that can be partially evaluated to false": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1", If: `${{ matches repository.name "horse" }}`},
},
},
wantSkipped: []int{0},
},
"one of many steps has if expression that can be evaluated to false": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1"},
{Run: "echo 2", If: `${{ matches repository.name "horse" }}`},
{Run: "echo 3"},
},
},
wantSkipped: []int{1},
},
"if expression that can NOT be partially evaluated": {
spec: &BatchSpec{
Steps: []Step{
{Run: "echo 1", If: `${{ eq outputs.value "foobar" }}`},
},
},
wantSkipped: []int{},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
haveSkipped, err := SkippedStepsForRepo(tt.spec, "github.com/sourcegraph/src-cli", []string{})
if err != nil {
t.Fatalf("unexpected err: %s", err)
}
want := tt.wantSkipped
sort.Sort(sortableInt(want))
have := make([]int, 0, len(haveSkipped))
for s := range haveSkipped {
have = append(have, s)
}
sort.Sort(sortableInt(have))
if diff := cmp.Diff(have, want); diff != "" {
t.Fatal(diff)
}
})
}
}
type sortableInt []int
func (s sortableInt) Len() int { return len(s) }
func (s sortableInt) Less(i, j int) bool { return s[i] < s[j] }
func (s sortableInt) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func TestBatchSpec_RequiredEnvVars(t *testing.T) {
for name, tc := range map[string]struct {
in string
want []string
}{
"no steps": {
in: `steps:`,
want: []string{},
},
"no env vars": {
in: `steps: [run: asdf]`,
want: []string{},
},
"static variable": {
in: `steps: [{run: asdf, env: [a: b]}]`,
want: []string{},
},
"dynamic variable": {
in: `steps: [{run: asdf, env: [a]}]`,
want: []string{"a"},
},
} {
t.Run(name, func(t *testing.T) {
var spec BatchSpec
err := yaml.Unmarshal([]byte(tc.in), &spec)
if err != nil {
t.Fatal(err)
}
have := spec.RequiredEnvVars()
if diff := cmp.Diff(have, tc.want); diff != "" {
t.Errorf("unexpected value: have=%q want=%q", have, tc.want)
}
})
}
}