Skip to content

Commit

Permalink
feat: support for modeling test data and an auto test command
Browse files Browse the repository at this point in the history
Signed-off-by: Nick Mitchell <[email protected]>

Also fixes bugs:
- worker-watcher may re-upload output from other tasks in a race.
- boot/up may hang due to race between alldone and redirect
- observe/queuestreamer may exit (on all done) prior to sending last batch of notifications

Signed-off-by: Nick Mitchell <[email protected]>

Signed-off-by: Nick Mitchell <[email protected]>
  • Loading branch information
starpit committed Dec 1, 2024
1 parent 46e6c66 commit 5f9df15
Show file tree
Hide file tree
Showing 18 changed files with 351 additions and 33 deletions.
44 changes: 44 additions & 0 deletions cmd/subcommands/tester.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//go:build full || manage

package subcommands

import (
"context"

"github.com/spf13/cobra"

"lunchpail.io/cmd/options"
"lunchpail.io/pkg/be"
"lunchpail.io/pkg/be/target"
"lunchpail.io/pkg/boot"
"lunchpail.io/pkg/build"
)

func init() {
if build.IsBuilt() && build.HasTestData() {
var cmd = &cobra.Command{
Use: "test",
Short: "Run stock tests",
Long: "Run stock tests",
}

buildOpts, err := options.AddBuildOptions(cmd)
if err != nil {
panic(err)
}

cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := context.Background()

buildOpts.Target.Platform = target.Local
backend, err := be.New(ctx, *buildOpts)
if err != nil {
return err
}

return boot.Tester{Backend: backend, Options: *buildOpts}.RunAll(ctx)
}

rootCmd.AddCommand(cmd)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ require (
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v2 v2.4.0
helm.sh/helm/v3 v3.16.2 // indirect
k8s.io/apiextensions-apiserver v0.31.2 // indirect
k8s.io/apiserver v0.31.2 // indirect
Expand Down
8 changes: 6 additions & 2 deletions pkg/boot/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

// Behave like `cat inputs | ... > outputs`
func catAndRedirect(ctx context.Context, inputs []string, backend be.Backend, ir llir.LLIR, noRedirect bool, opts build.LogOptions) error {
func catAndRedirect(ctx context.Context, inputs []string, backend be.Backend, ir llir.LLIR, alldone <-chan struct{}, noRedirect bool, redirectTo string, opts build.LogOptions) error {
client, err := s3.NewS3ClientForRun(ctx, backend, ir.Context.Run, opts)
if err != nil {
return err
Expand Down Expand Up @@ -69,6 +69,10 @@ func catAndRedirect(ctx context.Context, inputs []string, backend be.Backend, ir
// may be a fool's errand, e.g. what if a single input
// results in two outputs?
folderFor := func(output string) string {
if redirectTo != "" {
// We were asked to redirect to a particular directory
return redirectTo
}
inIdx := slices.IndexFunc(inputs, func(in string) bool { return filepath.Base(in) == output })
if inIdx >= 0 {
return filepath.Dir(inputs[inIdx])
Expand All @@ -78,7 +82,7 @@ func catAndRedirect(ctx context.Context, inputs []string, backend be.Backend, ir
if opts.Verbose {
fmt.Fprintln(os.Stderr, "up is redirecting output files", os.Args)
}
if err := builtins.RedirectTo(ctx, client.S3Client, client.RunContext, folderFor, opts); err != nil {
if err := builtins.RedirectTo(ctx, client.S3Client, client.RunContext, folderFor, alldone, opts); err != nil {
return err
}
}
Expand Down
159 changes: 159 additions & 0 deletions pkg/boot/tester.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//go:build full || manage

package boot

import (
"bytes"
"compress/gzip"
"context"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"slices"

"github.com/dustin/go-humanize/english"

"lunchpail.io/pkg/be"
"lunchpail.io/pkg/build"
"lunchpail.io/pkg/ir/hlir"
)

type Tester struct {
be.Backend
build.Options
}

func (t Tester) RunAll(ctx context.Context) error {
testData, stageDir, err := build.TestDataWithStage()
if err != nil {
return err
}
if !t.Options.Verbose() {
defer os.RemoveAll(stageDir)
}

inputs, expected, err := t.prepareInputs(testData, stageDir)
if err != nil {
return err
} else if len(inputs) == 0 {
return fmt.Errorf("This application provided no test input")
}

return t.Run(ctx, inputs, expected)
}

func (t Tester) prepareInputs(testData hlir.TestData, stageDir string) (inputs []string, outputs []string, err error) {
inputDir := build.TestDataDirForInput(stageDir)
expectedDir := build.TestDataDirForExpected(stageDir)
for _, test := range testData {
inputs = append(inputs, filepath.Join(inputDir, test.Input))
outputs = append(outputs, filepath.Join(expectedDir, test.Expected))
}

if t.Options.Verbose() {
fmt.Fprintln(os.Stderr, "Test data staged to", inputDir)
}

return
}

func (t Tester) Run(ctx context.Context, inputs []string, expected []string) error {
fmt.Fprintf(os.Stderr, "Scheduling %s for %s\n", english.Plural(len(inputs), "test", ""), build.Name())

if slices.IndexFunc(inputs, func(input string) bool { return filepath.Ext(input) == ".gz" }) >= 0 {
t.Options.Gunzip = true
}

redirectTo, err := ioutil.TempDir("", build.Name()+"-test-output")
if err != nil {
return err
}
if !t.Options.Verbose() {
defer os.RemoveAll(redirectTo)
}

if err := Up(ctx, t.Backend, UpOptions{Inputs: inputs, BuildOptions: t.Options, RedirectTo: redirectTo}); err != nil {
return err
}

if err := t.validate(inputs, expected, redirectTo); err != nil {
fmt.Fprintln(os.Stderr, "❌ FAIL", build.Name(), err)
return err
}

fmt.Fprintln(os.Stderr, "✅ PASS", build.Name())
return nil
}

func (t Tester) validate(inputs []string, expecteds []string, redirectTo string) error {
if len(expecteds) == 0 {
// Nothing to validate
if t.Options.Verbose() {
fmt.Fprintln(os.Stderr, "Skipping validation, as no expected output was provided for", build.Name())
}
return nil
}

if t.Options.Verbose() {
fmt.Fprintf(os.Stderr, "Validating output for %s in redirect directory %s\n", build.Name(), redirectTo)
}

actuals, err := os.ReadDir(redirectTo)
if err != nil {
return err
}

found := 0
for idx, expected := range expecteds {
expectedFileName := filepath.Base(inputs[idx])

// TODO O(N^2)
for _, actual := range actuals {
matches := actual.Name() == expectedFileName
matchesWithGunzip := !matches && actual.Name()+".gz" == expectedFileName
if matches || matchesWithGunzip {
found++

actualBytes, err := os.ReadFile(filepath.Join(redirectTo, actual.Name()))
if err != nil {
return err
}

expectedBytes, err := os.ReadFile(expected)
if err != nil {
return err
}

if ok, err := t.equal(matchesWithGunzip, expectedBytes, actualBytes); err != nil {
return err
} else if !ok {
return fmt.Errorf("actual!=expected for %s", filepath.Base(inputs[idx]))
}
}
}
}

if found != len(expecteds) {
return fmt.Errorf("Missing output files. Expected %d got %d.", len(expecteds), found)
}

return nil
}

func (t Tester) equal(needsGunzip bool, expected, actual []byte) (bool, error) {
if needsGunzip {
reader, err := gzip.NewReader(bytes.NewReader(expected))
if err != nil {
return false, err
}
defer reader.Close()

expected, err = ioutil.ReadAll(reader)
if err != nil {
return false, err
}
}

return bytes.Equal(expected, actual), nil
}
9 changes: 4 additions & 5 deletions pkg/boot/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type UpOptions struct {
BuildOptions build.Options
Executable string
NoRedirect bool
RedirectTo string
}

func Up(ctx context.Context, backend be.Backend, opts UpOptions) error {
Expand Down Expand Up @@ -153,11 +154,9 @@ func upLLIR(ctx context.Context, backend be.Backend, ir llir.LLIR, opts UpOption
// Then Minio went away on its own. That's probably ok.
errorFromAllDone = nil
}
alldone <- struct{}{}
cancel()
} else {
alldone <- struct{}{}
}
alldone <- struct{}{} // once for here
alldone <- struct{}{} // once for redirect
}
}()

Expand All @@ -173,7 +172,7 @@ func upLLIR(ctx context.Context, backend be.Backend, ir llir.LLIR, opts UpOption
}

defer func() { redirectDone <- struct{}{} }()
if err := catAndRedirect(cancellable, opts.Inputs, backend, ir, opts.NoRedirect, *opts.BuildOptions.Log); err != nil {
if err := catAndRedirect(cancellable, opts.Inputs, backend, ir, alldone, opts.NoRedirect, opts.RedirectTo, *opts.BuildOptions.Log); err != nil {
errorFromIo = err
cancel()
}
Expand Down
52 changes: 51 additions & 1 deletion pkg/build/breadcrumbs.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
"path/filepath"
"strings"
"time"

"gopkg.in/yaml.v2"

"lunchpail.io/pkg/ir/hlir"
)

//go:embed buildName.txt
Expand All @@ -25,6 +29,9 @@ var on string
//go:embed appVersion.txt
var appVersion string

//go:embed testData.yaml
var testData []byte

func Name() string {
n := os.Getenv("LUNCHPAIL_NAME")
if n == "" {
Expand Down Expand Up @@ -60,11 +67,46 @@ func AppVersion() string {
return strings.TrimSpace(appVersion)
}

func HasTestData() bool {
return len(testData) > 0
}

func TestData() (data hlir.TestData, err error) {
if !HasTestData() {
return
}

err = yaml.Unmarshal(testData, &data)
return
}

func TestDataWithStage() (data hlir.TestData, stagePath string, err error) {
data, err = TestData()
if err != nil {
return
}

stagePath, err = StageForBuilder(StageOptions{})
return
}

func TestDataDirFor(templatePath string) string {
return filepath.Join(templatePath, "test-data")
}

func TestDataDirForInput(templatePath string) string {
return filepath.Join(TestDataDirFor(templatePath), "input")
}

func TestDataDirForExpected(templatePath string) string {
return filepath.Join(TestDataDirFor(templatePath), "expected")
}

func IsBuilt() bool {
return strings.TrimSpace(name) != "<none>"
}

func DropBreadcrumbs(buildName, appVersion string, opts Options, stagedir string) error {
func DropBreadcrumbs(buildName, appVersion string, testData hlir.TestData, opts Options, stagedir string) error {
user, err := user.Current()
if err != nil {
return err
Expand All @@ -75,6 +117,14 @@ func DropBreadcrumbs(buildName, appVersion string, opts Options, stagedir string
return err
}

if len(testData) > 0 {
if b, err := yaml.Marshal(testData); err != nil {
return err
} else if err := os.WriteFile(filepath.Join(stagedir, "pkg/build/testData.yaml"), b, 0644); err != nil {
return err
}
}

if err := os.WriteFile(filepath.Join(stagedir, "pkg/build/buildName.txt"), []byte(buildName), 0644); err != nil {
return err
} else if err := os.WriteFile(filepath.Join(stagedir, "pkg/build/appVersion.txt"), []byte(appVersion), 0644); err != nil {
Expand Down
5 changes: 2 additions & 3 deletions pkg/build/stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type StageOptions struct {
}

// return (templatePath, error)
func StageForBuilder(appname string, opts StageOptions) (string, error) {
func StageForBuilder(opts StageOptions) (string, error) {
// TODO overlay on kube/common?
templatePath, err := ioutil.TempDir("", "lunchpail")
if err != nil {
Expand All @@ -34,8 +34,7 @@ func StageForBuilder(appname string, opts StageOptions) (string, error) {

// return (templatePath, error)
func StageForRun(opts StageOptions) (string, error) {
appname := Name()
templatePath, err := StageForBuilder(appname, opts)
templatePath, err := StageForBuilder(opts)

// TODO we could parallelize these two, but the overhead is probably not worth it
if err := emitPlaceholderChartYaml(templatePath); err != nil {
Expand Down
1 change: 1 addition & 0 deletions pkg/build/testData.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

4 changes: 2 additions & 2 deletions pkg/fe/builder/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func Build(ctx context.Context, sourcePath string, opts Options) error {
fmt.Fprintf(os.Stderr, "Building %s\n", buildName)

// Third, overlay source (if given)
appTemplatePath, appVersion, err := overlay.OverlaySourceOntoPriorBuild(buildName, sourcePath, opts.OverlayOptions)
appTemplatePath, appVersion, hasTestData, err := overlay.OverlaySourceOntoPriorBuild(buildName, sourcePath, opts.OverlayOptions)
if err != nil {
return err
}
Expand All @@ -63,7 +63,7 @@ func Build(ctx context.Context, sourcePath string, opts Options) error {
}

// Fifth, tell the build about itself (its name, version)
if err := build.DropBreadcrumbs(buildName, appVersion, opts.OverlayOptions.BuildOptions, lunchpailStageDir); err != nil {
if err := build.DropBreadcrumbs(buildName, appVersion, hasTestData, opts.OverlayOptions.BuildOptions, lunchpailStageDir); err != nil {
return err
}

Expand Down
Loading

0 comments on commit 5f9df15

Please sign in to comment.