diff --git a/cmd/test/main.go b/cmd/test/main.go new file mode 100644 index 0000000000000000000000000000000000000000..4a16d27c33b6198654a27f2f3af2f26f7967800d --- /dev/null +++ b/cmd/test/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + + "pggat/test" +) + +func main() { + var config test.Config + + flag.StringVar(&config.TestsPath, "path", "test/tests", "path to the tests to run") + + flag.BoolVar(&config.Offline, "offline", false, "if true, existing test results will be used") + + flag.StringVar(&config.Host, "host", "localhost", "postgres host") + flag.IntVar(&config.Port, "port", 5432, "postgres port") + flag.StringVar(&config.Database, "database", "pggat", "postgres database") + flag.StringVar(&config.User, "user", "postgres", "postgres user") + flag.StringVar(&config.Password, "password", "password", "postgres password") + + flag.Parse() + + tester := test.NewTester(config) + if err := tester.Run(); err != nil { + panic(err) + } +} diff --git a/contrib/discovery/k8s/k8s.go b/contrib/discovery/k8s/k8s.go deleted file mode 100644 index 15b5e0ec8cb9864e64de162994f5897f068738ae..0000000000000000000000000000000000000000 --- a/contrib/discovery/k8s/k8s.go +++ /dev/null @@ -1,127 +0,0 @@ -package k8s - -import ( - "context" - "fmt" - - "pggat/lib/gat" - - "tuxpa.in/a/zlog/log" - - v1 "k8s.io/api/core/v1" - "k8s.io/client-go/kubernetes" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/watch" -) - -type PodWatcher struct { - BaseRecipe gat.TCPRecipe - - Namespace string - ListOptions metav1.ListOptions - - pods map[string]*v1.Pod -} - -func (p *PodWatcher) Start( - ctx context.Context, - c *kubernetes.Clientset, - pool *gat.Pool, -) error { - p.pods = make(map[string]*v1.Pod) - err := p.getInitialPods(ctx, c, pool) - if err != nil { - return err - } - return p.startWatching(ctx, c, pool) -} - -func (p *PodWatcher) getInitialPods( - ctx context.Context, - c *kubernetes.Clientset, - pool *gat.Pool, -) error { - pods, err := c.CoreV1().Pods(p.Namespace).List(ctx, p.ListOptions) - if err != nil { - return err - } - for _, pod := range pods.Items { - if isPodReady(&pod) { - p.pods[pod.Name] = &pod - } - } - return nil -} - -func (p *PodWatcher) startWatching( - ctx context.Context, - c *kubernetes.Clientset, - pool *gat.Pool, -) error { - watcher, err := c.CoreV1().Pods(p.Namespace).Watch(ctx, p.ListOptions) - if err != nil { - return err - } - defer watcher.Stop() - - for event := range watcher.ResultChan() { - pod, ok := event.Object.(*v1.Pod) - if !ok { - continue - } - - podName := pod.Name - podIp := pod.Status.PodIP - podReady := isPodReady(pod) - - shouldDelete := false - shouldCreate := false - - // Log raw event stream to debug log - switch event.Type { - case watch.Added: - log.Printf("ADDED pod %s with ip %s. Ready = %v", podName, podIp, podReady) - if podReady { - shouldCreate = true - } else { - shouldDelete = true - } - - case watch.Modified: - log.Printf("MODIFIED pod %s with ip %s. Ready = %v", podName, podIp, podReady) - if podReady { - shouldCreate = true - } else { - shouldDelete = true - } - case watch.Deleted: - log.Printf("DELETED pod %s with ip %s. Ready = %v", podName, podIp, podReady) - shouldDelete = true - default: - // ignore this event - continue - } - - if shouldDelete { - pool.RemoveRecipe(podName) - delete(p.pods, podName) - } else if shouldCreate { - r := p.BaseRecipe - r.Address = fmt.Sprintf(r.Address, pod.Status.PodIP) - pool.AddRecipe(podName, r) - } - - } - - return nil -} - -func isPodReady(pod *v1.Pod) bool { - for _, condition := range pod.Status.Conditions { - if condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue { - return true - } - } - return false -} diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000000000000000000000000000000000000..912d5d3ea49f93016a19d12e6610514ff711b0b4 --- /dev/null +++ b/test/README.md @@ -0,0 +1,23 @@ +# How it works +All tests are listed in the `tests` directory. They are each run line by line against a real postgres +database and a database proxied through pggat. If the output differs in any meaningful way, the test +will fail. + +# Running without a database +The tests can be run without a postgres database by using previous test results in place of the +database. + +# Test format +The tests are formatted as a set of "instructions". Each instruction corresponds to zero or more packets +to be sent to the server. + +## Instructions +| Instruction | Arguments | Description | +|-------------|----------------|----------------------------------------------------------------------------------| +| PX | bool | Controls whether the next instructions can be run in parallel | +| SQ | string | Runs a simple query | +| QA | string, ...any | Runs a query with arguments. This will run a prepare, bind, explain, and execute | + +## Parallel tests +By default, many instances of a single test will be run at the same time. If the test or parts of the test +cannot be run in parallel, prepend `PX false`. diff --git a/test/config.go b/test/config.go new file mode 100644 index 0000000000000000000000000000000000000000..957d96c68ed24c67ad4385e22ff1558d521669f1 --- /dev/null +++ b/test/config.go @@ -0,0 +1,13 @@ +package test + +type Config struct { + TestsPath string + + Offline bool + + Host string + Port int + Database string + User string + Password string +} diff --git a/test/tester.go b/test/tester.go new file mode 100644 index 0000000000000000000000000000000000000000..7b6f1ecb0e7d57423007a14818335e6c69ebd0c4 --- /dev/null +++ b/test/tester.go @@ -0,0 +1,85 @@ +package test + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "tuxpa.in/a/zlog/log" +) + +type Tester struct { + config Config +} + +func NewTester(config Config) *Tester { + return &Tester{ + config: config, + } +} + +func (T *Tester) Run() error { + dirEntries, err := os.ReadDir(T.config.TestsPath) + if err != nil { + return err + } + + for _, dirEntry := range dirEntries { + if dirEntry.IsDir() { + continue + } + path := filepath.Join(T.config.TestsPath, dirEntry.Name()) + log.Printf(`Running test "%s"`, path) + + file, err := os.Open(path) + if err != nil { + return err + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + + instruction := fields[0] + arguments := make([]any, 0, len(fields)-1) + for _, argString := range fields[1:] { + var arg any + switch { + case argString == "true": + arg = true + case argString == "false": + arg = false + case strings.HasPrefix(argString, `"`), strings.HasPrefix(argString, "`"): + log.Printf("unquote %s", argString) + arg, err = strconv.Unquote(argString) + if err != nil { + return err + } + default: + return fmt.Errorf(`unknown argument "%s"`, argString) + } + arguments = append(arguments, arg) + } + log.Print(instruction, " ", arguments) + } + if err = scanner.Err(); err != nil { + return err + } + + if err = file.Close(); err != nil { + return err + } + + log.Print("OK") + } + + return nil +} diff --git a/test/testfile/instruction.go b/test/testfile/instruction.go new file mode 100644 index 0000000000000000000000000000000000000000..119fe6e7c52c8d1447d3d30ae1849a0c3d2ed8f7 --- /dev/null +++ b/test/testfile/instruction.go @@ -0,0 +1,6 @@ +package testfile + +type Instruction struct { + Op Opcode + Args []any +} diff --git a/test/testfile/opcode.go b/test/testfile/opcode.go new file mode 100644 index 0000000000000000000000000000000000000000..2684e6182ac40ef543e2435d1892498335b3cccb --- /dev/null +++ b/test/testfile/opcode.go @@ -0,0 +1,7 @@ +package testfile + +type Opcode int + +func OpcodeFromString(str string) (Opcode, bool) { + +} diff --git a/test/testfile/parser/any.go b/test/testfile/parser/any.go new file mode 100644 index 0000000000000000000000000000000000000000..86756b717c608634c4d80d4875c3751e2550d766 --- /dev/null +++ b/test/testfile/parser/any.go @@ -0,0 +1,4 @@ +package parser + +func Any(ctx *Context) (rune, bool) { +} diff --git a/test/testfile/parser/context.go b/test/testfile/parser/context.go new file mode 100644 index 0000000000000000000000000000000000000000..3e476cff829bd6220a6e369439a7b36e0c5b3bae --- /dev/null +++ b/test/testfile/parser/context.go @@ -0,0 +1,4 @@ +package parser + +type Context struct { +} diff --git a/test/testfile/parser/parser.go b/test/testfile/parser/parser.go new file mode 100644 index 0000000000000000000000000000000000000000..7e1e57d32a6cdc11b9bad9db62d20b747fa9749f --- /dev/null +++ b/test/testfile/parser/parser.go @@ -0,0 +1,3 @@ +package parser + +type Builder[O any] func(*Context) (O, bool) diff --git a/test/testfile/parser/parsers/v0/parser.go b/test/testfile/parser/parsers/v0/parser.go new file mode 100644 index 0000000000000000000000000000000000000000..a76501856891f5b92348f9916336eec13b032b10 --- /dev/null +++ b/test/testfile/parser/parsers/v0/parser.go @@ -0,0 +1,163 @@ +package parsers + +import ( + "strings" + + "pggat/test/testfile" + "pggat/test/testfile/parser" +) + +func NewlineOrEOF(ctx *parser.Context) (struct{}, bool) { + c, ok := parser.Any(ctx) + if !ok || c == '\n' { + return struct{}{}, true + } + return struct{}{}, false +} + +func Whitespace(ctx *parser.Context) (struct{}, bool) { + var n int + for { + _, ok := parser.SingleOf(ctx, func(r rune) bool { + switch r { + case ' ', '\t', '\r', '\n', '\v', '\f': + return true + default: + return false + } + }) + if !ok { + break + } + n++ + } + return struct{}{}, n > 0 +} + +func Identifier(ctx *parser.Context) (string, bool) { + var b strings.Builder + for { + c, ok := parser.SingleOf(ctx, func(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' + }) + if !ok { + break + } + b.WriteRune(c) + } + if b.Len() == 0 { + return "", false + } + return b.String(), true +} + +func Opcode(ctx *parser.Context) (testfile.Opcode, bool) { + ident, ok := Identifier(ctx) + if !ok { + return 0, false + } + return testfile.OpcodeFromString(ident) +} + +func StringArgument(ctx *parser.Context) (string, bool) { + // open quote + _, ok := parser.Single(ctx, '"') + if !ok { + return "", false + } + + var b strings.Builder + for { + c, ok := parser.Any(ctx) + if !ok { + return "", false + } + if c == '"' { + break + } + b.WriteRune(c) + } + + return b.String(), true +} + +func BoolArgument(ctx *parser.Context) (bool, bool) { + ident, ok := Identifier(ctx) + if !ok { + return false, false + } + switch ident { + case "false": + return false, true + case "true": + return true, true + default: + return false, false + } +} + +func FloatArgument(ctx *parser.Context) (float64, bool) { + return 0, false // TODO(garet) +} + +func IntArgument(ctx *parser.Context) (int, bool) { + return 0, false // TODO(garet) +} + +func NumberArgument(ctx *parser.Context) (any, bool) { + if arg, ok := parser.Try(ctx, FloatArgument); ok { + return arg, true + } + if arg, ok := parser.Try(ctx, IntArgument); ok { + return arg, true + } + return nil, false +} + +func Argument(ctx *parser.Context) (any, bool) { + if arg, ok := parser.Try(ctx, StringArgument); ok { + return arg, true + } + if arg, ok := parser.Try(ctx, BoolArgument); ok { + return arg, true + } + if arg, ok := parser.Try(ctx, NumberArgument); ok { + return arg, true + } + return nil, false +} + +func Arguments(ctx *parser.Context) ([]any, bool) { + var args []any + for { + arg, ok := parser.Try(ctx, Argument) + if !ok { + break + } + args = append(args, arg) + Whitespace(ctx) + } + return args, len(args) != 0 +} + +func Instruction(ctx *parser.Context) (testfile.Instruction, bool) { + Whitespace(ctx) + opcode, ok := Opcode(ctx) + if !ok { + return testfile.Instruction{}, false + } + + Whitespace(ctx) + arguments, _ := Arguments(ctx) + + Whitespace(ctx) + _, ok = NewlineOrEOF(ctx) + if !ok { + return testfile.Instruction{}, false + } + + return testfile.Instruction{ + Op: opcode, + Args: arguments, + }, true +} diff --git a/test/testfile/parser/singleof.go b/test/testfile/parser/singleof.go new file mode 100644 index 0000000000000000000000000000000000000000..a8cdc7c128a3298ab4155c25bce12cccac2f039c --- /dev/null +++ b/test/testfile/parser/singleof.go @@ -0,0 +1,23 @@ +package parser + +func SingleOf(ctx *Context, fn func(rune) bool) (rune, bool) { + c, ok := Any(ctx) + if !ok { + return 0, false + } + if !fn(c) { + return 0, false + } + return c, true +} + +func Single(ctx *Context, r rune) (struct{}, bool) { + c, ok := Any(ctx) + if !ok { + return struct{}{}, false + } + if c != r { + return struct{}{}, false + } + return struct{}{}, true +} diff --git a/test/testfile/parser/try.go b/test/testfile/parser/try.go new file mode 100644 index 0000000000000000000000000000000000000000..3dd8caf730c56f3c5f800d5be92c865ae2261c95 --- /dev/null +++ b/test/testfile/parser/try.go @@ -0,0 +1,5 @@ +package parser + +func Try[O any](ctx *Context, b Builder[O]) (O, bool) { + +} diff --git a/test/tests/000_select b/test/tests/000_select new file mode 100644 index 0000000000000000000000000000000000000000..be6a81acdfa4eb2d7bf22354818ca40f6b6d5442 --- /dev/null +++ b/test/tests/000_select @@ -0,0 +1 @@ +SQ "select 1;" \ No newline at end of file