diff --git a/envs/funcs.go b/envs/funcs.go index 2da63ba..6f73897 100644 --- a/envs/funcs.go +++ b/envs/funcs.go @@ -75,7 +75,7 @@ func GetEnvErrNoBlank(key string, ignoreWhitespace bool) (value string, err erro var e *EnvErrNoVal = &EnvErrNoVal{ VarName: key, WasRequiredNonEmpty: true, - IgnoreWhiteSpace: ignoreWhitespace, + IgnoreWhitespace: ignoreWhitespace, } if value, exists = os.LookupEnv(key); !exists { diff --git a/envs/funcs_enverrnoval.go b/envs/funcs_enverrnoval.go index ebfa805..6d2ca1e 100644 --- a/envs/funcs_enverrnoval.go +++ b/envs/funcs_enverrnoval.go @@ -12,13 +12,13 @@ func (e *EnvErrNoVal) Error() (errStr string) { sb.WriteString("the variable '") sb.WriteString(e.VarName) sb.WriteString("' was ") - if sb.WasFound { + if e.WasFound { sb.WriteString("found") } else { sb.WriteString("not found") } if e.WasRequiredNonEmpty && e.WasFound { - sb.WriteString(" but is empty and was required to be non-empty") + sb.WriteString(" but is empty and was required to be non-empty") } errStr = sb.String() diff --git a/envs/utils.go b/envs/utils.go index 309c0e5..ffa53ef 100644 --- a/envs/utils.go +++ b/envs/utils.go @@ -1,10 +1,10 @@ package envs import ( - `strconv` - `strings` + "strconv" + "strings" - `r00t2.io/sysutils/internal` + "r00t2.io/sysutils/internal" ) // envListToMap splits a []string of env var keypairs to a map. @@ -35,7 +35,7 @@ func nativizeEnvMap(stringMap map[string]string) (envMap map[string]interface{}) var pathVar string = internal.GetPathEnvName() var err error - envMap = make(map[string]interface{}, 0) + envMap = make(map[string]interface{}) for k, v := range stringMap { diff --git a/go.mod b/go.mod index 5ee6407..0b48bc2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module r00t2.io/sysutils -go 1.23.2 +go 1.24.5 require ( github.com/davecgh/go-spew v1.1.1 @@ -10,7 +10,7 @@ require ( golang.org/x/sync v0.16.0 golang.org/x/sys v0.34.0 honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 - r00t2.io/goutils v1.9.0 + r00t2.io/goutils v1.9.2 ) require ( diff --git a/go.sum b/go.sum index 51b21a9..9c76fd4 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,3 @@ -github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= @@ -12,15 +11,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= -github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -31,25 +27,17 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8 h1:FW42yWB1sGClqswyHIB68wo0+oPrav1IuQ+Tdy8Qp8E= honnef.co/go/augeas v0.0.0-20161110001225-ca62e35ed6b8/go.mod h1:44w9OfBSQ9l3o59rc2w3AnABtE44bmtNnRMNC7z+oKE= -r00t2.io/goutils v1.8.1 h1:TQcUycPKsYn0QI4uCqb56utmvu/vVSxlblBg98iXStg= -r00t2.io/goutils v1.8.1/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= -r00t2.io/goutils v1.9.0 h1:iEwa9LinCzabpTD03/2oUrFE3QinxszTzL48pBV9cD4= -r00t2.io/goutils v1.9.0/go.mod h1:9ObJI9S71wDLTOahwoOPs19DhZVYrOh4LEHmQ8SW4Lk= -r00t2.io/sysutils v1.1.1/go.mod h1:Wlfi1rrJpoKBOjWiYM9rw2FaiZqraD6VpXyiHgoDo/o= +r00t2.io/goutils v1.9.2 h1:1rcDgJ3MorWVBmZSvLpbAUNC+J+ctRfJQq5Wliucjww= +r00t2.io/goutils v1.9.2/go.mod h1:76AxpXUeL10uFklxRB11kQsrtj2AKiNm8AwG1bNoBCA= diff --git a/paths/consts_unix.go b/paths/consts_unix.go new file mode 100644 index 0000000..1c962b4 --- /dev/null +++ b/paths/consts_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package paths + +const ( + /* + MaxSymlinkLevel is hardcoded into the kernel for macOS, BSDs and Linux. It's unlikely to change. + Thankfully, it's the same on all of them. + + On all, it's defined as MAXSYMLINKS in the following headers: + + macOS (no, macOS is not a BSD; no, it is not FreeBSD; yes, I *will* fight you on it and win): sys/param.h + BSDs: sys/sys/param.h + Linux: include/linux/namei.h + */ + MaxSymlinkLevel uint = 40 +) diff --git a/paths/consts_windows.go b/paths/consts_windows.go new file mode 100644 index 0000000..9383341 --- /dev/null +++ b/paths/consts_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package paths + +const ( + /* + MaxSymLinkLevel on Windows is weird; Microsoft calls them "reparse points". + + And it changes on the Windows version you're on, but it's been 63 past Windows Server 2003/Windows XP. + They're *very* EOL, so I'm completely ignoring them. + + https://learn.microsoft.com/en-us/windows/win32/fileio/symbolic-link-programming-consideration + */ + MaxSymlinkLevel uint = 63 +) diff --git a/paths/errs.go b/paths/errs.go index e9bb296..9600495 100644 --- a/paths/errs.go +++ b/paths/errs.go @@ -1,10 +1,12 @@ package paths import ( - `errors` + "errors" + "fmt" ) var ( + ErrMaxSymlinkLevel = fmt.Errorf("max symlink level met/exceeded") ErrNilErrChan error = errors.New("an initialized error channel is required") ErrNilMatchChan error = errors.New("an initialized matches channel is required") ErrNilMismatchChan error = errors.New("an initialized mismatches channel is required") diff --git a/paths/funcs.go b/paths/funcs.go index 6f55683..f8c204e 100644 --- a/paths/funcs.go +++ b/paths/funcs.go @@ -129,7 +129,7 @@ func GetFirstWithRef(p []string) (content []byte, isDir, ok bool, idx int) { var locPaths []string var exists bool - var stat os.FileInfo + var stat fs.FileInfo var err error idx = -1 @@ -194,7 +194,7 @@ This is a bit more sane option than os.MkdirAll as it will normalize paths a lit */ func MakeDirIfNotExist(p string) (err error) { - var stat os.FileInfo + var stat fs.FileInfo var exists bool var locPath string = p @@ -235,6 +235,8 @@ path syntax/string itself is not supported on the runtime OS. This can be done v if errors.Is(err, fs.ErrInvalid) {...} RealPath is simply a wrapper around ExpandHome(path) and filepath.Abs(*path). + +Note that RealPath does *not* resolve symlinks. Only RealPathExistsStatTarget does that. */ func RealPath(p *string) (err error) { @@ -346,18 +348,22 @@ func RealPathExists(p *string) (exists bool, err error) { } /* -RealPathExistsStat is like RealPathExists except it will also return the os.FileInfo +RealPathExistsStat is like RealPathExists except it will also return the fs.FileInfo for the path (assuming it exists). If stat is nil, it is highly recommended to check err via the methods suggested in the documentation for RealPath and RealPathExists. */ -func RealPathExistsStat(p *string) (exists bool, stat os.FileInfo, err error) { +func RealPathExistsStat(p *string) (exists bool, stat fs.FileInfo, err error) { if exists, err = RealPathExists(p); err != nil { return } + if !exists { + return + } + if stat, err = os.Stat(*p); err != nil { return } @@ -365,6 +371,48 @@ func RealPathExistsStat(p *string) (exists bool, stat os.FileInfo, err error) { return } +/* +RealPathExistsStatTarget is the only "RealPather" that will resolve p to the (final) *target* of p if p is a symlink. + +If p is not a symlink but does exist, the tgt* will reflect the same as p*. + +See WalkLink for details on relRoot and other assorted rules/logic (RealPathExistsStatTarget wraps WalkLink). +*/ +func RealPathExistsStatTarget(p *string, relRoot string) (pExists, tgtExists, wasLink bool, pStat fs.FileInfo, tgtStat fs.FileInfo, err error) { + + var tgts []string + + if pExists, err = RealPathExists(p); err != nil { + return + } + tgtExists = pExists + if !pExists { + return + } + + // Can't use RealPathExistsStat because it calls os.Stat, not os.Lstat... thus defeating the purpose. + if pStat, err = os.Lstat(*p); err != nil { + return + } + tgtStat = pStat + + wasLink = pStat.Mode().Type()&fs.ModeSymlink == fs.ModeSymlink + + if wasLink { + if tgts, err = WalkLink(*p, relRoot); err != nil || tgts == nil || len(tgts) == 0 { + tgtExists = false + tgtStat = nil + return + } + if tgtExists, tgtStat, err = RealPathExistsStat(&tgts[len(tgts)-1]); err != nil { + return + } + *p = tgts[len(tgts)-1] + } + + return +} + // SearchFsPaths gets a file/directory/etc. path list based on the provided criteria. func SearchFsPaths(matcher FsSearchCriteria) (found, miss []*FsSearchResult, err error) { @@ -643,6 +691,80 @@ func StripSys(p string, abs, strict bool, n int) (slicedPath string) { return } +/* +WalkLink walks the recursive target(s) of lnk (unless/until MaxSymlinkLevel is hit, which will trigger ErrMaxSymlinkLevel) +until it reaches a real (non-symlink) target. + +lnk will have RealPath called on it first. + +If lnk is not a symlink, then tgts == []string{lnk} and err = nil. + +A broken link will return fs.ErrNotExist, with tgts containing the targets up to and including the path that triggered the error. + +If lnk itself does not exist, tgts will be nil and err will be that of fs.ErrNotExist. + +relRoot is a root directory to resolve relative links to. If empty, relative link target `t` from link `l` will be treated +as relative to `(path/filepath).Dir(l)` (that is to say, `t = filepath.Join(filepath.Dir(l), os.Readlink(l))`). +*/ +func WalkLink(lnk, relRoot string) (tgts []string, err error) { + + var exists bool + var curDepth uint + var stat fs.FileInfo + var curTgt string + var prevTgt string + + if exists, err = RealPathExists(&lnk); err != nil { + return + } else if !exists { + err = fs.ErrNotExist + return + } + + if relRoot != "" { + if err = RealPath(&relRoot); err != nil { + return + } + } + + tgts = []string{} + + curTgt = lnk + for curDepth = 0; curDepth < MaxSymlinkLevel; curDepth++ { + if exists, err = RealPathExists(&curTgt); err != nil { + return + } + prevTgt = curTgt + tgts = append(tgts, curTgt) + if !exists { + err = fs.ErrNotExist + return + } + if stat, err = os.Lstat(curTgt); err != nil { + return + } + if stat.Mode().Type()&os.ModeSymlink != os.ModeSymlink { + break + } + if curTgt, err = os.Readlink(curTgt); err != nil { + return + } + if !filepath.IsAbs(curTgt) { + if relRoot != "" { + curTgt = filepath.Join(relRoot, curTgt) + } else { + curTgt = filepath.Join(filepath.Dir(prevTgt), curTgt) + } + } + } + if curDepth >= MaxSymlinkLevel { + err = ErrMaxSymlinkLevel + return + } + + return +} + /* filterTimes checks a times.Timespec of a file using: - an age specified by the caller diff --git a/pdsh/consts.go b/pdsh/consts.go new file mode 100644 index 0000000..d781186 --- /dev/null +++ b/pdsh/consts.go @@ -0,0 +1,18 @@ +package pdsh + +import ( + "regexp" + + "r00t2.io/goutils/remap" +) + +const ( + dshGrpPathEnv string = "DSHGROUP_PATH" +) + +// DSH Groups +var ( + dshGrpDefGrpDir string = "/etc/dsh/group" + dshGrpInclPtrn *remap.ReMap = &remap.ReMap{Regexp: regexp.MustCompile(`^\s*#include\s+(?P.+)$`)} + dshGrpSubTokenPtrn *remap.ReMap = &remap.ReMap{Regexp: regexp.MustCompile(`^(?P0*)(?P[1-9]+[0-9]*)?(?:-(?P0*)(?P[1-9]+[0-9]*))?$`)} +) diff --git a/pdsh/docs.go b/pdsh/docs.go new file mode 100644 index 0000000..36420b6 --- /dev/null +++ b/pdsh/docs.go @@ -0,0 +1,16 @@ +/* +Package pdsh (!! WIP !!) provides PDSH-compatible functionality for parsing group files. + +Note that this library will *only* source and parse PDSH-compatible host/group files, +it will not actually connect to anything. +It simply provides ways of returning lists of hosts using generation rules/patterns. + +Currently, the only supported PDSH module is `misc/dshgroup` but additional/all other +host list modules are planned. + +For details, see: + + - https://github.com/chaos/pdsh/ + - https://github.com/chaos/pdsh/blob/master/doc/pdsh.1.in +*/ +package pdsh diff --git a/pdsh/errs.go b/pdsh/errs.go new file mode 100644 index 0000000..4eee8cf --- /dev/null +++ b/pdsh/errs.go @@ -0,0 +1,10 @@ +package pdsh + +import ( + "errors" +) + +var ( + ErrInvalidDshGrpSyntax error = errors.New("invalid dsh group file syntax") + ErrInvalidDshGrpPtrn error = errors.New("invalid dsh group pattern syntax") +) diff --git a/pdsh/funcs_dshgrouplister.go b/pdsh/funcs_dshgrouplister.go new file mode 100644 index 0000000..90e63bc --- /dev/null +++ b/pdsh/funcs_dshgrouplister.go @@ -0,0 +1,174 @@ +package pdsh + +import ( + "io/fs" + "os" + "path/filepath" + "strings" + + "r00t2.io/sysutils/envs" + "r00t2.io/sysutils/paths" +) + +/* +Evaluate returns a list of directories and files that would be searched/read with +the given call and DshGroupLister configuration, in order of parsing. + +The behavior is the same as DshGroupLister.GroupedHosts, including searchPaths. +If DshGroupLister.ForceLegacy is false, include files will also be parsed in. +(This may incur slightly additional processing time.) + +Only existing dirs/files are returned. Symlinks are evaluated to their target. + +If dedupe is true, deduplication is performed. This adds some cycles, but may be desired if you make heavy use of symlinks. +*/ +func (d *DshGroupLister) Evaluate(dedupe bool, searchPaths ...string) (dirs, files []string, err error) { + + var exists bool + // var u *user.User + var spl []string + var dPath string + var fPath string + var incls []string + var de fs.DirEntry + var stat fs.FileInfo + var entries []fs.DirEntry + var tmpF []string + var fpathMap map[string]bool = make(map[string]bool) + + // TODO: Does/how does pdsh resolve relative symlinks? + + // Dirs first + if searchPaths != nil { + for _, dPath = range searchPaths { + if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil { + return + } else if !exists { + continue + } + if !stat.IsDir() { + continue + } + dirs = append(dirs, dPath) + } + } + if !d.NoHome && envs.HasEnv("HOME") { + // So pdsh actually checks $HOME, it doesn't pull the homedir for the user. + /* + if u, err = user.Current(); err != nil { + return + } + dPath = filepath.Join(u.HomeDir, ".dsh", "group") + */ + dPath = filepath.Join(os.Getenv("HOME"), ".dsh", "group") + if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil { + return + } else if exists { + if stat.IsDir() { + dirs = append(dirs, dPath) + } + } + } + if !d.NoEnv && envs.HasEnv(dshGrpPathEnv) { + spl = strings.Split(os.Getenv(dshGrpPathEnv), string(os.PathListSeparator)) + for _, dPath = range spl { + if strings.TrimSpace(dPath) == "" { + continue + } + if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil { + return + } else if !exists { + continue + } + if !stat.IsDir() { + continue + } + dirs = append(dirs, dPath) + } + } + if !d.NoDefault && !envs.HasEnv(dshGrpPathEnv) { + dPath = dshGrpDefGrpDir + if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&dPath, "."); err != nil { + return + } else if exists { + if stat.IsDir() { + dirs = append(dirs, dPath) + } + } + } + + // Then files. Do *not* walk the dirs; only first-level is parsed by pdsh so this does the same. + for _, dPath = range dirs { + if entries, err = os.ReadDir(dPath); err != nil { + return + } + for _, de = range entries { + fPath = filepath.Join(dPath, de.Name()) + // NORMALLY, os.Stat calls stat(2), which follows symlinks. (os.Lstat()/lstat(2) does not.) + // But the stat for an fs.DirEntry? Uses lstat. + // Whatever, we want to resolve symlinks anyways. + if _, exists, _, _, stat, err = paths.RealPathExistsStatTarget(&fPath, "."); err != nil { + return + } else if exists { + if !stat.Mode().IsRegular() { + continue + } + if dedupe { + if _, exists = fpathMap[fPath]; !exists { + fpathMap[fPath] = true + files = append(files, fPath) + } + } else { + files = append(files, fPath) + } + if !d.ForceLegacy { + if incls, err = getDshGrpIncludes(fPath); err != nil { + return + } + if dedupe { + for _, i := range incls { + if _, exists = fpathMap[i]; !exists { + fpathMap[i] = true + files = append(files, i) + } + } + } else { + files = append(files, incls...) + } + } + } + } + } + + files = tmpF + + return +} + +/* +GroupedHosts returns a map of `map[][]string{[, , ...]}. + +Additional search paths may be specified via searchpaths. + +If there are any conflicting group names, the first found group name is used. +For example, assuming the group name ``, the following files will be checked in this order: + + 0. IF searchPaths is not nil: + a. searchpaths[0]/ + b. searchpaths[1]/ + c. searchpaths[2]/ + d. ( ... ) + 1. IF DshGroupLister.NoHome is false: + a. `~/.dsh/group/` + 2. IF $DSHGROUP_PATH is defined AND DshGroupLister.NoEnv is false: + a. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[0]/` + b. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[1]/` + c. `strings.Split(os.Getenv("DSHGROUP_PATH", string(os.PathListSeparator)))[2]/` + d. ( ... ) + 3. IF $DSHGROUP_PATH is NOT defined AND DshGroupLister.NoDefault is false: + a. `/etc/dsh/group/` +*/ +func (d *DshGroupLister) GroupedHosts(dedupe bool, searchPaths ...string) (groupedHosts map[string][]string, err error) { + + return +} diff --git a/pdsh/funcs_dshgrp.go b/pdsh/funcs_dshgrp.go new file mode 100644 index 0000000..807e96b --- /dev/null +++ b/pdsh/funcs_dshgrp.go @@ -0,0 +1,298 @@ +package pdsh + +import ( + "bufio" + "bytes" + "os" + "strconv" + "strings" + + "r00t2.io/sysutils/paths" +) + +/* +ParseDshPtrn parses ptrn using the DSH group pattern ptrn as according to `HOSTLIST EXPRESSSIONS` in pdsh(1). +`#include` directives are explicitly skipped; this only parses actual generation pattern strings. +*/ +func ParseDshPtrn(ptrn string) (hostList []string, err error) { + + var r rune + var pos int + var s string + var inToken bool + var tokStr string + var tok dshGrpToken + var strBuf *bytes.Buffer = new(bytes.Buffer) + var tokBuf *bytes.Buffer = new(bytes.Buffer) + var parser *dshGrpGenerator = &dshGrpGenerator{ + tokens: make([]dshGrpToken, 0), + tokenized: make([]string, 0), + text: ptrn, + } + + s = strings.TrimSpace(ptrn) + if s == "" { + return + } + if strings.HasPrefix(s, "#") { + return + } + // A quick sanity check. The end-state from the state machine below will catch any weird bracket issues beyond this. + if strings.Count(s, "[") != strings.Count(s, "]") { + err = ErrInvalidDshGrpSyntax + return + } + + // Now the hacky bits. We read until we get to a start-token ('['), end-token (']'), or a pattern separator (',') that is *outside* a range token. + for pos, r = range s { + switch r { + case '[': + if inToken { + // Nested [...[ + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: ErrInvalidDshGrpSyntax, + } + return + } + parser.tokenized = append(parser.tokenized, strBuf.String()) + strBuf.Reset() + inToken = true + case ']': + if !inToken { + // Nested ]...] + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: ErrInvalidDshGrpSyntax, + } + return + } + tokStr = tokBuf.String() + if tok, err = parseDshGrpToken(tokStr); err != nil { + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: err, + } + return + } + parser.tokens = append(parser.tokens, tok) + tokBuf.Reset() + inToken = false + default: + if inToken { + // If it isn't between '0' and '9', isn't '-', and isn't ','... + if !(0x30 <= r && r <= 0x39) && (r != 0x2d) && (r != 0x2c) { + // It's not a valid token. (The actual syntax is validated in parseDshGrpToken and parseDshGrpSubtoken) + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: ErrInvalidDshGrpSyntax, + } + return + } + tokBuf.WriteRune(r) + } else { + if strings.TrimSpace(string(r)) == "" || r == '#' { + // Whitespace is "invalid" (treat it as the end of the pattern). + // Same for end-of-line octothorpes. + if tokBuf.Len() > 0 { + // This should never happen. + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: ErrInvalidDshGrpSyntax, + } + return + } + if strBuf.Len() > 0 { + parser.tokenized = append(parser.tokenized, strBuf.String()) + } + break + } + // Otherwise we just check for valid DNS chars. + if !(0x30 <= r && r <= 0x39) && // '0'-'9' + (r != 0x2d) && // '-' + (r != 0x2e) && // '.' + !(0x41 <= r && r <= 0x5a) && // 'A' through 'Z' (inclusive) + !(0x6a <= r && r <= 0x7a) { // 'a' through 'z' (inclusive) + err = &PtrnParseErr{ + pos: uint(pos), + ptrn: ptrn, + r: r, + err: ErrInvalidDshGrpPtrn, + } + return + } + // (Probably) valid(-ish), so add it. + strBuf.WriteRune(r) + } + } + } + + // If the token never closed, it's also invalid. + if inToken { + err = ErrInvalidDshGrpSyntax + return + } + + return +} + +// parseDshGrpToken parses a token string into a dshGrpToken. +func parseDshGrpToken(tokenStr string) (token dshGrpToken, err error) { + + var s string + var st []string + var sub dshGrpSubtoken + + s = strings.TrimSpace(tokenStr) + st = strings.Split(s, ",") + token = dshGrpToken{ + token: tokenStr, + subtokens: make([]dshGrpSubtoken, 0, len(st)), + } + for _, s = range st { + if strings.TrimSpace(s) == "" { + continue + } + if sub, err = parseDshGrpSubtoken(s); err != nil { + return + } + token.subtokens = append(token.subtokens, sub) + + } + + return +} + +// parseDshGrpSubtoken parses a subtoken string into a dshGrpSubtoken. +func parseDshGrpSubtoken(subTokenStr string) (subtoken dshGrpSubtoken, err error) { + + var u64 uint64 + var vals []string + var endPad string + var startPad string + var st dshGrpSubtoken + var matches map[string][]string + + if matches = dshGrpSubTokenPtrn.MapString(subTokenStr, false, false, true); matches == nil || len(matches) == 0 { + err = ErrInvalidDshGrpPtrn + return + } + if vals = matches["start_pad"]; vals != nil && len(vals) == 1 { + startPad = vals[0] + } + /* + Due to a... particular quirk in the regex that I'm too tired to fix, + the start_pad may be e.g. "0" (or "00", etc.) and start may be "" if the range starts *at* 0 + (or 00, 000, etc.). + */ + if vals = matches["start"]; vals != nil && len(vals) == 1 { + if u64, err = strconv.ParseUint(vals[0], 10, 64); err != nil { + return + } + st.start = uint(u64) + } else if startPad != "" { + // Yeah, regex bug. So we remove one 0 from startPad, and set st.start to 0. + st.start = 0 // This is implicit, though. + startPad = startPad[:len(startPad)-1] + } + if vals = matches["end_pad"]; vals != nil && len(vals) == 1 { + endPad = vals[0] + } + if vals = matches["end"]; vals != nil && len(vals) == 1 { + if u64, err = strconv.ParseUint(vals[0], 10, 64); err != nil { + return + } + st.end = uint(u64) + } + + if startPad != "" && endPad != "" { + // We set the pad to the largest. + if len(startPad) > len(endPad) { + st.pad = startPad + } else { + st.pad = endPad + } + } else if startPad != "" { + st.pad = startPad + } else if endPad != "" { + st.pad = endPad + } + + subtoken = st + + return +} + +/* +getDshGrpIncludes parses fpath for `#include ...` directives. It skips any entries in which +`len(paths.SegmentSys(p) == []string{p}`, as these are inherently included by the dir read. + +It is assumed that fpath is a cleaned, absolute filepath. +*/ +func getDshGrpIncludes(fpath string) (includes []string, err error) { + + var f *os.File + var line string + var exists bool + var inclpath string + var subIncl []string + var segs []string + var scanner *bufio.Scanner + var matches map[string][]string + + if f, err = os.Open(fpath); err != nil { + return + } + defer f.Close() + + scanner = bufio.NewScanner(f) + for scanner.Scan() { + line = strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if !dshGrpInclPtrn.MatchString(line) { + continue + } + matches = dshGrpInclPtrn.MapString(line, false, false, true) + if matches == nil { + err = ErrInvalidDshGrpSyntax + return + } + if matches["incl"] == nil || len(matches["incl"]) == 0 { + err = ErrInvalidDshGrpSyntax + return + } + inclpath = matches["incl"][0] + segs = paths.SegmentSys(inclpath, false, false) + if segs == nil || len(segs) == 0 || (len(segs) == 1 && segs[0] == inclpath) { + continue + } + + if exists, err = paths.RealPathExists(&inclpath); err != nil { + return + } + if !exists { + continue + } + includes = append(includes, inclpath) + if subIncl, err = getDshGrpIncludes(inclpath); err != nil { + return + } + if subIncl != nil && len(subIncl) > 0 { + includes = append(includes, subIncl...) + } + } + + return +} diff --git a/pdsh/funcs_ptrnparseerr.go b/pdsh/funcs_ptrnparseerr.go new file mode 100644 index 0000000..f3150a5 --- /dev/null +++ b/pdsh/funcs_ptrnparseerr.go @@ -0,0 +1,16 @@ +package pdsh + +import ( + "fmt" +) + +// Error conforms a PtrnParseErr to error interface. +func (p *PtrnParseErr) Error() (errStr string) { + + errStr = fmt.Sprintf( + "Parse error in pattern '%s', position %d rune '%s': %v", + p.ptrn, p.pos, string(p.r), p.err, + ) + + return +} diff --git a/pdsh/types.go b/pdsh/types.go new file mode 100644 index 0000000..458a88a --- /dev/null +++ b/pdsh/types.go @@ -0,0 +1,86 @@ +package pdsh + +// TODO: This... doesn't really have much usefulness, does it? +/* +type ( + HostLister interface { + // Hosts returns ALL hsots (where applicable) that are considered/generated for a Lister. + Hosts() (hosts []string, err error) + } +) +*/ + +type ( + /* + DshGroupLister behaves like the host list generator + for pdsh(1)'s "dshgroup module options" (the `misc/dshgroup` + module for pdsh). + */ + DshGroupLister struct { + /* + NoEnv, if true, will *not* use DSHGROUP_PATH (force-defaulting to /etc/dsh/group/, + but see NoDefault). + */ + NoEnv bool + /* + NoDefault, if true, will *not* add the default path `/etc/dsh/group/` + to the search paths. + + If NoDefault is false, this path is only added if DSHGROUP_PATH is not defined + (or, if it IS defined, if NoEnv is true). + */ + NoDefault bool + // NoHome, if true, will *not* add the `~/.dsh/group/` path to the search paths. + NoHome bool + /* + ForceLegacy, if true, will disable the PDSH `#include ` modification -- + treating the source as a traditional DSH group file instead (e.g. `#include ...` + is treated as just a comment). + */ + ForceLegacy bool + } +) + +type ( + dshGrpGenerator struct { + /* + tokens are interleaved with tokenized and indexed *after*; + in other words, str = ... + */ + tokens []dshGrpToken + // tokenized holds the split original text with tokens removed and split where the tokens occur. + tokenized []string + // text holds the original pattern. + text string + } + dshGrpToken struct { + /* + token contains the original range specifier. + Tokens may be e.g.: + + * 3: str3 + * 3-5: str3, str4, str5 + * 3,5: str3, str5 + */ + token string + // subtokens hold a split of the individual range specifiers. + subtokens []dshGrpSubtoken + } + dshGrpSubtoken struct { + // start indicates either the single value or the start of the range. + start uint + // end, if 0 or less than start, indicates a single-value range. + end uint + // pad, if non-empty, is a string to add to the beginning of each of the generated substrings for this subtoken. + pad string + } +) + +type ( + PtrnParseErr struct { + pos uint + ptrn string + r rune + err error + } +)