// Mgmt // Copyright (C) 2013-2022+ 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 . //go:build !root package resources import ( "fmt" "io/ioutil" "os" "os/user" "path" "strconv" "strings" "sync" "syscall" "testing" "time" "github.com/purpleidea/mgmt/engine" "github.com/purpleidea/mgmt/pgraph" "github.com/purpleidea/mgmt/util" "github.com/purpleidea/mgmt/util/errwrap" ) // TODO: consider providing this as a lib so that we can add tests into the // specific _test.go file of each resource. // makeRes is a helper function to build a res. It should only be called in // tests, because it panics if something goes wrong. func makeRes(kind, name string) engine.Res { res, err := engine.NewNamedResource(kind, name) if err != nil { panic(fmt.Sprintf("could not create resource: %+v", err)) } return res } // Step is used for the timeline in tests. type Step interface { Action() error Expect() error } type manualStep struct { action func() error expect func() error } func (obj *manualStep) Action() error { return obj.action() } func (obj *manualStep) Expect() error { return obj.expect() } // NewManualStep creates a new manual step with an action and an expect test. func NewManualStep(action, expect func() error) Step { return &manualStep{ action: action, expect: expect, } } type startupStep struct { ms uint ch chan struct{} // set by test harness } func (obj *startupStep) Action() error { select { case <-obj.ch: // called by Running() in Watch case <-time.After(time.Duration(obj.ms) * time.Millisecond): return fmt.Errorf("took too long to startup") } return nil } func (obj *startupStep) Expect() error { return nil } // NewStartupStep waits up to this many ms for the Watch function to startup. func NewStartupStep(ms uint) Step { return &startupStep{ ms: ms, } } type changedStep struct { ms uint expect bool // what checkOK value we're expecting ch chan bool // set by test harness, filled with checkOK values } func (obj *changedStep) Action() error { select { case checkOK, ok := <-obj.ch: // from CheckApply() in test Process loop if !ok { return fmt.Errorf("channel closed unexpectedly") } if checkOK != obj.expect { return fmt.Errorf("got unexpected checkOK value of: %t", checkOK) } case <-time.After(time.Duration(obj.ms) * time.Millisecond): return fmt.Errorf("took too long to startup") } return nil } func (obj *changedStep) Expect() error { return nil } // NewChangedStep waits up to this many ms for a CheckApply action to occur. // Watch function to startup. func NewChangedStep(ms uint, expect bool) Step { return &changedStep{ ms: ms, expect: expect, } } type clearChangedStep struct { ms uint ch chan bool // set by test harness, filled with checkOK values } func (obj *clearChangedStep) Action() error { // read all pending events... for { select { case _, ok := <-obj.ch: // from CheckApply() in test Process loop if !ok { return fmt.Errorf("channel closed unexpectedly") } case <-time.After(time.Duration(obj.ms) * time.Millisecond): return nil // done waiting } } } func (obj *clearChangedStep) Expect() error { return nil } // NewClearChangedStep waits up to this many ms for additional CheckApply // actions to occur, and flushes them all so that a future NewChangedStep won't // see unwanted events. func NewClearChangedStep(ms uint) Step { return &clearChangedStep{ ms: ms, } } // FileExpect takes a path and a string to expect in that file, and builds a // Step that checks that out of them. func FileExpect(p, s string) Step { // path & string return &manualStep{ action: func() error { return nil }, expect: func() error { content, err := ioutil.ReadFile(p) if err != nil { return err } if string(content) != s { return fmt.Errorf("contents did not match in %s", p) } return nil }, } } // FileOwnerExpect takes a path and a uid to expect from that file, and builds a // Step that checks that out of them. func FileOwnerExpect(p, o string) Step { // path & owner return &manualStep{ action: func() error { return nil }, expect: func() error { var stat syscall.Stat_t if err := syscall.Stat(p, &stat); err != nil { return err } i, err := strconv.ParseUint(o, 10, 32) if err != nil { return err } if i != uint64(stat.Uid) { return fmt.Errorf("file uid did not match in %s", p) } return nil }, } } // FileWrite takes a path and a string to write to that file, and builds a Step // that does that to them. func FileWrite(p, s string) Step { // path & string return &manualStep{ action: func() error { // TODO: apparently using 0666 is equivalent to respecting the current umask const umask = 0666 return ioutil.WriteFile(p, []byte(s), umask) }, expect: func() error { return nil }, } } // ErrIsNotExistOK returns nil if we get an IsNotExist true result on the error. func ErrIsNotExistOK(e error) error { if os.IsNotExist(e) { return nil } return errwrap.Wrapf(e, "unexpected error") } // GetUID returns the UID of the user running this test. func GetUID() (string, error) { u, err := user.Lookup(os.Getenv("USER")) if err != nil { return "", err } return u.Uid, nil } func TestResources1(t *testing.T) { type test struct { // an individual test name string res engine.Res // a resource fail bool experr error // expected error if fail == true (nil ignores it) experrstr string // expected error prefix timeline []Step // TODO: this could be a generator that keeps pushing out steps until it's done! expect func() error // function to check for expected state startup func() error // function to run as startup cleanup func() error // function to run as cleanup } // helpers // TODO: make a series of helps to orchestrate the resources (eg: edit // file, wait for event w/ timeout, run command w/ timeout, etc...) sleep := func(ms uint) Step { return &manualStep{ action: func() error { time.Sleep(time.Duration(ms) * time.Millisecond) return nil }, expect: func() error { return nil }, } } testCases := []test{} { r := makeRes("file", "r1") res := r.(*FileRes) // if this panics, the test will panic p := "/tmp/whatever" s := "hello, world\n" res.Path = p res.State = FileStateExists contents := s res.Content = &contents timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, false), // did we do something? FileExpect(p, s), // check initial state NewClearChangedStep(1000 * 15), // did we do something? FileWrite(p, "this is whatever\n"), // change state NewChangedStep(1000*60, false), // did we do something? FileExpect(p, s), // check again sleep(1), // we can sleep too! } testCases = append(testCases, test{ name: "simple file", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.Remove(p) }, }) } { r := makeRes("exec", "x1") res := r.(*ExecRes) // if this panics, the test will panic s := "hello, world" f := "/tmp/whatever" res.Cmd = fmt.Sprintf("echo '%s' > '%s'", s, f) res.Shell = "/bin/bash" res.IfCmd = "! diff <(cat /tmp/whatever) <(echo hello, world)" res.IfShell = "/bin/bash" res.WatchCmd = fmt.Sprintf("/usr/bin/inotifywait -e modify -m %s", f) //res.WatchShell = "/bin/bash" timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, false), // did we do something? FileExpect(f, s+"\n"), // check initial state NewClearChangedStep(1000 * 15), // did we do something? FileWrite(f, "this is stuff!\n"), // change state NewChangedStep(1000*60, false), // did we do something? FileExpect(f, s+"\n"), // check again sleep(1), // we can sleep too! } testCases = append(testCases, test{ name: "simple exec", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, // build file for inotifywait startup: func() error { return ioutil.WriteFile(f, []byte("starting...\n"), 0666) }, cleanup: func() error { return os.Remove(f) }, }) } { r := makeRes("exec", "x2") res := r.(*ExecRes) // if this panics, the test will panic res.Env = map[string]string{ "boiling": "one hundred", } f := "/tmp/whatever" res.Cmd = fmt.Sprintf("env | grep boiling > %s", f) res.Shell = "/bin/bash" res.IfCmd = "! diff <(cat /tmp/whatever) <(echo boiling=one hundred)" res.IfShell = "/bin/bash" res.WatchCmd = fmt.Sprintf("/usr/bin/inotifywait -e modify -m %s", f) res.WatchShell = "/bin/bash" timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, false), // did we do something? FileExpect(f, "boiling=one hundred\n"), // check initial state NewClearChangedStep(1000 * 15), // did we do something? FileWrite(f, "this is stuff!\n"), // change state NewChangedStep(1000*60, false), // did we do something? FileExpect(f, "boiling=one hundred\n"), // check again sleep(1), // we can sleep too! } testCases = append(testCases, test{ name: "exec with env", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, // build file for inotifywait startup: func() error { return ioutil.WriteFile(f, []byte("starting...\n"), 0666) }, cleanup: func() error { return os.Remove(f) }, }) } { r := makeRes("file", "r1") res := r.(*FileRes) // if this panics, the test will panic p := "/tmp/emptyfile" res.Path = p res.State = FileStateExists timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, false), // did we do something? FileExpect(p, ""), // check initial state NewClearChangedStep(1000 * 15), // did we do something? } testCases = append(testCases, test{ name: "touch file", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.Remove(p) }, }) } { r := makeRes("file", "r1") res := r.(*FileRes) // if this panics, the test will panic p := "/tmp/existingfile" res.Path = p res.State = FileStateExists content := "some existing text\n" timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, true), // did we do something? FileExpect(p, content), // check initial state } testCases = append(testCases, test{ name: "existing file", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, startup: func() error { return ioutil.WriteFile(p, []byte(content), 0666) }, cleanup: func() error { return os.Remove(p) }, }) } { r := makeRes("file", "r1") res := r.(*FileRes) // if this panics, the test will panic p := "/tmp/ownerfile" uid, _ := GetUID() res.Path = p res.State = FileStateExists res.Owner = uid content := "some test file owned by uid " + uid timeline := []Step{ NewStartupStep(1000 * 60), // startup NewChangedStep(1000*60, true), // did we do something? FileExpect(p, content), // check file content FileOwnerExpect(p, uid), // check uid of the file } testCases = append(testCases, test{ name: "uid test file", res: res, fail: false, timeline: timeline, expect: func() error { return nil }, startup: func() error { return ioutil.WriteFile(p, []byte(content), 0666) }, cleanup: func() error { return os.Remove(p) }, }) } names := []string{} for index, tc := range testCases { // run all the tests if tc.name == "" { t.Errorf("test #%d: not named", index) continue } if util.StrInList(tc.name, names) { t.Errorf("test #%d: duplicate sub test name of: %s", index, tc.name) continue } names = append(names, tc.name) t.Run(fmt.Sprintf("test #%d (%s)", index, tc.name), func(t *testing.T) { res, fail, experr, experrstr, timeline, expect, startup, cleanup := tc.res, tc.fail, tc.experr, tc.experrstr, tc.timeline, tc.expect, tc.startup, tc.cleanup t.Logf("\n\ntest #%d: Res: %+v\n", index, res) defer t.Logf("test #%d: done!", index) // run validate! err := res.Validate() if !fail && err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not validate Res: %+v", index, err) return } if fail && err == nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: validate passed, expected fail", index) return } if fail && experr != nil && err != experr { // test for specific error! t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expected validate fail, got wrong error", index) t.Errorf("test #%d: got error: %+v", index, err) t.Errorf("test #%d: exp error: %+v", index, experr) return } // test for specific error string! if fail && experrstr != "" && !strings.HasPrefix(err.Error(), experrstr) { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expected validate fail, got wrong error", index) t.Errorf("test #%d: got error: %s", index, err.Error()) t.Errorf("test #%d: exp error: %s", index, experrstr) return } if fail && err != nil { t.Logf("test #%d: err: %+v", index, err) } changedChan := make(chan bool, 1) // buffered! readyChan := make(chan struct{}) eventChan := make(chan struct{}) doneChan := make(chan struct{}) debug := testing.Verbose() // set via the -test.v flag to `go test` logf := func(format string, v ...interface{}) { t.Logf(fmt.Sprintf("test #%d: ", index)+format, v...) } init := &engine.Init{ Running: func() { close(readyChan) select { // this always sends one! case eventChan <- struct{}{}: } }, // Watch runs this to send a changed event. Event: func() { select { case eventChan <- struct{}{}: } }, // Watch listens on this for close/pause events. Done: doneChan, Debug: debug, Logf: logf, // unused Send: func(st interface{}) error { return nil }, Recv: func() map[string]*engine.Send { return map[string]*engine.Send{} }, } if startup != nil { t.Logf("test #%d: running startup()", index) if err := startup(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not startup: %+v", index, err) } } // run init t.Logf("test #%d: running Init", index) err = res.Init(init) defer func() { if cleanup == nil { return } t.Logf("test #%d: running cleanup()", index) if err := cleanup(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not cleanup: %+v", index, err) } }() closeFn := func() { // run close (we don't ever expect an error on close!) t.Logf("test #%d: running Close", index) if err := res.Close(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not close Res: %+v", index, err) //return } } if !fail && err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not init Res: %+v", index, err) return } if fail && err == nil { closeFn() // close if Init didn't fail t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: init passed, expected fail", index) return } if fail && experr != nil && err != experr { // test for specific error! t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expected init fail, got wrong error", index) t.Errorf("test #%d: got error: %+v", index, err) t.Errorf("test #%d: exp error: %+v", index, experr) return } // test for specific error string! if fail && experrstr != "" && !strings.HasPrefix(err.Error(), experrstr) { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expected init fail, got wrong error", index) t.Errorf("test #%d: got error: %s", index, err.Error()) t.Errorf("test #%d: exp error: %s", index, experrstr) return } if fail && err != nil { t.Logf("test #%d: err: %+v", index, err) } defer closeFn() // run watch wg := &sync.WaitGroup{} defer wg.Wait() // if we return early wg.Add(1) go func() { defer wg.Done() t.Logf("test #%d: running Watch", index) if err := res.Watch(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: Watch failed: %s", index, err.Error()) } close(eventChan) // done with this part }() // TODO: can we block here if the test fails early? select { case <-readyChan: // called by Running() in Watch } wg.Add(1) go func() { // run timeline t.Logf("test #%d: executing timeline", index) defer wg.Done() for ix, step := range timeline { // magic setting of important values... if s, ok := step.(*startupStep); ok { s.ch = readyChan } if s, ok := step.(*changedStep); ok { s.ch = changedChan } if s, ok := step.(*clearChangedStep); ok { s.ch = changedChan } t.Logf("test #%d: step(%d)...", index, ix) if err := step.Action(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: step(%d) action failed: %s", index, ix, err.Error()) break } if err := step.Expect(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: step(%d) expect failed: %s", index, ix, err.Error()) break } } t.Logf("test #%d: shutting down Watch", index) close(doneChan) // send Watch shutdown command }() Loop: for { select { case _, ok := <-eventChan: // from Watch() if !ok { //t.Logf("test #%d: break!", index) break Loop } } t.Logf("test #%d: running CheckApply", index) checkOK, err := res.CheckApply(true) // no noop! if err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: CheckApply failed: %s", index, err.Error()) return } //t.Logf("test #%d: CheckApply(true) (%t, %+v)", index, checkOK, err) select { // send a msg if we can, but never block case changedChan <- checkOK: default: } } t.Logf("test #%d: waiting for shutdown", index) wg.Wait() if err := expect(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expect failed: %s", index, err.Error()) return } // all done! }) } } // TestResources2 just tests a partial execution of the resource by running // CheckApply and Reverse and basics without the mainloop. It's a less accurate // representation of a running resource, but is still useful for many // circumstances. This also uses a simpler timeline, because it was not possible // to get the reference passing of the reversed resource working with the fancy // version. func TestResources2(t *testing.T) { type test struct { // an individual test name string timeline []func() error // TODO: this could be a generator that keeps pushing out steps until it's done! expect func() error // function to check for expected state startup func() error // function to run as startup (unused?) cleanup func() error // function to run as cleanup } type initOptions struct { // graph is the graph that should be passed in with Init graph *pgraph.Graph // TODO: add more options if needed // logf specifies the log function for Init to pass through... logf func(format string, v ...interface{}) } type initOption func(*initOptions) addGraph := func(graph *pgraph.Graph) initOption { return func(io *initOptions) { io.graph = graph } } addLogf := func(logf func(format string, v ...interface{})) initOption { return func(io *initOptions) { io.logf = logf } } // resValidate runs Validate on the res. resValidate := func(res engine.Res) func() error { // run Close return func() error { return res.Validate() } } // resInit runs Init on the res. resInit := func(res engine.Res, opts ...initOption) func() error { io := &initOptions{} // defaults for _, optionFunc := range opts { // apply the options optionFunc(io) } logf := func(format string, v ...interface{}) { if io.logf == nil { return } io.logf(fmt.Sprintf("test: ")+format+"\n", v...) } init := &engine.Init{ //Debug: debug, Logf: logf, // unused Send: func(st interface{}) error { return nil }, Recv: func() map[string]*engine.Send { return map[string]*engine.Send{} }, // Copied from state.go FilteredGraph: func() (*pgraph.Graph, error) { //graph, err := pgraph.NewGraph("filtered") //if err != nil { // return nil, errwrap.Wrapf(err, "could not create graph") //} // Hack: We just add ourself as allowed since // we're just a one-vertex test suite... //graph.AddVertex(res) // hack! //return graph, nil // we return in a func so it's fresh! if io.graph == nil { return nil, fmt.Errorf("use addGraph to add one here") } return io.graph, nil }, } // run Init return func() error { return res.Init(init) } } // resCheckApplyError runs CheckApply with noop = false for the res. It // errors if the returned checkOK values isn't what we were expecting or // if the errOK function returns an error when given a chance to inspect // the returned error. resCheckApplyError := func(res engine.Res, expCheckOK bool, errOK func(e error) error) func() error { return func() error { checkOK, err := res.CheckApply(true) // no noop! if e := errOK(err); e != nil { return errwrap.Wrapf(e, "error from CheckApply did not match expected") } if checkOK != expCheckOK { return fmt.Errorf("result from CheckApply did not match expected: got: %t exp: %t", checkOK, expCheckOK) } return nil } } // resCheckApply runs CheckApply with noop = false for the res. It // errors if the returned checkOK values isn't what we were expecting or // if there was an error. resCheckApply := func(res engine.Res, expCheckOK bool) func() error { errOK := func(e error) error { if e == nil { return nil } return errwrap.Wrapf(e, "unexpected error from CheckApply") } return resCheckApplyError(res, expCheckOK, errOK) } // resClose runs Close on the res. resClose := func(res engine.Res) func() error { // run Close return func() error { return res.Close() } } // resReversal runs Reverse on the resource and stores the result in the // rev variable. This should be called before the res CheckApply, and // usually before Init, but after Validate. resReversal := func(res engine.Res, rev *engine.Res) func() error { return func() error { r, ok := res.(engine.ReversibleRes) if !ok { return fmt.Errorf("res is not a ReversibleRes") } // We don't really need this to be checked here. //if r.ReversibleMeta().Disabled { // return fmt.Errorf("res did not specify Meta:reverse") //} if r.ReversibleMeta().Reversal { //logf("triangle reversal") // warn! } reversed, err := r.Reversed() if err != nil { return errwrap.Wrapf(err, "could not reverse: %s", r.String()) } if reversed == nil { return nil // this can't be reversed, or isn't implemented here } reversed.ReversibleMeta().Reversal = true // set this for later... retRes, ok := reversed.(engine.Res) if !ok { return fmt.Errorf("not a Res") } *rev = retRes // store! return nil } } fileWrite := func(p, s string) func() error { // write the file to path return func() error { return ioutil.WriteFile(p, []byte(s), 0666) } } fileExpect := func(p, s string) func() error { // check the contents at the path match the string we expect return func() error { content, err := ioutil.ReadFile(p) if err != nil { return err } if string(content) != s { return fmt.Errorf("contents did not match in %s", p) } return nil } } fileExists := func(p string, dir bool) func() error { // does the file exist? return func() error { fi, err := os.Stat(p) if err != nil { return fmt.Errorf("file was supposed to be present, got: %+v", err) } if fi.IsDir() != dir { if dir { return fmt.Errorf("not a dir") } return fmt.Errorf("not a regular file") } return nil } } fileAbsent := func(p string) func() error { // does the file exist? return func() error { _, err := os.Stat(p) if err == nil { return fmt.Errorf("file exists, expecting absent") } if !os.IsNotExist(err) { return fmt.Errorf("file was supposed to be absent, got: %+v", err) } return nil } } fileRemove := func(p string) func() error { // remove the file at path return func() error { err := os.Remove(p) // if the file isn't there, don't error if err != nil && !os.IsNotExist(err) { return err } return nil } } fileMkdir := func(p string, all bool) func() error { // mkdir at the path return func() error { if all { return os.MkdirAll(p, 0777) } return os.Mkdir(p, 0777) } } testCases := []test{} { //file "/tmp/somefile" { // state => $const.res.file.state.exists, // content => "some new text\n", //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateExists content := "some new text\n" res.Content = &content timeline := []func() error{ fileWrite(p, "whatever"), resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), resCheckApply(r1, true), // it's already good resClose(r1), fileExpect(p, content), // ensure it exists } testCases = append(testCases, test{ name: "simple file", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // # state is NOT specified // content => "some new text\n", //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p //res.State = FileStateExists // not specified! content := "some new text\n" res.Content = &content timeline := []func() error{ fileWrite(p, "whatever"), resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), resCheckApply(r1, true), // it's already good resClose(r1), fileExpect(p, content), // ensure it exists } testCases = append(testCases, test{ name: "edit file only", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // # state is NOT specified // content => "some new text\n", //} // and no existing file exists! (therefore we want an error!) r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p //res.State = FileStateExists // not specified! content := "some new text\n" res.Content = &content timeline := []func() error{ fileRemove(p), // nothing here resValidate(r1), resInit(r1), resCheckApplyError(r1, false, ErrIsNotExistOK), // should error resCheckApplyError(r1, false, ErrIsNotExistOK), // double check resClose(r1), fileAbsent(p), // ensure it's absent } testCases = append(testCases, test{ name: "strict file", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // state => $const.res.file.state.absent, //} // and no existing file exists! r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateAbsent timeline := []func() error{ fileRemove(p), // nothing here resValidate(r1), resInit(r1), resCheckApply(r1, true), resCheckApply(r1, true), resClose(r1), fileAbsent(p), // ensure it's absent } testCases = append(testCases, test{ name: "absent file", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // state => $const.res.file.state.absent, //} // and a file already exists! r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateAbsent timeline := []func() error{ fileWrite(p, "whatever"), resValidate(r1), resInit(r1), resCheckApply(r1, false), resCheckApply(r1, true), resClose(r1), fileAbsent(p), // ensure it's absent } testCases = append(testCases, test{ name: "absent file pre-existing", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // content => "some new text\n", // state => $const.res.file.state.exists, // // Meta:reverse => true, //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateExists content := "some new text\n" res.Content = &content original := "this is the original state\n" // original state var r2 engine.Res // future reversed resource timeline := []func() error{ fileWrite(p, original), fileExpect(p, original), resValidate(r1), resReversal(r1, &r2), // runs in Init to snapshot func() error { // random test if st := r2.(*FileRes).State; st != "absent" { return fmt.Errorf("unexpected state: %s", st) } return nil }, resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), resCheckApply(r1, true), // it's already good resClose(r1), //resValidate(r2), // no!!! func() error { // wrap it b/c it is currently nil return r2.Validate() }, func() error { return resInit(r2)() }, func() error { return resCheckApply(r2, false)() }, func() error { return resCheckApply(r2, true)() }, func() error { return resClose(r2)() }, fileAbsent(p), // ensure it's absent } testCases = append(testCases, test{ name: "some file", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // content => "some new text\n", // // Meta:reverse => true, //} //# and there's an existing file at this path... r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p //res.State = FileStateExists // unspecified content := "some new text\n" res.Content = &content original := "this is the original state\n" // original state var r2 engine.Res // future reversed resource timeline := []func() error{ fileWrite(p, original), fileExpect(p, original), resValidate(r1), resReversal(r1, &r2), // runs in Init to snapshot func() error { // random test // state should be unspecified if st := r2.(*FileRes).State; st == "absent" || st == "exists" { return fmt.Errorf("unexpected state: %s", st) } return nil }, resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), resCheckApply(r1, true), // it's already good resClose(r1), //resValidate(r2), func() error { // wrap it b/c it is currently nil return r2.Validate() }, func() error { return resInit(r2)() }, func() error { return resCheckApply(r2, false)() }, func() error { return resCheckApply(r2, true)() }, func() error { return resClose(r2)() }, fileExpect(p, original), // we restored the contents! fileRemove(p), // cleanup } testCases = append(testCases, test{ name: "some file restore", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // content => "some new text\n", // // Meta:reverse => true, //} //# and there's NO existing file at this path... //# NOTE: This used to be a corner case subtlety for reversal. //# Now that we error in this scenario before reversal, it's ok! r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p //res.State = FileStateExists // unspecified content := "some new text\n" res.Content = &content var r2 engine.Res // future reversed resource timeline := []func() error{ fileRemove(p), // ensure no file exists resValidate(r1), resReversal(r1, &r2), // runs in Init to snapshot func() error { // random test // state should be unspecified i think // TODO: or should it be absent? if st := r2.(*FileRes).State; st == "absent" || st == "exists" { return fmt.Errorf("unexpected state: %s", st) } return nil }, resInit(r1), resCheckApplyError(r1, false, ErrIsNotExistOK), // changed //fileExpect(p, content), //resCheckApply(r1, true), // it's already good resClose(r1), //func() error { // // wrap it b/c it is currently nil // return r2.Validate() //}, //func() error { // return resInit(r2)() //}, //func() error { // it's already in the correct state // return resCheckApply(r2, true)() //}, //func() error { // return resClose(r2)() //}, //fileExpect(p, content), // we never changed it back... //fileRemove(p), // cleanup } testCases = append(testCases, test{ name: "ambiguous file restore", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // state => $const.res.file.state.absent, // // Meta:reverse => true, //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateAbsent original := "this is the original state\n" // original state var r2 engine.Res // future reversed resource timeline := []func() error{ fileWrite(p, original), fileExpect(p, original), resValidate(r1), resReversal(r1, &r2), // runs in Init to snapshot func() error { // random test if st := r2.(*FileRes).State; st != "exists" { return fmt.Errorf("unexpected state: %s", st) } return nil }, resInit(r1), resCheckApply(r1, false), // changed fileAbsent(p), // ensure it got removed resCheckApply(r1, true), // it's already good resClose(r1), //resValidate(r2), // no!!! func() error { // wrap it b/c it is currently nil return r2.Validate() }, func() error { return resInit(r2)() }, func() error { return resCheckApply(r2, false)() }, func() error { return resCheckApply(r2, true)() }, func() error { return resClose(r2)() }, fileExpect(p, original), // ensure it's back to original } testCases = append(testCases, test{ name: "some removal", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return nil }, }) } { //file "/tmp/somefile" { // state => $const.res.file.state.exists, // fragments => [ // "/tmp/frag1", // "/tmp/fragdir1/", // "/tmp/frag2", // "/tmp/fragdir2/", // "/tmp/frag3", // ], //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" res.Path = p res.State = FileStateExists res.Fragments = []string{ "/tmp/frag1", "/tmp/fragdir1/", "/tmp/frag2", "/tmp/fragdir2/", "/tmp/frag3", } frag1 := "frag1\n" f1 := "f1\n" f2 := "f2\n" f3 := "f3\n" frag2 := "frag2\n" f1d2 := "f1 from fragdir2\n" f2d2 := "f2 from fragdir2\n" f3d2 := "f3 from fragdir2\n" frag3 := "frag3\n" content := frag1 + f1 + f2 + f3 + frag2 + f1d2 + f2d2 + f3d2 + frag3 timeline := []func() error{ fileWrite("/tmp/frag1", frag1), fileWrite("/tmp/frag2", frag2), fileWrite("/tmp/frag3", frag3), fileMkdir("/tmp/fragdir1/", true), fileWrite("/tmp/fragdir1/f1", f1), fileWrite("/tmp/fragdir1/f2", f2), fileWrite("/tmp/fragdir1/f3", f3), fileMkdir("/tmp/fragdir2/", true), fileWrite("/tmp/fragdir2/f1", f1d2), fileWrite("/tmp/fragdir2/f2", f2d2), fileWrite("/tmp/fragdir2/f3", f3d2), fileWrite(p, "whatever"), resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), resCheckApply(r1, true), // it's already good resClose(r1), fileExpect(p, content), // ensure it exists } testCases = append(testCases, test{ name: "file fragments", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.Remove(p) }, }) } { //file "/tmp/somefile" { // state => $const.res.file.state.exists, // source => "/tmp/somefiletocopy", //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somefile" p2 := "/tmp/somefiletocopy" content := "hello this is some file to copy\n" res.Path = p res.State = FileStateExists res.Source = p2 timeline := []func() error{ fileAbsent(p), // ensure it's absent fileWrite(p2, content), resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExpect(p, content), // should be created like this fileExpect(p2, content), // should not change resCheckApply(r1, true), // it's already good fileExpect(p, content), // should already be like this fileExpect(p2, content), // should not change either resClose(r1), } testCases = append(testCases, test{ name: "copy file with source", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.Remove(p) }, }) } { //file "/tmp/somedir/" { // state => $const.res.file.state.exists, //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somedir/" res.Path = p res.State = FileStateExists timeline := []func() error{ fileAbsent(p), // ensure it's absent resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExists(p, true), // ensure it's a dir resCheckApply(r1, true), // it's already good fileExists(p, true), // ensure it's a dir resClose(r1), } testCases = append(testCases, test{ name: "make empty directory", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.RemoveAll(p) }, }) } { //file "/tmp/somedir/" { // state => $const.res.file.state.exists, // source => /tmp/somedirtocopy/, // recurse => true, //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somedir/" p2 := "/tmp/somedirtocopy/" res.Path = p res.State = FileStateExists res.Source = p2 res.Recurse = true f1 := path.Join(p, "f1") f2 := path.Join(p, "f2") d1 := path.Join(p, "d1/") d2 := path.Join(p, "d2/") d1f1 := path.Join(p, "d1/f1") d1f2 := path.Join(p, "d1/f2") d2f1 := path.Join(p, "d2/f1") d2f2 := path.Join(p, "d2/f2") d2f3 := path.Join(p, "d2/f3") xf1 := path.Join(p2, "f1") xf2 := path.Join(p2, "f2") xd1 := path.Join(p2, "d1/") xd2 := path.Join(p2, "d2/") xd1f1 := path.Join(p2, "d1/f1") xd1f2 := path.Join(p2, "d1/f2") xd2f1 := path.Join(p2, "d2/f1") xd2f2 := path.Join(p2, "d2/f2") xd2f3 := path.Join(p2, "d2/f3") timeline := []func() error{ fileMkdir(p2, true), fileWrite(xf1, "f1\n"), fileWrite(xf2, "f2\n"), fileMkdir(xd1, true), fileMkdir(xd2, true), fileWrite(xd1f1, "d1f1\n"), fileWrite(xd1f2, "d1f2\n"), fileWrite(xd2f1, "d2f1\n"), fileWrite(xd2f2, "d2f2\n"), fileWrite(xd2f3, "d2f3\n"), resValidate(r1), resInit(r1), resCheckApply(r1, false), // changed fileExists(p, true), // ensure it's a dir fileExists(f1, false), // ensure it's a file fileExists(f2, false), fileExists(d1, true), // ensure it's a dir fileExists(d2, true), fileExists(d1f1, false), fileExists(d1f2, false), fileExists(d2f1, false), fileExists(d2f2, false), fileExists(d2f3, false), resCheckApply(r1, true), // it's already good resClose(r1), } testCases = append(testCases, test{ name: "source dir copy", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.RemoveAll(p) }, }) } { //file "/tmp/somedir/" { // state => $const.res.file.state.exists, // recurse => true, // purge => true, //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somedir/" res.Path = p res.State = FileStateExists res.Recurse = true res.Purge = true f1 := path.Join(p, "f1") f2 := path.Join(p, "f2") d1 := path.Join(p, "d1/") d2 := path.Join(p, "d2/") d1f1 := path.Join(p, "d1/f1") d1f2 := path.Join(p, "d1/f2") d2f1 := path.Join(p, "d2/f1") d2f2 := path.Join(p, "d2/f2") d2f3 := path.Join(p, "d2/f3") graph, err := pgraph.NewGraph("test") if err != nil { panic("can't make graph") } graph.AddVertex(res) // add self timeline := []func() error{ fileMkdir(p, true), fileWrite(f1, "f1\n"), fileWrite(f2, "f2\n"), fileMkdir(d1, true), fileMkdir(d2, true), fileWrite(d1f1, "d1f1\n"), fileWrite(d1f2, "d1f2\n"), fileWrite(d2f1, "d2f1\n"), fileWrite(d2f2, "d2f2\n"), fileWrite(d2f3, "d2f3\n"), resValidate(r1), resInit(r1, addGraph(graph)), resCheckApply(r1, false), // changed fileExists(p, true), // ensure it's a dir fileAbsent(f1), // ensure it's absent fileAbsent(f2), fileAbsent(d1), fileAbsent(d2), fileAbsent(d1f1), fileAbsent(d1f2), fileAbsent(d2f1), fileAbsent(d2f2), fileAbsent(d2f3), resCheckApply(r1, true), // it's already good resClose(r1), } testCases = append(testCases, test{ name: "dir purge", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.RemoveAll(p) }, }) } { //file "/tmp/somedir/" { // state => $const.res.file.state.exists, // recurse => true, // purge => true, //} // TODO: should State be required for these to not delete them? //file "/tmp/somedir/hello" { //} //file "/tmp/somedir/nested-dir/" { //} //file "/tmp/somedir/nested-dir/nestedfileindir" { //} r1 := makeRes("file", "r1") res := r1.(*FileRes) // if this panics, the test will panic p := "/tmp/somedir/" res.Path = p res.State = FileStateExists res.Recurse = true res.Purge = true f1 := path.Join(p, "f1") f2 := path.Join(p, "f2") d1 := path.Join(p, "d1/") d2 := path.Join(p, "d2/") d1f1 := path.Join(p, "d1/f1") d1f2 := path.Join(p, "d1/f2") d2f1 := path.Join(p, "d2/f1") d2f2 := path.Join(p, "d2/f2") d2f3 := path.Join(p, "d2/f3") r2 := makeRes("file", "r2") res2 := r2.(*FileRes) p2 := path.Join(p, "hello") res2.Path = p2 p2c := "i am a hello file\n" // TODO: should State be required for this to not delete it? r3 := makeRes("file", "r3") res3 := r3.(*FileRes) p3 := path.Join(p, "nested-dir/") res3.Path = p3 // TODO: should State be required for this to not delete it? r4 := makeRes("file", "r4") res4 := r4.(*FileRes) p4 := path.Join(p3, "nestedfileindir") res4.Path = p4 p4c := "i am a nested file\n" // TODO: should State be required for this to not delete it? graph, err := pgraph.NewGraph("test") if err != nil { panic("can't make graph") } graph.AddVertex(res, res2, res3, res4) timeline := []func() error{ fileMkdir(p, true), fileWrite(f1, "f1\n"), fileWrite(f2, "f2\n"), fileMkdir(d1, true), fileMkdir(d2, true), fileWrite(d1f1, "d1f1\n"), fileWrite(d1f2, "d1f2\n"), fileWrite(d2f1, "d2f1\n"), fileWrite(d2f2, "d2f2\n"), fileWrite(d2f3, "d2f3\n"), fileWrite(p2, p2c), fileMkdir(p3, true), fileWrite(p4, p4c), resValidate(r2), resInit(r2), //resCheckApply(r2, false), // not really needed in test resClose(r2), resValidate(r3), resInit(r3), //resCheckApply(r3, false), // not really needed in test resClose(r3), resValidate(r4), resInit(r4), //resCheckApply(r4, false), // not really needed in test resClose(r4), resValidate(r1), resInit(r1, addGraph(graph), addLogf(nil)), // show the full graph resCheckApply(r1, false), // changed fileExists(p, true), // ensure it's a dir fileAbsent(f1), // ensure it's absent fileAbsent(f2), fileAbsent(d1), fileAbsent(d2), fileAbsent(d1f1), fileAbsent(d1f2), fileAbsent(d2f1), fileAbsent(d2f2), fileAbsent(d2f3), fileExists(p2, false), // ensure it's a file XXX !!! fileExists(p3, true), // ensure it's a dir fileExists(p4, false), resCheckApply(r1, true), // it's already good resClose(r1), } testCases = append(testCases, test{ name: "dir purge with others inside", timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, cleanup: func() error { return os.RemoveAll(p) }, }) } names := []string{} for index, tc := range testCases { // run all the tests if tc.name == "" { t.Errorf("test #%d: not named", index) continue } if util.StrInList(tc.name, names) { t.Errorf("test #%d: duplicate sub test name of: %s", index, tc.name) continue } names = append(names, tc.name) t.Run(fmt.Sprintf("test #%d (%s)", index, tc.name), func(t *testing.T) { timeline, expect, startup, cleanup := tc.timeline, tc.expect, tc.startup, tc.cleanup t.Logf("test #%d: starting...\n", index) defer t.Logf("test #%d: done!", index) //debug := testing.Verbose() // set via the -test.v flag to `go test` //logf := func(format string, v ...interface{}) { // t.Logf(fmt.Sprintf("test #%d: ", index)+format, v...) //} if startup != nil { t.Logf("test #%d: running startup()", index) if err := startup(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not startup: %+v", index, err) } } defer func() { if cleanup == nil { return } t.Logf("test #%d: running cleanup()", index) if err := cleanup(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: could not cleanup: %+v", index, err) } }() // run timeline t.Logf("test #%d: executing timeline", index) for ix, step := range timeline { t.Logf("test #%d: step(%d)...", index, ix) if err := step(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: step(%d) action failed: %s", index, ix, err.Error()) break } } t.Logf("test #%d: shutting down...", index) if err := expect(); err != nil { t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: expect failed: %s", index, err.Error()) return } // all done! }) } }