Files
mgmt/engine/resources/resources_test.go
James Shubin dd0e67540f all: Remove deprecated io/ioutil package
Porting everything to the newer imports was trivial except for one
instance which required a very small refactor.
2024-02-28 16:36:49 -05:00

1768 lines
48 KiB
Go

// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> 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 <http://www.gnu.org/licenses/>.
//go:build !root
package resources
import (
"context"
"fmt"
"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 := os.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 os.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 os.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 os.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 os.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 os.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{})
doneCtx, doneCtxCancel := context.WithCancel(context.Background())
defer doneCtxCancel()
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.
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 cleanup (we don't ever expect an error on cleanup!)
t.Logf("test #%d: running Cleanup", index)
if err := res.Cleanup(); 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(doneCtx); 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)
doneCtxCancel() // 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(doneCtx, 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, err
//}
// 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(context.TODO(), 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)
}
// resCleanup runs CLeanup on the res.
resCleanup := func(res engine.Res) func() error {
// run CLeanup
return func() error {
return res.Cleanup()
}
}
// 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 os.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 := os.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
resCleanup(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
resCleanup(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
resCleanup(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),
resCleanup(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),
resCleanup(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
resCleanup(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 resCleanup(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
resCleanup(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 resCleanup(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
resCleanup(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 resCleanup(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
resCleanup(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 resCleanup(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
resCleanup(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
resCleanup(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
resCleanup(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
resCleanup(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
resCleanup(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
resCleanup(r2),
resValidate(r3),
resInit(r3),
//resCheckApply(r3, false), // not really needed in test
resCleanup(r3),
resValidate(r4),
resInit(r4),
//resCheckApply(r4, false), // not really needed in test
resCleanup(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
resCleanup(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!
})
}
}
func TestResPtrUID1(t *testing.T) {
t1, err := engine.NewNamedResource("test", "test1")
if err != nil {
t.Errorf("could not build resource: %+v", err)
return
}
t2, err := engine.NewNamedResource("test", "test1")
if err != nil {
t.Errorf("could not build resource: %+v", err)
return
}
if uid1, uid2 := engine.PtrUID(t1), engine.PtrUID(t2); uid1 != uid2 {
t.Errorf("uid's don't match")
}
}