Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ git push origin v0.2.0

The Release workflow checks that the tag is on `main`, runs `make check`, builds the archives with `make release`, and creates the GitHub release with both archives and `checksums.txt`. A tag with a pre-release suffix, such as `v0.2.0-rc.1`, becomes a pre-release, so installed CLIs do not update to it.

`install.sh` and `fly update` install only a release that has `checksums.txt`. Releases before v0.2.0 have none, so push the tag right after the merge into `main`: until the release is published, `install.sh` from `main` stops.

### Dev pre-releases

To test a branch on real servers before it merges, publish a dev pre-release of its current commit:
Expand Down
14 changes: 10 additions & 4 deletions cmd/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"syscall"

"github.com/flywp/server-cli/internal/release"
"github.com/flywp/server-cli/internal/service"
Expand Down Expand Up @@ -37,7 +39,11 @@ command also restarts the agent, so that the agent runs the new binary.`,
return errors.New("the update command must be run as root, please run 'sudo fly update'")
}

update, err := release.CheckForUpdates(cmd.Context())
// Ctrl-C stops the download, and the partial download is removed.
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()

update, err := release.CheckForUpdates(ctx)
if err != nil {
return fmt.Errorf("checking for updates: %w", err)
}
Expand All @@ -48,7 +54,7 @@ command also restarts the agent, so that the agent runs the new binary.`,
fmt.Printf("This is not a release build (version %s). Latest release: %s\n", version.Version, latest)
case !update.Available:
fmt.Println("You are already running the latest version.")
return restartStaleAgent(cmd.Context())
return restartStaleAgent(ctx)
default:
fmt.Printf("New version available: %s\n", latest)
}
Expand All @@ -65,12 +71,12 @@ command also restarts the agent, so that the agent runs the new binary.`,
}

fmt.Println("Updating...")
if err := release.SelfUpdate(cmd.Context(), update.Release); err != nil {
if err := release.SelfUpdate(ctx, update.Release); err != nil {
return fmt.Errorf("updating: %w", err)
}
fmt.Printf("Updated to %s.\n", latest)

return restartAgent(cmd.Context())
return restartAgent(ctx)
},
}

Expand Down
22 changes: 22 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ download_release() {
info_msg "Download completed successfully."
}

# Examine the download with the checksum file of the release. The file has
# one line for each archive: "<sha256> <file name>".
verify_download() {
info_msg "Verifying the download with checksums.txt..."

CHECKSUMS_URL="https://github.com/flywp/server-cli/releases/download/${TAG_NAME}/checksums.txt"
if ! curl -fsSL -o "$TEMP_DIR/checksums.txt" "$CHECKSUMS_URL"; then
rm -rf "$TEMP_DIR"
error_exit "Failed to download checksums.txt of ${TAG_NAME}. The download cannot be checked, so it is not installed."
fi

if ! (cd "$TEMP_DIR" && grep " fly-${OS}-${ARCH}.tar.gz\$" checksums.txt | sha256sum -c --status -); then
rm -rf "$TEMP_DIR"
error_exit "The checksum of fly-${OS}-${ARCH}.tar.gz does not agree with checksums.txt. The download is not installed."
fi

info_msg "Checksum verified."
}

# Extract and install
install_binary() {
info_msg "Extracting $DOWNLOAD_FILE..."
Expand Down Expand Up @@ -215,6 +234,9 @@ main() {

# Download the release
download_release

# Examine the download
verify_download

# Install the binary
install_binary
Expand Down
80 changes: 80 additions & 0 deletions internal/release/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,83 @@ func TestRemoveTempKeepsYoungFiles(t *testing.T) {
t.Errorf("directory holds %v, want the young download and the binary only", names)
}
}

// testRelease serves a release with the archive and a checksum file, and
// returns its description.
func testRelease(t *testing.T, archive []byte, checksums string) *GithubRelease {
t.Helper()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/fly-linux-amd64.tar.gz":
_, _ = w.Write(archive)
case "/checksums.txt":
_, _ = w.Write([]byte(checksums))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)

rel := &GithubRelease{TagName: "v0.3.0"}
for _, name := range []string{"fly-linux-amd64.tar.gz", "checksums.txt"} {
if name == "checksums.txt" && checksums == "" {
continue
}
rel.Assets = append(rel.Assets, Asset{Name: name, BrowserDownloadURL: srv.URL + "/" + name})
}
return rel
}

func TestSelfUpdateChecksTheArchive(t *testing.T) {
data := archive(t, map[string]string{"fly-linux-amd64": "new"}).Bytes()
other := sum([]byte("other"))

tests := []struct {
name, checksums, want string
}{
{"checksum agrees", sum(data) + " fly-linux-arm64.tar.gz\n" + sum(data) + " fly-linux-amd64.tar.gz\n", ""},
{"binary mode mark", sum(data) + " *fly-linux-amd64.tar.gz\n", ""},
{"checksum does not agree", other + " fly-linux-amd64.tar.gz\n", "the sha256 of"},
{"no line for the archive", other + " fly-linux-arm64.tar.gz\n", "has no line for fly-linux-amd64.tar.gz"},
{"no checksum file", "", "has no checksums.txt"},
{"CRLF line ends", sum(data) + " fly-linux-amd64.tar.gz\r\n", ""},
{"upper case hex", strings.ToUpper(sum(data)) + " fly-linux-amd64.tar.gz\n", ""},
{"the same line two times", sum(data) + " fly-linux-amd64.tar.gz\n" + sum(data) + " fly-linux-amd64.tar.gz\n", ""},
{"two different sums", sum(data) + " fly-linux-amd64.tar.gz\n" + other + " fly-linux-amd64.tar.gz\n", "two different sums"},
{"a similar name", sum(data) + " fly-linux-amd64.tar.gz.sig\n", "has no line for fly-linux-amd64.tar.gz"},
{"empty checksum file", "\n", "has no line for fly-linux-amd64.tar.gz"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
exe := filepath.Join(dir, "fly")
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
t.Fatal(err)
}

err := selfUpdate(context.Background(), testRelease(t, data, tt.checksums), exe, "linux", "amd64")

got, _ := os.ReadFile(exe)
if tt.want == "" {
if err != nil || string(got) != "new" {
t.Fatalf("selfUpdate() = %v, binary %q; want the new binary", err, got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Errorf("the directory holds %d files after the update, want only the binary", len(entries))
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("selfUpdate() error = %v, want %q", err, tt.want)
}
if string(got) != "old" {
t.Errorf("binary = %q, want the old binary unchanged", got)
}
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
t.Errorf("the directory holds %d files, want only the binary", len(entries))
}
})
}
}
107 changes: 89 additions & 18 deletions internal/release/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strings"
"syscall"
"time"

Expand All @@ -32,12 +33,16 @@ var httpClient = &http.Client{Timeout: 60 * time.Second}
// maxBinarySize limits the size of the binary in a release archive.
const maxBinarySize = 200 << 20

// GithubRelease is a release in the GitHub API.
type GithubRelease struct {
TagName string `json:"tag_name"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
TagName string `json:"tag_name"`
Assets []Asset `json:"assets"`
}

