Skip to content

Commit 5430592

Browse files
committed
feat: support build the model artifact by raw file format
Signed-off-by: chlins <[email protected]>
1 parent 6b5f9c9 commit 5430592

File tree

13 files changed

+230
-46
lines changed

13 files changed

+230
-46
lines changed

cmd/build.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,16 @@ var buildCmd = &cobra.Command{
5252
func init() {
5353
flags := buildCmd.Flags()
5454
flags.IntVarP(&buildConfig.Concurrency, "concurrency", "c", buildConfig.Concurrency, "specify the number of concurrent build operations")
55-
flags.StringVarP(&buildConfig.Target, "target", "t", "", "target model artifact name")
56-
flags.StringVarP(&buildConfig.Modelfile, "modelfile", "f", "Modelfile", "model file path")
55+
flags.StringVarP(&buildConfig.Target, "target", "t", buildConfig.Target, "target model artifact name")
56+
flags.StringVarP(&buildConfig.Modelfile, "modelfile", "f", buildConfig.Modelfile, "model file path")
5757
flags.BoolVarP(&buildConfig.OutputRemote, "output-remote", "", false, "turning on this flag will output model artifact to remote registry directly")
5858
flags.BoolVarP(&buildConfig.PlainHTTP, "plain-http", "", false, "turning on this flag will use plain HTTP instead of HTTPS")
5959
flags.BoolVarP(&buildConfig.Insecure, "insecure", "", false, "turning on this flag will disable TLS verification")
6060
flags.BoolVar(&buildConfig.Nydusify, "nydusify", false, "[EXPERIMENTAL] nydusify the model artifact")
6161
flags.MarkHidden("nydusify")
6262
flags.StringVar(&buildConfig.SourceURL, "source-url", "", "source URL")
6363
flags.StringVar(&buildConfig.SourceRevision, "source-revision", "", "source revision")
64+
flags.BoolVar(&buildConfig.Raw, "raw", false, "turning on this flag will build model artifact layers in raw format")
6465

6566
if err := viper.BindPFlags(flags); err != nil {
6667
panic(fmt.Errorf("bind cache list flags to viper: %w", err))

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/CloudNativeAI/modctl
33
go 1.24.1
44

55
require (
6-
github.com/CloudNativeAI/model-spec v0.0.3
6+
github.com/CloudNativeAI/model-spec v0.0.5
77
github.com/antgroup/hugescm v0.18.0
88
github.com/avast/retry-go/v4 v4.6.1
99
github.com/briandowns/spinner v1.23.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
22
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
33
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
44
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
5-
github.com/CloudNativeAI/model-spec v0.0.3 h1:5mvgFQ+3pyupzxYjtV5XAeg9zRe6+46pLPBFNHlOrqE=
6-
github.com/CloudNativeAI/model-spec v0.0.3/go.mod h1:3U/4zubBfbUkW59ATSg41HnkYyKrKUcKFH/cVdoPQnk=
5+
github.com/CloudNativeAI/model-spec v0.0.5 h1:gVwvpiVJgGZynvIi+xy0Hp/zh+g1EuWa6x6Czm4P0uA=
6+
github.com/CloudNativeAI/model-spec v0.0.5/go.mod h1:3U/4zubBfbUkW59ATSg41HnkYyKrKUcKFH/cVdoPQnk=
77
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
88
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
99
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=

pkg/archiver/archiver.go

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,17 @@ func Untar(reader io.Reader, destPath string) error {
161161

162162
switch header.Typeflag {
163163
case tar.TypeDir:
164-
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
164+
if err := os.MkdirAll(targetPath, 0755); err != nil {
165165
return fmt.Errorf("failed to create directory %s: %w", targetPath, err)
166166
}
167+
// Set correct permissions for the directory.
168+
if err := os.Chmod(targetPath, os.FileMode(header.Mode)); err != nil {
169+
return fmt.Errorf("failed to set directory permissions %s: %w", targetPath, err)
170+
}
171+
// Set modification time for the directory.
172+
if err := os.Chtimes(targetPath, header.ModTime, header.ModTime); err != nil {
173+
return fmt.Errorf("failed to set directory mtime %s: %w", targetPath, err)
174+
}
167175

168176
case tar.TypeReg:
169177
file, err := os.OpenFile(
@@ -181,13 +189,13 @@ func Untar(reader io.Reader, destPath string) error {
181189
}
182190
file.Close()
183191

184-
case tar.TypeSymlink:
185-
if isRel(header.Linkname, destPath) && isRel(header.Name, destPath) {
186-
if err := os.Symlink(header.Linkname, targetPath); err != nil {
187-
return fmt.Errorf("failed to create symlink %s -> %s: %w", targetPath, header.Linkname, err)
188-
}
189-
} else {
190-
return fmt.Errorf("symlink %s -> %s points outside of destination directory", targetPath, header.Linkname)
192+
// Set correct permissions for the directory.
193+
if err := os.Chmod(targetPath, os.FileMode(header.Mode)); err != nil {
194+
return fmt.Errorf("failed to set directory permissions %s: %w", targetPath, err)
195+
}
196+
// Set modification time for the file.
197+
if err := os.Chtimes(targetPath, header.ModTime, header.ModTime); err != nil {
198+
return fmt.Errorf("failed to set file mtime %s: %w", targetPath, err)
191199
}
192200

193201
default:
@@ -198,18 +206,3 @@ func Untar(reader io.Reader, destPath string) error {
198206

199207
return nil
200208
}
201-
202-
// isRel checks if the candidate path is within the target directory after resolving symbolic links.
203-
func isRel(candidate, target string) bool {
204-
if filepath.IsAbs(candidate) {
205-
return false
206-
}
207-
208-
realpath, err := filepath.EvalSymlinks(filepath.Join(target, candidate))
209-
if err != nil {
210-
return false
211-
}
212-
213-
relpath, err := filepath.Rel(target, realpath)
214-
return err == nil && !strings.HasPrefix(filepath.Clean(relpath), "..")
215-
}

pkg/backend/build.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func (b *backend) Build(ctx context.Context, modelfilePath, workDir, target stri
9090
defer pb.Stop()
9191

9292
layers := []ocispec.Descriptor{}
93-
layerDescs, err := b.process(ctx, builder, workDir, pb, cfg, b.getProcessors(modelfile)...)
93+
layerDescs, err := b.process(ctx, builder, workDir, pb, cfg, b.getProcessors(modelfile, cfg)...)
9494
if err != nil {
9595
return fmt.Errorf("failed to process files: %w", err)
9696
}
@@ -154,23 +154,39 @@ func (b *backend) Build(ctx context.Context, modelfilePath, workDir, target stri
154154
return nil
155155
}
156156

157-
func (b *backend) getProcessors(modelfile modelfile.Modelfile) []processor.Processor {
157+
func (b *backend) getProcessors(modelfile modelfile.Modelfile, cfg *config.Build) []processor.Processor {
158158
processors := []processor.Processor{}
159159

160160
if configs := modelfile.GetConfigs(); len(configs) > 0 {
161-
processors = append(processors, processor.NewModelConfigProcessor(b.store, modelspec.MediaTypeModelWeightConfig, configs))
161+
mediaType := modelspec.MediaTypeModelWeightConfig
162+
if cfg.Raw {
163+
mediaType = modelspec.MediaTypeModelWeightConfigRaw
164+
}
165+
processors = append(processors, processor.NewModelConfigProcessor(b.store, mediaType, configs))
162166
}
163167

164168
if models := modelfile.GetModels(); len(models) > 0 {
165-
processors = append(processors, processor.NewModelProcessor(b.store, modelspec.MediaTypeModelWeight, models))
169+
mediaType := modelspec.MediaTypeModelWeight
170+
if cfg.Raw {
171+
mediaType = modelspec.MediaTypeModelWeightRaw
172+
}
173+
processors = append(processors, processor.NewModelProcessor(b.store, mediaType, models))
166174
}
167175

168176
if codes := modelfile.GetCodes(); len(codes) > 0 {
169-
processors = append(processors, processor.NewCodeProcessor(b.store, modelspec.MediaTypeModelCode, codes))
177+
mediaType := modelspec.MediaTypeModelCode
178+
if cfg.Raw {
179+
mediaType = modelspec.MediaTypeModelCodeRaw
180+
}
181+
processors = append(processors, processor.NewCodeProcessor(b.store, mediaType, codes))
170182
}
171183

172184
if docs := modelfile.GetDocs(); len(docs) > 0 {
173-
processors = append(processors, processor.NewDocProcessor(b.store, modelspec.MediaTypeModelDoc, docs))
185+
mediaType := modelspec.MediaTypeModelDoc
186+
if cfg.Raw {
187+
mediaType = modelspec.MediaTypeModelDocRaw
188+
}
189+
processors = append(processors, processor.NewDocProcessor(b.store, mediaType, docs))
174190
}
175191

176192
return processors

pkg/backend/build/builder.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ import (
2020
"bytes"
2121
"context"
2222
"encoding/json"
23+
"errors"
2324
"fmt"
2425
"io"
2526
"os"
2627
"path/filepath"
2728
"sync"
29+
"syscall"
2830
"time"
2931

3032
modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1"
@@ -204,6 +206,23 @@ func (ab *abstractBuilder) BuildLayer(ctx context.Context, mediaType, workDir, p
204206
applyDesc(&desc)
205207
}
206208

209+
// Retrieve the file metadata.
210+
metadata, err := getFileMetadata(path)
211+
if err != nil {
212+
return desc, fmt.Errorf("failed to retrieve file metadata: %w", err)
213+
}
214+
215+
metadataStr, err := json.Marshal(metadata)
216+
if err != nil {
217+
return desc, fmt.Errorf("failed to marshal metadata: %w", err)
218+
}
219+
220+
// Apply the metadata to the descriptor annotation.
221+
if desc.Annotations == nil {
222+
desc.Annotations = make(map[string]string)
223+
}
224+
desc.Annotations[modelspec.AnnotationFileMetadata] = string(metadataStr)
225+
207226
return desc, nil
208227
}
209228

@@ -306,3 +325,37 @@ func splitReader(original io.Reader) (io.Reader, io.Reader) {
306325

307326
return r1, r2
308327
}
328+
329+
// getFileMetadata retrieves metadata for a file at the given path.
330+
func getFileMetadata(path string) (modelspec.FileMetadata, error) {
331+
var metadata modelspec.FileMetadata
332+
333+
info, err := os.Stat(path)
334+
if err != nil {
335+
return metadata, err
336+
}
337+
338+
metadata.Name = info.Name()
339+
metadata.Mode = uint32(info.Mode().Perm())
340+
metadata.Size = info.Size()
341+
metadata.ModTime = info.ModTime()
342+
// Set Typeflag.
343+
switch {
344+
case info.Mode().IsRegular():
345+
metadata.Typeflag = 0 // Regular file
346+
case info.Mode().IsDir():
347+
metadata.Typeflag = 5 // Directory
348+
case info.Mode()&os.ModeSymlink != 0:
349+
metadata.Typeflag = 2 // Symlink
350+
default:
351+
return metadata, errors.New("unknown file typeflag")
352+
}
353+
354+
// UID and GID (Unix-specific).
355+
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
356+
metadata.Uid = stat.Uid
357+
metadata.Gid = stat.Gid
358+
}
359+
360+
return metadata, nil
361+
}

pkg/backend/build/builder_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import (
2222
"io"
2323
"os"
2424
"path/filepath"
25+
"runtime"
2526
"strings"
2627
"sync"
28+
"syscall"
2729
"testing"
2830
"time"
2931

@@ -286,3 +288,82 @@ func TestPipeReader(t *testing.T) {
286288
assert.NoError(t, err)
287289
wg.Wait()
288290
}
291+
292+
func createTempFile(t *testing.T, dir, pattern, content string) string {
293+
t.Helper()
294+
f, err := os.CreateTemp(dir, pattern)
295+
assert.NoError(t, err)
296+
_, err = f.WriteString(content)
297+
assert.NoError(t, err)
298+
err = f.Close()
299+
assert.NoError(t, err)
300+
return f.Name()
301+
}
302+
303+
func createTempDir(t *testing.T, dir, pattern string) string {
304+
t.Helper()
305+
name, err := os.MkdirTemp(dir, pattern)
306+
assert.NoError(t, err)
307+
return name
308+
}
309+
310+
func TestGetFileMetadata(t *testing.T) {
311+
baseTempDir := t.TempDir()
312+
313+
// --- Test Case 1: Regular File ---
314+
t.Run("Regular File", func(t *testing.T) {
315+
content := "hello world"
316+
filePath := createTempFile(t, baseTempDir, "testfile-*.txt", content)
317+
fileInfo, err := os.Stat(filePath) // Get ground truth info
318+
assert.NoError(t, err)
319+
320+
metadata, err := getFileMetadata(filePath)
321+
assert.NoError(t, err)
322+
323+
assert.Equal(t, filepath.Base(filePath), metadata.Name)
324+
assert.Equal(t, int64(len(content)), metadata.Size)
325+
assert.Equal(t, uint32(fileInfo.Mode().Perm()), metadata.Mode)
326+
assert.Equal(t, byte(0), metadata.Typeflag, "Typeflag should be 0 for regular file")
327+
assert.WithinDuration(t, fileInfo.ModTime(), metadata.ModTime, time.Second)
328+
329+
// Check UID/GID only on Unix-like systems
330+
if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
331+
assert.Equal(t, stat.Uid, metadata.Uid, "UID mismatch")
332+
assert.Equal(t, stat.Gid, metadata.Gid, "GID mismatch")
333+
} else if runtime.GOOS != "windows" {
334+
// If not windows and not syscall.Stat_t, something is unexpected
335+
t.Logf("Warning: Could not get syscall.Stat_t on non-Windows OS (%s)", runtime.GOOS)
336+
} else {
337+
// On Windows, expect 0 as syscall.Stat_t assertion fails
338+
assert.Equal(t, uint32(0), metadata.Uid, "UID should be 0 on Windows")
339+
assert.Equal(t, uint32(0), metadata.Gid, "GID should be 0 on Windows")
340+
}
341+
})
342+
343+
// --- Test Case 2: Directory ---
344+
t.Run("Directory", func(t *testing.T) {
345+
dirPath := createTempDir(t, baseTempDir, "testdir-*")
346+
dirInfo, err := os.Stat(dirPath)
347+
assert.NoError(t, err)
348+
349+
metadata, err := getFileMetadata(dirPath)
350+
assert.NoError(t, err)
351+
352+
assert.Equal(t, filepath.Base(dirPath), metadata.Name)
353+
assert.Equal(t, dirInfo.Size(), metadata.Size)
354+
assert.Equal(t, uint32(dirInfo.Mode().Perm()), metadata.Mode)
355+
assert.Equal(t, byte(5), metadata.Typeflag, "Typeflag should be 5 for directory")
356+
assert.WithinDuration(t, dirInfo.ModTime(), metadata.ModTime, time.Second)
357+
358+
// Check UID/GID only on Unix-like systems
359+
if stat, ok := dirInfo.Sys().(*syscall.Stat_t); ok {
360+
assert.Equal(t, stat.Uid, metadata.Uid, "UID mismatch")
361+
assert.Equal(t, stat.Gid, metadata.Gid, "GID mismatch")
362+
} else if runtime.GOOS != "windows" {
363+
t.Logf("Warning: Could not get syscall.Stat_t on non-Windows OS (%s)", runtime.GOOS)
364+
} else {
365+
assert.Equal(t, uint32(0), metadata.Uid, "UID should be 0 on Windows")
366+
assert.Equal(t, uint32(0), metadata.Gid, "GID should be 0 on Windows")
367+
}
368+
})
369+
}

pkg/backend/build_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package backend
1919
import (
2020
"testing"
2121

22+
"github.com/CloudNativeAI/modctl/pkg/config"
2223
"github.com/CloudNativeAI/modctl/test/mocks/modelfile"
2324

2425
"github.com/stretchr/testify/assert"
@@ -32,7 +33,7 @@ func TestGetProcessors(t *testing.T) {
3233
modelfile.On("GetDocs").Return([]string{"doc1", "doc2"})
3334

3435
b := &backend{}
35-
processors := b.getProcessors(modelfile)
36+
processors := b.getProcessors(modelfile, &config.Build{})
3637

3738
assert.Len(t, processors, 4)
3839
assert.Equal(t, "config", processors[0].Name())

pkg/backend/extract.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ func extractLayer(desc ocispec.Descriptor, outputDir string, reader io.Reader) e
9898
return fmt.Errorf("failed to create codec for media type %s: %w", desc.MediaType, err)
9999
}
100100

101-
if err := codec.Decode(reader, outputDir, filepath); err != nil {
101+
if err := codec.Decode(outputDir, filepath, reader, desc); err != nil {
102102
return fmt.Errorf("failed to decode the layer %s to output directory: %w", desc.Digest.String(), err)
103103
}
104104

pkg/codec/codec.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"fmt"
2121
"io"
2222
"strings"
23+
24+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2325
)
2426

2527
type Type = string
@@ -41,7 +43,7 @@ type Codec interface {
4143
Encode(targetFilePath, workDirPath string) (io.Reader, error)
4244

4345
// Decode reads the input reader and decodes the data into the output path.
44-
Decode(reader io.Reader, outputDir, filePath string) error
46+
Decode(outputDir, filePath string, reader io.Reader, desc ocispec.Descriptor) error
4547
}
4648

4749
func New(codecType Type) (Codec, error) {

0 commit comments

Comments
 (0)