diff --git a/docs/resources.md b/docs/resources.md index 6c8b24ee..0292d4a4 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -116,6 +116,12 @@ The force property is required if we want the file resource to be able to change a file into a directory or vice-versa. If such a change is needed, but the force property is not set to `true`, then this file resource will error. +### Purge + +The purge property is used when this file represents a directory, and we'd like +to remove any unmanaged files from within it. Please note that any unmanaged +files in a directory with this flag set will be irreversibly deleted. + ## Group The group resource manages the system groups from `/etc/group`. diff --git a/engine/resources/file.go b/engine/resources/file.go index 25db53ae..d42cd10f 100644 --- a/engine/resources/file.go +++ b/engine/resources/file.go @@ -61,6 +61,7 @@ const ( type FileRes struct { traits.Base // add the base methods without re-implementation traits.Edgeable + traits.GraphQueryable // allow others to query this res in the res graph //traits.Groupable // TODO: implement this traits.Recvable traits.Reversible @@ -86,7 +87,20 @@ type FileRes struct { // parameters. Content *string `lang:"content" yaml:"content"` // Source specifies the source contents for the file resource. It cannot - // be combined with the Content or Fragments parameters. + // be combined with the Content or Fragments parameters. It must be an + // absolute path, and it can point to a file or a directory. If it + // points to a file, then that will will be copied throuh directly. If + // it points to a directory, then it will copy the directory "rsync + // style" onto the file destination. As a result, if this is a file, + // then the main file res must be a file, and if it is a directory, then + // this must be a directory. To meaningfully copy a full directory, you + // also need to specify the Recurse parameter, which is currently + // required. If you want an existing dir to be turned into a file (or + // vice-versa) instead of erroring, then you'll also need to specify the + // Force parameter. If source is undefined and the file path is a + // directory, then a directory will be created. If left undefined, and + // combined with the Purge option too, then any unmanaged file in this + // dir will be removed. Source string `lang:"source" yaml:"source"` // Fragments specifies that the file is built from a list of individual // files. If one of the files is a directory, then the list of files in @@ -114,6 +128,11 @@ type FileRes struct { Mode string `lang:"mode" yaml:"mode"` Recurse bool `lang:"recurse" yaml:"recurse"` Force bool `lang:"force" yaml:"force"` + // Purge specifies that when true, any unmanaged file in this file + // directory will be removed. As a result, this file resource must be a + // directory. This isn't particularly meaningful if you don't also set + // Recurse to true. This doesn't work with Content or Fragments. + Purge bool `lang:"purge" yaml:"purge"` sha256sum string } @@ -198,10 +217,29 @@ func (obj *FileRes) Validate() error { return fmt.Errorf("can't specify file Content, Source, or Fragments when State is %s", FileStateAbsent) } + // The path and Source must either both be dirs or both not be. + srcIsDir := strings.HasSuffix(obj.Source, "/") + if isSrc && (obj.isDir() != srcIsDir) { + return fmt.Errorf("the path and Source must either both be dirs or both not be") + } + if obj.isDir() && (isContent || isFrag) { // makes no sense return fmt.Errorf("can't specify Content or Fragments when creating a Dir") } + // TODO: is this really a requirement that we want to enforce? + if isSrc && obj.isDir() && srcIsDir && !obj.Recurse { + return fmt.Errorf("you'll want to Recurse when you have a Source dir to copy") + } + // TODO: do we want to enforce this sort of thing? + if obj.Purge && !obj.Recurse { + return fmt.Errorf("you'll want to Recurse when you have a Purge to do") + } + + if isSrc && !obj.isDir() && !srcIsDir && obj.Recurse { + return fmt.Errorf("you can't recurse when copying a single file") + } + for _, frag := range obj.Fragments { // absolute paths begin with a slash if !strings.HasPrefix(frag, "/") { @@ -209,6 +247,14 @@ func (obj *FileRes) Validate() error { } } + if obj.Purge && (isContent || isFrag) { + return fmt.Errorf("can't combine Purge with Content or Fragments") + } + // XXX: should this work with obj.Purge && obj.Source != "" or not? + //if obj.Purge && obj.Source != "" { + // return fmt.Errorf("can't Purge when Source is specified") + //} + // TODO: should we silently ignore these errors or include them? //if obj.State == FileStateAbsent && obj.Owner != "" { // return fmt.Errorf("can't specify Owner for an absent file") @@ -244,11 +290,6 @@ func (obj *FileRes) Validate() error { } } - // XXX: should this specify that we create an empty directory instead? - //if obj.Source == "" && obj.isDir() { - // return fmt.Errorf("can't specify an empty source when creating a Dir.") - //} - return nil } @@ -617,11 +658,14 @@ func (obj *FileRes) dirCheckApply(apply bool) (bool, error) { // syncCheckApply is the CheckApply operation for a source and destination dir. // It is recursive and can create directories directly, and files via the usual // fileCheckApply method. It returns checkOK and error as is normally expected. -func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { +// If excludes is specified, none of those files there will be deleted by this, +// with the exception that a sync *can* convert a file to a dir, or vice-versa. +func (obj *FileRes) syncCheckApply(apply bool, src, dst string, excludes []string) (bool, error) { if obj.init.Debug { obj.init.Logf("syncCheckApply: %s -> %s", src, dst) } - if src == "" || dst == "" { + // an src of "" is now supported, if dst is a dir + if dst == "" { return false, fmt.Errorf("the src and dst must not be empty") } @@ -631,11 +675,14 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { srcIsDir := strings.HasSuffix(src, "/") dstIsDir := strings.HasSuffix(dst, "/") - if srcIsDir != dstIsDir { + if srcIsDir != dstIsDir && src != "" { return false, fmt.Errorf("the src and dst must be both either files or directories") } + if src == "" && !dstIsDir { + return false, fmt.Errorf("dst must be a dir if we have an empty src") + } - if !srcIsDir && !dstIsDir { + if !srcIsDir && !dstIsDir && src != "" { if obj.init.Debug { obj.init.Logf("syncCheckApply: %s -> %s", src, dst) } @@ -656,18 +703,23 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { } // else: if srcIsDir && dstIsDir - srcFiles, err := ReadDir(src) // if src does not exist... - if err != nil && !os.IsNotExist(err) { // an empty map comes out below! - return false, err + + smartSrc := make(map[string]FileInfo) + if src != "" { + srcFiles, err := ReadDir(src) // if src does not exist... + if err != nil && !os.IsNotExist(err) { // an empty map comes out below! + return false, err + } + smartSrc = mapPaths(srcFiles) + obj.init.Logf("syncCheckApply: srcFiles: %v", srcFiles) } + dstFiles, err := ReadDir(dst) if err != nil && !os.IsNotExist(err) { return false, err } - //obj.init.Logf("syncCheckApply: srcFiles: %v", srcFiles) - //obj.init.Logf("syncCheckApply: dstFiles: %v", dstFiles) - smartSrc := mapPaths(srcFiles) smartDst := mapPaths(dstFiles) + obj.init.Logf("syncCheckApply: dstFiles: %v", dstFiles) for relPath, fileInfo := range smartSrc { absSrc := fileInfo.AbsPath // absolute path @@ -713,7 +765,7 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { obj.init.Logf("syncCheckApply: recurse: %s -> %s", absSrc, absDst) } if obj.Recurse { - if c, err := obj.syncCheckApply(apply, absSrc, absDst); err != nil { // recurse + if c, err := obj.syncCheckApply(apply, absSrc, absDst, excludes); err != nil { // recurse return false, errwrap.Wrapf(err, "syncCheckApply: recurse failed") } else if !c { // don't let subsequent passes make this true checkOK = false @@ -728,6 +780,19 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { if !apply && len(smartDst) > 0 { // we know there are files to remove! return false, nil // so just exit now } + + // isExcluded specifies if the path is part of an excluded path. For + // example, if we exclude /tmp/foo/bar from deletion, then we don't want + // to delete /tmp/foo/bar *or* /tmp/foo/ *or* /tmp/ b/c they're parents. + isExcluded := func(p string) bool { + for _, x := range excludes { + if util.HasPathPrefix(x, p) { + return true + } + } + return false + } + // any files that now remain in smartDst need to be removed... for relPath, fileInfo := range smartDst { absSrc := src + relPath // absolute dest (should not exist!) @@ -743,6 +808,9 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { // think the symmetry is more elegant and correct here for now // Avoiding this is also useful if we had a recurse limit arg! if true { // switch + if isExcluded(absDst) { // skip removing excluded files + continue + } obj.init.Logf("syncCheckApply: removing: %s", absCleanDst) if apply { if err := os.RemoveAll(absCleanDst); err != nil { // dangerous ;) @@ -754,11 +822,14 @@ func (obj *FileRes) syncCheckApply(apply bool, src, dst string) (bool, error) { } _ = absSrc //obj.init.Logf("syncCheckApply: recurse rm: %s -> %s", absSrc, absDst) - //if c, err := obj.syncCheckApply(apply, absSrc, absDst); err != nil { + //if c, err := obj.syncCheckApply(apply, absSrc, absDst, excludes); err != nil { // return false, errwrap.Wrapf(err, "syncCheckApply: recurse rm failed") //} else if !c { // don't let subsequent passes make this true // checkOK = false //} + //if isExcluded(absDst) { // skip removing excluded files + // continue + //} //obj.init.Logf("syncCheckApply: removing: %s", absCleanDst) //if apply { // safety // if err := os.Remove(absCleanDst); err != nil { @@ -869,11 +940,48 @@ func (obj *FileRes) sourceCheckApply(apply bool) (bool, error) { obj.init.Logf("sourceCheckApply(%t)", apply) // source is not defined, leave it alone... - if obj.Source == "" { + if obj.Source == "" && !obj.Purge { return true, nil } - checkOK, err := obj.syncCheckApply(apply, obj.Source, obj.getPath()) + excludes := []string{} + + // If we're running a purge, do it here. + if obj.Purge { + graph, err := obj.init.FilteredGraph() + if err != nil { + return false, errwrap.Wrapf(err, "can't read filtered graph") + } + for _, vertex := range graph.Vertices() { + res, ok := vertex.(engine.Res) + if !ok { + // programming error + return false, fmt.Errorf("not a Res") + } + if res.Kind() != "file" { + continue // only interested in files + } + if res.Name() == obj.Name() { + continue // skip me! + } + fileRes, ok := res.(*FileRes) + if !ok { + // programming error + return false, fmt.Errorf("not a FileRes") + } + p := fileRes.getPath() // if others use it, make public! + if !util.HasPathPrefix(p, obj.getPath()) { + continue + } + excludes = append(excludes, p) + } + } + if obj.init.Debug { + obj.init.Logf("syncCheckApply: excludes: %+v", excludes) + } + + // XXX: should this work with obj.Purge && obj.Source != "" or not? + checkOK, err := obj.syncCheckApply(apply, obj.Source, obj.getPath(), excludes) if err != nil { obj.init.Logf("syncCheckApply: error: %v", err) return false, err @@ -1144,6 +1252,9 @@ func (obj *FileRes) Cmp(r engine.Res) error { if obj.Force != res.Force { return fmt.Errorf("the Force option differs") } + if obj.Purge != res.Purge { + return fmt.Errorf("the Purge option differs") + } return nil } @@ -1334,6 +1445,7 @@ func (obj *FileRes) Copy() engine.CopyableRes { Mode: obj.Mode, Recurse: obj.Recurse, Force: obj.Force, + Purge: obj.Purge, } } @@ -1446,6 +1558,18 @@ func (obj *FileRes) Reversed() (engine.ReversibleRes, error) { return res, nil } +// GraphQueryAllowed returns nil if you're allowed to query the graph. This +// function accepts information about the requesting resource so we can +// determine the access with some form of fine-grained control. +func (obj *FileRes) GraphQueryAllowed(opts ...engine.GraphQueryableOption) error { + options := &engine.GraphQueryableOptions{} // default options + options.Apply(opts...) // apply the options + if options.Kind != "file" { + return fmt.Errorf("only other files can access my information") + } + return nil +} + // smartPath adds a trailing slash to the path if it is a directory. func smartPath(fileInfo os.FileInfo) string { smartPath := fileInfo.Name() // absolute path diff --git a/engine/resources/resources_test.go b/engine/resources/resources_test.go index d0e685c1..5ab87f69 100644 --- a/engine/resources/resources_test.go +++ b/engine/resources/resources_test.go @@ -23,12 +23,14 @@ import ( "fmt" "io/ioutil" "os" + "path" "strings" "sync" "testing" "time" "github.com/purpleidea/mgmt/engine" + "github.com/purpleidea/mgmt/pgraph" "github.com/purpleidea/mgmt/util" "github.com/purpleidea/mgmt/util/errwrap" ) @@ -585,6 +587,29 @@ func TestResources2(t *testing.T) { 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 @@ -593,9 +618,18 @@ func TestResources2(t *testing.T) { } } // resInit runs Init on the res. - resInit := func(res engine.Res) func() error { + 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{}) { - // noop for now + if io.logf == nil { + return + } + io.logf(fmt.Sprintf("test: ")+format+"\n", v...) } init := &engine.Init{ //Debug: debug, @@ -611,15 +645,18 @@ func TestResources2(t *testing.T) { // 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") - } + //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! + //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 @@ -1229,10 +1266,326 @@ func TestResources2(t *testing.T) { timeline: timeline, expect: func() error { return nil }, startup: func() error { return nil }, - cleanup: func() error { return nil }, + cleanup: func() error { return os.Remove(p) }, }) } + { + //file "/tmp/somefile" { + // 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 = "exists" + 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 => "exists", + //} + r1 := makeRes("file", "r1") + res := r1.(*FileRes) // if this panics, the test will panic + p := "/tmp/somedir/" + res.Path = p + res.State = "exists" + + 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 => "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 = "exists" + 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 => "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 = "exists" + 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 => "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 = "exists" + 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 == "" { diff --git a/examples/lang/purge1.mcl b/examples/lang/purge1.mcl new file mode 100644 index 00000000..d84c1d35 --- /dev/null +++ b/examples/lang/purge1.mcl @@ -0,0 +1,19 @@ +file "/tmp/some_dir/" { + state => "exists", + #source => "", # this default means empty directory + recurse => true, + purge => true, +} + +file "/tmp/some_dir/fileA" { + state => "exists", + content => "i am fileA\n", +} +file "/tmp/some_dir/fileB" { + state => "exists", + content => "i am fileB\n", +} +file "/tmp/some_dir/fileC" { + state => "exists", + content => "i am fileC\n", +}