Merge "veyron/tools/gclogs: A tool that safely deletes old log files."
diff --git a/tools/gclogs/doc.go b/tools/gclogs/doc.go
new file mode 100644
index 0000000..302fe98
--- /dev/null
+++ b/tools/gclogs/doc.go
@@ -0,0 +1,31 @@
+// This file was auto-generated via go generate.
+// DO NOT UPDATE MANUALLY
+
+/*
+gclogs is a utility that safely deletes old log files.
+
+It looks for file names that match the format of files produced by the vlog
+package, and deletes the ones that have not changed in the amount of time
+specified by the --cutoff flag.
+
+Only files produced by the same user as the one running the gclogs command are
+considered for deletion.
+
+Usage:
+ gclogs [flags] <dir> ...
+
+<dir> ... A list of directories where to look for log files.
+
+The gclogs flags are:
+ -cutoff=24h0m0s
+ The age cut-off for a log file to be considered for garbage collection.
+ -n=false
+ If true, log files that would be deleted are shown on stdout, but not
+ actually deleted.
+ -program=.*
+ A regular expression to apply to the program part of the log file name, e.g
+ ".*test".
+ -verbose=false
+ If true, each deleted file is shown on stdout.
+*/
+package main
diff --git a/tools/gclogs/format.go b/tools/gclogs/format.go
new file mode 100644
index 0000000..502db25
--- /dev/null
+++ b/tools/gclogs/format.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "syscall"
+ "time"
+)
+
+var (
+ // The format of the log file names is:
+ // program.host.user.log.logger.tag.YYYYMMDD-HHmmss.pid
+ logFileRE = regexp.MustCompile(`^(.*)\.([^.]*)\.([^.]*)\.log\.([^.]*)\.([^.]*)\.(........-......)\.([0-9]*)$`)
+)
+
+type logFile struct {
+ // TODO(rthellend): Some of these fields are not used by anything yet,
+ // but they will be eventually when we need to sort the log files
+ // associated with a given instance.
+ symlink bool
+ program, host, user, logger, tag string
+ time time.Time
+ pid int
+}
+
+func parseFileInfo(dir string, fileInfo os.FileInfo) (*logFile, error) {
+ fileName := fileInfo.Name()
+ if fileInfo.Mode()&os.ModeSymlink != 0 {
+ buf := make([]byte, syscall.NAME_MAX)
+ n, err := syscall.Readlink(filepath.Join(dir, fileName), buf)
+ if err != nil {
+ return nil, err
+ }
+ linkName := string(buf[:n])
+ lf, err := parseFileName(linkName)
+ if err != nil {
+ return nil, err
+ }
+ lf.symlink = true
+ return lf, nil
+ }
+ return parseFileName(fileName)
+}
+
+func parseFileName(fileName string) (*logFile, error) {
+ if m := logFileRE.FindStringSubmatch(fileName); len(m) != 0 {
+ t, err := time.ParseInLocation("20060102-150405", m[6], time.Local)
+ if err != nil {
+ return nil, err
+ }
+ pid, err := strconv.ParseInt(m[7], 10, 32)
+ if err != nil {
+ return nil, err
+ }
+ return &logFile{
+ program: m[1],
+ host: m[2],
+ user: m[3],
+ logger: m[4],
+ tag: m[5],
+ time: t,
+ pid: int(pid),
+ }, nil
+ }
+ return nil, errors.New("not a recognized log file name")
+}
diff --git a/tools/gclogs/format_test.go b/tools/gclogs/format_test.go
new file mode 100644
index 0000000..5396894
--- /dev/null
+++ b/tools/gclogs/format_test.go
@@ -0,0 +1,97 @@
+package main
+
+import (
+ "io/ioutil"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestParseFileNameNoError(t *testing.T) {
+ testcases := []struct {
+ filename string
+ lf *logFile
+ }{
+ {
+ "program.host.user.log.veyron.INFO.20141204-131502.12345",
+ &logFile{false, "program", "host", "user", "veyron", "INFO", time.Date(2014, 12, 4, 13, 15, 2, 0, time.Local), 12345},
+ },
+ {
+ "prog.test.host-name.user.log.veyron.ERROR.20141204-131502.12345",
+ &logFile{false, "prog.test", "host-name", "user", "veyron", "ERROR", time.Date(2014, 12, 4, 13, 15, 2, 0, time.Local), 12345},
+ },
+ }
+ for _, tc := range testcases {
+ lf, err := parseFileName(tc.filename)
+ if err != nil {
+ t.Errorf("unexpected error for %q: %v", tc.filename, err)
+ }
+ if !reflect.DeepEqual(tc.lf, lf) {
+ t.Errorf("unexpected result: got %+v, expected %+v", lf, tc.lf)
+ }
+ }
+}
+
+func TestParseFileNameError(t *testing.T) {
+ testcases := []string{
+ "program.host.user.log.veyron.INFO.20141204-131502",
+ "prog.test.host-name.user.log.veyron.20141204-131502.12345",
+ "foo.txt",
+ }
+ for _, tc := range testcases {
+ if _, err := parseFileName(tc); err == nil {
+ t.Errorf("unexpected success for %q", tc)
+ }
+ }
+}
+
+func TestParseFileInfo(t *testing.T) {
+ tmpdir, err := ioutil.TempDir("", "parse-file-info-")
+ if err != nil {
+ t.Fatalf("ioutil.TempDir failed: %v", err)
+ }
+ defer os.RemoveAll(tmpdir)
+
+ name := "program.host.user.log.veyron.INFO.20141204-131502.12345"
+ if err := ioutil.WriteFile(filepath.Join(tmpdir, name), []byte{}, 0644); err != nil {
+ t.Fatalf("ioutil.WriteFile failed: %v", err)
+ }
+ link := "program.INFO"
+ if err := os.Symlink(name, filepath.Join(tmpdir, link)); err != nil {
+ t.Fatalf("os.Symlink failed: %v", err)
+ }
+
+ // Test regular file.
+ {
+ fi, err := os.Lstat(filepath.Join(tmpdir, name))
+ if err != nil {
+ t.Fatalf("os.Lstat failed: %v", err)
+ }
+ lf, err := parseFileInfo(tmpdir, fi)
+ if err != nil {
+ t.Errorf("parseFileInfo(%v, %v) failed: %v", tmpdir, fi, err)
+ }
+ expected := &logFile{false, "program", "host", "user", "veyron", "INFO", time.Date(2014, 12, 4, 13, 15, 2, 0, time.Local), 12345}
+ if !reflect.DeepEqual(lf, expected) {
+ t.Errorf("unexpected result: got %+v, expected %+v", lf, expected)
+ }
+ }
+
+ // Test symlink.
+ {
+ fi, err := os.Lstat(filepath.Join(tmpdir, link))
+ if err != nil {
+ t.Fatalf("os.Lstat failed: %v", err)
+ }
+ lf, err := parseFileInfo(tmpdir, fi)
+ if err != nil {
+ t.Errorf("parseFileInfo(%v, %v) failed: %v", tmpdir, fi, err)
+ }
+ expected := &logFile{true, "program", "host", "user", "veyron", "INFO", time.Date(2014, 12, 4, 13, 15, 2, 0, time.Local), 12345}
+ if !reflect.DeepEqual(lf, expected) {
+ t.Errorf("unexpected result: got %+v, expected %+v", lf, expected)
+ }
+ }
+}
diff --git a/tools/gclogs/gclogs.go b/tools/gclogs/gclogs.go
new file mode 100644
index 0000000..6fde8af
--- /dev/null
+++ b/tools/gclogs/gclogs.go
@@ -0,0 +1,157 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/user"
+ "path/filepath"
+ "regexp"
+ "time"
+
+ "veyron.io/lib/cmdline"
+)
+
+var (
+ flagCutoff time.Duration
+ flagProgname string
+ flagVerbose bool
+ flagDryrun bool
+
+ cmdGCLogs = &cmdline.Command{
+ Run: garbageCollectLogs,
+ Name: "gclogs",
+ Short: "gclogs is a utility that safely deletes old log files.",
+ Long: `
+gclogs is a utility that safely deletes old log files.
+
+It looks for file names that match the format of files produced by the vlog
+package, and deletes the ones that have not changed in the amount of time
+specified by the --cutoff flag.
+
+Only files produced by the same user as the one running the gclogs command
+are considered for deletion.
+`,
+ ArgsName: "<dir> ...",
+ ArgsLong: "<dir> ... A list of directories where to look for log files.",
+ }
+)
+
+func init() {
+ cmdGCLogs.Flags.DurationVar(&flagCutoff, "cutoff", 24*time.Hour, "The age cut-off for a log file to be considered for garbage collection.")
+ cmdGCLogs.Flags.StringVar(&flagProgname, "program", ".*", `A regular expression to apply to the program part of the log file name, e.g ".*test".`)
+ cmdGCLogs.Flags.BoolVar(&flagVerbose, "verbose", false, "If true, each deleted file is shown on stdout.")
+ cmdGCLogs.Flags.BoolVar(&flagDryrun, "n", false, "If true, log files that would be deleted are shown on stdout, but not actually deleted.")
+}
+
+func garbageCollectLogs(cmd *cmdline.Command, args []string) error {
+ if len(args) == 0 {
+ cmd.UsageErrorf("gclogs requires at least one argument")
+ }
+ timeCutoff := time.Now().Add(-flagCutoff)
+ currentUser, err := user.Current()
+ if err != nil {
+ return err
+ }
+ programRE, err := regexp.Compile(flagProgname)
+ if err != nil {
+ return err
+ }
+ var lastErr error
+ for _, logdir := range args {
+ if err := processDirectory(cmd, logdir, timeCutoff, programRE, currentUser.Username); err != nil {
+ lastErr = err
+ }
+ }
+ return lastErr
+}
+
+func processDirectory(cmd *cmdline.Command, logdir string, timeCutoff time.Time, programRE *regexp.Regexp, username string) error {
+ fmt.Fprintf(cmd.Stdout(), "Processing: %q\n", logdir)
+
+ f, err := os.Open(logdir)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ var lastErr error
+ deleted := 0
+ symlinks := []string{}
+ for {
+ fi, err := f.Readdir(100)
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ lastErr = err
+ break
+ }
+ for _, file := range fi {
+ fullname := filepath.Join(logdir, file.Name())
+ if file.IsDir() {
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Skipped directory: %q\n", fullname)
+ }
+ continue
+ }
+ lf, err := parseFileInfo(logdir, file)
+ if err != nil {
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Not a log file: %q\n", fullname)
+ }
+ continue
+ }
+ if lf.user != username {
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Skipped log file created by other user: %q\n", fullname)
+ }
+ continue
+ }
+ if !programRE.MatchString(lf.program) {
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Skipped log file doesn't match %q: %q\n", flagProgname, fullname)
+ }
+ continue
+ }
+ if lf.symlink {
+ symlinks = append(symlinks, fullname)
+ continue
+ }
+ if file.ModTime().Before(timeCutoff) {
+ if flagDryrun {
+ fmt.Fprintf(cmd.Stdout(), "Would delete %q\n", fullname)
+ continue
+ }
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Deleting %q\n", fullname)
+ }
+ if err := os.Remove(fullname); err != nil {
+ lastErr = err
+ } else {
+ deleted++
+ }
+ }
+ }
+ }
+ // Delete broken links.
+ for _, sl := range symlinks {
+ if _, err := os.Stat(sl); err != nil && os.IsNotExist(err) {
+ if flagDryrun {
+ fmt.Fprintf(cmd.Stdout(), "Would delete symlink %q\n", sl)
+ continue
+ }
+ if flagVerbose {
+ fmt.Fprintf(cmd.Stdout(), "Deleting symlink %q\n", sl)
+ }
+ if err := os.Remove(sl); err != nil {
+ lastErr = err
+ } else {
+ deleted++
+ }
+ }
+
+ }
+ fmt.Fprintf(cmd.Stdout(), "Number of files deleted: %d\n", deleted)
+ return lastErr
+}
diff --git a/tools/gclogs/gclogs_test.go b/tools/gclogs/gclogs_test.go
new file mode 100644
index 0000000..f9168fe
--- /dev/null
+++ b/tools/gclogs/gclogs_test.go
@@ -0,0 +1,177 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "io/ioutil"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+)
+
+func setup(t *testing.T, workdir, username string) (tmpdir string) {
+ var err error
+ tmpdir, err = ioutil.TempDir(workdir, "parse-file-info-")
+ if err != nil {
+ t.Fatalf("ioutil.TempDir failed: %v", err)
+ }
+ logfiles := []struct {
+ name string
+ link string
+ age time.Duration
+ }{
+ {"prog1.host.%USER%.log.veyron.INFO.20141204-131502.12345", "", 4 * time.Hour},
+ {"prog1.host.%USER%.log.veyron.INFO.20141204-145040.23456", "prog1.INFO", 1 * time.Hour},
+ {"prog1.host.%USER%.log.veyron.ERROR.20141204-145040.23456", "prog1.ERROR", 1 * time.Hour},
+ {"prog2.host.%USER%.log.veyron.INFO.20141204-135040.23456", "prog2.INFO", 4 * time.Hour},
+ {"prog3.host.otheruser.log.veyron.INFO.20141204-135040.23456", "prog3.INFO", 1 * time.Hour},
+ {"foo.txt", "", 1 * time.Hour},
+ {"bar.txt", "", 1 * time.Hour},
+ }
+ for _, l := range logfiles {
+ l.name = strings.Replace(l.name, "%USER%", username, -1)
+ filename := filepath.Join(tmpdir, l.name)
+ if err := ioutil.WriteFile(filename, []byte{}, 0644); err != nil {
+ t.Fatalf("ioutil.WriteFile failed: %v", err)
+ }
+ mtime := time.Now().Add(-l.age)
+ if err := os.Chtimes(filename, mtime, mtime); err != nil {
+ t.Fatalf("os.Chtimes failed: %v", err)
+ }
+ if l.link != "" {
+ if err := os.Symlink(l.name, filepath.Join(tmpdir, l.link)); err != nil {
+ t.Fatalf("os.Symlink failed: %v", err)
+ }
+ }
+ }
+ if err := os.Mkdir(filepath.Join(tmpdir, "subdir"), 0700); err != nil {
+ t.Fatalf("os.Mkdir failed: %v", err)
+ }
+ return
+}
+
+func TestGCLogs(t *testing.T) {
+ workdir, err := ioutil.TempDir("", "parse-file-info-")
+ if err != nil {
+ t.Fatalf("ioutil.TempDir failed: %v", err)
+ }
+ defer os.RemoveAll(workdir)
+
+ u, err := user.Current()
+ if err != nil {
+ t.Fatalf("user.Current failed: %v", err)
+ }
+
+ cmd := cmdGCLogs
+ var stdout, stderr bytes.Buffer
+ cmd.Init(nil, &stdout, &stderr)
+
+ testcases := []struct {
+ cutoff time.Duration
+ verbose bool
+ dryrun bool
+ expected []string
+ }{
+ {
+ cutoff: 6 * time.Hour,
+ verbose: false,
+ dryrun: false,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Number of files deleted: 0`,
+ },
+ },
+ {
+ cutoff: 2 * time.Hour,
+ verbose: false,
+ dryrun: true,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Would delete "%TESTDIR%/prog1.host.%USER%.log.veyron.INFO.20141204-131502.12345"`,
+ `Would delete "%TESTDIR%/prog2.host.%USER%.log.veyron.INFO.20141204-135040.23456"`,
+ `Number of files deleted: 0`,
+ },
+ },
+ {
+ cutoff: 2 * time.Hour,
+ verbose: false,
+ dryrun: false,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Number of files deleted: 3`,
+ },
+ },
+ {
+ cutoff: 2 * time.Hour,
+ verbose: true,
+ dryrun: false,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Deleting "%TESTDIR%/prog1.host.%USER%.log.veyron.INFO.20141204-131502.12345"`,
+ `Deleting "%TESTDIR%/prog2.host.%USER%.log.veyron.INFO.20141204-135040.23456"`,
+ `Deleting symlink "%TESTDIR%/prog2.INFO"`,
+ `Not a log file: "%TESTDIR%/bar.txt"`,
+ `Not a log file: "%TESTDIR%/foo.txt"`,
+ `Skipped directory: "%TESTDIR%/subdir"`,
+ `Skipped log file created by other user: "%TESTDIR%/prog3.INFO"`,
+ `Skipped log file created by other user: "%TESTDIR%/prog3.host.otheruser.log.veyron.INFO.20141204-135040.23456"`,
+ `Number of files deleted: 3`,
+ },
+ },
+ {
+ cutoff: time.Minute,
+ verbose: true,
+ dryrun: false,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Deleting "%TESTDIR%/prog1.host.%USER%.log.veyron.ERROR.20141204-145040.23456"`,
+ `Deleting "%TESTDIR%/prog1.host.%USER%.log.veyron.INFO.20141204-131502.12345"`,
+ `Deleting "%TESTDIR%/prog1.host.%USER%.log.veyron.INFO.20141204-145040.23456"`,
+ `Deleting "%TESTDIR%/prog2.host.%USER%.log.veyron.INFO.20141204-135040.23456"`,
+ `Deleting symlink "%TESTDIR%/prog1.ERROR"`,
+ `Deleting symlink "%TESTDIR%/prog1.INFO"`,
+ `Deleting symlink "%TESTDIR%/prog2.INFO"`,
+ `Not a log file: "%TESTDIR%/bar.txt"`,
+ `Not a log file: "%TESTDIR%/foo.txt"`,
+ `Skipped directory: "%TESTDIR%/subdir"`,
+ `Skipped log file created by other user: "%TESTDIR%/prog3.INFO"`,
+ `Skipped log file created by other user: "%TESTDIR%/prog3.host.otheruser.log.veyron.INFO.20141204-135040.23456"`,
+ `Number of files deleted: 7`,
+ },
+ },
+ {
+ cutoff: time.Minute,
+ verbose: false,
+ dryrun: false,
+ expected: []string{
+ `Processing: "%TESTDIR%"`,
+ `Number of files deleted: 7`,
+ },
+ },
+ }
+ for _, tc := range testcases {
+ testdir := setup(t, workdir, u.Username)
+ cutoff := fmt.Sprintf("--cutoff=%s", tc.cutoff)
+ verbose := fmt.Sprintf("--verbose=%v", tc.verbose)
+ dryrun := fmt.Sprintf("--n=%v", tc.dryrun)
+ if err := cmd.Execute([]string{cutoff, verbose, dryrun, testdir}); err != nil {
+ t.Fatalf("%v: %v", stderr.String(), err)
+ }
+ gotsl := strings.Split(stdout.String(), "\n")
+ if len(gotsl) >= 2 {
+ sort.Strings(gotsl[1 : len(gotsl)-2])
+ }
+ got := strings.Join(gotsl, "\n")
+ expected := strings.Join(tc.expected, "\n") + "\n"
+ expected = strings.Replace(expected, "%TESTDIR%", testdir, -1)
+ expected = strings.Replace(expected, "%USER%", u.Username, -1)
+ if got != expected {
+ t.Errorf("Unexpected result for (%v, %v): got %q, expected %q", tc.cutoff, tc.verbose, got, expected)
+ }
+ stdout.Reset()
+ }
+}
diff --git a/tools/gclogs/main.go b/tools/gclogs/main.go
new file mode 100644
index 0000000..5cdbead
--- /dev/null
+++ b/tools/gclogs/main.go
@@ -0,0 +1,8 @@
+// The following enables go generate to generate the doc.go file.
+//go:generate go run $VEYRON_ROOT/veyron/go/src/veyron.io/lib/cmdline/testdata/gendoc.go . -help
+
+package main
+
+func main() {
+ cmdGCLogs.Main()
+}