diff --git a/README.md b/README.md index 26d65df..06dd94f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Out of the box, Checkup currently supports: - Checking HTTP endpoints - Checking TCP endpoints (TLS supported) -- Storing results on S3 +- Storing results on S3 or on the local filesystem - Viewing results on a status page that is mobile-responsive and 100% static @@ -143,7 +143,7 @@ As you perform checks, the status page will update every so often with the lates ### Performing checks -You can run checks many different ways: cron, AWS Lambda, or a time.Ticker in your own Go program, to name a few. Checks should be run on a regular basis. How often you run checks depends on your requirements and how much time you render on the status page. +You can run checks many different ways: cron, AWS Lambda, or a time.Ticker in your own Go program, to name a few. Checks should be run on a regular basis. How often you run checks depends on your requirements and how much time you render on the status page. For example, if you run checks every 10 minutes, showing the last 24 hours on the status page will require 144 check files to be downloaded on each page load. You can distribute your checks to help avoid localized network problems, but this multiplies the number of files by the number of nodes you run checks on, so keep that in mind. @@ -287,4 +287,3 @@ Uh oh, having some fires? 🔥 You can create a type that implements `checkup.No #### Other kinds of checks or storage providers Need to check more than HTTP? S3 too Amazony for you? You can implement your own Checker and Storage types. If it's general enough, feel free to submit a pull request so others can use it too! - diff --git a/checkup.go b/checkup.go index ef69e08..424e476 100644 --- a/checkup.go +++ b/checkup.go @@ -200,6 +200,8 @@ func (c Checkup) MarshalJSON() ([]byte, error) { switch c.Storage.(type) { case S3: providerName = "s3" + case FS: + providerName = "fs" default: return result, fmt.Errorf("unknown Storage type") } @@ -297,6 +299,13 @@ func (c *Checkup) UnmarshalJSON(b []byte) error { return err } c.Storage = storage + case "fs": + var storage FS + err = json.Unmarshal(raw.Storage, &storage) + if err != nil { + return err + } + c.Storage = storage default: return fmt.Errorf("%s: unknown Storage type", types.Storage.Provider) } diff --git a/fs.go b/fs.go new file mode 100644 index 0000000..ec8dddc --- /dev/null +++ b/fs.go @@ -0,0 +1,114 @@ +package checkup + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + "time" +) + +const indexName = "index.json" + +// FS is a way to store checkup results on the local filesystem. +type FS struct { + // The path to the directory where check files will be stored. + Dir string `json:"dir"` + // The URL corresponding to fs.Dir. + URL string `json:"url"` + + // Check files older than CheckExpiry will be + // deleted on calls to Maintain(). If this is + // the zero value, no old check files will be + // deleted. + CheckExpiry time.Duration `json:"check_expiry,omitempty"` +} + +func (fs FS) readIndex() (map[string]int64, error) { + index := map[string]int64{} + + f, err := os.Open(filepath.Join(fs.Dir, indexName)) + if os.IsNotExist(err) { + return index, nil + } else if err != nil { + return nil, err + } + defer f.Close() + + err = json.NewDecoder(f).Decode(&index) + return index, err +} + +func (fs FS) writeIndex(index map[string]int64) error { + f, err := os.Create(filepath.Join(fs.Dir, indexName)) + if err != nil { + return err + } + defer f.Close() + + return json.NewEncoder(f).Encode(index) +} + +// Store stores results on filesystem according to the configuration in fs. +func (fs FS) Store(results []Result) error { + // Write results to a new file + name := *GenerateFilename() + f, err := os.Create(filepath.Join(fs.Dir, name)) + if err != nil { + return err + } + err = json.NewEncoder(f).Encode(results) + f.Close() + if err != nil { + return err + } + + // Read current index file + index, err := fs.readIndex() + if err != nil { + return err + } + + // Add new file to index + index[name] = time.Now().UnixNano() + + // Write new index + return fs.writeIndex(index) +} + +// Maintain deletes check files that are older than fs.CheckExpiry. +func (fs FS) Maintain() error { + if fs.CheckExpiry == 0 { + return nil + } + + files, err := ioutil.ReadDir(fs.Dir) + if err != nil { + return err + } + + index, err := fs.readIndex() + if err != nil { + return err + } + + for _, f := range files { + if f.Name() == indexName { + continue + } + + nsec, ok := index[f.Name()] + if !ok { + continue + } + + if time.Since(time.Unix(0, nsec)) > fs.CheckExpiry { + if err := os.Remove(filepath.Join(fs.Dir, f.Name())); err != nil { + return err + } + delete(index, f.Name()) + } + } + + return fs.writeIndex(index) +} diff --git a/fs_test.go b/fs_test.go new file mode 100644 index 0000000..2f2be28 --- /dev/null +++ b/fs_test.go @@ -0,0 +1,78 @@ +package checkup + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" +) + +func TestFS(t *testing.T) { + results := []Result{{Title: "Testing"}} + resultsBytes := []byte(`[{"title":"Testing"}]`+"\n") + + dir, err := ioutil.TempDir("", "checkup") + if err != nil { + t.Fatalf("Cannot create temporary directory: %v", err) + } + defer os.RemoveAll(dir) + + specimen := FS{ + Dir: dir, + } + + if err := specimen.Store(results); err != nil { + t.Fatalf("Expected no error from Store(), got: %v", err) + } + + // Make sure index has been created + index, err := specimen.readIndex() + if err != nil { + t.Fatalf("Cannot read index: %v", err) + } + + if len(index) != 1 { + t.Fatalf("Expected length of index to be 1, but got %v", len(index)) + } + + var ( + name string + nsec int64 + ) + for name, nsec = range index {} + + // Make sure index has timestamp of check + ts := time.Unix(0, nsec) + if time.Since(ts) > 1*time.Second { + t.Errorf("Timestamp of check is %s but expected something very recent", ts) + } + + // Make sure check file bytes are correct + checkfile := filepath.Join(specimen.Dir, name) + b, err := ioutil.ReadFile(checkfile) + if err != nil { + t.Fatalf("Expected no error reading body, got: %v", err) + } + if bytes.Compare(b, resultsBytes) != 0 { + t.Errorf("Contents of file are wrong\nExpected %s\nGot %s", resultsBytes, b) + } + + // Make sure check file is not deleted after maintain with CheckExpiry == 0 + if err := specimen.Maintain(); err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if _, err := os.Stat(checkfile); err != nil { + t.Fatalf("Expected not error calling Stat() on checkfile, got: %v", err) + } + + // Make sure checkfile is deleted after maintain with CheckExpiry > 0 + specimen.CheckExpiry = 1 * time.Nanosecond + if err := specimen.Maintain(); err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if _, err := os.Stat(checkfile); !os.IsNotExist(err) { + t.Fatalf("Expected checkfile to be deleted, but Stat() returned error: %v", err) + } +} diff --git a/statuspage/js/fs.js b/statuspage/js/fs.js new file mode 100644 index 0000000..0215ca7 --- /dev/null +++ b/statuspage/js/fs.js @@ -0,0 +1,68 @@ +/** + +FS Storage Adapter for Checkup.js + +**/ + +var checkup = checkup || {}; + +checkup.storage = (function() { + var url; + + // getCheckFileList gets the list of check files within + // the given timeframe (as a unit of nanoseconds) to + // download. + function getCheckFileList(timeframe, callback) { + var after = time.Now() - timeframe; + checkup.getJSON(url+'/index.json', function(index) { + var names = []; + for (var name in index) { + if (index[name] >= after) { + names.push(name); + } + } + callback(names); + }); + }; + + // setup prepares this storage unit to operate. + this.setup = function(cfg) { + url = cfg.url; + }; + + // getChecksWithin gets all the checks within timeframe as a unit + // of nanoseconds, and executes callback for each check file. + this.getChecksWithin = function(timeframe, fileCallback, doneCallback) { + var checksLoaded = 0, resultsLoaded = 0; + getCheckFileList(timeframe, function(list) { + if (list.length == 0 && (typeof doneCallback === 'function')) { + doneCallback(checksLoaded); + } else { + for (var i = 0; i < list.length; i++) { + checkup.getJSON(url+'/'+list[i], function(filename) { + return function(json, url) { + checksLoaded++; + resultsLoaded += json.length; + if (typeof fileCallback === 'function') + fileCallback(json, filename); + if (checksLoaded >= list.length && (typeof doneCallback === 'function')) + doneCallback(checksLoaded, resultsLoaded); + }; + }(list[i])); + } + } + }); + }; + + // getNewChecks gets any checks since the timestamp on the file name + // of the youngest check file that has been downloaded. If no check + // files have been downloaded, no new check files will be loaded. + this.getNewChecks = function(fileCallback, doneCallback) { + if (!checkup.lastCheckTs == null) + return; + var timeframe = time.Now() - checkup.lastCheckTs; + return this.getChecksWithin(timeframe, fileCallback, doneCallback); + }; + + return this; +})();