// Asset is a file of a release.
type Asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}

// Update compares the latest release with the running version.
Expand Down Expand Up @@ -124,13 +129,17 @@ func get(ctx context.Context, url string) (*http.Response, error) {
return resp, nil
}

// SelfUpdate replaces the running binary with the binary from release.
func SelfUpdate(ctx context.Context, release *GithubRelease) error {
assetURL := assetURL(release, runtime.GOOS, runtime.GOARCH)
if assetURL == "" {
return fmt.Errorf("no suitable binary found for this system (OS: %s, ARCH: %s)", runtime.GOOS, runtime.GOARCH)
}
// ChecksumsAsset is the checksum file of each release: one line for each
// archive, "<sha256> <file name>" (the output of sha256sum).
const ChecksumsAsset = "checksums.txt"

// selfUpdateTimeout limits the download of an update.
const selfUpdateTimeout = 10 * time.Minute

// SelfUpdate replaces the running binary with the binary from release. It
// installs the archive only when its sha256 agrees with the checksum file of
// the release.
func SelfUpdate(ctx context.Context, release *GithubRelease) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("finding the current executable: %w", err)
Expand All @@ -140,13 +149,71 @@ func SelfUpdate(ctx context.Context, release *GithubRelease) error {
return fmt.Errorf("resolving symlinks: %w", err)
}

resp, err := get(ctx, assetURL)
return selfUpdate(ctx, release, exe, runtime.GOOS, runtime.GOARCH)
}

