blob: 11e459f752bba60afcf6011c55ac382d0b7bf97c [file]
// Copyright 2016 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package selfupdate
import (
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"go.fuchsia.dev/jiri/gitutil"
"go.fuchsia.dev/jiri/log"
"go.fuchsia.dev/jiri/osutil"
"go.fuchsia.dev/jiri/version"
)
const (
JiriRepository = "https://fuchsia.googlesource.com/jiri"
JiriCIPDEndPoint = "https://chrome-infra-packages.appspot.com/dl/fuchsia/tools/jiri"
)
var (
updateTestVersionErr = fmt.Errorf("jiri has test version")
updateVersionErr = fmt.Errorf("jiri is already at latest version")
updateNotAvailableErr = fmt.Errorf("latest version of jiri not available")
)
// Update checks whether a new version of Jiri is available and if so,
// it will download it and replace the current version with the new one.
func Update(ctx context.Context, force bool) error {
if !force && version.GitCommit == "" {
return updateTestVersionErr
}
commit, err := getCurrentCommit(ctx, JiriRepository)
if err != nil {
return err
}
if force || commit != version.GitCommit {
// CIPD HTTP endpoint does not allow HTTP HEAD.
// Download the Jiri archive directly.
b, err := downloadBinary(ctx, JiriCIPDEndPoint, commit)
if err == updateNotAvailableErr {
return err
}
if err != nil {
return fmt.Errorf("cannot download latest jiri binary, %s", err)
}
unarchivedBinary, err := unarchiveJiri(b)
if err != nil {
return err
}
path, err := osutil.Executable()
if err != nil {
return fmt.Errorf("cannot get executable path, %s", err)
}
return updateExecutable(path, unarchivedBinary)
}
return updateVersionErr
}
func unarchiveJiri(b []byte) ([]byte, error) {
zipReader, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
if err != nil {
return nil, fmt.Errorf("Failed to read jiri archive: %v", err)
}
for _, file := range zipReader.File {
if file.Name == "jiri" {
fileReader, err := file.Open()
defer fileReader.Close()
if err != nil {
return nil, fmt.Errorf("Failed to read jiri archive: %v", err)
}
return io.ReadAll(fileReader)
}
}
return nil, fmt.Errorf("Cannot find jiri in update archive")
}
func UpdateAndExecute(ctx context.Context, force bool) error {
// Capture executable path before it is replaced in Update func
path, err := osutil.Executable()
if err != nil {
return fmt.Errorf("cannot get executable path, %s", err)
}
if err := Update(ctx, force); err != nil {
if err != updateNotAvailableErr && err != updateVersionErr &&
err != updateTestVersionErr {
return err
}
log.Debugf(ctx, "selfupdate not possible: %s", err)
// No update available, so continue normal execution with the current
// executable instead of doing a redundant exec of the same executable.
return nil
}
args := []string{}
for _, a := range os.Args {
if !strings.HasPrefix(a, "-force-autoupdate") {
args = append(args, a)
}
}
// Run the updated version.
if err = syscall.Exec(path, args, os.Environ()); err != nil {
return fmt.Errorf("cannot execute %s: %s", path, err)
}
return nil
}
func getCurrentCommit(ctx context.Context, repository string) (string, error) {
out, err := gitutil.New(ctx).LsRemote(ctx, repository, "refs/heads/main")
if err != nil {
return "", err
}
return strings.Fields(out)[0], nil
}
func downloadBinary(ctx context.Context, endpoint, version string) ([]byte, error) {
os := runtime.GOOS
if os == "darwin" {
os = "mac"
}
url := fmt.Sprintf("%s/%s-%s/+/git_revision:%s", endpoint, os, runtime.GOARCH, version)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, updateNotAvailableErr
}
if err := checkHTTPResponseErrors(res); err != nil {
return nil, err
}
bytes, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return bytes, nil
}
func checkHTTPResponseErrors(res *http.Response) error {
if res.StatusCode == http.StatusOK {
return nil
}
// don't read the full body to protect against large errors
body, err := io.ReadAll(io.LimitReader(res.Body, 1024))
if err != nil {
return fmt.Errorf("HTTP request failed: %s (failed to read body: %w)", res.Status, err)
}
trimmed := strings.TrimSpace(string(body))
if trimmed != "" {
return fmt.Errorf("HTTP request failed: %s: %s", res.Status, maybeTruncateString(trimmed, 200))
}
return fmt.Errorf("HTTP request failed: %s", res.Status)
}
func maybeTruncateString(s string, maxLen int) string {
count := 0
for i := range s {
if count == maxLen {
return s[:i] + "..."
}
count++
}
return s
}
func updateExecutable(path string, b []byte) error {
fi, err := os.Stat(path)
if err != nil {
return err
}
dir := filepath.Dir(path)
// Write the new version to a file.
newfile, err := os.CreateTemp(dir, "jiri")
if err != nil {
return err
}
if _, err := newfile.Write(b); err != nil {
return err
}
if err := newfile.Chmod(fi.Mode()); err != nil {
return err
}
if err := newfile.Close(); err != nil {
return err
}
// Backup the existing version.
oldfile, err := os.CreateTemp(dir, "jiri")
if err != nil {
return err
}
defer os.Remove(oldfile.Name())
if err := oldfile.Close(); err != nil {
return err
}
err = osutil.Rename(path, oldfile.Name())
if err != nil {
return err
}
// Replace the existing version.
err = osutil.Rename(newfile.Name(), path)
if err != nil {
// Try to rollback the change in case of error.
rerr := osutil.Rename(oldfile.Name(), path)
if rerr != nil {
return rerr
}
return err
}
return nil
}