6e1a3987b7
This commit adds a command called `self-update` which downloads the latest released version of restic from GitHub and replacing the current binary with it. It does not rely on any external program (so it'll work everywhere), but still verifies the GPG signature using the embedded GPG public key. By default, the `self-update` command is hidden behind the `selfupdate` built tag, which is only set when restic is built using `build.go`. The reason for this is that downstream distributions will then not include the command by default, so users are encouraged to use the platform-specific distribution mechanism.
170 lines
4 KiB
Go
170 lines
4 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/net/context/ctxhttp"
|
|
)
|
|
|
|
// Release collects data about a single release on GitHub.
|
|
type Release struct {
|
|
Name string `json:"name"`
|
|
TagName string `json:"tag_name"`
|
|
Draft bool `json:"draft"`
|
|
PreRelease bool `json:"prerelease"`
|
|
PublishedAt time.Time `json:"published_at"`
|
|
Assets []Asset `json:"assets"`
|
|
|
|
Version string `json:"-"` // set manually in the code
|
|
}
|
|
|
|
// Asset is a file uploaded and attached to a release.
|
|
type Asset struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func (r Release) String() string {
|
|
return fmt.Sprintf("%v %v, %d assets",
|
|
r.TagName,
|
|
r.PublishedAt.Local().Format("2006-01-02 15:04:05"),
|
|
len(r.Assets))
|
|
}
|
|
|
|
const githubAPITimeout = 30 * time.Second
|
|
|
|
// githubError is returned by the GitHub API, e.g. for rate-limiting.
|
|
type githubError struct {
|
|
Message string
|
|
}
|
|
|
|
// GitHubLatestRelease uses the GitHub API to get information about the latest
|
|
// release of a repository.
|
|
func GitHubLatestRelease(ctx context.Context, owner, repo string) (Release, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, githubAPITimeout)
|
|
defer cancel()
|
|
|
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
|
|
// pin API version 3
|
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
|
|
|
res, err := ctxhttp.Do(ctx, http.DefaultClient, req)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
content := res.Header.Get("Content-Type")
|
|
if strings.Contains(content, "application/json") {
|
|
// try to decode error message
|
|
var msg githubError
|
|
jerr := json.NewDecoder(res.Body).Decode(&msg)
|
|
if jerr == nil {
|
|
return Release{}, fmt.Errorf("unexpected status %v (%v) returned, message:\n %v", res.StatusCode, res.Status, msg.Message)
|
|
}
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
return Release{}, fmt.Errorf("unexpected status %v (%v) returned", res.StatusCode, res.Status)
|
|
}
|
|
|
|
buf, err := ioutil.ReadAll(res.Body)
|
|
if err != nil {
|
|
_ = res.Body.Close()
|
|
return Release{}, err
|
|
}
|
|
|
|
err = res.Body.Close()
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
|
|
var release Release
|
|
err = json.Unmarshal(buf, &release)
|
|
if err != nil {
|
|
return Release{}, err
|
|
}
|
|
|
|
if release.TagName == "" {
|
|
return Release{}, errors.New("tag name for latest release is empty")
|
|
}
|
|
|
|
if !strings.HasPrefix(release.TagName, "v") {
|
|
return Release{}, errors.Errorf("tag name %q is invalid, does not start with 'v'", release.TagName)
|
|
}
|
|
|
|
release.Version = release.TagName[1:]
|
|
|
|
return release, nil
|
|
}
|
|
|
|
func getGithubData(ctx context.Context, url string) ([]byte, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, githubAPITimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// request binary data
|
|
req.Header.Set("Accept", "application/octet-stream")
|
|
|
|
res, err := ctxhttp.Do(ctx, http.DefaultClient, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("unexpected status %v (%v) returned", res.StatusCode, res.Status)
|
|
}
|
|
|
|
buf, err := ioutil.ReadAll(res.Body)
|
|
if err != nil {
|
|
_ = res.Body.Close()
|
|
return nil, err
|
|
}
|
|
|
|
err = res.Body.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
func getGithubDataFile(ctx context.Context, assets []Asset, suffix string, printf func(string, ...interface{})) (filename string, data []byte, err error) {
|
|
var url string
|
|
for _, a := range assets {
|
|
if strings.HasSuffix(a.Name, suffix) {
|
|
url = a.URL
|
|
filename = a.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
if url == "" {
|
|
return "", nil, fmt.Errorf("unable to find file with suffix %v", suffix)
|
|
}
|
|
|
|
printf("download %v\n", filename)
|
|
data, err = getGithubData(ctx, url)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return filename, data, nil
|
|
}
|