Skip to content

Commit 365125e

Browse files
committed
feat: add image export command
Optimize package listing to use Secret metadata without decompressing package payloads.
1 parent 14f7a63 commit 365125e

11 files changed

Lines changed: 1743 additions & 10 deletions

File tree

‎internal/app/root.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
deletecmd "github.com/harmonycloud/saola-cli/internal/cmd/delete"
2626
describecmd "github.com/harmonycloud/saola-cli/internal/cmd/describe"
2727
getcmd "github.com/harmonycloud/saola-cli/internal/cmd/get"
28+
imagescmd "github.com/harmonycloud/saola-cli/internal/cmd/images"
2829
inspectcmd "github.com/harmonycloud/saola-cli/internal/cmd/inspect"
2930
installcmd "github.com/harmonycloud/saola-cli/internal/cmd/install"
3031
runcmd "github.com/harmonycloud/saola-cli/internal/cmd/run"
@@ -82,6 +83,7 @@ func NewRootCmd() *cobra.Command {
8283
upgradecmd.NewCmdUpgrade(cfg),
8384
buildcmd.NewCmdBuild(cfg),
8485
inspectcmd.NewCmdInspect(cfg),
86+
imagescmd.NewCmdImages(cfg),
8587
versioncmd.NewCmdVersion(cfg),
8688
)
8789

