diff --git a/core/clusterip/main.go b/core/clusterip/main.go index 2c6662dc3..498566838 100644 --- a/core/clusterip/main.go +++ b/core/clusterip/main.go @@ -45,7 +45,18 @@ func (t L) Len() int { } func (t L) Less(i, j int) bool { - return t[i].Path.String() < t[j].Path.String() + switch { + case t[i].Path.String() != t[j].Path.String(): + return t[i].Path.String() < t[j].Path.String() + case t[i].Node != t[j].Node: + return t[i].Node < t[j].Node + case t[i].RID != t[j].RID: + return t[i].RID < t[j].RID + case !t[i].IP.Equal(t[j].IP): + return t[i].IP.String() < t[j].IP.String() + default: + return false + } } func (t L) Swap(i, j int) { diff --git a/core/clusterip/main_test.go b/core/clusterip/main_test.go new file mode 100644 index 000000000..87be2b4d1 --- /dev/null +++ b/core/clusterip/main_test.go @@ -0,0 +1,48 @@ +package clusterip + +import ( + "github.com/opensvc/om3/core/path" + "github.com/stretchr/testify/assert" + "net" + "sort" + "testing" +) + +func TestSort(t *testing.T) { + sortedList := func() L { + return L{ + {IP: net.IP{10, 8, 0, 8}, Node: "test", Path: path.T{Name: "a", Namespace: "A", Kind: path.KindCcfg}, RID: "ip#10"}, + {IP: net.IP{10, 200, 3, 1}, Node: "test", Path: path.T{Name: "b", Namespace: "A", Kind: path.KindSvc}, RID: "ip#3"}, + {IP: net.IP{10, 6, 6, 9}, Node: "a", Path: path.T{Name: "z", Namespace: "C", Kind: path.KindVol}, RID: "ip#99"}, + {IP: net.IP{10, 0, 0, 0}, Node: "b", Path: path.T{Name: "z", Namespace: "C", Kind: path.KindVol}, RID: "ip#70"}, + {IP: net.IP{10, 20, 1, 1}, Node: "b", Path: path.T{Name: "z", Namespace: "D", Kind: path.KindSvc}, RID: "ip#10"}, + {IP: net.IP{10, 30, 0, 1}, Node: "b", Path: path.T{Name: "z", Namespace: "D", Kind: path.KindSvc}, RID: "ip#10"}, + {IP: net.IP{10, 99, 99, 1}, Node: "b", Path: path.T{Name: "z", Namespace: "D", Kind: path.KindUsr}, RID: "ip#10"}, + {IP: net.IP{10, 0, 99, 1}, Node: "b", Path: path.T{Name: "z", Namespace: "D", Kind: path.KindUsr}, RID: "ip#8"}, + } + } + unsortedList := func(order []int) L { + list := L{} + ori := sortedList() + for _, v := range order { + list = append(list, ori[v]) + } + return list + } + listToBeSorted := unsortedList([]int{1, 2, 0, 3, 6, 4, 5, 7}) + sort.Sort(listToBeSorted) + assert.Equal(t, sortedList(), listToBeSorted) + + listToBeSorted = unsortedList([]int{4, 5, 6, 1, 3, 2, 0, 7}) + sort.Sort(listToBeSorted) + assert.Equal(t, sortedList(), listToBeSorted) + + listToBeSorted = unsortedList([]int{4, 0, 5, 2, 1, 6, 7, 3}) + sort.Sort(listToBeSorted) + assert.Equal(t, sortedList(), listToBeSorted) + + listToBeSorted = unsortedList([]int{6, 7, 3, 4, 5, 2, 1, 0}) + sort.Sort(listToBeSorted) + assert.Equal(t, sortedList(), listToBeSorted) + +} diff --git a/util/compobj/file.go b/util/compobj/file.go index 57b85fb64..e067f539e 100644 --- a/util/compobj/file.go +++ b/util/compobj/file.go @@ -22,25 +22,31 @@ type ( } CompFile struct { Path string `json:"path"` - Mode int `json:"mode"` + Mode *int `json:"mode"` UID interface{} `json:"uid"` GID interface{} `json:"gid"` - Fmt string `json:"fmt"` + Fmt *string `json:"fmt"` Ref string `json:"ref"` } ) -var compFilesInfo = ObjInfo{ - DefaultPrefix: "OSVC_COMP_FILE_", - ExampleValue: CompFile{ - Path: "/some/path/to/file", - Fmt: "root@corp.com %%HOSTNAME%%@corp.com", - UID: 500, - GID: 500, - }, - Description: `* Verify and install file content. +var ( + collectorSafeGetMetaFunc = collectorSafeGetMeta + + stringFmt = "root@corp.com %%HOSTNAME%%@corp.com" + + compFilesInfo = ObjInfo{ + DefaultPrefix: "OSVC_COMP_FILE_", + ExampleValue: CompFile{ + Path: "/some/path/to/file", + Fmt: &stringFmt, + UID: 500, + GID: 500, + }, + Description: `* Verify and install file content. * Verify and set file or directory ownership and permission * Directory mode is triggered if the path ends with / +* Only for the fmt field : if the newline character is not present at the end of the text, one is automatically added Special wildcards:: @@ -48,7 +54,7 @@ Special wildcards:: %%HOSTNAME%% Hostname %%SHORT_HOSTNAME%% Short hostname `, - FormDefinition: `Desc: | + FormDefinition: `Desc: | A file rule, fed to the 'files' compliance object to create a directory or a file and set its ownership and permissions. For files, a reference content can be specified or pointed through an URL. Css: comp48 @@ -114,7 +120,8 @@ Inputs: Help: A reference content for the file. The text can embed substitution variables specified with %%ENV:VAR%%. Type: text `, -} + } +) func init() { m["file"] = NewCompFiles @@ -137,7 +144,7 @@ func (t *CompFiles) Add(s string) error { func (t CompFile) Content() ([]byte, error) { if t.Ref == "" { - b := []byte(t.Fmt) + b := []byte(*t.Fmt) if !bytes.HasSuffix(b, []byte("\n")) { b = append(b, []byte("\n")...) } @@ -173,7 +180,7 @@ func (t CompFile) ParseGID() int { } func (t CompFile) FileMode() (os.FileMode, error) { - s := fmt.Sprintf("0%d", t.Mode) + s := fmt.Sprintf("0%d", *t.Mode) i, err := strconv.ParseInt(s, 8, 32) if err != nil { return os.FileMode(0), err @@ -181,8 +188,29 @@ func (t CompFile) FileMode() (os.FileMode, error) { return os.FileMode(i), nil } +func (t CompFiles) checkPathExistance(rule CompFile) ExitCode { + _, err := os.Lstat(rule.Path) + m, _ := file.Mode(rule.Path) + t.VerboseErrorf(m.String()) + if err != nil { + if os.IsNotExist(err) { + t.VerboseErrorf("the file %s does not exist\n", rule.Path) + return ExitNok + } + t.VerboseErrorf("can't check if the file %s exist: %s", rule.Path, err) + return ExitNok + } + return ExitOk +} + func (t CompFiles) checkMode(rule CompFile) ExitCode { + if rule.Mode == nil { + return ExitNotApplicable + } target, err := rule.FileMode() + if strings.HasSuffix(rule.Path, "/") { + target = target | os.ModeDir + } if err != nil { t.VerboseErrorf("file %s parse target mode: %s\n", rule.Path, err) return ExitNok @@ -202,6 +230,9 @@ func (t CompFiles) checkMode(rule CompFile) ExitCode { func (t CompFiles) fixMode(rule CompFile) ExitCode { target, err := rule.FileMode() + if strings.HasSuffix(rule.Path, "/") { + target = target | os.ModeDir + } if err != nil { t.Errorf("file %s parse target mode: %s\n", rule.Path, err) return ExitNok @@ -273,7 +304,7 @@ func (t CompFile) isSafeRef() bool { } func (t CompFiles) checkSafeRef(rule CompFile) ExitCode { - meta, err := collectorSafeGetMeta(rule.Ref) + meta, err := collectorSafeGetMetaFunc(rule.Ref) currentMD5, err := file.MD5(rule.Path) if err != nil { t.VerboseErrorf("file %s md5sum: %s\n", rule.Path, err) @@ -288,6 +319,9 @@ func (t CompFiles) checkSafeRef(rule CompFile) ExitCode { } func (t CompFiles) checkContent(rule CompFile) ExitCode { + if rule.Ref == "" && rule.Fmt == nil { + return ExitNotApplicable + } if rule.isSafeRef() { return t.checkSafeRef(rule) } @@ -342,8 +376,33 @@ func (t CompFiles) fixContent(rule CompFile) ExitCode { t.Infof("file %s rewritten\n", rule.Path) return ExitOk } +func (t CompFiles) fixPathExistance(rule CompFile) ExitCode { + if strings.HasPrefix(rule.Path, "/") { + err := os.Mkdir(rule.Path, 0666) + if err != nil { + t.VerboseErrorf("can't create the file :%s", rule.Path) + return ExitNok + } + return ExitOk + } + f, err := os.Create(rule.Path) + if err != nil { + t.VerboseErrorf("can't create the file :%s", rule.Path) + return ExitNok + } + if err = f.Close(); err != nil { + t.VerboseErrorf("can't close the file :%s", rule.Path) + return ExitNok + } + return ExitOk +} func (t CompFiles) FixRule(rule CompFile) ExitCode { + if e := t.checkPathExistance(rule); e == ExitNok { + if e := t.fixPathExistance(rule); e == ExitNok { + return ExitNok + } + } if e := t.checkContent(rule); e == ExitNok { if e := t.fixContent(rule); e == ExitNok { return e @@ -364,6 +423,10 @@ func (t CompFiles) FixRule(rule CompFile) ExitCode { func (t CompFiles) CheckRule(rule CompFile) ExitCode { var e, o ExitCode + if o = t.checkPathExistance(rule); o == ExitNok { + return ExitNok + } + e = e.Merge(o) o = t.checkContent(rule) e = e.Merge(o) o = t.checkOwnership(rule) diff --git a/util/compobj/file_test.go b/util/compobj/file_test.go new file mode 100644 index 000000000..622196768 --- /dev/null +++ b/util/compobj/file_test.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "github.com/opensvc/om3/util/file" + "github.com/stretchr/testify/require" + "net/http" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestFile(t *testing.T) { + type prepareEnv func(t *testing.T, file *CompFile) + type testCase struct { + envs []prepareEnv + rule CompFile + expectCheck ExitCode + expectFix ExitCode + needRoot bool + } + + orig := collectorSafeGetMetaFunc + defer func() { + collectorSafeGetMetaFunc = orig + }() + collectorSafeGetMetaFunc = func(safePath string) (SafeFileMeta, error) { + safePath = strings.Replace(safePath, "safe://", "", 1) + md5, err := file.MD5(safePath) + if err != nil { + return SafeFileMeta{}, err + } + return SafeFileMeta{MD5: hex.EncodeToString(md5)}, nil + } + + startServer := func(addr string) func() { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte("a response\n")) + }) + s := http.Server{Addr: addr, Handler: mux} + go func() { + t.Logf("starting server %s", addr) + _ = s.ListenAndServe() + }() + return func() { + _ = s.Shutdown(context.Background()) + t.Logf("shutdowned server %s", addr) + } + } + + defer startServer(":8080")() + time.Sleep(time.Millisecond) + t.Run("ensure fake web server is running", func(t *testing.T) { + get, err := http.Get("http://localhost:8080/") + require.NoError(t, err) + b := make([]byte, 500) + l, err := get.Body.Read(b) + require.Greater(t, l, 0) + require.Equal(t, "a response\n", string(b[:l])) + }) + + withEmptyFile := func(t *testing.T, r *CompFile) { + t.Helper() + f := filepath.Join(t.TempDir(), "withEmptyFile") + r.Path = f + t.Logf("with file %s", f) + created, err := os.Create(f) + require.NoErrorf(t, err, "can't create file for rule: %s", f) + require.NoError(t, created.Close()) + } + + withFileContent := func(t *testing.T, r *CompFile) { + t.Helper() + f := filepath.Join(t.TempDir(), "withFileContent") + r.Path = f + b, err := r.Content() + require.NoError(t, err) + if !strings.HasSuffix(string(b), "\n") { + b = append(b, byte('\n')) + } + t.Logf("with file %s contents: '%s'", f, b) + require.Nil(t, os.WriteFile(f, b, 0666)) + } + + withBadPerms := func(t *testing.T, r *CompFile) { + require.NoError(t, os.Chmod(r.Path, os.FileMode(*r.Mode)^os.ModeSticky)) + } + + withPerms := func(t *testing.T, r *CompFile) { + t.Helper() + s := fmt.Sprintf("0%d", *r.Mode) + i, err := strconv.ParseInt(s, 8, 32) + require.NoError(t, err) + if strings.HasSuffix(r.Path, "/") { + err = os.Chmod(r.Path, os.FileMode(i)|os.ModeDir) + } else { + err = os.Chmod(r.Path, os.FileMode(i)) + } + require.NoError(t, err) + t.Logf("with perms %s for file: '%s'", "0"+strconv.Itoa(int(i)), r.Path) + } + + withUid := func(t *testing.T, r *CompFile) { + t.Helper() + err := os.Chown(r.Path, r.UID.(int), -1) + require.NoError(t, err) + t.Logf("with Uid %d for file: '%s'", r.UID.(int), r.Path) + } + + withGid := func(t *testing.T, r *CompFile) { + t.Helper() + err := os.Chown(r.Path, -1, r.GID.(int)) + require.NoError(t, err) + t.Logf("with Gid %d for file: '%s'", r.GID.(int), r.Path) + } + + withWrongUid := func(t *testing.T, r *CompFile) { + t.Helper() + var err error + if r.UID.(int) == 1500 { + err = os.Chown(r.Path, 1501, -1) + t.Logf("with uid %d for file: '%s'", 1501, r.Path) + } else { + err = os.Chown(r.Path, 1500, -1) + t.Logf("with uid %d for file: '%s'", 1500, r.Path) + } + require.NoError(t, err) + } + + withWrongGid := func(t *testing.T, r *CompFile) { + t.Helper() + var err error + if r.GID.(int) == 1500 { + err = os.Chown(r.Path, -1, 1501) + t.Logf("with gid %d for file: '%s'", 1501, r.Path) + } else { + err = os.Chown(r.Path, -1, 1500) + t.Logf("with gid %d for file: '%s'", 1500, r.Path) + } + require.NoError(t, err) + } + + withSafeRef := func(t *testing.T, r *CompFile) { + t.Helper() + withEmptyFile(t, r) + r.Ref = "safe://" + r.Path + } + + withWrongSafeRef := func(t *testing.T, r *CompFile) { + t.Helper() + withEmptyFile(t, r) + r.Ref = "safe://" + r.Path + withEmptyFile(t, r) + err := os.WriteFile(r.Path, []byte("content"), 0666) + require.NoError(t, err) + } + + obj := CompFiles{Obj: &Obj{rules: make([]interface{}, 0), verbose: true}} + pts := func(s string) *string { return &s } + pti := func(i int) *int { return &i } + cases := map[string]testCase{ + "with empty file": { + envs: []prepareEnv{withEmptyFile}, + rule: CompFile{Fmt: pts("content\n")}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with correct content": { + envs: []prepareEnv{withFileContent}, + rule: CompFile{Fmt: pts("content\n")}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with correct content and no carriage return and the end of the content": { + envs: []prepareEnv{withFileContent}, + rule: CompFile{Fmt: pts("content")}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with correct content from ref": { + envs: []prepareEnv{withFileContent}, + rule: CompFile{Ref: "http://localhost:8080/"}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with safe ref": { + envs: []prepareEnv{withSafeRef}, + rule: CompFile{}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with wrong safe ref": { + envs: []prepareEnv{withWrongSafeRef}, + rule: CompFile{}, + expectCheck: ExitNok, + expectFix: ExitNok}, + + "with bad perms (file mode)": { + envs: []prepareEnv{withFileContent, withBadPerms}, + rule: CompFile{Fmt: pts("content\n"), Mode: pti(666)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with correct perms (file mode)": { + envs: []prepareEnv{withFileContent, withPerms}, + rule: CompFile{Fmt: pts("content\n"), Mode: pti(666)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with no field": { + envs: []prepareEnv{}, + rule: CompFile{}, + expectCheck: ExitNok, + expectFix: ExitNok}, + + "with uid (file mode)": { + envs: []prepareEnv{withEmptyFile, withUid}, + rule: CompFile{UID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with gid (file mode)": { + envs: []prepareEnv{withEmptyFile, withGid}, + rule: CompFile{GID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with wrong uid (file mode)": { + envs: []prepareEnv{withEmptyFile, withWrongUid}, + rule: CompFile{UID: 1600}, + expectCheck: ExitNok, + expectFix: ExitOk, + needRoot: true}, + + "with wrong gid (file mode)": { + envs: []prepareEnv{withEmptyFile, withWrongGid}, + rule: CompFile{GID: 1600}, + expectCheck: ExitNok, + expectFix: ExitOk, + needRoot: true}, + + "with bad path (file mode)": { + envs: []prepareEnv{}, + rule: CompFile{Path: filepath.Join(t.TempDir(), "wrongpath")}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with path (dir mode)": { + envs: []prepareEnv{}, + rule: CompFile{Path: filepath.Join(t.TempDir()) + string(filepath.Separator)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with bad path (dir mode)": { + envs: []prepareEnv{}, + rule: CompFile{Path: filepath.Join(t.TempDir(), "wrongDir") + string(filepath.Separator)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with uid (dir mode)": { + envs: []prepareEnv{withUid}, + rule: CompFile{Path: t.TempDir(), UID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with perms (dir mode)": { + envs: []prepareEnv{withPerms}, + rule: CompFile{Path: filepath.Join(t.TempDir()) + string(filepath.Separator), Mode: pti(666)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with bad perms (dir mode)": { + envs: []prepareEnv{withBadPerms}, + rule: CompFile{Path: filepath.Join(t.TempDir()) + string(filepath.Separator), Mode: pti(666)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with fmt (dir mode)": { + envs: []prepareEnv{}, + rule: CompFile{Path: filepath.Join(t.TempDir()) + string(filepath.Separator), Fmt: pts("content")}, + expectCheck: ExitNok, + expectFix: ExitNok}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if c.needRoot { + usr, err := user.Current() + require.NoError(t, err) + if usr.Username != "root" { + t.Skip("need root") + } + } + for _, f := range c.envs { + f(t, &c.rule) + } + t.Run("Check", func(t *testing.T) { + t.Logf("check : %d", obj.CheckRule(c.rule)) + require.Equal(t, c.expectCheck, obj.CheckRule(c.rule)) + }) + t.Run("Fix", func(t *testing.T) { + require.Equal(t, c.expectFix, obj.FixRule(c.rule)) + }) + if c.expectCheck == ExitNok && c.expectFix == ExitOk { + t.Run("Check after succeed Fix should succeed", func(t *testing.T) { + require.Equal(t, ExitOk, obj.CheckRule(c.rule)) + }) + } + if c.expectCheck == ExitNok && c.expectFix == ExitNok { + t.Run("Check continue to fail after failed fix", func(t *testing.T) { + require.Equal(t, ExitNok, obj.CheckRule(c.rule)) + }) + } + if c.rule.Fmt != nil && c.expectFix == ExitOk { + t.Run("read file after fix ok to verify content is fmt", func(t *testing.T) { + b, err := os.ReadFile(c.rule.Path) + require.NoError(t, err) + var expected string + if c.rule.Fmt != nil { + if strings.HasSuffix(*c.rule.Fmt, "\n") { + expected = *c.rule.Fmt + } else { + expected = string(*c.rule.Fmt + "\n") + } + } + require.Equal(t, expected, string(b)) + }) + } + }) + } +} diff --git a/util/compobj/fileprop.go b/util/compobj/fileprop.go new file mode 100644 index 000000000..d0ebbb994 --- /dev/null +++ b/util/compobj/fileprop.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" +) + +type ( + CompFilesProps struct { + *Obj + } + CompFileProp struct { + Path string `json:"path"` + Mode *int `json:"mode"` + UID interface{} `json:"uid"` + GID interface{} `json:"gid"` + } +) + +var ( + compFilesPropsInfo = ObjInfo{ + DefaultPrefix: "OSVC_COMP_FILEPROP_", + ExampleValue: CompFileProp{ + Path: "/some/path/to/file", + UID: 500, + GID: 500, + }, + Description: `* Verify file existance, mode and ownership. +* The collector provides the format with wildcards. +* The module replace the wildcards with contextual values. + +In fix() the file is created empty with the right mode & ownership. + +Special wildcards:: + + %%ENV:VARNAME%% Any environment variable value + %%HOSTNAME%% Hostname + %%SHORT_HOSTNAME%% Short hostname +`, + FormDefinition: `Desc: | + A fileprop rule, fed to the 'fileprop' compliance object to verify the target file ownership and permissions. +Css: comp48 +Outputs: + - + Dest: compliance variable + Class: fileprop + Type: json + Format: dict +Inputs: + - + Id: path + Label: Path + DisplayModeLabel: path + LabelCss: action16 + Mandatory: Yes + Help: File path to check the ownership and permissions for. + Type: string + - + Id: mode + Label: Permissions + DisplayModeLabel: perm + LabelCss: action16 + Help: "In octal form. Example: 644" + Type: integer + - + Id: uid + Label: Owner + DisplayModeLabel: uid + LabelCss: guy16 + Help: Either a user ID or a user name + Type: string or integer + - + Id: gid + Label: Owner group + DisplayModeLabel: gid + LabelCss: guy16 + Help: Either a group ID or a group name + Type: string or integer +`, + } +) + +func init() { + m["fileprop"] = NewCompFilesProps +} + +func NewCompFilesProps() interface{} { + return &CompFilesProps{ + Obj: NewObj(), + } +} + +func (t *CompFilesProps) Add(s string) error { + var data CompFileProp + if err := json.Unmarshal([]byte(s), &data); err != nil { + return err + } + t.Obj.Add(data) + return nil +} + +func (t CompFilesProps) FixRule(rule CompFileProp) ExitCode { + fileobj := CompFiles{Obj: &Obj{rules: make([]interface{}, 0), verbose: true}} + rulefile := CompFile{ + Path: rule.Path, + Mode: rule.Mode, + UID: rule.UID, + GID: rule.GID, + Fmt: nil, + Ref: "", + } + if e := fileobj.checkPathExistance(rulefile); e == ExitNok { + if e := fileobj.fixPathExistance(rulefile); e == ExitNok { + return ExitNok + } + } + if e := fileobj.checkOwnership(rulefile); e == ExitNok { + if e := fileobj.fixOwnership(rulefile); e == ExitNok { + return e + } + } + if e := fileobj.checkMode(rulefile); e == ExitNok { + if e := fileobj.fixMode(rulefile); e == ExitNok { + return e + } + } + return ExitOk +} + +func (t CompFilesProps) CheckRule(rule CompFileProp) ExitCode { + fileobj := CompFiles{Obj: &Obj{rules: make([]interface{}, 0), verbose: true}} + rulefile := CompFile{ + Path: rule.Path, + Mode: rule.Mode, + UID: rule.UID, + GID: rule.GID, + Fmt: nil, + Ref: "", + } + var e, o ExitCode + if o = fileobj.checkPathExistance(rulefile); o == ExitNok { + return ExitNok + } + e = e.Merge(o) + o = fileobj.checkOwnership(rulefile) + e = e.Merge(o) + o = fileobj.checkMode(rulefile) + e = e.Merge(o) + return e +} + +func (t CompFilesProps) Check() ExitCode { + t.SetVerbose(true) + e := ExitOk + for _, i := range t.Rules() { + rule := i.(CompFileProp) + o := t.CheckRule(rule) + e = e.Merge(o) + } + return e +} + +func (t CompFilesProps) Fix() ExitCode { + t.SetVerbose(false) + for _, i := range t.Rules() { + rule := i.(CompFileProp) + if e := t.FixRule(rule); e == ExitNok { + return ExitNok + } + } + return ExitOk +} + +func (t CompFilesProps) Fixable() ExitCode { + return ExitNotApplicable +} + +func (t CompFilesProps) Info() ObjInfo { + return compFilesPropsInfo +} diff --git a/util/compobj/fileprop_test.go b/util/compobj/fileprop_test.go new file mode 100644 index 000000000..4f1745a2e --- /dev/null +++ b/util/compobj/fileprop_test.go @@ -0,0 +1,215 @@ +package main + +import ( + "fmt" + "github.com/stretchr/testify/require" + "os" + "os/user" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestFileprop(t *testing.T) { + type prepareEnv func(t *testing.T, file *CompFileProp) + type testCase struct { + envs []prepareEnv + rule CompFileProp + expectCheck ExitCode + expectFix ExitCode + needRoot bool + } + + withFile := func(t *testing.T, r *CompFileProp) { + t.Helper() + f := filepath.Join(t.TempDir(), "withEmptyFile") + r.Path = f + t.Logf("with file %s", f) + created, err := os.Create(f) + require.NoErrorf(t, err, "can't create file for rule: %s", f) + require.NoError(t, created.Close()) + } + + withBadPerms := func(t *testing.T, r *CompFileProp) { + require.NoError(t, os.Chmod(r.Path, os.FileMode(*r.Mode)^os.ModeSticky)) + } + + withPerms := func(t *testing.T, r *CompFileProp) { + t.Helper() + s := fmt.Sprintf("0%d", *r.Mode) + i, err := strconv.ParseInt(s, 8, 32) + require.NoError(t, err) + if strings.HasSuffix(r.Path, "/") { + err = os.Chmod(r.Path, os.FileMode(i)|os.ModeDir) + } else { + err = os.Chmod(r.Path, os.FileMode(i)) + } + require.NoError(t, err) + t.Logf("with perms %s for file: '%s'", "0"+strconv.Itoa(int(i)), r.Path) + } + + withUid := func(t *testing.T, r *CompFileProp) { + t.Helper() + err := os.Chown(r.Path, r.UID.(int), -1) + require.NoError(t, err) + t.Logf("with Uid %d for file: '%s'", r.UID.(int), r.Path) + } + + withGid := func(t *testing.T, r *CompFileProp) { + t.Helper() + err := os.Chown(r.Path, -1, r.GID.(int)) + require.NoError(t, err) + t.Logf("with Gid %d for file: '%s'", r.GID.(int), r.Path) + } + + withWrongUid := func(t *testing.T, r *CompFileProp) { + t.Helper() + var err error + if r.UID.(int) == 1500 { + err = os.Chown(r.Path, 1501, -1) + t.Logf("with uid %d for file: '%s'", 1501, r.Path) + } else { + err = os.Chown(r.Path, 1500, -1) + t.Logf("with uid %d for file: '%s'", 1500, r.Path) + } + require.NoError(t, err) + } + + withWrongGid := func(t *testing.T, r *CompFileProp) { + t.Helper() + var err error + if r.GID.(int) == 1500 { + err = os.Chown(r.Path, -1, 1501) + t.Logf("with gid %d for file: '%s'", 1501, r.Path) + } else { + err = os.Chown(r.Path, -1, 1500) + t.Logf("with gid %d for file: '%s'", 1500, r.Path) + } + require.NoError(t, err) + } + + obj := CompFilesProps{Obj: &Obj{rules: make([]interface{}, 0), verbose: true}} + pti := func(i int) *int { return &i } + cases := map[string]testCase{ + "with empty file": { + envs: []prepareEnv{withFile}, + rule: CompFileProp{}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with bad perms (file mode)": { + envs: []prepareEnv{withFile, withBadPerms}, + rule: CompFileProp{Mode: pti(666)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with correct perms (file mode)": { + envs: []prepareEnv{withFile, withPerms}, + rule: CompFileProp{Mode: pti(666)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with no field": { + envs: []prepareEnv{}, + rule: CompFileProp{}, + expectCheck: ExitNok, + expectFix: ExitNok}, + + "with uid (file mode)": { + envs: []prepareEnv{withFile, withUid}, + rule: CompFileProp{UID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with gid (file mode)": { + envs: []prepareEnv{withFile, withGid}, + rule: CompFileProp{GID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with wrong uid (file mode)": { + envs: []prepareEnv{withFile, withWrongUid}, + rule: CompFileProp{UID: 1600}, + expectCheck: ExitNok, + expectFix: ExitOk, + needRoot: true}, + + "with wrong gid (file mode)": { + envs: []prepareEnv{withFile, withWrongGid}, + rule: CompFileProp{GID: 1600}, + expectCheck: ExitNok, + expectFix: ExitOk, + needRoot: true}, + + "with bad path (file mode)": { + envs: []prepareEnv{}, + rule: CompFileProp{Path: filepath.Join(t.TempDir(), "wrongpath")}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with path (dir mode)": { + envs: []prepareEnv{}, + rule: CompFileProp{Path: filepath.Join(t.TempDir()) + string(filepath.Separator)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with bad path (dir mode)": { + envs: []prepareEnv{}, + rule: CompFileProp{Path: filepath.Join(t.TempDir(), "wrongDir") + string(filepath.Separator)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + + "with uid (dir mode)": { + envs: []prepareEnv{withUid}, + rule: CompFileProp{Path: t.TempDir(), UID: 1600}, + expectCheck: ExitOk, + expectFix: ExitOk, + needRoot: true}, + + "with perms (dir mode)": { + envs: []prepareEnv{withPerms}, + rule: CompFileProp{Path: filepath.Join(t.TempDir()) + string(filepath.Separator), Mode: pti(666)}, + expectCheck: ExitOk, + expectFix: ExitOk}, + + "with bad perms (dir mode)": { + envs: []prepareEnv{withBadPerms}, + rule: CompFileProp{Path: filepath.Join(t.TempDir()) + string(filepath.Separator), Mode: pti(666)}, + expectCheck: ExitNok, + expectFix: ExitOk}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if c.needRoot { + usr, err := user.Current() + require.NoError(t, err) + if usr.Username != "root" { + t.Skip("need root") + } + } + for _, f := range c.envs { + f(t, &c.rule) + } + t.Run("Check", func(t *testing.T) { + t.Logf("check : %d", obj.CheckRule(c.rule)) + require.Equal(t, c.expectCheck, obj.CheckRule(c.rule)) + }) + t.Run("Fix", func(t *testing.T) { + require.Equal(t, c.expectFix, obj.FixRule(c.rule)) + }) + if c.expectCheck == ExitNok && c.expectFix == ExitOk { + t.Run("Check after succeed Fix should succeed", func(t *testing.T) { + require.Equal(t, ExitOk, obj.CheckRule(c.rule)) + }) + } + if c.expectCheck == ExitNok && c.expectFix == ExitNok { + t.Run("Check continue to fail after failed fix", func(t *testing.T) { + require.Equal(t, ExitNok, obj.CheckRule(c.rule)) + }) + } + }) + } +} diff --git a/util/compobj/main.go b/util/compobj/main.go index 5fd45a86b..5400ea002 100644 --- a/util/compobj/main.go +++ b/util/compobj/main.go @@ -38,7 +38,8 @@ type ( Description string `json:"description"` FormDefinition string `json:"form_definition"` } - ExitCode int + ExitCode int + exitCodePair [2]ExitCode ) var ( @@ -59,18 +60,19 @@ func (t ExitCode) Exit() { } func (t ExitCode) Merge(o ExitCode) ExitCode { + pair := exitCodePair{t, o} switch { - case t == ExitOk && o == ExitOk: + case pair.is(ExitOk, ExitOk): return ExitOk - case t == ExitOk && o == ExitNok: + case pair.is(ExitOk, ExitNok): return ExitNok - case t == ExitOk && o == ExitNotApplicable: + case pair.is(ExitOk, ExitNotApplicable): return ExitOk - case t == ExitNok && o == ExitOk: + case pair.is(ExitNok, ExitNotApplicable): return ExitNok - case t == ExitNok && o == ExitNotApplicable: + case pair.is(ExitNok, ExitNok): return ExitNok - case t == ExitNotApplicable && o == ExitNotApplicable: + case pair.is(ExitNotApplicable, ExitNotApplicable): return ExitNotApplicable default: return ExitCode(-1) @@ -154,35 +156,43 @@ func syntax() { `, os.Args[0], os.Args[0]) } -func links() { - fmt.Println("The compliance objects in this collection must be called via a symlink.") - fmt.Println("Collection content:") +func links(w io.Writer) { + _, _ = fmt.Fprintln(w, "The compliance objects in this collection must be called via a symlink.") + _, _ = fmt.Fprintln(w, "Collection content:") for k, _ := range m { - fmt.Printf(" %s\n", k) + _, _ = fmt.Fprintf(w, " %s\n", k) } } +func (t exitCodePair) is(e0 ExitCode, e1 ExitCode) bool { + return (t[0] == e0 && t[1] == e1) || (t[1] == e0 && t[0] == e1) +} func main() { - objName := filepath.Base(os.Args[0]) - if p, err := os.Readlink(os.Args[0]); err != nil || filepath.Base(p) == objName { - links() - os.Exit(0) + e := mainArgs(os.Args, os.Stdout, os.Stderr) + e.Exit() +} + +func mainArgs(osArgs []string, wOut, wErr io.Writer) ExitCode { + objName := filepath.Base(osArgs[0]) + if p, err := os.Readlink(osArgs[0]); err != nil || filepath.Base(p) == objName { + links(wErr) + return ExitOk } newObj, ok := m[objName] if !ok { - fmt.Fprintf(os.Stderr, "%s compliance object not found in the core collection\n", objName) - os.Exit(1) + fmt.Fprintf(wErr, "%s compliance object not found in the core collection\n", objName) + return ExitNok } var prefix, action string - switch len(os.Args) { + switch len(osArgs) { case 2: - action = os.Args[1] + action = osArgs[1] case 3: - prefix = os.Args[1] - action = os.Args[2] + prefix = osArgs[1] + action = osArgs[2] default: syntax() - os.Exit(1) + return ExitNok } obj := newObj().(I) if prefix == "" { @@ -196,27 +206,26 @@ func main() { continue } if err := obj.Add(v); err != nil { - fmt.Fprintf(os.Stderr, "incompatible data: %s: %+v\n", err, v) + _, _ = fmt.Fprintf(wErr, "incompatible data: %s: %+v\n", err, v) continue } } - var e ExitCode switch action { case "check": - e = obj.Check() + return obj.Check() case "fix": - e = obj.Fix() + return obj.Fix() case "fixable": - e = obj.Fixable() + return obj.Fixable() case "info": nfo := obj.Info() - fmt.Println(nfo.MarkDown()) + _, _ = fmt.Fprintln(wOut, nfo.MarkDown()) + return ExitOk default: - fmt.Fprintf(os.Stderr, "invalid action: %s\n", action) - e.Exit() + _, _ = fmt.Fprintf(wErr, "invalid action: %s\n", action) + return ExitOk } - e.Exit() } func getFile(url string) ([]byte, error) { diff --git a/util/compobj/main_test.go b/util/compobj/main_test.go new file mode 100644 index 000000000..ab0c58d69 --- /dev/null +++ b/util/compobj/main_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "github.com/stretchr/testify/require" + "io" + "os" + "path/filepath" + "testing" +) + +type ( + fakeModule struct { + checkCode ExitCode + fixCode ExitCode + fixableCode ExitCode + info ObjInfo + add error + rules []interface{} + } +) + +var ( + t I = &fakeModule{} +) + +func (f *fakeModule) Fix() ExitCode { return f.fixCode } +func (f *fakeModule) Check() ExitCode { return f.checkCode } +func (f *fakeModule) Fixable() ExitCode { return f.fixableCode } +func (f *fakeModule) Info() ObjInfo { return f.info } +func (f *fakeModule) Add(s string) error { return f.add } +func (f *fakeModule) Rules() []interface{} { return f.rules } + +func Test_runAction(t *testing.T) { + mOri := m + defer func() { + m = mOri + }() + m = map[string]func() interface{}{"fake": func() interface{} { + return &fakeModule{ + checkCode: ExitNok, + fixCode: ExitOk, + fixableCode: ExitNotApplicable, + info: ObjInfo{ + DefaultPrefix: "foo", + ExampleValue: nil, + Description: "bar", + FormDefinition: "", + }, + add: nil, + rules: nil, + } + }, + } + + cases := map[string]struct { + args []string + exitCode ExitCode + expectedOut string + expectedErr string + }{ + "badModule": { + args: []string{"badModule", "fix"}, + exitCode: ExitOk, + expectedErr: "The compliance objects in this collection must be called via a symlink.\nCollection content:\n fake\n", + }, + "fix": { + args: []string{"fake", "fix"}, + exitCode: ExitOk, + }, + "check": { + args: []string{"fake", "check"}, + exitCode: ExitNok, + }, + "fixable": { + args: []string{"fake", "fixable"}, + exitCode: ExitNotApplicable, + }, + "info": { + args: []string{"fake", "info"}, + exitCode: ExitOk, + expectedOut: "Description\n===========\n\n bar\n\n\nExample rule\n============\n\n::\n\n null\n\n\nForm definition\n===============\n\n::\n\n\n\n\n", + }, + "badAction": { + args: []string{"fake", "badAction"}, + exitCode: ExitOk, + expectedErr: "invalid action: badAction\n", + }, + } + + for s, tc := range cases { + t.Run(s, func(t *testing.T) { + if s != "badModule" { + execDir := t.TempDir() + execName := filepath.Join(execDir, tc.args[0]) + require.NoError(t, os.Symlink(os.Args[0], filepath.Join(execDir, tc.args[0]))) + tc.args[0] = execName + } + var wOut, wErr io.ReadWriter + wOut = os.Stdout + wErr = os.Stderr + var bErr, bOut []byte + if tc.expectedOut != "" { + wOut = bytes.NewBuffer(bOut) + } + if tc.expectedErr != "" { + wErr = bytes.NewBuffer(bErr) + } + require.Equal(t, tc.exitCode, mainArgs(tc.args, wOut, wErr)) + + if tc.expectedOut != "" { + b := make([]byte, len(tc.expectedOut)+1000) + i, _ := wOut.Read(b) + require.Equal(t, tc.expectedOut, string(b[:i])) + } + if tc.expectedErr != "" { + b := make([]byte, len(tc.expectedErr)+1000) + i, _ := wErr.Read(b) + require.Equal(t, tc.expectedErr, string(b[:i])) + } + }) + } +} diff --git a/util/compobj/package.go b/util/compobj/package.go index cdee8ce40..87fd86bb3 100644 --- a/util/compobj/package.go +++ b/util/compobj/package.go @@ -18,21 +18,38 @@ type ( *Obj } CompPackage []string + + commandInterface interface { + Run() error + Stdout() []byte + } ) -var compPackagesInfo = ObjInfo{ - DefaultPrefix: "OSVC_COMP_PACKAGES_", - ExampleValue: CompPackage{ - "bzip2", - "-zip", - "zip", - }, - Description: `* Verify a list of packages is installed or removed +var ( + cmdRun = func(r commandInterface) error { + return r.Run() + } + + cmdStdout = func(r commandInterface) []byte { + return r.Stdout() + } + execLookPath = func(path string) (string, error) { + return exec.LookPath(path) + } + + compPackagesInfo = ObjInfo{ + DefaultPrefix: "OSVC_COMP_PACKAGES_", + ExampleValue: CompPackage{ + "bzip2", + "-zip", + "zip", + }, + Description: `* Verify a list of packages is installed or removed * A '-' prefix before the package name means the package should be removed * No prefix before the package name means the package should be installed * The package version is not checked `, - FormDefinition: `Desc: | + FormDefinition: `Desc: | A rule defining a set of packages, fed to the 'packages' compliance object for it to check each package installed or not-installed status. Css: comp48 @@ -53,13 +70,15 @@ Inputs: Help: Use '-' as a prefix to set 'not installed' as the target state. Use '*' as a wildcard for package name expansion for operating systems able to list packages available for installation. Type: string `, -} + } +) var ( packages = map[string]interface{}{} hasItMap = map[string]bool{} osVendor = os.Getenv("OSVC_COMP_NODES_OS_VENDOR") osName = os.Getenv("OSVC_COMP_NODES_OS_NAME") + osArch = os.Getenv("OSVC_COMP_NODES_OS_ARCH") ) func init() { @@ -77,7 +96,7 @@ func hasDpkg() bool { default: return false } - p, err := exec.LookPath("dpkg") + p, err := execLookPath("dpkg") return p != "" && err == nil }) } @@ -98,6 +117,22 @@ func hasYum() bool { }) } +func hasDnf() bool { + return hasIt("dnf", func() bool { + if osName != "Linux" { + return false + } + switch osVendor { + case "Red Hat", "RedHat", "CentOS", "Oracle": + // pass + default: + return false + } + p, err := exec.LookPath("dnf") + return p != "" && err == nil + }) +} + func hasRpm() bool { return hasIt("rpm", func() bool { if osName != "Linux" { @@ -109,7 +144,7 @@ func hasRpm() bool { default: return false } - p, err := exec.LookPath("rpm") + p, err := execLookPath("rpm") return p != "" && err == nil }) } @@ -135,7 +170,7 @@ func hasPkgadd() bool { if osName != "SunOS" { return false } - p, err := exec.LookPath("pkgadd") + p, err := execLookPath("pkgadd") return p != "" && err == nil }) } @@ -161,7 +196,7 @@ func hasFreebsdPkg() bool { if osName != "FreeBSD" { return false } - p, err := exec.LookPath("pkg") + p, err := execLookPath("pkg") return p != "" && err == nil }) } @@ -354,12 +389,115 @@ func yumExpand(names []string) ([]string, error) { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) + if err != nil { + return names, err + } + expanded := map[string]interface{}{} + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) + for scanner.Scan() { + line := string(scanner.Text()) + l := strings.Fields(line) + if len(l) != 3 { + continue + } + name := l[0] + expanded[name] = nil + } + + for _, pkg := range names { + expanded = filterPkgMap(expanded, pkg) + } + + return xmap.Keys(expanded), nil +} + +func filterPkgMap(m map[string]interface{}, pkgName string) map[string]interface{} { + numberOfOccurence := 0 + + for key := range m { + if strings.Split(key, ".")[0] == pkgName { + numberOfOccurence++ + } + } + + if numberOfOccurence < 2 { + return m + } + + if osArch == "i386" || osArch == "i586" || osArch == "i686" || osArch == "ia32" { + numberOf32BitsArchOccurence := 0 + last32bitsKey := "" + for key := range m { + switch { + case key == pkgName+".i386" || key == pkgName+".i586" || key == pkgName+".i686" || key == pkgName+".ia32": + numberOf32BitsArchOccurence++ + last32bitsKey = key + delete(m, key) + case strings.Split(key, ".")[0] == pkgName && key != pkgName+".noarch": + delete(m, key) + } + } + + if numberOf32BitsArchOccurence == 1 { + m[last32bitsKey] = nil + } + return m + } + + for key := range m { + if strings.Split(key, ".")[0] == pkgName && key != pkgName+".noarch" && key != pkgName+"."+osArch { + delete(m, key) + } + } + return m +} + +func dnfAdd(names []string) error { + names, err := dnfExpand(names) + if err != nil { + return err + } + args := []string{"-y", "install"} + args = append(args, names...) + cmd := command.New( + command.WithName("dnf"), + command.WithArgs(args), + command.WithOnStdoutLine(fo), + command.WithOnStderrLine(fe), + ) + fmt.Println(cmd) + return cmd.Run() +} + +func dnfDel(names []string) error { + args := []string{"-y", "remove"} + args = append(args, names...) + cmd := command.New( + command.WithName("dnf"), + command.WithArgs(args), + command.WithOnStdoutLine(fo), + command.WithOnStderrLine(fe), + ) + fmt.Println(cmd) + return cmd.Run() +} + +func dnfExpand(names []string) ([]string, error) { + args := []string{"list"} + args = append(args, names...) + cmd := command.New( + command.WithName("dnf"), + command.WithArgs(args), + command.WithBufferedStdout(), + command.WithOnStderrLine(fe), + ) + err := cmdRun(cmd) if err != nil { return names, err } expanded := map[string]interface{}{} - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { line := string(scanner.Text()) l := strings.Fields(line) @@ -369,6 +507,11 @@ func yumExpand(names []string) ([]string, error) { name := l[0] expanded[name] = nil } + + for _, pkg := range names { + expanded = filterPkgMap(expanded, pkg) + } + return xmap.Keys(expanded), nil } @@ -381,14 +524,14 @@ func aptExpand(names []string) ([]string, error) { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) if err != nil { return names, err } expanded := map[string]interface{}{} - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { - line := string(scanner.Text()) + line := scanner.Text() l := strings.Split(line, "/") if len(l) < 2 { continue @@ -436,14 +579,16 @@ func rpmLoadInstalledPackages() error { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) if err != nil { return fmt.Errorf("can not fetch installed packages list: %w", err) } - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { name := string(scanner.Text()) packages[name] = nil + name = strings.Split(name, ".")[0] + packages[name] = nil } return nil } @@ -455,11 +600,11 @@ func pkginfoLoadInstalledPackages() error { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) if err != nil { return fmt.Errorf("can not fetch installed packages list: %w", err) } - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { line := string(scanner.Text()) v := strings.Split(line, ":") @@ -482,18 +627,17 @@ func dpkgLoadInstalledPackages() error { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) if err != nil { return fmt.Errorf("can not fetch installed packages list: %w", err) } - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { line := string(scanner.Text()) if !strings.HasPrefix(line, "ii") { continue } name := strings.Fields(line)[1] - name = strings.Split(name, ":")[0] packages[name] = nil } return nil @@ -506,11 +650,11 @@ func freebsdPkgLoadInstalledPackages() error { command.WithBufferedStdout(), command.WithOnStderrLine(fe), ) - err := cmd.Run() + err := cmdRun(cmd) if err != nil { return fmt.Errorf("can not fetch installed packages list: %w", err) } - scanner := bufio.NewScanner(bytes.NewReader(cmd.Stdout())) + scanner := bufio.NewScanner(bytes.NewReader(cmdStdout(cmd))) for scanner.Scan() { line := string(scanner.Text()) l := strings.Fields(line) @@ -525,11 +669,21 @@ func freebsdPkgLoadInstalledPackages() error { return nil } -func (t CompPackages) fixPkgAdd(names []string) ExitCode { +func (t *CompPackages) fixPkgAdd(names []string) ExitCode { var err error switch { case hasApt(): err = aptAdd(names) + case hasDnf(): + err = dnfAdd(names) + case hasYum(): + err = yumAdd(names) + case hasZypper(): + err = zypperAdd(names) + case hasFreebsdPkg(): + err = freebsdPkgAdd(names) + case hasApk(): + err = apkAdd(names) default: return ExitNotApplicable } @@ -540,11 +694,13 @@ func (t CompPackages) fixPkgAdd(names []string) ExitCode { return ExitOk } -func (t CompPackages) fixPkgDel(names []string) ExitCode { +func (t *CompPackages) fixPkgDel(names []string) ExitCode { var err error switch { case hasApt(): err = aptDel(names) + case hasDnf(): + err = dnfDel(names) case hasYum(): err = yumDel(names) case hasZypper(): @@ -563,7 +719,7 @@ func (t CompPackages) fixPkgDel(names []string) ExitCode { return ExitOk } -func (t CompPackages) checkPkgAdd(name string) ExitCode { +func (t *CompPackages) checkPkgAdd(name string) ExitCode { if _, ok := packages[name]; !ok { t.VerboseErrorf("package %s is not installed, but should be\n", name) return ExitNok @@ -572,7 +728,7 @@ func (t CompPackages) checkPkgAdd(name string) ExitCode { return ExitOk } -func (t CompPackages) checkPkgDel(name string) ExitCode { +func (t *CompPackages) checkPkgDel(name string) ExitCode { if _, ok := packages[name]; ok { t.Errorf("package %s is installed, but should not be\n", name) return ExitNok @@ -581,7 +737,7 @@ func (t CompPackages) checkPkgDel(name string) ExitCode { return ExitOk } -func (t CompPackages) CheckRule(rule CompPackage) ExitCode { +func (t *CompPackages) CheckRule(rule CompPackage) ExitCode { var e, o ExitCode for _, s := range rule { s = strings.TrimPrefix(s, "+") @@ -596,7 +752,7 @@ func (t CompPackages) CheckRule(rule CompPackage) ExitCode { return e } -func (t CompPackages) Check() ExitCode { +func (t *CompPackages) Check() ExitCode { t.SetVerbose(true) if err := loadInstalledPackages(); err != nil { t.VerboseErrorf("%s\n", err) @@ -611,7 +767,7 @@ func (t CompPackages) Check() ExitCode { return e } -func (t CompPackages) parseRules() ([]string, []string) { +func (t *CompPackages) parseRules() ([]string, []string) { adds := []string{} dels := []string{} for _, i := range t.Rules() { @@ -623,7 +779,7 @@ func (t CompPackages) parseRules() ([]string, []string) { return adds, dels } -func (t CompPackages) parseRule(rule CompPackage) ([]string, []string) { +func (t *CompPackages) parseRule(rule CompPackage) ([]string, []string) { adds := []string{} dels := []string{} for _, s := range rule { @@ -642,7 +798,7 @@ func (t CompPackages) parseRule(rule CompPackage) ([]string, []string) { return adds, dels } -func (t CompPackages) Fix() ExitCode { +func (t *CompPackages) Fix() ExitCode { e := ExitNotApplicable t.SetVerbose(false) if err := loadInstalledPackages(); err != nil { @@ -661,10 +817,10 @@ func (t CompPackages) Fix() ExitCode { return e } -func (t CompPackages) Fixable() ExitCode { +func (t *CompPackages) Fixable() ExitCode { return ExitNotApplicable } -func (t CompPackages) Info() ObjInfo { +func (t *CompPackages) Info() ObjInfo { return compPackagesInfo } diff --git a/util/compobj/package_test.go b/util/compobj/package_test.go new file mode 100644 index 000000000..ef10fb02f --- /dev/null +++ b/util/compobj/package_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "github.com/stretchr/testify/require" + "os" + "testing" +) + +func TestPackage_loadInstalledPackages(t *testing.T) { + type ( + simulateEnvFunc func(CmdOutputFilePath string) + ) + + dpkgEnv := func(cmdOutputFilePath string) { + + osVendor = "Ubuntu" + osName = "Linux" + + cmdRun = func(r commandInterface) error { + return nil + } + + cmdStdout = func(r commandInterface) []byte { + b, _ := os.ReadFile(cmdOutputFilePath) + return b + } + + execLookPath = func(path string) (string, error) { + if path == "dpkg" { + return "path", nil + } + return "", nil + } + } + + rpmEnv := func(cmdOutputFilePath string) { + + osVendor = "RedHat" + osName = "Linux" + cmdRun = func(r commandInterface) error { + return nil + } + + cmdStdout = func(r commandInterface) []byte { + b, _ := os.ReadFile(cmdOutputFilePath) + return b + } + + execLookPath = func(path string) (string, error) { + if path == "rpm" { + return "path", nil + } + return "", nil + } + + } + + pkgaddEnv := func(cmdOutputFilePath string) { + + osVendor = "Solaris" + osName = "SunOS" + cmdRun = func(r commandInterface) error { + return nil + } + + cmdStdout = func(r commandInterface) []byte { + b, _ := os.ReadFile(cmdOutputFilePath) + return b + } + + execLookPath = func(path string) (string, error) { + if path == "pkgadd" { + return "path", nil + } + return "", nil + } + } + + freebsdpkgEnv := func(cmdOutputFilePath string) { + + osVendor = "FreeBsd" + osName = "FreeBSD" + cmdRun = func(r commandInterface) error { + return nil + } + + cmdStdout = func(r commandInterface) []byte { + b, _ := os.ReadFile(cmdOutputFilePath) + return b + } + + execLookPath = func(path string) (string, error) { + if path == "pkg" { + return "path", nil + } + return "", nil + } + } + + testCases := map[string]struct { + environment simulateEnvFunc + cmdOutputDataPath string + expectedPackagesMapOutput map[string]interface{} + }{ + "read packages with dpkg": { + environment: dpkgEnv, + cmdOutputDataPath: "./testdata/cmdUbuntuInstalledPackages.out", + expectedPackagesMapOutput: map[string]interface{}{"accountsservice": nil, "acl": nil, "acpi-support": nil, "bind9-dnsutils": nil}, + }, + + "read packages with rpm": { + environment: rpmEnv, + cmdOutputDataPath: "./testdata/cmdRedHatInstalledPackages.out", + expectedPackagesMapOutput: map[string]interface{}{"make": nil, "make.x86_64": nil, "yum-metadata-parser": nil, "yum-metadata-parser.x86_64": nil, "nss-tools": nil, "nss-tools.x86_64": nil, "tar": nil, "tar.x86_64": nil}, + }, + + "read packages with pkgadd (solaris) ": { + environment: pkgaddEnv, + cmdOutputDataPath: "./testdata/cmdSolarisInstalledPackages.out", + expectedPackagesMapOutput: map[string]interface{}{"SUNWsmhba": nil, "SUNWsmhbar": nil, "SUNWsmpd": nil}, + }, + + "read packages with pkg (freeBSD) ": { + environment: freebsdpkgEnv, + cmdOutputDataPath: "./testdata/cmdFreeBSDInstalledPackages.out", + expectedPackagesMapOutput: map[string]interface{}{"ca_root_nss": nil, "curl": nil, "pkg": nil}, + }, + } + + for name, c := range testCases { + t.Run(name, func(t *testing.T) { + defer func() { + hasItMap = map[string]bool{} + packages = map[string]interface{}{} + }() + + origCmdRun := cmdRun + origCmdStdout := cmdStdout + origExecLookPath := execLookPath + c.environment(c.cmdOutputDataPath) + defer func() { + cmdStdout = origCmdStdout + cmdRun = origCmdRun + execLookPath = origExecLookPath + }() + + require.NoError(t, loadInstalledPackages()) + require.Equal(t, c.expectedPackagesMapOutput, packages) + }) + } +} + +func TestPackage_checkRule(t *testing.T) { + testCases := map[string]struct { + rule CompPackage + packagesInstalled map[string]interface{} + expectedResult ExitCode + }{ + "all the packages fit to the rule": { + rule: CompPackage{"acpi", "tar", "zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil, "tar": nil, "zip": nil}, + expectedResult: ExitOk, + }, + "more packages than necessary": { + rule: CompPackage{"acpi", "tar", "zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil, "tar": nil, "zip": nil, "foo": nil, "foo2": nil}, + expectedResult: ExitOk, + }, + "missing some packages": { + rule: CompPackage{"acpi", "tar", "zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil}, + expectedResult: ExitNok, + }, + "missing all packages": { + rule: CompPackage{"acpi", "tar", "zip"}, + packagesInstalled: map[string]interface{}{"foo": nil, "bar": nil}, + expectedResult: ExitNok, + }, + "with 1 excluded packages present": { + rule: CompPackage{"-acpi", "tar", "-zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil, "tar": nil}, + expectedResult: ExitNok, + }, + "with 2 excluded packages present": { + rule: CompPackage{"-acpi", "tar", "-zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil, "tar": nil, "zip": nil}, + expectedResult: ExitNok, + }, + "with all excluded packages present": { + rule: CompPackage{"-acpi", "-tar", "-zip"}, + packagesInstalled: map[string]interface{}{"acpi": nil, "tar": nil, "zip": nil}, + expectedResult: ExitNok, + }, + "excluded packages are not installed": { + rule: CompPackage{"-acpi", "tar", "-zip"}, + packagesInstalled: map[string]interface{}{"tar": nil, "foobar": nil}, + expectedResult: ExitOk, + }, + "empty rule": { + rule: CompPackage{}, + packagesInstalled: map[string]interface{}{"tar": nil, "foobar": nil}, + expectedResult: ExitOk, + }, + } + + obj := CompPackages{Obj: &Obj{rules: make([]interface{}, 0), verbose: true}} + for name, c := range testCases { + t.Run(name, func(t *testing.T) { + packagesOri := packages + defer func() { packages = packagesOri }() + + packages = c.packagesInstalled + require.Equal(t, c.expectedResult, obj.CheckRule(c.rule)) + }) + } +} + +func TestPackage_expand(t *testing.T) { + + var expandFunc func(names []string) ([]string, error) + + cmdRunOri := cmdRun + defer func() { cmdRun = cmdRunOri }() + + cmdStdoutOri := cmdStdout + defer func() { cmdStdout = cmdStdoutOri }() + + osArchOri := osArch + defer func() { osArch = osArchOri }() + + createEnv := func(cmdOutputFilePath string, arch string, pkgMgr string) { + + osArch = arch + + cmdRun = func(r commandInterface) error { + return nil + } + + cmdStdout = func(r commandInterface) []byte { + b, _ := os.ReadFile(cmdOutputFilePath) + return b + } + + expandFunc = func(names []string) ([]string, error) { + switch pkgMgr { + case "apt": + return aptExpand(names) + case "yum": + return yumExpand(names) + case "dnf": + return dnfExpand(names) + default: + panic("can't create env for unexpected package manager " + pkgMgr) + } + } + } + + sliceToMap := func(s []string) map[string]interface{} { + m := map[string]interface{}{} + for _, elem := range s { + m[elem] = nil + } + return m + } + + testCases := map[string]struct { + pkgSys string + arch string + cmdOutputDataPath string + pkgNames []string + expectedPkgList []string + }{ + "testing the parser aptExpand": { + pkgSys: "apt", + arch: "", + cmdOutputDataPath: "./testdata/cmdUbuntuListPackages", + pkgNames: []string{"xsol", "zip", "zzuf"}, + expectedPkgList: []string{"xsol", "zip", "zzuf"}, + }, + + "testing the parser yumExpand with no specified arch in pkgNames but no choice for bash (64 bits arch) ": { + pkgSys: "yum", + arch: "amd64", + cmdOutputDataPath: "./testdata/cmdRedHatListPackages", + pkgNames: []string{"bash", "systemd-container", "bash-completion", "iproute"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.noarch", "iproute.amd64", "bash-completion.noarch", "bash-completion.amd64"}, + }, + + "testing the parser yumExpand with a 32 bits arch and multiple systemd-container 32bits arch": { + pkgSys: "yum", + arch: "i586", + cmdOutputDataPath: "./testdata/cmdRedHatListPackages", + pkgNames: []string{"bash", "systemd-container", "bash-completion", "iproute"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.noarch", "bash-completion.noarch"}, + }, + + "testing the parser yumExpand with only specified arch in pkgNames, systemd-container is not specify but only 32bits arch is available": { + pkgSys: "yum", + arch: "i586", + cmdOutputDataPath: "./testdata/cmdRedHatListPackagesSpecified.out", + pkgNames: []string{"bash.x86_64", "systemd-container", "bash-completion.amd64", "iproute.amd64"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.i686", "bash-completion.amd64", "iproute.amd64"}, + }, + + "testing the parser dnfExpand with no specified arch in pkgNames but no choice for bash (64 bits arch) ": { + pkgSys: "dnf", + arch: "amd64", + cmdOutputDataPath: "./testdata/cmdRedHatListPackages", + pkgNames: []string{"bash", "systemd-container", "bash-completion", "iproute"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.noarch", "iproute.amd64", "bash-completion.noarch", "bash-completion.amd64"}, + }, + + "testing the parser dnfExpand with a 32 bits arch and multiple systemd-container 32bits arch": { + pkgSys: "dnf", + arch: "i586", + cmdOutputDataPath: "./testdata/cmdRedHatListPackages", + pkgNames: []string{"bash", "systemd-container", "bash-completion", "iproute"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.noarch", "bash-completion.noarch"}, + }, + + "testing the parser dnfExpand with only specified arch in pkgNames, systemd-container is not specify but only 32bits arch is available": { + pkgSys: "dnf", + arch: "i586", + cmdOutputDataPath: "./testdata/cmdRedHatListPackagesSpecified.out", + pkgNames: []string{"bash.x86_64", "systemd-container", "bash-completion.amd64", "iproute.amd64"}, + expectedPkgList: []string{"bash.x86_64", "systemd-container.i686", "bash-completion.amd64", "iproute.amd64"}, + }, + } + + for name, c := range testCases { + t.Run(name, func(t *testing.T) { + createEnv(c.cmdOutputDataPath, c.arch, c.pkgSys) + list, err := expandFunc(c.pkgNames) + require.NoError(t, err) + require.Equal(t, sliceToMap(c.expectedPkgList), sliceToMap(list)) + }) + } +} diff --git a/util/compobj/symlink_test.go b/util/compobj/symlink_test.go new file mode 100644 index 000000000..eb531501a --- /dev/null +++ b/util/compobj/symlink_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "testing" +) + +func TestSymlink(t *testing.T) { + makeObj := func(testPath string) I { + obj := NewCompSymlinks().(I) + rulesList := []string{ + fmt.Sprintf(`{"Symlink":"%s","Target":"target1"}`, filepath.Join(testPath, "testlink1")), + fmt.Sprintf(`{"Symlink":"%s","Target":"target1"}`, filepath.Join(testPath, "testlink2")), + } + for _, rule := range rulesList { + if err := obj.Add(rule); err != nil { + require.NoError(t, err) + } + } + return obj + } + + testPath := t.TempDir() + obj := makeObj(testPath) + assert.Equal(t, obj.Check(), ExitNok) + + err := os.Symlink("target1", filepath.Join(testPath, "testlink1")) + if err != nil { + require.NoError(t, err) + } + err = os.Symlink("WrongTarget", filepath.Join(testPath, "testlink2")) + if err != nil { + require.NoError(t, err) + } + assert.Equal(t, obj.Check(), ExitNok) + obj.Fix() + assert.Equal(t, obj.Check(), ExitNok) + + testPath = t.TempDir() + obj = makeObj(testPath) + err = os.Symlink("target1", filepath.Join(testPath, "testlink1")) + if err != nil { + require.NoError(t, err) + } + obj.Fix() + assert.Equal(t, obj.Check(), ExitOk) + +} diff --git a/util/compobj/testdata/cmdFreeBSDInstalledPackages.out b/util/compobj/testdata/cmdFreeBSDInstalledPackages.out new file mode 100644 index 000000000..979948157 --- /dev/null +++ b/util/compobj/testdata/cmdFreeBSDInstalledPackages.out @@ -0,0 +1,3 @@ +ca_root_nss-3.89.1 Root certificate bundle from the Mozilla Project +curl-8.1.2 Command line tool and library +pkg-1.19.2 Package manager \ No newline at end of file diff --git a/util/compobj/testdata/cmdRedHatInstalledPackages.out b/util/compobj/testdata/cmdRedHatInstalledPackages.out new file mode 100644 index 000000000..22633c2a3 --- /dev/null +++ b/util/compobj/testdata/cmdRedHatInstalledPackages.out @@ -0,0 +1,4 @@ +make.x86_64 +yum-metadata-parser.x86_64 +nss-tools.x86_64 +tar.x86_64 \ No newline at end of file diff --git a/util/compobj/testdata/cmdRedHatListPackages b/util/compobj/testdata/cmdRedHatListPackages new file mode 100644 index 000000000..fbe82d457 --- /dev/null +++ b/util/compobj/testdata/cmdRedHatListPackages @@ -0,0 +1,9 @@ +bash.x86_64 5.1.8-6.el9_1 @rhel-9-for-x86_64-baseos-rpms +bash-completion.noarch 1:2.11-4.el9 @anaconda +bash-completion.amd64 1:2.11-4.el9 @anaconda +systemd-container.i686 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +systemd-container.i586 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +systemd-container.noarch 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +systemd-container.x86_64 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +iproute.amd64 6.1.0-1.el9 @rhel-9-for-x86_64-baseos-rpms +iproute.x86_64 6.1.0-1.el9 @rhel-9-for-x86_64-baseos-rpms \ No newline at end of file diff --git a/util/compobj/testdata/cmdRedHatListPackagesSpecified.out b/util/compobj/testdata/cmdRedHatListPackagesSpecified.out new file mode 100644 index 000000000..c74a83b47 --- /dev/null +++ b/util/compobj/testdata/cmdRedHatListPackagesSpecified.out @@ -0,0 +1,5 @@ +bash.x86_64 5.1.8-6.el9_1 @rhel-9-for-x86_64-baseos-rpms +bash-completion.amd64 1:2.11-4.el9 @anaconda +systemd-container.i686 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +systemd-container.amd64 252-14.el9_2.3 rhel-9-for-x86_64-baseos-rpms +iproute.amd64 6.1.0-1.el9 @rhel-9-for-x86_64-baseos-rpms \ No newline at end of file diff --git a/util/compobj/testdata/cmdSolarisInstalledPackages.out b/util/compobj/testdata/cmdSolarisInstalledPackages.out new file mode 100644 index 000000000..7b563a6c1 --- /dev/null +++ b/util/compobj/testdata/cmdSolarisInstalledPackages.out @@ -0,0 +1,39 @@ + INSTDATE: Aug 16 2018 19:09 + HOTLINE: Please contact your local service provider + STATUS: completely installed + + PKGINST: SUNWsmhba + NAME: SM-HBA Libraries and CLI + CATEGORY: system + ARCH: i386 + VERSION: 11.11,REV=2009.11.11 + BASEDIR: / + VENDOR: Oracle Corporation + DESC: T11 Storage Management HBA API Libraries and CLI + INSTDATE: Aug 16 2018 19:09 + HOTLINE: Please contact your local service provider + STATUS: completely installed + + PKGINST: SUNWsmhbar + NAME: SM-HBA Libraries and CLI (root) + CATEGORY: system + ARCH: i386 + VERSION: 11.11,REV=2009.11.11 + BASEDIR: / + VENDOR: Oracle Corporation + DESC: T11 Storage Management HBA API Libraries and CLI (root) + INSTDATE: Aug 16 2018 19:09 + HOTLINE: Please contact your local service provider + STATUS: completely installed + + PKGINST: SUNWsmpd + NAME: Target Driver for Serial SCSI Management Protocol (SMP) Compliant Devices + CATEGORY: system + ARCH: i386 + VERSION: 11.11,REV=2009.11.11 + BASEDIR: / + VENDOR: Oracle Corporation + DESC: Target Driver for Serial SCSI Management Protocol (SMP) Compliant Devices + INSTDATE: Aug 16 2018 19:09 + HOTLINE: Please contact your local service provider + STATUS: completely installed \ No newline at end of file diff --git a/util/compobj/testdata/cmdUbuntuInstalledPackages.out b/util/compobj/testdata/cmdUbuntuInstalledPackages.out new file mode 100644 index 000000000..25b2248aa --- /dev/null +++ b/util/compobj/testdata/cmdUbuntuInstalledPackages.out @@ -0,0 +1,9 @@ +Desired=Unknown/Install/Remove/Purge/Hold +| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend +|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad) +||/ Name Version Architecture Description ++++-==========================================-=======================================-============-================================================================================ +ii accountsservice 22.08.8-1ubuntu7.1 amd64 query and manipulate user account information +ii acl 2.3.1-3 amd64 access control list - utilities +ii acpi-support 0.144 amd64 scripts for handling many ACPI events +ii bind9-dnsutils 1:9.18.12-1ubuntu1.1 amd64 Clients provided with BIND 9 diff --git a/util/compobj/testdata/cmdUbuntuListPackages b/util/compobj/testdata/cmdUbuntuListPackages new file mode 100644 index 000000000..4d3ef5dc9 --- /dev/null +++ b/util/compobj/testdata/cmdUbuntuListPackages @@ -0,0 +1,5 @@ +Listing... Done +xsol/lunar 0.31-15 amd64 +zip/lunar,now 3.0-13 amd64 [installed,automatic] +zip/lunar 3.0-13 i386 +zzuf/lunar 0.15-2build1 amd64 \ No newline at end of file