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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions catalogue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,33 @@ and `publisher` (so existing installs keep their pin and keep running), set
`"renamed_to": "<new id>"`, set `"hidden": true`, drop `bundle_url` / `bundles` /
`metadata_url` (the tombstone is not installable), and delete the old
`apps/<old-id>/` detail dir. The full new entry lives under the new id, and the
catalogue is re-signed. A bundles-aware `pilotctl` then omits the old id from the
listing 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. One
hop only — a `renamed_to` that points at another tombstone is a bug and is not
chased.
catalogue is re-signed. A `pilotctl` that knows the fields (this repo's, from
the release after v1.13.10) then:

- omits the old id from `catalogue` (text and `--json`);
- on `install <old>`, warns and installs `renamed_to` instead. With
`--version` (a pin, as the managed-fleet reconcile passes) it refuses, naming
the new id: a pin names a release of one app, and the old id has none;
- reports an installed old id in `outdated` as `renamed` (AVAILABLE is the new
id). `upgrade --all` skips it, since the hourly updater runs it and the new id
has its own publisher key and method names; `upgrade <old>` exits 1 with the
install-then-uninstall steps;
- on `view <old>` warns, and on `call` of an old id that is not installed says
it was renamed.

Moving a node over is `pilotctl appstore install <new>` then `pilotctl appstore
uninstall <old> --yes`; uninstall also stops anything still running from the
old app's files (see "What install and upgrade do with app state" below).
Older clients ignore both fields: they list the tombstone and fail its install
with "placeholder sha256". `hidden` alone (without `renamed_to`) just omits an
entry from the listing while keeping it resolvable. One hop only — a
`renamed_to` that points at another tombstone is a bug and is not chased.
`catalogue/lint` checks every tombstone's shape on every PR.

Keep the tombstone while any install of the old id may exist, and keep any
native-tool assets the new id's `install.json` still downloads from the old
id's R2 prefix (io.pilot.smol 1.2.0 stages smolvm from
`io.pilot.smolmachines/1.2.0/`).

## Detail schema (`apps/<id>/metadata.json`)

Expand Down Expand Up @@ -310,6 +331,16 @@ git show origin/main:catalogue/catalogue.json > /tmp/base.json
app empty. It warns loudly and still keeps the backup.
- `upgrade --all` goes on to the next app when one fails, and exits 1 at the
end naming the apps that were not upgraded.
- `uninstall` first stops every process whose executable lives in the app's
dir or in one of its backups (SIGTERM, then SIGKILL after 5 s), then deletes
the dir, then stops anything the supervisor respawned in between. That is
the app itself, an instance orphaned by a daemon that died hard, and what the
app started that detached from it: a smolvm microVM, a daemonized
redis/postgres/mysql server. Nothing could manage those once the dir is
gone. The output (and `--json` `stopped_processes`) names them.
- `install` refuses a bundle whose `install.json` lists native tools but none
for this host's os/arch: the adapter would exit at every start and the
supervisor would suspend it.

## Catalogue signing key

Expand Down
169 changes: 143 additions & 26 deletions catalogue/lint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@
// whose id and app_version match the entry and whose binary matches its
// pinned sha256, and its binary can run on the platform it is published
// for. A legacy single-bundle entry (no `bundles` map) is installed by every
// platform, so it must not ship a native binary at all.
// platform, so it must not ship a native binary at all. A "cli" bundle's
// install.json must carry a native tool for every platform the bundle is
// published for; the adapter exits at every start where it has none.
//
// 3. Rename tombstones (not overridable), checked on every run: an entry with
// no bundle must be a tombstone (renamed_to + hidden + the old publisher
// key, no version, no metadata_url) whose renamed_to names an installable
// entry.
//
// Usage:
//
Expand Down Expand Up @@ -67,12 +74,15 @@ type catalogue struct {
}

