diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61b3280..67c36d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Run tests run: make test + - name: Test release installer + run: sudo env INSTALL_DIR=/usr/local/bin sh scripts/install-test.sh + lint: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c057c..7c4741b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,14 @@ All notable changes to the FlatRun CLI are documented in this file. -## [0.4.0-beta.4] - 2026-08-20 +## [0.3.1] - 2026-08-21 ### Added - Push a local directory to a deployment in one request, with optional destination cleanup - File operations from the agent are available through the generated command catalogue +- Update the installed CLI in place after verifying its published SHA-256 checksum +- Install verified Linux and macOS releases with one shell command ## [0.3.0] - 2026-08-11 diff --git a/Makefile b/Makefile index ac428c5..aa2cfe9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help deps build test fmt fmt-check vet lint lint-install test-coverage clean qa +.PHONY: help deps build test test-installer fmt fmt-check vet lint lint-install test-coverage clean qa BINARY_NAME=flatrun VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") @@ -12,6 +12,7 @@ help: @echo "make deps - Download dependencies" @echo "make build - Build CLI binary" @echo "make test - Run unit tests" + @echo "make test-installer - Install and run a pinned published release" @echo "make test-coverage - Run tests with coverage report" @echo "make fmt - Format code with gofmt" @echo "make fmt-check - Check gofmt formatting" @@ -30,6 +31,9 @@ build: test: go test ./... +test-installer: + sh scripts/install-test.sh + test-coverage: go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out -o coverage.html diff --git a/README.md b/README.md index c7eb8a7..9d49dcc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,28 @@ `flatrun` is the command-line interface for FlatRun. It is intended to be the automation and operator surface for FlatRun in the same way cloud CLIs wrap a larger platform API. -## Install from source +## Install + +Install the latest release on Linux or macOS: + +```bash +curl -fsSL https://raw.githubusercontent.com/flatrun/cli/main/scripts/install.sh | sudo sh +``` + +The installer detects amd64 or arm64, verifies the published SHA-256 checksum, and installs +`flatrun` in `/usr/local/bin`. Pin a release with `FLATRUN_VERSION`, or choose another destination +with `INSTALL_DIR`. + +Update an installed release in place: + +```bash +flatrun update +``` + +Use `sudo flatrun update` when the binary is installed in a system directory such as +`/usr/local/bin`. Check for a newer version without installing it with `flatrun update --check`. + +## Build from source ```bash make build diff --git a/VERSION b/VERSION index bfa2c56..9e11b32 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0-beta.4 +0.3.1 diff --git a/internal/command/root.go b/internal/command/root.go index ebdb90e..22b1fa9 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -16,6 +16,7 @@ import ( "github.com/flatrun/cli/internal/config" "github.com/flatrun/cli/internal/flatrun" + cliupdate "github.com/flatrun/cli/internal/update" "gopkg.in/yaml.v3" ) @@ -194,6 +195,8 @@ func Run(args []string, stdout, stderr io.Writer) int { case "version", "--version": _, _ = fmt.Fprintf(stdout, "%s\nbuild_time=%s\ngit_commit=%s\n", Version, BuildTime, GitCommit) return 0 + case "update": + return runUpdate(args[1:], stdout, stderr) case "configure": return runConfigure(args[1:], stdout, stderr) case "health": @@ -245,10 +248,43 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, " health Check that the agent is reachable") _, _ = fmt.Fprintln(w, " api Call any endpoint directly") _, _ = fmt.Fprintln(w, " version Print CLI version") + _, _ = fmt.Fprintln(w, " update Update the CLI to the latest release") _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, "Add --json to any command for the raw answer, or to a listing for every command.") } +func runUpdate(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs.SetOutput(stderr) + check := fs.Bool("check", false, "Check for an update without installing it") + if code, ok := parseFlagSet(fs, args); !ok { + return code + } + if fs.NArg() != 0 { + _, _ = fmt.Fprintln(stderr, "Usage: flatrun update [--check]") + return 2 + } + result, err := cliupdate.Run(context.Background(), Version, *check) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Update failed: %v\n", err) + return 1 + } + if result.Latest == result.Current { + _, _ = fmt.Fprintf(stdout, "FlatRun CLI %s is up to date.\n", result.Current) + return 0 + } + if *check { + _, _ = fmt.Fprintf(stdout, "FlatRun CLI %s is available. Current version: %s.\n", result.Latest, result.Current) + return 0 + } + if result.Updated { + _, _ = fmt.Fprintf(stdout, "Updated FlatRun CLI from %s to %s.\n", result.Current, result.Latest) + return 0 + } + _, _ = fmt.Fprintf(stdout, "FlatRun CLI %s is up to date.\n", result.Current) + return 0 +} + func wrapNames(names []string, width int) []string { var lines []string current := "" diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 76562f8..1e35076 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -1100,6 +1100,34 @@ func TestVersionPrintsBuildMetadata(t *testing.T) { } } +func TestUpdateRejectsDevelopmentBuild(t *testing.T) { + oldVersion := Version + Version = "dev" + t.Cleanup(func() { Version = oldVersion }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"update"}, &stdout, &stderr) + if code != 1 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "development builds cannot be updated automatically") { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } +} + +func TestUpdateRejectsArguments(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Run([]string{"update", "unexpected"}, &stdout, &stderr) + if code != 2 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "Usage: flatrun update [--check]") { + t.Fatalf("unexpected stderr: %s", stderr.String()) + } +} + func TestPrintResponsePrintsTopLevelArrayWhenNoFallback(t *testing.T) { var stdout bytes.Buffer diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..e5cb15a --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,335 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const maxDownloadSize = 100 << 20 + +var ( + releasesURL = "https://api.github.com/repos/flatrun/cli/releases" + executable = os.Executable +) + +type release struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Assets []asset `json:"assets"` +} + +type asset struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` +} + +type Result struct { + Current string + Latest string + Updated bool +} + +func Run(ctx context.Context, current string, checkOnly bool) (Result, error) { + result := Result{Current: current} + if current == "dev" { + return result, errors.New("development builds cannot be updated automatically") + } + + rel, err := latest(ctx, strings.Contains(current, "-")) + if err != nil { + return result, err + } + result.Latest = strings.TrimPrefix(rel.TagName, "v") + isNewer, err := newer(result.Latest, current) + if err != nil { + return result, err + } + if !isNewer || checkOnly { + return result, nil + } + + archiveName := fmt.Sprintf("flatrun-%s-%s-%s.tar.gz", result.Latest, runtime.GOOS, runtime.GOARCH) + archiveAsset, ok := findAsset(rel.Assets, archiveName) + if !ok { + return result, fmt.Errorf("release %s does not provide %s/%s", result.Latest, runtime.GOOS, runtime.GOARCH) + } + checksumsAsset, ok := findAsset(rel.Assets, "checksums.txt") + if !ok { + return result, errors.New("release does not provide checksums.txt") + } + + archive, err := download(ctx, archiveAsset.URL) + if err != nil { + return result, fmt.Errorf("download release: %w", err) + } + checksums, err := download(ctx, checksumsAsset.URL) + if err != nil { + return result, fmt.Errorf("download checksums: %w", err) + } + if err := verify(archiveName, archive, string(checksums)); err != nil { + return result, err + } + binaryName := fmt.Sprintf("flatrun-%s-%s-%s", result.Latest, runtime.GOOS, runtime.GOARCH) + binary, err := extract(archive, binaryName) + if err != nil { + return result, err + } + if err := replace(binary); err != nil { + return result, err + } + result.Updated = true + return result, nil +} + +func latest(ctx context.Context, includePrerelease bool) (release, error) { + url := releasesURL + "/latest" + if includePrerelease { + url = releasesURL + "?per_page=20" + } + body, err := download(ctx, url) + if err != nil { + return release{}, fmt.Errorf("check latest release: %w", err) + } + if !includePrerelease { + var rel release + if err := json.Unmarshal(body, &rel); err != nil { + return release{}, fmt.Errorf("read latest release: %w", err) + } + return rel, nil + } + var releases []release + if err := json.Unmarshal(body, &releases); err != nil { + return release{}, fmt.Errorf("read releases: %w", err) + } + for _, rel := range releases { + if !rel.Draft { + return rel, nil + } + } + return release{}, errors.New("no published release found") +} + +func download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "flatrun-cli") + resp, err := (&http.Client{Timeout: 2 * time.Minute}).Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned %s", url, resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxDownloadSize { + return nil, errors.New("download exceeds 100 MiB limit") + } + return data, nil +} + +func findAsset(assets []asset, name string) (asset, bool) { + for _, candidate := range assets { + if candidate.Name == name { + return candidate, true + } + } + return asset{}, false +} + +func verify(name string, archive []byte, checksums string) error { + want := "" + for _, line := range strings.Split(checksums, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + want = fields[0] + break + } + } + if want == "" { + return fmt.Errorf("checksum for %s is missing", name) + } + sum := sha256.Sum256(archive) + if !strings.EqualFold(hex.EncodeToString(sum[:]), want) { + return errors.New("release checksum does not match") + } + return nil +} + +func extract(archive []byte, binaryName string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("open release archive: %w", err) + } + defer func() { _ = gz.Close() }() + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read release archive: %w", err) + } + if header.Name != binaryName || header.Typeflag != tar.TypeReg { + continue + } + if header.Size < 0 || header.Size > maxDownloadSize { + return nil, errors.New("release binary exceeds 100 MiB limit") + } + binary, err := io.ReadAll(io.LimitReader(tr, header.Size)) + if err != nil { + return nil, fmt.Errorf("read release binary: %w", err) + } + if int64(len(binary)) != header.Size { + return nil, errors.New("release binary is truncated") + } + return binary, nil + } + return nil, fmt.Errorf("release archive does not contain %s", binaryName) +} + +func replace(binary []byte) error { + path, err := executable() + if err != nil { + return fmt.Errorf("locate current executable: %w", err) + } + path, err = filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve current executable: %w", err) + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("inspect current executable: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".flatrun-update-*") + if err != nil { + return fmt.Errorf("prepare update beside %s: %w", path, err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := tmp.Write(binary); err != nil { + _ = tmp.Close() + return fmt.Errorf("write update: %w", err) + } + if err := tmp.Chmod(info.Mode().Perm()); err != nil { + _ = tmp.Close() + return fmt.Errorf("set update permissions: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync update: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close update: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replace %s: %w", path, err) + } + return nil +} + +func newer(candidate, current string) (bool, error) { + candidateParts, err := parseVersion(candidate) + if err != nil { + return false, fmt.Errorf("latest release: %w", err) + } + currentParts, err := parseVersion(current) + if err != nil { + return false, fmt.Errorf("current version: %w", err) + } + for i := 0; i < 3; i++ { + if candidateParts.numbers[i] != currentParts.numbers[i] { + return candidateParts.numbers[i] > currentParts.numbers[i], nil + } + } + if candidateParts.pre == currentParts.pre { + return false, nil + } + if candidateParts.pre == "" { + return true, nil + } + if currentParts.pre == "" { + return false, nil + } + return comparePrerelease(candidateParts.pre, currentParts.pre) > 0, nil +} + +type versionParts struct { + numbers [3]int + pre string +} + +func parseVersion(value string) (versionParts, error) { + value = strings.TrimPrefix(value, "v") + value = strings.SplitN(value, "+", 2)[0] + parts := strings.SplitN(value, "-", 2) + core := strings.Split(parts[0], ".") + if len(core) != 3 { + return versionParts{}, fmt.Errorf("invalid version %q", value) + } + parsed := versionParts{} + for i, part := range core { + n, err := strconv.Atoi(part) + if err != nil || n < 0 { + return versionParts{}, fmt.Errorf("invalid version %q", value) + } + parsed.numbers[i] = n + } + if len(parts) == 2 { + parsed.pre = parts[1] + } + return parsed, nil +} + +func comparePrerelease(left, right string) int { + l := strings.FieldsFunc(left, func(r rune) bool { return r == '.' || r == '-' }) + r := strings.FieldsFunc(right, func(r rune) bool { return r == '.' || r == '-' }) + for i := 0; i < len(l) && i < len(r); i++ { + ln, le := strconv.Atoi(l[i]) + rn, re := strconv.Atoi(r[i]) + if le == nil && re == nil && ln != rn { + if ln > rn { + return 1 + } + return -1 + } + if l[i] != r[i] { + if le == nil { + return -1 + } + if re == nil || l[i] > r[i] { + return 1 + } + return -1 + } + } + if len(l) > len(r) { + return 1 + } + if len(l) < len(r) { + return -1 + } + return 0 +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..9a90ee6 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,120 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestNewer(t *testing.T) { + tests := []struct { + candidate string + current string + want bool + }{ + {"0.4.0", "0.3.0", true}, + {"0.4.0-beta.5", "0.4.0-beta.4", true}, + {"0.4.0-beta.4", "0.4.0-beta.4", false}, + {"0.3.0", "0.4.0-beta.4", false}, + {"0.4.0-beta.4", "0.4.0", false}, + {"0.4.0-beta.10", "0.4.0-beta.4", true}, + } + for _, tt := range tests { + t.Run(tt.candidate+"_from_"+tt.current, func(t *testing.T) { + got, err := newer(tt.candidate, tt.current) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("newer(%q, %q) = %v, want %v", tt.candidate, tt.current, got, tt.want) + } + }) + } +} + +func TestRunDownloadsVerifiesAndReplacesExecutable(t *testing.T) { + archiveName := fmt.Sprintf("flatrun-0.4.0-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + binaryName := fmt.Sprintf("flatrun-0.4.0-%s-%s", runtime.GOOS, runtime.GOARCH) + archive := testArchive(t, binaryName, []byte("new binary")) + sum := sha256.Sum256(archive) + checksums := hex.EncodeToString(sum[:]) + " " + archiveName + "\n" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/releases/latest": + _ = json.NewEncoder(w).Encode(release{ + TagName: "v0.4.0", + Assets: []asset{ + {Name: archiveName, URL: "http://" + r.Host + "/archive"}, + {Name: "checksums.txt", URL: "http://" + r.Host + "/checksums"}, + }, + }) + case "/archive": + _, _ = w.Write(archive) + case "/checksums": + _, _ = w.Write([]byte(checksums)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + oldReleasesURL := releasesURL + oldExecutable := executable + releasesURL = server.URL + "/releases" + path := filepath.Join(t.TempDir(), "flatrun") + if err := os.WriteFile(path, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + executable = func() (string, error) { return path, nil } + t.Cleanup(func() { + releasesURL = oldReleasesURL + executable = oldExecutable + }) + + result, err := Run(context.Background(), "0.3.0", false) + if err != nil { + t.Fatal(err) + } + if !result.Updated || result.Latest != "0.4.0" { + t.Fatalf("result = %+v", result) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "new binary" { + t.Fatalf("executable = %q", got) + } +} + +func testArchive(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buffer bytes.Buffer + gz := gzip.NewWriter(&buffer) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/scripts/install-test.sh b/scripts/install-test.sh new file mode 100755 index 0000000..f010078 --- /dev/null +++ b/scripts/install-test.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -eu + +version="0.3.0" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +install_dir="${INSTALL_DIR:-${tmp_dir}/bin}" + +FLATRUN_VERSION="$version" INSTALL_DIR="$install_dir" sh "$(dirname "$0")/install.sh" +output=$(PATH="${install_dir}:$PATH" flatrun version) + +[ "$(printf '%s\n' "$output" | sed -n '1p')" = "$version" ] +printf '%s\n' "$output" | grep '^build_time=' >/dev/null +printf '%s\n' "$output" | grep '^git_commit=' >/dev/null + +printf 'CLI installer test passed for release %s\n' "$version" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..876bcf7 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +set -eu + +REPOSITORY="flatrun/cli" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +RELEASE_API_URL="${FLATRUN_RELEASE_API_URL:-https://api.github.com/repos/${REPOSITORY}/releases/latest}" +RELEASE_BASE_URL="${FLATRUN_RELEASE_BASE_URL:-https://github.com/${REPOSITORY}/releases/download}" + +fail() { + printf 'FlatRun CLI installation failed: %s\n' "$1" >&2 + exit 1 +} + +command -v curl >/dev/null 2>&1 || fail "curl is required" +command -v tar >/dev/null 2>&1 || fail "tar is required" + +if [ -z "${FLATRUN_VERSION:-}" ]; then + FLATRUN_VERSION=$(curl -fsSL "$RELEASE_API_URL" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1) +fi +[ -n "$FLATRUN_VERSION" ] || fail "could not determine the latest release" +case "$FLATRUN_VERSION" in + *[!0-9A-Za-z.+-]*) fail "release version contains unsupported characters" ;; +esac + +case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) fail "supported operating systems are Linux and macOS" ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) fail "supported architectures are amd64 and arm64" ;; +esac + +archive="flatrun-${FLATRUN_VERSION}-${os}-${arch}.tar.gz" +binary="flatrun-${FLATRUN_VERSION}-${os}-${arch}" +release_url="${RELEASE_BASE_URL}/v${FLATRUN_VERSION}" +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM + +curl -fsSL "${release_url}/${archive}" -o "${tmp_dir}/${archive}" +curl -fsSL "${release_url}/checksums.txt" -o "${tmp_dir}/checksums.txt" + +expected=$(awk -v name="$archive" '$2 == name { print $1 }' "${tmp_dir}/checksums.txt") +[ -n "$expected" ] || fail "release checksum is missing" +if command -v sha256sum >/dev/null 2>&1; then + calculated=$(sha256sum "${tmp_dir}/${archive}" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + calculated=$(shasum -a 256 "${tmp_dir}/${archive}" | awk '{ print $1 }') +else + fail "sha256sum or shasum is required" +fi +[ "$calculated" = "$expected" ] || fail "checksum verification failed" + +tar -xzf "${tmp_dir}/${archive}" -C "$tmp_dir" "$binary" +mkdir -p "$INSTALL_DIR" +install -m 0755 "${tmp_dir}/${binary}" "${INSTALL_DIR}/flatrun" + +printf 'FlatRun CLI %s installed at %s/flatrun\n' "$FLATRUN_VERSION" "$INSTALL_DIR"