Skip to content

Commit 9a80600

Browse files
committed
feat(cli): add command to create custom OCI images from directories
Signed-off-by: Ettore Di Giacinto <[email protected]>
1 parent fc02bc0 commit 9a80600

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-0
lines changed

core/cli/util.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@ import (
44
"encoding/json"
55
"errors"
66
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
710

11+
"github.com/mholt/archiver/v3"
812
"github.com/rs/zerolog/log"
913

1014
gguf "github.com/gpustack/gguf-parser-go"
1115
cliContext "github.com/mudler/LocalAI/core/cli/context"
1216
"github.com/mudler/LocalAI/core/config"
1317
"github.com/mudler/LocalAI/core/gallery"
1418
"github.com/mudler/LocalAI/pkg/downloader"
19+
"github.com/mudler/LocalAI/pkg/oci"
1520
)
1621

1722
type UtilCMD struct {
1823
GGUFInfo GGUFInfoCMD `cmd:"" name:"gguf-info" help:"Get information about a GGUF file"`
24+
CreateOCIImage CreateOCIImageCMD `cmd:"" name:"create-oci-image" help:"Create an OCI image from a file or a directory"`
1925
HFScan HFScanCMD `cmd:"" name:"hf-scan" help:"Checks installed models for known security issues. WARNING: this is a best-effort feature and may not catch everything!"`
2026
UsecaseHeuristic UsecaseHeuristicCMD `cmd:"" name:"usecase-heuristic" help:"Checks a specific model config and prints what usecase LocalAI will offer for it."`
2127
}
@@ -36,6 +42,35 @@ type UsecaseHeuristicCMD struct {
3642
ModelsPath string `env:"LOCALAI_MODELS_PATH,MODELS_PATH" type:"path" default:"${basepath}/models" help:"Path containing models used for inferencing" group:"storage"`
3743
}
3844

45+
type CreateOCIImageCMD struct {
46+
Input []string `arg:"" help:"Input file or directory to create an OCI image from"`
47+
Output string `default:"image.tar" help:"Output OCI image name"`
48+
ImageName string `default:"localai" help:"Image name"`
49+
Platform string `default:"linux/amd64" help:"Platform of the image"`
50+
}
51+
52+
func (u *CreateOCIImageCMD) Run(ctx *cliContext.Context) error {
53+
log.Info().Msg("Creating OCI image from input")
54+
55+
dir, err := os.MkdirTemp("", "localai")
56+
if err != nil {
57+
return err
58+
}
59+
defer os.RemoveAll(dir)
60+
err = archiver.Archive(u.Input, filepath.Join(dir, "archive.tar"))
61+
if err != nil {
62+
return err
63+
}
64+
log.Info().Msgf("Creating '%s' as '%s' from %v", u.Output, u.Input, u.Input)
65+
66+
platform := strings.Split(u.Platform, "/")
67+
if len(platform) != 2 {
68+
return fmt.Errorf("invalid platform: %s", u.Platform)
69+
}
70+
71+
return oci.CreateTar(filepath.Join(dir, "archive.tar"), u.Output, u.ImageName, platform[1], platform[0])
72+
}
73+
3974
func (u *GGUFInfoCMD) Run(ctx *cliContext.Context) error {
4075
if u.Args == nil || len(u.Args) == 0 {
4176
return fmt.Errorf("no GGUF file provided")

pkg/oci/tarball.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package oci
2+
3+
import (
4+
"io"
5+
"os"
6+
7+
containerdCompression "github.com/containerd/containerd/archive/compression"
8+
"github.com/google/go-containerregistry/pkg/name"
9+
v1 "github.com/google/go-containerregistry/pkg/v1"
10+
"github.com/google/go-containerregistry/pkg/v1/empty"
11+
"github.com/google/go-containerregistry/pkg/v1/mutate"
12+
"github.com/google/go-containerregistry/pkg/v1/tarball"
13+
"github.com/pkg/errors"
14+
)
15+
16+
func imageFromTar(imagename, architecture, OS string, opener func() (io.ReadCloser, error)) (name.Reference, v1.Image, error) {
17+
newRef, err := name.ParseReference(imagename)
18+
if err != nil {
19+
return nil, nil, err
20+
}
21+
22+
layer, err := tarball.LayerFromOpener(opener)
23+
if err != nil {
24+
return nil, nil, err
25+
}
26+
27+
baseImage := empty.Image
28+
cfg, err := baseImage.ConfigFile()
29+
if err != nil {
30+
return nil, nil, err
31+
}
32+
33+
cfg.Architecture = architecture
34+
cfg.OS = OS
35+
36+
baseImage, err = mutate.ConfigFile(baseImage, cfg)
37+
if err != nil {
38+
return nil, nil, err
39+
}
40+
img, err := mutate.Append(baseImage, mutate.Addendum{
41+
Layer: layer,
42+
History: v1.History{
43+
CreatedBy: "localai",
44+
Comment: "Custom image",
45+
},
46+
})
47+
if err != nil {
48+
return nil, nil, err
49+
}
50+
51+
return newRef, img, nil
52+
}
53+
54+
// CreateTar a imagetarball from a standard tarball
55+
func CreateTar(srctar, dstimageTar, imagename, architecture, OS string) error {
56+
57+
dstFile, err := os.Create(dstimageTar)
58+
if err != nil {
59+
return errors.Wrap(err, "Cannot create "+dstimageTar)
60+
}
61+
defer dstFile.Close()
62+
63+
newRef, img, err := imageFromTar(imagename, architecture, OS, func() (io.ReadCloser, error) {
64+
f, err := os.Open(srctar)
65+
if err != nil {
66+
return nil, errors.Wrap(err, "Cannot open "+srctar)
67+
}
68+
decompressed, err := containerdCompression.DecompressStream(f)
69+
if err != nil {
70+
return nil, errors.Wrap(err, "Cannot open "+srctar)
71+
}
72+
73+
return decompressed, nil
74+
})
75+
if err != nil {
76+
return err
77+
}
78+
79+
// NOTE: We might also stream that back to the daemon with daemon.Write(tag, img)
80+
return tarball.Write(newRef, img, dstFile)
81+
82+
}

0 commit comments

Comments
 (0)