Files
kopia/cli/update_check.go
Jarek Kowalski f4347886b8 logging: simplified log levels (#954)
Removed Warning, Notify and Fatal:

* `Warning` => `Error` or `Info`
* `Notify` => `Info`
* `Fatal` was never used.

Note that --log-level=warning is still supported for backwards
compatibility, but it is the same as --log-level=error.

Co-authored-by: Julio López <julio+gh@kasten.io>
2021-04-09 07:27:35 -07:00

270 lines
8.0 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/pkg/errors"
"golang.org/x/mod/semver"
"github.com/kopia/kopia/internal/atomicfile"
"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 (
latestReleaseGitHubURLFormat = "https://api.github.com/repos/%v/releases/latest"
checksumsURLFormat = "https://github.com/%v/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".
`
updateAvailableNoticeFormat = `
Upgrade of Kopia from %v to %v is available.
Visit https://github.com/%v/releases/latest to download it.
`
)
// 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 atomicfile.Write(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
}
log(ctx).Infof(autoUpdateNotice, updateStateFilename())
} 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", fmt.Sprintf(latestReleaseGitHubURLFormat, repo.BuildGitHubRepo), 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(checksumsURLFormat, repo.BuildGitHubRepo, 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) {
if repo.BuildGitHubRepo == "" {
// not built from GH repo.
return
}
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
}
log(ctx).Infof(updateAvailableNoticeFormat, ensureVPrefix(repo.BuildVersion), ensureVPrefix(updatedVersion), repo.BuildGitHubRepo)
}
func ensureVPrefix(s string) string {
if strings.HasPrefix(s, "v") {
return s
}
return "v" + s
}