type entry struct {
ID string `json:"id"`
Version string `json:"version"`
BundleURL string `json:"bundle_url"`
BundleSHA string `json:"bundle_sha256"`
Bundles map[string]variant `json:"bundles,omitempty"`
RenamedTo string `json:"renamed_to,omitempty"`
ID string `json:"id"`
Version string `json:"version"`
BundleURL string `json:"bundle_url"`
BundleSHA string `json:"bundle_sha256"`
Bundles map[string]variant `json:"bundles,omitempty"`
RenamedTo string `json:"renamed_to,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Publisher string `json:"publisher,omitempty"`
MetadataURL string `json:"metadata_url,omitempty"`
}

type variant struct {
Expand Down Expand Up @@ -125,9 +135,18 @@ type bundleInfo struct {
manifest *manifest
binary binaryFormat
binarySHA string
assets assetPlatforms
fetchError error
}

// assetPlatforms describes a "cli" bundle's install.json: the native tool the
// adapter stages at first start and the os/arch it is published for. nil when
// the bundle has no install.json (or it lists no assets).
type assetPlatforms struct {
Command string
Platforms []string
}

func main() {
base := flag.String("base", "", "catalogue.json at the PR base (omit or empty file: every entry is new)")
head := flag.String("head", "catalogue/catalogue.json", "catalogue.json at the PR head")
Expand Down Expand Up @@ -201,19 +220,26 @@ func (l *linter) lint(base, head *catalogue) []finding {
for _, e := range base.Apps {
baseByID[e.ID] = e
}
headByID := map[string]entry{}
for _, e := range head.Apps {
headByID[e.ID] = e
}
seen := map[string]bool{}
for _, e := range head.Apps {
if seen[e.ID] {
out = append(out, finding{AppID: e.ID, Title: "duplicate catalogue id", Msg: fmt.Sprintf("%s appears more than once in the catalogue", e.ID)})
}
seen[e.ID] = true
// Checked even when untouched: a rename target can change or go
// away in a PR that never edits the tombstone itself.
if tombstone(e) || e.RenamedTo != "" {
out = append(out, checkTombstone(e, headByID)...)
continue // not installable; no bundle to check
}
old, existed := baseByID[e.ID]
if existed && reflect.DeepEqual(old, e) {
continue // untouched entry
}
if tombstone(e) {
continue // not installable; nothing to check
}
out = append(out, l.checkBundles(e)...)
if existed {
if reason := updateTrigger(old, e); reason != "" {
Expand All @@ -227,6 +253,47 @@ func (l *linter) lint(base, head *catalogue) []finding {

func tombstone(e entry) bool { return e.BundleURL == "" && len(e.Bundles) == 0 }

// checkTombstone enforces catalogue/README.md "Renaming an app". An entry with
// no bundle is only meaningful as a rename tombstone: it keeps the old id's
// publisher pin (the daemon stops an installed app whose id has no pin) and
// points pilotctl at the new id. Anything else there is a broken entry that
// pilotctl reports as a "placeholder sha256" at install time.
func checkTombstone(e entry, headByID map[string]entry) []finding {
var out []finding
fail := func(format string, args ...any) {
out = append(out, finding{AppID: e.ID, Title: "bad rename tombstone", Msg: e.ID + ": " + fmt.Sprintf(format, args...)})
}
if e.RenamedTo == "" {
fail("the entry has no bundle_url and no bundles, so nothing can install it; publish a bundle, or make it a rename tombstone (renamed_to + hidden, see catalogue/README.md)")
return out
}
if !tombstone(e) {
fail("renamed_to is set but the entry still publishes a bundle; a tombstone must drop bundle_url and bundles")
}
if e.Version != "" {
fail("a tombstone has no version (it has no release); older pilotctl compares it against installed copies and tries to upgrade them to it")
}
if e.MetadataURL != "" {
fail("a tombstone has no metadata_url; delete apps/%s/ and the pin", e.ID)
}
if !e.Hidden {
fail("a tombstone must set \"hidden\": true")
}
if e.Publisher == "" {
fail("a tombstone must keep the old publisher key: installed copies are pinned to it, and the daemon stops an installed app whose id has no pin")
}
to, ok := headByID[e.RenamedTo]
switch {
case e.RenamedTo == e.ID:
fail("renamed_to names the entry itself")
case !ok:
fail("renamed_to %q is not in the catalogue", e.RenamedTo)
case to.RenamedTo != "" || tombstone(to):
fail("renamed_to %q is itself a tombstone; renames are one hop", e.RenamedTo)
}
return out
}

// resolved mirrors pilotctl's resolveBundle for one platform.
func resolved(e entry, plat string) variant {
if len(e.Bundles) == 0 {
Expand Down Expand Up @@ -387,6 +454,19 @@ func (l *linter) checkBundles(e entry) []finding {
if info.binarySHA != m.Binary.SHA256 {
fail("bundle does not match entry", "%s: binary %s has sha256 %s but the manifest pins %s; pilotctl refuses to install it", where, m.Binary.Path, info.binarySHA, m.Binary.SHA256)
}
if a := info.assets; len(a.Platforms) > 0 {
plats := []string{t.plat}
if t.plat == "" {
plats = knownPlatforms // a legacy bundle is installed everywhere
}
for _, p := range plats {
if !contains(a.Platforms, p) {
fail("no native tool for a published platform",
"%s: install.json ships %s only for %s, so the app exits at every start on %s (\"install assets: stage: no asset for %s\") and the supervisor suspends it. Drop %s from `bundles`, or add its asset",
where, a.Command, strings.Join(a.Platforms, ", "), p, p, p)
}
}
}
bf := info.binary
switch {
case !bf.Native:
Expand All @@ -410,70 +490,107 @@ func (l *linter) inspect(v variant) *bundleInfo {
return info
}
info := &bundleInfo{}
info.manifest, info.binary, info.binarySHA, info.fetchError = l.readBundle(v)
info.manifest, info.binary, info.binarySHA, info.assets, info.fetchError = l.readBundle(v)
l.cache[key] = info
return info
}

func (l *linter) readBundle(v variant) (*manifest, binaryFormat, string, error) {
func (l *linter) readBundle(v variant) (*manifest, binaryFormat, string, assetPlatforms, error) {
var none binaryFormat
body, err := l.fetch(v.BundleURL)
if err != nil {
return nil, none, "", fmt.Errorf("fetch %s: %w", v.BundleURL, err)
return nil, none, "", assetPlatforms{}, fmt.Errorf("fetch %s: %w", v.BundleURL, err)
}
defer body.Close()
tmp, err := os.MkdirTemp("", "catalogue-lint-*")
if err != nil {
return nil, none, "", err
return nil, none, "", assetPlatforms{}, err
}
defer os.RemoveAll(tmp)
tarPath := filepath.Join(tmp, "bundle.tar.gz")
f, err := os.Create(tarPath) // #nosec G304 -- fixed name in our own temp dir
if err != nil {
return nil, none, "", err
return nil, none, "", assetPlatforms{}, err
}
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(body, maxBundleBytes+1))
_ = f.Close()
if err != nil {
return nil, none, "", fmt.Errorf("download %s: %w", v.BundleURL, err)
return nil, none, "", assetPlatforms{}, fmt.Errorf("download %s: %w", v.BundleURL, err)
}
if n > maxBundleBytes {
return nil, none, "", fmt.Errorf("%s is larger than pilotctl's %d-byte download cap", v.BundleURL, maxBundleBytes)
return nil, none, "", assetPlatforms{}, fmt.Errorf("%s is larger than pilotctl's %d-byte download cap", v.BundleURL, maxBundleBytes)
}
if got := hex.EncodeToString(h.Sum(nil)); got != v.BundleSHA {
return nil, none, "", fmt.Errorf("%s has sha256 %s, the catalogue pins %s", v.BundleURL, got, v.BundleSHA)
return nil, none, "", assetPlatforms{}, fmt.Errorf("%s has sha256 %s, the catalogue pins %s", v.BundleURL, got, v.BundleSHA)
}
files, err := extract(tarPath, tmp)
if err != nil {
return nil, none, "", fmt.Errorf("unpack %s: %w", v.BundleURL, err)
return nil, none, "", assetPlatforms{}, fmt.Errorf("unpack %s: %w", v.BundleURL, err)
}
mfPath, ok := files["manifest.json"]
if !ok {
return nil, none, "", errors.New("bundle has no top-level manifest.json")
return nil, none, "", assetPlatforms{}, errors.New("bundle has no top-level manifest.json")
}
raw, err := os.ReadFile(mfPath) // #nosec G304 -- a file we extracted into our temp dir
if err != nil {
return nil, none, "", err
return nil, none, "", assetPlatforms{}, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, none, "", fmt.Errorf("parse manifest.json: %w", err)
return nil, none, "", assetPlatforms{}, fmt.Errorf("parse manifest.json: %w", err)
}
binPath, ok := files[path.Clean(m.Binary.Path)]
if !ok {
return nil, none, "", fmt.Errorf("manifest binary %q is not in the bundle", m.Binary.Path)
return nil, none, "", assetPlatforms{}, fmt.Errorf("manifest binary %q is not in the bundle", m.Binary.Path)
}
bin, err := os.ReadFile(binPath) // #nosec G304 -- a file we extracted into our temp dir
if err != nil {
return nil, none, "", err
return nil, none, "", assetPlatforms{}, err
}
sum := sha256.Sum256(bin)
bf, err := detectBinary(binPath)
if err != nil {
return nil, none, "", err
return nil, none, "", assetPlatforms{}, err
}
var assets assetPlatforms
if p, ok := files["install.json"]; ok {
if assets, err = readAssetPlatforms(p); err != nil {
return nil, none, "", assetPlatforms{}, err
}
}
return &m, bf, hex.EncodeToString(sum[:]), assets, nil
}

// readAssetPlatforms reads a bundle's install.json. It mirrors the adapter's
// StageAssets (app-template internal/scaffold/templates/stage.go.tmpl), which
// picks the assets whose os and arch equal the host's.
func readAssetPlatforms(p string) (assetPlatforms, error) {
raw, err := os.ReadFile(p) // #nosec G304 -- a file we extracted into our temp dir
if err != nil {
return assetPlatforms{}, err
}
var spec struct {
Command string `json:"command"`
Assets []struct {
OS string `json:"os"`
Arch string `json:"arch"`
} `json:"assets"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return assetPlatforms{}, fmt.Errorf("parse install.json: %w", err)
}
out := assetPlatforms{Command: spec.Command}
for _, a := range spec.Assets {
if p := a.OS + "/" + a.Arch; !contains(out.Platforms, p) {
out.Platforms = append(out.Platforms, p)
}
}
sort.Strings(out.Platforms)
if out.Command == "" {
out.Command = "its native tool"
}
return &m, bf, hex.EncodeToString(sum[:]), nil
return out, nil
}

// extract writes each regular file of the gzipped tar at tarPath into dir under
Expand Down
Loading
Loading