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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion catalogue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ ignores the v2 fields. **Always set `"version": 2` when using any v2 field.**
"license": "<SPDX id>",

"metadata_url": "https://<host>/apps/<id>/metadata.json",
"metadata_sha256": "<hex sha256 of metadata.json>"
"metadata_sha256": "<hex sha256 of metadata.json>",

"renamed_to": "<canonical id>, optional",
"hidden": false
}
]
}
Expand All @@ -58,6 +61,26 @@ fields stay required. `pilotctl` decodes the index directly into
`catalogueEntry` in `cmd/pilotctl/appstore_catalogue.go` — any field added
here must also land there.

### Renaming an app (`renamed_to` + `hidden`)

Both are optional v2 fields (**keep `"version": 2`**). To rename an app id
`old → new` without breaking existing installs, do NOT delete the old entry —
the daemon supervisor pins each installed app's publisher key from its
catalogue entry and **fail-closes (stops) an installed app whose id has no
pin**. Instead, replace the old entry's body with a **tombstone**: keep `id`
and `publisher` (so existing installs keep their pin and keep running), set
`"renamed_to": "<new id>"`, and set `"hidden": true`. Drop `bundle_url` /
`bundles` / `metadata_url` (the tombstone is not installable) and delete the old
`apps/<old-id>/` detail dir. Then add the full new entry under the new id and
re-sign.

A bundles-aware `pilotctl` then, for the old id: omits it from `catalogue`,
and on `install`/`view`/`call` prints a deprecation warning and routes to
`renamed_to`. `hidden` alone (without `renamed_to`) just omits an entry from the
listing while keeping it resolvable. Older clients ignore both fields (they see
a normal, pin-only entry). One hop only — a `renamed_to` that points at another
tombstone is a bug and is not chased.

`bundles` is the per-platform map keyed by `"os/arch"`. It is an **optional v2
field — keep `"version": 2`, do NOT bump to 3.** `loadCatalogue` fail-closes on
any version other than 1 or 2, so a version-3 catalogue is rejected wholesale by
Expand Down
66 changes: 0 additions & 66 deletions catalogue/apps/io.pilot.smolmachines/metadata.json

This file was deleted.

