diff --git a/config/config_test.go b/config/config_test.go index 68a6a1c13..28141d231 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1025,6 +1025,48 @@ func TestParse(t *testing.T) { }, false, }, + { + "template_uid", + `template { + uid = 1000 + }`, + &Config{ + Templates: &TemplateConfigs{ + &TemplateConfig{ + Uid: Int(1000), + }, + }, + }, + false, + }, + { + "template_gid", + `template { + gid = 1000 + }`, + &Config{ + Templates: &TemplateConfigs{ + &TemplateConfig{ + Gid: Int(1000), + }, + }, + }, + false, + }, + { + "template_uid_gid_default", + `template { + }`, + &Config{ + Templates: &TemplateConfigs{ + &TemplateConfig{ + Uid: nil, + Gid: nil, + }, + }, + }, + false, + }, { "template_source", `template { diff --git a/config/template.go b/config/template.go index a42e4d87f..3dd34776d 100644 --- a/config/template.go +++ b/config/template.go @@ -64,6 +64,20 @@ type TemplateConfig struct { // secrets from Vault. Perms *os.FileMode `mapstructure:"perms"` + // Uid is the numeric uid that will be set when creating the file on disk. + // Useful when simply setting Perms is not enough. + // + // Platform dependent: this doesn't work on Windows but it fails gracefully + // with a warning + Uid *int `mapstructure:"uid"` + + // Gid is the numeric gid that will be set when creating the file on disk. + // Useful when simply setting Perms is not enough. + // + // Platform dependent: this doesn't work on Windows but it fails gracefully + // with a warning + Gid *int `mapstructure:"gid"` + // Source is the path on disk to the template contents to evaluate. Either // this or Contents should be specified, but not both. Source *string `mapstructure:"source"` diff --git a/manager/runner.go b/manager/runner.go index eb2db8abf..952a398f6 100644 --- a/manager/runner.go +++ b/manager/runner.go @@ -780,6 +780,8 @@ func (r *Runner) runTemplate(tmpl *template.Template, runCtx *templateRunCtx) (* DryStream: r.outStream, Path: config.StringVal(templateConfig.Destination), Perms: config.FileModeVal(templateConfig.Perms), + Uid: templateConfig.Uid, + Gid: templateConfig.Gid, }) if err != nil { return nil, errors.Wrap(err, "error rendering "+templateConfig.Display()) diff --git a/renderer/file_ownership.go b/renderer/file_ownership.go new file mode 100644 index 000000000..f4879573c --- /dev/null +++ b/renderer/file_ownership.go @@ -0,0 +1,49 @@ +//go:build !windows +// +build !windows + +package renderer + +import ( + "os" + "syscall" +) + +func getFileOwnership(path string) (*int, *int) { + file_info, err := os.Stat(path) + + if err != nil { + return nil, nil + } + + file_sys := file_info.Sys() + st := file_sys.(*syscall.Stat_t) + return intPtr(int(st.Uid)), intPtr(int(st.Gid)) +} + +func setFileOwnership(path string, uid, gid *int) error { + wantedUid := sanitizeUidGid(uid) + wantedGid := sanitizeUidGid(gid) + if wantedUid == -1 && wantedGid == -1 { + return nil //noop + } + return os.Chown(path, wantedUid, wantedGid) +} + +func isChownNeeded(path string, uid, gid *int) bool { + wantedUid := sanitizeUidGid(uid) + wantedGid := sanitizeUidGid(gid) + if wantedUid == -1 && wantedGid == -1 { + return false + } + + currUid, currGid := getFileOwnership(path) + return wantedUid != *currUid || wantedGid != *currGid +} + +// sanitizeUidGid sanitizes the uid/gid so that can be an input for os.Chown +func sanitizeUidGid(id *int) int { + if id == nil { + return -1 + } + return *id +} diff --git a/renderer/file_ownership_windows.go b/renderer/file_ownership_windows.go new file mode 100644 index 000000000..dd9f14690 --- /dev/null +++ b/renderer/file_ownership_windows.go @@ -0,0 +1,20 @@ +//go:build windows +// +build windows + +package renderer + +import ( + "log" + "os" +) + +func setFileOwnership(path string, uid, gid *int) error { + if uid != nil || gid != nil { + log.Printf("[WARN] (runner) cannot set uid/gid for rendered files on Windows") + } + return nil +} + +func isChownNeeded(path string, wantedUid, wantedGid *int) bool { + return false +} diff --git a/renderer/renderer.go b/renderer/renderer.go index 90e178321..6190381a1 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -36,6 +36,7 @@ type RenderInput struct { DryStream io.Writer Path string Perms os.FileMode + Uid, Gid *int } // RenderResult is returned and stored. It contains the status of the render @@ -65,7 +66,13 @@ func Render(i *RenderInput) (*RenderResult, error) { return nil, errors.Wrap(err, "failed reading file") } - if bytes.Equal(existing, i.Contents) && fileExists { + var chownNeeded bool + + if fileExists { + chownNeeded = isChownNeeded(i.Path, i.Uid, i.Gid) + } + + if bytes.Equal(existing, i.Contents) && fileExists && !chownNeeded { return &RenderResult{ DidRender: false, WouldRender: true, @@ -79,6 +86,10 @@ func Render(i *RenderInput) (*RenderResult, error) { if err := AtomicWrite(i.Path, i.CreateDestDirs, i.Contents, i.Perms, i.Backup); err != nil { return nil, errors.Wrap(err, "failed writing file") } + + if err = setFileOwnership(i.Path, i.Uid, i.Gid); err != nil { + return nil, errors.Wrap(err, "failed setting file ownership") + } } return &RenderResult{ @@ -186,3 +197,8 @@ func AtomicWrite(path string, createDestDirs bool, contents []byte, perms os.Fil return nil } + +// intPtr returns a pointer to the given int. +func intPtr(i int) *int { + return &i +} diff --git a/renderer/renderer_test.go b/renderer/renderer_test.go index 5d2052564..8e7984ff8 100644 --- a/renderer/renderer_test.go +++ b/renderer/renderer_test.go @@ -315,3 +315,214 @@ func TestRender(t *testing.T) { } }) } + +func TestRender_Chown(t *testing.T) { + + // Can't change uid unless root, but can try + // changing the group id. + // setting Uid to -1 means no change + wantedUid := -1 + + //we enumerate the groups the current user (running the tests) belongs to + groups, err := os.Getgroups() + if err != nil { + t.Fatalf("getgroups: %s", err) + } + t.Log("groups: ", groups) + + // we'll use the last group because we can assume it's not the default one + // for the current user (thinking about CI/CD). + // In order to make sure that this is tested properly we would have to + // preconfigure the environment and specify the gid here or (better) add + // the user to a group and leave this dynamic avoiding hardcoded values, + // worst case scenario, if the user belongs to a single group, these tests + // would not be testing the cange of ownership but only the fact that it doesn't + // fail unexpectedly + wantedGid := groups[0] + + t.Run("sets-file-ownership-when-file-exists-same-content", func(t *testing.T) { + + outDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outDir) + outFile, err := ioutil.TempFile(outDir, "") + if err != nil { + t.Fatal(err) + } + contents := []byte("first") + if _, err := outFile.Write(contents); err != nil { + t.Fatal(err) + } + path := outFile.Name() + if err = outFile.Close(); err != nil { + t.Fatal(err) + } + + rr, err := Render(&RenderInput{ + Path: path, + Contents: contents, + Uid: intPtr(wantedUid), + Gid: intPtr(wantedGid), + }) + if err != nil { + t.Fatal(err) + } + switch { + case rr.WouldRender && rr.DidRender: //we expect rerendering to disk here + default: + t.Fatalf("Bad render results; would: %v, did: %v", + rr.WouldRender, rr.DidRender) + } + + gotUid, gotGid := getFileOwnership(path) + if *gotGid != wantedGid { + t.Fatalf("Bad render results; gotUid: %v, wantedGid: %v, gotGid: %v", + *gotUid, wantedGid, *gotGid) + } + + }) + + t.Run("sets-file-ownership-when-file-exists-diff-content", func(t *testing.T) { + + outDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outDir) + outFile, err := ioutil.TempFile(outDir, "") + if err != nil { + t.Fatal(err) + } + contents := []byte("first") + if _, err := outFile.Write(contents); err != nil { + t.Fatal(err) + } + path := outFile.Name() + if err = outFile.Close(); err != nil { + t.Fatal(err) + } + + diff_contents := []byte("not-first") + rr, err := Render(&RenderInput{ + Path: path, + Contents: diff_contents, + Uid: intPtr(wantedUid), + Gid: intPtr(wantedGid), + }) + if err != nil { + t.Fatal(err) + } + switch { + case rr.WouldRender && rr.DidRender: + default: + t.Fatalf("Bad render results; would: %v, did: %v", + rr.WouldRender, rr.DidRender) + } + + gotUid, gotGid := getFileOwnership(path) + if *gotGid != wantedGid { + t.Fatalf("Bad render results; gotUid: %v, wantedGid: %v, gotGid: %v", + *gotUid, wantedGid, *gotGid) + } + + }) + t.Run("sets-file-ownership-when-file-no-exists", func(t *testing.T) { + + outDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outDir) + path := path.Join(outDir, "no-exists") + contents := []byte("first") + + rr, err := Render(&RenderInput{ + Path: path, + Contents: contents, + Uid: intPtr(wantedUid), + Gid: intPtr(wantedGid), + }) + if err != nil { + t.Fatal(err) + } + switch { + case rr.WouldRender && rr.DidRender: + default: + t.Fatalf("Bad render results; would: %v, did: %v", + rr.WouldRender, rr.DidRender) + } + + gotUid, gotGid := getFileOwnership(path) + if *gotGid != wantedGid { + t.Fatalf("Bad render results; gotUid: %v, wantedGid: %v, gotGid: %v", + *gotUid, wantedGid, *gotGid) + } + + }) + t.Run("sets-file-ownership-when-empty-file-no-exists", func(t *testing.T) { + + outDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outDir) + path := path.Join(outDir, "no-exists") + contents := []byte{} + + rr, err := Render(&RenderInput{ + Path: path, + Contents: contents, + Uid: intPtr(wantedUid), + Gid: intPtr(wantedGid), + }) + if err != nil { + t.Fatal(err) + } + switch { + case rr.WouldRender && rr.DidRender: + default: + t.Fatalf("Bad render results; would: %v, did: %v", + rr.WouldRender, rr.DidRender) + } + + gotUid, gotGid := getFileOwnership(path) + if *gotGid != wantedGid { + t.Fatalf("Bad render results; gotUid: %v, wantedGid: %v, gotGid: %v", + *gotUid, wantedGid, *gotGid) + } + }) + + t.Run("should-be-noop-when-missing-gid", func(t *testing.T) { + + outDir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(outDir) + path := path.Join(outDir, "no-exists") + contents := []byte{} + + rr, err := Render(&RenderInput{ + Path: path, + Contents: contents, + Uid: intPtr(1000), + }) + if err != nil { + t.Fatal(err) + } + switch { + case rr.WouldRender && rr.DidRender: + default: + t.Fatalf("Bad render results; would: %v, did: %v", + rr.WouldRender, rr.DidRender) + } + + gotUid, gotGid := getFileOwnership(path) + if *gotGid == wantedGid { + t.Fatalf("Bad render results; we shouldn't have altered uid/gid. gotUid: %v, gotGid: %v", + *gotUid, *gotGid) + } + }) +}