Skip to content

Commit baf6f13

Browse files
authored
feat: introduce fetch command to get partial files (#137)
Signed-off-by: chlins <[email protected]>
1 parent 9f28c10 commit baf6f13

File tree

7 files changed

+438
-4
lines changed

7 files changed

+438
-4
lines changed

.golangci.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
run:
2-
deadline: 3m
2+
timeout: 3m
33
modules-download-mode: readonly
4-
skip-dirs:
5-
- test/mocks
64

75
linters-settings:
86
gocyclo:
@@ -11,13 +9,16 @@ linters-settings:
119
sections:
1210
- standard
1311
- default
12+
- prefix(github.com/CloudNativeAI/modctl)
1413

1514
issues:
1615
new: true
1716
exclude-rules:
1817
- linters:
1918
- staticcheck
2019
text: "SA1019:"
20+
exclude-dirs:
21+
- test/mocks
2122

2223
linters:
2324
disable-all: true
@@ -34,6 +35,7 @@ linters:
3435
- errcheck
3536

3637
output:
37-
format: colored-line-number
38+
formats:
39+
- format: colored-line-number
3840
print-issued-lines: true
3941
print-linter-name: true

cmd/fetch.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2025 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package cmd
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/CloudNativeAI/modctl/pkg/backend"
24+
"github.com/CloudNativeAI/modctl/pkg/config"
25+
26+
"github.com/spf13/cobra"
27+
"github.com/spf13/viper"
28+
)
29+
30+
var fetchConfig = config.NewFetch()
31+
32+
// fetchCmd represents the modctl command for fetch.
33+
var fetchCmd = &cobra.Command{
34+
Use: "fetch [flags] <target>",
35+
Short: "A command line tool for modctl fetch, please note that this command is designed for remote model fetching only.",
36+
Args: cobra.ExactArgs(1),
37+
DisableAutoGenTag: true,
38+
SilenceUsage: true,
39+
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
if err := fetchConfig.Validate(); err != nil {
42+
return err
43+
}
44+
45+
return runFetch(context.Background(), args[0])
46+
},
47+
}
48+
49+
// init initializes fetch command.
50+
func init() {
51+
flags := fetchCmd.Flags()
52+
flags.IntVar(&fetchConfig.Concurrency, "concurrency", fetchConfig.Concurrency, "specify the number of concurrent fetch operations")
53+
flags.BoolVar(&fetchConfig.PlainHTTP, "plain-http", false, "use plain HTTP instead of HTTPS")
54+
flags.BoolVar(&fetchConfig.Insecure, "insecure", false, "use insecure connection for the fetch operation and skip TLS verification")
55+
flags.StringVar(&fetchConfig.Proxy, "proxy", "", "use proxy for the fetch operation")
56+
flags.StringVar(&fetchConfig.Output, "output", "", "specify the directory for fetching the model artifact")
57+
flags.StringSliceVar(&fetchConfig.Patterns, "patterns", []string{}, "specify the patterns for fetching the model artifact")
58+
59+
if err := viper.BindPFlags(flags); err != nil {
60+
panic(fmt.Errorf("bind cache pull flags to viper: %w", err))
61+
}
62+
}
63+
64+
// runFetch runs the fetch modctl.
65+
func runFetch(ctx context.Context, target string) error {
66+
b, err := backend.New(rootConfig.StoargeDir)
67+
if err != nil {
68+
return err
69+
}
70+
71+
if target == "" {
72+
return fmt.Errorf("target is required")
73+
}
74+
75+
if err := b.Fetch(ctx, target, fetchConfig); err != nil {
76+
return err
77+
}
78+
79+
fmt.Printf("Successfully fetched model artifact: %s\n", target)
80+
return nil
81+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,6 @@ func init() {
8585
rootCmd.AddCommand(inspectCmd)
8686
rootCmd.AddCommand(extractCmd)
8787
rootCmd.AddCommand(tagCmd)
88+
rootCmd.AddCommand(fetchCmd)
8889
rootCmd.AddCommand(modelfile.RootCmd)
8990
}

pkg/backend/backend.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type Backend interface {
3737
// Pull pulls an artifact from a registry.
3838
Pull(ctx context.Context, target string, cfg *config.Pull) error
3939

40+
// Fetch fetches partial files to the output.
41+
Fetch(ctx context.Context, target string, cfg *config.Fetch) error
42+
4043
// Push pushes the image to the registry.
4144
Push(ctx context.Context, target string, cfg *config.Push) error
4245

pkg/backend/fetch.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2025 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package backend
18+
19+
import (
20+
"context"
21+
"crypto/tls"
22+
"encoding/json"
23+
"fmt"
24+
"net/http"
25+
"net/url"
26+
"path/filepath"
27+
28+
modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1"
29+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
30+
"golang.org/x/sync/errgroup"
31+
"oras.land/oras-go/v2/registry/remote"
32+
"oras.land/oras-go/v2/registry/remote/auth"
33+
"oras.land/oras-go/v2/registry/remote/credentials"
34+
"oras.land/oras-go/v2/registry/remote/retry"
35+
36+
internalpb "github.com/CloudNativeAI/modctl/internal/pb"
37+
"github.com/CloudNativeAI/modctl/pkg/config"
38+
)
39+
40+
// Fetch fetches partial files to the output.
41+
func (b *backend) Fetch(ctx context.Context, target string, cfg *config.Fetch) error {
42+
// parse the repository and tag from the target.
43+
ref, err := ParseReference(target)
44+
if err != nil {
45+
return fmt.Errorf("failed to parse the target: %w", err)
46+
}
47+
48+
_, tag := ref.Repository(), ref.Tag()
49+
50+
// create the src storage from the remote repository.
51+
src, err := remote.NewRepository(target)
52+
if err != nil {
53+
return fmt.Errorf("failed to create remote repository: %w", err)
54+
}
55+
56+
// gets the credentials store.
57+
credStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{AllowPlaintextPut: true})
58+
if err != nil {
59+
return fmt.Errorf("failed to create credential store: %w", err)
60+
}
61+
62+
// create the http client.
63+
httpClient := &http.Client{}
64+
if cfg.Proxy != "" {
65+
proxyURL, err := url.Parse(cfg.Proxy)
66+
if err != nil {
67+
return fmt.Errorf("failed to parse the proxy URL: %w", err)
68+
}
69+
70+
httpClient.Transport = retry.NewTransport(&http.Transport{
71+
Proxy: http.ProxyURL(proxyURL),
72+
TLSClientConfig: &tls.Config{
73+
InsecureSkipVerify: cfg.Insecure,
74+
},
75+
})
76+
}
77+
78+
src.Client = &auth.Client{
79+
Cache: auth.NewCache(),
80+
Credential: credentials.Credential(credStore),
81+
Client: httpClient,
82+
}
83+
84+
if cfg.PlainHTTP {
85+
src.PlainHTTP = true
86+
}
87+
88+
_, manifestReader, err := src.Manifests().FetchReference(ctx, tag)
89+
if err != nil {
90+
return fmt.Errorf("failed to fetch the manifest: %w", err)
91+
}
92+
93+
defer manifestReader.Close()
94+
95+
var manifest ocispec.Manifest
96+
if err := json.NewDecoder(manifestReader).Decode(&manifest); err != nil {
97+
return fmt.Errorf("failed to decode the manifest: %w", err)
98+
}
99+
100+
layers := []ocispec.Descriptor{}
101+
// filter the layers by patterns.
102+
for _, layer := range manifest.Layers {
103+
for _, pattern := range cfg.Patterns {
104+
if anno := layer.Annotations; anno != nil {
105+
matched, err := filepath.Match(pattern, anno[modelspec.AnnotationFilepath])
106+
if err != nil {
107+
return fmt.Errorf("failed to match pattern: %w", err)
108+
}
109+
110+
if matched {
111+
layers = append(layers, layer)
112+
}
113+
}
114+
}
115+
}
116+
117+
if len(layers) == 0 {
118+
return fmt.Errorf("no layers matched the patterns")
119+
}
120+
121+
pb := internalpb.NewProgressBar()
122+
pb.Start()
123+
defer pb.Stop()
124+
125+
g := &errgroup.Group{}
126+
g.SetLimit(cfg.Concurrency)
127+
128+
for _, layer := range layers {
129+
g.Go(func() error {
130+
return pullAndExtractFromRemote(ctx, pb, internalpb.NormalizePrompt("Fetching blob"), src, cfg.Output, layer)
131+
})
132+
}
133+
134+
return g.Wait()
135+
}

0 commit comments

Comments
 (0)