mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
feat(cli): add local-ai addons inspect and install
inspect verifies an add-on zip on this machine and shows what it holds. install checks it, uploads it to a running server and prints the page where the administrator confirms the install and the host access. The command never installs by itself. Assisted-by: Claude:claude-sonnet-5-5 go gofmt
This commit is contained in:
3 files changed
+284
No files matched your search
@@ -0,0 +1,166 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cliContext "github.com/mudler/LocalAI/core/cli/context"
|
||||
"github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/core/services/addons/packages"
|
||||
)
|
||||
|
||||
// AddonsCMD works with add-on zip files, the ones `make addons-zip` writes.
|
||||
type AddonsCMD struct {
|
||||
Inspect AddonsInspectCMD `cmd:"" help:"Check an add-on zip file and show what it holds, without installing it"`
|
||||
Install AddonsInstallCMD `cmd:"" help:"Upload an add-on zip file to a running server. You finish the install in the browser"`
|
||||
}
|
||||
|
||||
// AddonsInspectCMD verifies a zip on this machine.
|
||||
type AddonsInspectCMD struct {
|
||||
File string `arg:"" name:"file" type:"existingfile" help:"The add-on zip file"`
|
||||
|
||||
// output receives the report. It is stdout when nil.
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
func (c *AddonsInspectCMD) Run(_ *cliContext.Context) error {
|
||||
sum, err := packages.InspectFile(context.Background(), c.File, 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", c.File, err)
|
||||
}
|
||||
w := c.output
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
writeAddonFileSummary(w, sum)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAddonFileSummary(w io.Writer, sum packages.FileSummary) {
|
||||
m := sum.Package
|
||||
_, _ = fmt.Fprintf(w, "add-on: %s %s\n", sum.ID, sum.Version)
|
||||
_, _ = fmt.Fprintf(w, "platform: %s/%s\n", sum.OS, sum.Arch)
|
||||
_, _ = fmt.Fprintf(w, "host API: %d to %d\n", m.API.Min, m.API.Max)
|
||||
_, _ = fmt.Fprintf(w, "files: %d, %d bytes\n", sum.Files, sum.Size)
|
||||
_, _ = fmt.Fprintf(w, "sha256: %s\n", sum.SHA256)
|
||||
var req, opt []string
|
||||
if h := m.Capabilities.HostOperations; h != nil {
|
||||
req, opt = h.Required, h.Optional
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "host access: required %s; optional %s\n", listOrNone(req), listOrNone(opt))
|
||||
_, _ = fmt.Fprintln(w, "origin: local file, unsigned. Nothing signs this file; install it only if you trust whoever made it.")
|
||||
}
|
||||
|
||||
func listOrNone(l []string) string {
|
||||
if len(l) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(l, ", ")
|
||||
}
|
||||
|
||||
// AddonsInstallCMD uploads a zip to a running server.
|
||||
type AddonsInstallCMD struct {
|
||||
File string `arg:"" name:"file" type:"existingfile" help:"The add-on zip file"`
|
||||
Target string `env:"LOCALAI_TARGET,LOCALAI_MCP_TARGET" default:"http://localhost:8080" help:"Base URL of the LocalAI server"`
|
||||
APIKey string `env:"LOCALAI_API_KEY" help:"Administrator API key of the server, when the server asks for one"`
|
||||
|
||||
output io.Writer
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// The install itself is a decision about what the add-on may do on this host,
|
||||
// so it is not made here. The command checks the file, hands it to the
|
||||
// server's staging area and prints the page where the administrator confirms
|
||||
// the install and the host operations.
|
||||
func (c *AddonsInstallCMD) Run(_ *cliContext.Context) error {
|
||||
sum, err := packages.InspectFile(context.Background(), c.File, 0, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", c.File, err)
|
||||
}
|
||||
w := c.output
|
||||
if w == nil {
|
||||
w = os.Stdout
|
||||
}
|
||||
writeAddonFileSummary(w, sum)
|
||||
base, err := url.Parse(strings.TrimRight(c.Target, "/"))
|
||||
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
|
||||
return fmt.Errorf("--target: %q is not a http or https URL", c.Target)
|
||||
}
|
||||
view, err := c.upload(base)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(view.SHA256, sum.SHA256) {
|
||||
return errors.New("the server holds a file with another digest than the one sent; nothing was installed")
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "\nUploaded to %s as %s (%s).\n", base.Host, view.FileID, view.Relation)
|
||||
_, _ = fmt.Fprintf(w, "Finish the install in the browser, where you review the host access it asks for:\n %s/app/addons?file=%s\n", base.String(), view.FileID)
|
||||
_, _ = fmt.Fprintln(w, "The file waits there for one hour.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AddonsInstallCMD) upload(base *url.URL) (addons.FileView, error) {
|
||||
var view addons.FileView
|
||||
f, err := os.Open(c.File) // #nosec G304 -- the file the administrator named on the command line
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
pr, pw := io.Pipe()
|
||||
mw := multipart.NewWriter(pw)
|
||||
go func() {
|
||||
part, e := mw.CreateFormFile("file", filepath.Base(c.File))
|
||||
if e == nil {
|
||||
_, e = io.Copy(part, f)
|
||||
}
|
||||
if e == nil {
|
||||
e = mw.Close()
|
||||
}
|
||||
_ = pw.CloseWithError(e)
|
||||
}()
|
||||
req, err := http.NewRequest(http.MethodPost, base.String()+"/api/addons/files", pr)
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
if c.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
}
|
||||
client := c.client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 30 * time.Minute}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return view, fmt.Errorf("upload to %s: %w", base.Host, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var e struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &e) == nil && e.Error.Message != "" {
|
||||
return view, fmt.Errorf("the server refused the file (%d %s): %s", resp.StatusCode, e.Error.Type, e.Error.Message)
|
||||
}
|
||||
return view, fmt.Errorf("the server refused the file (%d)", resp.StatusCode)
|
||||
}
|
||||
if err = json.NewDecoder(bytes.NewReader(body)).Decode(&view); err != nil || view.FileID == "" {
|
||||
return view, errors.New("the server answered with something that is not an add-on file")
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/mudler/LocalAI/core/services/addons"
|
||||
"github.com/mudler/LocalAI/pkg/addons/zipformat"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Add-on zip commands", func() {
|
||||
manifest := `{"schemaVersion":1,"id":"sample","version":"1.2.3","executable":"bin/sample","os":"linux","arch":"amd64","host":{"min":1,"max":2},"api":{"min":2,"max":2},"protocol":{"min":1,"max":1},"dataFormat":{"min":1,"max":2},"capabilities":{"service":true,"hostOperations":{"required":["workspace.materialize"],"optional":["egress.check"]}}}`
|
||||
writeZip := func() string {
|
||||
var b bytes.Buffer
|
||||
Expect(zipformat.Write(&b, "sample", "1.2.3", "linux", "amd64", "bin/sample", []zipformat.Entry{
|
||||
{Name: "manifest.json", Data: []byte(manifest)}, {Name: "bin/sample", Data: []byte("x"), Exec: true},
|
||||
})).To(Succeed())
|
||||
p := filepath.Join(GinkgoT().TempDir(), "sample-1.2.3-linux-amd64.zip")
|
||||
Expect(os.WriteFile(p, b.Bytes(), 0o600)).To(Succeed())
|
||||
return p
|
||||
}
|
||||
parse := func(args ...string) (*AddonsCMD, error) {
|
||||
var command struct {
|
||||
Addons AddonsCMD `cmd:"" name:"addons"`
|
||||
}
|
||||
p, err := kong.New(&command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = p.Parse(append([]string{"addons"}, args...))
|
||||
return &command.Addons, err
|
||||
}
|
||||
|
||||
It("shows what a zip holds and that nothing signs it", func() {
|
||||
c, err := parse("inspect", writeZip())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var out bytes.Buffer
|
||||
c.Inspect.output = &out
|
||||
Expect(c.Inspect.Run(nil)).To(Succeed())
|
||||
Expect(out.String()).To(ContainSubstring("sample 1.2.3"))
|
||||
Expect(out.String()).To(ContainSubstring("linux/amd64"))
|
||||
Expect(out.String()).To(ContainSubstring("required workspace.materialize; optional egress.check"))
|
||||
Expect(out.String()).To(MatchRegexp(`sha256:\s+[0-9a-f]{64}`))
|
||||
Expect(out.String()).To(ContainSubstring("local file, unsigned"))
|
||||
})
|
||||
|
||||
It("refuses a file that is not an add-on zip", func() {
|
||||
p := filepath.Join(GinkgoT().TempDir(), "x.zip")
|
||||
Expect(os.WriteFile(p, []byte("nope"), 0o600)).To(Succeed())
|
||||
c, err := parse("inspect", p)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(c.Inspect.Run(nil)).To(MatchError(ContainSubstring("not a zip")))
|
||||
})
|
||||
|
||||
It("uploads the file with the key, and prints the page that finishes the install", func() {
|
||||
path := writeZip()
|
||||
raw, _ := os.ReadFile(path)
|
||||
var gotAuth, gotName string
|
||||
var gotBytes []byte
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
Expect(r.Method + " " + r.URL.Path).To(Equal("POST /api/addons/files"))
|
||||
f, h, err := r.FormFile("file")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
gotName = h.Filename
|
||||
gotBytes, _ = io.ReadAll(f)
|
||||
_ = json.NewEncoder(w).Encode(addons.FileView{FileID: "0123456789abcdef0123456789abcdef", SHA256: zipSHA(raw), Relation: "new"})
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
c, err := parse("install", path, "--target", srv.URL, "--api-key", "k1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var out bytes.Buffer
|
||||
c.Install.output = &out
|
||||
Expect(c.Install.Run(nil)).To(Succeed())
|
||||
Expect(gotAuth).To(Equal("Bearer k1"))
|
||||
Expect(gotName).To(Equal("sample-1.2.3-linux-amd64.zip"))
|
||||
Expect(gotBytes).To(Equal(raw))
|
||||
Expect(out.String()).To(ContainSubstring(srv.URL + "/app/addons?file=0123456789abcdef0123456789abcdef"))
|
||||
Expect(out.String()).To(ContainSubstring("in the browser"))
|
||||
})
|
||||
|
||||
It("reports the reason the server gives", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte(`{"error":{"type":"wrong_platform","code":400,"message":"This file is for linux/arm64; this host is linux/amd64"}}`))
|
||||
}))
|
||||
DeferCleanup(srv.Close)
|
||||
c, err := parse("install", writeZip(), "--target", srv.URL)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
var out bytes.Buffer
|
||||
c.Install.output = &out
|
||||
Expect(c.Install.Run(nil)).To(MatchError(ContainSubstring("wrong_platform")))
|
||||
})
|
||||
|
||||
It("rejects a target that is not a web address", func() {
|
||||
c, err := parse("install", writeZip(), "--target", "ftp://x")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
c.Install.output = io.Discard
|
||||
Expect(c.Install.Run(nil)).To(MatchError(ContainSubstring("--target")))
|
||||
})
|
||||
})
|
||||
|
||||
func zipSHA(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return hex.EncodeToString(s[:])
|
||||
}
|
||||
@@ -15,6 +15,7 @@ var CLI struct {
|
||||
Federated FederatedCLI `cmd:"" help:"Run LocalAI in federated mode"`
|
||||
Models ModelsCMD `cmd:"" help:"Manage LocalAI models and definitions"`
|
||||
Backends BackendsCMD `cmd:"" help:"Manage LocalAI backends and definitions"`
|
||||
Addons AddonsCMD `cmd:"" help:"Check an add-on zip file and upload it to a running server to install it"`
|
||||
Library LibraryCMD `cmd:"" help:"Manage the library sources LocalAI reads add-ons, backends and models from"`
|
||||
TTS TTSCMD `cmd:"" help:"Convert text to speech"`
|
||||
SoundGeneration SoundGenerationCMD `cmd:"" help:"Generates audio files from text or audio"`
|
||||
|
||||
Reference in new issue
Block a user