Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
38d35fe
Amend rustfs spec with verified CLI/asset facts from Task 1
munezaclovis Apr 14, 2026
1b2391e
Add Rustfs binary descriptor
munezaclovis Apr 14, 2026
839b01f
Add ExtractZip + installRustfs for RustFS binaries
munezaclovis Apr 14, 2026
c614e9f
Add Kind and Enabled fields to registry.ServiceInstance
munezaclovis Apr 14, 2026
6632b4d
Add BinaryService interface and binary registry
munezaclovis Apr 14, 2026
dbd5efb
Replace Docker S3 service with RustFS BinaryService
munezaclovis Apr 14, 2026
e08cb6e
Add supervisor package for child-process management
munezaclovis Apr 14, 2026
3c26550
Add supervisor-process builder and daemon-status.json writer
munezaclovis Apr 14, 2026
e8509f0
Extend ServerManager.Reconcile with binary-service phase
munezaclovis Apr 14, 2026
60a041d
Wire supervisor into daemon start and filter Colima boot
munezaclovis Apr 14, 2026
72e40a8
Add service command kind dispatcher
munezaclovis Apr 14, 2026
949f84c
Implement service:add binary path
munezaclovis Apr 14, 2026
927893f
Implement service:start and service:stop binary paths
munezaclovis Apr 14, 2026
86d226a
Implement service:remove and service:destroy binary paths
munezaclovis Apr 14, 2026
44364db
Read daemon-status.json for binary service observability
munezaclovis Apr 14, 2026
f96bca9
Implement service:logs binary path via tail-f of log file
munezaclovis Apr 14, 2026
271cc29
Refresh binary-service binaries in pv update
munezaclovis Apr 14, 2026
2ce7853
Add E2E phase for S3 binary service lifecycle
munezaclovis Apr 14, 2026
3f5710b
Fix supervisor Stop/watch race on cmd.Wait
munezaclovis Apr 14, 2026
fd3acb6
Address PR review: fix 6 critical + 2 important issues
munezaclovis Apr 14, 2026
7a7c87f
wip
munezaclovis Apr 14, 2026
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
9 changes: 7 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,18 @@ jobs:
timeout-minutes: 2
run: scripts/e2e/verify-final.sh

# ── Phase 20: Uninstall ───────────────────────────────────────
# ── Phase 20: S3 binary service lifecycle ──────────────────────
- name: E2E — S3 binary service lifecycle
timeout-minutes: 3
run: scripts/e2e/s3-binary.sh

# ── Phase 21: Uninstall ───────────────────────────────────────
# TODO: frankenphp untrust hangs in CI (internal sudo prompt, no terminal)
# - name: Test pv uninstall
# timeout-minutes: 1
# run: scripts/e2e/uninstall.sh

# ── Phase 21: Failure Diagnostics & Cleanup ────────────────────
# ── Phase 22: Failure Diagnostics & Cleanup ────────────────────
- name: Dump logs on failure
if: failure()
run: scripts/e2e/diagnostics.sh
Expand Down
54 changes: 54 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import (
"syscall"
"time"

"github.com/prvious/pv/internal/binaries"
"github.com/prvious/pv/internal/colima"
colimacmd "github.com/prvious/pv/internal/commands/colima"
"github.com/prvious/pv/internal/commands/composer"
"github.com/prvious/pv/internal/commands/mago"
"github.com/prvious/pv/internal/commands/php"
"github.com/prvious/pv/internal/packages"
"github.com/prvious/pv/internal/registry"
"github.com/prvious/pv/internal/selfupdate"
"github.com/prvious/pv/internal/server"
"github.com/prvious/pv/internal/services"
"github.com/prvious/pv/internal/ui"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -99,6 +103,56 @@ var updateCmd = &cobra.Command{
}
}