32 changes: 4 additions & 28 deletions catalogue/catalogue.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,34 +82,10 @@
},
{
"id": "io.pilot.smolmachines",
"version": "1.2.0",
"description": "Smol Machines — spin up fast, hardware-isolated Linux microVMs on demand (sub-second boot, real hypervisor isolation) to safely run untrusted code, GPU tasks, or headless browser automation in a disposable sandbox.",
"display_name": "Smol Machines",
"vendor": "smol machines",
"license": "Apache-2.0",
"source_url": "https://github.com/smol-machines/smolvm",
"bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-amd64.tar.gz",
"bundle_sha256": "44141c68524081f4ef6b439f4bb18e949b91900db1d93bf88c6178aaaac917e6",
"bundles": {
"darwin/arm64": {
"bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-darwin-arm64.tar.gz",
"bundle_sha256": "b6e6b1604b96939966e4fbe9daaa7f1a994e073e168a1be69c6be55498d3d62c"
},
"darwin/amd64": {
"bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-darwin-amd64.tar.gz",
"bundle_sha256": "06835bbdfabe684bc302c75dbafcc27e820c462bde0bedfc7b78d6e2593443e8"
},
"linux/arm64": {
"bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-arm64.tar.gz",
"bundle_sha256": "1df2c5d0ff9b5ad1db4773278da4f52e60cd77fb9169955c5e81f316da610d3c"
},
"linux/amd64": {
"bundle_url": "https://pub-f09f9a4ea848491198d48e329ba030e3.r2.dev/bundles/io.pilot.smolmachines/1.2.0/io.pilot.smolmachines-1.2.0-linux-amd64.tar.gz",
"bundle_sha256": "44141c68524081f4ef6b439f4bb18e949b91900db1d93bf88c6178aaaac917e6"
}
},
"metadata_url": "https://raw.githubusercontent.com/pilot-protocol/pilotprotocol/main/catalogue/apps/io.pilot.smolmachines/metadata.json",
"metadata_sha256": "f43493f690786b8adbe1ac1072bbec0b0d04e05d9a247e7b14e5d36c4e397a3b",
"renamed_to": "io.pilot.smol",
"hidden": true,
"display_name": "Smol Machines (renamed → io.pilot.smol)",
"description": "Renamed to io.pilot.smol. Install io.pilot.smol — it is the same app plus a cloud plane. This tombstone entry is retained only to keep already-installed copies running and to redirect the old id; it is not listed and is not installable.",
"publisher": "ed25519:3QJm6H6OdjtfrF+Es1lrRjfFmdtq2tGvVSWxia63vcI="
},
{
Expand Down
2 changes: 1 addition & 1 deletion catalogue/catalogue.json.sig
Original file line number Diff line number Diff line change
@@ -1 +1 @@
zjuh59YUSuwetWmEVs3K/0A3v8gsQCRN2NjDW98UoSU8UCj2AGHPWOZg30ncUayLLDove9N7URsQmMqPSF3BDg==
LVW3kLb7ZwK0qfjEY8HWp2oT6uDFkGW5eS8BqbHpgt3x1SeOZD4cXLrz5giWCGHuqhKBV0sfhQ3NdsLF22zYDQ==
14 changes: 14 additions & 0 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -2152,6 +2152,20 @@ func cmdAppStoreCall(args []string) {

sockPath := filepath.Join(appStoreRoot(), appID, "app.sock")
if _, err := os.Stat(sockPath); err != nil {
// No local socket for this id. It may be a rename tombstone — an id that
// was renamed and never installed here. Consult the catalogue (best-effort,
// offline-tolerant) and, if so, warn with a switch hint before failing.
// We do NOT silently retarget to the new app's socket: the method
// namespace changes with the rename (e.g. smolmachines.* → smol.*), so a
// verbatim old method would only 404 there. An existing old-id install
// keeps its own socket at this path, so this branch never fires for it.
if c, lerr := loadCatalogue(); lerr == nil {
if canonicalID, _, renamed := resolveRenamed(c, appID); renamed {
fatalHint("invalid_argument",
fmt.Sprintf("install %q (`pilotctl appstore install %s`) and call its methods — see `pilotctl appstore view %s`", canonicalID, canonicalID, canonicalID),
"app %q was renamed to %q", appID, canonicalID)
}
}
fatalHint("io_error",
"is the daemon running and has it supervised this app yet?",
"socket %s not present: %v", sockPath, err)
Expand Down
81 changes: 71 additions & 10 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,22 @@ type catalogueEntry struct {
// manifest.
MetadataURL string `json:"metadata_url,omitempty"`
MetadataSHA string `json:"metadata_sha256,omitempty"`

// --- rename / deprecation redirect ---
// RenamedTo, when non-empty, marks this entry as a tombstone: the app was
// renamed to the given catalogue id. The entry is kept (not deleted) only to
// preserve the publisher pin for already-installed copies — the daemon
// supervisor fail-closes an installed app whose id has no catalogue pin — and
// to redirect id resolution. `install`/`view`/`call` of this id warn and route
// to RenamedTo; it is omitted from the `catalogue` listing (see Hidden).
// Older clients that don't know this field simply ignore it (omitempty keeps
// v1/v2 wire-compat). At most one hop is followed — a chain is a catalogue bug.
RenamedTo string `json:"renamed_to,omitempty"`

// Hidden, when true, omits this entry from the `catalogue` listing. It is
// still resolvable by id (for redirect and back-compat). A RenamedTo tombstone
// is treated as hidden regardless of this flag.
Hidden bool `json:"hidden,omitempty"`
}

// bundleVariant is one platform's downloadable tarball + its pinned sha256.
Expand Down Expand Up @@ -307,15 +323,25 @@ func cmdAppStoreCatalogue(_ []string) {
"check $PILOT_APPSTORE_CATALOG_URL (currently: "+catalogueURL()+")",
"%v", err)
}
// Tombstones (renamed_to) and explicitly hidden entries are not offered in
// the listing — they exist only to preserve publisher pins and to redirect
// old ids. They remain resolvable by `view`/`install`/`call`.
visible := make([]catalogueEntry, 0, len(c.Apps))
for _, e := range c.Apps {
if e.Hidden || e.RenamedTo != "" {
continue
}
visible = append(visible, e)
}
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(c.Apps)
_ = json.NewEncoder(os.Stdout).Encode(visible)
return
}
if len(c.Apps) == 0 {
if len(visible) == 0 {
fmt.Println("catalogue is empty")
return
}
for _, e := range c.Apps {
for _, e := range visible {
fmt.Printf("%-40s %s\n", e.ID, e.Description)
}
fmt.Println("\nRun 'pilotctl appstore view <id>' for full details.")
Expand All @@ -338,12 +364,43 @@ const (
installSourceLocal
)

// findEntry returns a pointer to the catalogue entry with the given id, or nil.
// The pointer aliases c.Apps' backing array, so it stays valid for reads as long
// as c does.
func (c *catalogue) findEntry(id string) *catalogueEntry {
for i := range c.Apps {
if c.Apps[i].ID == id {
return &c.Apps[i]
}
}
return nil
}

// resolveRenamed follows a rename tombstone. Given a requested id, it returns the
// canonical id and its entry (or nil if the canonical id is absent), plus whether
// a rename was followed. When the id is not a tombstone it returns (id, its own
// entry-or-nil, false). On a followed rename it prints a one-time deprecation
// warning to stderr. Only one hop is followed — a tombstone that points at another
// tombstone is a catalogue bug and is not chased, avoiding any loop.
func resolveRenamed(c *catalogue, id string) (canonicalID string, entry *catalogueEntry, renamed bool) {
e := c.findEntry(id)
if e == nil || e.RenamedTo == "" {
return id, e, false
}
canonical := e.RenamedTo
fmt.Fprintf(os.Stderr,
"warn: app %q has been renamed to %q — use %q instead. The old id is deprecated: it no longer receives updates and will be removed.\n",
id, canonical, canonical)
return canonical, c.findEntry(canonical), true
}

// resolveInstallTarget turns the user's `target` arg into a local
// bundle directory the existing install code can consume, plus a
// source tag indicating which trust regime the bundle came from. If
// `target` matches a catalogue ID, the catalogue entry is fetched,
// verified, and unpacked. Otherwise `target` is treated as a local
// path and the caller is expected to apply sideload policy.
// verified, and unpacked. A `target` that matches a rename tombstone
// warns and installs the canonical app instead. Otherwise `target` is
// treated as a local path and the caller applies sideload policy.
func resolveInstallTarget(target string) (string, installSource, error) {
c, err := loadCatalogue()
if err != nil {
Expand All @@ -353,11 +410,15 @@ func resolveInstallTarget(target string) (string, installSource, error) {
// the user knows their URL or env override might be the issue.
fmt.Fprintf(os.Stderr, "warn: catalogue lookup failed (%v); proceeding with local-path interpretation\n", err)
} else {
for _, e := range c.Apps {
if target == e.ID {
dir, err := fetchAndUnpackBundle(e)
return dir, installSourceCatalogue, err
}
// resolveRenamed also handles the plain (non-renamed) match: it
// returns the exact entry when `target` isn't a tombstone.
canonicalID, entry, renamed := resolveRenamed(c, target)
if renamed && entry == nil {
return "", installSourceLocal, fmt.Errorf("app %q was renamed to %q, but %q is missing from the catalogue", target, canonicalID, canonicalID)
}
if entry != nil {
dir, err := fetchAndUnpackBundle(*entry)
return dir, installSourceCatalogue, err
}
}
info, err := os.Stat(target)
Expand Down
68 changes: 68 additions & 0 deletions cmd/pilotctl/appstore_rename_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import "testing"

// A rename tombstone (renamed_to set) resolves to the canonical entry, and a
// plain id resolves to itself. This is the shared resolver behind the
// install/view/call redirect.
func TestResolveRenamed(t *testing.T) {
c := &catalogue{Apps: []catalogueEntry{
{ID: "io.pilot.smolmachines", RenamedTo: "io.pilot.smol", Hidden: true, Publisher: "ed25519:old"},
{ID: "io.pilot.smol", Version: "1.2.0", Publisher: "ed25519:new"},
{ID: "io.pilot.other", Version: "1.0.0"},
}}

// Tombstone → canonical entry.
canonical, entry, renamed := resolveRenamed(c, "io.pilot.smolmachines")
if !renamed {
t.Fatalf("expected renamed=true for the tombstone id")
}
if canonical != "io.pilot.smol" {
t.Fatalf("canonical = %q, want io.pilot.smol", canonical)
}
if entry == nil || entry.ID != "io.pilot.smol" {
t.Fatalf("entry = %+v, want the io.pilot.smol entry", entry)
}

// Plain id → itself, not flagged renamed.
id, e2, r2 := resolveRenamed(c, "io.pilot.smol")
if r2 || id != "io.pilot.smol" || e2 == nil || e2.ID != "io.pilot.smol" {
t.Fatalf("plain resolve got (%q, %+v, %v), want (io.pilot.smol, io.pilot.smol-entry, false)", id, e2, r2)
}

// Unknown id → itself, nil entry, not renamed.
id3, e3, r3 := resolveRenamed(c, "io.pilot.missing")
if r3 || e3 != nil || id3 != "io.pilot.missing" {
t.Fatalf("unknown resolve got (%q, %+v, %v), want (io.pilot.missing, nil, false)", id3, e3, r3)
}
}

// A tombstone that points at a missing canonical id still reports the rename,
// with a nil entry — callers surface a clear error rather than chasing it.
func TestResolveRenamed_CanonicalMissing(t *testing.T) {
c := &catalogue{Apps: []catalogueEntry{
{ID: "io.pilot.old", RenamedTo: "io.pilot.gone"},
}}
canonical, entry, renamed := resolveRenamed(c, "io.pilot.old")
if !renamed || canonical != "io.pilot.gone" || entry != nil {
t.Fatalf("got (%q, %+v, %v), want (io.pilot.gone, nil, true)", canonical, entry, renamed)
}
}

// Only one hop is followed: a tombstone pointing at another tombstone is a
// catalogue bug and must not loop. resolveRenamed returns the (still-tombstone)
// second entry without recursing.
func TestResolveRenamed_OneHopOnly(t *testing.T) {
c := &catalogue{Apps: []catalogueEntry{
{ID: "a", RenamedTo: "b"},
{ID: "b", RenamedTo: "c"},
{ID: "c", Version: "1.0.0"},
}}
canonical, entry, renamed := resolveRenamed(c, "a")
if !renamed || canonical != "b" {
t.Fatalf("canonical = %q (renamed=%v), want b/true — one hop only", canonical, renamed)
}
if entry == nil || entry.ID != "b" {
t.Fatalf("entry = %+v, want the (tombstone) b entry, not a chased c", entry)
}
}
10 changes: 4 additions & 6 deletions cmd/pilotctl/appstore_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,10 @@ func cmdAppStoreView(args []string) {
var entry *catalogueEntry
var meta *appMetadata
if c, err := loadCatalogue(); err == nil {
for i := range c.Apps {
if c.Apps[i].ID == appID {
entry = &c.Apps[i]
break
}
}
// Follow a rename tombstone so `view <old-id>` resolves to (and displays)
// the renamed app, with a deprecation warning. Local install facts above
// stay keyed to the requested id so an existing old-id install still shows.
_, entry, _ = resolveRenamed(c, appID)
if entry != nil {
if m, err := loadAppMetadata(*entry); err != nil {
fmt.Fprintf(os.Stderr, "warn: could not load detail metadata: %v\n", err)
Expand Down
Loading