‎internal/cmd/images/cmd.go‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
Copyright 2025 The OpenSaola 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 images
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"io"
23+
"os"
24+
"strings"
25+
"time"
26+
27+
imageexport "github.com/harmonycloud/saola-cli/internal/images"
28+
29+
"github.com/harmonycloud/saola-cli/internal/config"
30+
"github.com/harmonycloud/saola-cli/internal/lang"
31+
"github.com/spf13/cobra"
32+
)
33+
34+
// ExportOptions holds parameters for "images export".
35+
//
36+
// ExportOptions 保存 "images export" 命令参数。
37+
type ExportOptions struct {
38+
Config *config.Config
39+
PkgDir string
40+
Output string
41+
LockFile string
42+
Repositories []string
43+
Platform string
44+
MultiArch bool
45+
Insecure bool
46+
SkipMissing bool
47+
DryRun bool
48+
Timeout time.Duration
49+
Runner imageexport.Runner
50+
Out io.Writer
51+
ErrOut io.Writer
52+
}
53+
54+
// NewCmdImages returns the "images" command group.
55+
//
56+
// NewCmdImages 返回 images 命令组。
57+
func NewCmdImages(cfg *config.Config) *cobra.Command {
58+
cmd := &cobra.Command{
59+
Use: "images",
60+
Short: lang.T("发现并导出包依赖的容器镜像", "Discover and export package container images"),
61+
Long: lang.T(
62+
`发现本地中间件包中的镜像引用,并从一个或多个候选仓库中导出实际存在的镜像。`,
63+
`Discover image references in a local middleware package and export existing images from one or more candidate repositories.`,
64+
),
65+
}
66+
cmd.AddCommand(NewCmdExport(cfg))
67+
return cmd
68+
}
69+
70+
// NewCmdExport returns the "images export" command.
71+
//
72+
// NewCmdExport 返回 images export 子命令。
73+
func NewCmdExport(cfg *config.Config) *cobra.Command {
74+
o := &ExportOptions{
75+
Config: cfg,
76+
Platform: "all",
77+
MultiArch: true,
78+
Timeout: 30 * time.Second,
79+
Out: os.Stdout,
80+
ErrOut: os.Stderr,
81+
}
82+
83+
cmd := &cobra.Command{
84+
Use: "export <pkg-dir>",
85+
Short: lang.T("导出包依赖的容器镜像", "Export package container images"),
86+
Long: lang.T(
87+
`扫描本地中间件包中的镜像引用,按 --repository 的声明顺序探测镜像是否存在,并把命中的镜像导出为归档文件。`,
88+
`Scan image references in a local middleware package, probe repositories in --repository order, and export the resolved images as an archive.`,
89+
),
90+
Example: ` saola images export ./redis -r 10.10.101.172:443/middleware -r 10.10.102.124:443/middleware
91+
saola images export ./redis -r 10.10.101.172:443/middleware,10.10.102.124:443/middleware -o redis-images.tar
92+
saola images export ./redis -r 10.10.101.172:443/middleware --dry-run`,
93+
Args: cobra.ExactArgs(1),
94+
RunE: func(cmd *cobra.Command, args []string) error {
95+
o.PkgDir = args[0]
96+
o.Out = cmd.OutOrStdout()
97+
o.ErrOut = cmd.ErrOrStderr()
98+
return o.Run(cmd.Context())
99+
},
100+
}
101+
102+
cmd.Flags().StringArrayVarP(&o.Repositories, "repository", "r", nil, lang.T("候选镜像仓库,可重复声明或用逗号分隔", "Candidate image repository; repeat or comma-separate values"))
103+
cmd.Flags().StringVarP(&o.Output, "output", "o", "", lang.T("输出镜像归档路径(默认:<name>-<version>-images.tar)", "Output image archive path (default: <name>-<version>-images.tar)"))
104+
cmd.Flags().StringVar(&o.LockFile, "lock-file", "", lang.T("输出镜像锁定清单路径(默认:<output>.lock.json)", "Output image lock file path (default: <output>.lock.json)"))
105+
cmd.Flags().StringVar(&o.Platform, "platform", "all", lang.T("导出平台,例如 linux/amd64;all 表示保留多架构", "Export platform, for example linux/amd64; all keeps multi-arch images"))
106+
cmd.Flags().BoolVar(&o.MultiArch, "multi-arch", true, lang.T("使用 skopeo 导出时保留多架构清单", "Keep multi-arch manifests when exporting with skopeo"))
107+
cmd.Flags().BoolVar(&o.Insecure, "insecure", false, lang.T("跳过镜像仓库 TLS 校验", "Skip registry TLS verification"))
108+
cmd.Flags().BoolVar(&o.SkipMissing, "skip-missing", false, lang.T("存在无法解析的镜像时仍导出已命中的镜像", "Export resolved images even when some images are missing"))
109+
cmd.Flags().BoolVar(&o.DryRun, "dry-run", false, lang.T("仅打印镜像候选,不执行导出", "Print image candidates without exporting"))
110+
cmd.Flags().DurationVar(&o.Timeout, "timeout", 30*time.Second, lang.T("单个镜像探测或导出的超时时间", "Timeout for each image probe or export"))
111+
return cmd
112+
}
113+
114+
// Run executes image export.
115+
//
116+
// Run 执行镜像导出。
117+
func (o *ExportOptions) Run(ctx context.Context) error {
118+
out := o.Out
119+
if out == nil {
120+
out = os.Stdout
121+
}
122+
errOut := o.ErrOut
123+
if errOut == nil {
124+
errOut = os.Stderr
125+
}
126+
127+
result, err := imageexport.ExportPackage(ctx, imageexport.ExportOptions{
128+
PkgDir: o.PkgDir,
129+
Output: o.Output,
130+
LockFile: o.LockFile,
131+
Repositories: o.Repositories,
132+
Platform: o.Platform,
133+
MultiArch: o.MultiArch,
134+
Insecure: o.Insecure,
135+
SkipMissing: o.SkipMissing,
136+
DryRun: o.DryRun,
137+
Timeout: o.Timeout,
138+
Runner: o.Runner,
139+
})
140+
if err != nil {
141+
return err
142+
}
143+
144+
if o.DryRun {
145+
printDryRun(out, result)
146+
return nil
147+
}
148+
149+
fmt.Fprintf(out, "Exported %d images for %s@%s -> %s\n", len(result.Resolved), result.Metadata.Name, result.Metadata.Version, result.Output)
150+
fmt.Fprintf(out, "Wrote lock file -> %s\n", result.LockPath)
151+
if len(result.Missing) > 0 {
152+
fmt.Fprintf(errOut, "Skipped %d missing images because --skip-missing was set\n", len(result.Missing))
153+
}
154+
return nil
155+
}
156+
157+
func printDryRun(out io.Writer, result *imageexport.ExportResult) {
158+
fmt.Fprintf(out, "Found %d image groups for %s@%s\n", len(result.Groups), result.Metadata.Name, result.Metadata.Version)
159+
for _, group := range result.Groups {
160+
fmt.Fprintf(out, "- %s\n", group.Name)
161+
for _, candidate := range group.Candidates {
162+
parts := []string{candidate.Image}
163+
if candidate.Version != "" {
164+
parts = append(parts, "version="+candidate.Version)
165+
}
166+
if candidate.File != "" {
167+
parts = append(parts, "source="+candidate.File)
168+
}
169+
if candidate.Field != "" {
170+
parts = append(parts, "field="+candidate.Field)
171+
}
172+
fmt.Fprintf(out, " - %s\n", strings.Join(parts, " "))
173+
}
174+
}
175+
}

