diff --git a/.github/workflows/release-cli.yaml b/.github/workflows/release-cli.yaml index b63f168..5129a40 100644 --- a/.github/workflows/release-cli.yaml +++ b/.github/workflows/release-cli.yaml @@ -23,8 +23,9 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version-file: cli/go.mod + go-version: '1.26.x' check-latest: true + cache-dependency-path: cli/go.sum - name: Install dependencies run: go mod download @@ -39,9 +40,20 @@ jobs: p12-password: ${{ secrets.P12_PASSWORD }} - name: Build and sign the binary - run: make build && ./scripts/sign.sh + run: | + if [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + VERSION="${GITHUB_REF_NAME}" + else + VERSION="v0.0.0-${GITHUB_SHA:0:12}" + fi + make build VERSION="${VERSION}" COMMIT="${GITHUB_SHA:0:12}" TEAM_ID="${APPLE_TEAM_ID}" + ./scripts/sign.sh env: SIGNING_CERTIFICATE_NAME: ${{ secrets.SIGNING_CERTIFICATE_NAME }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Verify Mach-O UUID + run: otool -l bin/rvmm | grep -q 'cmd LC_UUID' - name: Import installer signing certificate uses: apple-actions/import-codesign-certs@v3 @@ -67,5 +79,7 @@ jobs: if: github.event_name == 'release' uses: softprops/action-gh-release@v2 with: - files: cli/rvmm_macOS_arm64.pkg + files: | + cli/rvmm_macOS_arm64.pkg + cli/rvmm_macOS_arm64.pkg.sha256 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/upload-docs.yaml b/.github/workflows/upload-docs.yaml new file mode 100644 index 0000000..de8a4c1 --- /dev/null +++ b/.github/workflows/upload-docs.yaml @@ -0,0 +1,29 @@ +name: Upload docs to autopilot + +on: + push: + branches: ["main"] + paths: + - "docs/**" + - "scripts/upload_docs.py" + - ".github/workflows/upload-docs.yaml" + workflow_dispatch: + +concurrency: + group: upload-docs + cancel-in-progress: false + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Upload docs + env: + DOCS_ENDPOINT: https://autopilot.rxlab.app + DOCS_REPOSITORY_ID: rxtech-lab/macos-github-action-vm + DOCS_UPLOAD_TOKEN: ${{ secrets.DOCS_UPLOAD_TOKEN }} + run: python scripts/upload_docs.py diff --git a/.gitignore b/.gitignore index 0772845..39ee08a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ rvmm cli/bin/ *.pkg +*.pkg.sha256 *.yaml *.log diff --git a/README.md b/README.md index 5f76e5b..67cfbb9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A tool for managing macOS virtual machines as GitHub Actions self-hosted runners - **Log Monitoring**: Send VM logs to PostHog for centralized monitoring across multiple machines - **Interactive TUI**: User-friendly terminal interface for all operations - **Headless Mode**: Run in background via LaunchAgent/LaunchDaemon +- **Automatic Updates**: Install verified GitHub releases silently through a dedicated root updater ## Installation @@ -26,6 +27,10 @@ A tool for managing macOS virtual machines as GitHub Actions self-hosted runners Download the notarized `rvmm_macOS_arm64.pkg` installer from the [GitHub Releases](../../releases) page and install it. The binary is installed to `/usr/local/bin/rvmm`. +The package also installs `lab.rxtech.rvmm.updater`, a narrowly scoped root LaunchDaemon. The initial package installation requires administrator approval. After that, the updater checks for a new published GitHub release at boot and every six hours, verifies the SHA-256 checksum, Developer ID Installer signature, Apple team, and notarization, then installs it without prompting. + +Select **Update rvmm** in the TUI to request an immediate background check. Installing an update does not terminate the currently running RVMM process or active VM work; the new binary is used on the next safe service start. + ### Build from Source The CLI lives in the `cli/` directory: @@ -239,6 +244,7 @@ In PostHog, you can: - **cli/internal/monitor**: Log file monitoring with tail-follow logic - **cli/internal/posthog**: PostHog API client for log event capture - **cli/internal/tui**: Bubble Tea terminal UI + - **cli/internal/updater**: GitHub release discovery, verified downloads, updater state, and package installation - **cli/assets**: Embedded plist template and example config - **cli/scripts**: Build, signing, and notarization scripts for releases - **guest**: Example external Packer template for building the runner VM image @@ -248,7 +254,7 @@ In PostHog, you can: Releases are automated via GitHub Actions: 1. The "Create a new release" workflow (`create-release.yaml`) runs semantic-release to tag and create a GitHub release. -2. The "Release rvmm CLI" workflow (`release-cli.yaml`) triggers on the release event: it builds the CLI, signs the binary, packages it as a `.pkg` installer, notarizes it with Apple, and uploads `rvmm_macOS_arm64.pkg` to the GitHub release. +2. The "Release rvmm CLI" workflow (`release-cli.yaml`) triggers on the release event: it stamps the tag into the binary, signs it, verifies its Mach-O UUID, packages and notarizes it, and uploads `rvmm_macOS_arm64.pkg` plus its SHA-256 checksum. ## Daemon Files @@ -264,6 +270,15 @@ After installation, daemons create the following files: - Plist: `${daemon.plist_path}` with `.monitor` suffix - Logs: `${working_directory}/monitor_stdout.log`, `${working_directory}/monitor_stderr.log` +### Automatic Updater + +- Plist: `/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist` +- Status: `/Library/Application Support/RVMM/Updater/status.json` +- Requests: `/Library/Application Support/RVMM/Updater/requests/` +- Logs: `/Library/Logs/rvmm-updater.log` + +The updater is intentionally separate from the runner daemon. Only verified package installation runs as root; Tart and GitHub Actions runner operations continue under `daemon.user`. + ## Troubleshooting ### Runner Daemon Issues diff --git a/cli/Makefile b/cli/Makefile index f3707dd..bea91f8 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -6,11 +6,13 @@ BUILD_DIR := ./bin VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +TEAM_ID ?= LDFLAGS := -s -w \ - -X github.com/rxtech-lab/rvmm/cmd.Version=$(VERSION) \ - -X github.com/rxtech-lab/rvmm/cmd.Commit=$(COMMIT) \ - -X github.com/rxtech-lab/rvmm/cmd.BuildDate=$(BUILD_DATE) + -X github.com/rxtech-lab/rvmm/internal/buildinfo.Version=$(VERSION) \ + -X github.com/rxtech-lab/rvmm/internal/buildinfo.Commit=$(COMMIT) \ + -X github.com/rxtech-lab/rvmm/internal/buildinfo.BuildDate=$(BUILD_DATE) \ + -X github.com/rxtech-lab/rvmm/internal/buildinfo.TeamID=$(TEAM_ID) .PHONY: deps build build-all install package clean test @@ -52,6 +54,7 @@ clean: @echo "==> Cleaning..." rm -rf $(BUILD_DIR) rm -f $(BINARY_NAME)_macOS_*.pkg + rm -f $(BINARY_NAME)_macOS_*.pkg.sha256 @echo "==> Cleaned" test: diff --git a/cli/assets/lab.rxtech.rvmm.updater.plist b/cli/assets/lab.rxtech.rvmm.updater.plist new file mode 100644 index 0000000..9e23537 --- /dev/null +++ b/cli/assets/lab.rxtech.rvmm.updater.plist @@ -0,0 +1,30 @@ + + + + + Label + lab.rxtech.rvmm.updater + ProgramArguments + + /usr/local/bin/rvmm + update-worker + + RunAtLoad + + StartInterval + 21600 + QueueDirectories + + /Library/Application Support/RVMM/Updater/requests + + + ProcessType + Background + ThrottleInterval + 60 + StandardOutPath + /Library/Logs/rvmm-updater.log + StandardErrorPath + /Library/Logs/rvmm-updater.log + + diff --git a/cli/internal/buildinfo/buildinfo.go b/cli/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..5de223c --- /dev/null +++ b/cli/internal/buildinfo/buildinfo.go @@ -0,0 +1,9 @@ +package buildinfo + +// These values are replaced by linker flags in release builds. +var ( + Version = "dev" + Commit = "none" + BuildDate = "unknown" + TeamID = "" +) diff --git a/cli/internal/tui/app.go b/cli/internal/tui/app.go index 8e32570..0c2f440 100644 --- a/cli/internal/tui/app.go +++ b/cli/internal/tui/app.go @@ -17,6 +17,7 @@ import ( "github.com/rxtech-lab/rvmm/internal/daemon" "github.com/rxtech-lab/rvmm/internal/runner" "github.com/rxtech-lab/rvmm/internal/setup" + "github.com/rxtech-lab/rvmm/internal/updater" "go.uber.org/zap" ) @@ -48,6 +49,7 @@ const ( actionMonitorDaemonUninstall actionMonitorDaemonStatus actionViewLogs + actionUpdateRequest actionQuit ) @@ -81,7 +83,10 @@ type model struct { windowWidth int windowHeight int lastError string + lastMessage string lastLogLine string + updateStatus updater.Status + updaterReady bool } func Run() { @@ -134,11 +139,17 @@ func newModel() model { if logErr != nil { m.lastError = logErr.Error() } + if status, err := updater.ReadStatus(updater.StatusPath); err == nil { + m.updateStatus = status + m.updaterReady = true + } else if !errors.Is(err, os.ErrNotExist) { + m.updaterReady = true + } return m } func (m model) Init() tea.Cmd { - return tickLogTail(m.logPath) + return tea.Batch(tickLogTail(m.logPath), tickUpdaterStatus()) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -173,6 +184,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastLogLine = msg.line } return m, tickLogTail(m.logPath) + case updaterStatusMsg: + m.updateStatus = msg.status + m.updaterReady = msg.installed + return m, tickUpdaterStatus() case taskDoneMsg: m.busy = false m.busyLabel = "" @@ -182,8 +197,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.err != nil { m.lastError = msg.err.Error() + m.lastMessage = "" } else { m.lastError = "" + if msg.action == actionUpdateRequest { + m.lastMessage = "Update requested; installation will continue in the background." + } } return m, nil } @@ -464,6 +483,13 @@ func (m model) runListImagesCmd() tea.Cmd { } } +func (m model) runUpdateRequestCmd() tea.Cmd { + return func() tea.Msg { + err := updater.RequestUpdate(updater.RequestDirectory) + return taskDoneMsg{action: actionUpdateRequest, err: err} + } +} + func (m model) runDaemonCmd(action actionType) tea.Cmd { return func() tea.Msg { cfg, err := loadConfig(m.configPath) diff --git a/cli/internal/tui/menu_root.go b/cli/internal/tui/menu_root.go index 7e535c4..5d2bd8c 100644 --- a/cli/internal/tui/menu_root.go +++ b/cli/internal/tui/menu_root.go @@ -10,6 +10,7 @@ func rootMenuEntries() []menuEntry { daemonMenuItem{}, monitorDaemonMenuItem{}, viewLogsMenuItem{}, + updateMenuItem{}, quitMenuItem{}, } } diff --git a/cli/internal/tui/menu_update.go b/cli/internal/tui/menu_update.go new file mode 100644 index 0000000..1eb67e0 --- /dev/null +++ b/cli/internal/tui/menu_update.go @@ -0,0 +1,19 @@ +package tui + +import tea "github.com/charmbracelet/bubbletea" + +type updateMenuItem struct{} + +func (updateMenuItem) Title() string { + return "Update rvmm" +} + +func (updateMenuItem) Description() string { + return "Request an immediate signed update in the background" +} + +func (updateMenuItem) OnSelect(m *model) (tea.Model, tea.Cmd) { + m.busy = true + m.busyLabel = "Request update" + return *m, tea.Batch(m.runUpdateRequestCmd(), m.spinner.Tick) +} diff --git a/cli/internal/tui/menu_update_test.go b/cli/internal/tui/menu_update_test.go new file mode 100644 index 0000000..b118165 --- /dev/null +++ b/cli/internal/tui/menu_update_test.go @@ -0,0 +1,25 @@ +package tui + +import ( + "testing" + + "github.com/rxtech-lab/rvmm/internal/updater" +) + +func TestRootMenuContainsUpdate(t *testing.T) { + t.Parallel() + for _, entry := range rootMenuEntries() { + if entry.Title() == "Update rvmm" { + return + } + } + t.Fatal("root menu does not contain Update rvmm") +} + +func TestFormatUpdaterStatus(t *testing.T) { + t.Parallel() + got := formatUpdaterStatus(updater.Status{State: "installed", LatestVersion: "v1.1.0", RestartNeeded: true}, true) + if got != "installed (v1.1.0) - restart pending" { + t.Fatalf("formatUpdaterStatus() = %q", got) + } +} diff --git a/cli/internal/tui/updater_status.go b/cli/internal/tui/updater_status.go new file mode 100644 index 0000000..1338c4b --- /dev/null +++ b/cli/internal/tui/updater_status.go @@ -0,0 +1,42 @@ +package tui + +import ( + "errors" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rxtech-lab/rvmm/internal/updater" +) + +type updaterStatusMsg struct { + status updater.Status + installed bool +} + +func tickUpdaterStatus() tea.Cmd { + return tea.Tick(2*time.Second, func(time.Time) tea.Msg { + status, err := updater.ReadStatus(updater.StatusPath) + if err != nil { + return updaterStatusMsg{installed: !errors.Is(err, os.ErrNotExist)} + } + return updaterStatusMsg{status: status, installed: true} + }) +} + +func formatUpdaterStatus(status updater.Status, installed bool) string { + if !installed { + return "not installed" + } + if status.State == "" { + return "waiting for first check" + } + result := status.State + if status.LatestVersion != "" { + result += " (" + status.LatestVersion + ")" + } + if status.RestartNeeded { + result += " - restart pending" + } + return result +} diff --git a/cli/internal/tui/views.go b/cli/internal/tui/views.go index 6c64480..7e0d874 100644 --- a/cli/internal/tui/views.go +++ b/cli/internal/tui/views.go @@ -34,13 +34,18 @@ func (m model) viewMenu() string { if m.lastError != "" { lastError = "\n\nLast error: " + m.lastError } + lastMessage := "" + if m.lastMessage != "" { + lastMessage = "\n\n" + m.lastMessage + } + updaterLine := "Updater: " + formatUpdaterStatus(m.updateStatus, m.updaterReady) header := headerView("RVMM", status, latest) tips := "enter=select s=stop runner q=quit" if len(m.menuStack) > 0 { tips = "enter=select esc=back s=stop runner q=quit" } - return fmt.Sprintf("%s\n\n%s\n\n%s%s\n\nTips: %s", header, m.menu.View(), logLine, lastError, tips) + return fmt.Sprintf("%s\n\n%s\n\n%s\n%s%s%s\n\nTips: %s", header, m.menu.View(), logLine, updaterLine, lastMessage, lastError, tips) } func (m model) viewConfig() string { diff --git a/cli/internal/updater/client.go b/cli/internal/updater/client.go new file mode 100644 index 0000000..77f3cc2 --- /dev/null +++ b/cli/internal/updater/client.go @@ -0,0 +1,254 @@ +package updater + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + DefaultReleaseURL = "https://api.github.com/repos/rxtech-lab/macos-github-action-vm/releases/latest" + PackageAssetName = "rvmm_macOS_arm64.pkg" + ChecksumAssetName = PackageAssetName + ".sha256" + maxReleaseBodySize = 2 << 20 + maxChecksumSize = 16 << 10 + maxPackageSize = 512 << 20 +) + +type ReleaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []ReleaseAsset `json:"assets"` +} + +type Update struct { + CurrentVersion string + LatestVersion string + Package ReleaseAsset + Checksum ReleaseAsset + Available bool +} + +type ReleaseClient interface { + Check(context.Context, string) (Update, error) + Download(context.Context, Update, string) error +} + +type Client struct { + ReleaseURL string + HTTPClient *http.Client + UserAgent string +} + +func NewClient(version string) *Client { + return &Client{ + ReleaseURL: DefaultReleaseURL, + HTTPClient: &http.Client{ + Timeout: 2 * time.Minute, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("too many redirects") + } + if req.URL.Scheme != "https" || !allowedDownloadHost(req.URL.Hostname()) { + return fmt.Errorf("refusing redirect to %s", req.URL.String()) + } + return nil + }, + }, + UserAgent: "rvmm/" + version, + } +} + +func (c *Client) Check(ctx context.Context, currentVersion string) (Update, error) { + if _, err := parseVersion(currentVersion); err != nil { + return Update{}, fmt.Errorf("current build is not a release version: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ReleaseURL, nil) + if err != nil { + return Update{}, fmt.Errorf("create release request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", c.UserAgent) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return Update{}, fmt.Errorf("fetch latest release: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return Update{}, fmt.Errorf("fetch latest release: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var release githubRelease + decoder := json.NewDecoder(io.LimitReader(resp.Body, maxReleaseBodySize)) + if err := decoder.Decode(&release); err != nil { + return Update{}, fmt.Errorf("decode latest release: %w", err) + } + if release.Draft || release.Prerelease { + return Update{}, errors.New("latest release must be a published full release") + } + if _, err := parseVersion(release.TagName); err != nil { + return Update{}, fmt.Errorf("latest release tag: %w", err) + } + + comparison, err := compareVersions(release.TagName, currentVersion) + if err != nil { + return Update{}, fmt.Errorf("compare versions: %w", err) + } + result := Update{CurrentVersion: currentVersion, LatestVersion: release.TagName, Available: comparison > 0} + if !result.Available { + return result, nil + } + + for _, asset := range release.Assets { + switch asset.Name { + case PackageAssetName: + result.Package = asset + case ChecksumAssetName: + result.Checksum = asset + } + } + if err := validateAsset(result.Package, PackageAssetName, maxPackageSize); err != nil { + return Update{}, err + } + if err := validateAsset(result.Checksum, ChecksumAssetName, maxChecksumSize); err != nil { + return Update{}, err + } + return result, nil +} + +func (c *Client) Download(ctx context.Context, update Update, destination string) error { + expectedChecksum, err := c.fetchChecksum(ctx, update.Checksum.BrowserDownloadURL) + if err != nil { + return err + } + + if err := validateDownloadURL(update.Package.BrowserDownloadURL); err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, update.Package.BrowserDownloadURL, nil) + if err != nil { + return fmt.Errorf("create package request: %w", err) + } + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("download package: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download package: status %d", resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return fmt.Errorf("create download directory: %w", err) + } + temporary := destination + ".part" + file, err := os.OpenFile(temporary, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("create package file: %w", err) + } + hash := sha256.New() + written, copyErr := io.Copy(io.MultiWriter(file, hash), io.LimitReader(resp.Body, maxPackageSize+1)) + closeErr := file.Close() + if copyErr != nil { + _ = os.Remove(temporary) + return fmt.Errorf("write package: %w", copyErr) + } + if closeErr != nil { + _ = os.Remove(temporary) + return fmt.Errorf("close package: %w", closeErr) + } + if written > maxPackageSize || (update.Package.Size > 0 && written != update.Package.Size) { + _ = os.Remove(temporary) + return fmt.Errorf("package size mismatch: downloaded %d bytes, release reports %d", written, update.Package.Size) + } + actualChecksum := hex.EncodeToString(hash.Sum(nil)) + if !strings.EqualFold(actualChecksum, expectedChecksum) { + _ = os.Remove(temporary) + return fmt.Errorf("package checksum mismatch: got %s, expected %s", actualChecksum, expectedChecksum) + } + if err := os.Rename(temporary, destination); err != nil { + _ = os.Remove(temporary) + return fmt.Errorf("finalize package: %w", err) + } + return nil +} + +func (c *Client) fetchChecksum(ctx context.Context, checksumURL string) (string, error) { + if err := validateDownloadURL(checksumURL); err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + return "", fmt.Errorf("create checksum request: %w", err) + } + req.Header.Set("User-Agent", c.UserAgent) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("download checksum: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download checksum: status %d", resp.StatusCode) + } + + scanner := bufio.NewScanner(io.LimitReader(resp.Body, maxChecksumSize)) + if !scanner.Scan() { + return "", errors.New("checksum asset is empty") + } + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 || len(fields[0]) != sha256.Size*2 { + return "", errors.New("checksum asset does not contain a SHA-256 digest") + } + if _, err := hex.DecodeString(fields[0]); err != nil { + return "", fmt.Errorf("invalid SHA-256 digest: %w", err) + } + return fields[0], nil +} + +func validateAsset(asset ReleaseAsset, name string, maximumSize int64) error { + if asset.Name != name || asset.BrowserDownloadURL == "" { + return fmt.Errorf("release is missing required asset %s", name) + } + if asset.Size <= 0 || asset.Size > maximumSize { + return fmt.Errorf("release asset %s has invalid size %d", name, asset.Size) + } + return validateDownloadURL(asset.BrowserDownloadURL) +} + +func validateDownloadURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid release asset URL: %w", err) + } + if parsed.Scheme != "https" || !allowedDownloadHost(parsed.Hostname()) { + return fmt.Errorf("refusing release asset URL %s", rawURL) + } + return nil +} + +func allowedDownloadHost(host string) bool { + return host == "github.com" || host == "objects.githubusercontent.com" || host == "release-assets.githubusercontent.com" || host == "github-releases.githubusercontent.com" +} diff --git a/cli/internal/updater/client_test.go b/cli/internal/updater/client_test.go new file mode 100644 index 0000000..1f18c5f --- /dev/null +++ b/cli/internal/updater/client_test.go @@ -0,0 +1,112 @@ +package updater + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return f(request) +} + +func response(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +func TestClientCheckFindsNewerRelease(t *testing.T) { + t.Parallel() + body := `{ + "tag_name":"v1.2.0", + "draft":false, + "prerelease":false, + "assets":[ + {"name":"rvmm_macOS_arm64.pkg","browser_download_url":"https://github.com/rxtech-lab/macos-github-action-vm/releases/download/v1.2.0/rvmm_macOS_arm64.pkg","size":42}, + {"name":"rvmm_macOS_arm64.pkg.sha256","browser_download_url":"https://github.com/rxtech-lab/macos-github-action-vm/releases/download/v1.2.0/rvmm_macOS_arm64.pkg.sha256","size":80} + ] + }` + client := NewClient("v1.1.0") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusOK, body), nil + }) + + update, err := client.Check(context.Background(), "v1.1.0") + if err != nil { + t.Fatal(err) + } + if !update.Available || update.LatestVersion != "v1.2.0" { + t.Fatalf("unexpected update: %+v", update) + } +} + +func TestClientCheckRequiresChecksumAsset(t *testing.T) { + t.Parallel() + body := `{"tag_name":"v1.2.0","assets":[{"name":"rvmm_macOS_arm64.pkg","browser_download_url":"https://github.com/example.pkg","size":42}]}` + client := NewClient("v1.1.0") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusOK, body), nil + }) + if _, err := client.Check(context.Background(), "v1.1.0"); err == nil || !strings.Contains(err.Error(), ChecksumAssetName) { + t.Fatalf("Check() error = %v, want missing checksum", err) + } +} + +func TestClientDownloadVerifiesChecksum(t *testing.T) { + t.Parallel() + packageBody := "signed package contents" + digest := fmt.Sprintf("%x", sha256.Sum256([]byte(packageBody))) + client := NewClient("v1.1.0") + client.HTTPClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.HasSuffix(request.URL.Path, ".sha256") { + return response(http.StatusOK, digest+" "+PackageAssetName+"\n"), nil + } + return response(http.StatusOK, packageBody), nil + }) + update := Update{ + Available: true, + Package: ReleaseAsset{Name: PackageAssetName, BrowserDownloadURL: "https://github.com/package", Size: int64(len(packageBody))}, + Checksum: ReleaseAsset{Name: ChecksumAssetName, BrowserDownloadURL: "https://github.com/package.sha256", Size: 80}, + } + destination := filepath.Join(t.TempDir(), PackageAssetName) + if err := client.Download(context.Background(), update, destination); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(destination) + if err != nil { + t.Fatal(err) + } + if string(data) != packageBody { + t.Fatalf("downloaded %q, want %q", data, packageBody) + } +} + +func TestClientDownloadRejectsChecksumMismatch(t *testing.T) { + t.Parallel() + client := NewClient("v1.1.0") + client.HTTPClient.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if strings.HasSuffix(request.URL.Path, ".sha256") { + return response(http.StatusOK, strings.Repeat("0", 64)), nil + } + return response(http.StatusOK, "package"), nil + }) + update := Update{ + Package: ReleaseAsset{Name: PackageAssetName, BrowserDownloadURL: "https://github.com/package", Size: 7}, + Checksum: ReleaseAsset{Name: ChecksumAssetName, BrowserDownloadURL: "https://github.com/package.sha256", Size: 64}, + } + err := client.Download(context.Background(), update, filepath.Join(t.TempDir(), PackageAssetName)) + if err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("Download() error = %v, want checksum mismatch", err) + } +} diff --git a/cli/internal/updater/state.go b/cli/internal/updater/state.go new file mode 100644 index 0000000..7d3fec9 --- /dev/null +++ b/cli/internal/updater/state.go @@ -0,0 +1,87 @@ +package updater + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + StateDirectory = "/Library/Application Support/RVMM/Updater" + RequestDirectory = StateDirectory + "/requests" + StatusPath = StateDirectory + "/status.json" + LockPath = StateDirectory + "/update.lock" +) + +type Status struct { + State string `json:"state"` + CurrentVersion string `json:"current_version,omitempty"` + LatestVersion string `json:"latest_version,omitempty"` + Message string `json:"message,omitempty"` + RestartNeeded bool `json:"restart_needed,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ReadStatus(path string) (Status, error) { + data, err := os.ReadFile(path) + if err != nil { + return Status{}, err + } + var status Status + if err := json.Unmarshal(data, &status); err != nil { + return Status{}, fmt.Errorf("decode updater status: %w", err) + } + return status, nil +} + +func writeStatus(path string, status Status) error { + status.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(status, "", " ") + if err != nil { + return fmt.Errorf("encode updater status: %w", err) + } + data = append(data, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("create updater state directory: %w", err) + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".status-*") + if err != nil { + return fmt.Errorf("create updater status: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0644); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} + +func RequestUpdate(directory string) error { + info, err := os.Stat(directory) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return errors.New("background updater is not installed; install the latest RVMM package once with administrator approval") + } + return fmt.Errorf("inspect updater request directory: %w", err) + } + if !info.IsDir() { + return errors.New("updater request path is not a directory") + } + name := fmt.Sprintf("request-%d-%d", time.Now().UnixNano(), os.Getpid()) + file, err := os.OpenFile(filepath.Join(directory, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("queue update request: %w", err) + } + return file.Close() +} diff --git a/cli/internal/updater/state_test.go b/cli/internal/updater/state_test.go new file mode 100644 index 0000000..14bcef0 --- /dev/null +++ b/cli/internal/updater/state_test.go @@ -0,0 +1,38 @@ +package updater + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRequestUpdateCreatesQueueEntry(t *testing.T) { + t.Parallel() + directory := t.TempDir() + if err := RequestUpdate(directory); err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("got %d request entries, want 1", len(entries)) + } +} + +func TestStatusRoundTrip(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "status.json") + want := Status{State: "installed", CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", RestartNeeded: true} + if err := writeStatus(path, want); err != nil { + t.Fatal(err) + } + got, err := ReadStatus(path) + if err != nil { + t.Fatal(err) + } + if got.State != want.State || got.LatestVersion != want.LatestVersion || !got.RestartNeeded { + t.Fatalf("ReadStatus() = %+v, want %+v", got, want) + } +} diff --git a/cli/internal/updater/verify.go b/cli/internal/updater/verify.go new file mode 100644 index 0000000..19ef2a3 --- /dev/null +++ b/cli/internal/updater/verify.go @@ -0,0 +1,39 @@ +package updater + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +func VerifyPackage(ctx context.Context, packagePath, teamID string) error { + if teamID == "" { + return fmt.Errorf("release build does not contain an expected Apple team ID") + } + output, err := exec.CommandContext(ctx, "/usr/sbin/pkgutil", "--check-signature", packagePath).CombinedOutput() + if err != nil { + return fmt.Errorf("verify package signature: %w: %s", err, strings.TrimSpace(string(output))) + } + if !signatureContainsTeamID(string(output), teamID) { + return fmt.Errorf("package is not signed by expected Apple team %s", teamID) + } + + output, err = exec.CommandContext(ctx, "/usr/sbin/spctl", "--assess", "--type", "install", "--verbose=2", packagePath).CombinedOutput() + if err != nil { + return fmt.Errorf("verify package trust: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} + +func signatureContainsTeamID(output, teamID string) bool { + return strings.Contains(output, "("+teamID+")") || strings.Contains(output, "TeamIdentifier="+teamID) +} + +func InstallPackage(ctx context.Context, packagePath string) error { + output, err := exec.CommandContext(ctx, "/usr/sbin/installer", "-pkg", packagePath, "-target", "/").CombinedOutput() + if err != nil { + return fmt.Errorf("install package: %w: %s", err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/cli/internal/updater/verify_test.go b/cli/internal/updater/verify_test.go new file mode 100644 index 0000000..d89eac9 --- /dev/null +++ b/cli/internal/updater/verify_test.go @@ -0,0 +1,13 @@ +package updater + +import "testing" + +func TestSignatureContainsTeamID(t *testing.T) { + t.Parallel() + if !signatureContainsTeamID("Developer ID Installer: Example Corp (ABC123XYZ)", "ABC123XYZ") { + t.Fatal("expected team ID to be recognized") + } + if signatureContainsTeamID("Developer ID Installer: Other Corp (OTHERTEAM)", "ABC123XYZ") { + t.Fatal("unexpected signer accepted") + } +} diff --git a/cli/internal/updater/version.go b/cli/internal/updater/version.go new file mode 100644 index 0000000..7bd2187 --- /dev/null +++ b/cli/internal/updater/version.go @@ -0,0 +1,158 @@ +package updater + +import ( + "fmt" + "strconv" + "strings" +) + +type semanticVersion struct { + major uint64 + minor uint64 + patch uint64 + prerelease []string +} + +func parseVersion(value string) (semanticVersion, error) { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, "v") + if buildIndex := strings.IndexByte(value, '+'); buildIndex >= 0 { + value = value[:buildIndex] + } + + core := value + var prerelease []string + if prereleaseIndex := strings.IndexByte(value, '-'); prereleaseIndex >= 0 { + core = value[:prereleaseIndex] + pre := value[prereleaseIndex+1:] + if pre == "" { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + prerelease = strings.Split(pre, ".") + } + + parts := strings.Split(core, ".") + if len(parts) != 3 { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + values := make([]uint64, 3) + for i, part := range parts { + if part == "" || (len(part) > 1 && part[0] == '0') { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + parsed, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q: %w", value, err) + } + values[i] = parsed + } + + for _, identifier := range prerelease { + if identifier == "" { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + for _, r := range identifier { + if !(r >= '0' && r <= '9') && !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && r != '-' { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + } + if isNumeric(identifier) && len(identifier) > 1 && identifier[0] == '0' { + return semanticVersion{}, fmt.Errorf("invalid semantic version %q", value) + } + } + + return semanticVersion{major: values[0], minor: values[1], patch: values[2], prerelease: prerelease}, nil +} + +func compareVersions(left, right string) (int, error) { + l, err := parseVersion(left) + if err != nil { + return 0, err + } + r, err := parseVersion(right) + if err != nil { + return 0, err + } + + if result := compareUint64(l.major, r.major); result != 0 { + return result, nil + } + if result := compareUint64(l.minor, r.minor); result != 0 { + return result, nil + } + if result := compareUint64(l.patch, r.patch); result != 0 { + return result, nil + } + return comparePrerelease(l.prerelease, r.prerelease), nil +} + +func compareUint64(left, right uint64) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + +func comparePrerelease(left, right []string) int { + if len(left) == 0 && len(right) == 0 { + return 0 + } + if len(left) == 0 { + return 1 + } + if len(right) == 0 { + return -1 + } + + for i := 0; i < len(left) && i < len(right); i++ { + if left[i] == right[i] { + continue + } + leftNumeric := isNumeric(left[i]) + rightNumeric := isNumeric(right[i]) + switch { + case leftNumeric && rightNumeric: + if len(left[i]) < len(right[i]) { + return -1 + } + if len(left[i]) > len(right[i]) { + return 1 + } + if left[i] < right[i] { + return -1 + } + return 1 + case leftNumeric: + return -1 + case rightNumeric: + return 1 + case left[i] < right[i]: + return -1 + default: + return 1 + } + } + + if len(left) < len(right) { + return -1 + } + if len(left) > len(right) { + return 1 + } + return 0 +} + +func isNumeric(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/cli/internal/updater/version_test.go b/cli/internal/updater/version_test.go new file mode 100644 index 0000000..f710b58 --- /dev/null +++ b/cli/internal/updater/version_test.go @@ -0,0 +1,41 @@ +package updater + +import "testing" + +func TestCompareVersions(t *testing.T) { + t.Parallel() + tests := []struct { + left string + right string + want int + }{ + {"v1.2.3", "1.2.3", 0}, + {"v1.2.4", "v1.2.3", 1}, + {"v2.0.0", "v1.99.99", 1}, + {"v1.2.3-beta.2", "v1.2.3-beta.11", -1}, + {"v1.2.3", "v1.2.3-rc.1", 1}, + {"v1.2.3+build.2", "v1.2.3+build.1", 0}, + } + for _, test := range tests { + test := test + t.Run(test.left+"_"+test.right, func(t *testing.T) { + t.Parallel() + got, err := compareVersions(test.left, test.right) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("compareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) + } + }) + } +} + +func TestParseVersionRejectsInvalidValues(t *testing.T) { + t.Parallel() + for _, value := range []string{"dev", "1.2", "1.02.3", "1.2.3-", "1.2.3-01"} { + if _, err := parseVersion(value); err == nil { + t.Errorf("parseVersion(%q) succeeded, want error", value) + } + } +} diff --git a/cli/internal/updater/worker.go b/cli/internal/updater/worker.go new file mode 100644 index 0000000..aba330d --- /dev/null +++ b/cli/internal/updater/worker.go @@ -0,0 +1,131 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "syscall" + "time" +) + +type Worker struct { + CurrentVersion string + TeamID string + Client ReleaseClient + StatusPath string + LockPath string + RequestDir string + Verify func(context.Context, string, string) error + Install func(context.Context, string) error + IsRoot func() bool + IsSupported func() bool +} + +func NewWorker(currentVersion, teamID string) *Worker { + return &Worker{ + CurrentVersion: currentVersion, + TeamID: teamID, + Client: NewClient(currentVersion), + StatusPath: StatusPath, + LockPath: LockPath, + RequestDir: RequestDirectory, + Verify: VerifyPackage, + Install: InstallPackage, + IsRoot: func() bool { return os.Geteuid() == 0 }, + IsSupported: func() bool { return runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" }, + } +} + +func (w *Worker) Run(ctx context.Context) error { + if !w.IsRoot() { + return errors.New("update worker must run as root") + } + if !w.IsSupported() { + return fmt.Errorf("automatic updates are only supported on macOS arm64") + } + if err := os.MkdirAll(filepath.Dir(w.LockPath), 0755); err != nil { + return fmt.Errorf("create updater state directory: %w", err) + } + lock, err := os.OpenFile(w.LockPath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("open updater lock: %w", err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + return errors.New("another update worker is already running") + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) + defer w.consumeRequests() + pendingRestart := false + if previous, err := ReadStatus(w.StatusPath); err == nil { + pendingRestart = previous.RestartNeeded + } + + setStatus := func(state, latest, message string, restart bool) { + _ = writeStatus(w.StatusPath, Status{ + State: state, + CurrentVersion: w.CurrentVersion, + LatestVersion: latest, + Message: message, + RestartNeeded: restart, + }) + } + setStatus("checking", "", "Checking GitHub for updates", pendingRestart) + + update, err := w.Client.Check(ctx, w.CurrentVersion) + if err != nil { + setStatus("error", "", err.Error(), pendingRestart) + return err + } + if !update.Available { + message := "RVMM is up to date" + if pendingRestart { + message = "RVMM is up to date; service restart is still pending" + } + setStatus("up_to_date", update.LatestVersion, message, pendingRestart) + return nil + } + + directory, err := os.MkdirTemp("/private/tmp", "rvmm-update-*") + if err != nil { + setStatus("error", update.LatestVersion, err.Error(), pendingRestart) + return fmt.Errorf("create update directory: %w", err) + } + defer os.RemoveAll(directory) + packagePath := filepath.Join(directory, PackageAssetName) + + setStatus("downloading", update.LatestVersion, "Downloading signed update", pendingRestart) + if err := w.Client.Download(ctx, update, packagePath); err != nil { + setStatus("error", update.LatestVersion, err.Error(), pendingRestart) + return err + } + setStatus("verifying", update.LatestVersion, "Verifying package signature and notarization", pendingRestart) + if err := w.Verify(ctx, packagePath, w.TeamID); err != nil { + setStatus("error", update.LatestVersion, err.Error(), pendingRestart) + return err + } + setStatus("installing", update.LatestVersion, "Installing update", pendingRestart) + installCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + if err := w.Install(installCtx, packagePath); err != nil { + setStatus("error", update.LatestVersion, err.Error(), pendingRestart) + return err + } + setStatus("installed", update.LatestVersion, "Update installed; active VM work was not interrupted", true) + return nil +} + +func (w *Worker) consumeRequests() { + entries, err := os.ReadDir(w.RequestDir) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() { + _ = os.Remove(filepath.Join(w.RequestDir, entry.Name())) + } + } +} diff --git a/cli/internal/updater/worker_test.go b/cli/internal/updater/worker_test.go new file mode 100644 index 0000000..cae6fdd --- /dev/null +++ b/cli/internal/updater/worker_test.go @@ -0,0 +1,105 @@ +package updater + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +type fakeReleaseClient struct { + update Update + downloaded bool +} + +func (f *fakeReleaseClient) Check(context.Context, string) (Update, error) { + return f.update, nil +} + +func (f *fakeReleaseClient) Download(_ context.Context, _ Update, destination string) error { + f.downloaded = true + return os.WriteFile(destination, []byte("package"), 0600) +} + +func TestWorkerInstallsAvailableUpdate(t *testing.T) { + t.Parallel() + directory := t.TempDir() + requests := filepath.Join(directory, "requests") + if err := os.Mkdir(requests, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(requests, "request"), nil, 0600); err != nil { + t.Fatal(err) + } + fakeClient := &fakeReleaseClient{update: Update{Available: true, LatestVersion: "v1.1.0"}} + verified := false + installed := false + worker := &Worker{ + CurrentVersion: "v1.0.0", + TeamID: "TEAMID", + Client: fakeClient, + StatusPath: filepath.Join(directory, "status.json"), + LockPath: filepath.Join(directory, "update.lock"), + RequestDir: requests, + Verify: func(context.Context, string, string) error { + verified = true + return nil + }, + Install: func(context.Context, string) error { + installed = true + return nil + }, + IsRoot: func() bool { return true }, + IsSupported: func() bool { return true }, + } + if err := worker.Run(context.Background()); err != nil { + t.Fatal(err) + } + if !fakeClient.downloaded || !verified || !installed { + t.Fatalf("downloaded=%v verified=%v installed=%v", fakeClient.downloaded, verified, installed) + } + status, err := ReadStatus(worker.StatusPath) + if err != nil { + t.Fatal(err) + } + if status.State != "installed" || !status.RestartNeeded { + t.Fatalf("unexpected status: %+v", status) + } + entries, err := os.ReadDir(requests) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("request queue was not consumed: %v", entries) + } +} + +func TestWorkerPreservesPendingRestartWhenUpToDate(t *testing.T) { + t.Parallel() + directory := t.TempDir() + statusPath := filepath.Join(directory, "status.json") + if err := writeStatus(statusPath, Status{State: "installed", LatestVersion: "v1.1.0", RestartNeeded: true}); err != nil { + t.Fatal(err) + } + worker := &Worker{ + CurrentVersion: "v1.1.0", + Client: &fakeReleaseClient{update: Update{Available: false, LatestVersion: "v1.1.0"}}, + StatusPath: statusPath, + LockPath: filepath.Join(directory, "update.lock"), + RequestDir: filepath.Join(directory, "requests"), + Verify: func(context.Context, string, string) error { return nil }, + Install: func(context.Context, string) error { return nil }, + IsRoot: func() bool { return true }, + IsSupported: func() bool { return true }, + } + if err := worker.Run(context.Background()); err != nil { + t.Fatal(err) + } + status, err := ReadStatus(statusPath) + if err != nil { + t.Fatal(err) + } + if !status.RestartNeeded { + t.Fatalf("pending restart was cleared: %+v", status) + } +} diff --git a/cli/main.go b/cli/main.go index 71b3944..e56df7e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -10,11 +10,13 @@ import ( "sync" "syscall" + "github.com/rxtech-lab/rvmm/internal/buildinfo" "github.com/rxtech-lab/rvmm/internal/config" "github.com/rxtech-lab/rvmm/internal/monitor" "github.com/rxtech-lab/rvmm/internal/posthog" "github.com/rxtech-lab/rvmm/internal/runner" "github.com/rxtech-lab/rvmm/internal/tui" + "github.com/rxtech-lab/rvmm/internal/updater" "go.uber.org/zap" ) @@ -27,9 +29,29 @@ func main() { monitorHeadless() return } + if len(os.Args) > 1 && os.Args[1] == "update-worker" { + updateWorker() + return + } tui.Run() } +func updateWorker() { + logger, err := zap.NewProduction() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + + worker := updater.NewWorker(buildinfo.Version, buildinfo.TeamID) + if err := worker.Run(context.Background()); err != nil { + logger.Error("Automatic update failed", zap.Error(err)) + os.Exit(1) + } + logger.Info("Automatic update check completed") +} + func runHeadless() { fs := flag.NewFlagSet("run", flag.ExitOnError) configPath := fs.String("config", "", "path to config file") diff --git a/cli/scripts/package-notarize.sh b/cli/scripts/package-notarize.sh index b237b09..274594e 100755 --- a/cli/scripts/package-notarize.sh +++ b/cli/scripts/package-notarize.sh @@ -2,6 +2,8 @@ set -e +export COPYFILE_DISABLE=1 + if [ -z "${INSTALLER_SIGNING_CERTIFICATE_NAME}" ]; then echo "Error: INSTALLER_SIGNING_CERTIFICATE_NAME is not set" exit 1 @@ -17,10 +19,24 @@ source "$(dirname "$0")/binaries.sh" PKG_FILE="${PKG_FILE:-rvmm_macOS_arm64.pkg}" PKG_VERSION="${PKG_VERSION:-1.0}" PKG_IDENTIFIER="${PKG_IDENTIFIER:-lab.rxtech.rvmm}" -TMP_DIR="tmp_pkg_build" +TMP_DIR="$(/usr/bin/mktemp -d /private/tmp/rvmm-pkg-root.XXXXXX)" +PKG_SCRIPTS_DIR="$(/usr/bin/mktemp -d /private/tmp/rvmm-pkg-scripts.XXXXXX)" + +cleanup() { + /bin/rm -rf "${TMP_DIR}" "${PKG_SCRIPTS_DIR}" +} +trap cleanup EXIT echo "Creating package structure" mkdir -p "${TMP_DIR}/usr/local/bin" +mkdir -p "${TMP_DIR}/Library/LaunchDaemons" +mkdir -p "${TMP_DIR}/Library/Application Support/RVMM/Updater/requests" +mkdir -p "${PKG_SCRIPTS_DIR}" +chmod 0770 "${TMP_DIR}/Library/Application Support/RVMM/Updater/requests" +cp "assets/lab.rxtech.rvmm.updater.plist" "${TMP_DIR}/Library/LaunchDaemons/" +chmod 0644 "${TMP_DIR}/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist" +cp "scripts/pkg-scripts/postinstall" "${PKG_SCRIPTS_DIR}/postinstall" +chmod 0755 "${PKG_SCRIPTS_DIR}/postinstall" for binary in "${BINARIES[@]}"; do BINARY_PATH="bin/${binary}" @@ -34,20 +50,26 @@ for binary in "${BINARIES[@]}"; do cp "${BINARY_PATH}" "${TMP_DIR}/usr/local/bin/" done +# Avoid packaging macOS metadata sidecars such as ._rvmm. +/usr/bin/xattr -cr "${TMP_DIR}" + echo "Building pkg installer (version ${PKG_VERSION})" pkgbuild --root "${TMP_DIR}" \ + --ownership recommended \ --identifier "${PKG_IDENTIFIER}" \ --version "${PKG_VERSION}" \ --sign "${INSTALLER_SIGNING_CERTIFICATE_NAME}" \ + --scripts "${PKG_SCRIPTS_DIR}" \ --install-location "/" \ "${PKG_FILE}" -rm -rf "${TMP_DIR}" - echo "Submitting for notarization" xcrun notarytool submit "${PKG_FILE}" --verbose --apple-id "${APPLE_ID}" --team-id "${APPLE_TEAM_ID}" --password "${APPLE_ID_PWD}" --wait echo "Stapling notarization ticket" xcrun stapler staple -v "${PKG_FILE}" +echo "Writing SHA-256 checksum" +/usr/bin/shasum -a 256 "${PKG_FILE}" > "${PKG_FILE}.sha256" + echo "Package created, signed, notarized and stapled successfully: ${PKG_FILE}" diff --git a/cli/scripts/pkg-scripts/postinstall b/cli/scripts/pkg-scripts/postinstall new file mode 100755 index 0000000..403960c --- /dev/null +++ b/cli/scripts/pkg-scripts/postinstall @@ -0,0 +1,23 @@ +#!/bin/bash + +set -euo pipefail + +TARGET_VOLUME="${3:-/}" +STATE_DIR="${TARGET_VOLUME%/}/Library/Application Support/RVMM/Updater" +REQUEST_DIR="${STATE_DIR}/requests" +PLIST_PATH="${TARGET_VOLUME%/}/Library/LaunchDaemons/lab.rxtech.rvmm.updater.plist" +LABEL="lab.rxtech.rvmm.updater" + +/usr/bin/install -d -o root -g wheel -m 0755 "${STATE_DIR}" +/usr/bin/install -d -o root -g admin -m 0770 "${REQUEST_DIR}" +/usr/sbin/chown root:wheel "${PLIST_PATH}" +/bin/chmod 0644 "${PLIST_PATH}" + +if [ "${TARGET_VOLUME}" = "/" ]; then + /bin/launchctl enable "system/${LABEL}" || true + if ! /bin/launchctl print "system/${LABEL}" >/dev/null 2>&1; then + /bin/launchctl bootstrap system "${PLIST_PATH}" + fi +fi + +exit 0 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..46a048e --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,63 @@ +--- +slug: architecture/overview +title: RVMM Architecture Overview +description: Components and runtime flow of the macOS GitHub Actions runner VM manager +--- + +# RVMM architecture overview + +RVMM is a Go command-line application for running ephemeral GitHub Actions self-hosted runners inside Tart virtual machines on Apple Silicon Macs. It combines host setup, image management, runner registration, bounded VM concurrency, launchd integration, and optional PostHog log forwarding. + +## Repository layout + +| Area | Responsibility | +| --- | --- | +| `cli/main.go` | Selects the interactive TUI, headless runner, or headless monitor entry point. | +| `cli/internal/config` | Loads `rvmm.yaml`, applies defaults, and validates required settings. | +| `cli/internal/runner` | Obtains GitHub registration tokens and manages Tart VM, SSH, and ephemeral runner lifecycles. | +| `cli/internal/tui` | Implements the Bubble Tea menus for setup, configuration, image operations, runners, daemons, and logs. | +| `cli/internal/daemon` | Installs, removes, and inspects runner and monitor launchd jobs. | +| `cli/internal/monitor` | Tails runner stdout and stderr files. | +| `cli/internal/posthog` | Sends new log lines to PostHog capture endpoints. | +| `cli/internal/setup` | Installs or validates host dependencies and creates a sample configuration. | +| `cli/assets` | Embeds the launchd plist template and example configuration in the binary. | +| `guest/runner.pkr.hcl` | Provides an example Packer template for a Tart runner image. | +| `.github/workflows` | Creates releases and builds, signs, notarizes, and publishes the macOS installer. | + +## Runtime modes + +Running `rvmm` without arguments opens the interactive TUI. The TUI calls the same config, setup, runner, daemon, and image-management packages used by headless operation. + +`rvmm run -config rvmm.yaml` starts the runner loop. `rvmm monitor -config rvmm.yaml` starts two log tailers for the configured working directory and requires `posthog.enabled: true`. + +## Runner lifecycle + +1. Load and validate configuration. +2. Verify that `tart`, `sshpass`, `wget`, and `packer` are available. +3. Log in to the OCI registry when credentials are configured. +4. Reuse a local Tart image or pull the configured image from the registry. +5. Create `options.max_concurrent_runners` worker slots. +6. For each slot, request a short-lived registration token from GitHub. +7. Clone the base image to an instance named `_` and boot it without graphics. +8. Wait for the guest IP and SSH, then configure an ephemeral GitHub Actions runner in the guest. +9. Run one Actions job, stop the VM, and delete the instance during cleanup. +10. Return the slot to the pool and repeat until the context is cancelled or the shutdown flag exists. + +Each worker owns its own `VMManager`, while the GitHub client is shared. The base image is initialized once before workers begin. + +## VM image construction + +The example Packer template uses the Tart plugin and starts from a Cirrus Labs macOS image. It downloads the latest arm64 GitHub Actions runner into the guest, installs Apple certificate authorities, selects Xcode, accepts its license, completes first-launch setup, and downloads available platforms and simulator runtimes. + +The CLI accepts external `.pkr.hcl` templates, runs `packer init`, and then runs `packer build` from the selected template's directory. Supporting provisioner files therefore stay beside the template. + +## Background services and observability + +The runner can be installed as a system LaunchDaemon or user LaunchAgent depending on `daemon.plist_path`. Its stdout and stderr are written under `options.working_directory`. + +The optional monitor runs as a separate LaunchAgent. It polls both files, starts at their current end, detects truncation, and forwards subsequent non-empty lines as `mac_ci_log_line` PostHog events identified by `posthog.machine_label`. + +## Release flow + +The manual semantic-release workflow creates a GitHub release from `main`. The release workflow runs on the self-hosted macOS ARM64 runner, executes Go tests, builds and signs `rvmm`, creates and notarizes a macOS installer package for release events, and uploads the package to the GitHub release. + diff --git a/docs/code/go.md b/docs/code/go.md new file mode 100644 index 0000000..0843577 --- /dev/null +++ b/docs/code/go.md @@ -0,0 +1,129 @@ +--- +slug: code/go-packages +title: Go Package Reference +description: Go package index for the RVMM command-line application +--- + +# Go package reference + +This index is derived from `go doc` for module `github.com/rxtech-lab/rvmm` using Go 1.22. Run `go doc -all ` from `cli/` for full declarations and field documentation. + +## `github.com/rxtech-lab/rvmm` + +The executable package selects the Bubble Tea TUI by default. It also exposes the `run` and `monitor` command modes through the process entry point. + +## `assets` + +Embedded build assets: + +```text +var ConfigExample []byte +var EkidenPlist []byte +``` + +## `internal/config` + +Loads, defaults, and validates the YAML configuration. + +```text +type Config struct { ... } + func Load(configPath string) (*Config, error) +type DaemonConfig struct { ... } +type GitHubConfig struct { ... } +type OptionsConfig struct { ... } +type PostHogConfig struct { ... } +type RegistryConfig struct { ... } +type VMConfig struct { ... } +func (c *Config) Validate() error +``` + +## `internal/daemon` + +Manages launchd jobs for the runner and log monitor. + +```text +func Install(log *zap.Logger, cfg *config.Config, configPath string, out io.Writer) error +func InstallMonitor(log *zap.Logger, cfg *config.Config, configPath string, out io.Writer) error +func IsRunning(cfg *config.Config) (bool, error) +func Status(log *zap.Logger, cfg *config.Config, out io.Writer) error +func StatusMonitor(log *zap.Logger, cfg *config.Config, out io.Writer) error +func Uninstall(log *zap.Logger, cfg *config.Config, out io.Writer) error +func UninstallMonitor(log *zap.Logger, cfg *config.Config, out io.Writer) error +type PlistData struct { ... } +``` + +## `internal/monitor` + +Polls a log file and sends new lines to PostHog. + +```text +type LogTailer struct { ... } + func NewLogTailer(filePath string, logType string, posthog *posthog.Client, log *zap.Logger) *LogTailer + func (t *LogTailer) Start(ctx context.Context) error +``` + +## `internal/posthog` + +Builds and sends single or batched PostHog capture requests. + +```text +type CaptureRequest struct { ... } +type Client struct { ... } + func NewClient(cfg *config.PostHogConfig, log *zap.Logger) *Client + func (c *Client) CaptureLogEvent(logType string, logLine string) error + func (c *Client) CaptureLogEventBatch(logType string, logLines []string) error +type Event struct { ... } +``` + +## `internal/runner` + +Coordinates registration tokens, Tart instances, SSH, and ephemeral runner workers. + +```text +func Run(ctx context.Context, log *zap.Logger, cfg *config.Config) error +type GitHubClient struct { ... } + func NewGitHubClient(cfg *config.Config, log *zap.Logger) *GitHubClient + func (g *GitHubClient) GetRegistrationToken() (string, error) +type RegistrationTokenResponse struct { ... } +type SSHClient struct { ... } + func NewSSHClient(cfg *config.Config, log *zap.Logger) *SSHClient + func (s *SSHClient) ConfigureRunner(ctx context.Context, ip, token, runnerName string) error + func (s *SSHClient) Execute(ctx context.Context, ip, command string, showOutput bool) error + func (s *SSHClient) ExecuteWithOutput(ctx context.Context, ip, command string) (string, error) + func (s *SSHClient) RunRunner(ctx context.Context, ip string) error + func (s *SSHClient) WaitForSSH(ctx context.Context, ip string) error +type VMManager struct { ... } + func NewVMManager(cfg *config.Config, log *zap.Logger) *VMManager + func (v *VMManager) Cleanup(ctx context.Context, instanceName string) + func (v *VMManager) Clone(ctx context.Context, instanceName string) error + func (v *VMManager) Delete(ctx context.Context, instanceName string) error + func (v *VMManager) GetCachePath() string + func (v *VMManager) GetRegistryPath() string + func (v *VMManager) ImageExists(ctx context.Context) (bool, error) + func (v *VMManager) Login(ctx context.Context) error + func (v *VMManager) PullImage(ctx context.Context) error + func (v *VMManager) Start(ctx context.Context, instanceName string) (*exec.Cmd, error) + func (v *VMManager) Stop(ctx context.Context, instanceName string) error + func (v *VMManager) WaitForIP(ctx context.Context, instanceName string) (string, error) +``` + +## `internal/setup` + +Installs and validates host dependencies and creates the sample configuration. + +```text +var RequiredPackages = []string { ... } +var RequiredTools = []string { ... } +func CheckDependencies() error +func Run(log *zap.Logger) error +func RunWithIO(log *zap.Logger, stdout, stderr io.Writer, stdin io.Reader) error +``` + +## `internal/tui` + +Implements the Bubble Tea interface and delegates work to the setup, runner, daemon, and image-management packages. + +```text +func Run() +``` + diff --git a/docs/operations/configuration.md b/docs/operations/configuration.md new file mode 100644 index 0000000..6e0f3f6 --- /dev/null +++ b/docs/operations/configuration.md @@ -0,0 +1,96 @@ +--- +slug: operations/configuration +title: RVMM Configuration and Operations +description: Configure, run, monitor, and release RVMM +--- + +# RVMM configuration and operations + +RVMM reads YAML from the path passed with `-config`. Without an explicit path it searches for `rvmm.yaml` in the current directory, `$HOME/.rvmm`, and `/etc/rvmm`. The interactive setup flow writes an example `rvmm.yaml` in the current directory if one does not already exist. + +## Host requirements + +RVMM is intended for macOS on Apple Silicon with hardware virtualization enabled. Runtime and image-building operations use: + +- `tart` for VM images and instances +- `sshpass` for guest automation +- `wget` for downloads +- `packer` from `hashicorp/tap` for image templates + +The TUI Setup action can install Homebrew and these packages. + +## Configuration sections + +### `github` + +| Field | Meaning | +| --- | --- | +| `api_token` | GitHub token used to request runner registration tokens. | +| `registration_endpoint` | Organization or repository runner registration-token endpoint. | +| `runner_url` | Organization or repository URL passed to the guest runner configuration. | +| `runner_name` | Base runner name; the worker slot is appended at runtime. | +| `runner_labels` | Labels registered on each ephemeral runner. | +| `runner_group` | Optional organization or enterprise runner group. | + +`api_token`, `registration_endpoint`, and `runner_url` are required. + +### `vm` + +`username` and `password` are the guest SSH credentials and are required. `display` controls the Tart display configuration applied before boot and defaults to `3840x2160`. + +### `registry` + +`image_name` is required and can name a local Tart image or an OCI image. Set `url`, `username`, and `password` when RVMM must authenticate and pull from a registry. If `image_name` already starts with the configured registry URL, RVMM avoids adding the prefix twice. + +### `options` + +| Field | Default | Meaning | +| --- | --- | --- | +| `truncate_size` | empty | Optional target size used when resizing a pulled image. | +| `log_file` | `runner.log` | TUI log path. | +| `max_concurrent_runners` | `1` | Number of VM worker slots; must be at least one. | +| `shutdown_flag_file` | `.shutdown` | Stops new work and waits for active workers when this file exists. | +| `working_directory` | `/Users/admin/vm` | Runner logs and launchd working directory. | + +### `daemon` + +`label` identifies the launchd job, `plist_path` selects a system LaunchDaemon or user LaunchAgent location, and `user` is written into the generated plist. Installing under `/Library/LaunchDaemons` generally requires elevated filesystem permissions. + +### `posthog` + +Set `enabled: true`, then provide `api_key`, `host`, and a unique `machine_label` to forward runner logs. These values are validated only when monitoring is enabled. + +## Common commands + +```bash +# Build and test the CLI +cd cli +make build +make test + +# Run interactively +./bin/rvmm + +# Run ephemeral workers in the foreground +./bin/rvmm run -config /path/to/rvmm.yaml + +# Forward new runner logs to PostHog +./bin/rvmm monitor -config /path/to/rvmm.yaml + +# Build the example guest image +cd ../guest +packer init runner.pkr.hcl +packer build runner.pkr.hcl +``` + +## Operational checks + +- Confirm the image is visible with `tart list` before starting workers. +- Confirm the guest credentials match the Packer image and that SSH is reachable. +- Confirm the GitHub token can call the configured registration endpoint. +- Check the configured launchd domain with `launchctl print system/