diff --git a/core/application/addons_test.go b/core/application/addons_test.go index 2ed580e04..8f39317e4 100644 --- a/core/application/addons_test.go +++ b/core/application/addons_test.go @@ -90,6 +90,36 @@ var _ = Describe("addon bootstrap", func() { Expect(e).NotTo(HaveOccurred()) }) + It("registers the official library once and respects the admin afterwards", func() { + requireAddonRuntime() + c := cfg() + c.OfficialLibrary = true + Expect(os.MkdirAll(c.Root, 0700)).To(Succeed()) + // The default library is on the network; keep this spec offline by + // cancelling the startup refresh. The seed happens before it. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + a := setup() + a.applicationConfig.Addons = c + a.applicationConfig.Context = ctx + _ = a.startAddons() + sources, e := catalog.NewSources(filepath.Join(c.Root, "sources.json"), c.AdminCheck, catalog.CredentialResolver{}) + Expect(e).NotTo(HaveOccurred()) + list, e := sources.List(context.Background(), contract.Actor{}) + Expect(e).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].URL).To(Equal(catalog.OfficialLibraryURL)) + // The admin removes it; the next start does not add it again. + Expect(sources.Save(context.Background(), contract.Actor{}, catalog.Source{ID: catalog.OfficialLibraryID, Removed: true})).To(Succeed()) + a = setup() + a.applicationConfig.Addons = c + Expect(a.startAddons()).To(Succeed()) + DeferCleanup(a.Shutdown) + list, e = a.AddonSources().List(context.Background(), contract.Actor{}) + Expect(e).NotTo(HaveOccurred()) + Expect(list).To(BeEmpty()) + }) + It("bounds offline refresh and ignores disabled sources and forwarders", func() { requireAddonRuntime() var calls atomic.Int32 diff --git a/core/application/application.go b/core/application/application.go index e0a4a9178..f8e7edcaf 100644 --- a/core/application/application.go +++ b/core/application/application.go @@ -867,6 +867,11 @@ func (a *Application) startAddons() error { if err != nil { return err } + if c.OfficialLibrary { + if _, err = sources.SeedDefault(catalog.OfficialLibrary()); err != nil { + return err + } + } host := c.Host if host == (contract.HostCompatibility{}) { host = contract.HostCompatibility{Host: 1, API: 1, Protocol: 1, SDK: contract.HostSDKVersion, DataFormat: 1, OS: runtime.GOOS, Arch: runtime.GOARCH} diff --git a/core/services/addons/catalog/official.go b/core/services/addons/catalog/official.go new file mode 100644 index 000000000..ffc552656 --- /dev/null +++ b/core/services/addons/catalog/official.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +package catalog + +const ( + // OfficialLibraryID is the source ID of the official add-on library. + OfficialLibraryID = "official" + // OfficialLibraryURL is the YAML index of the first-party add-ons, kept in + // this repository at gallery/addons/index.yaml and served from the master + // branch, like the model gallery. + OfficialLibraryURL = "https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/addons/index.yaml" +) + +// OfficialLibrary is the library a standard install registers on its first +// start. It requires the signature of the add-on package workflow that runs on +// master, so a package that master did not build and sign is refused. +func OfficialLibrary() Source { + return Source{ + ID: OfficialLibraryID, + Name: "Official add-ons", + URL: OfficialLibraryURL, + Enabled: true, + Policy: VerificationPolicy{ + RequireSignature: true, + Issuer: "https://token.actions.githubusercontent.com", + Identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master", + }, + } +} diff --git a/core/services/addons/catalog/official_test.go b/core/services/addons/catalog/official_test.go new file mode 100644 index 000000000..84a73dc26 --- /dev/null +++ b/core/services/addons/catalog/official_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +package catalog + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("official library default", func() { + It("is a valid, signature-requiring source", func() { + src := OfficialLibrary() + Expect(validSource(src)).To(BeTrue()) + Expect(src.Enabled).To(BeTrue()) + Expect(src.Policy.RequireSignature).To(BeTrue()) + Expect(src.URL).To(HavePrefix("https://raw.githubusercontent.com/mudler/LocalAI/master/")) + }) + It("is added when there is no sources.json and is written to it", func() { + s := setup() + added, e := s.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + Expect(added).To(BeTrue()) + list, e := s.List(ctx, admin) + Expect(e).NotTo(HaveOccurred()) + Expect(list).To(HaveLen(1)) + Expect(list[0].ID).To(Equal(OfficialLibraryID)) + info, e := os.Stat(s.path) + Expect(e).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) + reloaded, e := NewSources(s.path, checker, CredentialResolver{}) + Expect(e).NotTo(HaveOccurred()) + Expect(reloaded.sources).To(HaveKey(OfficialLibraryID)) + }) + It("keeps a sources.json the admin already has", func() { + s := setup() + save(s, "mine", "https://example.org/index.yaml") + reloaded, e := NewSources(s.path, checker, CredentialResolver{}) + Expect(e).NotTo(HaveOccurred()) + added, e := reloaded.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + Expect(added).To(BeFalse()) + Expect(reloaded.sources).To(HaveLen(1)) + Expect(reloaded.sources).To(HaveKey("mine")) + }) + It("does not add the default again after the admin removed it", func() { + s := setup() + _, e := s.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + Expect(s.Save(ctx, admin, Source{ID: OfficialLibraryID, Removed: true})).To(Succeed()) + reloaded, e := NewSources(s.path, checker, CredentialResolver{}) + Expect(e).NotTo(HaveOccurred()) + added, e := reloaded.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + Expect(added).To(BeFalse()) + Expect(reloaded.sources).To(BeEmpty()) + }) + It("seeds only once per instance", func() { + s := setup() + _, e := s.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + added, e := s.SeedDefault(OfficialLibrary()) + Expect(e).NotTo(HaveOccurred()) + Expect(added).To(BeFalse()) + }) +}) + +var _ = Describe("official library index", func() { + It("lists the four first-party add-ons in the library format", func() { + data, e := os.ReadFile("../../../../gallery/addons/index.yaml") + Expect(e).NotTo(HaveOccurred()) + releases, e := DecodeIndex(data) + Expect(e).NotTo(HaveOccurred()) + ids := []string{} + for _, r := range releases { + ids = append(ids, r.AddonID) + Expect(digestPattern.MatchString(r.Digest)).To(BeTrue(), r.AddonID) + Expect(versionPattern.MatchString(r.Version)).To(BeTrue(), r.AddonID) + Expect(r.Artifact).To(HavePrefix("oci://"), r.AddonID) + } + Expect(ids).To(ConsistOf("fine-tuning", "quantization", "voice", "face")) + }) +}) diff --git a/core/services/addons/catalog/sources.go b/core/services/addons/catalog/sources.go index 319f69614..cad4b6036 100644 --- a/core/services/addons/catalog/sources.go +++ b/core/services/addons/catalog/sources.go @@ -77,6 +77,10 @@ type Sources struct { credentials CredentialResolver sources map[string]Source cache map[string]cached + // fresh is true when no configuration file existed at construction. Only + // then may SeedDefault add a library; a file the admin already has, even an + // empty one, is never touched. + fresh bool } var idPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) @@ -91,6 +95,7 @@ func NewSources(path string, admin AdminCheck, credentials CredentialResolver) ( s := &Sources{path: path, admin: admin, credentials: credentials, sources: map[string]Source{}, cache: map[string]cached{}} info, e := os.Lstat(path) if os.IsNotExist(e) { + s.fresh = true return s, nil } if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 { @@ -212,6 +217,16 @@ func (s *Sources) Save(ctx context.Context, actor contract.Actor, src Source) er } else { next[src.ID] = src } + if e := s.persist(next); e != nil { + return e + } + s.sources = next + delete(s.cache, src.ID) + return nil +} + +// persist writes the whole configuration atomically. The caller holds s.mu. +func (s *Sources) persist(next map[string]Source) error { data, e := json.Marshal(next) if e != nil || len(data) > 1<<20 { return ErrInvalid @@ -231,10 +246,32 @@ func (s *Sources) Save(ctx context.Context, actor contract.Actor, src Source) er if e = os.Rename(f.Name(), s.path); e != nil { return ErrUnavailable } - s.sources = next - delete(s.cache, src.ID) return nil } + +// SeedDefault adds a default library on the first start, when the owner has no +// configuration file yet, and writes the file. The file is the record: once it +// exists, the admin's choices win, so a default that the admin removes stays +// removed (the file then holds the remaining libraries, possibly none) and a +// library the admin edited is not reset. It reports whether it added src. +// It is trusted startup code and takes no actor; it never runs for a request. +func (s *Sources) SeedDefault(src Source) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.fresh { + return false, nil + } + if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) { + return false, ErrInvalid + } + next := map[string]Source{src.ID: src} + if e := s.persist(next); e != nil { + return false, e + } + s.sources = next + s.fresh = false + return true, nil +} func decode(data []byte, out any) error { d := json.NewDecoder(bytes.NewReader(data)) d.DisallowUnknownFields() diff --git a/gallery/addons/index.yaml b/gallery/addons/index.yaml new file mode 100644 index 000000000..63208f496 --- /dev/null +++ b/gallery/addons/index.yaml @@ -0,0 +1,77 @@ +# Official add-on library: the first-party add-ons, one entry for each package. +# LocalAI registers this file as its default library (the raw URL of this file +# on the master branch). The entries are rendered by the add-on package +# workflow with `addonpkg catalog`, so the digests pin the published images: +# regenerate this file from the workflow output, never edit a digest by hand. +- addonId: "fine-tuning" + name: Fine-tuning + description: "Train an adapter on your own data, then export it as a model you can chat with." + version: "0.0.0-dev.99d1dca2c869" + channel: "development" + artifact: "oci://quay.io/go-skynet/local-ai-addons/fine-tuning:sha-99d1dca2c869" + digest: "sha256:9e2168ea73c32427e9f7d3c071d2dd36ca37877a78dd3bdbacebd8b9fe6e270a" + os: "linux" + arch: "amd64" + policy: + requireSignature: true + issuer: "https://token.actions.githubusercontent.com" + identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" + ui: + title: "Fine-tuning" + description: "Train an adapter on your own data, then export it as a model you can chat with." + mainPage: "jobs" + icon: "ui/icon.png" +- addonId: "quantization" + name: Quantization + description: "Shrink a model to a smaller quantized file, then import it so you can chat with it." + version: "0.0.0-dev.99d1dca2c869" + channel: "development" + artifact: "oci://quay.io/go-skynet/local-ai-addons/quantization:sha-99d1dca2c869" + digest: "sha256:d1dd5b3cfc7e96cac2825cd8aaf86ebacb49d686f433f1969ad149928d0858ad" + os: "linux" + arch: "amd64" + policy: + requireSignature: true + issuer: "https://token.actions.githubusercontent.com" + identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" + ui: + title: "Quantization" + description: "Shrink a model to a smaller quantized file, then import it so you can chat with it." + mainPage: "jobs" + icon: "ui/icon.png" +- addonId: "voice" + name: Voice + description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them." + version: "0.0.0-dev.99d1dca2c869" + channel: "development" + artifact: "oci://quay.io/go-skynet/local-ai-addons/voice:sha-99d1dca2c869" + digest: "sha256:1d9ff8c81ad8963c2a32426a97dfb4d2935833f9a36615af9c132040672236a2" + os: "linux" + arch: "amd64" + policy: + requireSignature: true + issuer: "https://token.actions.githubusercontent.com" + identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" + ui: + title: "Voice" + description: "Recognise who is speaking, keep the voices that text-to-speech can use, and speak text with them." + mainPage: "recognition" + icon: "ui/icon.png" +- addonId: "face" + name: Face + description: "Find out whose face is in a photo, or whether two photos show the same person." + version: "0.0.0-dev.99d1dca2c869" + channel: "development" + artifact: "oci://quay.io/go-skynet/local-ai-addons/face:sha-99d1dca2c869" + digest: "sha256:a48ad28afc98b10f952e330243487a0fd3ab2aa4187478e8768712f6b77e86f6" + os: "linux" + arch: "amd64" + policy: + requireSignature: true + issuer: "https://token.actions.githubusercontent.com" + identity: "https://github.com/mudler/LocalAI/.github/workflows/addons-package.yml@refs/heads/master" + ui: + title: "Face" + description: "Find out whose face is in a photo, or whether two photos show the same person." + mainPage: "recognition" + icon: "ui/icon.png"