diff --git a/Makefile b/Makefile index c719cb04f..214765780 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,9 @@ GOHOSTARCH = $(shell go env GOHOSTARCH) default: build +¯\_(ツ)_/¯: + @echo "¯\_(ツ)_/¯" + # # art # diff --git a/examples/lang/env0.mcl b/examples/lang/env0.mcl index 4e7c7ea63..929a6892c 100644 --- a/examples/lang/env0.mcl +++ b/examples/lang/env0.mcl @@ -1,19 +1,19 @@ # read and print environment variable # env TEST=123 EMPTY= ./mgmt run --tmp-prefix --lang=examples/lang/env0.mcl --converged-timeout=5 -$x = getenv("TEST", "321") +$x = defaultenv("TEST", "321") print "print1" { msg => printf("the value of the environment variable TEST is: %s", $x), } -$y = getenv("DOESNOTEXIT", "321") +$y = defaultenv("DOESNOTEXIT", "321") print "print2" { msg => printf("environment variable DOESNOTEXIT does not exist, defaulting to: %s", $y), } -$z = getenv("EMPTY", "456") +$z = defaultenv("EMPTY", "456") print "print3" { msg => printf("same goes for epmty variables like EMPTY: %s", $z), diff --git a/integration/deploy_test.go b/integration/deploy_test.go new file mode 100644 index 000000000..cd8295486 --- /dev/null +++ b/integration/deploy_test.go @@ -0,0 +1,42 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package integration + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestDeployFailSyntaxError makes sure deploy errors when the lang files contains a syntax error +func TestDeployFailSyntaxError(t *testing.T) { + // create an mgmt test environment and ensure cleanup/debug logging on failure/exit + m := Instance{} + defer m.Cleanup(t) + defer m.StopBackground(t) + + m.RunBackground(t) + m.WaitUntilIdle(t) + + // deploy lang file to the just started instance + out, err := m.DeployLangFile(nil, "lang/syntaxerror.mcl") + if err == nil { + t.Fatal("deploy command did not fail") + } + assert.Contains(t, out, "could not set scope") +} diff --git a/integration/instance.go b/integration/instance.go new file mode 100644 index 000000000..a42c9033c --- /dev/null +++ b/integration/instance.go @@ -0,0 +1,437 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// notes: +// https://www.philosophicalhacker.com/post/integration-tests-in-go/ +// https://github.com/golang/go/wiki/TableDrivenTests +// https://blog.golang.org/subtests +// https://splice.com/blog/lesser-known-features-go-test/ +// https://coreos.com/blog/testing-distributed-systems-in-go.html +// https://www.philosophicalhacker.com/post/integration-tests-in-go/ + +package integration + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" + + errwrap "github.com/pkg/errors" +) + +const ( + defaultTimeout = 120 * time.Second // longest time a mgmt instance should exist during tests + convergedTimeout = 5 * time.Second + idleTimeout = 5 * time.Second + shutdownTimeout = 10 * time.Second + convergedIndicator = "Converged for 5 seconds, exiting!" + exitedIndicator = "Goodbye!" +) + +// Instance represents a single mgmt instance that can be managed +type Instance struct { + // settings passed into the instance + Timeout time.Duration + Env []string + + // output and metadata regarding the mgmt instance + Name string + Tmpdir string + Prefix string + Workdir string + DeployOutput string + Stdout bytes.Buffer + Stderr bytes.Buffer + Err error + Seeds string + + command string + env string + ctx context.Context + cmd *exec.Cmd + cancel context.CancelFunc +} + +// start starts running mgmt run in an isolated environment +func (m *Instance) start(t *testing.T, mgmtargs ...string) error { + // TODO: fakechroot/docker for proper isolation, for now relying on passing a temp. dir + // TODO: currently better isolation for used client/server ports is much needed + + if m.Timeout == 0 { + m.Timeout = defaultTimeout + } + + m.ctx, m.cancel = context.WithTimeout(context.Background(), m.Timeout) + + if m.Tmpdir == "" { + // create temporary directory to use during testing + var err error + m.Tmpdir, err = ioutil.TempDir("", fmt.Sprintf("mgmt-integrationtest-%s-", m.Name)) + if err != nil { + if t != nil { + t.Helper() + t.Fatal("Error: can't create temporary directory") + } + return err + } + } + + prefix := path.Join(m.Tmpdir, "prefix") + if err := os.MkdirAll(prefix, 0755); err != nil { + if t != nil { + t.Helper() + t.Fatal("Error: can't create temporary prefix directory") + } + return err + } + m.Prefix = prefix + workdir := path.Join(m.Tmpdir, "workdir") + if err := os.MkdirAll(workdir, 0755); err != nil { + if t != nil { + t.Helper() + t.Fatal("Error: can't create temporary working directory") + } + return err + } + m.Workdir = workdir + + cmdargs := []string{"run"} + if m.Name != "" { + cmdargs = append(cmdargs, fmt.Sprintf("--hostname=%s", m.Name)) + } + cmdargs = append(cmdargs, mgmtargs...) + m.command = fmt.Sprintf("%s %s", mgmt, strings.Join(cmdargs, " ")) + + m.cmd = exec.CommandContext(m.ctx, mgmt, cmdargs...) + + m.cmd.Stdout = &m.Stdout + m.cmd.Stderr = &m.Stderr + + m.cmd.Env = []string{ + fmt.Sprintf("MGMT_TEST_ROOT=%s", workdir), + fmt.Sprintf("MGMT_PREFIX=%s", prefix), + } + m.cmd.Env = append(m.cmd.Env, m.Env...) + m.env = strings.Join(m.cmd.Env, " ") + + m.Seeds = fmt.Sprintf("http://127.0.0.1:2379") + + if err := m.cmd.Start(); err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "Command %s failed to start", m.command)) + } + return err + } + return nil +} + +// wait waits for previously started mgmt run to finish cleanly +func (m *Instance) wait(t *testing.T) error { + if err := m.cmd.Wait(); err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "Command failed to complete")) + } + return err + } + return nil +} + +// stop stops the mgmt background instance +func (m *Instance) stop(t *testing.T) error { + if err := m.cmd.Process.Signal(syscall.SIGINT); err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "Failed to kill the command")) + } + return err + } + + if err := m.cmd.Wait(); err != nil { + // kill the process if it fails to shutdown nicely, so we get a stack trace + m.cmd.Process.Signal(syscall.SIGKILL) + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "Command '%s' failed to complete", m.command)) + } + return err + } + + if m.ctx.Err() == context.DeadlineExceeded { + if t != nil { + t.Helper() + t.Fatal("Command timed out") + } + return fmt.Errorf("Command timed out") + } + return nil +} + +// Run runs mgmt to convergence +func (m *Instance) Run(t *testing.T, mgmtargs ...string) error { + mgmtargs = append(mgmtargs, "--converged-timeout=5") + if err := m.start(t, mgmtargs...); err != nil { + return err + } + if err := m.wait(t); err != nil { + return err + } + return nil +} + +// RunLangFile runs mgmt with the given mcl file to convergence +func (m *Instance) RunLangFile(t *testing.T, langfilerelpath string, mgmtargs ...string) error { + _, testfilepath, _, _ := runtime.Caller(0) + testdirpath := filepath.Dir(testfilepath) + langfilepath := path.Join(testdirpath, langfilerelpath) + + mgmtargs = append(mgmtargs, fmt.Sprintf("--lang=%s", langfilepath)) + + return m.Run(t, mgmtargs...) +} + +// RunBackground runs mgmt in the background +func (m *Instance) RunBackground(t *testing.T, mgmtargs ...string) error { + return m.start(t, mgmtargs...) +} + +// DeployLang deploys a mcl file provided as content to the running instance +func (m *Instance) DeployLang(t *testing.T, code string) (string, error) { + content := []byte(code) + tmpfile, err := ioutil.TempFile("", "deploy.mcl") + if err != nil { + if t != nil { + t.Helper() + t.Fatal(err) + } + return "", err + } + + defer os.Remove(tmpfile.Name()) // clean up + + if _, err := tmpfile.Write(content); err != nil { + if t != nil { + t.Helper() + t.Fatal(err) + } + return "", err + } + if err := tmpfile.Close(); err != nil { + if t != nil { + t.Helper() + t.Fatal(err) + } + return "", err + } + + cmd := exec.Command(mgmt, "deploy", "--no-git", "lang", "--lang", tmpfile.Name()) + // TODO: environment should be shared by instance and deploy + cmd.Env = []string{fmt.Sprintf("MGMT_SEEDS=%s", m.Seeds)} + + out, err := cmd.CombinedOutput() + if err != nil { + msg := errwrap.Wrapf(err, "Deploy failed") + if t != nil { + t.Logf("Deploy output: %s", out) + t.Helper() + t.Fatal(msg) + } + return string(out), msg + } + m.DeployOutput = string(out) + + return string(out), nil +} + +// DeployLangFile deploys a mcl file to the running instance +func (m *Instance) DeployLangFile(t *testing.T, langfilerelpath string) (string, error) { + _, testfilepath, _, _ := runtime.Caller(0) + testdirpath := filepath.Dir(testfilepath) + langfilepath := path.Join(testdirpath, langfilerelpath) + + cmd := exec.Command(mgmt, "deploy", "--no-git", "lang", "--lang", langfilepath) + // TODO: environment should be shared by instance and deploy + cmd.Env = []string{fmt.Sprintf("MGMT_SEEDS=%s", m.Seeds)} + + out, err := cmd.CombinedOutput() + if err != nil { + msg := errwrap.Wrapf(err, "Deploy failed") + if t != nil { + t.Logf("Deploy output: %s", out) + t.Helper() + t.Fatal(msg) + } + return string(out), msg + } + m.DeployOutput = string(out) + + return string(out), nil +} + +// StopBackground stops the mgmt instance running in the background +func (m *Instance) StopBackground(t *testing.T) error { + t.Helper() + err := m.stop(t) + + // TODO: proper shutdown checking, eg "Main: Waiting..." is last logline for x seconds + time.Sleep(shutdownTimeout) + + return err +} + +// Finished indicates if the command output matches that of a converged and finished (exited) run +func (m Instance) Finished(t *testing.T, converge bool) error { + if m.Stderr.String() == "" { + if t != nil { + t.Helper() + t.Fatal("Instance run had no output") + } + return fmt.Errorf("Instance run had no output") + } + + var converged bool + if converge { + converged = strings.Contains(m.Stderr.String(), convergedIndicator) + } else { + converged = true + } + exited := strings.Contains(m.Stderr.String(), exitedIndicator) + if !(converged && exited) { + t.Logf("Command output: %s", m.Stderr.String()) + if t != nil { + t.Helper() + t.Fatal("Instance run output does not indicate finished run") + } + return fmt.Errorf("Instance run output does not indicate finished run") + } + return nil +} + +// Pass checks if a non-empty `pass` file exists in the workdir +// This file should be created by the configuration run to indicate it completed. +func (m Instance) Pass(t *testing.T) error { + passfilepath := path.Join(m.Workdir, "pass") + passfilestat, err := os.Stat(passfilepath) + if os.IsNotExist(err) { + msg := fmt.Sprintf("the file `%s` was not created by the configuration", passfilepath) + if t != nil { + t.Helper() + t.Fatal(msg) + } + return fmt.Errorf(msg) + } + if passfilestat.Size() == 0 { + msg := fmt.Sprintf("the file `%s` is empty", passfilepath) + if t != nil { + t.Helper() + t.Fatal(msg) + } + return fmt.Errorf(msg) + } + return nil +} + +// Cleanup makes sure temporary directory is cleaned +func (m *Instance) Cleanup(t *testing.T) error { + // stop the timeout context for the command + m.cancel() + // we expect the command to be stopped (StopBackground()) or else assume we don't care about it closing nicely + m.cmd.Process.Signal(syscall.SIGKILL) + + // be helpful on failure and keep temporary directories for debugging + if t.Failed() { + t.Logf("\nName: %s\nRan command:\nenv %s %s\nStdout:\n%s\nStderr:\n%s", + m.Name, m.env, m.command, m.Stdout.String(), m.Stderr.String()) + t.Logf("Deploy output:\n%s", m.DeployOutput) + return nil + } + + if m.Tmpdir == "" { + return nil + } + if err := os.RemoveAll(m.Tmpdir); err != nil { + return err + } + m.Tmpdir = "" + return nil +} + +// WaitUntilIdle waits for the current instance to reach reach idle state () +func (m *Instance) WaitUntilIdle(t *testing.T) error { + // TODO: sleep is bad UX on testing, refactor to wait for a signal either from logging or maybe using etcdclient and AddHostnameConvergedWatcher? + // TODO: should we consider being idle the same as being converged? + time.Sleep(idleTimeout) + + return nil +} + +// WaitUntilConverged waits for the current instance to reach a converged state +func (m *Instance) WaitUntilConverged(t *testing.T) error { + // TODO: sleep is bad UX on testing, refactor to wait for a signal either from logging or maybe using etcdclient and AddHostnameConvergedWatcher? + time.Sleep(convergedTimeout) + + return nil +} + +// WorkdirWriteToFile write a string to a file in the current instance workdir +func (m *Instance) WorkdirWriteToFile(t *testing.T, name string, text string) error { + data := []byte(text) + if err := ioutil.WriteFile(path.Join(m.Workdir, name), data, 0644); err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "failed to create file %s in mgmt instance working directory", name)) + } + return err + } + return nil +} + +// WorkdirReadFromFile reads a file from the workdir and returns the content as a string +func (m *Instance) WorkdirReadFromFile(t *testing.T, name string) (string, error) { + data, err := ioutil.ReadFile(path.Join(m.Workdir, name)) + if err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "failed to read file %s from workdir", name)) + } + return "", err + } + return string(data), nil +} + +// WorkdirRemoveFile removes a file from the instance workdir +func (m *Instance) WorkdirRemoveFile(t *testing.T, name string) error { + if err := os.Remove(path.Join(m.Workdir, name)); err != nil { + if t != nil { + t.Helper() + t.Fatal(errwrap.Wrapf(err, "failed to remove file %s in mgmt instance working directory", name)) + } + return err + } + return nil +} diff --git a/integration/lang/simple.mcl b/integration/lang/simple.mcl new file mode 100644 index 000000000..a7dfe1d7b --- /dev/null +++ b/integration/lang/simple.mcl @@ -0,0 +1,6 @@ +# minimal example to make integrationtest pass +$root = getenv("MGMT_TEST_ROOT") + +file "${root}/pass" { + content => "not empty", +} diff --git a/integration/lang/syntaxerror.mcl b/integration/lang/syntaxerror.mcl new file mode 100644 index 000000000..04f361b66 --- /dev/null +++ b/integration/lang/syntaxerror.mcl @@ -0,0 +1,2 @@ +$x = 1 +$x = 2 diff --git a/integration/sanity_test.go b/integration/sanity_test.go new file mode 100644 index 000000000..b6f95e3e1 --- /dev/null +++ b/integration/sanity_test.go @@ -0,0 +1,137 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package integration + +import ( + "net/http" + "os/exec" + "strings" + "testing" + + errwrap "github.com/pkg/errors" +) + +const ( + stringExistingInHelp = "--version, -v print the version" +) + +// TestHelp verified the most simple invocation of mgmt does not fail. +// If this test fails assumptions made by the rest of the testsuite are invalid. +func TestHelp(t *testing.T) { + out, err := exec.Command(mgmt).Output() + if err != nil { + t.Fatalf("Error: %v", err) + } + if !strings.Contains(string(out), stringExistingInHelp) { + t.Logf("Command output: %s", string(out)) + t.Fatal("Expected output not found") + } +} + +// TestSmoketest makes sure the most basic run functionality works. +// If this test fails assumptions made by the rest of the testsuite are invalid. +func TestSmoketest(t *testing.T) { + // create an mgmt test environment and ensure cleanup/debug logging on failure/exit + m := Instance{} + defer m.Cleanup(t) + + // run mgmt to convergence + m.Run(t) + + // verify output contains what is expected from a converging and finished run + m.Finished(t, true) +} + +// TestSimple applies a simple mcl file and tests the result. +// If this test fails assumptions made by the rest of the testsuite are invalid. +func TestSimple(t *testing.T) { + // create an mgmt test environment and ensure cleanup/debug logging on failure/exit + m := Instance{} + defer m.Cleanup(t) + + // apply the configration from lang/simple.mcl + m.RunLangFile(t, "lang/simple.mcl") + + // verify output contains what is expected from a converging and finished run + m.Finished(t, true) + + // verify if a non-empty `pass` file is created in the working directory + m.Pass(t) +} + +// TestDeploy checks if background running and deployment works. +// If this test fails assumptions made by the rest of the testsuite are invalid. +func TestDeploy(t *testing.T) { + // create an mgmt test environment and ensure cleanup/debug logging on failure/exit + m := Instance{} + defer m.Cleanup(t) + + // start a mgmt instance running in the background + m.RunBackground(t) + + // wait until server is up and running + m.WaitUntilIdle(t) + + // expect mgmt to listen on default client and server url + if _, err := http.Get("http://127.0.0.1:2379"); err != nil { + t.Fatal("default client url is not reachable over tcp") + } + if _, err := http.Get("http://127.0.0.1:2380"); err != nil { + t.Fatal("default server url is not reachable over tcp") + } + + // deploy lang file to the just started instance + out, err := m.DeployLangFile(nil, "lang/simple.mcl") + if err != nil { + t.Fatal(errwrap.Wrapf(err, "deploy command failed, output: %s", out)) + } + + // wait for deploy to come to a rest + m.WaitUntilConverged(t) + + // stop the running instance + m.StopBackground(t) + + // verify output contains what is expected from a converged and cleanly finished run + m.Finished(t, false) + + // verify if a non-empty `pass` file is created in the working directory + m.Pass(t) +} + +// TestDeployLang tests deploying lang code directly. +// This also is the most lean/simple example for a deploy integrationtest +// If this test fails assumptions made by the rest of the testsuite are invalid. +func TestDeployLang(t *testing.T) { + m := Instance{} + defer m.Cleanup(t) + + m.RunBackground(t) + m.WaitUntilIdle(t) + + // deploy lang file to the just started instance + m.DeployLang(t, ` + $root = getenv("MGMT_TEST_ROOT") + file "${root}/pass" { + content => "not empty", + } + `) + m.WaitUntilConverged(t) + + m.Pass(t) +} diff --git a/integration/shared_test.go b/integration/shared_test.go new file mode 100644 index 000000000..510a36f23 --- /dev/null +++ b/integration/shared_test.go @@ -0,0 +1,74 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package integration + +import ( + "io/ioutil" + "log" + "os" + "path/filepath" + "testing" +) + +var mgmt string + +// TestMain ensures a temporary environment is created (and cleaned afterward) to perform tests in +func TestMain(m *testing.M) { + // get absolute path for mgmt binary from testenvironment + mgmt = os.Getenv("MGMT") + // fallback to assumption based on current directory if path is not provided + if mgmt == "" { + path, err := filepath.Abs("../mgmt") + if err != nil { + log.Printf("failed to get absolute mgmt path") + os.Exit(1) + } + mgmt = path + } + if _, err := os.Stat(mgmt); os.IsNotExist(err) { + log.Printf("mgmt executable %s does not exist", mgmt) + os.Exit(1) + } + + // move to clean/stateless directory before running tests + cwd, err := os.Getwd() + if err != nil { + log.Printf("failed to get current directory") + os.Exit(1) + } + tmpdir, err := ioutil.TempDir("", "mgmt-integrationtest") + if err != nil { + log.Printf("failed to create test working directory") + os.Exit(1) + } + if err := os.Chdir(tmpdir); err != nil { + log.Printf("failed to enter test working directory") + os.Exit(1) + } + + // run all the tests + os.Exit(m.Run()) + + // and back to where we started + os.Chdir(cwd) + + if err := os.RemoveAll(tmpdir); err != nil { + log.Printf("failed to remove working directory") + os.Exit(1) + } +} diff --git a/lang/gapi.go b/lang/gapi.go index 3487957a4..4c4e486cf 100644 --- a/lang/gapi.go +++ b/lang/gapi.go @@ -19,6 +19,7 @@ package lang import ( "fmt" + "io/ioutil" "log" "strings" "sync" @@ -67,6 +68,23 @@ func (obj *GAPI) Cli(c *cli.Context, fs resources.Fs) (*gapi.Deploy, error) { return nil, fmt.Errorf("input code is empty") } + // validate syntax before deploying + fileContent, err := ioutil.ReadFile(s) + if err != nil { + return nil, errwrap.Wrapf(err, "can't read code from file `%s`", s) + } + code := strings.NewReader(string(fileContent)) + obj := &Lang{ + Input: code, + Logf: func(format string, v ...interface{}) { + log.Printf(Name+"%s: "+format, v...) + }, + } + err = obj.Validate() + if err != nil { + return nil, errwrap.Wrapf(err, s) + } + // read through this local path, and store it in our file system // since our deploy should work anywhere in the cluster, let the // engine ensure that this file system is replicated everywhere! @@ -138,6 +156,9 @@ func (obj *GAPI) LangInit() error { Hostname: obj.data.Hostname, World: obj.data.World, Debug: obj.data.Debug, + Logf: func(format string, v ...interface{}) { + log.Printf(Name+": "+format, v...) + }, } if err := obj.lang.Init(); err != nil { return errwrap.Wrapf(err, "can't init the lang") diff --git a/lang/lang.go b/lang/lang.go index de8972034..0c80a0132 100644 --- a/lang/lang.go +++ b/lang/lang.go @@ -63,6 +63,8 @@ type Lang struct { closeChan chan struct{} // close signal wg *sync.WaitGroup + + Logf func(format string, v ...interface{}) } // Init initializes the lang struct, and starts up the initial data sources. @@ -78,67 +80,9 @@ func (obj *Lang) Init() error { once := &sync.Once{} loadedSignal := func() { close(obj.loadedChan) } // only run once! - // run the lexer/parser and build an AST - log.Printf("%s: Lexing/Parsing...", Name) - ast, err := LexParse(obj.Input) - if err != nil { - return errwrap.Wrapf(err, "could not generate AST") - } - if obj.Debug { - log.Printf("%s: behold, the AST: %+v", Name, ast) - } - - // TODO: should we validate the structure of the AST? - // TODO: should we do this *after* interpolate, or trust it to behave? - //if err := ast.Validate(); err != nil { - // return errwrap.Wrapf(err, "could not validate AST") - //} - - log.Printf("%s: Interpolating...", Name) - // interpolate strings and other expansionable nodes in AST - interpolated, err := ast.Interpolate() - if err != nil { - return errwrap.Wrapf(err, "could not interpolate AST") - } - obj.ast = interpolated - - // top-level, built-in, initial global scope - scope := &interfaces.Scope{ - Variables: map[string]interfaces.Expr{ - "purpleidea": &ExprStr{V: "hello world!"}, // james says hi - // TODO: change to a func when we can change hostname dynamically! - "hostname": &ExprStr{V: obj.Hostname}, - }, - } - - log.Printf("%s: Building Scope...", Name) - // propagate the scope down through the AST... - if err := obj.ast.SetScope(scope); err != nil { - return errwrap.Wrapf(err, "could not set scope") - } - - // apply type unification - logf := func(format string, v ...interface{}) { - if obj.Debug { // unification only has debug messages... - log.Printf(Name+": unification: "+format, v...) - } - } - log.Printf("%s: Running Type Unification...", Name) - if err := unification.Unify(obj.ast, unification.SimpleInvariantSolverLogger(logf)); err != nil { - return errwrap.Wrapf(err, "could not unify types") - } - - log.Printf("%s: Building Function Graph...", Name) - // we assume that for some given code, the list of funcs doesn't change - // iow, we don't support variable, variables or absurd things like that - graph, err := obj.ast.Graph() // build the graph of functions + graph, err := obj.Compile() if err != nil { - return errwrap.Wrapf(err, "could not generate function graph") - } - - if obj.Debug { - log.Printf("%s: function graph: %+v", Name, graph) - graph.Logf("%s: ", Name) // log graph with this printf prefix... + return err } if graph.NumVertices() == 0 { // no funcs to load! @@ -165,7 +109,7 @@ func (obj *Lang) Init() error { World: obj.World, Debug: obj.Debug, Logf: func(format string, v ...interface{}) { - log.Printf(Name+": funcs: "+format, v...) + log.Printf(Name+"%s: "+format, v...) }, Glitch: false, // FIXME: verify this functionality is perfect! } @@ -267,3 +211,81 @@ func (obj *Lang) Close() error { obj.wg.Wait() return err } + +// Compile takes the lang input code and turns it into a the graph™ +func (obj *Lang) Compile() (*pgraph.Graph, error) { + // run the lexer/parser and build an AST + obj.Logf("Lexing/Parsing...") + ast, err := LexParse(obj.Input) + if err != nil { + return nil, errwrap.Wrapf(err, "could not generate AST") + } + if obj.Debug { + obj.Logf("behold, the AST: %+v", ast) + } + + // TODO: should we validate the structure of the AST? + // TODO: should we do this *after* interpolate, or trust it to behave? + //if err := ast.Validate(); err != nil { + // return nil, errwrap.Wrapf(err, "could not validate AST") + //} + + obj.Logf("Interpolating...") + // interpolate strings and other expansionable nodes in AST + interpolated, err := ast.Interpolate() + if err != nil { + return nil, errwrap.Wrapf(err, "could not interpolate AST") + } + obj.ast = interpolated + + // top-level, built-in, initial global scope + scope := &interfaces.Scope{ + Variables: map[string]interfaces.Expr{ + "purpleidea": &ExprStr{V: "hello world!"}, // james says hi + // TODO: change to a func when we can change hostname dynamically! + "hostname": &ExprStr{V: obj.Hostname}, + }, + } + + obj.Logf("Building Scope...") + // propagate the scope down through the AST... + if err := obj.ast.SetScope(scope); err != nil { + return nil, errwrap.Wrapf(err, "could not set scope") + } + + // apply type unification + logf := func(format string, v ...interface{}) { + if obj.Debug { // unification only has debug messages... + obj.Logf(Name+": unification: "+format, v...) + } + } + obj.Logf("Running Type Unification...") + if err := unification.Unify(obj.ast, unification.SimpleInvariantSolverLogger(logf)); err != nil { + return nil, errwrap.Wrapf(err, "could not unify types") + } + + obj.Logf("Building Function Graph...") + // we assume that for some given code, the list of funcs doesn't change + // iow, we don't support variable, variables or absurd things like that + graph, err := obj.ast.Graph() // build the graph of functions + if err != nil { + return nil, errwrap.Wrapf(err, "could not generate function graph") + } + + if obj.Debug { + obj.Logf("function graph: %+v", graph) + graph.Logf("%s: ", Name) // log graph with this printf prefix... + } + + return graph, nil +} + +// Validate performs as much validation of the Input as it can without starting the func engine +func (obj *Lang) Validate() error { + _, err := obj.Compile() + if err != nil { + return err + } + + return err +} diff --git a/lang/lang_test.go b/lang/lang_test.go index 74869e9fe..134b2e4df 100644 --- a/lang/lang_test.go +++ b/lang/lang_test.go @@ -19,6 +19,7 @@ package lang import ( "fmt" + "log" "strings" "testing" @@ -85,6 +86,9 @@ func runInterpret(code string) (*pgraph.Graph, error) { lang := &Lang{ Input: str, // string as an interface that satisfies io.Reader Debug: true, + Logf: func(format string, v ...interface{}) { + log.Printf(Name+"%s: "+format, v...) + }, } if err := lang.Init(); err != nil { return nil, errwrap.Wrapf(err, "init failed") diff --git a/lib/cli.go b/lib/cli.go index 9c0a4fc65..413e9878f 100644 --- a/lib/cli.go +++ b/lib/cli.go @@ -423,6 +423,18 @@ func CLI(program, version string, flags Flags) error { Action: run, Flags: runFlags, }, + { + Name: "validate", + Aliases: []string{"v"}, + Usage: "validate", + Action: validate, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "lang, l", + Usage: "lang file to validate", + }, + }, + }, { Name: "deploy", Aliases: []string{"d"}, diff --git a/lib/cli_test.go b/lib/cli_test.go new file mode 100644 index 000000000..aad8505b4 --- /dev/null +++ b/lib/cli_test.go @@ -0,0 +1,93 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package lib + +import ( + "flag" + "io/ioutil" + "strings" + "testing" + + errwrap "github.com/pkg/errors" + + "github.com/urfave/cli" +) + +// TestValidateCliPass tests if cli invocation of validation is able to pass on valid files +func TestValidateCliPass(t *testing.T) { + tmpFile, err := ioutil.TempFile("", "pass.mcl") + if err != nil { + t.Fatal(errwrap.Wrapf(err, "can't create temp file")) + } + filePath := tmpFile.Name() // path to temp file + defer tmpFile.Close() + if _, err := tmpFile.Write([]byte("$x = 1")); err != nil { + t.Fatal(errwrap.Wrapf(err, "can't write file")) + } + + // create command line arguments for validating the file + set := flag.NewFlagSet("test", 0) + set.String("lang", filePath, "doc") + ctx := cli.NewContext(nil, set, nil) + + // invoke validate subcommand with arguments to validate the file + if err := validate(ctx); err != nil { + t.Fatal(errwrap.Wrapf(err, "valid file should pass validation")) + } +} + +// TestValidateCliFail tests if cli invocation of validation is able to fail on invalid files +func TestValidateCliFail(t *testing.T) { + tmpFile, err := ioutil.TempFile("", "fail.mcl") + if err != nil { + t.Fatal(errwrap.Wrapf(err, "can't create temp file")) + } + filePath := tmpFile.Name() // path to temp file + defer tmpFile.Close() + if _, err := tmpFile.Write([]byte("$x = 1; $x = 2")); err != nil { + t.Fatal(errwrap.Wrapf(err, "can't write file")) + } + + // create command line arguments for validating the file + set := flag.NewFlagSet("test", 0) + set.String("lang", filePath, "doc") + ctx := cli.NewContext(nil, set, nil) + + // invoke validate subcommand with arguments to validate the file + if err := validate(ctx); err == nil { + t.Fatal(errwrap.Wrapf(err, "invalid file should _not_ pass validation")) + } +} + +// TestValidateError tests if validation fails on codes with different kind of invalid syntax +func TestValidateError(t *testing.T) { + samples := []string{ + `file "/tmp/mgmt" { content => "test" }`, // syntaxerror, missing trailing comma + "$x = 1; $x = 2", // scoperror, double assignment + "$x = $x1 + 1", // unificationerror, $x1 does not exist + `$y = ""; $x = $y + 1`, // unificationerror, types don't match + } + for _, s := range samples { + t.Run("", func(st *testing.T) { + sample := strings.NewReader(s) + if err := validateCode(sample); err == nil { + st.Fatal("example should _not_ pass validation") + } + }) + } +} diff --git a/lib/validate.go b/lib/validate.go new file mode 100644 index 000000000..e9c376c5b --- /dev/null +++ b/lib/validate.go @@ -0,0 +1,72 @@ +// Mgmt +// Copyright (C) 2013-2018+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package lib + +import ( + "bufio" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + + errwrap "github.com/pkg/errors" + "github.com/purpleidea/mgmt/lang" + "github.com/urfave/cli" +) + +// validate handles the cli logic and reading code from a file/stdin for validation +func validate(c *cli.Context) error { + filepath := c.String("lang") + + if len(filepath) == 0 { + return fmt.Errorf("please provide path for file to validate") + } + + var code io.Reader + // allow to use - to read file from stdin + if filepath == "-" { + code = bufio.NewReader(os.Stdin) + } else { + filecontent, err := ioutil.ReadFile(filepath) + if err != nil { + return errwrap.Wrapf(err, "can't read code from file `%s`", filepath) + } + code = strings.NewReader(string(filecontent)) + } + + err := validateCode(code) + + if err != nil { + // TODO: change format to some open generic error format supported by CI tools (line number etc)? + if filepath != "-" { + return errwrap.Wrapf(err, filepath) + } + return err + } + + return err +} + +// validateCode validates code from a reader +func validateCode(code io.Reader) error { + // validate the code, suppressing normal compile log output + obj := &lang.Lang{Input: code, Logf: func(format string, v ...interface{}) {}} + err := obj.Validate() + return err +} diff --git a/test.sh b/test.sh index 8c796e8b9..2e1c61cfb 100755 --- a/test.sh +++ b/test.sh @@ -52,7 +52,9 @@ run-testsuite ./test/test-markdownlint.sh run-testsuite ./test/test-commit-message.sh run-testsuite ./test/test-govet.sh run-testsuite ./test/test-examples.sh +run-testsuite ./test/test-examples-validate.sh run-testsuite ./test/test-gotest.sh +run-testsuite ./test/test-integration.sh # skipping: https://github.com/purpleidea/mgmt/issues/327 # run-test ./test/test-crossbuild.sh diff --git a/test/test-commit-message.sh b/test/test-commit-message.sh index ff21bdec6..e240ce7b0 100755 --- a/test/test-commit-message.sh +++ b/test/test-commit-message.sh @@ -56,9 +56,10 @@ travis_regex='^\([a-z0-9]\(\(, \)\|[a-z0-9]\)\+[a-z0-9]: \)\+[A-Z0-9][^:]\+[^:.] test_commit_message() { echo "Testing commit message $1" - if ! git log --format=%s $1 | head -n 1 | grep -q "$travis_regex" + msg="$(git log --format=%s "$1" | head -n 1)" + if ! echo "$msg" | grep -q "$travis_regex" then - echo "FAIL: Commit message should match the following regex: '$travis_regex'" + echo "FAIL: Commit message '$msg' should match the following regex: '$travis_regex'" echo echo "eg:" echo "prometheus: Implement rest api" @@ -69,42 +70,43 @@ test_commit_message() { test_commit_message_common_bugs() { echo "Testing commit message for common bugs $1" - if git log --format=%s $1 | head -n 1 | grep -q "^resource:" + if git log --format=%s "$1" | head -n 1 | grep -q "^resource:" then echo 'FAIL: Commit message starts with `resource:`, did you mean `resources:` ?' exit 1 fi - if git log --format=%s $1 | head -n 1 | grep -q "^tests:" + if git log --format=%s "$1" | head -n 1 | grep -q "^tests:" then echo 'FAIL: Commit message starts with `tests:`, did you mean `test:` ?' exit 1 fi - if git log --format=%s $1 | head -n 1 | grep -q "^doc:" + if git log --format=%s "$1" | head -n 1 | grep -q "^doc:" then echo 'FAIL: Commit message starts with `doc:`, did you mean `docs:` ?' exit 1 fi - if git log --format=%s $1 | head -n 1 | grep -q "^example:" + if git log --format=%s "$1" | head -n 1 | grep -q "^example:" then echo 'FAIL: Commit message starts with `example:`, did you mean `examples:` ?' exit 1 fi - if git log --format=%s $1 | head -n 1 | grep -q "^language:" + if git log --format=%s "$1" | head -n 1 | grep -q "^language:" then echo 'FAIL: Commit message starts with `language:`, did you mean `lang:` ?' exit 1 fi } -if [[ -n "$TRAVIS_PULL_REQUEST_SHA" ]] -then - commits=$(git log --format=%H origin/${TRAVIS_BRANCH}..${TRAVIS_PULL_REQUEST_SHA}) - [[ -n "$commits" ]] - - for commit in $commits - do - test_commit_message $commit - test_commit_message_common_bugs $commit - done +# use information from Travis to determine commits to test, fallback to optimistic values when not runnning on travis +commits=$(git log --format=%H origin/${TRAVIS_BRANCH:-master}..${TRAVIS_PULL_REQUEST_SHA:-HEAD}) +if [[ -z "$commits" ]]; then + echo "FAIL: No commits between origin/${TRAVIS_BRANCH:-master} and ${TRAVIS_PULL_REQUEST_SHA:-HEAD}" + exit 1 fi + +for commit in $commits +do + test_commit_message "$commit" + test_commit_message_common_bugs "$commit" +done echo 'PASS' diff --git a/test/test-examples-validate.sh b/test/test-examples-validate.sh new file mode 100755 index 000000000..fee0fd2f9 --- /dev/null +++ b/test/test-examples-validate.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# validate the examples using mgmt validate + +set -e + +# shellcheck disable=SC1091 +. test/util.sh + +echo running "$0" + +ROOT=$(dirname "${BASH_SOURCE}")/.. +cd "${ROOT}" + +failures='' + +# validate .mcl examples +for file in $(find examples/lang/ -maxdepth 3 -type f -name '*.mcl'); do + $MGMT validate --lang "$file" || fail_test "file did not pass validation: $file" +done + +if [[ -n "$failures" ]]; then + echo 'FAIL' + echo "The following tests (in: examples/lang/) have failed:" + echo -e "$failures" + echo + exit 1 +fi +echo 'PASS' diff --git a/test/test-gotest.sh b/test/test-gotest.sh index 17cad7e75..c58cd2b67 100755 --- a/test/test-gotest.sh +++ b/test/test-gotest.sh @@ -14,14 +14,19 @@ function run-test() } base=$(go list .) -for pkg in `go list -e ./... | grep -v "^${base}/vendor/" | grep -v "^${base}/examples/" | grep -v "^${base}/test/" | grep -v "^${base}/old/" | grep -v "^${base}/tmp/"`; do +for pkg in `go list -e ./... | grep -v "^${base}/vendor/" | grep -v "^${base}/examples/" | grep -v "^${base}/test/" | grep -v "^${base}/old/" | grep -v "^${base}/tmp/" | grep -v "^${base}/integration/"`; do echo -e "\ttesting: $pkg" run-test go test "$pkg" if [ "$1" = "--race" ]; then + shift run-test go test -race "$pkg" fi done +if [[ "$@" = *"--integration"* ]]; then + run-test go test github.com/purpleidea/mgmt/integration/ +fi + if [[ -n "$failures" ]]; then echo 'FAIL' echo 'The following `go test` runs have failed:' diff --git a/test/test-integration.sh b/test/test-integration.sh new file mode 100755 index 000000000..f534951d3 --- /dev/null +++ b/test/test-integration.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +echo running "$0" "$@" + +#ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )" # dir! +ROOT=$(dirname "${BASH_SOURCE}")/.. +cd "${ROOT}" +. test/util.sh + +failures='' +function run-test() +{ + $@ || failures=$( [ -n "$failures" ] && echo "$failures\\n$@" || echo "$@" ) +} + +run-test go test github.com/purpleidea/mgmt/integration/ + +if [[ -n "$failures" ]]; then + echo 'FAIL' + echo 'The following `go test` runs have failed:' + echo -e "$failures" + echo + exit 1 +fi +echo 'PASS'