Files
kopia/cli/update_check.go
Jarek Kowalski 1a8fcb086c Added endurance test which tests kopia over long time scale (#558)
Globally replaced all use of time with internal 'clock' package
which provides indirection to time.Now()

Added support for faking clock in Kopia via KOPIA_FAKE_CLOCK_ENDPOINT

logfile: squelch annoying log message

testenv: added faketimeserver which serves time over HTTP

testing: added endurance test which tests kopia over long time scale

This creates kopia repository and simulates usage of Kopia over multiple
months (using accelerated fake time) to trigger effects that are only
visible after long time passage (maintenance, compactions, expirations).

The test is not used part of any test suite yet but will run in
post-submit mode only, preferably 24/7.

testing: refactored internal/clock to only support injection when
'testing' build tag is present
2020-08-26 23:03:46 -07:00

269 lines
8.0 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/natefinch/atomic"
"github.com/pkg/errors"
"golang.org/x/mod/semver"
"github.com/kopia/kopia/internal/clock"
"github.com/kopia/kopia/repo"
)
const (
checkForUpdatesEnvar = "KOPIA_CHECK_FOR_UPDATES"
githubTimeout = 10 * time.Second
)
// hidden flags to control auto-update behavior.
var (
initialUpdateCheckDelay = app.Flag("initial-update-check-delay", "Initial delay before first time update check").Default("24h").Hidden().Envar("KOPIA_INITIAL_UPDATE_CHECK_DELAY").Duration()
updateCheckInterval = app.Flag("update-check-interval", "Interval between update checks").Default("168h").Hidden().Envar("KOPIA_UPDATE_CHECK_INTERVAL").Duration()
updateAvailableNotifyInterval = app.Flag("update-available-notify-interval", "Interval between update notifications").Default("1h").Hidden().Envar("KOPIA_UPDATE_NOTIFY_INTERVAL").Duration()
)
const (
latestReleaseGitHubURL = "https://api.github.com/repos/kopia/kopia/releases/latest"
checksumsURL = "https://github.com/kopia/kopia/releases/download/%v/checksums.txt.sig"
autoUpdateNotice = `
NOTICE: Kopia will check for updates on GitHub every 7 days, starting 24 hours after first use.
To disable this behavior, set environment variable ` + checkForUpdatesEnvar + `=false
Alternatively you can remove the file "%v".
`
updateAvailableNotice = `
Upgrade of Kopia from %v to %v is available.
Visit https://github.com/kopia/kopia/releases/latest to download it.
`
)
var noticeColor = color.New(color.FgCyan)
// updateState is persisted in a JSON file and used to determine when to check for updates
// and whether to notify user about updates.
type updateState struct {
NextCheckTime time.Time `json:"nextCheckTimestamp"`
NextNotifyTime time.Time `json:"nextNotifyTimestamp"`
AvailableVersion string `json:"availableVersion"`
}
// updateStateFilename returns the name of the update state.
func updateStateFilename() string {
return filepath.Join(repositoryConfigFileName() + ".update-info.json")
}
// writeUpdateState writes update state file.
func writeUpdateState(us *updateState) error {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(us); err != nil {
return errors.Wrap(err, "unable to marshal JSON")
}
return atomic.WriteFile(updateStateFilename(), &buf)
}
func removeUpdateState() {
os.Remove(updateStateFilename()) // nolint:errcheck
}
// getUpdateState reads the update state file if available.
func getUpdateState() (*updateState, error) {
f, err := os.Open(updateStateFilename())
if err != nil {
return nil, errors.Wrap(err, "unable to open update state file")
}
defer f.Close() //nolint:errcheck,gosec
us := &updateState{}
if err := json.NewDecoder(f).Decode(us); err != nil {
return nil, errors.Wrap(err, "unable to parse update state")
}
return us, nil
}
// maybeInitializeUpdateCheck optionally writes update state file with initial update
// set 24 hours from now.
func maybeInitializeUpdateCheck(ctx context.Context) {
if connectCheckForUpdates {
us := &updateState{
NextCheckTime: clock.Now().Add(*initialUpdateCheckDelay),
NextNotifyTime: clock.Now().Add(*initialUpdateCheckDelay),
}
if err := writeUpdateState(us); err != nil {
log(ctx).Debugf("error initializing update state")
return
}
noticeColor.Fprintf(os.Stderr, autoUpdateNotice, updateStateFilename()) //nolint:errcheck
} else {
removeUpdateState()
}
}
// getLatestReleaseNameFromGitHub gets the name of the release marked 'latest' on GitHub.
func getLatestReleaseNameFromGitHub(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, githubTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", latestReleaseGitHubURL, nil)
if err != nil {
return "", errors.Wrap(err, "unable to get latest release from github")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", errors.Wrap(err, "unable to get latest release from github")
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return "", errors.Errorf("invalid status code from GitHub: %v", resp.StatusCode)
}
var responseObject struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&responseObject); err != nil {
return "", errors.Wrap(err, "invalid Github API response")
}
return responseObject.Name, nil
}
// verifyGitHubReleaseIsComplete downloads checksum file to verify that the release is complete.
func verifyGitHubReleaseIsComplete(ctx context.Context, releaseName string) error {
ctx, cancel := context.WithTimeout(ctx, githubTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(checksumsURL, releaseName), nil)
if err != nil {
return errors.Wrap(err, "unable to download releases checksum")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "unable to download releases checksum")
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode != http.StatusOK {
return errors.Errorf("invalid status code from GitHub: %v", resp.StatusCode)
}
return nil
}
func maybeCheckForUpdates(ctx context.Context) (string, error) {
if v := os.Getenv(checkForUpdatesEnvar); v != "" {
// see if environment variable is set to false.
if b, err := strconv.ParseBool(v); err == nil && !b {
return "", errors.Errorf("update check disabled")
}
}
us, err := getUpdateState()
if err != nil {
return "", err
}
if err := maybeCheckGithub(ctx, us); err != nil {
return "", errors.Wrap(err, "error checking github")
}
log(ctx).Debugf("build version %v, available %v", ensureVPrefix(repo.BuildVersion), ensureVPrefix(us.AvailableVersion))
if us.AvailableVersion == "" || semver.Compare(ensureVPrefix(repo.BuildVersion), ensureVPrefix(us.AvailableVersion)) >= 0 {
// no new version available
return "", nil
}
if clock.Now().After(us.NextNotifyTime) {
us.NextNotifyTime = clock.Now().Add(*updateAvailableNotifyInterval)
if err := writeUpdateState(us); err != nil {
return "", errors.Wrap(err, "unable to write update state")
}
return us.AvailableVersion, nil
}
// no time to notify yet
return "", nil
}
func maybeCheckGithub(ctx context.Context, us *updateState) error {
if !clock.Now().After(us.NextCheckTime) {
return nil
}
log(ctx).Debugf("time for next update check has been reached")
// before we check for update, write update state file again, so if this fails
// we won't bother GitHub for a while
us.NextCheckTime = clock.Now().Add(*updateCheckInterval)
if err := writeUpdateState(us); err != nil {
return errors.Wrap(err, "unable to write update state")
}
newAvailableVersion, err := getLatestReleaseNameFromGitHub(ctx)
if err != nil {
return errors.Wrap(err, "update to get latest release from GitHub")
}
log(ctx).Debugf("latest version on github: %v previous %v", newAvailableVersion, us.AvailableVersion)
// we got updated version from GitHub, write it in a state file again
if newAvailableVersion != us.AvailableVersion {
if err = verifyGitHubReleaseIsComplete(ctx, newAvailableVersion); err != nil {
return errors.Wrap(err, "unable to validate GitHub release")
}
us.AvailableVersion = newAvailableVersion
if err := writeUpdateState(us); err != nil {
return errors.Wrap(err, "unable to write update state")
}
}
return nil
}
// maybePrintUpdateNotification prints notification about available version.
func maybePrintUpdateNotification(ctx context.Context) {
updatedVersion, err := maybeCheckForUpdates(ctx)
if err != nil {
log(ctx).Debugf("unable to check for updates: %v", err)
return
}
if updatedVersion == "" {
log(ctx).Debugf("no updated version available")
return
}
noticeColor.Fprintf(os.Stderr, updateAvailableNotice, ensureVPrefix(repo.BuildVersion), ensureVPrefix(updatedVersion)) //nolint:errcheck
}
func ensureVPrefix(s string) string {
if strings.HasPrefix(s, "v") {
return s
}
return "v" + s
}