mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 20:43:27 -05:00
Checking several add-ons of one library in a row reached the registry only for the first, because the library interval also throttled the per-add-on tag resolution. The others answered unknown at once, so a check-all reported one update and the next pass the next one. The library interval now spares only the library fetch; each add-on is throttled by its own interval. Turning on an installed package whose manifest this host refuses now fails with the typed installed_incompatible cause and a message that says to upgrade, instead of the generic owner-unavailable 503. The upgrade of such a package stays allowed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
739 lines
31 KiB
Go
739 lines
31 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package addons
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/registry"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
|
|
"github.com/mudler/LocalAI/core/services/addons/catalog"
|
|
"github.com/mudler/LocalAI/core/services/addons/packages"
|
|
"github.com/mudler/LocalAI/core/services/addons/updates"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// updateLibrary serves a library whose entries a spec changes. Each entry is
|
|
// a package built from the fixture manifest with its own version label and
|
|
// content, so a digest-only change has the same label and other bytes.
|
|
type updateLibrary struct {
|
|
mu sync.Mutex
|
|
server *httptest.Server
|
|
archives map[string][]byte
|
|
releases []catalog.Release
|
|
sources *catalog.Sources
|
|
// transform lets lifecycle specs publish real typed executable packages.
|
|
transform func(map[string]any) map[string][]byte
|
|
}
|
|
|
|
func (l *updateLibrary) publish(version, channel, content string, requires catalog.Requirements) catalog.Release {
|
|
GinkgoHelper()
|
|
manifest, e := os.ReadFile("../../../tests/addons/fixtures/packages/manifest.json")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
var m map[string]any
|
|
Expect(json.Unmarshal(manifest, &m)).To(Succeed())
|
|
m["version"] = version
|
|
files := map[string][]byte{"bin/sample": []byte(content)}
|
|
if l.transform != nil {
|
|
for name, data := range l.transform(m) {
|
|
files[name] = data
|
|
}
|
|
}
|
|
manifest, _ = json.Marshal(m)
|
|
files["manifest.json"] = manifest
|
|
var b bytes.Buffer
|
|
tw := tar.NewWriter(&b)
|
|
// Stable entry order keeps identical package contents at the same digest.
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
data := files[name]
|
|
Expect(tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))})).To(Succeed())
|
|
_, e = tw.Write(data)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
}
|
|
Expect(tw.Close()).To(Succeed())
|
|
name := fmt.Sprintf("/%s-%s-%x.tar", version, channel, sha256.Sum256([]byte(content)))
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.archives[name] = b.Bytes()
|
|
r := catalog.Release{ID: "sample", Name: "Sample", Version: version, Channel: channel, Artifact: l.server.URL + name,
|
|
Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(b.Bytes())), OS: "linux", Arch: "amd64", Requires: requires}
|
|
// One entry per version and channel: a moving channel is replaced.
|
|
kept := l.releases[:0]
|
|
for _, o := range l.releases {
|
|
if o.Version != version || o.Channel != channel {
|
|
kept = append(kept, o)
|
|
}
|
|
}
|
|
l.releases = append(kept, r)
|
|
return r
|
|
}
|
|
|
|
func (l *updateLibrary) refresh() {
|
|
GinkgoHelper()
|
|
_, e := l.sources.Refresh(ctx, "test")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
}
|
|
|
|
type updateRig struct {
|
|
lib *updateLibrary
|
|
m *Manager
|
|
cfg OwnerConfig
|
|
clk *time.Time
|
|
tick func(time.Duration)
|
|
}
|
|
|
|
func newUpdateRig(withUpdates bool) *updateRig {
|
|
GinkgoHelper()
|
|
root := GinkgoT().TempDir()
|
|
lib := &updateLibrary{archives: map[string][]byte{}}
|
|
lib.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lib.mu.Lock()
|
|
defer lib.mu.Unlock()
|
|
if r.URL.Path == "/library.yaml" {
|
|
_ = catalog.WriteIndex(w, lib.releases)
|
|
return
|
|
}
|
|
if data, ok := lib.archives[r.URL.Path]; ok {
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
DeferCleanup(lib.server.Close)
|
|
sources, e := catalog.NewSources(filepath.Join(root, "sources.json"), admin, catalog.CredentialResolver{})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
lib.sources = sources
|
|
Expect(sources.Save(ctx, actor, catalog.Source{ID: "test", Name: "Test", URL: lib.server.URL + "/library.yaml", Enabled: true, AllowLoopbackHTTP: true})).To(Succeed())
|
|
c := catalog.Catalog{Sources: sources, TargetOS: "linux", TargetArch: "amd64"}
|
|
cfg := OwnerConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "disk-one", Root: filepath.Join(root, "owner")}
|
|
i := &packages.Installer{Root: filepath.Join(cfg.Root, "packages"), Catalog: c, Host: contract.HostCompatibility{Host: 1, API: contract.HostAPIVersion, Protocol: 1, SDK: 1, DataFormat: 1, OS: "linux", Arch: "amd64"}}
|
|
DeferCleanup(func() {
|
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, e error) error {
|
|
if e == nil && info.IsDir() {
|
|
_ = os.Chmod(p, 0700)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
|
rig := &updateRig{lib: lib, cfg: cfg, clk: &now}
|
|
rig.tick = func(d time.Duration) { now = now.Add(d) }
|
|
// The first release exists before the manager starts.
|
|
lib.publish("1.2.3-dev", "development", "build-1", catalog.Requirements{})
|
|
lib.publish("1.2.3", "", "release-1.2.3", catalog.Requirements{})
|
|
lib.refresh()
|
|
rig.m = open(cfg, c, i)
|
|
if withUpdates {
|
|
rig.m.EnableUpdates(updates.Config{Now: func() time.Time { return now }, MinRefresh: time.Second, TTL: time.Hour})
|
|
}
|
|
return rig
|
|
}
|
|
|
|
func upgrade(op string, expected, candidate string) LifecycleCommand {
|
|
return LifecycleCommand{OperationID: op, AddonID: "sample", Action: "upgrade", ExpectedDigest: expected, CandidateDigest: candidate}
|
|
}
|
|
|
|
func (r *updateRig) installChannel() Status {
|
|
GinkgoHelper()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "install", AddonID: "sample", Action: "install", SourceID: "test", Version: "development"})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
s, e := r.m.Status(ctx, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return s
|
|
}
|
|
|
|
func (r *updateRig) installVersion() Status {
|
|
GinkgoHelper()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "install", AddonID: "sample", Action: "install", SourceID: "test", Version: "1.2.3"})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
s, e := r.m.Status(ctx, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return s
|
|
}
|
|
|
|
func (r *updateRig) journalOps() int {
|
|
r.m.mu.Lock()
|
|
defer r.m.mu.Unlock()
|
|
return len(r.m.journal.Operations)
|
|
}
|
|
|
|
var _ = Describe("update library archive fixtures", func() {
|
|
It("publishes identical bytes and digests with sorted archive entries for unchanged inputs and transforms", func() {
|
|
for _, transformed := range []bool{false, true} {
|
|
lib := &updateLibrary{archives: map[string][]byte{}, server: &httptest.Server{URL: "http://fixture.invalid"}}
|
|
wantNames := []string{"bin/sample", "manifest.json"}
|
|
if transformed {
|
|
lib.transform = func(m map[string]any) map[string][]byte {
|
|
m["description"] = "unchanged transform"
|
|
return map[string][]byte{"z-last.txt": []byte("last"), "a-first.txt": []byte("first"), "ui/main.js": []byte("export default {};")}
|
|
}
|
|
wantNames = []string{"a-first.txt", "bin/sample", "manifest.json", "ui/main.js", "z-last.txt"}
|
|
}
|
|
var archives [][]byte
|
|
digests := map[string]bool{}
|
|
for attempt := 0; attempt < 100; attempt++ {
|
|
release := lib.publish("1.2.3", "", "unchanged content", catalog.Requirements{})
|
|
digests[release.Digest] = true
|
|
archives = append(archives, lib.archives[strings.TrimPrefix(release.Artifact, lib.server.URL)])
|
|
}
|
|
Expect(digests).To(HaveLen(1), "unchanged publishes must not change the digest; transformed=%t", transformed)
|
|
for _, archive := range archives {
|
|
Expect(archive).To(Equal(archives[0]))
|
|
tr := tar.NewReader(bytes.NewReader(archive))
|
|
var names []string
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
Expect(err).NotTo(HaveOccurred())
|
|
names = append(names, header.Name)
|
|
}
|
|
Expect(names).To(Equal(wantNames), "archive entries must use lexical order")
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
var _ = Describe("explicit upgrade from an authoritative update check", func() {
|
|
It("reports a digest-only change of a moving channel, applies nothing and offers the pinned upgrade", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
Expect(installed.Selection).To(Equal("development"))
|
|
// The channel moves: same version label, other bytes.
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpdateAvailable))
|
|
Expect(st.InstalledVersion).To(Equal(st.CandidateVersion))
|
|
Expect(st.CandidateVersion).To(Equal("1.2.3-dev"))
|
|
Expect(st.CandidateDigest).To(Equal(next.Digest))
|
|
Expect(st.InstalledDigest).To(Equal(installed.Digest))
|
|
// A check applied nothing.
|
|
after, _ := r.m.Status(ctx, "sample")
|
|
Expect(after.Generation).To(Equal(uint64(1)))
|
|
Expect(after.Generations).To(HaveLen(1))
|
|
|
|
out, e := r.m.Submit(ctx, actor, upgrade("up-1", installed.Digest, next.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(out.Phase).To(Equal("completed"))
|
|
Expect(out.Generation).To(Equal(uint64(2)))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generations[1].Receipt.Digest).To(Equal(next.Digest))
|
|
Expect(s.Generations[1].Receipt.Selection).To(Equal("development"))
|
|
Expect(s.Generations[1].Receipt.SourceID).To(Equal("test"))
|
|
// The previous generation is retained, never deleted.
|
|
Expect(s.Generations[0].Receipt.Directory).To(BeADirectory())
|
|
// The same operation ID is answered with the same result.
|
|
again, e := r.m.Submit(ctx, actor, upgrade("up-1", installed.Digest, next.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(again).To(Equal(out))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
// The status of the new package is up to date and says so.
|
|
st, e = r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpToDate))
|
|
})
|
|
|
|
It("refuses a second operation ID for the same transition, in flight or done, and applies it once", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 4)
|
|
for i := range errs {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, errs[i] = r.m.Submit(ctx, actor, upgrade(fmt.Sprintf("racer-%d", i), installed.Digest, next.Digest))
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
ok := 0
|
|
for _, e := range errs {
|
|
switch {
|
|
case e == nil:
|
|
ok++
|
|
default:
|
|
Expect(e).To(MatchError(ErrTransitionConflict))
|
|
}
|
|
}
|
|
Expect(ok).To(Equal(1))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
// After completion the same transition under yet another ID is refused too.
|
|
_, e := r.m.Submit(ctx, actor, upgrade("late", installed.Digest, next.Digest))
|
|
Expect(e).To(MatchError(ErrTransitionConflict))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
})
|
|
|
|
It("runs concurrent requests with one operation ID once and gives each the same answer", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
var wg sync.WaitGroup
|
|
outs := make([]OperationStatus, 4)
|
|
errs := make([]error, 4)
|
|
for i := range outs {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
outs[i], errs[i] = r.m.Submit(ctx, actor, upgrade("same", installed.Digest, next.Digest))
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for i := range outs {
|
|
Expect(errs[i]).NotTo(HaveOccurred())
|
|
Expect(outs[i]).To(Equal(outs[0]))
|
|
Expect(outs[i].Phase).To(Equal("completed"))
|
|
}
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
})
|
|
|
|
It("serializes conflicting upgrades by the installed digest and refuses the loser without a record", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
b := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
_, e := r.m.Submit(ctx, actor, upgrade("first", installed.Digest, b.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
before := r.journalOps()
|
|
// A frontend that still shows the old state asks for another candidate.
|
|
c := r.lib.publish("1.2.3-dev", "development", "build-3", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
_, e = r.m.Submit(ctx, actor, upgrade("second", installed.Digest, c.Digest))
|
|
Expect(e).To(MatchError(ErrStaleInstalled))
|
|
Expect(r.journalOps()).To(Equal(before), "a refused request leaves nothing behind")
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
})
|
|
|
|
It("refuses a candidate that is not the one the library resolves now", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
old := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
// The channel moves again before the administrator confirms.
|
|
r.lib.publish("1.2.3-dev", "development", "build-3", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
before := r.journalOps()
|
|
_, e := r.m.Submit(ctx, actor, upgrade("pinned", installed.Digest, old.Digest))
|
|
Expect(e).To(MatchError(ErrCandidateChanged))
|
|
Expect(r.journalOps()).To(Equal(before))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
})
|
|
|
|
It("refuses an upgrade to the digest that is already installed", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
d := s.Generations[0].Receipt.Digest
|
|
_, e := r.m.Submit(ctx, actor, upgrade("noop", d, d))
|
|
Expect(e).To(MatchError(ErrCandidateChanged))
|
|
})
|
|
|
|
It("rechecks compatibility at execution and refuses an incompatible candidate", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{Host: contract.VersionRange{Min: 5, Max: 6}})
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.Incompatible))
|
|
Expect(st.Reasons).To(ConsistOf(updates.ReasonHost))
|
|
_, e = r.m.Submit(ctx, actor, upgrade("inc", installed.Digest, next.Digest))
|
|
Expect(e).To(MatchError(ErrIncompatible))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
})
|
|
|
|
It("selects a newer immutable version and treats a rewritten version as an integrity error", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installVersion()
|
|
installed := s.Generations[0].Receipt
|
|
Expect(installed.Selection).To(Equal("1.2.3"))
|
|
rewritten := r.lib.publish("1.2.3", "", "release-rewritten", catalog.Requirements{})
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.IntegrityError))
|
|
_, e = r.m.Submit(ctx, actor, upgrade("rewrite", installed.Digest, rewritten.Digest))
|
|
Expect(e).To(MatchError(ErrIntegrity))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
|
|
// Restored to the original bytes, and a newer version exists.
|
|
r.lib.mu.Lock()
|
|
r.lib.releases = r.lib.releases[:0]
|
|
r.lib.mu.Unlock()
|
|
orig := r.lib.publish("1.2.3", "", "release-1.2.3", catalog.Requirements{})
|
|
Expect(orig.Digest).To(Equal(installed.Digest))
|
|
newer := r.lib.publish("1.3.0", "", "release-1.3.0", catalog.Requirements{})
|
|
r.tick(2 * time.Second)
|
|
st, e = r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpdateAvailable))
|
|
Expect(st.CandidateVersion).To(Equal("1.3.0"))
|
|
out, e := r.m.Submit(ctx, actor, upgrade("v13", installed.Digest, newer.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(out.Phase).To(Equal("completed"))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generations[1].Receipt.Manifest.Version).To(Equal("1.3.0"))
|
|
Expect(s.Generations[1].Receipt.Selection).To(Equal("1.3.0"))
|
|
})
|
|
|
|
It("keeps the last good generation when the candidate cannot be staged", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
// The archive no longer matches the digest the library lists.
|
|
r.lib.mu.Lock()
|
|
for name := range r.lib.archives {
|
|
if r.lib.archives[name] != nil && fmt.Sprintf("sha256:%x", sha256.Sum256(r.lib.archives[name])) == next.Digest {
|
|
r.lib.archives[name] = []byte("corrupt")
|
|
}
|
|
}
|
|
r.lib.mu.Unlock()
|
|
out, e := r.m.Submit(ctx, actor, upgrade("broken", installed.Digest, next.Digest))
|
|
Expect(e).To(MatchError(ErrOperationFailed))
|
|
Expect(out.Phase).NotTo(Equal("completed"))
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Installed).To(BeTrue())
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
Expect(s.Generations).To(HaveLen(1))
|
|
Expect(s.Generations[0].Receipt.Directory).To(BeADirectory())
|
|
// A failed attempt does not block a new operation for the same candidate.
|
|
r.lib.mu.Lock()
|
|
for name := range r.lib.archives {
|
|
if string(r.lib.archives[name]) == "corrupt" {
|
|
delete(r.lib.archives, name)
|
|
}
|
|
}
|
|
r.lib.mu.Unlock()
|
|
fixed := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
Expect(fixed.Digest).To(Equal(next.Digest))
|
|
r.lib.refresh()
|
|
retry, e := r.m.Submit(ctx, actor, upgrade("again", installed.Digest, next.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(retry.Phase).To(Equal("completed"))
|
|
})
|
|
|
|
It("never switches the library or channel, and a failed check is never up to date", func() {
|
|
r := newUpdateRig(true)
|
|
s := r.installChannel()
|
|
installed := s.Generations[0].Receipt
|
|
// Another channel and a newer version exist; the installed channel is unchanged.
|
|
r.lib.publish("9.9.9", "latest", "other", catalog.Requirements{})
|
|
r.lib.publish("1.9.0", "", "newer", catalog.Requirements{})
|
|
r.lib.refresh()
|
|
st, _ := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(st.State).To(Equal(updates.UpToDate))
|
|
Expect(st.Channel).To(Equal("development"))
|
|
// The library goes away.
|
|
r.lib.server.Close()
|
|
r.tick(time.Minute)
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.CheckFailed))
|
|
Expect(st.LastSuccess).NotTo(BeZero())
|
|
Expect(st.Error).To(Equal(updates.ClassRefresh))
|
|
// And the upgrade cannot be started from it.
|
|
_, e = r.m.Submit(ctx, actor, upgrade("nolib", installed.Digest, "sha256:"+fmt.Sprintf("%064x", 1)))
|
|
Expect(e).To(HaveOccurred())
|
|
s, _ = r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
})
|
|
|
|
It("lets an administrator refresh and everyone with the feature read a redacted status", func() {
|
|
r := newUpdateRig(true)
|
|
r.installChannel()
|
|
other := contract.Actor{UserID: "member", CredentialID: "key2", Mode: "named-user"}
|
|
_, e := r.m.CheckUpdates(ctx, other, "sample")
|
|
Expect(e).To(MatchError(ErrForbidden))
|
|
// Not enabled: the add-on does not exist for a non-administrator.
|
|
_, e = r.m.UpdateStatus(ctx, other, "sample")
|
|
Expect(e).To(MatchError(ErrForbidden))
|
|
_, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable", AddonID: "sample", Action: "enable", Generation: 1})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
_, e = r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
view, e := r.m.UpdateStatus(ctx, other, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(view.State).To(Equal(updates.UpdateAvailable))
|
|
Expect(view.InstalledVersion).To(Equal("1.2.3-dev"))
|
|
Expect(view.CandidateDigest + view.InstalledDigest + view.SourceID + view.Channel).To(BeEmpty())
|
|
full, e := r.m.UpdateStatus(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(full.CandidateDigest).NotTo(BeEmpty())
|
|
// A non-administrator cannot upgrade, whatever the pins say.
|
|
_, e = r.m.Submit(ctx, other, upgrade("nope", full.InstalledDigest, full.CandidateDigest))
|
|
Expect(e).To(MatchError(ErrForbidden))
|
|
})
|
|
|
|
It("shows the pending update in the apps list from the cache only", func() {
|
|
r := newUpdateRig(true)
|
|
r.installChannel()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable", AddonID: "sample", Action: "enable", Generation: 1})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
list, e := r.m.Apps(ctx, actor)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(list).To(HaveLen(1))
|
|
Expect(list[0].Update).To(BeEmpty(), "never checked: no chip")
|
|
r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
// The library server is gone: the apps list must not need it.
|
|
r.lib.server.Close()
|
|
list, _ = r.m.Apps(ctx, actor)
|
|
Expect(list[0].Update).To(BeEmpty(), "a library change nobody checked is not reported")
|
|
_, _ = r.m.CheckUpdates(ctx, actor, "sample")
|
|
list, _ = r.m.Apps(ctx, actor)
|
|
Expect(list[0].Update).To(BeEmpty(), "a failed check is not an update")
|
|
installed, e := r.m.Installed(ctx, actor)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(installed[0].Update).NotTo(BeNil())
|
|
Expect(installed[0].Update.Available).To(BeFalse())
|
|
Expect(installed[0].Update.State).To(Equal("check-failed"))
|
|
})
|
|
|
|
It("reports the pending update of an installed add-on that is turned off, and keeps the desired state through an upgrade", func() {
|
|
r := newUpdateRig(true)
|
|
installed := r.installChannel().Generations[0].Receipt
|
|
// Never enabled: the owner's desired state is off.
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpdateAvailable), "a disabled add-on is still checked")
|
|
list, e := r.m.Apps(ctx, actor)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(list).To(HaveLen(1))
|
|
Expect(list[0].Enabled).To(BeFalse())
|
|
Expect(list[0].Update).To(Equal("update-pending"))
|
|
inst, e := r.m.Installed(ctx, actor)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(inst[0].Update).NotTo(BeNil())
|
|
Expect(inst[0].Update.Available).To(BeTrue())
|
|
// Upgrading it does not turn it on.
|
|
_, e = r.m.Submit(ctx, actor, upgrade("up-off", installed.Digest, next.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
after, _ := r.m.Status(ctx, "sample")
|
|
Expect(after.DesiredEnabled).To(BeFalse())
|
|
// Turning it on is durable, and a later upgrade keeps it on.
|
|
_, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable-1", AddonID: "sample", Action: "enable", Generation: after.Generation})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
again := r.lib.publish("1.2.3-dev", "development", "build-3", catalog.Requirements{})
|
|
r.tick(time.Minute)
|
|
st, _ = r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(st.State).To(Equal(updates.UpdateAvailable))
|
|
_, e = r.m.Submit(ctx, actor, upgrade("up-on", next.Digest, again.Digest))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
final, _ := r.m.Status(ctx, "sample")
|
|
Expect(final.DesiredEnabled).To(BeTrue(), "an upgrade must not flip the desired state")
|
|
})
|
|
|
|
It("refuses to turn on an installed package the host cannot run with a typed cause, and an upgrade makes it startable", func() {
|
|
r := newUpdateRig(true)
|
|
installed := r.installChannel().Generations[0].Receipt
|
|
// The installed package was built for an API range this host refuses.
|
|
r.m.mu.Lock()
|
|
st := r.m.journal.Addons["sample"]
|
|
for i := range st.Generations {
|
|
st.Generations[i].Receipt.Manifest.API = contract.VersionRange{Min: 1, Max: 1}
|
|
}
|
|
r.m.journal.Addons["sample"] = st
|
|
r.m.mu.Unlock()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable-old", AddonID: "sample", Action: "enable", Generation: 1})
|
|
Expect(e).To(HaveOccurred())
|
|
var cause *FailureCause
|
|
Expect(errors.As(e, &cause)).To(BeTrue(), "the refusal is typed, not a generic outage")
|
|
Expect(cause.Code).To(Equal(CauseInstalledIncompatible))
|
|
after, _ := r.m.Status(ctx, "sample")
|
|
Expect(after.DesiredEnabled).To(BeFalse())
|
|
// The check still runs for it and offers the upgrade.
|
|
next := r.lib.publish("1.2.3-dev", "development", "build-2", catalog.Requirements{})
|
|
check, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(check.State).To(Equal(updates.UpdateAvailable))
|
|
_, e = r.m.Submit(ctx, actor, upgrade("up-old", installed.Digest, next.Digest))
|
|
Expect(e).NotTo(HaveOccurred(), "an incompatible installed package can be upgraded")
|
|
_, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "enable-new", AddonID: "sample", Action: "enable", Generation: 2})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("does not accept pins without an update checker or on another action", func() {
|
|
r := newUpdateRig(false)
|
|
s := r.installChannel()
|
|
_, e := r.m.Submit(ctx, actor, upgrade("nochecker", s.Generations[0].Receipt.Digest, "sha256:"+fmt.Sprintf("%064x", 2)))
|
|
Expect(e).To(HaveOccurred())
|
|
Expect(e).NotTo(MatchError(ErrOperationFailed))
|
|
_, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "x", AddonID: "sample", Action: "disable", ExpectedDigest: "a", CandidateDigest: "b"})
|
|
Expect(e).To(MatchError(ErrConflict))
|
|
_, e = r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "y", AddonID: "sample", Action: "upgrade", ExpectedDigest: s.Generations[0].Receipt.Digest})
|
|
Expect(e).To(MatchError(ErrConflict))
|
|
})
|
|
|
|
It("keeps the unpinned typed upgrade of an earlier release working", func() {
|
|
r := newUpdateRig(true)
|
|
r.installVersion()
|
|
out, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "legacy", AddonID: "sample", Action: "upgrade", SourceID: "test", Version: "1.2.3"})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(out.Phase).To(Equal("completed"))
|
|
})
|
|
})
|
|
|
|
// pushDevelopment builds a package from the fixture manifest with the version
|
|
// label a development build carries and pushes it to the tag of artifact, the
|
|
// way the publish workflow moves the development tag. It returns the digest
|
|
// the tag names now.
|
|
func pushDevelopment(artifact, version, content string) string {
|
|
GinkgoHelper()
|
|
manifest, e := os.ReadFile("../../../tests/addons/fixtures/packages/manifest.json")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
var m map[string]any
|
|
Expect(json.Unmarshal(manifest, &m)).To(Succeed())
|
|
m["version"] = version
|
|
manifest, _ = json.Marshal(m)
|
|
var b bytes.Buffer
|
|
tw := tar.NewWriter(&b)
|
|
for _, f := range []struct {
|
|
name string
|
|
data []byte
|
|
}{{"manifest.json", manifest}, {"bin/sample", []byte(content)}} {
|
|
Expect(tw.WriteHeader(&tar.Header{Name: f.name, Mode: 0755, Size: int64(len(f.data))})).To(Succeed())
|
|
_, e = tw.Write(f.data)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
}
|
|
Expect(tw.Close()).To(Succeed())
|
|
layer, e := tarball.LayerFromReader(bytes.NewReader(b.Bytes()))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
img, e := mutate.AppendLayers(empty.Image, layer)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
cfg, e := img.ConfigFile()
|
|
Expect(e).NotTo(HaveOccurred())
|
|
cfg.OS, cfg.Architecture = "linux", "amd64"
|
|
img, e = mutate.ConfigFile(img, cfg)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
ref, e := name.ParseReference(strings.TrimPrefix(artifact, "oci://"))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(remote.Write(ref, img)).To(Succeed())
|
|
d, e := img.Digest()
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return d.String()
|
|
}
|
|
|
|
// The official library lists a development add-on as a mutable entry: a
|
|
// moving tag under the label 0.0.0-development, with no digest. The installed
|
|
// package carries its own label (0.0.0-dev.<commit>).
|
|
var _ = Describe("explicit upgrade from an authoritative update check, for a development build installed from a moving tag", func() {
|
|
It("is offered when the tag moved, pinned to the build the tag names, and upgrades to exactly that build", func() {
|
|
r := newUpdateRig(false)
|
|
reg := httptest.NewServer(registry.New())
|
|
DeferCleanup(reg.Close)
|
|
artifact := "oci://" + strings.TrimPrefix(reg.URL, "http://") + "/local-ai-addons:sample-development"
|
|
current := pushDevelopment(artifact, "0.0.0-dev.aaaaaaaaaaaa", "dev-1")
|
|
first := current
|
|
r.m.installer.Lookup = func(context.Context, string, string) (string, error) { return current, nil }
|
|
r.m.EnableUpdates(updates.Config{Now: func() time.Time { return *r.clk }, MinRefresh: time.Second, TTL: time.Hour})
|
|
r.lib.mu.Lock()
|
|
r.lib.releases = append(r.lib.releases, catalog.Release{ID: "sample", Name: "Sample", Version: "0.0.0-development", Channel: "development",
|
|
Artifact: artifact, Mutable: true, OS: "linux", Arch: "amd64"})
|
|
r.lib.mu.Unlock()
|
|
r.lib.refresh()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "install", AddonID: "sample", Action: "install", SourceID: "test", Version: "0.0.0-development"})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpToDate), "the tag still names the installed build")
|
|
|
|
current = pushDevelopment(artifact, "0.0.0-dev.bbbbbbbbbbbb", "dev-2")
|
|
r.tick(2 * time.Second)
|
|
st, e = r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpdateAvailable))
|
|
Expect(st.InstalledDigest).To(Equal(first))
|
|
Expect(st.CandidateDigest).To(Equal(current))
|
|
// The installed view gives the page the digest it pins.
|
|
installed, e := r.m.Installed(ctx, actor)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(installed[0].Update.Digest).To(Equal(current))
|
|
|
|
out, e := r.m.Submit(ctx, actor, upgrade("up-dev", first, current))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(out.Phase).To(Equal("completed"))
|
|
s, _ := r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(2)))
|
|
Expect(s.Generations[1].Receipt.Digest).To(Equal(current))
|
|
|
|
st, e = r.m.UpdateStatus(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.State).To(Equal(updates.UpToDate))
|
|
})
|
|
|
|
It("refuses the upgrade when the tag moved again after the check, and installs nothing", func() {
|
|
r := newUpdateRig(false)
|
|
reg := httptest.NewServer(registry.New())
|
|
DeferCleanup(reg.Close)
|
|
artifact := "oci://" + strings.TrimPrefix(reg.URL, "http://") + "/local-ai-addons:sample-development"
|
|
current := pushDevelopment(artifact, "0.0.0-dev.aaaaaaaaaaaa", "dev-1")
|
|
first := current
|
|
r.m.installer.Lookup = func(context.Context, string, string) (string, error) { return current, nil }
|
|
r.m.EnableUpdates(updates.Config{Now: func() time.Time { return *r.clk }, MinRefresh: time.Second, TTL: time.Hour})
|
|
r.lib.mu.Lock()
|
|
r.lib.releases = append(r.lib.releases, catalog.Release{ID: "sample", Name: "Sample", Version: "0.0.0-development", Channel: "development",
|
|
Artifact: artifact, Mutable: true, OS: "linux", Arch: "amd64"})
|
|
r.lib.mu.Unlock()
|
|
r.lib.refresh()
|
|
_, e := r.m.Submit(ctx, actor, LifecycleCommand{OperationID: "install", AddonID: "sample", Action: "install", SourceID: "test", Version: "0.0.0-development"})
|
|
Expect(e).NotTo(HaveOccurred())
|
|
checked := pushDevelopment(artifact, "0.0.0-dev.bbbbbbbbbbbb", "dev-2")
|
|
current = checked
|
|
st, e := r.m.CheckUpdates(ctx, actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(st.CandidateDigest).To(Equal(checked))
|
|
current = pushDevelopment(artifact, "0.0.0-dev.cccccccccccc", "dev-3")
|
|
_, e = r.m.Submit(ctx, actor, upgrade("up-moved", first, checked))
|
|
Expect(e).To(MatchError(ErrCandidateChanged))
|
|
s, _ := r.m.Status(ctx, "sample")
|
|
Expect(s.Generation).To(Equal(uint64(1)))
|
|
})
|
|
})
|
|
|
|
var _ = context.Background
|