func selfUpdate(ctx context.Context, release *GithubRelease, exe, goos, goarch string) error {
ctx, cancel := context.WithTimeout(ctx, selfUpdateTimeout)
defer cancel()

archiveURL := assetURL(release, goos, goarch)
if archiveURL == "" {
return fmt.Errorf("no suitable binary found for this system (OS: %s, ARCH: %s)", goos, goarch)
}

name := BinaryName(goos, goarch)
sum, err := checksum(ctx, release, name+".tar.gz")
if err != nil {
return err
}

archive, err := Download(ctx, archiveURL, sum, filepath.Dir(exe))
if err != nil {
return err
}
defer func() { _ = os.Remove(archive) }()

return Install(archive, exe, name)
}

// checksum returns the sha256 of the release file name from the checksum
// file of the release.
func checksum(ctx context.Context, release *GithubRelease, name string) (string, error) {
url := asset(release, ChecksumsAsset)
if url == "" {
return "", fmt.Errorf("release %s has no %s, so its download cannot be checked", release.TagName, ChecksumsAsset)
}

resp, err := get(ctx, url)
if err != nil {
return fmt.Errorf("downloading update: %w", err)
return "", fmt.Errorf("downloading %s: %w", ChecksumsAsset, err)
}
defer func() { _ = resp.Body.Close() }()

return replaceBinary(exe, resp.Body, BinaryName(runtime.GOOS, runtime.GOARCH))
data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("downloading %s: %w", ChecksumsAsset, err)
}

// install.sh reads the file with the same rules. Two different sums for
// one file make the file not valid: it is not clear which one is correct.
var sum string
for line := range strings.Lines(string(data)) {
// sha256sum marks a file that it read in binary mode with "*".
fields := strings.Fields(line)
if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name {
continue
}
if sum != "" && !strings.EqualFold(sum, fields[0]) {
return "", fmt.Errorf("%s of release %s has two different sums for %s", ChecksumsAsset, release.TagName, name)
}
sum = fields[0]
}
if sum == "" {
return "", fmt.Errorf("%s of release %s has no line for %s", ChecksumsAsset, release.TagName, name)
}

return sum, nil
}

// BinaryName is the name of the binary in a release archive. Releases must
Expand All @@ -162,10 +229,14 @@ func assetURL(release *GithubRelease, goos, goarch string) string {
return ""
}

expectedName := BinaryName(goos, goarch) + ".tar.gz"
for _, asset := range release.Assets {
if asset.Name == expectedName {
return asset.BrowserDownloadURL
return asset(release, BinaryName(goos, goarch)+".tar.gz")
}

// asset returns the download URL of the release file name, or "".
func asset(release *GithubRelease, name string) string {
for _, a := range release.Assets {
if a.Name == name {
return a.BrowserDownloadURL
}
}

Expand Down
5 changes: 1 addition & 4 deletions internal/release/release_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,7 @@ func TestLatestReleaseErrors(t *testing.T) {
func TestAssetURL(t *testing.T) {
release := &GithubRelease{TagName: "v0.2.0"}
for _, name := range []string{"fly-linux-amd64.tar.gz", "fly-linux-arm64.tar.gz"} {
release.Assets = append(release.Assets, struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}{Name: name, BrowserDownloadURL: "https://example.com/" + name})
release.Assets = append(release.Assets, Asset{Name: name, BrowserDownloadURL: "https://example.com/" + name})
}

tests := []struct{ goos, goarch, want string }{
Expand Down
Loading