// Step 4: Update binary-service binaries.
// registry.Load / LoadVersions return nil on a non-IsNotExist error
// (corrupt file, permissions), so both pointers must be checked
// before we dereference them.
reg, regErr := registry.Load()
if regErr != nil {
ui.Subtle(fmt.Sprintf("Skipping binary-service updates: cannot load registry: %v", regErr))
} else {
vs, vsErr := binaries.LoadVersions()
if vsErr != nil {
ui.Subtle(fmt.Sprintf("Skipping binary-service updates: cannot load versions state: %v", vsErr))
} else {
var binaryUpdated []string
for name, svc := range services.AllBinary() {
if _, registered := reg.Services[name]; !registered {
continue
}
latest, err := binaries.FetchLatestVersion(client, svc.Binary())
if err != nil {
ui.Subtle(fmt.Sprintf("Skipping %s: %v", svc.DisplayName(), err))
continue
}
if !binaries.NeedsUpdate(vs, svc.Binary(), latest) {
continue
}
current := vs.Get(svc.Binary().Name)
if err := ui.Step(fmt.Sprintf("Updating %s %s -> %s", svc.Binary().DisplayName, current, latest), func() (string, error) {
if err := binaries.InstallBinary(client, svc.Binary(), latest); err != nil {
return "", err
}
return fmt.Sprintf("Updated %s to %s", svc.Binary().DisplayName, latest), nil
}); err != nil {
ui.Subtle(fmt.Sprintf("Could not update %s: %v", svc.DisplayName(), err))
continue
}
vs.Set(svc.Binary().Name, latest)
binaryUpdated = append(binaryUpdated, name)
}
if len(binaryUpdated) > 0 {
if err := vs.Save(); err != nil {
ui.Subtle(fmt.Sprintf("Could not save versions state: %v", err))
}
if server.IsRunning() {
ui.Subtle(fmt.Sprintf("Updated binaries: %s. Run `pv service:stop %s && pv service:start %s` (or `pv restart`) to load them.",
strings.Join(binaryUpdated, ", "), binaryUpdated[0], binaryUpdated[0]))
}
}
}
}

ui.Footer(start, "")

