diff --git a/README.md b/README.md index 8362681c..d7df9a7a 100644 --- a/README.md +++ b/README.md @@ -607,8 +607,25 @@ You can change this with `-f` option like below. $ ayd -f /path/to/ayd.log ping:example.com ``` -There is no feature to log rotate. -Please consider using the log rotation tool if you have a plan to use it for a long time. +It is recommended to rotate logs if you run Ayd for long time. +You can rotate logs like below. + +``` shell +$ ayd -f ./ayd_%Y%m%d.log ping:example.com +$ ayd -f /path/to/%Y/%m%d/ayd.log ping:example.com +``` + +There are some keywords to specify how to name log files. + +- `%Y`: Full year like `2006`. +- `%y`: Short year like `06`. +- `%m`: Month. +- `%d`: Day of month. +- `%H`: Hour. +- `%M`: Minute. +- `%%`: A '%' character. + +Unknown keywords will be just ignored and keep as is. If you use `-f -` option, Ayd does not write log file. This is not recommended for production use because Ayd can't restore last status when restore if don't save log file. diff --git a/cmd/ayd/main.go b/cmd/ayd/main.go index 73fe08f6..6ee15ea8 100644 --- a/cmd/ayd/main.go +++ b/cmd/ayd/main.go @@ -141,7 +141,6 @@ func (cmd *AydCommand) Run(args []string) (exitCode int) { fmt.Fprintf(cmd.ErrStream, "error: failed to open log file: %s\n", err) return 1 } - defer s.Close() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -150,6 +149,7 @@ func (cmd *AydCommand) Run(args []string) (exitCode int) { alert, err := scheme.NewAlerterSet(cmd.AlertURLs) if err != nil { fmt.Fprintln(cmd.ErrStream, err) + s.Close() return 2 } s.OnStatusChanged = append(s.OnStatusChanged, func(r api.Record) { @@ -158,10 +158,19 @@ func (cmd *AydCommand) Run(args []string) (exitCode int) { } if cmd.OneshotMode { - return cmd.RunOneshot(ctx, s) + exitCode = cmd.RunOneshot(ctx, s) } else { - return cmd.RunServer(ctx, s) + exitCode = cmd.RunServer(ctx, s) } + + s.Close() + + healthy, _ := s.Errors() + if exitCode == 0 && !healthy { + return 1 + } + + return exitCode } func main() { diff --git a/cmd/ayd/main_test.go b/cmd/ayd/main_test.go index f8559524..2b92a7db 100644 --- a/cmd/ayd/main_test.go +++ b/cmd/ayd/main_test.go @@ -3,7 +3,10 @@ package main_test import ( "bytes" "fmt" + "os" + "path/filepath" "regexp" + "runtime" "testing" "github.com/macrat/ayd/cmd/ayd" @@ -198,3 +201,26 @@ func TestAydCommand_Run(t *testing.T) { }) } } + +func TestAydCommand_Run_permissionDenied(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("permission test only works on *nix OS") + } + + path := filepath.Join(t.TempDir(), "log") + if err := os.Mkdir(path, 0); err != nil { + t.Fatalf("failed to make test directory: %s", err) + } + + buf := bytes.NewBuffer([]byte{}) + cmd := main.AydCommand{ + OutStream: buf, + ErrStream: buf, + } + + code := cmd.Run([]string{"ayd", "-1", "-f", filepath.Join(path, "ayd.log"), "dummy:"}) + t.Log(buf.String()) + if code != 1 { + t.Errorf("unexpected return code: %d", code) + } +} diff --git a/cmd/ayd/server_test.go b/cmd/ayd/server_test.go index 2d58bb1d..04594e51 100644 --- a/cmd/ayd/server_test.go +++ b/cmd/ayd/server_test.go @@ -3,9 +3,7 @@ package main_test import ( "context" "fmt" - "os" "regexp" - "runtime" "sync" "testing" "time" @@ -130,26 +128,6 @@ func TestRunServer_tls_error(t *testing.T) { } } -func TestRunServer_permissionError(t *testing.T) { - if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { - t.Skip("permission test only works on *nix OS") - } - - s := testutil.NewStore(t) - defer s.Close() - os.Chmod(s.Path(), 0200) - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - cmd, _ := MakeTestCommand(t, []string{"dummy:"}) - - code := cmd.RunServer(ctx, s) - if code != 1 { - t.Errorf("unexpected return code: %d", code) - } -} - func BenchmarkRunServer(b *testing.B) { s := testutil.NewStore(b) defer s.Close() diff --git a/internal/endpoint/store.go b/internal/endpoint/store.go index a64fe6bf..f6ce15fb 100644 --- a/internal/endpoint/store.go +++ b/internal/endpoint/store.go @@ -7,9 +7,6 @@ import ( ) type Store interface { - // Path returns path to log file. - Path() string - // Targets returns target URLs include inactive target. Targets() []string diff --git a/internal/store/pattern.go b/internal/store/pattern.go new file mode 100644 index 00000000..355ce09b --- /dev/null +++ b/internal/store/pattern.go @@ -0,0 +1,423 @@ +package store + +import ( + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +type pathFragment interface { + Build(t time.Time) string + Len() int + Glob() string + FillTimePattern(s string, tp *timePattern) (ok bool) +} + +type constFragment string + +func (c constFragment) Build(_ time.Time) string { + return string(c) +} + +func (c constFragment) Len() int { + return len(c) +} + +func (c constFragment) Glob() string { + return string(c) +} + +func (c constFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + return s == string(c) +} + +type yearFragment struct { + Short bool +} + +func (y yearFragment) Build(t time.Time) string { + if y.Short { + return t.Format("06") + } else { + return strconv.Itoa(t.Year()) + } +} + +func (y yearFragment) Len() int { + if y.Short { + return 2 + } else { + return 4 + } +} + +func (y yearFragment) Glob() string { + if y.Short { + return "[0-9][0-9]" + } else { + return "[0-9][0-9][0-9][0-9]" + } +} + +func (y yearFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return false + } + + if y.Short { + n += 2000 + } + + if tp.Year >= 0 && tp.Year != n { + return false + } + + tp.Year = n + return true +} + +type monthFragment struct{} + +func (m monthFragment) Build(t time.Time) string { + return t.Format("01") +} + +func (m monthFragment) Len() int { + return 2 +} + +func (m monthFragment) Glob() string { + return "[0-1][0-9]" +} + +func (m monthFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || 12 < n { + return false + } + + if tp.Month >= 1 && tp.Month != n { + return false + } + + tp.Month = n + return true +} + +type dayFragment struct{} + +func (d dayFragment) Build(t time.Time) string { + return t.Format("02") +} + +func (d dayFragment) Len() int { + return 2 +} + +func (d dayFragment) Glob() string { + return "[0-3][0-9]" +} + +func (d dayFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || 31 < n { + return false + } + + if tp.Day >= 1 && tp.Day != n { + return false + } + + tp.Day = n + return true +} + +type hourFragment struct{} + +func (h hourFragment) Build(t time.Time) string { + return t.Format("15") +} + +func (h hourFragment) Len() int { + return 2 +} + +func (h hourFragment) Glob() string { + return "[0-2][0-9]" +} + +func (h hourFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 0 || 23 < n { + return false + } + + if tp.Hour >= 0 && tp.Hour != n { + return false + } + + tp.Hour = n + return true +} + +type minuteFragment struct{} + +func (m minuteFragment) Build(t time.Time) string { + return t.Format("04") +} + +func (m minuteFragment) Len() int { + return 2 +} + +func (m minuteFragment) Glob() string { + return "[0-5][0-9]" +} + +func (m minuteFragment) FillTimePattern(s string, tp *timePattern) (ok bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 0 || 59 < n { + return false + } + + if tp.Minute >= 0 && tp.Minute != n { + return false + } + + tp.Minute = n + return true +} + +type PathPattern struct { + pattern string + fragments []pathFragment +} + +func ParsePathPattern(s string) PathPattern { + p := PathPattern{pattern: s} + + var buf []string + left := 0 + for i := 0; i < len(s); i++ { + if s[i] == '%' { + i++ + + if len(s) <= i { + break + } + + if s[i] == '%' { + buf = append(buf, s[left:i]) + left = i + 1 + continue + } + + buf = append(buf, s[left:i-1]) + left = i + 1 + if c := constFragment(strings.Join(buf, "")); c != "" { + p.fragments = append(p.fragments, c) + } + buf = nil + + switch s[i] { + case 'Y': + p.fragments = append(p.fragments, yearFragment{false}) + case 'y': + p.fragments = append(p.fragments, yearFragment{true}) + case 'm': + p.fragments = append(p.fragments, monthFragment{}) + case 'd': + p.fragments = append(p.fragments, dayFragment{}) + case 'H': + p.fragments = append(p.fragments, hourFragment{}) + case 'M': + p.fragments = append(p.fragments, minuteFragment{}) + default: + buf = append(buf, "%", string(s[i])) + } + } + } + if c := constFragment(strings.Join(append(buf, s[left:]), "")); c != "" { + p.fragments = append(p.fragments, c) + } + + return p +} + +func (p PathPattern) String() string { + return p.pattern +} + +func (p PathPattern) IsEmpty() bool { + return len(p.fragments) == 0 +} + +func (p PathPattern) Build(t time.Time) string { + ss := make([]string, len(p.fragments)) + for i, f := range p.fragments { + ss[i] = f.Build(t) + } + return strings.Join(ss, "") +} + +func (p PathPattern) parseTimePattern(filename string) (tp timePattern, ok bool) { + tp = emptyTimePattern + + l := 0 + for _, f := range p.fragments { + r := l + f.Len() + if r > len(filename) { + return tp, false + } + + if !f.FillTimePattern(filename[l:r], &tp) { + return tp, false + } + + l = r + } + + return tp, len(filename) == l +} + +func (p PathPattern) Match(filename string, since, until time.Time) bool { + if p.IsEmpty() { + return filename == "" + } + + tp, ok := p.parseTimePattern(filename) + if !ok { + return false + } + + max := tp.Exec(until, maxTimePattern) + min := tp.Exec(since, minTimePattern) + + return !since.After(max) && !min.After(until) +} + +func (p PathPattern) Glob() string { + ss := make([]string, len(p.fragments)) + for i, f := range p.fragments { + ss[i] = f.Glob() + } + return strings.Join(ss, "") +} + +// ListAll returns all log file pathes. +// The result is sorted by time order. +func (p PathPattern) ListAll() []string { + xs, err := filepath.Glob(p.Glob()) + if err != nil { + return nil + } + + rs := make([]string, 0, len(xs)) + tps := make([]timePattern, 0, len(xs)) + + for _, x := range xs { + if tp, ok := p.parseTimePattern(x); ok { + rs = append(rs, x) + tps = append(tps, tp) + } + } + + sort.Slice(rs, func(i, j int) bool { + return tps[i].Less(tps[j]) + }) + + return rs +} + +// ListBetween returns log file pathes. +// The result is filtered by since and until query, but not sorted. +func (p PathPattern) ListBetween(since, until time.Time) []string { + xs, err := filepath.Glob(p.Glob()) + if err != nil { + return nil + } + + rs := make([]string, 0, len(xs)) + + for _, x := range xs { + if p.Match(x, since, until) { + rs = append(rs, x) + } + } + + return rs +} + +type timePattern struct { + Year int + Month int + Day int + Hour int + Minute int +} + +var ( + emptyTimePattern = timePattern{-1, -1, -1, -1, -1} + minTimePattern = timePattern{0, 1, 1, 0, 0} + maxTimePattern = timePattern{9999, 12, 31, 23, 59} +) + +func (p timePattern) Exec(t time.Time, base timePattern) time.Time { + useCopy := false + r := base + + if p.Minute >= 0 { + r.Minute = p.Minute + useCopy = true + } + + if p.Hour >= 0 { + r.Hour = p.Hour + useCopy = true + } else if useCopy { + r.Hour = t.Hour() + } + + if p.Day >= 1 { + r.Day = p.Day + useCopy = true + } else if useCopy { + r.Day = t.Day() + } + + if p.Month >= 1 { + r.Month = p.Month + useCopy = true + } else if useCopy { + r.Month = int(t.Month()) + } + + if p.Year >= 0 { + r.Year = p.Year + //useCopy = true // No need this. + } else if useCopy { + r.Year = t.Year() + } + + return r.Time(t.Location()) +} + +func (p timePattern) Time(loc *time.Location) time.Time { + return time.Date( + p.Year, + time.Month(p.Month), + p.Day, + p.Hour, + p.Minute, + 0, + 0, + loc, + ) +} + +func (p timePattern) Less(x timePattern) bool { + return p.Year < x.Year || p.Month < x.Month || p.Day < x.Day || p.Hour < x.Hour || p.Minute < x.Minute +} diff --git a/internal/store/pattern_test.go b/internal/store/pattern_test.go new file mode 100644 index 00000000..60bec109 --- /dev/null +++ b/internal/store/pattern_test.go @@ -0,0 +1,119 @@ +package store_test + +import ( + "testing" + "time" + + "github.com/macrat/ayd/internal/store" +) + +func TestPathPattern_parseAndBuild(t *testing.T) { + times := []time.Time{ + time.Date(2001, 2, 3, 4, 5, 6, 7, time.UTC), + time.Date(1234, 11, 29, 20, 42, 50, 234, time.UTC), + } + tests := []struct { + input string + want []string + }{ + {"ayd.log", []string{"ayd.log", "ayd.log"}}, + {"ayd_%Y%m%d%H%M.log", []string{"ayd_200102030405.log", "ayd_123411292042.log"}}, + {"year=%y/month=%m/day=%d/ayd.log", []string{"year=01/month=02/day=03/ayd.log", "year=34/month=11/day=29/ayd.log"}}, + {"ayd_%ignore%%%Y.log", []string{"ayd_%ignore%2001.log", "ayd_%ignore%1234.log"}}, + } + + for _, tt := range tests { + p := store.ParsePathPattern(tt.input) + + for i, want := range tt.want { + actual := p.Build(times[i]) + if actual != want { + t.Errorf("%s: unexpected result:\nexpected: %s\n but got: %s", tt.input, want, actual) + } + } + } +} + +func TestPathPattern_Match(t *testing.T) { + tests := []struct { + pattern string + fname string + since time.Time + until time.Time + want bool + }{ + {"ayd.log", "ayd.log", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"ayd.log", "log.json", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"ayd_%Y%m%d.log", "ayd_20220101.log", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"ayd_%Y%m%d.log", "ayd_2022010.log", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"ayd_%Y-%m-%d.log", "ayd_2022-06-15.log", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"ayd_%m%d.log", "ayd_0401.log", time.Date(2022, 3, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 5, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y%m%dT%H%M.log", "20210102T1504.log", time.Date(2021, 1, 2, 15, 4, 0, 0, time.UTC), time.Date(2021, 1, 2, 15, 4, 10, 0, time.UTC), true}, + {"%y/ayd_%H%M.log", "22/ayd_2059.log", time.Date(2022, 1, 1, 20, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 21, 0, 0, 0, time.UTC), true}, + {"%y/ayd_%H%M.log", "22/ayd_2101.log", time.Date(2022, 1, 1, 20, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 21, 0, 0, 0, time.UTC), false}, + {"%y/ayd_%H%M.log", "22/ayd_1959.log", time.Date(2022, 1, 1, 20, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 21, 0, 0, 0, time.UTC), false}, + {"%m_%d_%Y.log", "12_25_2001.log", time.Date(2001, 12, 1, 0, 0, 0, 0, time.UTC), time.Date(2001, 12, 30, 0, 0, 0, 0, time.UTC), true}, + {"ayd/date=%Y%m%d/time=%H%M/log.json", "ayd/year=20220203/time=1504/log.json", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 2, 3, 15, 3, 0, 0, time.UTC), false}, + {"ayd/date=%Y%m%d/time=%H%M/log.json", "ayd/year=20220203/time=1503/log.json", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 2, 3, 15, 3, 0, 0, time.UTC), false}, + {"", "", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%", "%", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%%", "%", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%%%", "%%", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%a%%", "%a%", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y", "2022", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y", "-123", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%m", "12", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), true}, + {"%m", "13", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), false}, + {"%d", "31", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), true}, + {"%d", "32", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), false}, + {"%H", "23", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), true}, + {"%H", "24", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), false}, + {"%M", "59", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), true}, + {"%M", "60", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 12, 31, 23, 59, 0, 0, time.UTC), false}, + {"%Y%m%d", "20220101", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y%m%d", "20220101?", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%Y%m%d", "2022010", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%Y-%Y", "2022-2022", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y-%Y", "2022-2023", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%Y-%y", "2022-22", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%Y-%y", "2022-23", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%m-%m", "01-01", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%m-%m", "01-02", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%d-%d", "01-01", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%d-%d", "01-02", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%H-%H", "01-01", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%H-%H", "01-02", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + {"%M-%M", "01-01", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), true}, + {"%M-%M", "01-02", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), false}, + } + + for _, tt := range tests { + p := store.ParsePathPattern(tt.pattern) + actual := p.Match(tt.fname, tt.since, tt.until) + if actual != tt.want { + t.Errorf("%s: %s: want=%v actual=%v", tt.pattern, tt.fname, tt.want, actual) + } + } +} + +func FuzzPathPattern_Build(f *testing.F) { + f.Add("ayd_%y%m%d.log") + f.Add("%Y-%m-%dT%H:%M.txt") + f.Add("ayd/year=%Y/month=%m/day=%d/hour=%H/minute=%M/log.json") + f.Add("ayd-log/date=%Y%m%d/time=%H%M/ayd.log") + f.Add("/var/log/ayd/%Y/%m/%d/%H%M.log") + f.Add("%%percent%%%") + + now := time.Now() + + f.Fuzz(func(t *testing.T, s string) { + if len(s) == 0 { + t.Skip() + } + + p := store.ParsePathPattern(s) + if len(p.Build(now)) == 0 { + t.Errorf("pattern %q made an empty string", s) + } + }) +} diff --git a/internal/store/scanner.go b/internal/store/scanner.go index f7f0f689..c118f650 100644 --- a/internal/store/scanner.go +++ b/internal/store/scanner.go @@ -37,12 +37,6 @@ func (r *fileScanner) Close() error { return r.file.Close() } -func (r *fileScanner) seek(pos int64) { - r.file.Seek(pos, os.SEEK_SET) - r.reader = bufio.NewReader(r.file) - r.pos = pos -} - func (r *fileScanner) Scan() bool { for { b, err := r.reader.ReadBytes('\n') @@ -64,6 +58,84 @@ func (r *fileScanner) Record() api.Record { return r.rec } +type fileScannerSet struct { + scanners []*fileScanner + scanned bool + earliest int +} + +func newFileScannerSet(pathes []string, since, until time.Time) (*fileScannerSet, error) { + min := time.Unix(1<<63-1, 0) + + var ss fileScannerSet + for i, p := range pathes { + s, err := newFileScanner(p, since, until) + if err != nil { + ss.Close() + return nil, err + } + if !s.Scan() { + s.Close() + ss.Close() + continue + } + if s.Record().Time.Before(min) { + ss.earliest = i + min = s.Record().Time + } + ss.scanners = append(ss.scanners, s) + } + return &ss, nil +} + +func (r *fileScannerSet) Close() error { + var err error + for _, s := range r.scanners { + if e := s.Close(); e != nil { + err = e + } + } + return err +} + +func (r *fileScannerSet) updateEarliest() { + max := time.Unix(1<<63-1, 0) + for i, s := range r.scanners { + if s.Record().Time.Before(max) { + r.earliest = i + max = s.Record().Time + } + } +} + +func (r *fileScannerSet) Scan() bool { + if !r.scanned { + r.scanned = true + return len(r.scanners) > 0 + } + + if len(r.scanners) == 0 { + return false + } + + if r.scanners[r.earliest].Scan() { + r.updateEarliest() + return true + } else { + r.scanners[r.earliest].Close() + r.scanners = append(r.scanners[:r.earliest], r.scanners[r.earliest+1:]...) + r.updateEarliest() + return len(r.scanners) > 0 + } +} + +func (r *fileScannerSet) Record() api.Record { + if len(r.scanners) < 0 { + panic("This is a bug if you see this message.") + } + return r.scanners[r.earliest].Record() +} + type inMemoryScanner struct { records []api.Record index int @@ -129,11 +201,11 @@ func (r dummyScanner) Record() api.Record { } func (s *Store) OpenLog(since, until time.Time) (api.LogScanner, error) { - if s.Path() == "" { + if s.path.IsEmpty() { return newInMemoryScanner(s, since, until), nil } - r, err := newFileScanner(s.Path(), since, until) + r, err := newFileScannerSet(s.path.ListBetween(since, until), since, until) if errors.Is(err, os.ErrNotExist) { return dummyScanner{}, nil } else { diff --git a/internal/store/scanner_test.go b/internal/store/scanner_test.go index e66881d6..724ee05a 100644 --- a/internal/store/scanner_test.go +++ b/internal/store/scanner_test.go @@ -2,7 +2,6 @@ package store_test import ( "io" - "os" "path/filepath" "testing" "time" @@ -33,10 +32,10 @@ func TestStore_OpenLog(t *testing.T) { if scanner, err := inStorage.OpenLog(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), time.Now()); err != nil { t.Fatalf("failed to prepare in-memory store: %s", err) } else { - defer scanner.Close() for scanner.Scan() { inMemory.Report(scanner.Record().Target, scanner.Record()) } + scanner.Close() } for _, s := range stores { @@ -126,15 +125,4 @@ func TestStore_OpenLog_logRemoved(t *testing.T) { testCount(r) r.Close() } - - if err := os.Remove(p); err != nil { - t.Fatalf("failed to remove test log file: %s", err) - } - - if r, err := s.OpenLog(baseTime.Add(-1*time.Hour), baseTime.Add(1*time.Hour)); err != nil { - t.Fatalf("failed to open reader: %s", err) - } else { - testCount(r) - r.Close() - } } diff --git a/internal/store/store.go b/internal/store/store.go index 2642442d..a4f51dc5 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,6 +6,7 @@ import ( "io" "net/url" "os" + "path/filepath" "sort" "strings" "sync" @@ -20,15 +21,14 @@ const ( ) var ( - LogRestoreBytes = int64(100 * 1024 * 1024) - TooLargeLogMessage = "WARNING: read only last 100MB from log file because it is too large" + LogRestoreBytes = int64(100 * 1024 * 1024) ) type RecordHandler func(api.Record) // Store is the log handler of Ayd, and it also the database of Ayd. type Store struct { - path string + path PathPattern Console io.Writer @@ -51,7 +51,7 @@ func New(path string, console io.Writer) (*Store, error) { ch := make(chan api.Record, 32) store := &Store{ - path: path, + path: ParsePathPattern(path), Console: console, probeHistory: make(probeHistoryMap), currentIncidents: make(map[string]*api.Incident), @@ -60,23 +60,14 @@ func New(path string, console io.Writer) (*Store, error) { healthy: true, } - if store.path != "" { - if f, err := os.OpenFile(store.path, os.O_WRONLY|os.O_APPEND|os.O_CREATE|os.O_SYNC, 0644); err != nil { - close(ch) - return nil, err - } else { - f.Close() - } - } - go store.writer(ch, store.writerStopped) return store, nil } -// Path returns path to log file. -func (s *Store) Path() string { - return s.path +// Path returns pathes to log files. +func (s *Store) Pathes() []string { + return s.path.ListAll() } // IncidentCount returns the count of incident causes. @@ -118,13 +109,19 @@ func (s *Store) writer(ch <-chan api.Record, stopped chan struct{}) { reader.Reset(msg) reader.WriteTo(s.Console) - if s.path == "" { + if s.path.IsEmpty() { continue } s.setHealthy() - f, err := os.OpenFile(s.path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + p := s.path.Build(r.Time) + + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + s.handleError(err, "failed to create log directory") + } + + f, err := os.OpenFile(p, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) if err != nil { s.handleError(err, "failed to open log file") continue @@ -353,32 +350,56 @@ func (s *Store) Report(source *api.URL, r api.Record) { } func (s *Store) Restore() error { - if s.path == "" { + if s.path.IsEmpty() { return nil } s.historyLock.Lock() defer s.historyLock.Unlock() - f, err := os.OpenFile(s.path, os.O_RDONLY|os.O_CREATE, 0644) + s.probeHistory = make(probeHistoryMap) + + pathes := s.path.ListAll() + + var loadedSize int64 + for i := range pathes { + if loadedSize > LogRestoreBytes { + break + } + + path := pathes[len(pathes)-i-1] + + size, err := s.restoreOneFile(path, LogRestoreBytes-loadedSize) + if err != nil { + return err + } + loadedSize += size + } + + for k := range s.probeHistory { + s.probeHistory[k].setInactive() + } + + return nil +} + +func (s *Store) restoreOneFile(path string, maxSize int64) (int64, error) { + f, err := os.OpenFile(path, os.O_RDONLY|os.O_CREATE, 0644) if err != nil { - return err + return 0, err } defer f.Close() - if ret, _ := f.Seek(-LogRestoreBytes, os.SEEK_END); ret != 0 { - u := &api.URL{Scheme: "ayd", Opaque: "log"} - fmt.Fprintln(s.Console, api.Record{ - Time: time.Now(), - Status: api.StatusDegrade, - Target: u, - Message: TooLargeLogMessage, - Extra: map[string]interface{}{ - "log_size": ret + LogRestoreBytes, - }, - }) + + size, err := f.Seek(0, os.SEEK_END) + if err != nil { + return 0, err } - s.probeHistory = make(probeHistoryMap) + if size > maxSize { + f.Seek(-maxSize, os.SEEK_END) + } else { + f.Seek(0, os.SEEK_SET) + } reader := bufio.NewReader(f) for { @@ -402,11 +423,7 @@ func (s *Store) Restore() error { } } - for k := range s.probeHistory { - s.probeHistory[k].setInactive() - } - - return nil + return size, nil } // ActivateTarget marks the target will reported via specified source. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 41522f1e..2998aaf7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -6,6 +6,7 @@ import ( "io" "net/url" "os" + "path/filepath" "regexp" "runtime" "strings" @@ -61,19 +62,10 @@ func TestStore_errorLogging(t *testing.T) { defer os.Remove(f.Name()) f.Close() - os.Chmod(f.Name(), 0000) - - _, err = store.New(f.Name(), io.Discard) - if err == nil { - t.Errorf("expected failed to open %s (with permission 000) but successed", f.Name()) - } - - os.Chmod(f.Name(), 0600) - buf := NewBuffer() s, err := store.New(f.Name(), buf) if err != nil { - t.Errorf("failed to open store %s (with permission 600)", err) + t.Errorf("failed to open store %s (with permission 600): %s", f.Name(), err) } defer s.Close() @@ -127,14 +119,9 @@ func TestStore_errorLogging(t *testing.T) { } func TestStore_Restore(t *testing.T) { - f, err := os.CreateTemp("", "ayd-test-*") - if err != nil { - t.Fatalf("failed to create log file: %s", err) - } - defer os.Remove(f.Name()) - f.Close() + path := filepath.Join(t.TempDir(), "%H.log") - s1, err := store.New(f.Name(), io.Discard) + s1, err := store.New(path, io.Discard) if err != nil { t.Fatalf("failed to create store: %s", err) } @@ -142,21 +129,21 @@ func TestStore_Restore(t *testing.T) { records := []api.Record{ { - Time: time.Now().Add(-30 * time.Minute), + Time: time.Now().Add(-time.Hour), Target: &api.URL{Scheme: "ping", Opaque: "restore-test"}, Status: api.StatusUnknown, Message: "hello world", Latency: 1 * time.Second, }, { - Time: time.Now().Add(-20 * time.Minute), + Time: time.Now().Add(-30 * time.Minute), Target: &api.URL{Scheme: "exec", Opaque: "/usr/local/bin/test.sh"}, Status: api.StatusHealthy, Message: "foobar", Latency: 123 * time.Millisecond, }, { - Time: time.Now().Add(-10 * time.Minute), + Time: time.Now().Add(-15 * time.Minute), Target: &api.URL{Scheme: "http", Host: "test.local", Path: "/abc/def"}, Status: api.StatusFailure, Message: "hoge", @@ -177,7 +164,7 @@ func TestStore_Restore(t *testing.T) { time.Sleep(100 * time.Millisecond) // wait for write - s2, err := store.New(f.Name(), io.Discard) + s2, err := store.New(path, io.Discard) if err != nil { t.Fatalf("failed to create store: %s", err) } @@ -363,7 +350,7 @@ func TestStore_Restore_limitBorder(t *testing.T) { } } -func TestStore_Restore_rotate(t *testing.T) { +func TestStore_Restore_fileRemoved(t *testing.T) { t.Parallel() f, err := os.CreateTemp("", "ayd-test-*") @@ -768,6 +755,87 @@ func TestStore_ReportInternalError(t *testing.T) { } } +func TestStore_logRotate(t *testing.T) { + dir := t.TempDir() + + s, err := store.New(filepath.Join(dir, "dt=%Y%m%d/%H.log"), io.Discard) + if err != nil { + t.Fatalf("failed to create store: %s", err) + } + + report := func(Y, m, d, H, M int) { + s.Report(&api.URL{Scheme: "dummy"}, api.Record{ + Time: time.Date(Y, time.Month(m), d, H, M, 0, 0, time.UTC), + Target: &api.URL{Scheme: "dummy"}, + }) + } + assert := func(lines ...int) { + t.Helper() + ls := s.Pathes() + if len(ls) != len(lines) { + t.Fatalf("unexpected number of log files found: expected=%d got=%d", len(lines), len(ls)) + } + for i, f := range ls { + bs, err := os.ReadFile(f) + if err != nil { + t.Fatalf("%d: failed to read %s: %s", i, f, err) + } + if n := bytes.Count(bs, []byte{'\n'}); n != lines[i] { + t.Fatalf("%d: unexpected number of lines found in %s: expected=%d got=%d", i, f, lines[i], n) + } + } + } + + assert() + + report(2001, 2, 3, 16, 5) + report(2001, 2, 3, 16, 6) + time.Sleep(10 * time.Millisecond) + assert(2) + + report(2001, 2, 4, 16, 50) + report(2001, 2, 3, 16, 7) + time.Sleep(10 * time.Millisecond) + assert(3, 1) + + report(2001, 2, 4, 16, 5) + report(2001, 2, 3, 4, 5) + time.Sleep(10 * time.Millisecond) + assert(1, 3, 2) + + r, err := s.OpenLog(time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("failed to open scanner: %s", err) + } + defer r.Close() + + tests := []struct { + Day, Hour, Minute int + }{ + {3, 4, 5}, + {3, 16, 5}, + {3, 16, 6}, + {3, 16, 7}, + {4, 16, 50}, + {4, 16, 5}, + } + for i, tt := range tests { + if !r.Scan() { + t.Fatalf("%d: failed to scan", i) + } + + got := r.Record().Time + + if got.Day() != tt.Day || got.Hour() != tt.Hour || got.Minute() != tt.Minute { + t.Errorf("%d: unexpected date record found: want=02/%02d %02d:%02d actual=02/%02d %02d:%02d", i, tt.Day, tt.Hour, tt.Minute, got.Day(), got.Hour(), got.Minute()) + } + } + + if r.Scan() { + t.Fatalf("unexpected extra record found: %s", r.Record()) + } +} + func TestStore_MakeReport(t *testing.T) { s := testutil.NewStoreWithLog(t) defer s.Close()