├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ ├── lint.yml │ └── release.yaml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── .mockery.yaml ├── LICENSE ├── Makefile ├── README.md ├── build └── package │ └── docs │ ├── modctl.1 │ └── modctl.1.md ├── cmd ├── attach.go ├── build.go ├── extract.go ├── fetch.go ├── inspect.go ├── list.go ├── login.go ├── logout.go ├── modelfile │ ├── generate.go │ └── modelfile.go ├── prune.go ├── pull.go ├── push.go ├── rm.go ├── root.go ├── tag.go └── version.go ├── copyright.txt ├── docs └── getting-started.md ├── go.mod ├── go.sum ├── hack └── nfpm.yaml ├── internal └── pb │ └── pb.go ├── main.go ├── pkg ├── archiver │ ├── archiver.go │ └── archiver_test.go ├── backend │ ├── attach.go │ ├── attach_test.go │ ├── backend.go │ ├── build.go │ ├── build │ │ ├── builder.go │ │ ├── builder_test.go │ │ ├── config.go │ │ ├── config │ │ │ └── model.go │ │ ├── hooks │ │ │ └── hooks.go │ │ ├── interceptor │ │ │ ├── interceptor.go │ │ │ ├── nydus.go │ │ │ └── nydus_test.go │ │ ├── local.go │ │ ├── local_test.go │ │ └── remote.go │ ├── build_test.go │ ├── extract.go │ ├── fetch.go │ ├── fetch_test.go │ ├── inspect.go │ ├── inspect_test.go │ ├── list.go │ ├── list_test.go │ ├── login.go │ ├── logout.go │ ├── nydusify.go │ ├── processor │ │ ├── base.go │ │ ├── code.go │ │ ├── code_test.go │ │ ├── doc.go │ │ ├── doc_test.go │ │ ├── model.go │ │ ├── model_config.go │ │ ├── model_config_test.go │ │ ├── model_test.go │ │ ├── options.go │ │ └── processor.go │ ├── prune.go │ ├── pull.go │ ├── pull_test.go │ ├── push.go │ ├── push_test.go │ ├── referencer.go │ ├── referencer_test.go │ ├── remote │ │ └── client.go │ ├── retry.go │ ├── rm.go │ ├── rm_test.go │ ├── tag.go │ └── tag_test.go ├── codec │ ├── codec.go │ ├── raw.go │ └── tar.go ├── config │ ├── attach.go │ ├── build.go │ ├── build_test.go │ ├── extract.go │ ├── fetch.go │ ├── inspect.go │ ├── login.go │ ├── login_test.go │ ├── modelfile │ │ └── modelfile.go │ ├── prune.go │ ├── pull.go │ ├── push.go │ └── root.go ├── modelfile │ ├── command │ │ └── command.go │ ├── constants.go │ ├── constants_test.go │ ├── modelfile.go │ ├── modelfile_test.go │ └── parser │ │ ├── args_parser.go │ │ ├── args_parser_test.go │ │ ├── ast.go │ │ ├── ast_test.go │ │ ├── parser.go │ │ └── parser_test.go ├── source │ ├── git_gogit.go │ ├── git_libgit2.go │ ├── git_test.go │ ├── parser.go │ ├── testdata │ │ └── zeta-repo │ │ │ ├── .zeta │ │ │ ├── HEAD │ │ │ ├── blob │ │ │ │ └── pack │ │ │ │ │ ├── pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.idx │ │ │ │ │ ├── pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.mtimes │ │ │ │ │ └── pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.pack │ │ │ ├── index │ │ │ ├── logs │ │ │ │ ├── HEAD │ │ │ │ └── refs │ │ │ │ │ └── heads │ │ │ │ │ └── v1.0.0 │ │ │ ├── metadata │ │ │ │ └── pack │ │ │ │ │ ├── pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.idx │ │ │ │ │ ├── pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.mtimes │ │ │ │ │ └── pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.pack │ │ │ ├── refs │ │ │ │ ├── heads │ │ │ │ │ └── v1.0.0 │ │ │ │ └── remotes │ │ │ │ │ └── origin │ │ │ │ │ └── v1.0.0 │ │ │ └── zeta.toml │ │ │ ├── .zetaignore │ │ │ ├── 1.txt │ │ │ ├── LEGAL.md │ │ │ └── Readme.md │ ├── zeta.go │ └── zeta_test.go ├── storage │ ├── distribution │ │ └── distribution.go │ ├── factory.go │ └── storage.go └── version │ ├── platform_darwin.go │ ├── platform_linux.go │ └── version.go └── test └── mocks ├── backend ├── backend.go └── build │ ├── builder.go │ └── output_strategy.go ├── modelfile └── modelfile.go └── storage └── storage.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, release-*] 6 | paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**'] 7 | pull_request: 8 | branches: [main, release-*] 9 | paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**'] 10 | schedule: 11 | - cron: '0 4 * * *' 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | name: Test 19 | timeout-minutes: 60 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 24 | with: 25 | submodules: recursive 26 | 27 | - name: Install Go 28 | uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b 29 | with: 30 | go-version-file: go.mod 31 | 32 | - name: Install dependencies 33 | run: | 34 | sudo apt-get update 35 | sudo apt-get install -y pkg-config 36 | sudo apt update && \ 37 | sudo DEBIAN_FRONTEND=noninteractive apt install -y build-essential cmake pkg-config libssl-dev libssh2-1-dev zlib1g-dev libhttp-parser-dev python3 wget tar git && \ 38 | wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.5.1.tar.gz -O libgit2-v1.5.1.tar.gz && \ 39 | tar -xzf libgit2-v1.5.1.tar.gz && \ 40 | cd libgit2-1.5.1 && \ 41 | mkdir build && \ 42 | cd build && \ 43 | cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF && \ 44 | make -j$(nproc) && \ 45 | sudo make install && \ 46 | sudo ldconfig 47 | env: 48 | LIBGIT2_SYS_USE_PKG_CONFIG: "1" 49 | 50 | - name: Run Unit tests 51 | run: |- 52 | go version 53 | go test -ldflags '-extldflags "-static"' -tags static,system_libgit2 -v ./... 54 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL Analysis 2 | 3 | on: 4 | push: 5 | branches: [main, release-*] 6 | paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**'] 7 | pull_request: 8 | branches: [main, release-*] 9 | paths-ignore: ['**.md', '**.png', '**.jpg', '**.svg', '**/docs/**'] 10 | schedule: 11 | - cron: '0 4 * * *' 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | analyze: 18 | name: Analyze 19 | runs-on: ubuntu-latest 20 | 21 | permissions: 22 | security-events: write 23 | 24 | strategy: 25 | fail-fast: false 26 | matrix: 27 | language: [go] 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 32 | 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f 35 | with: 36 | languages: ${{ matrix.language }} 37 | 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f 43 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [main, release-*] 6 | pull_request: 7 | branches: [main, release-*] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | lint: 14 | name: Lint 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 20 | with: 21 | fetch-depth: '0' 22 | 23 | - name: Install dependencies 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install -y pkg-config 27 | sudo apt update && \ 28 | sudo DEBIAN_FRONTEND=noninteractive apt install -y build-essential cmake pkg-config libssl-dev libssh2-1-dev zlib1g-dev libhttp-parser-dev python3 wget tar git && \ 29 | wget https://github.com/libgit2/libgit2/archive/refs/tags/v1.5.1.tar.gz -O libgit2-v1.5.1.tar.gz && \ 30 | tar -xzf libgit2-v1.5.1.tar.gz && \ 31 | cd libgit2-1.5.1 && \ 32 | mkdir build && \ 33 | cd build && \ 34 | cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF && \ 35 | make -j$(nproc) && \ 36 | sudo make install && \ 37 | sudo ldconfig 38 | env: 39 | LIBGIT2_SYS_USE_PKG_CONFIG: "1" 40 | 41 | - name: Golangci lint 42 | uses: golangci/golangci-lint-action@v8.0.0 43 | with: 44 | version: v2.1 45 | args: --verbose --timeout=10m 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | ### macOS ### 28 | # General 29 | .DS_Store 30 | .AppleDouble 31 | .LSOverride 32 | 33 | # Icon must end with two \r 34 | Icon 35 | 36 | 37 | # Thumbnails 38 | ._* 39 | 40 | # Files that might appear in the root of a volume 41 | .DocumentRevisions-V100 42 | .fseventsd 43 | .Spotlight-V100 44 | .TemporaryItems 45 | .Trashes 46 | .VolumeIcon.icns 47 | .com.apple.timemachine.donotpresent 48 | 49 | # Directories potentially created on remote AFP share 50 | .AppleDB 51 | .AppleDesktop 52 | Network Trash Folder 53 | Temporary Items 54 | .apdisk 55 | 56 | ### macOS Patch ### 57 | # iCloud generated files 58 | *.icloud 59 | 60 | # output 61 | bin 62 | output 63 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pkg/source/testdata/git-repo"] 2 | path = pkg/source/testdata/git-repo 3 | url = https://github.com/octocat/Hello-World.git 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | modules-download-mode: readonly 4 | output: 5 | formats: 6 | text: 7 | path: stdout 8 | print-linter-name: true 9 | print-issued-lines: true 10 | linters: 11 | default: none 12 | enable: 13 | - errcheck 14 | - goconst 15 | - gocyclo 16 | - govet 17 | - misspell 18 | - staticcheck 19 | settings: 20 | gocyclo: 21 | min-complexity: 100 22 | exclusions: 23 | generated: lax 24 | presets: 25 | - comments 26 | - common-false-positives 27 | - legacy 28 | - std-error-handling 29 | rules: 30 | - linters: 31 | - staticcheck 32 | text: 'SA1019:' 33 | paths: 34 | - third_party$ 35 | - builtin$ 36 | - examples$ 37 | - test/mocks 38 | issues: 39 | new: true 40 | formatters: 41 | enable: 42 | - gci 43 | - gofmt 44 | settings: 45 | gci: 46 | sections: 47 | - standard 48 | - default 49 | - prefix(github.com/CloudNativeAI/modctl) 50 | exclusions: 51 | generated: lax 52 | paths: 53 | - third_party$ 54 | - builtin$ 55 | - examples$ 56 | -------------------------------------------------------------------------------- /.mockery.yaml: -------------------------------------------------------------------------------- 1 | with-expecter: true 2 | boilerplate-file: copyright.txt 3 | outpkg: "{{.PackageName}}" 4 | mockname: "{{.InterfaceName}}" 5 | filename: "{{.InterfaceName | snakecase}}.go" 6 | packages: 7 | github.com/CloudNativeAI/modctl/pkg/backend: 8 | interfaces: 9 | Backend: 10 | config: 11 | dir: test/mocks/backend 12 | github.com/CloudNativeAI/modctl/pkg/storage: 13 | interfaces: 14 | Storage: 15 | config: 16 | dir: test/mocks/storage 17 | github.com/CloudNativeAI/modctl/pkg/modelfile: 18 | interfaces: 19 | Modelfile: 20 | config: 21 | dir: test/mocks/modelfile 22 | github.com/CloudNativeAI/modctl/pkg/backend/build: 23 | interfaces: 24 | Builder: 25 | config: 26 | dir: test/mocks/backend/build 27 | OutputStrategy: 28 | config: 29 | dir: test/mocks/backend/build 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modctl 2 | 3 | [![CI](https://github.com/CloudNativeAI/modctl/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/CloudNativeAI/modctl/actions/workflows/ci.yml) 4 | [![GoDoc](https://godoc.org/github.com/CloudNativeAI/modctl?status.svg)](https://godoc.org/github.com/CloudNativeAI/modctl) 5 | 6 | Modctl is a user-friendly CLI tool for managing OCI model artifacts, which are bundled based on [Model Spec](https://github.com/CloudNativeAI/model-spec). 7 | It offers commands such as `build`, `pull`, `push`, and more, making it easy for users to convert their AI models into OCI artifacts. 8 | 9 | ## Documentation 10 | 11 | You can find the full documentation on the [getting started](./docs/getting-started.md). 12 | 13 | ## LICENSE 14 | 15 | Apache 2.0 License. Please see [LICENSE](LICENSE) for more information. 16 | -------------------------------------------------------------------------------- /build/package/docs/modctl.1: -------------------------------------------------------------------------------- 1 | .\" Automatically generated by Pandoc 3.6.1 2 | .\" 3 | .TH "MODCTL" "1" "" "Version v2.2.0" "Frivolous \[lq]Modctl\[rq] Documentation" 4 | .SH NAME 5 | \f[B]modctl\f[R] \[em] A command line tool for managing artifact bundled 6 | based on the Model Format Specification 7 | .SS OPTIONS 8 | .IP 9 | .EX 10 | A command line tool for managing artifact bundled based on the Model Format Specification 11 | 12 | Usage: 13 | modctl [flags] 14 | modctl [command] 15 | 16 | Available Commands: 17 | build A command line tool for modctl build 18 | completion Generate the autocompletion script for the specified shell 19 | extract A command line tool for modctl extract 20 | help Help about any command 21 | inspect A command line tool for modctl inspect 22 | login A command line tool for modctl login 23 | logout A command line tool for modctl logout 24 | ls A command line tool for modctl list 25 | prune A command line tool for modctl prune 26 | pull A command line tool for modctl pull 27 | push A command line tool for modctl push 28 | rm A command line tool for modctl rm 29 | 30 | Flags: 31 | \-h, \-\-help help for modctl 32 | \-\-storage\-dir string specify the storage directory for modctl (default \[dq]/Users/qiwenbo/.modctl\[dq]) 33 | .EE 34 | .SH BUGS 35 | See GitHub Issues: \c 36 | .UR https://github.com/CloudNativeAI/modctl/issues 37 | .UE \c 38 | -------------------------------------------------------------------------------- /build/package/docs/modctl.1.md: -------------------------------------------------------------------------------- 1 | % MODCTL(1) Version v2.2.0 | Frivolous "Modctl" Documentation 2 | 3 | # NAME 4 | 5 | **modctl** — A command line tool for managing artifact bundled based on the Model Format Specification 6 | 7 | ## OPTIONS 8 | 9 | ```shell 10 | A command line tool for managing artifact bundled based on the Model Format Specification 11 | 12 | Usage: 13 | modctl [flags] 14 | modctl [command] 15 | 16 | Available Commands: 17 | build A command line tool for modctl build 18 | completion Generate the autocompletion script for the specified shell 19 | extract A command line tool for modctl extract 20 | help Help about any command 21 | inspect A command line tool for modctl inspect 22 | login A command line tool for modctl login 23 | logout A command line tool for modctl logout 24 | ls A command line tool for modctl list 25 | prune A command line tool for modctl prune 26 | pull A command line tool for modctl pull 27 | push A command line tool for modctl push 28 | rm A command line tool for modctl rm 29 | 30 | Flags: 31 | -h, --help help for modctl 32 | --storage-dir string specify the storage directory for modctl (default "/Users/qiwenbo/.modctl") 33 | ``` 34 | 35 | # BUGS 36 | 37 | See GitHub Issues: 38 | -------------------------------------------------------------------------------- /cmd/attach.go: -------------------------------------------------------------------------------- 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 | "time" 23 | 24 | "github.com/briandowns/spinner" 25 | "github.com/spf13/cobra" 26 | "github.com/spf13/viper" 27 | 28 | "github.com/CloudNativeAI/modctl/pkg/backend" 29 | "github.com/CloudNativeAI/modctl/pkg/config" 30 | ) 31 | 32 | var attachConfig = config.NewAttach() 33 | 34 | // attachCmd represents the modctl command for attach. 35 | var attachCmd = &cobra.Command{ 36 | Use: "attach [flags] ", 37 | Short: "A command line tool for modctl attach", 38 | Args: cobra.ExactArgs(1), 39 | DisableAutoGenTag: true, 40 | SilenceUsage: true, 41 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | if err := attachConfig.Validate(); err != nil { 44 | return err 45 | } 46 | 47 | return runAttach(context.Background(), args[0]) 48 | }, 49 | } 50 | 51 | // init initializes build command. 52 | func init() { 53 | flags := attachCmd.Flags() 54 | flags.StringVarP(&attachConfig.Source, "source", "s", "", "source model artifact name") 55 | flags.StringVarP(&attachConfig.Target, "target", "t", "", "target model artifact name") 56 | flags.BoolVarP(&attachConfig.OutputRemote, "output-remote", "", false, "turning on this flag will output model artifact to remote registry directly") 57 | flags.BoolVarP(&attachConfig.PlainHTTP, "plain-http", "", false, "turning on this flag will use plain HTTP instead of HTTPS") 58 | flags.BoolVarP(&attachConfig.Insecure, "insecure", "", false, "turning on this flag will disable TLS verification") 59 | flags.BoolVarP(&attachConfig.Force, "force", "f", false, "turning on this flag will force the attach, which will overwrite the layer if it already exists with same filepath") 60 | flags.BoolVar(&attachConfig.Nydusify, "nydusify", false, "[EXPERIMENTAL] nydusify the model artifact") 61 | flags.MarkHidden("nydusify") 62 | 63 | if err := viper.BindPFlags(flags); err != nil { 64 | panic(fmt.Errorf("bind cache list flags to viper: %w", err)) 65 | } 66 | } 67 | 68 | // runAttach runs the attach modctl. 69 | func runAttach(ctx context.Context, filepath string) error { 70 | b, err := backend.New(rootConfig.StoargeDir) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if err := b.Attach(ctx, filepath, attachConfig); err != nil { 76 | return err 77 | } 78 | 79 | fmt.Printf("Successfully attached model artifact: %s\n", attachConfig.Target) 80 | 81 | // nydusify the model artifact if needed. 82 | if attachConfig.Nydusify { 83 | sp := spinner.New(spinner.CharSets[39], 100*time.Millisecond, spinner.WithSuffix("Nydusifying...")) 84 | sp.Start() 85 | defer sp.Stop() 86 | 87 | nydusName, err := b.Nydusify(ctx, attachConfig.Target) 88 | if err != nil { 89 | err = fmt.Errorf("failed to nydusify %s: %w", attachConfig.Target, err) 90 | sp.FinalMSG = err.Error() 91 | return err 92 | } 93 | 94 | sp.FinalMSG = fmt.Sprintf("Successfully nydusify model artifact: %s", nydusName) 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/build.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "time" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/backend" 25 | "github.com/CloudNativeAI/modctl/pkg/config" 26 | "github.com/briandowns/spinner" 27 | 28 | "github.com/spf13/cobra" 29 | "github.com/spf13/viper" 30 | ) 31 | 32 | var buildConfig = config.NewBuild() 33 | 34 | // buildCmd represents the modctl command for build. 35 | var buildCmd = &cobra.Command{ 36 | Use: "build [flags] ", 37 | Short: "A command line tool for modctl build", 38 | Args: cobra.ExactArgs(1), 39 | DisableAutoGenTag: true, 40 | SilenceUsage: true, 41 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | if err := buildConfig.Validate(); err != nil { 44 | return err 45 | } 46 | 47 | return runBuild(context.Background(), args[0]) 48 | }, 49 | } 50 | 51 | // init initializes build command. 52 | func init() { 53 | flags := buildCmd.Flags() 54 | flags.IntVarP(&buildConfig.Concurrency, "concurrency", "c", buildConfig.Concurrency, "specify the number of concurrent build operations") 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") 57 | flags.BoolVarP(&buildConfig.OutputRemote, "output-remote", "", false, "turning on this flag will output model artifact to remote registry directly") 58 | flags.BoolVarP(&buildConfig.PlainHTTP, "plain-http", "", false, "turning on this flag will use plain HTTP instead of HTTPS") 59 | flags.BoolVarP(&buildConfig.Insecure, "insecure", "", false, "turning on this flag will disable TLS verification") 60 | flags.BoolVar(&buildConfig.Nydusify, "nydusify", false, "[EXPERIMENTAL] nydusify the model artifact") 61 | flags.MarkHidden("nydusify") 62 | flags.StringVar(&buildConfig.SourceURL, "source-url", "", "source URL") 63 | flags.StringVar(&buildConfig.SourceRevision, "source-revision", "", "source revision") 64 | // TODO: set the raw flag to true by default in future. 65 | flags.BoolVar(&buildConfig.Raw, "raw", false, "turning on this flag will build model artifact layers in raw format") 66 | 67 | if err := viper.BindPFlags(flags); err != nil { 68 | panic(fmt.Errorf("bind cache list flags to viper: %w", err)) 69 | } 70 | } 71 | 72 | // runBuild runs the build modctl. 73 | func runBuild(ctx context.Context, workDir string) error { 74 | b, err := backend.New(rootConfig.StoargeDir) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if err := b.Build(ctx, buildConfig.Modelfile, workDir, buildConfig.Target, buildConfig); err != nil { 80 | return err 81 | } 82 | 83 | fmt.Printf("Successfully built model artifact: %s\n", buildConfig.Target) 84 | 85 | // nydusify the model artifact if needed. 86 | if buildConfig.Nydusify { 87 | sp := spinner.New(spinner.CharSets[39], 100*time.Millisecond, spinner.WithSuffix("Nydusifying...")) 88 | sp.Start() 89 | defer sp.Stop() 90 | 91 | nydusName, err := b.Nydusify(ctx, buildConfig.Target) 92 | if err != nil { 93 | err = fmt.Errorf("failed to nydusify %s: %w", buildConfig.Target, err) 94 | sp.FinalMSG = err.Error() 95 | return err 96 | } 97 | 98 | sp.FinalMSG = fmt.Sprintf("Successfully nydusify model artifact: %s", nydusName) 99 | } 100 | 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /cmd/extract.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 extractConfig = config.NewExtract() 31 | 32 | // extractCmd represents the modctl command for extract. 33 | var extractCmd = &cobra.Command{ 34 | Use: "extract --output ", 35 | Short: "A command line tool for modctl extract", 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 := extractConfig.Validate(); err != nil { 42 | return err 43 | } 44 | 45 | return runExtract(context.Background(), args[0]) 46 | }, 47 | } 48 | 49 | // init initializes extract command. 50 | func init() { 51 | flags := extractCmd.Flags() 52 | flags.StringVar(&extractConfig.Output, "output", "", "specify the output for extracting the model artifact") 53 | flags.IntVar(&extractConfig.Concurrency, "concurrency", extractConfig.Concurrency, "specify the concurrency for extracting the model artifact") 54 | 55 | if err := viper.BindPFlags(flags); err != nil { 56 | panic(fmt.Errorf("bind cache extract flags to viper: %w", err)) 57 | } 58 | } 59 | 60 | // runExtract runs the extract modctl. 61 | func runExtract(ctx context.Context, target string) error { 62 | b, err := backend.New(rootConfig.StoargeDir) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | if target == "" { 68 | return fmt.Errorf("target is required") 69 | } 70 | 71 | if err := b.Extract(ctx, target, extractConfig); err != nil { 72 | return err 73 | } 74 | 75 | fmt.Printf("Successfully extracted model artifact %s to %s\n", target, extractConfig.Output) 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /cmd/fetch.go: -------------------------------------------------------------------------------- 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] ", 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 | } 82 | -------------------------------------------------------------------------------- /cmd/inspect.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "encoding/json" 22 | "fmt" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/backend" 25 | "github.com/CloudNativeAI/modctl/pkg/config" 26 | 27 | "github.com/spf13/cobra" 28 | "github.com/spf13/viper" 29 | ) 30 | 31 | var inspectConfig = config.NewInspect() 32 | 33 | // inspectCmd represents the modctl command for inspect. 34 | var inspectCmd = &cobra.Command{ 35 | Use: "inspect [flags] ", 36 | Short: "A command line tool for modctl inspect", 37 | Args: cobra.ExactArgs(1), 38 | DisableAutoGenTag: true, 39 | SilenceUsage: true, 40 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | return runInspect(context.Background(), args[0]) 43 | }, 44 | } 45 | 46 | // init initializes inspect command. 47 | func init() { 48 | flags := inspectCmd.Flags() 49 | flags.BoolVar(&inspectConfig.Remote, "remote", false, "inspect model artifact from remote registry") 50 | flags.BoolVar(&inspectConfig.PlainHTTP, "plain-http", false, "use plain HTTP instead of HTTPS") 51 | flags.BoolVar(&inspectConfig.Insecure, "insecure", false, "allow insecure connections") 52 | 53 | if err := viper.BindPFlags(flags); err != nil { 54 | panic(fmt.Errorf("bind cache inspect flags to viper: %w", err)) 55 | } 56 | } 57 | 58 | // runInspect runs the inspect modctl. 59 | func runInspect(ctx context.Context, target string) error { 60 | b, err := backend.New(rootConfig.StoargeDir) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | if target == "" { 66 | return fmt.Errorf("target is required") 67 | } 68 | 69 | inspected, err := b.Inspect(ctx, target, inspectConfig) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | data, err := json.MarshalIndent(inspected, "", " ") 75 | if err != nil { 76 | return err 77 | } 78 | 79 | fmt.Println(string(data)) 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "os" 23 | "text/tabwriter" 24 | 25 | "github.com/CloudNativeAI/modctl/pkg/backend" 26 | 27 | humanize "github.com/dustin/go-humanize" 28 | "github.com/spf13/cobra" 29 | "github.com/spf13/viper" 30 | ) 31 | 32 | // listCmd represents the modctl command for list. 33 | var listCmd = &cobra.Command{ 34 | Use: "ls", 35 | Short: "A command line tool for modctl list", 36 | Args: cobra.NoArgs, 37 | DisableAutoGenTag: true, 38 | SilenceUsage: true, 39 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | return runList(context.Background()) 42 | }, 43 | } 44 | 45 | // init initializes list command. 46 | func init() { 47 | flags := listCmd.Flags() 48 | 49 | if err := viper.BindPFlags(flags); err != nil { 50 | panic(fmt.Errorf("bind cache list flags to viper: %w", err)) 51 | } 52 | } 53 | 54 | // runList runs the list modctl. 55 | func runList(ctx context.Context) error { 56 | b, err := backend.New(rootConfig.StoargeDir) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | artifacts, err := b.List(ctx) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | tw := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) 67 | defer tw.Flush() 68 | fmt.Fprintln(tw, "REPOSITORY\tTAG\tDIGEST\tCREATED\tSIZE") 69 | 70 | for _, artifact := range artifacts { 71 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", artifact.Repository, artifact.Tag, artifact.Digest, humanize.Time(artifact.CreatedAt), humanize.IBytes(uint64(artifact.Size))) 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "strings" 23 | "syscall" 24 | 25 | "golang.org/x/crypto/ssh/terminal" 26 | 27 | "github.com/CloudNativeAI/modctl/pkg/backend" 28 | "github.com/CloudNativeAI/modctl/pkg/config" 29 | "github.com/spf13/cobra" 30 | "github.com/spf13/viper" 31 | ) 32 | 33 | var loginConfig = config.NewLogin() 34 | 35 | // loginCmd represents the modctl command for login. 36 | var loginCmd = &cobra.Command{ 37 | Use: "login [flags] ", 38 | Short: "A command line tool for modctl login", 39 | Example: ` 40 | # login to docker hub: 41 | modctl login -u foo registry-1.docker.io 42 | 43 | # login to registry served over http: 44 | modctl login -u foo --plain-http registry-insecure.io 45 | `, 46 | Args: cobra.ExactArgs(1), 47 | DisableAutoGenTag: true, 48 | SilenceUsage: true, 49 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 50 | RunE: func(cmd *cobra.Command, args []string) error { 51 | if err := loginConfig.Validate(); err != nil { 52 | return err 53 | } 54 | 55 | return runLogin(cmd.Context(), args[0]) 56 | }, 57 | } 58 | 59 | // init initializes login command. 60 | func init() { 61 | flags := loginCmd.Flags() 62 | flags.StringVarP(&loginConfig.Username, "username", "u", "", "Username for login") 63 | flags.StringVarP(&loginConfig.Password, "password", "p", "", "Password for login") 64 | flags.BoolVar(&loginConfig.PasswordStdin, "password-stdin", true, "Take the password from stdin by default") 65 | flags.BoolVar(&loginConfig.PlainHTTP, "plain-http", false, "Allow http connections to registry") 66 | flags.BoolVar(&loginConfig.Insecure, "insecure", false, "Allow insecure connections to registry") 67 | 68 | if err := viper.BindPFlags(flags); err != nil { 69 | panic(fmt.Errorf("bind cache login flags to viper: %w", err)) 70 | } 71 | } 72 | 73 | // runLogin runs the login modctl. 74 | func runLogin(ctx context.Context, registry string) error { 75 | b, err := backend.New(rootConfig.StoargeDir) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // read password from stdin if password-stdin is set 81 | if loginConfig.PasswordStdin && loginConfig.Password == "" { 82 | fmt.Print("Enter password: ") 83 | password, err := terminal.ReadPassword(syscall.Stdin) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | loginConfig.Password = strings.TrimSpace(string(password)) 89 | } 90 | 91 | fmt.Println("\nLogging In...") 92 | 93 | if err := b.Login(ctx, registry, loginConfig.Username, loginConfig.Password, loginConfig); err != nil { 94 | return err 95 | } 96 | 97 | fmt.Println("Login Succeeded.") 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /cmd/logout.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | 25 | "github.com/spf13/cobra" 26 | "github.com/spf13/viper" 27 | ) 28 | 29 | // logoutCmd represents the modctl command for logout. 30 | var logoutCmd = &cobra.Command{ 31 | Use: "logout [flags]", 32 | Short: "A command line tool for modctl logout", 33 | Args: cobra.ExactArgs(1), 34 | DisableAutoGenTag: true, 35 | SilenceUsage: true, 36 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | return runLogout(context.Background(), args[0]) 39 | }, 40 | } 41 | 42 | // init initializes logout command. 43 | func init() { 44 | flags := logoutCmd.Flags() 45 | 46 | if err := viper.BindPFlags(flags); err != nil { 47 | panic(fmt.Errorf("bind cache logout flags to viper: %w", err)) 48 | } 49 | } 50 | 51 | // runLogout runs the logout modctl. 52 | func runLogout(ctx context.Context, registry string) error { 53 | b, err := backend.New(rootConfig.StoargeDir) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if err := b.Logout(ctx, registry); err != nil { 59 | return err 60 | } 61 | 62 | fmt.Println("Logout Succeeded.") 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /cmd/modelfile/generate.go: -------------------------------------------------------------------------------- 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 modelfile 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | 24 | configmodelfile "github.com/CloudNativeAI/modctl/pkg/config/modelfile" 25 | "github.com/CloudNativeAI/modctl/pkg/modelfile" 26 | 27 | "github.com/spf13/cobra" 28 | "github.com/spf13/viper" 29 | ) 30 | 31 | var generateConfig = configmodelfile.NewGenerateConfig() 32 | 33 | // generateCmd represents the modelfile tools command for generating modelfile. 34 | var generateCmd = &cobra.Command{ 35 | Use: "generate [flags] ", 36 | Short: "A command line tool for generating modelfile in the workspace, the workspace must be a directory including model files and model configuration files", 37 | Args: cobra.ExactArgs(1), 38 | DisableAutoGenTag: true, 39 | SilenceUsage: true, 40 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 41 | RunE: func(cmd *cobra.Command, args []string) error { 42 | if err := generateConfig.Convert(args[0]); err != nil { 43 | return err 44 | } 45 | 46 | if err := generateConfig.Validate(); err != nil { 47 | return err 48 | } 49 | 50 | return runGenerate(context.Background()) 51 | }, 52 | } 53 | 54 | // init initializes generate command. 55 | func init() { 56 | flags := generateCmd.Flags() 57 | flags.StringVarP(&generateConfig.Name, "name", "n", "", "specify the model name, such as llama3-8b-instruct, gpt2-xl, qwen2-vl-72b-instruct, etc") 58 | flags.StringVar(&generateConfig.Arch, "arch", "", "specify the model architecture, such as transformer, cnn, rnn, etc") 59 | flags.StringVar(&generateConfig.Family, "family", "", "specify model family, such as llama3, gpt2, qwen2, etc") 60 | flags.StringVar(&generateConfig.Format, "format", "", "specify model format, such as safetensors, pytorch, onnx, etc") 61 | flags.StringVar(&generateConfig.ParamSize, "param-size", "", "specify number of model parameters, such as 8b, 16b, 32b, etc") 62 | flags.StringVar(&generateConfig.Precision, "precision", "", "specify model precision, such as bf16, fp16, int8, etc") 63 | flags.StringVar(&generateConfig.Quantization, "quantization", "", "specify model quantization, such as awq, gptq, etc") 64 | flags.StringVarP(&generateConfig.Output, "output", "O", ".", "specify the output path of modelfilem, must be a directory") 65 | flags.BoolVar(&generateConfig.IgnoreUnrecognizedFileTypes, "ignore-unrecognized-file-types", false, "ignore the unrecognized file types in the workspace") 66 | flags.BoolVar(&generateConfig.Overwrite, "overwrite", false, "overwrite the existing modelfile") 67 | 68 | if err := viper.BindPFlags(flags); err != nil { 69 | panic(fmt.Errorf("bind cache list flags to viper: %w", err)) 70 | } 71 | } 72 | 73 | // runGenerate runs the generate modelfile. 74 | func runGenerate(_ context.Context) error { 75 | fmt.Printf("Generating modelfile for %s\n", generateConfig.Workspace) 76 | modelfile, err := modelfile.NewModelfileByWorkspace(generateConfig.Workspace, generateConfig) 77 | if err != nil { 78 | return fmt.Errorf("failed to generate modelfile: %w", err) 79 | } 80 | 81 | content := modelfile.Content() 82 | if err := os.WriteFile(generateConfig.Output, content, 0644); err != nil { 83 | return fmt.Errorf("failed to write modelfile: %w", err) 84 | } 85 | 86 | fmt.Printf("Successfully generated modelfile:\n%s\n", string(content)) 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/modelfile/modelfile.go: -------------------------------------------------------------------------------- 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 modelfile 18 | 19 | import ( 20 | "github.com/sirupsen/logrus" 21 | 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/viper" 24 | ) 25 | 26 | // RootCmd represents the modelfile tools command for modelfile operation. 27 | var RootCmd = &cobra.Command{ 28 | Use: "modelfile", 29 | Short: "A command line tool for modelfile operation", 30 | Args: cobra.ExactArgs(1), 31 | DisableAutoGenTag: true, 32 | SilenceUsage: true, 33 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 34 | RunE: func(cmd *cobra.Command, args []string) error { 35 | logrus.Debug("modctl modelfile is running") 36 | 37 | return nil 38 | }, 39 | } 40 | 41 | // init initializes modelfile command. 42 | func init() { 43 | flags := RootCmd.Flags() 44 | 45 | if err := viper.BindPFlags(flags); err != nil { 46 | panic(err) 47 | } 48 | 49 | // Add sub command. 50 | RootCmd.AddCommand(generateCmd) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/prune.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 pruneConfig = config.NewPrune() 31 | 32 | // pruneCmd represents the modctl command for prune. 33 | var pruneCmd = &cobra.Command{ 34 | Use: "prune [flags]", 35 | Short: "A command line tool for modctl prune", 36 | Args: cobra.NoArgs, 37 | DisableAutoGenTag: true, 38 | SilenceUsage: true, 39 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 40 | RunE: func(cmd *cobra.Command, args []string) error { 41 | return runPrune(context.Background()) 42 | }, 43 | } 44 | 45 | // init initializes prune command. 46 | func init() { 47 | flags := rmCmd.Flags() 48 | flags.BoolVar(&pruneConfig.DryRun, "dry-run", false, "do not remove any blobs, just print what would be removed") 49 | flags.BoolVar(&pruneConfig.RemoveUntagged, "remove-untagged", true, "remove untagged manifests") 50 | 51 | if err := viper.BindPFlags(flags); err != nil { 52 | panic(fmt.Errorf("bind cache rm flags to viper: %w", err)) 53 | } 54 | } 55 | 56 | // runPrune runs the prune modctl. 57 | func runPrune(ctx context.Context) error { 58 | b, err := backend.New(rootConfig.StoargeDir) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | return b.Prune(ctx, pruneConfig.DryRun, pruneConfig.RemoveUntagged) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/pull.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 pullConfig = config.NewPull() 31 | 32 | // pullCmd represents the modctl command for pull. 33 | var pullCmd = &cobra.Command{ 34 | Use: "pull [flags] ", 35 | Short: "A command line tool for modctl pull", 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 := pullConfig.Validate(); err != nil { 42 | return err 43 | } 44 | 45 | return runPull(context.Background(), args[0]) 46 | }, 47 | } 48 | 49 | // init initializes pull command. 50 | func init() { 51 | flags := pullCmd.Flags() 52 | flags.IntVar(&pullConfig.Concurrency, "concurrency", pullConfig.Concurrency, "specify the number of concurrent pull operations") 53 | flags.BoolVar(&pullConfig.PlainHTTP, "plain-http", false, "use plain HTTP instead of HTTPS") 54 | flags.BoolVar(&pullConfig.Insecure, "insecure", false, "use insecure connection for the pull operation and skip TLS verification") 55 | flags.StringVar(&pullConfig.Proxy, "proxy", "", "use proxy for the pull operation") 56 | flags.StringVar(&pullConfig.ExtractDir, "extract-dir", "", "specify the extract dir for extracting the model artifact") 57 | flags.BoolVar(&pullConfig.ExtractFromRemote, "extract-from-remote", false, "turning on this flag will pull and extract the data from remote registry and no longer store model artifact locally, so user must specify extract-dir as the output directory") 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 | // runPull runs the pull modctl. 65 | func runPull(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.Pull(ctx, target, pullConfig); err != nil { 76 | return err 77 | } 78 | 79 | fmt.Printf("Successfully pulled model artifact: %s\n", target) 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /cmd/push.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "time" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/backend" 25 | "github.com/CloudNativeAI/modctl/pkg/config" 26 | 27 | "github.com/briandowns/spinner" 28 | "github.com/spf13/cobra" 29 | "github.com/spf13/viper" 30 | ) 31 | 32 | var pushConfig = config.NewPush() 33 | 34 | // pushCmd represents the modctl command for push. 35 | var pushCmd = &cobra.Command{ 36 | Use: "push [flags] ", 37 | Short: "A command line tool for modctl push", 38 | Args: cobra.ExactArgs(1), 39 | DisableAutoGenTag: true, 40 | SilenceUsage: true, 41 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 42 | RunE: func(cmd *cobra.Command, args []string) error { 43 | if err := pushConfig.Validate(); err != nil { 44 | return err 45 | } 46 | 47 | return runPush(context.Background(), args[0]) 48 | }, 49 | } 50 | 51 | // init initializes push command. 52 | func init() { 53 | flags := pushCmd.Flags() 54 | flags.IntVar(&pushConfig.Concurrency, "concurrency", pushConfig.Concurrency, "specify the number of concurrent push operations") 55 | flags.BoolVar(&pushConfig.PlainHTTP, "plain-http", false, "use plain HTTP instead of HTTPS") 56 | flags.BoolVar(&pushConfig.Insecure, "insecure", false, "turning on this flag will disable TLS verification") 57 | flags.BoolVar(&pushConfig.Nydusify, "nydusify", false, "[EXPERIMENTAL] nydusify the model artifact") 58 | flags.MarkHidden("nydusify") 59 | 60 | if err := viper.BindPFlags(flags); err != nil { 61 | panic(fmt.Errorf("bind cache push flags to viper: %w", err)) 62 | } 63 | } 64 | 65 | // runPush runs the push modctl. 66 | func runPush(ctx context.Context, target string) error { 67 | b, err := backend.New(rootConfig.StoargeDir) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | if err := b.Push(ctx, target, pushConfig); err != nil { 73 | return err 74 | } 75 | 76 | fmt.Printf("Successfully pushed model artifact: %s\n", target) 77 | 78 | // nydusify the model artifact if needed. 79 | if pushConfig.Nydusify { 80 | sp := spinner.New(spinner.CharSets[39], 100*time.Millisecond, spinner.WithSuffix("Nydusifying...")) 81 | sp.Start() 82 | defer sp.Stop() 83 | 84 | nydusName, err := b.Nydusify(ctx, target) 85 | if err != nil { 86 | err = fmt.Errorf("failed to nydusify %s: %w", target, err) 87 | sp.FinalMSG = err.Error() 88 | return err 89 | } 90 | 91 | sp.FinalMSG = fmt.Sprintf("Successfully nydusify model artifact: %s", nydusName) 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /cmd/rm.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | 25 | "github.com/spf13/cobra" 26 | "github.com/spf13/viper" 27 | ) 28 | 29 | // rmCmd represents the modctl command for rm. 30 | var rmCmd = &cobra.Command{ 31 | Use: "rm [flags] ", 32 | Short: "A command line tool for modctl rm", 33 | Args: cobra.ExactArgs(1), 34 | DisableAutoGenTag: true, 35 | SilenceUsage: true, 36 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | return runRm(context.Background(), args[0]) 39 | }, 40 | } 41 | 42 | // init initializes rm command. 43 | func init() { 44 | flags := rmCmd.Flags() 45 | 46 | if err := viper.BindPFlags(flags); err != nil { 47 | panic(fmt.Errorf("bind cache rm flags to viper: %w", err)) 48 | } 49 | } 50 | 51 | // runRm runs the rm modctl. 52 | func runRm(ctx context.Context, target string) error { 53 | b, err := backend.New(rootConfig.StoargeDir) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if target == "" { 59 | return fmt.Errorf("target is required") 60 | } 61 | 62 | digest, err := b.Remove(ctx, target) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | fmt.Printf("Deleted: %s\n", digest) 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "log" 21 | "net/http" 22 | _ "net/http/pprof" 23 | "os" 24 | "os/signal" 25 | "syscall" 26 | 27 | "github.com/spf13/cobra" 28 | "github.com/spf13/viper" 29 | 30 | "github.com/CloudNativeAI/modctl/cmd/modelfile" 31 | internalpb "github.com/CloudNativeAI/modctl/internal/pb" 32 | "github.com/CloudNativeAI/modctl/pkg/config" 33 | ) 34 | 35 | var rootConfig *config.Root 36 | 37 | // rootCmd represents the modctl command. 38 | var rootCmd = &cobra.Command{ 39 | Use: "modctl", 40 | Short: "A command line tool for managing artifact bundled based on the Model Format Specification", 41 | Args: cobra.MaximumNArgs(1), 42 | DisableAutoGenTag: true, 43 | SilenceUsage: true, 44 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 45 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 46 | // Start pprof server if enabled. 47 | if rootConfig.Pprof { 48 | go func() { 49 | err := http.ListenAndServe(rootConfig.PprofAddr, nil) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | }() 54 | } 55 | 56 | // TODO: need refactor as currently use a global flag to control the progress bar render. 57 | internalpb.SetDisableProgress(rootConfig.DisableProgress) 58 | return nil 59 | }, 60 | } 61 | 62 | // Execute adds all child commands to the root command and sets flags appropriately. 63 | // This is called by main.main(). It only needs to happen once to the rootCmd. 64 | func Execute() { 65 | sig := make(chan os.Signal, 1) 66 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 67 | 68 | go func() { 69 | <-sig 70 | os.Exit(1) 71 | }() 72 | 73 | if err := rootCmd.Execute(); err != nil { 74 | os.Exit(1) 75 | } 76 | } 77 | 78 | func init() { 79 | var err error 80 | rootConfig, err = config.NewRoot() 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | // Bind more cache specific persistent flags. 86 | flags := rootCmd.PersistentFlags() 87 | flags.StringVar(&rootConfig.StoargeDir, "storage-dir", rootConfig.StoargeDir, "specify the storage directory for modctl") 88 | flags.BoolVar(&rootConfig.Pprof, "pprof", rootConfig.Pprof, "enable pprof") 89 | flags.StringVar(&rootConfig.PprofAddr, "pprof-addr", rootConfig.PprofAddr, "specify the address for pprof") 90 | flags.BoolVar(&rootConfig.DisableProgress, "no-progress", rootConfig.DisableProgress, "disable progress bar") 91 | 92 | // Bind common flags. 93 | if err := viper.BindPFlags(flags); err != nil { 94 | panic(err) 95 | } 96 | 97 | // Add sub command. 98 | rootCmd.AddCommand(versionCmd) 99 | rootCmd.AddCommand(buildCmd) 100 | rootCmd.AddCommand(listCmd) 101 | rootCmd.AddCommand(loginCmd) 102 | rootCmd.AddCommand(logoutCmd) 103 | rootCmd.AddCommand(pullCmd) 104 | rootCmd.AddCommand(pushCmd) 105 | rootCmd.AddCommand(rmCmd) 106 | rootCmd.AddCommand(pruneCmd) 107 | rootCmd.AddCommand(inspectCmd) 108 | rootCmd.AddCommand(extractCmd) 109 | rootCmd.AddCommand(tagCmd) 110 | rootCmd.AddCommand(fetchCmd) 111 | rootCmd.AddCommand(attachCmd) 112 | rootCmd.AddCommand(modelfile.RootCmd) 113 | } 114 | -------------------------------------------------------------------------------- /cmd/tag.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | 25 | "github.com/spf13/cobra" 26 | "github.com/spf13/viper" 27 | ) 28 | 29 | // tagCmd represents the modctl command for tag. 30 | var tagCmd = &cobra.Command{ 31 | Use: "tag [flags] ", 32 | Short: "A command line tool for modctl tag", 33 | Args: cobra.ExactArgs(2), 34 | DisableAutoGenTag: true, 35 | SilenceUsage: true, 36 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 37 | RunE: func(cmd *cobra.Command, args []string) error { 38 | return runTag(context.Background(), args[0], args[1]) 39 | }, 40 | } 41 | 42 | // init initializes tag command. 43 | func init() { 44 | flags := tagCmd.Flags() 45 | 46 | if err := viper.BindPFlags(flags); err != nil { 47 | panic(fmt.Errorf("bind cache tag flags to viper: %w", err)) 48 | } 49 | } 50 | 51 | // runTag runs the tag modctl. 52 | func runTag(ctx context.Context, source, target string) error { 53 | b, err := backend.New(rootConfig.StoargeDir) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | if source == "" || target == "" { 59 | return fmt.Errorf("source and target are required") 60 | } 61 | 62 | return b.Tag(ctx, source, target) 63 | } 64 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 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 | "fmt" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/version" 23 | 24 | "github.com/spf13/cobra" 25 | "github.com/spf13/viper" 26 | ) 27 | 28 | // versionCmd represents the modctl command for version. 29 | var versionCmd = &cobra.Command{ 30 | Use: "version", 31 | Short: "A command line tool for modctl version", 32 | DisableAutoGenTag: true, 33 | SilenceUsage: true, 34 | FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true}, 35 | RunE: func(cmd *cobra.Command, args []string) error { 36 | return runVersion() 37 | }, 38 | } 39 | 40 | // init initializes version command. 41 | func init() { 42 | flags := rmCmd.Flags() 43 | 44 | if err := viper.BindPFlags(flags); err != nil { 45 | panic(fmt.Errorf("bind version flags to viper: %w", err)) 46 | } 47 | } 48 | 49 | // runVersion runs the version modctl. 50 | func runVersion() error { 51 | fmt.Printf("%-12s%s\n", "Version:", version.GitVersion) 52 | fmt.Printf("%-12s%s\n", "Commit:", version.GitCommit) 53 | fmt.Printf("%-12s%s\n", "Platform:", version.Platform) 54 | fmt.Printf("%-12s%s\n", "BuildTime:", version.BuildTime) 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /copyright.txt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | -------------------------------------------------------------------------------- /hack/nfpm.yaml: -------------------------------------------------------------------------------- 1 | arch: ${GOARCH} 2 | platform: ${GOOS} 3 | name: modctl 4 | version: ${VERSION} 5 | maintainer: "Model Spec Maintainers " 6 | description: "A command line tool for managing artifact bundled based on the Model Format Specification" 7 | license: "Apache 2.0" 8 | contents: 9 | - src: modctl 10 | dst: /usr/bin/modctl 11 | expand: true 12 | 13 | - src: build/package/docs/modctl.1 14 | dst: /usr/share/man/man1/modctl.1 15 | 16 | - src: LICENSE 17 | dst: /usr/share/doc/modctl/License 18 | -------------------------------------------------------------------------------- /internal/pb/pb.go: -------------------------------------------------------------------------------- 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 pb 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | "sync" 24 | "time" 25 | 26 | humanize "github.com/dustin/go-humanize" 27 | mpbv8 "github.com/vbauerster/mpb/v8" 28 | "github.com/vbauerster/mpb/v8/decor" 29 | ) 30 | 31 | var ( 32 | // disableProgress is the flag to disable progress bar. 33 | disableProgress bool 34 | ) 35 | 36 | // SetDisableProgress disables the progress bar. 37 | func SetDisableProgress(disable bool) { 38 | disableProgress = disable 39 | } 40 | 41 | // NormalizePrompt normalizes the prompt string. 42 | func NormalizePrompt(prompt string) string { 43 | return fmt.Sprintf("%s =>", prompt) 44 | } 45 | 46 | // ProgressBar is a progress bar. 47 | type ProgressBar struct { 48 | mu sync.RWMutex 49 | mpb *mpbv8.Progress 50 | bars map[string]*progressBar 51 | } 52 | 53 | type progressBar struct { 54 | *mpbv8.Bar 55 | size int64 56 | msg string 57 | } 58 | 59 | // NewProgressBar creates a new progress bar. 60 | func NewProgressBar(writers ...io.Writer) *ProgressBar { 61 | opts := []mpbv8.ContainerOption{ 62 | mpbv8.WithAutoRefresh(), 63 | mpbv8.WithWidth(60), 64 | mpbv8.WithRefreshRate(300 * time.Millisecond), 65 | } 66 | 67 | // If no writer specified, use stdout. 68 | if len(writers) == 0 { 69 | opts = append(opts, mpbv8.WithOutput(os.Stdout)) 70 | } else if len(writers) == 1 { 71 | opts = append(opts, mpbv8.WithOutput(writers[0])) 72 | } else { 73 | opts = append(opts, mpbv8.WithOutput(io.MultiWriter(writers...))) 74 | } 75 | 76 | return &ProgressBar{ 77 | mpb: mpbv8.New(opts...), 78 | bars: make(map[string]*progressBar), 79 | } 80 | } 81 | 82 | // Add adds a new progress bar. 83 | func (p *ProgressBar) Add(prompt, name string, size int64, reader io.Reader) io.Reader { 84 | // Return the reader directly if progress is disabled. 85 | if disableProgress { 86 | return reader 87 | } 88 | 89 | p.mu.RLock() 90 | oldBar := p.bars[name] 91 | p.mu.RUnlock() 92 | 93 | // If the bar exists, drop and remove it. 94 | if oldBar != nil { 95 | oldBar.Abort(true) 96 | } 97 | 98 | newBar := &progressBar{size: size, msg: fmt.Sprintf("%s %s", prompt, name)} 99 | // Create a new bar if it does not exist. 100 | newBar.Bar = p.mpb.New(size, 101 | mpbv8.BarStyle(), 102 | mpbv8.BarFillerOnComplete("|"), 103 | mpbv8.PrependDecorators( 104 | decor.Any(func(s decor.Statistics) string { 105 | return newBar.msg 106 | }, decor.WCSyncSpaceR), 107 | ), 108 | mpbv8.AppendDecorators( 109 | decor.OnComplete(decor.Counters(decor.SizeB1024(0), "% .2f / % .2f"), humanize.Bytes(uint64(size))), 110 | decor.OnComplete(decor.Name(" | ", decor.WCSyncWidthR), " | "), 111 | decor.OnComplete( 112 | decor.AverageSpeed(decor.SizeB1024(0), "% .2f", decor.WCSyncWidthR), "done", 113 | ), 114 | ), 115 | ) 116 | 117 | p.mu.Lock() 118 | p.bars[name] = newBar 119 | p.mu.Unlock() 120 | 121 | return newBar.ProxyReader(reader) 122 | } 123 | 124 | // Complete completes the progress bar. 125 | func (p *ProgressBar) Complete(name string, msg string) { 126 | p.mu.RLock() 127 | bar, ok := p.bars[name] 128 | p.mu.RUnlock() 129 | 130 | if ok { 131 | bar.msg = msg 132 | bar.Bar.SetCurrent(bar.size) 133 | } 134 | } 135 | 136 | // Abort aborts the progress bar. 137 | func (p *ProgressBar) Abort(name string, err error) { 138 | p.mu.RLock() 139 | bar, ok := p.bars[name] 140 | p.mu.RUnlock() 141 | 142 | if ok { 143 | // TODO: Log error message. 144 | bar.Abort(true) 145 | } 146 | } 147 | 148 | // Start starts the progress bar. 149 | func (p *ProgressBar) Start() {} 150 | 151 | // Stop waits for the progress bar to finish. 152 | func (p *ProgressBar) Stop() { 153 | p.mpb.Shutdown() 154 | } 155 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 main 18 | 19 | import ( 20 | "github.com/CloudNativeAI/modctl/cmd" 21 | ) 22 | 23 | func main() { 24 | cmd.Execute() 25 | } 26 | -------------------------------------------------------------------------------- /pkg/archiver/archiver_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 archiver 18 | 19 | import ( 20 | "bytes" 21 | "io" 22 | "os" 23 | "path/filepath" 24 | "testing" 25 | ) 26 | 27 | func TestTar(t *testing.T) { 28 | tmpDir, err := os.MkdirTemp("", "archiver_test") 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | defer os.RemoveAll(tmpDir) 33 | 34 | filePath := filepath.Join(tmpDir, "testfile.txt") 35 | if err := os.WriteFile(filePath, []byte("hello"), 0644); err != nil { 36 | t.Fatalf("write file error: %v", err) 37 | } 38 | 39 | tarReader, err := Tar(filePath, tmpDir) 40 | if err != nil { 41 | t.Fatalf("Tar error: %v", err) 42 | } 43 | 44 | var buf bytes.Buffer 45 | if _, err := io.Copy(&buf, tarReader); err != nil { 46 | t.Fatalf("copy tar error: %v", err) 47 | } 48 | 49 | if buf.Len() == 0 { 50 | t.Fatal("tar archive is empty") 51 | } 52 | } 53 | 54 | func TestUntar(t *testing.T) { 55 | tmpDir, err := os.MkdirTemp("", "archiver_test") 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | defer os.RemoveAll(tmpDir) 60 | 61 | filePath := filepath.Join(tmpDir, "testfile.txt") 62 | if err := os.WriteFile(filePath, []byte("hello"), 0644); err != nil { 63 | t.Fatalf("write file error: %v", err) 64 | } 65 | 66 | tarReader, err := Tar(filePath, tmpDir) 67 | if err != nil { 68 | t.Fatalf("Tar error: %v", err) 69 | } 70 | 71 | var buf bytes.Buffer 72 | if _, err := io.Copy(&buf, tarReader); err != nil { 73 | t.Fatalf("copy tar error: %v", err) 74 | } 75 | 76 | extractDir, err := os.MkdirTemp("", "archiver_extracted") 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | defer os.RemoveAll(extractDir) 81 | 82 | if err := Untar(bytes.NewReader(buf.Bytes()), extractDir); err != nil { 83 | t.Fatalf("Untar error: %v", err) 84 | } 85 | 86 | extractedFile := filepath.Join(extractDir, filepath.Base(filePath)) 87 | data, err := os.ReadFile(extractedFile) 88 | if err != nil { 89 | t.Fatalf("read extracted file error: %v", err) 90 | } 91 | 92 | if string(data) != "hello" { 93 | t.Errorf("expected 'hello', got '%s'", string(data)) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/backend/backend.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | 22 | "github.com/CloudNativeAI/modctl/pkg/config" 23 | "github.com/CloudNativeAI/modctl/pkg/storage" 24 | ) 25 | 26 | // Backend is the interface to represent the backend. 27 | type Backend interface { 28 | // Login logs into a registry. 29 | Login(ctx context.Context, registry, username, password string, cfg *config.Login) error 30 | 31 | // Logout logs out from a registry. 32 | Logout(ctx context.Context, registry string) error 33 | 34 | // Attach attaches user materials into the model artifact which follows the Model Spec. 35 | Attach(ctx context.Context, filepath string, cfg *config.Attach) error 36 | 37 | // Build builds the user materials into the model artifact which follows the Model Spec. 38 | Build(ctx context.Context, modelfilePath, workDir, target string, cfg *config.Build) error 39 | 40 | // Pull pulls an artifact from a registry. 41 | Pull(ctx context.Context, target string, cfg *config.Pull) error 42 | 43 | // Fetch fetches partial files to the output. 44 | Fetch(ctx context.Context, target string, cfg *config.Fetch) error 45 | 46 | // Push pushes the image to the registry. 47 | Push(ctx context.Context, target string, cfg *config.Push) error 48 | 49 | // List lists all the model artifacts. 50 | List(ctx context.Context) ([]*ModelArtifact, error) 51 | 52 | // Remove deletes the model artifact. 53 | Remove(ctx context.Context, target string) (string, error) 54 | 55 | // Prune prunes the unused blobs and clean up the storage. 56 | Prune(ctx context.Context, dryRun, removeUntagged bool) error 57 | 58 | // Inspect inspects the model artifact. 59 | Inspect(ctx context.Context, target string, cfg *config.Inspect) (*InspectedModelArtifact, error) 60 | 61 | // Extract extracts the model artifact. 62 | Extract(ctx context.Context, target string, cfg *config.Extract) error 63 | 64 | // Tag creates a new tag that refers to the source model artifact. 65 | Tag(ctx context.Context, source, target string) error 66 | 67 | // Nydusify converts the model artifact to nydus format. 68 | Nydusify(ctx context.Context, target string) (string, error) 69 | } 70 | 71 | // backend is the implementation of Backend. 72 | type backend struct { 73 | store storage.Storage 74 | } 75 | 76 | // New creates a new backend. 77 | func New(storageDir string) (Backend, error) { 78 | store, err := storage.New("", storageDir) 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return &backend{ 84 | store: store, 85 | }, nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/backend/build/config.go: -------------------------------------------------------------------------------- 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 build 18 | 19 | import ( 20 | "github.com/CloudNativeAI/modctl/pkg/backend/build/interceptor" 21 | ) 22 | 23 | type Option func(*config) 24 | 25 | // config is the configuration for the building. 26 | type config struct { 27 | plainHTTP bool 28 | insecure bool 29 | interceptor interceptor.Interceptor 30 | } 31 | 32 | func WithPlainHTTP(plainHTTP bool) Option { 33 | return func(c *config) { 34 | c.plainHTTP = plainHTTP 35 | } 36 | } 37 | 38 | func WithInsecure(insecure bool) Option { 39 | return func(c *config) { 40 | c.insecure = insecure 41 | } 42 | } 43 | 44 | func WithInterceptor(interceptor interceptor.Interceptor) Option { 45 | return func(c *config) { 46 | c.interceptor = interceptor 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkg/backend/build/config/model.go: -------------------------------------------------------------------------------- 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 config 18 | 19 | // Model is the configuration for building the Model. 20 | type Model struct { 21 | Architecture string 22 | Format string 23 | Precision string 24 | Quantization string 25 | ParamSize string 26 | Family string 27 | Name string 28 | SourceURL string 29 | SourceRevision string 30 | } 31 | -------------------------------------------------------------------------------- /pkg/backend/build/hooks/hooks.go: -------------------------------------------------------------------------------- 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 hooks 18 | 19 | import ( 20 | "io" 21 | 22 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 23 | ) 24 | 25 | // OnStartFunc defines the signature for the OnStart hook function. 26 | type OnStartFunc func(name string, size int64, reader io.Reader) io.Reader 27 | 28 | // OnErrorFunc defines the signature for the OnError hook function. 29 | type OnErrorFunc func(name string, err error) 30 | 31 | // OnCompleteFunc defines the signature for the OnComplete hook function. 32 | type OnCompleteFunc func(name string, desc ocispec.Descriptor) 33 | 34 | // Hooks is a struct that contains hook functions. 35 | type Hooks struct { 36 | // OnStart is called when the build process starts. 37 | OnStart OnStartFunc 38 | 39 | // OnError is called when the build process encounters an error. 40 | OnError OnErrorFunc 41 | 42 | // OnComplete is called when the build process completes successfully. 43 | OnComplete OnCompleteFunc 44 | } 45 | 46 | // NewHooks creates a new Hooks instance with optional function parameters. 47 | func NewHooks(opts ...Option) Hooks { 48 | h := Hooks{ 49 | OnStart: func(name string, size int64, reader io.Reader) io.Reader { 50 | return reader 51 | }, 52 | OnError: func(name string, err error) {}, 53 | OnComplete: func(name string, desc ocispec.Descriptor) {}, 54 | } 55 | 56 | for _, opt := range opts { 57 | opt(&h) 58 | } 59 | 60 | return h 61 | } 62 | 63 | // Option is a function type that can be used to customize a Hooks instance. 64 | type Option func(*Hooks) 65 | 66 | // WithOnStart returns an Option that sets the OnStart hook. 67 | func WithOnStart(f OnStartFunc) Option { 68 | return func(h *Hooks) { 69 | if f != nil { 70 | h.OnStart = f 71 | } 72 | } 73 | } 74 | 75 | // WithOnError returns an Option that sets the OnError hook. 76 | func WithOnError(f OnErrorFunc) Option { 77 | return func(h *Hooks) { 78 | if f != nil { 79 | h.OnError = f 80 | } 81 | } 82 | } 83 | 84 | // WithOnComplete returns an Option that sets the OnComplete hook. 85 | func WithOnComplete(f OnCompleteFunc) Option { 86 | return func(h *Hooks) { 87 | if f != nil { 88 | h.OnComplete = f 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/backend/build/interceptor/interceptor.go: -------------------------------------------------------------------------------- 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 interceptor 18 | 19 | import ( 20 | "context" 21 | "io" 22 | 23 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 24 | ) 25 | 26 | // ApplyDescriptorFn is a function that applies changes to the descriptor. 27 | type ApplyDescriptorFn func(desc *ocispec.Descriptor) 28 | 29 | // Interceptor is an interface that defines the interceptor for the building stream. 30 | type Interceptor interface { 31 | // Intercept intercepts the building stream for some customized logic, readerType is the original stream type, such as raw or tar. 32 | Intercept(ctx context.Context, mediaType string, filepath string, readerType string, reader io.Reader) (ApplyDescriptorFn, error) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/backend/build/interceptor/nydus_test.go: -------------------------------------------------------------------------------- 1 | package interceptor 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestCalcCrc32_NormalExecution_ReturnsCorrectCrc32(t *testing.T) { 12 | data := []byte("hello world") 13 | expectedCrc32 := []uint32{0xc99465aa} 14 | 15 | crc32Results, err := calcCrc32(context.Background(), bytes.NewReader(data), int64(len(data))) 16 | assert.NoError(t, err) 17 | assert.Equal(t, expectedCrc32, crc32Results) 18 | } 19 | 20 | func TestCalcCrc32_EmptyInput_ReturnsEmptySlice(t *testing.T) { 21 | crc32Results, err := calcCrc32(context.Background(), bytes.NewReader([]byte{}), 10) 22 | assert.NoError(t, err) 23 | assert.NotEmpty(t, crc32Results) 24 | assert.Equal(t, uint32(0x0), crc32Results[0]) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/backend/build/local.go: -------------------------------------------------------------------------------- 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 build 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "io" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/backend/build/hooks" 25 | "github.com/CloudNativeAI/modctl/pkg/storage" 26 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 27 | 28 | godigest "github.com/opencontainers/go-digest" 29 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 30 | ) 31 | 32 | func NewLocalOutput(cfg *config, store storage.Storage, repo, tag string) (OutputStrategy, error) { 33 | return &localOutput{ 34 | cfg: cfg, 35 | store: store, 36 | repo: repo, 37 | tag: tag, 38 | }, nil 39 | } 40 | 41 | type localOutput struct { 42 | cfg *config 43 | store storage.Storage 44 | repo string 45 | tag string 46 | } 47 | 48 | // OutputLayer outputs the layer blob to the local storage. 49 | func (lo *localOutput) OutputLayer(ctx context.Context, mediaType, relPath, digest string, size int64, reader io.Reader, hooks hooks.Hooks) (ocispec.Descriptor, error) { 50 | reader = hooks.OnStart(relPath, size, reader) 51 | digest, size, err := lo.store.PushBlob(ctx, lo.repo, reader, ocispec.Descriptor{}) 52 | if err != nil { 53 | hooks.OnError(relPath, err) 54 | return ocispec.Descriptor{}, fmt.Errorf("failed to push blob to storage: %w", err) 55 | } 56 | 57 | desc := ocispec.Descriptor{ 58 | MediaType: mediaType, 59 | Digest: godigest.Digest(digest), 60 | Size: size, 61 | Annotations: map[string]string{ 62 | modelspec.AnnotationFilepath: relPath, 63 | }, 64 | } 65 | 66 | hooks.OnComplete(relPath, desc) 67 | return desc, nil 68 | } 69 | 70 | // OutputConfig outputs the config blob to the storage. 71 | func (lo *localOutput) OutputConfig(ctx context.Context, mediaType, digest string, size int64, reader io.Reader, hooks hooks.Hooks) (ocispec.Descriptor, error) { 72 | reader = hooks.OnStart(digest, size, reader) 73 | digest, size, err := lo.store.PushBlob(ctx, lo.repo, reader, ocispec.Descriptor{}) 74 | if err != nil { 75 | hooks.OnError(digest, err) 76 | return ocispec.Descriptor{}, fmt.Errorf("failed to push config to storage: %w", err) 77 | } 78 | 79 | desc := ocispec.Descriptor{ 80 | MediaType: mediaType, 81 | Size: size, 82 | Digest: godigest.Digest(digest), 83 | } 84 | 85 | hooks.OnComplete(digest, desc) 86 | return desc, nil 87 | } 88 | 89 | // OutputManifest outputs the manifest blob to the local storage. 90 | func (lo *localOutput) OutputManifest(ctx context.Context, mediaType, digest string, size int64, reader io.Reader, hooks hooks.Hooks) (ocispec.Descriptor, error) { 91 | reader = hooks.OnStart(digest, size, reader) 92 | manifestJSON, err := io.ReadAll(reader) 93 | if err != nil { 94 | hooks.OnError(digest, err) 95 | return ocispec.Descriptor{}, fmt.Errorf("failed to read manifest JSON: %w", err) 96 | } 97 | 98 | digest, err = lo.store.PushManifest(ctx, lo.repo, lo.tag, manifestJSON) 99 | if err != nil { 100 | hooks.OnError(digest, err) 101 | return ocispec.Descriptor{}, fmt.Errorf("failed to push manifest to storage: %w", err) 102 | } 103 | 104 | desc := ocispec.Descriptor{ 105 | MediaType: mediaType, 106 | Digest: godigest.Digest(digest), 107 | Size: int64(len(manifestJSON)), 108 | } 109 | 110 | hooks.OnComplete(digest, desc) 111 | return desc, nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/backend/build_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "testing" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/config" 23 | "github.com/CloudNativeAI/modctl/test/mocks/modelfile" 24 | 25 | "github.com/stretchr/testify/assert" 26 | ) 27 | 28 | func TestGetProcessors(t *testing.T) { 29 | modelfile := &modelfile.Modelfile{} 30 | modelfile.On("GetConfigs").Return([]string{"config1", "config2"}) 31 | modelfile.On("GetModels").Return([]string{"model1", "model2"}) 32 | modelfile.On("GetCodes").Return([]string{"1.py", "2.py"}) 33 | modelfile.On("GetDocs").Return([]string{"doc1", "doc2"}) 34 | 35 | b := &backend{} 36 | processors := b.getProcessors(modelfile, &config.Build{}) 37 | 38 | assert.Len(t, processors, 4) 39 | assert.Equal(t, "config", processors[0].Name()) 40 | assert.Equal(t, "model", processors[1].Name()) 41 | assert.Equal(t, "code", processors[2].Name()) 42 | assert.Equal(t, "doc", processors[3].Name()) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/backend/extract.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "bufio" 21 | "context" 22 | "encoding/json" 23 | "fmt" 24 | "io" 25 | 26 | "github.com/CloudNativeAI/modctl/pkg/codec" 27 | "github.com/CloudNativeAI/modctl/pkg/config" 28 | "github.com/CloudNativeAI/modctl/pkg/storage" 29 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 30 | "golang.org/x/sync/errgroup" 31 | 32 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 33 | ) 34 | 35 | const ( 36 | // defaultBufferSize is the default buffer size for reading the blob, default is 4MB. 37 | defaultBufferSize = 4 * 1024 * 1024 38 | ) 39 | 40 | // Extract extracts the model artifact. 41 | func (b *backend) Extract(ctx context.Context, target string, cfg *config.Extract) 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 | repo, tag := ref.Repository(), ref.Tag() 49 | // pull the manifest from the storage. 50 | manifestRaw, _, err := b.store.PullManifest(ctx, repo, tag) 51 | if err != nil { 52 | return fmt.Errorf("failed to pull the manifest from storage: %w", err) 53 | } 54 | // unmarshal the manifest. 55 | var manifest ocispec.Manifest 56 | if err := json.Unmarshal(manifestRaw, &manifest); err != nil { 57 | return fmt.Errorf("failed to unmarshal the manifest: %w", err) 58 | } 59 | 60 | return exportModelArtifact(ctx, b.store, manifest, repo, cfg) 61 | } 62 | 63 | // exportModelArtifact exports the target model artifact to the output directory, which will open the artifact and extract to restore the original repo structure. 64 | func exportModelArtifact(ctx context.Context, store storage.Storage, manifest ocispec.Manifest, repo string, cfg *config.Extract) error { 65 | g := &errgroup.Group{} 66 | g.SetLimit(cfg.Concurrency) 67 | 68 | for _, layer := range manifest.Layers { 69 | g.Go(func() error { 70 | // pull the blob from the storage. 71 | reader, err := store.PullBlob(ctx, repo, layer.Digest.String()) 72 | if err != nil { 73 | return fmt.Errorf("failed to pull the blob from storage: %w", err) 74 | } 75 | defer reader.Close() 76 | 77 | bufferedReader := bufio.NewReaderSize(reader, defaultBufferSize) 78 | if err := extractLayer(layer, cfg.Output, bufferedReader); err != nil { 79 | return fmt.Errorf("failed to extract layer %s: %w", layer.Digest.String(), err) 80 | } 81 | 82 | return nil 83 | }) 84 | } 85 | 86 | return g.Wait() 87 | } 88 | 89 | // extractLayer extracts the layer to the output directory. 90 | func extractLayer(desc ocispec.Descriptor, outputDir string, reader io.Reader) error { 91 | var filepath string 92 | if desc.Annotations != nil && desc.Annotations[modelspec.AnnotationFilepath] != "" { 93 | filepath = desc.Annotations[modelspec.AnnotationFilepath] 94 | } 95 | 96 | codec, err := codec.New(codec.TypeFromMediaType(desc.MediaType)) 97 | if err != nil { 98 | return fmt.Errorf("failed to create codec for media type %s: %w", desc.MediaType, err) 99 | } 100 | 101 | if err := codec.Decode(outputDir, filepath, reader, desc); err != nil { 102 | return fmt.Errorf("failed to decode the layer %s to output directory: %w", desc.Digest.String(), err) 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /pkg/backend/fetch.go: -------------------------------------------------------------------------------- 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 | "encoding/json" 22 | "fmt" 23 | "path/filepath" 24 | 25 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 26 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 27 | "golang.org/x/sync/errgroup" 28 | 29 | internalpb "github.com/CloudNativeAI/modctl/internal/pb" 30 | "github.com/CloudNativeAI/modctl/pkg/backend/remote" 31 | "github.com/CloudNativeAI/modctl/pkg/config" 32 | ) 33 | 34 | // Fetch fetches partial files to the output. 35 | func (b *backend) Fetch(ctx context.Context, target string, cfg *config.Fetch) error { 36 | // parse the repository and tag from the target. 37 | ref, err := ParseReference(target) 38 | if err != nil { 39 | return fmt.Errorf("failed to parse the target: %w", err) 40 | } 41 | 42 | repo, tag := ref.Repository(), ref.Tag() 43 | client, err := remote.New(repo, remote.WithPlainHTTP(cfg.PlainHTTP), remote.WithInsecure(cfg.Insecure)) 44 | if err != nil { 45 | return fmt.Errorf("failed to create remote client: %w", err) 46 | } 47 | 48 | _, manifestReader, err := client.Manifests().FetchReference(ctx, tag) 49 | if err != nil { 50 | return fmt.Errorf("failed to fetch the manifest: %w", err) 51 | } 52 | 53 | defer manifestReader.Close() 54 | 55 | var manifest ocispec.Manifest 56 | if err := json.NewDecoder(manifestReader).Decode(&manifest); err != nil { 57 | return fmt.Errorf("failed to decode the manifest: %w", err) 58 | } 59 | 60 | layers := []ocispec.Descriptor{} 61 | // filter the layers by patterns. 62 | for _, layer := range manifest.Layers { 63 | for _, pattern := range cfg.Patterns { 64 | if anno := layer.Annotations; anno != nil { 65 | matched, err := filepath.Match(pattern, anno[modelspec.AnnotationFilepath]) 66 | if err != nil { 67 | return fmt.Errorf("failed to match pattern: %w", err) 68 | } 69 | 70 | if matched { 71 | layers = append(layers, layer) 72 | } 73 | } 74 | } 75 | } 76 | 77 | if len(layers) == 0 { 78 | return fmt.Errorf("no layers matched the patterns") 79 | } 80 | 81 | pb := internalpb.NewProgressBar() 82 | pb.Start() 83 | defer pb.Stop() 84 | 85 | g := &errgroup.Group{} 86 | g.SetLimit(cfg.Concurrency) 87 | 88 | for _, layer := range layers { 89 | g.Go(func() error { 90 | return pullAndExtractFromRemote(ctx, pb, internalpb.NormalizePrompt("Fetching blob"), client, cfg.Output, layer) 91 | }) 92 | } 93 | 94 | return g.Wait() 95 | } 96 | -------------------------------------------------------------------------------- /pkg/backend/inspect.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "encoding/json" 22 | "fmt" 23 | "time" 24 | 25 | godigest "github.com/opencontainers/go-digest" 26 | 27 | "github.com/CloudNativeAI/modctl/pkg/config" 28 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 29 | ) 30 | 31 | // InspectedModelArtifact is the data structure for model artifact that has been inspected. 32 | type InspectedModelArtifact struct { 33 | // ID is the image id of the model artifact. 34 | ID string `json:"Id"` 35 | // Digest is the digest of the model artifact. 36 | Digest string `json:"Digest"` 37 | // Architecture is the architecture of the model. 38 | Architecture string `json:"Architecture"` 39 | // CreatedAt is the creation time of the model artifact. 40 | CreatedAt string `json:"CreatedAt"` 41 | // Family is the family of the model. 42 | Family string `json:"Family"` 43 | // Format is the format of the model. 44 | Format string `json:"Format"` 45 | // Name is the name of the model. 46 | Name string `json:"Name"` 47 | // ParamSize is the param size of the model. 48 | ParamSize string `json:"ParamSize"` 49 | // Precision is the precision of the model. 50 | Precision string `json:"Precision"` 51 | // Quantization is the quantization of the model. 52 | Quantization string `json:"Quantization"` 53 | // Layers is the layers of the model artifact. 54 | Layers []InspectedModelArtifactLayer `json:"Layers"` 55 | } 56 | 57 | // InspectedModelArtifactLayer is the data structure for model artifact layer that has been inspected. 58 | type InspectedModelArtifactLayer struct { 59 | // Digest is the digest of the model artifact layer. 60 | Digest string `json:"Digest"` 61 | // Size is the size of the model artifact layer. 62 | Size int64 `json:"Size"` 63 | // Filepath is the filepath of the model artifact layer. 64 | Filepath string `json:"Filepath"` 65 | } 66 | 67 | // Inspect inspects the target from the storage. 68 | func (b *backend) Inspect(ctx context.Context, target string, cfg *config.Inspect) (*InspectedModelArtifact, error) { 69 | _, err := ParseReference(target) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to parse target: %w", err) 72 | } 73 | 74 | manifest, err := b.getManifest(ctx, target, cfg.Remote, cfg.PlainHTTP, cfg.Insecure) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to get manifest: %w", err) 77 | } 78 | 79 | manifestRaw, err := json.Marshal(manifest) 80 | if err != nil { 81 | return nil, fmt.Errorf("failed to marshal manifest: %w", err) 82 | } 83 | 84 | config, err := b.getModelConfig(ctx, target, manifest.Config, cfg.Remote, cfg.PlainHTTP, cfg.Insecure) 85 | if err != nil { 86 | return nil, fmt.Errorf("failed to get config: %w", err) 87 | } 88 | 89 | inspectedModelArtifact := &InspectedModelArtifact{ 90 | ID: manifest.Config.Digest.String(), 91 | Digest: godigest.FromBytes(manifestRaw).String(), 92 | Architecture: config.Config.Architecture, 93 | Family: config.Descriptor.Family, 94 | Format: config.Config.Format, 95 | Name: config.Descriptor.Name, 96 | ParamSize: config.Config.ParamSize, 97 | Precision: config.Config.Precision, 98 | Quantization: config.Config.Quantization, 99 | } 100 | 101 | if config.Descriptor.CreatedAt != nil { 102 | inspectedModelArtifact.CreatedAt = config.Descriptor.CreatedAt.Format(time.RFC3339) 103 | } 104 | 105 | for _, layer := range manifest.Layers { 106 | inspectedModelArtifact.Layers = append(inspectedModelArtifact.Layers, InspectedModelArtifactLayer{ 107 | Digest: layer.Digest.String(), 108 | Size: layer.Size, 109 | Filepath: layer.Annotations[modelspec.AnnotationFilepath], 110 | }) 111 | } 112 | 113 | return inspectedModelArtifact, nil 114 | } 115 | -------------------------------------------------------------------------------- /pkg/backend/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "encoding/json" 22 | "fmt" 23 | "sort" 24 | "time" 25 | 26 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 27 | 28 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 29 | ) 30 | 31 | // ModelArtifact is the data model to represent the model artifact. 32 | type ModelArtifact struct { 33 | // Repository is the repository of the model artifact. 34 | Repository string 35 | // Tag is the tag of the model artifact. 36 | Tag string 37 | // Digest is the digest of the model artifact. 38 | Digest string 39 | // Size is the size of the model artifact. 40 | Size int64 41 | // CreatedAt is the creation time of the model artifact. 42 | CreatedAt time.Time 43 | } 44 | 45 | // List lists all the model artifacts. 46 | func (b *backend) List(ctx context.Context) ([]*ModelArtifact, error) { 47 | modelArtifacts := []*ModelArtifact{} 48 | 49 | // list all the repositories. 50 | repos, err := b.store.ListRepositories(ctx) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to list repositories: %w", err) 53 | } 54 | 55 | // list all the tags in the repository. 56 | for _, repo := range repos { 57 | tags, err := b.store.ListTags(ctx, repo) 58 | if err != nil { 59 | return nil, fmt.Errorf("failed to list tags in repository %s: %w", repo, err) 60 | } 61 | 62 | // assemble the model artifact. 63 | for _, tag := range tags { 64 | modelArtifact, err := b.assembleModelArtifact(ctx, repo, tag) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to assemble model artifact: %w", err) 67 | } 68 | 69 | modelArtifacts = append(modelArtifacts, modelArtifact) 70 | } 71 | } 72 | 73 | sort.Slice(modelArtifacts, func(i, j int) bool { 74 | return modelArtifacts[i].CreatedAt.After(modelArtifacts[j].CreatedAt) 75 | }) 76 | 77 | return modelArtifacts, nil 78 | } 79 | 80 | // assembleModelArtifact assembles the model artifact from the original storage. 81 | func (b *backend) assembleModelArtifact(ctx context.Context, repo, tag string) (*ModelArtifact, error) { 82 | manifestRaw, digest, err := b.store.PullManifest(ctx, repo, tag) 83 | if err != nil { 84 | return nil, fmt.Errorf("failed to pull manifest: %w", err) 85 | } 86 | 87 | // parse the manifest. 88 | var manifest ocispec.Manifest 89 | if err := json.Unmarshal(manifestRaw, &manifest); err != nil { 90 | return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) 91 | } 92 | 93 | // calculate the size of the model artifact. 94 | size := int64(len(manifestRaw)) + manifest.Config.Size 95 | for _, layer := range manifest.Layers { 96 | size += layer.Size 97 | } 98 | 99 | // fetch and parse the model config. 100 | configReader, err := b.store.PullBlob(ctx, repo, manifest.Config.Digest.String()) 101 | if err != nil { 102 | return nil, fmt.Errorf("failed to pull config: %w", err) 103 | } 104 | 105 | defer configReader.Close() 106 | var config modelspec.Model 107 | if err := json.NewDecoder(configReader).Decode(&config); err != nil { 108 | return nil, fmt.Errorf("failed to decode config: %w", err) 109 | } 110 | 111 | modelArtifact := &ModelArtifact{ 112 | Repository: repo, 113 | Tag: tag, 114 | Digest: digest, 115 | Size: size, 116 | } 117 | 118 | if config.Descriptor.CreatedAt != nil { 119 | modelArtifact.CreatedAt = *config.Descriptor.CreatedAt 120 | } 121 | 122 | return modelArtifact, nil 123 | } 124 | -------------------------------------------------------------------------------- /pkg/backend/list_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "bytes" 21 | "context" 22 | "encoding/json" 23 | "io" 24 | "testing" 25 | 26 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 27 | "github.com/stretchr/testify/assert" 28 | "github.com/stretchr/testify/mock" 29 | 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | ) 32 | 33 | func TestList(t *testing.T) { 34 | mockStore := &storage.Storage{} 35 | b := &backend{store: mockStore} 36 | ctx := context.Background() 37 | repos := []string{"example.com/repo1", "example.com/repo2"} 38 | tags := []string{"tag1", "tag2"} 39 | manifest := ocispec.Manifest{ 40 | Layers: []ocispec.Descriptor{ 41 | {Size: 1024}, 42 | {Size: 1024}, 43 | }, 44 | Config: ocispec.Descriptor{Size: 1024}, 45 | } 46 | manifestRaw, err := json.Marshal(manifest) 47 | assert.NoError(t, err) 48 | 49 | config := `{ 50 | "descriptor": { 51 | "createdAt": "2025-02-12T17:01:43.968027+08:00", 52 | "family": "qwen2", 53 | "name": "Qwen2.5-0.5B" 54 | }, 55 | "modelfs": { 56 | "type": "layers", 57 | "diff_ids": null 58 | }, 59 | "config": { 60 | "architecture": "transformer", 61 | "format": "tensorflow", 62 | "parameterSize": 50000000000, 63 | "precision": "int8", 64 | "puantization": "gptq" 65 | } 66 | }` 67 | 68 | mockStore.On("ListRepositories", ctx).Return(repos, nil) 69 | mockStore.On("ListTags", ctx, repos[0]).Return(tags, nil) 70 | mockStore.On("ListTags", ctx, repos[1]).Return(tags, nil) 71 | mockStore.On("PullManifest", ctx, mock.Anything, mock.Anything).Return(manifestRaw, "sha256:1234567890abcdef", nil) 72 | mockStore.On("PullBlob", ctx, mock.Anything, mock.Anything).Return( 73 | func(ctx context.Context, repo string, digest string) (io.ReadCloser, error) { 74 | return io.NopCloser(bytes.NewReader([]byte(config))), nil 75 | }, 76 | nil, 77 | ) 78 | 79 | artifacts, err := b.List(ctx) 80 | assert.NoError(t, err, "list failed") 81 | assert.Len(t, artifacts, 4, "unexpected number of artifacts") 82 | assert.Equal(t, repos[0], artifacts[0].Repository, "unexpected repository") 83 | assert.Equal(t, tags[0], artifacts[0].Tag, "unexpected tag") 84 | assert.Equal(t, "sha256:1234567890abcdef", artifacts[0].Digest, "unexpected digest") 85 | assert.Equal(t, int64(3*1024+len(manifestRaw)), artifacts[0].Size, "unexpected size") 86 | assert.Equal(t, "2025-02-12T17:01:43.968027+08:00", artifacts[0].CreatedAt.Format("2006-01-02T15:04:05.000000-07:00"), "unexpected created at") 87 | } 88 | -------------------------------------------------------------------------------- /pkg/backend/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "net/http" 23 | 24 | "oras.land/oras-go/v2/registry/remote" 25 | "oras.land/oras-go/v2/registry/remote/auth" 26 | "oras.land/oras-go/v2/registry/remote/credentials" 27 | "oras.land/oras-go/v2/registry/remote/retry" 28 | 29 | "github.com/CloudNativeAI/modctl/pkg/config" 30 | ) 31 | 32 | // Login logs into a registry. 33 | func (b *backend) Login(ctx context.Context, registry, username, password string, cfg *config.Login) error { 34 | // read credentials from docker store. 35 | store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{AllowPlaintextPut: true}) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | reg, err := remote.NewRegistry(registry) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | httpClient := &http.Client{ 46 | Transport: retry.NewTransport(&http.Transport{ 47 | TLSClientConfig: &tls.Config{ 48 | InsecureSkipVerify: cfg.Insecure, 49 | }, 50 | }), 51 | } 52 | reg.Client = &auth.Client{ 53 | Cache: auth.NewCache(), 54 | Credential: credentials.Credential(store), 55 | Client: httpClient, 56 | } 57 | 58 | if cfg.PlainHTTP { 59 | reg.PlainHTTP = true 60 | } 61 | 62 | cred := auth.Credential{ 63 | Username: username, 64 | Password: password, 65 | } 66 | 67 | return credentials.Login(ctx, store, reg, cred) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/backend/logout.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | 22 | "oras.land/oras-go/v2/registry/remote/credentials" 23 | ) 24 | 25 | // Logout logs out of a registry. 26 | func (b *backend) Logout(ctx context.Context, registry string) error { 27 | // read credentials from docker store. 28 | store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{AllowPlaintextPut: true}) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | // remove credentials from store. 34 | if err := credentials.Logout(ctx, store, registry); err != nil { 35 | return err 36 | } 37 | 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /pkg/backend/nydusify.go: -------------------------------------------------------------------------------- 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 | "os" 22 | "os/exec" 23 | ) 24 | 25 | const ( 26 | // nydusImageTagSuffix is the suffix for the nydus image tag. 27 | nydusImageTagSuffix = "_nydus_v2" 28 | ) 29 | 30 | // Nydusify is a function that converts a given model artifact to a nydus image. 31 | func (b *backend) Nydusify(ctx context.Context, source string) (string, error) { 32 | target := source + nydusImageTagSuffix 33 | cmd := exec.CommandContext( 34 | ctx, 35 | "nydusify", 36 | "convert", 37 | "--source-backend-type", 38 | "model-artifact", 39 | "--compressor", 40 | "lz4_block", 41 | "--fs-version", 42 | "5", 43 | "--source", 44 | source, 45 | "--target", 46 | target, 47 | ) 48 | cmd.Stdout = os.Stdout 49 | cmd.Stderr = os.Stderr 50 | if err := cmd.Run(); err != nil { 51 | return "", err 52 | } 53 | 54 | return target, nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/backend/processor/code.go: -------------------------------------------------------------------------------- 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 processor 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/backend/build" 23 | "github.com/CloudNativeAI/modctl/pkg/storage" 24 | 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | ) 27 | 28 | const ( 29 | codeProcessorName = "code" 30 | ) 31 | 32 | // NewCodeProcessor creates a new code processor. 33 | func NewCodeProcessor(store storage.Storage, mediaType string, patterns []string) Processor { 34 | return &codeProcessor{ 35 | base: &base{ 36 | name: codeProcessorName, 37 | store: store, 38 | mediaType: mediaType, 39 | patterns: patterns, 40 | }, 41 | } 42 | } 43 | 44 | // codeProcessor is the processor to process the code file. 45 | type codeProcessor struct { 46 | base *base 47 | } 48 | 49 | func (p *codeProcessor) Name() string { 50 | return codeProcessorName 51 | } 52 | 53 | func (p *codeProcessor) Process(ctx context.Context, builder build.Builder, workDir string, opts ...ProcessOption) ([]ocispec.Descriptor, error) { 54 | return p.base.Process(ctx, builder, workDir, opts...) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/backend/processor/code_test.go: -------------------------------------------------------------------------------- 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 processor 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | buildmock "github.com/CloudNativeAI/modctl/test/mocks/backend/build" 26 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 27 | 28 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 29 | godigest "github.com/opencontainers/go-digest" 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | "github.com/stretchr/testify/assert" 32 | "github.com/stretchr/testify/mock" 33 | "github.com/stretchr/testify/suite" 34 | ) 35 | 36 | type codeProcessorSuite struct { 37 | suite.Suite 38 | mockStore *storage.Storage 39 | mockBuilder *buildmock.Builder 40 | processor Processor 41 | workDir string 42 | } 43 | 44 | func (s *codeProcessorSuite) SetupTest() { 45 | s.mockStore = &storage.Storage{} 46 | s.mockBuilder = &buildmock.Builder{} 47 | s.processor = NewCodeProcessor(s.mockStore, modelspec.MediaTypeModelCode, []string{"*.py"}) 48 | // generate test files for prorcess. 49 | s.workDir = s.Suite.T().TempDir() 50 | if err := os.WriteFile(filepath.Join(s.workDir, "test.py"), []byte(""), 0644); err != nil { 51 | s.Suite.T().Fatal(err) 52 | } 53 | } 54 | 55 | func (s *codeProcessorSuite) TestName() { 56 | assert.Equal(s.Suite.T(), "code", s.processor.Name()) 57 | } 58 | 59 | func (s *codeProcessorSuite) TestProcess() { 60 | ctx := context.Background() 61 | s.mockBuilder.On("BuildLayer", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ocispec.Descriptor{ 62 | Digest: godigest.Digest("sha256:1234567890abcdef"), 63 | Size: int64(1024), 64 | Annotations: map[string]string{ 65 | modelspec.AnnotationFilepath: "test.py", 66 | }, 67 | }, nil) 68 | 69 | desc, err := s.processor.Process(ctx, s.mockBuilder, s.workDir) 70 | assert.NoError(s.Suite.T(), err) 71 | assert.NotNil(s.Suite.T(), desc) 72 | assert.Equal(s.Suite.T(), "sha256:1234567890abcdef", desc[0].Digest.String()) 73 | assert.Equal(s.Suite.T(), int64(1024), desc[0].Size) 74 | assert.Equal(s.Suite.T(), "test.py", desc[0].Annotations[modelspec.AnnotationFilepath]) 75 | } 76 | 77 | func TestCodeProcessorSuite(t *testing.T) { 78 | suite.Run(t, new(codeProcessorSuite)) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/backend/processor/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/backend/build" 23 | "github.com/CloudNativeAI/modctl/pkg/storage" 24 | 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | ) 27 | 28 | const ( 29 | docProcessorName = "doc" 30 | ) 31 | 32 | // NewDocProcessor creates a new doc processor. 33 | func NewDocProcessor(store storage.Storage, mediaType string, patterns []string) Processor { 34 | return &docProcessor{ 35 | base: &base{ 36 | name: docProcessorName, 37 | store: store, 38 | mediaType: mediaType, 39 | patterns: patterns, 40 | }, 41 | } 42 | } 43 | 44 | // docProcessor is the processor to process the doc file. 45 | type docProcessor struct { 46 | base *base 47 | } 48 | 49 | func (p *docProcessor) Name() string { 50 | return docProcessorName 51 | } 52 | 53 | func (p *docProcessor) Process(ctx context.Context, builder build.Builder, workDir string, opts ...ProcessOption) ([]ocispec.Descriptor, error) { 54 | return p.base.Process(ctx, builder, workDir, opts...) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/backend/processor/doc_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | buildmock "github.com/CloudNativeAI/modctl/test/mocks/backend/build" 26 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 27 | 28 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 29 | godigest "github.com/opencontainers/go-digest" 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | "github.com/stretchr/testify/assert" 32 | "github.com/stretchr/testify/mock" 33 | "github.com/stretchr/testify/suite" 34 | ) 35 | 36 | type docProcessorSuite struct { 37 | suite.Suite 38 | mockStore *storage.Storage 39 | mockBuilder *buildmock.Builder 40 | processor Processor 41 | workDir string 42 | } 43 | 44 | func (s *docProcessorSuite) SetupTest() { 45 | s.mockStore = &storage.Storage{} 46 | s.mockBuilder = &buildmock.Builder{} 47 | s.processor = NewDocProcessor(s.mockStore, modelspec.MediaTypeModelDoc, []string{"LICENSE"}) 48 | // generate test files for prorcess. 49 | s.workDir = s.Suite.T().TempDir() 50 | if err := os.WriteFile(filepath.Join(s.workDir, "LICENSE"), []byte(""), 0644); err != nil { 51 | s.Suite.T().Fatal(err) 52 | } 53 | } 54 | 55 | func (s *docProcessorSuite) TestName() { 56 | assert.Equal(s.Suite.T(), "doc", s.processor.Name()) 57 | } 58 | 59 | func (s *docProcessorSuite) TestProcess() { 60 | ctx := context.Background() 61 | s.mockBuilder.On("BuildLayer", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ocispec.Descriptor{ 62 | Digest: godigest.Digest("sha256:1234567890abcdef"), 63 | Size: int64(1024), 64 | Annotations: map[string]string{ 65 | modelspec.AnnotationFilepath: "LICENSE", 66 | }, 67 | }, nil) 68 | 69 | desc, err := s.processor.Process(ctx, s.mockBuilder, s.workDir) 70 | assert.NoError(s.Suite.T(), err) 71 | assert.NotNil(s.Suite.T(), desc) 72 | assert.Equal(s.Suite.T(), "sha256:1234567890abcdef", desc[0].Digest.String()) 73 | assert.Equal(s.Suite.T(), int64(1024), desc[0].Size) 74 | assert.Equal(s.Suite.T(), "LICENSE", desc[0].Annotations[modelspec.AnnotationFilepath]) 75 | } 76 | 77 | func TestDocProcessorSuite(t *testing.T) { 78 | suite.Run(t, new(docProcessorSuite)) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/backend/processor/model.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/backend/build" 23 | "github.com/CloudNativeAI/modctl/pkg/storage" 24 | 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | ) 27 | 28 | const ( 29 | modelProcessorName = "model" 30 | ) 31 | 32 | // NewModelProcessor creates a new model processor. 33 | func NewModelProcessor(store storage.Storage, mediaType string, patterns []string) Processor { 34 | return &modelProcessor{ 35 | base: &base{ 36 | name: modelProcessorName, 37 | store: store, 38 | mediaType: mediaType, 39 | patterns: patterns, 40 | }, 41 | } 42 | } 43 | 44 | // modelProcessor is the processor to process the model file. 45 | type modelProcessor struct { 46 | base *base 47 | } 48 | 49 | func (p *modelProcessor) Name() string { 50 | return modelProcessorName 51 | } 52 | 53 | func (p *modelProcessor) Process(ctx context.Context, builder build.Builder, workDir string, opts ...ProcessOption) ([]ocispec.Descriptor, error) { 54 | return p.base.Process(ctx, builder, workDir, opts...) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/backend/processor/model_config.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/backend/build" 23 | "github.com/CloudNativeAI/modctl/pkg/storage" 24 | 25 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 26 | ) 27 | 28 | const ( 29 | modelConfigProcessorName = "config" 30 | ) 31 | 32 | // NewModelConfigProcessor creates a new model config processor. 33 | func NewModelConfigProcessor(store storage.Storage, mediaType string, patterns []string) Processor { 34 | return &modelConfigProcessor{ 35 | base: &base{ 36 | name: modelConfigProcessorName, 37 | store: store, 38 | mediaType: mediaType, 39 | patterns: patterns, 40 | }, 41 | } 42 | } 43 | 44 | // modelConfigProcessor is the processor to process the model config file. 45 | type modelConfigProcessor struct { 46 | base *base 47 | } 48 | 49 | func (p *modelConfigProcessor) Name() string { 50 | return modelConfigProcessorName 51 | } 52 | 53 | func (p *modelConfigProcessor) Process(ctx context.Context, builder build.Builder, workDir string, opts ...ProcessOption) ([]ocispec.Descriptor, error) { 54 | return p.base.Process(ctx, builder, workDir, opts...) 55 | } 56 | -------------------------------------------------------------------------------- /pkg/backend/processor/model_config_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | buildmock "github.com/CloudNativeAI/modctl/test/mocks/backend/build" 26 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 27 | 28 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 29 | godigest "github.com/opencontainers/go-digest" 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | "github.com/stretchr/testify/assert" 32 | "github.com/stretchr/testify/mock" 33 | "github.com/stretchr/testify/suite" 34 | ) 35 | 36 | type modelConfigProcessorSuite struct { 37 | suite.Suite 38 | mockStore *storage.Storage 39 | mockBuilder *buildmock.Builder 40 | processor Processor 41 | workDir string 42 | } 43 | 44 | func (s *modelConfigProcessorSuite) SetupTest() { 45 | s.mockStore = &storage.Storage{} 46 | s.mockBuilder = &buildmock.Builder{} 47 | s.processor = NewModelConfigProcessor(s.mockStore, modelspec.MediaTypeModelWeightConfig, []string{"config"}) 48 | // generate test files for prorcess. 49 | s.workDir = s.Suite.T().TempDir() 50 | if err := os.WriteFile(filepath.Join(s.workDir, "config"), []byte(""), 0644); err != nil { 51 | s.Suite.T().Fatal(err) 52 | } 53 | } 54 | 55 | func (s *modelConfigProcessorSuite) TestName() { 56 | assert.Equal(s.Suite.T(), "config", s.processor.Name()) 57 | } 58 | 59 | func (s *modelConfigProcessorSuite) TestProcess() { 60 | ctx := context.Background() 61 | s.mockBuilder.On("BuildLayer", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ocispec.Descriptor{ 62 | Digest: godigest.Digest("sha256:1234567890abcdef"), 63 | Size: int64(1024), 64 | Annotations: map[string]string{ 65 | modelspec.AnnotationFilepath: "config", 66 | }, 67 | }, nil) 68 | 69 | desc, err := s.processor.Process(ctx, s.mockBuilder, s.workDir) 70 | assert.NoError(s.Suite.T(), err) 71 | assert.NotNil(s.Suite.T(), desc) 72 | assert.Equal(s.Suite.T(), "sha256:1234567890abcdef", desc[0].Digest.String()) 73 | assert.Equal(s.Suite.T(), int64(1024), desc[0].Size) 74 | assert.Equal(s.Suite.T(), "config", desc[0].Annotations[modelspec.AnnotationFilepath]) 75 | } 76 | 77 | func TestModelConfigProcessorSuite(t *testing.T) { 78 | suite.Run(t, new(modelConfigProcessorSuite)) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/backend/processor/model_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | "os" 22 | "path/filepath" 23 | "testing" 24 | 25 | buildmock "github.com/CloudNativeAI/modctl/test/mocks/backend/build" 26 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 27 | 28 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 29 | godigest "github.com/opencontainers/go-digest" 30 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 31 | "github.com/stretchr/testify/assert" 32 | "github.com/stretchr/testify/mock" 33 | "github.com/stretchr/testify/suite" 34 | ) 35 | 36 | type modelProcessorSuite struct { 37 | suite.Suite 38 | mockStore *storage.Storage 39 | mockBuilder *buildmock.Builder 40 | processor Processor 41 | workDir string 42 | } 43 | 44 | func (s *modelProcessorSuite) SetupTest() { 45 | s.mockStore = &storage.Storage{} 46 | s.mockBuilder = &buildmock.Builder{} 47 | s.processor = NewModelProcessor(s.mockStore, modelspec.MediaTypeModelWeight, []string{"model"}) 48 | // generate test files for prorcess. 49 | s.workDir = s.Suite.T().TempDir() 50 | if err := os.WriteFile(filepath.Join(s.workDir, "model"), []byte(""), 0644); err != nil { 51 | s.Suite.T().Fatal(err) 52 | } 53 | } 54 | 55 | func (s *modelProcessorSuite) TestName() { 56 | assert.Equal(s.Suite.T(), "model", s.processor.Name()) 57 | } 58 | 59 | func (s *modelProcessorSuite) TestProcess() { 60 | ctx := context.Background() 61 | s.mockBuilder.On("BuildLayer", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ocispec.Descriptor{ 62 | Digest: godigest.Digest("sha256:1234567890abcdef"), 63 | Size: int64(1024), 64 | Annotations: map[string]string{ 65 | modelspec.AnnotationFilepath: "model", 66 | }, 67 | }, nil) 68 | 69 | desc, err := s.processor.Process(ctx, s.mockBuilder, s.workDir) 70 | assert.NoError(s.Suite.T(), err) 71 | assert.NotNil(s.Suite.T(), desc) 72 | assert.Equal(s.Suite.T(), "sha256:1234567890abcdef", desc[0].Digest.String()) 73 | assert.Equal(s.Suite.T(), int64(1024), desc[0].Size) 74 | assert.Equal(s.Suite.T(), "model", desc[0].Annotations[modelspec.AnnotationFilepath]) 75 | } 76 | 77 | func TestModelProcessorSuite(t *testing.T) { 78 | suite.Run(t, new(modelProcessorSuite)) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/backend/processor/options.go: -------------------------------------------------------------------------------- 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 processor 18 | 19 | import ( 20 | "time" 21 | 22 | retry "github.com/avast/retry-go/v4" 23 | 24 | "github.com/CloudNativeAI/modctl/internal/pb" 25 | ) 26 | 27 | type ProcessOption func(*processOptions) 28 | 29 | type processOptions struct { 30 | // concurrency is the number of concurrent workers to use for processing. 31 | concurrency int 32 | // progressTracker is the progress bar to use for tracking progress. 33 | progressTracker *pb.ProgressBar 34 | } 35 | 36 | func WithConcurrency(concurrency int) ProcessOption { 37 | return func(o *processOptions) { 38 | o.concurrency = concurrency 39 | } 40 | } 41 | 42 | func WithProgressTracker(tracker *pb.ProgressBar) ProcessOption { 43 | return func(o *processOptions) { 44 | o.progressTracker = tracker 45 | } 46 | } 47 | 48 | var defaultRetryOpts = []retry.Option{ 49 | retry.Attempts(3), 50 | retry.DelayType(retry.BackOffDelay), 51 | retry.Delay(5 * time.Second), 52 | retry.MaxDelay(10 * time.Second), 53 | } 54 | -------------------------------------------------------------------------------- /pkg/backend/processor/processor.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 processor 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/backend/build" 23 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 24 | ) 25 | 26 | // Processor is the interface to recognize and process the identified file. 27 | type Processor interface { 28 | // Name returns the name of the processor. 29 | Name() string 30 | // Process processes the file. 31 | Process(ctx context.Context, builder build.Builder, workDir string, opts ...ProcessOption) ([]ocispec.Descriptor, error) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/backend/prune.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "fmt" 22 | ) 23 | 24 | // Prune prunes the unused blobs and clean up the storage. 25 | func (b *backend) Prune(ctx context.Context, dryRun, removeUntagged bool) error { 26 | if err := b.store.PerformGC(ctx, dryRun, removeUntagged); err != nil { 27 | return fmt.Errorf("faile to perform gc: %w", err) 28 | } 29 | 30 | if err := b.store.PerformPurgeUploads(ctx, dryRun); err != nil { 31 | return fmt.Errorf("failed to perform purge uploads: %w", err) 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/backend/pull_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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_test 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "testing" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/config" 25 | mocks "github.com/CloudNativeAI/modctl/test/mocks/backend" 26 | 27 | "github.com/stretchr/testify/assert" 28 | ) 29 | 30 | func TestPull(t *testing.T) { 31 | ctx := context.Background() 32 | target1 := "example.com/test-repo:should_error" 33 | target2 := "example.com/test-repo:should_not_error" 34 | cfg := &config.Pull{} 35 | 36 | b := &mocks.Backend{} 37 | b.On("Pull", ctx, target1, cfg).Return(errors.New("mock error")) 38 | err := b.Pull(ctx, target1, cfg) 39 | assert.Error(t, err, "Push should return an error") 40 | 41 | b.On("Pull", ctx, target2, cfg).Return(nil) 42 | err = b.Pull(ctx, target2, cfg) 43 | assert.NoError(t, err, "Push should not return an error") 44 | } 45 | -------------------------------------------------------------------------------- /pkg/backend/push_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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_test 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "testing" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/config" 25 | mocks "github.com/CloudNativeAI/modctl/test/mocks/backend" 26 | 27 | "github.com/stretchr/testify/assert" 28 | ) 29 | 30 | func TestPush(t *testing.T) { 31 | ctx := context.Background() 32 | target1 := "example.com/test-repo:should_error" 33 | target2 := "example.com/test-repo:should_not_error" 34 | cfg := &config.Push{} 35 | 36 | b := &mocks.Backend{} 37 | b.On("Push", ctx, target1, cfg).Return(errors.New("mock error")) 38 | err := b.Push(ctx, target1, cfg) 39 | assert.Error(t, err, "Push should return an error") 40 | 41 | b.On("Push", ctx, target2, cfg).Return(nil) 42 | err = b.Push(ctx, target2, cfg) 43 | assert.NoError(t, err, "Push should not return an error") 44 | } 45 | -------------------------------------------------------------------------------- /pkg/backend/referencer.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 "github.com/distribution/reference" 20 | 21 | // Referencer is the interface for the reference. 22 | type Referencer interface { 23 | // Repository returns the repository of the reference. 24 | Repository() string 25 | // Tag returns the tag of the reference. 26 | Tag() string 27 | // Digest returns the digest of the reference. 28 | Digest() string 29 | } 30 | 31 | type referencer struct { 32 | named reference.Named 33 | } 34 | 35 | // ParseReference parses the reference. 36 | func ParseReference(ref string) (Referencer, error) { 37 | named, err := reference.ParseNamed(ref) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return &referencer{named: named}, nil 43 | } 44 | 45 | // Repository returns the repository of the reference. 46 | func (r *referencer) Repository() string { 47 | return reference.TrimNamed(r.named).String() 48 | } 49 | 50 | // Tag returns the tag of the reference. 51 | func (r *referencer) Tag() string { 52 | if tagged, ok := r.named.(reference.Tagged); ok { 53 | return tagged.Tag() 54 | } 55 | 56 | return "" 57 | } 58 | 59 | // Digest returns the digest of the reference. 60 | func (r *referencer) Digest() string { 61 | if digested, ok := r.named.(reference.Digested); ok { 62 | return digested.Digest().String() 63 | } 64 | 65 | return "" 66 | } 67 | -------------------------------------------------------------------------------- /pkg/backend/referencer_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestParseReference(t *testing.T) { 26 | tests := []struct { 27 | input string 28 | expected string 29 | hasError bool 30 | }{ 31 | {"example.com/repo:tag", "example.com/repo", false}, 32 | {"example.com/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", "example.com/repo", false}, 33 | {"invalid reference", "", true}, 34 | } 35 | 36 | for _, test := range tests { 37 | ref, err := ParseReference(test.input) 38 | if test.hasError { 39 | assert.Error(t, err) 40 | } else { 41 | assert.NoError(t, err) 42 | assert.Equal(t, test.expected, ref.Repository()) 43 | } 44 | } 45 | } 46 | 47 | func TestReferencer_Repository(t *testing.T) { 48 | ref, err := ParseReference("example.com/repo:tag") 49 | assert.NoError(t, err) 50 | assert.Equal(t, "example.com/repo", ref.Repository()) 51 | } 52 | 53 | func TestReferencer_Tag(t *testing.T) { 54 | ref, err := ParseReference("example.com/repo:tag") 55 | assert.NoError(t, err) 56 | assert.Equal(t, "tag", ref.Tag()) 57 | 58 | ref, err = ParseReference("example.com/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") 59 | assert.NoError(t, err) 60 | assert.Equal(t, "", ref.Tag()) 61 | } 62 | 63 | func TestReferencer_Digest(t *testing.T) { 64 | ref, err := ParseReference("example.com/repo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") 65 | assert.NoError(t, err) 66 | assert.Equal(t, "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", ref.Digest()) 67 | 68 | ref, err = ParseReference("example.com/repo:tag") 69 | assert.NoError(t, err) 70 | assert.Equal(t, "", ref.Digest()) 71 | } 72 | 73 | func TestReferencer(t *testing.T) { 74 | ref, err := ParseReference("example.com/repo:tag@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef") 75 | assert.NoError(t, err) 76 | assert.Equal(t, "example.com/repo", ref.Repository()) 77 | assert.Equal(t, "tag", ref.Tag()) 78 | assert.Equal(t, "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", ref.Digest()) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/backend/remote/client.go: -------------------------------------------------------------------------------- 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 remote 18 | 19 | import ( 20 | "crypto/tls" 21 | "fmt" 22 | "net/http" 23 | "net/url" 24 | 25 | "oras.land/oras-go/v2/registry/remote" 26 | "oras.land/oras-go/v2/registry/remote/auth" 27 | "oras.land/oras-go/v2/registry/remote/credentials" 28 | "oras.land/oras-go/v2/registry/remote/retry" 29 | ) 30 | 31 | type Repository = remote.Repository 32 | 33 | type Option func(*client) 34 | 35 | type client struct { 36 | retry bool 37 | plainHTTP bool 38 | insecure bool 39 | proxy string 40 | } 41 | 42 | func New(repo string, opts ...Option) (*remote.Repository, error) { 43 | client := &client{} 44 | for _, opt := range opts { 45 | opt(client) 46 | } 47 | 48 | transport := &http.Transport{ 49 | TLSClientConfig: &tls.Config{ 50 | InsecureSkipVerify: client.insecure, 51 | }, 52 | } 53 | 54 | if client.proxy != "" { 55 | proxyURL, err := url.Parse(client.proxy) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to parse the proxy URL: %w", err) 58 | } 59 | 60 | transport.Proxy = http.ProxyURL(proxyURL) 61 | } 62 | 63 | httpClient := &http.Client{} 64 | if client.retry { 65 | httpClient.Transport = retry.NewTransport(transport) 66 | } else { 67 | httpClient.Transport = transport 68 | } 69 | 70 | repository, err := remote.NewRepository(repo) 71 | if err != nil { 72 | return nil, fmt.Errorf("failed to create repository: %w", err) 73 | } 74 | 75 | // Load credentials from Docker config. 76 | credStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{AllowPlaintextPut: true}) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to create credential store: %w", err) 79 | } 80 | 81 | repository.Client = &auth.Client{ 82 | Cache: auth.NewCache(), 83 | Credential: credentials.Credential(credStore), 84 | Client: httpClient, 85 | } 86 | 87 | repository.PlainHTTP = client.plainHTTP 88 | return repository, nil 89 | } 90 | 91 | func WithRetry(retry bool) Option { 92 | return func(c *client) { 93 | c.retry = retry 94 | } 95 | } 96 | 97 | func WithProxy(proxy string) Option { 98 | return func(c *client) { 99 | c.proxy = proxy 100 | } 101 | } 102 | 103 | func WithInsecure(insecure bool) Option { 104 | return func(c *client) { 105 | c.insecure = insecure 106 | } 107 | } 108 | 109 | func WithPlainHTTP(plainHTTP bool) Option { 110 | return func(c *client) { 111 | c.plainHTTP = plainHTTP 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /pkg/backend/retry.go: -------------------------------------------------------------------------------- 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 | "time" 21 | 22 | retry "github.com/avast/retry-go/v4" 23 | ) 24 | 25 | var defaultRetryOpts = []retry.Option{ 26 | retry.Attempts(3), 27 | retry.DelayType(retry.BackOffDelay), 28 | retry.Delay(5 * time.Second), 29 | retry.MaxDelay(10 * time.Second), 30 | } 31 | -------------------------------------------------------------------------------- /pkg/backend/rm.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "fmt" 22 | ) 23 | 24 | // Remove removes the target from the storage, notice that remove only removes the manifest, 25 | // the blobs may still be used by other manifests, so should use prune to remove the unused blobs. 26 | func (b *backend) Remove(ctx context.Context, target string) (string, error) { 27 | ref, err := ParseReference(target) 28 | if err != nil { 29 | return "", fmt.Errorf("failed to parse target: %w", err) 30 | } 31 | 32 | // if the reference is a tag, it will only untagged this manifest, 33 | // but if provided a digest, it will remove the manifest and all tags referencing it. 34 | repo, reference := ref.Repository(), ref.Tag() 35 | if ref.Digest() != "" { 36 | reference = ref.Digest() 37 | } 38 | 39 | if reference == "" { 40 | return "", fmt.Errorf("invalid reference, tag or digest must be provided") 41 | } 42 | 43 | if err := b.store.DeleteManifest(ctx, repo, reference); err != nil { 44 | return "", fmt.Errorf("failed to delete manifest %s: %w", reference, err) 45 | } 46 | 47 | return reference, nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/backend/rm_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "testing" 22 | 23 | "github.com/CloudNativeAI/modctl/test/mocks/storage" 24 | 25 | "github.com/stretchr/testify/assert" 26 | ) 27 | 28 | func TestRemove(t *testing.T) { 29 | mockStore := &storage.Storage{} 30 | b := &backend{store: mockStore} 31 | ctx := context.Background() 32 | target := "example.com/repo:tag" 33 | ref, err := ParseReference("example.com/repo:tag") 34 | assert.NoError(t, err) 35 | 36 | mockStore.On("DeleteManifest", ctx, ref.Repository(), ref.Tag()).Return(nil) 37 | 38 | result, err := b.Remove(ctx, target) 39 | assert.NoError(t, err) 40 | assert.Equal(t, ref.Tag(), result) 41 | 42 | mockStore.AssertExpectations(t) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/backend/tag.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | "encoding/json" 22 | "fmt" 23 | 24 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 25 | ) 26 | 27 | // Tag creates a new tag that refers to the source model artifact. 28 | func (b *backend) Tag(ctx context.Context, source, target string) error { 29 | srcRef, err := ParseReference(source) 30 | if err != nil { 31 | return fmt.Errorf("failed to parse source: %w", err) 32 | } 33 | 34 | targetRef, err := ParseReference(target) 35 | if err != nil { 36 | return fmt.Errorf("failed to parse target: %w", err) 37 | } 38 | 39 | manifestRaw, _, err := b.store.PullManifest(ctx, srcRef.Repository(), srcRef.Tag()) 40 | if err != nil { 41 | return fmt.Errorf("failed to pull manifest: %w", err) 42 | } 43 | 44 | var manifest ocispec.Manifest 45 | if err := json.Unmarshal(manifestRaw, &manifest); err != nil { 46 | return fmt.Errorf("failed to unmarshal manifest: %w", err) 47 | } 48 | 49 | // mount the blob from source. 50 | layers := []ocispec.Descriptor{manifest.Config} 51 | for _, layer := range manifest.Layers { 52 | layers = append(layers, layer) 53 | } 54 | 55 | for _, layer := range layers { 56 | if err := b.store.MountBlob(ctx, srcRef.Repository(), targetRef.Repository(), layer); err != nil { 57 | return fmt.Errorf("failed to mount blob %s: %w", layer.Digest.String(), err) 58 | } 59 | } 60 | 61 | if _, err := b.store.PushManifest(ctx, targetRef.Repository(), targetRef.Tag(), manifestRaw); err != nil { 62 | return fmt.Errorf("failed to push manifest: %w", err) 63 | } 64 | 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /pkg/codec/codec.go: -------------------------------------------------------------------------------- 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 codec 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "strings" 23 | 24 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 25 | ) 26 | 27 | type Type = string 28 | 29 | const ( 30 | // Raw is the raw codec type. 31 | Raw Type = "raw" 32 | 33 | // Tar is the tar codec type. 34 | Tar Type = "tar" 35 | ) 36 | 37 | // Codec is an interface for encoding and decoding the data. 38 | type Codec interface { 39 | // Type returns the type of the codec. 40 | Type() Type 41 | 42 | // Encode encodes the target file into a reader. 43 | Encode(targetFilePath, workDirPath string) (io.Reader, error) 44 | 45 | // Decode reads the input reader and decodes the data into the output path. 46 | Decode(outputDir, filePath string, reader io.Reader, desc ocispec.Descriptor) error 47 | } 48 | 49 | func New(codecType Type) (Codec, error) { 50 | switch codecType { 51 | case Raw: 52 | return newRaw(), nil 53 | case Tar: 54 | return newTar(), nil 55 | default: 56 | return nil, fmt.Errorf("unsupported codec type: %s", codecType) 57 | } 58 | } 59 | 60 | // TypeFromMediaType returns the codec type from the media type, 61 | // return empty string if not supported. 62 | func TypeFromMediaType(mediaType string) Type { 63 | // If the mediaType ends with ".tar", return Tar. 64 | if strings.HasSuffix(mediaType, ".tar") { 65 | return Tar 66 | } 67 | 68 | // If the mediaType ends with ".raw", return Raw. 69 | if strings.HasSuffix(mediaType, ".raw") { 70 | return Raw 71 | } 72 | 73 | return "" 74 | } 75 | -------------------------------------------------------------------------------- /pkg/codec/raw.go: -------------------------------------------------------------------------------- 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 codec 18 | 19 | import ( 20 | "encoding/json" 21 | "io" 22 | "os" 23 | "path/filepath" 24 | 25 | modelspec "github.com/CloudNativeAI/model-spec/specs-go/v1" 26 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 27 | ) 28 | 29 | // raw is a codec that for raw files. 30 | type raw struct{} 31 | 32 | // newRaw creates a new raw codec instance. 33 | func newRaw() *raw { 34 | return &raw{} 35 | } 36 | 37 | // Type returns the type of the codec. 38 | func (r *raw) Type() string { 39 | return Raw 40 | } 41 | 42 | // Encode reads the target file into a reader. 43 | func (r *raw) Encode(targetFilePath, workDirPath string) (io.Reader, error) { 44 | return os.Open(targetFilePath) 45 | } 46 | 47 | // Decode reads the input reader and decodes the data into the output path. 48 | func (r *raw) Decode(outputDir, filePath string, reader io.Reader, desc ocispec.Descriptor) error { 49 | fullPath := filepath.Join(outputDir, filePath) 50 | dir := filepath.Dir(fullPath) 51 | if err := os.MkdirAll(dir, 0755); err != nil { 52 | return err 53 | } 54 | 55 | file, err := os.Create(fullPath) 56 | if err != nil { 57 | return err 58 | } 59 | defer file.Close() 60 | 61 | if _, err := io.Copy(file, reader); err != nil { 62 | return err 63 | } 64 | 65 | var fileMetadata *modelspec.FileMetadata 66 | // Try to retrieve the file metadata from annotation for raw file. 67 | if desc.Annotations != nil { 68 | if fm := desc.Annotations[modelspec.AnnotationFileMetadata]; fm != "" { 69 | if err := json.Unmarshal([]byte(fm), &fileMetadata); err != nil { 70 | return err 71 | } 72 | } 73 | } 74 | 75 | // Restore file metadata if available. 76 | if fileMetadata != nil { 77 | // Restore file mode (convert from decimal to octal). 78 | if fileMetadata.Mode != 0 { 79 | if err := file.Chmod(os.FileMode(fileMetadata.Mode)); err != nil { 80 | return err 81 | } 82 | } 83 | } 84 | 85 | // Restore modification time if available. 86 | if fileMetadata != nil && !fileMetadata.ModTime.IsZero() { 87 | if err := os.Chtimes(fullPath, fileMetadata.ModTime, fileMetadata.ModTime); err != nil { 88 | return err 89 | } 90 | } 91 | 92 | return nil 93 | } 94 | -------------------------------------------------------------------------------- /pkg/codec/tar.go: -------------------------------------------------------------------------------- 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 codec 18 | 19 | import ( 20 | "io" 21 | 22 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 23 | 24 | "github.com/CloudNativeAI/modctl/pkg/archiver" 25 | ) 26 | 27 | // tar is a codec for tar files. 28 | type tar struct{} 29 | 30 | // newTar creates a new tar codec instance. 31 | func newTar() *tar { 32 | return &tar{} 33 | } 34 | 35 | // Type returns the type of the codec. 36 | func (t *tar) Type() string { 37 | return Tar 38 | } 39 | 40 | // Encode tars the target file into a reader. 41 | func (t *tar) Encode(targetFilePath, workDirPath string) (io.Reader, error) { 42 | return archiver.Tar(targetFilePath, workDirPath) 43 | } 44 | 45 | // Decode reads the input reader and decodes the data into the output path. 46 | func (t *tar) Decode(outputDir, filePath string, reader io.Reader, desc ocispec.Descriptor) error { 47 | // As the file name has been provided in the tar header, 48 | // so we do not care about the filePath. 49 | return archiver.Untar(reader, outputDir) 50 | } 51 | -------------------------------------------------------------------------------- /pkg/config/attach.go: -------------------------------------------------------------------------------- 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 config 18 | 19 | import "fmt" 20 | 21 | type Attach struct { 22 | Source string 23 | Target string 24 | OutputRemote bool 25 | PlainHTTP bool 26 | Insecure bool 27 | Nydusify bool 28 | Force bool 29 | } 30 | 31 | func NewAttach() *Attach { 32 | return &Attach{ 33 | Source: "", 34 | Target: "", 35 | OutputRemote: false, 36 | PlainHTTP: false, 37 | Insecure: false, 38 | Nydusify: false, 39 | Force: false, 40 | } 41 | } 42 | 43 | func (a *Attach) Validate() error { 44 | if a.Source == "" || a.Target == "" { 45 | return fmt.Errorf("source and target must be specified") 46 | } 47 | 48 | if a.Nydusify { 49 | if !a.OutputRemote { 50 | return fmt.Errorf("nydusify only works with output remote") 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /pkg/config/build.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import "fmt" 20 | 21 | const ( 22 | // defaultBuildConcurrency is the default number of concurrent builds. 23 | defaultBuildConcurrency = 5 24 | ) 25 | 26 | type Build struct { 27 | Concurrency int 28 | Target string 29 | Modelfile string 30 | OutputRemote bool 31 | PlainHTTP bool 32 | Insecure bool 33 | Nydusify bool 34 | SourceURL string 35 | SourceRevision string 36 | Raw bool 37 | } 38 | 39 | func NewBuild() *Build { 40 | return &Build{ 41 | Concurrency: defaultBuildConcurrency, 42 | Target: "", 43 | Modelfile: "Modelfile", 44 | OutputRemote: false, 45 | PlainHTTP: false, 46 | Insecure: false, 47 | Nydusify: false, 48 | SourceURL: "", 49 | SourceRevision: "", 50 | Raw: false, 51 | } 52 | } 53 | 54 | func (b *Build) Validate() error { 55 | if b.Concurrency <= 0 { 56 | return fmt.Errorf("concurrency must be greater than 0") 57 | } 58 | 59 | if len(b.Target) == 0 { 60 | return fmt.Errorf("target model artifact name is required") 61 | } 62 | 63 | if len(b.Modelfile) == 0 { 64 | return fmt.Errorf("model file path is required") 65 | } 66 | 67 | if b.Nydusify { 68 | if !b.OutputRemote { 69 | return fmt.Errorf("nydusify only works with output remote") 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /pkg/config/build_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import ( 20 | "testing" 21 | ) 22 | 23 | func TestNewBuild(t *testing.T) { 24 | build := NewBuild() 25 | if build.Concurrency == 0 { 26 | t.Errorf("expected Concurrency to be greater than 0, got %d", build.Concurrency) 27 | } 28 | 29 | if build.Target != "" { 30 | t.Errorf("expected Target to be empty, got %s", build.Target) 31 | } 32 | 33 | if build.Modelfile != "Modelfile" { 34 | t.Errorf("expected Modelfile to be 'Modelfile', got %s", build.Modelfile) 35 | } 36 | } 37 | 38 | func TestBuild_Validate(t *testing.T) { 39 | tests := []struct { 40 | name string 41 | build *Build 42 | expectErr bool 43 | }{ 44 | { 45 | name: "valid build", 46 | build: &Build{ 47 | Concurrency: 1, 48 | Target: "target", 49 | Modelfile: "Modelfile", 50 | }, 51 | expectErr: false, 52 | }, 53 | { 54 | name: "missing concurrency", 55 | build: &Build{ 56 | Concurrency: 0, 57 | Target: "target", 58 | Modelfile: "Modelfile", 59 | }, 60 | expectErr: true, 61 | }, 62 | { 63 | name: "missing target", 64 | build: &Build{ 65 | Target: "", 66 | Modelfile: "Modelfile", 67 | }, 68 | expectErr: true, 69 | }, 70 | { 71 | name: "missing modelfile", 72 | build: &Build{ 73 | Target: "target", 74 | Modelfile: "", 75 | }, 76 | expectErr: true, 77 | }, 78 | } 79 | 80 | for _, tt := range tests { 81 | t.Run(tt.name, func(t *testing.T) { 82 | err := tt.build.Validate() 83 | if (err != nil) != tt.expectErr { 84 | t.Errorf("expected error: %v, got: %v", tt.expectErr, err) 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/config/extract.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import "fmt" 20 | 21 | const ( 22 | // defaultExtractConcurrency is the default number of concurrent extracts. 23 | defaultExtractConcurrency = 5 24 | ) 25 | 26 | type Extract struct { 27 | Output string 28 | Concurrency int 29 | } 30 | 31 | func NewExtract() *Extract { 32 | return &Extract{ 33 | Output: "", 34 | Concurrency: defaultExtractConcurrency, 35 | } 36 | } 37 | 38 | func (e *Extract) Validate() error { 39 | if e.Concurrency <= 0 { 40 | return fmt.Errorf("concurrency must be greater than 0") 41 | } 42 | 43 | if e.Output == "" { 44 | return fmt.Errorf("output is required") 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /pkg/config/fetch.go: -------------------------------------------------------------------------------- 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 config 18 | 19 | import "fmt" 20 | 21 | const ( 22 | // defaultFetchConcurrency is the default number of concurrent fetch operations. 23 | defaultFetchConcurrency = 5 24 | ) 25 | 26 | type Fetch struct { 27 | Concurrency int 28 | PlainHTTP bool 29 | Proxy string 30 | Insecure bool 31 | Output string 32 | Patterns []string 33 | } 34 | 35 | func NewFetch() *Fetch { 36 | return &Fetch{ 37 | Concurrency: defaultFetchConcurrency, 38 | PlainHTTP: false, 39 | Proxy: "", 40 | Insecure: false, 41 | Output: "", 42 | Patterns: []string{}, 43 | } 44 | } 45 | 46 | func (f *Fetch) Validate() error { 47 | if f.Concurrency < 1 { 48 | return fmt.Errorf("invalid concurrency: %d", f.Concurrency) 49 | } 50 | 51 | if f.Output == "" { 52 | return fmt.Errorf("output is required") 53 | } 54 | 55 | if len(f.Patterns) == 0 { 56 | return fmt.Errorf("patterns are required") 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/config/inspect.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | type Inspect struct { 20 | Remote bool 21 | PlainHTTP bool 22 | Insecure bool 23 | } 24 | 25 | func NewInspect() *Inspect { 26 | return &Inspect{ 27 | Remote: false, 28 | PlainHTTP: false, 29 | Insecure: false, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/config/login.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import "fmt" 20 | 21 | type Login struct { 22 | Username string 23 | Password string 24 | PasswordStdin bool 25 | PlainHTTP bool 26 | Insecure bool 27 | } 28 | 29 | func NewLogin() *Login { 30 | return &Login{ 31 | Username: "", 32 | Password: "", 33 | PasswordStdin: true, 34 | PlainHTTP: false, 35 | Insecure: false, 36 | } 37 | } 38 | 39 | func (l *Login) Validate() error { 40 | if len(l.Username) == 0 { 41 | return fmt.Errorf("missing username") 42 | } 43 | 44 | if len(l.Password) == 0 && !l.PasswordStdin { 45 | return fmt.Errorf("missing password") 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/config/login_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import ( 20 | "testing" 21 | ) 22 | 23 | func TestNewLogin(t *testing.T) { 24 | login := NewLogin() 25 | if login.Username != "" { 26 | t.Errorf("expected empty username, got %s", login.Username) 27 | } 28 | if login.Password != "" { 29 | t.Errorf("expected empty password, got %s", login.Password) 30 | } 31 | if login.PasswordStdin != true { 32 | t.Errorf("expected PasswordStdin to be true, got %v", login.PasswordStdin) 33 | } 34 | } 35 | 36 | func TestLogin_Validate(t *testing.T) { 37 | tests := []struct { 38 | name string 39 | login *Login 40 | wantErr bool 41 | errMsg string 42 | }{ 43 | { 44 | name: "missing username", 45 | login: &Login{ 46 | Username: "", 47 | Password: "password", 48 | }, 49 | wantErr: true, 50 | errMsg: "missing username", 51 | }, 52 | { 53 | name: "missing password", 54 | login: &Login{ 55 | Username: "username", 56 | Password: "", 57 | }, 58 | wantErr: true, 59 | errMsg: "missing password", 60 | }, 61 | { 62 | name: "valid login", 63 | login: &Login{ 64 | Username: "username", 65 | Password: "password", 66 | }, 67 | wantErr: false, 68 | }, 69 | } 70 | 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | err := tt.login.Validate() 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) 76 | return 77 | } 78 | if err != nil && err.Error() != tt.errMsg { 79 | t.Errorf("Validate() error message = %v, want %v", err.Error(), tt.errMsg) 80 | } 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pkg/config/modelfile/modelfile.go: -------------------------------------------------------------------------------- 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 modelfile 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "path/filepath" 23 | "strings" 24 | ) 25 | 26 | // DefaultModelfileName is the default name of the modelfile. 27 | const DefaultModelfileName = "Modelfile" 28 | 29 | type GenerateConfig struct { 30 | Workspace string 31 | Name string 32 | Version string 33 | Output string 34 | IgnoreUnrecognizedFileTypes bool 35 | Overwrite bool 36 | Arch string 37 | Family string 38 | Format string 39 | ParamSize string 40 | Precision string 41 | Quantization string 42 | } 43 | 44 | func NewGenerateConfig() *GenerateConfig { 45 | return &GenerateConfig{ 46 | Workspace: ".", 47 | Name: "", 48 | Version: "", 49 | Output: "", 50 | IgnoreUnrecognizedFileTypes: false, 51 | Overwrite: false, 52 | Arch: "", 53 | Family: "", 54 | Format: "", 55 | ParamSize: "", 56 | Precision: "", 57 | Quantization: "", 58 | } 59 | } 60 | 61 | func (g *GenerateConfig) Convert(workspace string) error { 62 | modelfilePath := filepath.Join(g.Output, DefaultModelfileName) 63 | absModelfilePath, err := filepath.Abs(modelfilePath) 64 | if err != nil { 65 | return err 66 | } 67 | g.Output = absModelfilePath 68 | 69 | if !strings.HasSuffix(workspace, "/") { 70 | workspace += "/" 71 | } 72 | 73 | absWorkspace, err := filepath.Abs(workspace) 74 | if err != nil { 75 | return err 76 | } 77 | g.Workspace = absWorkspace 78 | return nil 79 | } 80 | 81 | func (g *GenerateConfig) Validate() error { 82 | // Check if the output path exists modelfile, if so, check if we can overwrite it. 83 | // If the output path does not exist, we can create the modelfile. 84 | if _, err := os.Stat(g.Output); err == nil { 85 | if !g.Overwrite { 86 | return fmt.Errorf("Modelfile already exists at %s - use --overwrite to overwrite", g.Output) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/config/prune.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | type Prune struct { 20 | DryRun bool 21 | RemoveUntagged bool 22 | } 23 | 24 | func NewPrune() *Prune { 25 | return &Prune{ 26 | DryRun: false, 27 | RemoveUntagged: true, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/config/pull.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | 24 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 25 | ) 26 | 27 | const ( 28 | // defaultPullConcurrency is the default number of concurrent pull operations. 29 | defaultPullConcurrency = 5 30 | ) 31 | 32 | type Pull struct { 33 | Concurrency int 34 | PlainHTTP bool 35 | Proxy string 36 | Insecure bool 37 | ExtractDir string 38 | ExtractFromRemote bool 39 | Hooks PullHooks 40 | ProgressWriter io.Writer 41 | DisableProgress bool 42 | } 43 | 44 | func NewPull() *Pull { 45 | return &Pull{ 46 | Concurrency: defaultPullConcurrency, 47 | PlainHTTP: false, 48 | Proxy: "", 49 | Insecure: false, 50 | ExtractDir: "", 51 | ExtractFromRemote: false, 52 | Hooks: &emptyPullHook{}, 53 | ProgressWriter: os.Stdout, 54 | DisableProgress: false, 55 | } 56 | } 57 | 58 | func (p *Pull) Validate() error { 59 | if p.Concurrency < 1 { 60 | return fmt.Errorf("invalid concurrency: %d", p.Concurrency) 61 | } 62 | 63 | // Validate the ExtractDir if user specify the ExtractFromRemote to true. 64 | if p.ExtractFromRemote { 65 | if p.ExtractDir == "" { 66 | return fmt.Errorf("the extract dir must be specified when enabled extract from remote") 67 | } 68 | } 69 | 70 | return nil 71 | } 72 | 73 | // PullHooks is the hook events during the pull operation. 74 | type PullHooks interface { 75 | // BeforePullLayer will execute before pulling the layer described as desc, will carry the manifest as well. 76 | BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) 77 | 78 | // AfterPullLayer will execute after pulling the layer described as desc, the error will be nil if pulled successfully. 79 | AfterPullLayer(desc ocispec.Descriptor, err error) 80 | } 81 | 82 | // emptyPullHook is the empty pull hook implementation with do nothing. 83 | type emptyPullHook struct{} 84 | 85 | func (emptyPullHook) BeforePullLayer(desc ocispec.Descriptor, manifest ocispec.Manifest) {} 86 | func (emptyPullHook) AfterPullLayer(desc ocispec.Descriptor, err error) {} 87 | -------------------------------------------------------------------------------- /pkg/config/push.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import "fmt" 20 | 21 | const ( 22 | // defaultPushConcurrency is the default number of concurrent push operations. 23 | defaultPushConcurrency = 5 24 | ) 25 | 26 | type Push struct { 27 | Concurrency int 28 | PlainHTTP bool 29 | Insecure bool 30 | Nydusify bool 31 | } 32 | 33 | func NewPush() *Push { 34 | return &Push{ 35 | Concurrency: defaultPushConcurrency, 36 | PlainHTTP: false, 37 | Nydusify: false, 38 | } 39 | } 40 | 41 | func (p *Push) Validate() error { 42 | if p.Concurrency < 1 { 43 | return fmt.Errorf("invalid concurrency: %d", p.Concurrency) 44 | } 45 | 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /pkg/config/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 config 18 | 19 | import ( 20 | "os/user" 21 | "path/filepath" 22 | ) 23 | 24 | type Root struct { 25 | StoargeDir string 26 | Pprof bool 27 | PprofAddr string 28 | DisableProgress bool 29 | } 30 | 31 | func NewRoot() (*Root, error) { 32 | user, err := user.Current() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return &Root{ 38 | StoargeDir: filepath.Join(user.HomeDir, ".modctl"), 39 | Pprof: false, 40 | PprofAddr: "localhost:6060", 41 | DisableProgress: false, 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/modelfile/command/command.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 command 18 | 19 | // Define the command strings for modelfile. 20 | const ( 21 | // CONFIG is the command to set the configuration of the model, which is used for 22 | // the model to be served, such as the config.json, generation_config.json, etc. 23 | // The CONFIG command can be used multiple times in a modelfile, it 24 | // will be copied the config file to the artifact package as a layer. 25 | CONFIG = "CONFIG" 26 | 27 | // MODEL is the command to set the model file path. The value of this command 28 | // is the glob of the model file path to match the model file name. 29 | // The MODEL command can be used multiple times in a modelfile, it will scan 30 | // the model file path by the glob and copy each model file to the artifact 31 | // package, and each model file will be a layer. 32 | MODEL = "MODEL" 33 | 34 | // CODE is the command to set the code file path. The value of this commands 35 | // is the glob of the code file path to match the code file name. 36 | // The CODE command can be used multiple times in a modelfile, it will scan 37 | // the code file path by the glob and copy each code file to the artifact 38 | // package, and each code file will be a layer. 39 | CODE = "CODE" 40 | 41 | // DATASET is the command to set the dataset file path. The value of this commands 42 | // is the glob of the dataset file path to match the dataset file name. 43 | // The DATASET command can be used multiple times in a modelfile, it will scan 44 | // the dataset file path by the glob and copy each dataset file to the artifact 45 | // package, and each dataset file will be a layer. 46 | DATASET = "DATASET" 47 | 48 | // DOC is the command to set the documentation file path. The value of this commands 49 | // is the glob of the documentation file path to match the documentation file name. 50 | // The DOC command can be used multiple times in a modelfile, it will scan 51 | // the documentation file path by the glob and copy each documentation file to the artifact 52 | // package, and each documentation file will be a layer. 53 | DOC = "DOC" 54 | 55 | // NAME is the command to set the model name, such as llama3-8b-instruct, gpt2-xl, 56 | // qwen2-vl-72b-instruct, etc. 57 | NAME = "NAME" 58 | 59 | // ARCH is the command to set the architecture of the model, such as transformer, 60 | // cnn, rnn, etc. 61 | ARCH = "ARCH" 62 | 63 | // FAMILY is the command to set the family of the model, such as llama3, gpt2, qwen2, etc. 64 | FAMILY = "FAMILY" 65 | 66 | // FORMAT is the command to set the format of the model, such as onnx, tensorflow, pytorch, etc. 67 | FORMAT = "FORMAT" 68 | 69 | // PARAMSIZE is the command to set the parameter size of the model. 70 | PARAMSIZE = "PARAMSIZE" 71 | 72 | // PRECISION is the command to set the precision of the model, such as bf16, fp16, int8, etc. 73 | PRECISION = "PRECISION" 74 | 75 | // QUANTIZATION is the command to set the quantization of the model, such as awq, gptq, etc. 76 | QUANTIZATION = "QUANTIZATION" 77 | ) 78 | 79 | // Commands is a list of all the commands that can be used in a modelfile. 80 | var Commands = []string{ 81 | CONFIG, 82 | MODEL, 83 | CODE, 84 | DATASET, 85 | DOC, 86 | NAME, 87 | ARCH, 88 | FAMILY, 89 | FORMAT, 90 | PARAMSIZE, 91 | PRECISION, 92 | QUANTIZATION, 93 | } 94 | -------------------------------------------------------------------------------- /pkg/modelfile/constants_test.go: -------------------------------------------------------------------------------- 1 | package modelfile 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIsFileType(t *testing.T) { 10 | testCases := []struct { 11 | filename string 12 | patterns []string 13 | expected bool 14 | }{ 15 | {"config.json", []string{"*.json", "*.yaml"}, true}, 16 | {"config.yaml", []string{"*.json", "*.yaml"}, true}, 17 | {"config.txt", []string{"*.json", "*.yaml"}, false}, 18 | {"image.JPG", []string{"*.jpg", "*.png"}, true}, 19 | {"image.jpeg", []string{"*.jpg", "*.png"}, false}, 20 | {"script.py", []string{"*.py", "*.sh"}, true}, 21 | {"script.sh", []string{"*.py", "*.sh"}, true}, 22 | {"script.bash", []string{"*.py", "*.sh"}, false}, 23 | } 24 | 25 | assert := assert.New(t) 26 | for _, tc := range testCases { 27 | assert.Equal(tc.expected, IsFileType(tc.filename, tc.patterns)) 28 | } 29 | } 30 | 31 | func TestIsSkippable(t *testing.T) { 32 | testCases := []struct { 33 | filename string 34 | expected bool 35 | }{ 36 | {".hiddenfile", true}, 37 | {"modelfile", true}, 38 | {"__pycache__", true}, 39 | {"file.pyc", true}, 40 | {"file.pyo", true}, 41 | {"file.pyd", true}, 42 | {"visiblefile.txt", false}, 43 | {"directory", false}, 44 | } 45 | 46 | assert := assert.New(t) 47 | for _, tc := range testCases { 48 | assert.Equal(tc.expected, isSkippable(tc.filename)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/modelfile/parser/args_parser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 parser 18 | 19 | import ( 20 | "errors" 21 | ) 22 | 23 | // parseStringArgs parses the string type of args and returns a Node, for example: 24 | // "MODEL foo" args' value is "foo". 25 | func parseStringArgs(args []string, start, end int) (Node, error) { 26 | if len(args) != 1 { 27 | return nil, errors.New("invalid args") 28 | } 29 | 30 | if args[0] == "" { 31 | return nil, errors.New("empty args") 32 | } 33 | 34 | return NewNode(args[0], start, end), nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/modelfile/parser/args_parser_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 parser 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestParseStringArgs(t *testing.T) { 26 | testCases := []struct { 27 | args []string 28 | start int 29 | end int 30 | expectErr bool 31 | expected string 32 | }{ 33 | {[]string{"foo"}, 1, 2, false, "foo"}, 34 | {[]string{"bar"}, 3, 4, false, "bar"}, 35 | {[]string{}, 5, 6, true, ""}, 36 | {[]string{"foo", "bar"}, 7, 8, true, ""}, 37 | {[]string{""}, 9, 10, true, ""}, 38 | } 39 | 40 | assert := assert.New(t) 41 | for _, tc := range testCases { 42 | node, err := parseStringArgs(tc.args, tc.start, tc.end) 43 | if tc.expectErr { 44 | assert.Error(err) 45 | assert.Nil(node) 46 | continue 47 | } 48 | 49 | assert.NoError(err) 50 | assert.NotNil(node) 51 | assert.Equal(tc.expected, node.GetValue()) 52 | assert.Equal(tc.start, node.GetStartLine()) 53 | assert.Equal(tc.end, node.GetEndLine()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/modelfile/parser/ast_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 | package parser 17 | 18 | import ( 19 | "testing" 20 | 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | func TestNewRootNode(t *testing.T) { 25 | root := NewRootNode() 26 | 27 | assert := assert.New(t) 28 | assert.Equal("", root.GetValue()) 29 | assert.Nil(root.GetNext()) 30 | assert.Empty(root.GetChildren()) 31 | assert.Equal(0, root.GetStartLine()) 32 | assert.Equal(0, root.GetEndLine()) 33 | assert.Nil(root.GetAttributes()) 34 | } 35 | 36 | func TestNewNode(t *testing.T) { 37 | testCases := []struct { 38 | value string 39 | startLine int 40 | endLine int 41 | }{ 42 | {"test1", 1, 2}, 43 | {"test2", 3, 4}, 44 | {"test3", 5, 6}, 45 | } 46 | 47 | assert := assert.New(t) 48 | for _, tc := range testCases { 49 | node := NewNode(tc.value, tc.startLine, tc.endLine) 50 | 51 | assert.Equal(tc.value, node.GetValue(), "expected value %s", tc.value) 52 | assert.Nil(node.GetNext(), "expected nil next node") 53 | assert.Empty(node.GetChildren(), "expected no children") 54 | assert.Equal(tc.startLine, node.GetStartLine(), "expected start line %d", tc.startLine) 55 | assert.Equal(tc.endLine, node.GetEndLine(), "expected end line %d", tc.endLine) 56 | assert.Nil(node.GetAttributes(), "expected nil attributes") 57 | } 58 | } 59 | 60 | func TestAddChild(t *testing.T) { 61 | parent := NewNode("parent", 1, 2) 62 | children := []Node{ 63 | NewNode("child1", 3, 4), 64 | NewNode("child2", 5, 6), 65 | NewNode("child3", 7, 8), 66 | } 67 | 68 | for _, child := range children { 69 | parent.AddChild(child) 70 | } 71 | 72 | assert := assert.New(t) 73 | assert.Len(parent.GetChildren(), len(children)) 74 | for i, child := range children { 75 | assert.Equal(child, parent.GetChildren()[i]) 76 | } 77 | } 78 | 79 | func TestAddNext(t *testing.T) { 80 | node1 := NewNode("node1", 1, 2) 81 | node2 := NewNode("node2", 3, 4) 82 | node1.AddNext(node2) 83 | 84 | assert := assert.New(t) 85 | assert.Equal(node2, node1.GetNext()) 86 | } 87 | 88 | func TestAddAttribute(t *testing.T) { 89 | node := NewNode("node", 1, 2) 90 | attributes := map[string]string{ 91 | "key1": "value1", 92 | "key2": "value2", 93 | "key3": "value3", 94 | } 95 | 96 | for key, value := range attributes { 97 | node.AddAttribute(key, value) 98 | } 99 | 100 | assert := assert.New(t) 101 | assert.Equal(attributes, node.GetAttributes()) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/modelfile/parser/parser.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 parser 18 | 19 | import ( 20 | "bufio" 21 | "fmt" 22 | "io" 23 | "strings" 24 | 25 | "github.com/CloudNativeAI/modctl/pkg/modelfile/command" 26 | ) 27 | 28 | // Parse parses the modelfile and returns the root node of the AST, 29 | // and the root node is the entry point of the AST. Walk the AST to 30 | // get the information of the modelfile. 31 | func Parse(reader io.Reader) (Node, error) { 32 | root := NewRootNode() 33 | currentLine := 0 34 | 35 | scanner := bufio.NewScanner(reader) 36 | scanner.Split(bufio.ScanLines) 37 | for scanner.Scan() { 38 | bytes := scanner.Bytes() 39 | trimmedLine := strings.TrimSpace(string(bytes)) 40 | 41 | // If the line is empty, continue to the next line. 42 | if isEmptyContinuationLine(trimmedLine) { 43 | currentLine++ 44 | continue 45 | } 46 | 47 | // If the line is a comment, do not to record it and 48 | // continue to the next line. 49 | if isComment(trimmedLine) { 50 | currentLine++ 51 | continue 52 | } 53 | 54 | // If the line is a command, parse the command line, and add 55 | // the command node and the args node to the root node. 56 | if isCommand(trimmedLine) { 57 | node, err := parseCommandLine(trimmedLine, currentLine, currentLine) 58 | if err != nil { 59 | return nil, fmt.Errorf("parse command line error on line %d: %w", currentLine, err) 60 | } 61 | 62 | root.AddChild(node) 63 | currentLine++ 64 | continue 65 | } 66 | 67 | // If the line is not a comment, empty continuation, or a command, return an error. 68 | return nil, fmt.Errorf("parse error on line %d: %s", currentLine, string(bytes)) 69 | } 70 | 71 | return root, nil 72 | } 73 | 74 | // isComment checks if the line is a comment. 75 | func isComment(line string) bool { 76 | return strings.HasPrefix(line, "#") 77 | } 78 | 79 | // isCommand checks if the line is a command. 80 | func isCommand(line string) bool { 81 | line = strings.ToUpper(line) 82 | for _, cmd := range command.Commands { 83 | if strings.HasPrefix(line, cmd) { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | } 90 | 91 | // isEmptyContinuationLine checks if the line is an empty continuation line. 92 | func isEmptyContinuationLine(line string) bool { 93 | return len(line) == 0 94 | } 95 | 96 | // parseCommandLine parses the command line and returns the command node with the args node. 97 | // Need to walk the next node of the command node to get the args node. 98 | func parseCommandLine(line string, start, end int) (Node, error) { 99 | cmd, args, err := splitCommand(line) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | switch cmd { 105 | case command.CONFIG, command.MODEL, command.CODE, command.DATASET, command.DOC, command.NAME, command.ARCH, command.FAMILY, command.FORMAT, command.PARAMSIZE, command.PRECISION, command.QUANTIZATION: 106 | argsNode, err := parseStringArgs(args, start, end) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | cmdNode := NewNode(cmd, start, end) 112 | cmdNode.AddNext(argsNode) 113 | return cmdNode, nil 114 | default: 115 | return nil, fmt.Errorf("invalid command: %s", cmd) 116 | } 117 | } 118 | 119 | // splitCommand splits the command line into the command and the args. Returns the 120 | // command and the args, and an error if the command line is invalid. 121 | // Example: "MODEL foo" returns "MODEL", ["foo"] and nil. 122 | func splitCommand(line string) (string, []string, error) { 123 | parts := strings.Fields(line) 124 | if len(parts) < 2 { 125 | return "", nil, fmt.Errorf("invalid command line: %s", line) 126 | } 127 | 128 | return strings.ToUpper(parts[0]), parts[1:], nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/source/git_gogit.go: -------------------------------------------------------------------------------- 1 | //go:build disable_libgit2 2 | 3 | /* 4 | * Copyright 2025 The CNAI Authors 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | package source 20 | 21 | import ( 22 | "fmt" 23 | 24 | gogit "github.com/go-git/go-git/v5" 25 | ) 26 | 27 | type git struct{} 28 | 29 | func (g *git) Parse(workspace string) (*Info, error) { 30 | repo, err := gogit.PlainOpen(workspace) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to open repo: %w", err) 33 | } 34 | 35 | // By default, use the origin as the remote. 36 | remote, err := repo.Remote("origin") 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to get remote: %w", err) 39 | } 40 | remoteURLs := remote.Config().URLs 41 | if len(remoteURLs) == 0 { 42 | return nil, fmt.Errorf("no URLs found for remote 'origin'") 43 | } 44 | url := remoteURLs[0] 45 | 46 | // Fetch the head commit. 47 | head, err := repo.Head() 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to get HEAD: %w", err) 50 | } 51 | commitHash := head.Hash().String() 52 | 53 | // Check if the workspace is dirty. 54 | worktree, err := repo.Worktree() 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to get worktree: %w", err) 57 | } 58 | status, err := worktree.Status() 59 | if err != nil { 60 | return nil, fmt.Errorf("failed to get status: %w", err) 61 | } 62 | isDirty := !status.IsClean() 63 | 64 | return &Info{ 65 | URL: url, 66 | Commit: commitHash, 67 | Dirty: isDirty, 68 | }, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/source/git_libgit2.go: -------------------------------------------------------------------------------- 1 | //go:build !disable_libgit2 2 | 3 | /* 4 | * Copyright 2025 The CNAI Authors 5 | * 6 | * Licensed under the Apache License, Version 2.0 (the "License"); 7 | * you may not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, software 13 | * distributed under the License is distributed on an "AS IS" BASIS, 14 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | * See the License for the specific language governing permissions and 16 | * limitations under the License. 17 | */ 18 | 19 | package source 20 | 21 | import ( 22 | "fmt" 23 | "path/filepath" 24 | "strings" 25 | 26 | git2go "github.com/libgit2/git2go/v34" 27 | ) 28 | 29 | const ( 30 | // The error returned by libgit2 when the user is not the owner of the git repository. 31 | safeDirectoryNotFoundErrorMsg = "config value 'safe.directory' was not found" 32 | ) 33 | 34 | // isSafeDirectoryNotFoundError checks if the error is a safe.directory not found error. 35 | func isSafeDirectoryNotFoundError(err error) bool { 36 | if err != nil { 37 | return strings.Contains(err.Error(), safeDirectoryNotFoundErrorMsg) 38 | } 39 | 40 | return false 41 | } 42 | 43 | type git struct{} 44 | 45 | func (g *git) Parse(workspace string) (*Info, error) { 46 | repo, err := git2go.OpenRepository(workspace) 47 | if err != nil { 48 | // Try to set safe.directory manually if it is not found, and try to open repository again. 49 | if isSafeDirectoryNotFoundError(err) { 50 | config, err := git2go.OpenDefault() 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to open config: %w", err) 53 | } 54 | defer config.Free() 55 | 56 | absWorkspace, err := filepath.Abs(workspace) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to get absolute path of workspace: %w", err) 59 | } 60 | 61 | if err := config.SetString("safe.directory", absWorkspace); err != nil { 62 | return nil, fmt.Errorf("failed to set safe.directory: %w", err) 63 | } 64 | } 65 | 66 | repo, err = git2go.OpenRepository(workspace) 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to open repository at %s: %w", workspace, err) 69 | } 70 | } 71 | defer repo.Free() 72 | 73 | // Get remote URL(Source URL). 74 | remote, err := repo.Remotes.Lookup("origin") 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to lookup remote: %w", err) 77 | } 78 | defer remote.Free() 79 | 80 | url := remote.Url() 81 | if len(url) == 0 { 82 | return nil, fmt.Errorf("failed to get remote URL") 83 | } 84 | 85 | // Get HEAD commit. 86 | head, err := repo.Head() 87 | if err != nil { 88 | return nil, fmt.Errorf("failed to get HEAD: %w", err) 89 | } 90 | defer head.Free() 91 | 92 | commitSHA := head.Target().String() 93 | if len(commitSHA) == 0 { 94 | return nil, fmt.Errorf("failed to get HEAD commit") 95 | } 96 | 97 | // Check whether the workspace is dirty. 98 | statusOpts := git2go.StatusOptions{} 99 | statusOpts.Show = git2go.StatusShowIndexAndWorkdir 100 | statusOpts.Flags = git2go.StatusOptIncludeUntracked | git2go.StatusOptRenamesHeadToIndex | git2go.StatusOptSortCaseSensitively 101 | 102 | statusList, err := repo.StatusList(&statusOpts) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to get status list: %w", err) 105 | } 106 | defer statusList.Free() 107 | 108 | entryCount, err := statusList.EntryCount() 109 | if err != nil { 110 | return nil, fmt.Errorf("failed to get status entry count: %w", err) 111 | } 112 | 113 | isDirty := entryCount > 0 114 | 115 | return &Info{ 116 | URL: url, 117 | Commit: commitSHA, 118 | Dirty: isDirty, 119 | }, nil 120 | } 121 | -------------------------------------------------------------------------------- /pkg/source/git_test.go: -------------------------------------------------------------------------------- 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 source 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestGit(t *testing.T) { 26 | parser := &git{} 27 | info, err := parser.Parse("testdata/git-repo") 28 | assert.NoError(t, err) 29 | assert.Equal(t, "https://github.com/octocat/Hello-World.git", info.URL, "source url should be equal to expected") 30 | assert.Equal(t, "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", info.Commit, "commit should be equal to expected") 31 | assert.Equal(t, false, info.Dirty, "dirty should be equal to expected") 32 | } 33 | -------------------------------------------------------------------------------- /pkg/source/parser.go: -------------------------------------------------------------------------------- 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 source 18 | 19 | import "fmt" 20 | 21 | const ( 22 | // ParserTypeGit is the type of parser for git repositories. 23 | ParserTypeGit = "git" 24 | 25 | // ParserTypeZeta is the type of parser for zeta repositories. 26 | ParserTypeZeta = "zeta" 27 | ) 28 | 29 | // Parser is an interface for parsing the source information. 30 | type Parser interface { 31 | Parse(workspace string) (*Info, error) 32 | } 33 | 34 | // Info is a struct that holds the source information. 35 | type Info struct { 36 | // URL is the URL of the source. 37 | // e.g git is the origin remote URL. 38 | URL string 39 | // Commit is the commit hash of the source. 40 | // e.g git is the HEAD commit hash. 41 | Commit string 42 | // Dirty is true if the source is dirty. 43 | // e.g git is indicating whether the workspace is dirty. 44 | Dirty bool 45 | } 46 | 47 | func NewParser(typ string) (Parser, error) { 48 | switch typ { 49 | case ParserTypeGit: 50 | return &git{}, nil 51 | case ParserTypeZeta: 52 | return &zeta{}, nil 53 | default: 54 | return nil, fmt.Errorf("unsupported parser type: %s", typ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/HEAD: -------------------------------------------------------------------------------- 1 | ref: refs/heads/v1.0.0 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.idx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.idx -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.mtimes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.mtimes -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/blob/pack/pack-75921aadba613dd766cf4b44df2bc998c5c5ccce8e958f8ef8a9127f516cbea3.pack -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/index: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/index -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/logs/HEAD: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000000000000000000000000000 928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a <> 1744943558 +0800 switch: move from to v1.0.0 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/logs/refs/heads/v1.0.0: -------------------------------------------------------------------------------- 1 | 0000000000000000000000000000000000000000000000000000000000000000 928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a <> 1744943558 +0800 branch: Created from 928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.idx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.idx -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.mtimes: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.mtimes -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/.zeta/metadata/pack/pack-47f52810f7b2cfb88f0053a8e51e68afa7cca31f8dbd8b11cc92f46eb05ac86a.pack -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/refs/heads/v1.0.0: -------------------------------------------------------------------------------- 1 | 928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/refs/remotes/origin/v1.0.0: -------------------------------------------------------------------------------- 1 | 928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zeta/zeta.toml: -------------------------------------------------------------------------------- 1 | [core] 2 | remote = "https://zeta.com/test/zeta-repo" 3 | compression-algo = "zstd" 4 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/.zetaignore: -------------------------------------------------------------------------------- 1 | # Zeta untracked files to ignore -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudNativeAI/modctl/9adffa9da8582a16e063d326cdc0598a490b0b00/pkg/source/testdata/zeta-repo/1.txt -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/LEGAL.md: -------------------------------------------------------------------------------- 1 | LEGAL 2 | -------------------------------------------------------------------------------- /pkg/source/testdata/zeta-repo/Readme.md: -------------------------------------------------------------------------------- 1 | # Readme 2 | 3 | -------------------------------------------------------------------------------- /pkg/source/zeta.go: -------------------------------------------------------------------------------- 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 source 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | 23 | pkgzeta "github.com/antgroup/hugescm/pkg/zeta" 24 | ) 25 | 26 | type zeta struct{} 27 | 28 | func (z *zeta) Parse(workspace string) (*Info, error) { 29 | ctx := context.Background() 30 | repo, err := pkgzeta.Open(ctx, &pkgzeta.OpenOptions{ 31 | Worktree: workspace, 32 | Quiet: true, 33 | Verbose: false, 34 | }) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to open repo: %w", err) 37 | } 38 | defer repo.Close() 39 | 40 | url := repo.Core.Remote 41 | if len(url) == 0 { 42 | return nil, fmt.Errorf("no remote URL found") 43 | } 44 | 45 | rev, err := repo.Revision(ctx, "HEAD") 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to get HEAD revision: %w", err) 48 | } 49 | commitHash := rev.String() 50 | 51 | status, err := repo.Worktree().Status(ctx, false) 52 | if err != nil { 53 | return nil, fmt.Errorf("failed to get status: %w", err) 54 | } 55 | isDirty := !status.IsClean() 56 | 57 | return &Info{ 58 | URL: url, 59 | Commit: commitHash, 60 | Dirty: isDirty, 61 | }, nil 62 | } 63 | -------------------------------------------------------------------------------- /pkg/source/zeta_test.go: -------------------------------------------------------------------------------- 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 source 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestZeta(t *testing.T) { 26 | parser := &zeta{} 27 | info, err := parser.Parse("testdata/zeta-repo") 28 | assert.NoError(t, err) 29 | assert.Equal(t, "https://zeta.com/test/zeta-repo", info.URL, "source url should be equal to expected") 30 | assert.Equal(t, "928f09e2c2162f7c94e8be8e36c2f9a3bd978c2756ab6815dba1a4f4228f279a", info.Commit, "commit should be equal to expected") 31 | assert.Equal(t, true, info.Dirty, "dirty should be equal to expected") 32 | } 33 | -------------------------------------------------------------------------------- /pkg/storage/factory.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 storage 18 | 19 | import ( 20 | "path/filepath" 21 | 22 | "github.com/CloudNativeAI/modctl/pkg/storage/distribution" 23 | ) 24 | 25 | const ( 26 | // contentV1Dir is the content v1 directory. 27 | contentV1Dir = "content.v1" 28 | ) 29 | 30 | // Type is the type of storage. 31 | type Type = string 32 | 33 | // New gets the storage by the type. 34 | func New(storageType Type, storageDir string, opts ...Option) (Storage, error) { 35 | storageOpts := &Options{} 36 | for _, opt := range opts { 37 | opt(storageOpts) 38 | } 39 | 40 | storageOpts.RootDir = filepath.Join(storageDir, contentV1Dir) 41 | switch storageType { 42 | case distribution.StorageTypeDistribution: 43 | return distribution.NewStorage(storageOpts.RootDir) 44 | // extend more storage types here. 45 | // case "other": 46 | default: 47 | // currently by default we are using distribution as storage. 48 | return distribution.NewStorage(storageOpts.RootDir) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pkg/storage/storage.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2024 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 storage 18 | 19 | import ( 20 | "context" 21 | "io" 22 | 23 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 24 | ) 25 | 26 | // Option is the option wrapper for modifying the storage options. 27 | type Option func(*Options) 28 | 29 | // Options is the options for the storage. 30 | type Options struct { 31 | // RootDir is the root directory of the storage. 32 | RootDir string 33 | } 34 | 35 | // Storage is an interface for storage which wraps the storage operations. 36 | type Storage interface { 37 | // PullManifest pulls the manifest from the storage. 38 | PullManifest(ctx context.Context, repo, reference string) ([]byte, string, error) 39 | // PushManifest pushes the manifest to the storage. 40 | PushManifest(ctx context.Context, repo, reference string, body []byte) (string, error) 41 | // StatManifest stats the manifest in the storage. 42 | StatManifest(ctx context.Context, repo, digest string) (bool, error) 43 | // DeleteManifest deletes the manifest from the storage. 44 | DeleteManifest(ctx context.Context, repo, reference string) error 45 | // PullBlob pulls the blob from the storage. 46 | PullBlob(ctx context.Context, repo, digest string) (io.ReadCloser, error) 47 | // PushBlob pushes the blob to the storage. 48 | PushBlob(ctx context.Context, repo string, body io.Reader, desc ocispec.Descriptor) (string, int64, error) 49 | // MountBlob mounts the blob to the storage. 50 | MountBlob(ctx context.Context, fromRepo, toRepo string, desc ocispec.Descriptor) error 51 | // StatBlob stats the blob in the storage. 52 | StatBlob(ctx context.Context, repo, digest string) (bool, error) 53 | // ListRepositories lists all the repositories in the storage. 54 | ListRepositories(ctx context.Context) ([]string, error) 55 | // ListTags lists all the tags in the repository. 56 | ListTags(ctx context.Context, repo string) ([]string, error) 57 | // PerformGC performs the garbage collection in the storage to free up the space. 58 | PerformGC(ctx context.Context, dryRun, removeUntagged bool) error 59 | // PerformPurgeUploads performs the purge uploads in the storage to free up the space. 60 | PerformPurgeUploads(ctx context.Context, dryRun bool) error 61 | } 62 | 63 | // WithRootDir sets the root directory of the storage. 64 | func WithRootDir(rootDir string) Option { 65 | return func(o *Options) { 66 | o.RootDir = rootDir 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/version/platform_darwin.go: -------------------------------------------------------------------------------- 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 version 18 | 19 | const platform = "darwin" 20 | -------------------------------------------------------------------------------- /pkg/version/platform_linux.go: -------------------------------------------------------------------------------- 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 version 18 | 19 | const platform = "linux" 20 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 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 version 18 | 19 | var ( 20 | GitVersion = "v0.0.1" 21 | GitCommit = "unknown" 22 | Platform = platform 23 | BuildTime = "unknown" 24 | ) 25 | --------------------------------------------------------------------------------