‎internal/cmd/images/cmd_test.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
Copyright 2025 The OpenSaola 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 images
18+
19+
import (
20+
"bytes"
21+
"os"
22+
"path/filepath"
23+
"strings"
24+
"testing"
25+
26+
"github.com/harmonycloud/saola-cli/internal/config"
27+
)
28+
29+
func TestNewCmdExport_DryRun(t *testing.T) {
30+
t.Parallel()
31+
dir := makeCmdImagePackage(t)
32+
cmd := NewCmdExport(&config.Config{})
33+
out := &bytes.Buffer{}
34+
errOut := &bytes.Buffer{}
35+
cmd.SetOut(out)
36+
cmd.SetErr(errOut)
37+
cmd.SetArgs([]string{
38+
dir,
39+
"-r", "repo-a.example:5000/middleware,repo-b.example:5000/middleware",
40+
"--dry-run",
41+
})
42+
43+
if err := cmd.Execute(); err != nil {
44+
t.Fatalf("Execute: %v", err)
45+
}
46+
got := out.String()
47+
for _, want := range []string{
48+
"Found",
49+
"repo-a.example:5000/middleware/redis-cli-port:v8.2.6-1.0.0-redis",
50+
"repo-b.example:5000/middleware/redis-cli-port:v8.2.6-1.0.0-redis",
51+
} {
52+
if !strings.Contains(got, want) {
53+
t.Fatalf("expected output to contain %q, got:\n%s", want, got)
54+
}
55+
}
56+
}
57+
58+
func TestNewCmdImages_HasExport(t *testing.T) {
59+
t.Parallel()
60+
cmd := NewCmdImages(&config.Config{})
61+
if _, _, err := cmd.Find([]string{"export"}); err != nil {
62+
t.Fatalf("expected export command: %v", err)
63+
}
64+
}
65+
66+
func makeCmdImagePackage(t *testing.T) string {
67+
t.Helper()
68+
dir := t.TempDir()
69+
writeCmdFile(t, filepath.Join(dir, "metadata.yaml"), `name: Redis
70+
version: 2.20.1-1.0.1
71+
app:
72+
version:
73+
- "8.2.6"
74+
`)
75+
writeCmdFile(t, filepath.Join(dir, "baseline.yaml"), `apiVersion: middleware.harmonycloud.cn/v1
76+
kind: MiddlewareBaseline
77+
spec:
78+
necessary:
79+
repository: ""
80+
version: '{"type":"version","default":"8.2.6"}'
81+
parameters:
82+
pod:
83+
middlewareImage: '"{{- $version := .Necessary.version -}}{{- $image := dict -}}{{- $_ := set $image "redisImageTag_8_2_6" "v8.2.6-1.0.0-redis" -}}{{- $versionUnderscore := replace "." "_" $version -}}{{- $imageTagKey := printf "redisImageTag_%s" $versionUnderscore -}}{{- printf "redis-cli-port:%s" (index $image $imageTagKey) -}}"'
84+
`)
85+
return dir
86+
}
87+
88+
func writeCmdFile(t *testing.T, path, data string) {
89+
t.Helper()
90+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
91+
t.Fatalf("MkdirAll: %v", err)
92+
}
93+
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
94+
t.Fatalf("WriteFile: %v", err)
95+
}
96+
}

0 commit comments

Comments
 (0)