if len(failures) > 0 {
Expand Down
35 changes: 17 additions & 18 deletions docs/superpowers/specs/2026-04-14-rustfs-native-binary-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,25 +95,25 @@ The existing `Service` (Docker) interface is unchanged.
- `Name()`: `"s3"` (matches the user-facing service name)
- `DisplayName()`: `"S3 Storage (RustFS)"`
- `Binary()`: `binaries.Rustfs`
- `Args(dataDir)`: `["server", dataDir, "--address", ":9000", "--console-address", ":9001"]`
- **VERIFY during implementation:** exact flag names by running `./rustfs server --help` on the downloaded binary.
- `Env()`: `["RUSTFS_ROOT_USER=rstfsadmin", "RUSTFS_ROOT_PASSWORD=rstfsadmin"]`
- `Args(dataDir)`: `["server", dataDir, "--address", ":9000", "--console-enable", "--console-address", ":9001"]`
- **Verified 2026-04-14** against `rustfs 1.0.0-alpha.93`. The positional `<VOLUMES>` is the data-dir. `--console-enable` is required to actually open port 9001; without it, the console UI never binds.
- `Env()`: `["RUSTFS_ACCESS_KEY=rstfsadmin", "RUSTFS_SECRET_KEY=rstfsadmin"]`
- **Verified 2026-04-14**: the `RUSTFS_ROOT_USER` / `RUSTFS_ROOT_PASSWORD` names in earlier drafts are invalid; the real env vars are `RUSTFS_ACCESS_KEY` and `RUSTFS_SECRET_KEY` (per `rustfs server --help`).
- `Port()`: `9000`; `ConsolePort()`: `9001`
- `WebRoutes()`: `[{s3 → 9001}, {s3-api → 9000}]` (unchanged from current Docker version)
- `EnvVars(project)`: identical keys/values to the current Docker S3 so linked projects keep working
- `ReadyCheck()`: `TCPPort: 9000, Timeout: 30s`

### `binaries.Rustfs` (`internal/binaries/rustfs.go`)

- Archive naming: `rustfs-{platform}-latest.zip`, where `{platform}` is:
- `darwin/arm64` → `macos-aarch64` (confirmed by user's curl)
- `darwin/amd64` → `macos-x86_64` (**VERIFY** on releases page)
- `linux/amd64` → `linux-x86_64` (**VERIFY**)
- `linux/arm64` → `linux-aarch64` (**VERIFY**)
- Archive naming: `rustfs-{platform}-latest.zip`, where `{platform}` is (verified 2026-04-14 against alpha.93):
- `darwin/arm64` → `macos-aarch64`
- `darwin/amd64` → `macos-x86_64`
- `linux/amd64` → `linux-x86_64-gnu` (RustFS publishes `-gnu` and `-musl` variants on Linux; pv uses the glibc build)
- `linux/arm64` → `linux-aarch64-gnu`
- Download URL pattern: `https://github.com/rustfs/rustfs/releases/download/{version}/rustfs-{platform}-latest.zip`
- Latest version: `https://api.github.com/repos/rustfs/rustfs/releases/latest`
- `NeedsExtract: true` — `.zip` archive, extract to `~/.pv/internal/bin/rustfs`
- **VERIFY during implementation:** whether the `.zip` contains `rustfs` at the root or inside a subdirectory.
- Latest version: `https://api.github.com/repos/rustfs/rustfs/releases?per_page=1` — the `/releases/latest` endpoint returns 404 because RustFS marks every release as a prerelease. `FetchLatestVersion` must special-case rustfs to parse an array response and take `[0].tag_name`.
- `NeedsExtract: true` — `.zip` archive, extract to `~/.pv/internal/bin/rustfs`. The `rustfs` binary sits at the **root** of the zip (verified 2026-04-14).

### `supervisor` package (`internal/supervisor/`)

Expand Down Expand Up @@ -462,14 +462,13 @@ Added as a new phase in `.github/workflows/e2e.yml`.
- RustFS on-disk format compatibility across upgrades. Documented limitation: "RustFS is alpha; major version bumps may require `pv service:destroy s3` + re-add."
- Performance / load.

## Verification Items (before implementation starts)
## Verification Items (verified 2026-04-14 against alpha.93)

These were assumptions that need ground-truth verification in the first task of the implementation plan:

1. Asset names on RustFS releases for `macos-x86_64`, `linux-x86_64`, `linux-aarch64`. Only `macos-aarch64` is user-confirmed.
2. Exact CLI flags for RustFS — does `./rustfs server --help` accept `--address :9000 --console-address :9001`, or is the syntax different?
3. Archive contents — does the `.zip` have `rustfs` at the root or nested in a subdirectory?
4. Whether RustFS exposes a usable health endpoint (e.g. `/minio/health/live`) — if yes, we can upgrade `ReadyCheck` from TCP to HTTP; if no, TCP is sufficient.
1. Asset names on RustFS releases: confirmed. macOS uses `macos-{aarch64,x86_64}`; Linux uses `linux-{aarch64,x86_64}-{gnu,musl}`. pv ships the `-gnu` variant.
2. CLI flags: confirmed. `rustfs server <VOLUMES>... [--address :9000] [--console-enable] [--console-address :9001]`. `<VOLUMES>` is a positional argument, and `--console-enable` is required to bind port 9001.
3. Archive contents: the `rustfs` binary is at the root of the `.zip` (no subdirectory).
4. Health endpoint: not investigated; `ReadyCheck` stays TCP-based per the original decision.
5. Latest-version API: RustFS marks every release as prerelease, so `/releases/latest` 404s. `FetchLatestVersion` for rustfs uses `/releases?per_page=1` and parses `[0].tag_name` instead.

## Deferred (explicit non-goals for this spec, listed for future reference)

Expand Down
42 changes: 42 additions & 0 deletions internal/binaries/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package binaries

import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
Expand Down Expand Up @@ -168,3 +169,44 @@ func ExtractTarGz(archivePath, destPath, binaryName string) error {
func MakeExecutable(path string) error {
return os.Chmod(path, 0755)
}

// ExtractZip extracts a single binary from a .zip archive at archivePath,
// locating the file by basename and writing it to destPath with 0o755 mode.
// Mirrors the semantics of ExtractTarGz for .zip archives.
func ExtractZip(archivePath, destPath, binaryName string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open zip %s: %w", archivePath, err)
}
defer r.Close()

if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return err
}

for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if filepath.Base(f.Name) != binaryName {
continue
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open %s in zip: %w", f.Name, err)
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
rc.Close()
return fmt.Errorf("create %s: %w", destPath, err)
}
_, copyErr := io.Copy(out, rc)
rc.Close()
out.Close()
if copyErr != nil {
return fmt.Errorf("copy %s: %w", f.Name, copyErr)
}
return nil
}
return fmt.Errorf("binary %q not found in zip %s", binaryName, archivePath)
}
63 changes: 63 additions & 0 deletions internal/binaries/download_zip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package binaries

import (
"archive/zip"
"os"
"path/filepath"
"testing"
)

func TestExtractZip_FlattensSingleBinary(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
fw, err := w.Create("nested/dir/rustfs")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte("#!/bin/sh\necho hi\n")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}

