From 3569da24c570b8049c9549b2ce7889daf538d4ec Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 20 Oct 2026 20:23:39 +0000 Subject: [PATCH] feat(addons): register an official add-on library by default Add gallery/addons/index.yaml, a YAML index of the four first-party add-ons (fine-tuning, quantization, voice, face) in the library format. The entries come from addonpkg catalog over locally built linux/amd64 packages, so the digests pin those images. On the first start, when the owner has no sources.json, register the raw GitHub URL of that index on master as the "official" library and write the file. The file is then the record: a library the admin removes stays removed, and an existing file is never changed. --addons-official-library=false skips the default. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto --- core/application/addons_test.go | 30 +++++++ core/application/application.go | 5 ++ core/services/addons/catalog/official.go | 28 +++++++ core/services/addons/catalog/official_test.go | 83 +++++++++++++++++++ core/services/addons/catalog/sources.go | 41 ++++++++- gallery/addons/index.yaml | 77 +++++++++++++++++ 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 core/services/addons/catalog/official.go create mode 100644 core/services/addons/catalog/official_test.go create mode 100644 gallery/addons/index.yaml 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"