destPath := filepath.Join(tmp, "out", "rustfs")
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
t.Fatal(err)
}
if err := ExtractZip(zipPath, destPath, "rustfs"); err != nil {
t.Fatal(err)
}

info, err := os.Stat(destPath)
if err != nil {
t.Fatalf("expected %s to exist: %v", destPath, err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("expected %s to be executable, got mode %v", destPath, info.Mode())
}
}

func TestExtractZip_MissingBinary(t *testing.T) {
tmp := t.TempDir()
zipPath := filepath.Join(tmp, "test.zip")
f, _ := os.Create(zipPath)
w := zip.NewWriter(f)
fw, _ := w.Create("something-else")
_, _ = fw.Write([]byte("x"))
w.Close()
f.Close()

err := ExtractZip(zipPath, filepath.Join(tmp, "out", "rustfs"), "rustfs")
if err == nil {
t.Fatal("expected error when binary not found in zip")
}
}
21 changes: 19 additions & 2 deletions internal/binaries/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ func InstallBinaryProgress(client *http.Client, b Binary, version string, progre
}

switch b.Name {
case "mago":
return installMago(client, url, progress)
case "composer":
return installComposer(client, url, b, version, progress)
case "mago":
return installMago(client, url, progress)
case "rustfs":
return installRustfs(client, url, progress)
default:
return fmt.Errorf("unknown binary: %s", b.Name)
}
Expand All @@ -53,6 +55,21 @@ func installMago(client *http.Client, url string, progress ProgressFunc) error {
return MakeExecutable(destPath)
}

func installRustfs(client *http.Client, url string, progress ProgressFunc) error {
internalBin := config.InternalBinDir()
archivePath := filepath.Join(internalBin, "rustfs.zip")
destPath := filepath.Join(internalBin, "rustfs")

if err := DownloadProgress(client, url, archivePath, progress); err != nil {
return err
}
if err := ExtractZip(archivePath, destPath, "rustfs"); err != nil {
return err
}
os.Remove(archivePath)
return MakeExecutable(destPath)
}

func installComposer(client *http.Client, url string, b Binary, version string, progress ProgressFunc) error {
destPath := config.ComposerPharPath()

Expand Down
4 changes: 4 additions & 0 deletions internal/binaries/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ func DownloadURL(b Binary, version string) (string, error) {
return magoURL(version)
case "composer":
return composerURL(), nil
case "rustfs":
return rustfsURL(version)
default:
return "", fmt.Errorf("unknown binary: %s", b.Name)
}
Expand All @@ -56,6 +58,8 @@ func LatestVersionURL(b Binary) string {
switch b.Name {
case "mago":
return "https://api.github.com/repos/carthage-software/mago/releases/latest"
case "rustfs":
return "https://api.github.com/repos/rustfs/rustfs/releases?per_page=1"
default:
return ""
}
Expand Down
47 changes: 47 additions & 0 deletions internal/binaries/rustfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package binaries

import (
"fmt"
"runtime"
)

var Rustfs = Binary{
Name: "rustfs",
DisplayName: "RustFS",
NeedsExtract: true,
}

// rustfsPlatformNames maps GOOS/GOARCH to the platform suffix RustFS uses in
// its release asset filenames. Linux releases publish -gnu (glibc) and -musl
// variants; pv ships the -gnu variant for the broadest compatibility with
// typical developer machines.
var rustfsPlatformNames = map[string]map[string]string{
"darwin": {
"arm64": "macos-aarch64",
"amd64": "macos-x86_64",
},
"linux": {
"amd64": "linux-x86_64-gnu",
"arm64": "linux-aarch64-gnu",
},
}

func rustfsArchiveName() (string, error) {
archMap, ok := rustfsPlatformNames[runtime.GOOS]
if !ok {
return "", fmt.Errorf("unsupported OS for RustFS: %s", runtime.GOOS)
}
platform, ok := archMap[runtime.GOARCH]
if !ok {
return "", fmt.Errorf("unsupported architecture for RustFS: %s/%s", runtime.GOOS, runtime.GOARCH)
}
return fmt.Sprintf("rustfs-%s-latest.zip", platform), nil
}

func rustfsURL(version string) (string, error) {
archive, err := rustfsArchiveName()
if err != nil {
return "", err
}
return fmt.Sprintf("https://github.com/rustfs/rustfs/releases/download/%s/%s", version, archive), nil
}
Loading
Loading