Skip to content

Commit f5b0edc

Browse files
authored
[data] Add support for VirtualDisk and VirtualDiskSnapshot targets in data-export commands (e.g. vd/<name>, vds/<name>) (#148)
Signed-off-by: Aleksandr Zimin <[email protected]>
1 parent 5612294 commit f5b0edc

File tree

8 files changed

+529
-30
lines changed

8 files changed

+529
-30
lines changed

internal/dataexport/cmd/create/create.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func NewCommand(ctx context.Context, log *slog.Logger) *cobra.Command {
5959
}
6060

6161
cmd.Flags().StringP("namespace", "n", "d8-data-exporter", "data volume namespace")
62-
cmd.Flags().String("ttl", "30m", "Time to live")
62+
cmd.Flags().String("ttl", "2m", "Time to live")
6363
cmd.Flags().Bool("publish", false, "Provide access outside of cluster")
6464

6565
return cmd
@@ -76,14 +76,18 @@ func parseArgs(args []string) (deName, volumeKind, volumeName string, err error)
7676
err = fmt.Errorf("invalid volume format, expect: <type>/<name>")
7777
return
7878
}
79-
volumeKind, volumeName = resourceTypeAndName[0], resourceTypeAndName[1]
79+
volumeKind, volumeName = strings.ToLower(resourceTypeAndName[0]), resourceTypeAndName[1]
8080
switch volumeKind {
81-
case "pvc", "PVC":
81+
case "pvc", "persistentvolumeclaim":
8282
volumeKind = util.PersistentVolumeClaimKind
83-
case "vs", "VS":
83+
case "vs", "volumesnapshot":
8484
volumeKind = util.VolumeSnapshotKind
85+
case "vd", "virtualdisk":
86+
volumeKind = util.VirtualDiskKind
87+
case "vds", "virtualdisksnapshot":
88+
volumeKind = util.VirtualDiskSnapshotKind
8589
default:
86-
err = fmt.Errorf("invalid volume type, expect: 'pvc' or 'vs'")
90+
err = fmt.Errorf("invalid volume type; valid values: pvc | persistentvolumeclaim | vs | volumesnapshot | vd | virtualdisk | vds | virtualdisksnapshot")
8791
return
8892
}
8993

internal/dataexport/cmd/download/download.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,16 @@ const (
4343

4444
func cmdExamples() string {
4545
resp := []string{
46-
fmt.Sprintf(" # Start exporter + Download + Stop for Filesystem"),
46+
" # Start exporter + Download + Stop for Filesystem",
4747
fmt.Sprintf(" ... %s [flags] kind/volume_name path/file.ext [-o out_file.ext]", cmdName),
48-
fmt.Sprintf(" ... %s -n target-namespace PVC/my-file-volume mydir/testdir/file.txt -o file.txt", cmdName),
49-
fmt.Sprintf(" # Start exporter + Download + Stop for Block"),
48+
fmt.Sprintf(" ... %s -n target-namespace pvc/my-file-volume mydir/testdir/file.txt -o file.txt", cmdName),
49+
" # Start exporter + Download + Stop for Block",
5050
fmt.Sprintf(" ... %s [flags] kind/volume_name [-o out_file.ext]", cmdName),
51-
fmt.Sprintf(" ... %s -n target-namespace VS/my-vs-volume -o file.txt", cmdName),
51+
fmt.Sprintf(" ... %s -n target-namespace vs/my-vs-volume -o file.txt", cmdName),
52+
" # Start exporter + Download + Stop for VirtualDisk (Block)",
53+
fmt.Sprintf(" ... %s -n target-namespace vd/my-virtualdisk -o file.img", cmdName),
54+
" # Start exporter + Download + Stop for VirtualDiskSnapshot (Block)",
55+
fmt.Sprintf(" ... %s -n target-namespace vds/my-virtualdisk-snapshot -o file.img", cmdName),
5256
}
5357
return strings.Join(resp, "\n")
5458
}
@@ -70,6 +74,7 @@ func NewCommand(ctx context.Context, log *slog.Logger) *cobra.Command {
7074
cmd.Flags().StringP("namespace", "n", "d8-data-exporter", "data volume namespace")
7175
cmd.Flags().StringP("output", "o", "", "file to save data (default: same as resource)") //TODO support /dev/stdout
7276
cmd.Flags().Bool("publish", false, "Provide access outside of cluster")
77+
cmd.Flags().String("ttl", "2m", "Time to live for auto-created DataExport")
7378

7479
return cmd
7580
}
@@ -240,9 +245,11 @@ func recursiveDownload(ctx context.Context, sClient *safeClient.SafeClient, log
240245
}
241246

242247
func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []string) error {
248+
243249
namespace, _ := cmd.Flags().GetString("namespace")
244250
dstPath, _ := cmd.Flags().GetString("output")
245251
publish, _ := cmd.Flags().GetBool("publish")
252+
ttl, _ := cmd.Flags().GetString("ttl")
246253

247254
dataName, srcPath, err := parseArgs(args)
248255
if err != nil {
@@ -260,14 +267,14 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
260267
return err
261268
}
262269

263-
deName, err := util.CreateDataExporterIfNeeded(ctx, log, dataName, namespace, publish, rtClient)
270+
deName, err := util.CreateDataExporterIfNeededFunc(ctx, log, dataName, namespace, publish, ttl, rtClient)
264271
if err != nil {
265272
return err
266273
}
267274

268275
log.Info("DataExport created", slog.String("name", deName), slog.String("namespace", namespace))
269276

270-
url, volumeMode, subClient, err := util.PrepareDownload(ctx, log, deName, namespace, publish, sClient)
277+
url, volumeMode, subClient, err := util.PrepareDownloadFunc(ctx, log, deName, namespace, publish, sClient)
271278
if err != nil {
272279
return err
273280
}
@@ -287,7 +294,7 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
287294
dstPath = deName
288295
}
289296
default:
290-
return fmt.Errorf("%w: %s", util.UnsupportedVolumeModeErr, volumeMode)
297+
return fmt.Errorf("%w: %s", util.ErrUnsupportedVolumeMode, volumeMode)
291298
}
292299

293300
log.Info("Start downloading", slog.String("url", url+srcPath), slog.String("dstPath", dstPath))
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package download
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
"testing"
12+
13+
"log/slog"
14+
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/deckhouse/deckhouse-cli/internal/dataexport/util"
18+
safereq "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client"
19+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
20+
)
21+
22+
// helper to create SafeClient with empty rest.Config (no auth)
23+
func newNoAuthSafe() *safereq.SafeClient {
24+
// Ensure that SafeClient allows unauthenticated HTTP requests during unit tests.
25+
safereq.SupportNoAuth = true
26+
sc, _ := safereq.NewSafeClient()
27+
return sc.Copy()
28+
}
29+
30+
func TestDownloadFilesystem_OK(t *testing.T) {
31+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
require.Equal(t, "/api/v1/files/foo.txt", r.URL.Path)
33+
w.Header().Set("X-Type", "file")
34+
w.Header().Set("Content-Length", "3")
35+
w.WriteHeader(200)
36+
w.Write([]byte("abc"))
37+
}))
38+
defer srv.Close()
39+
40+
// stub PrepareDownload / CreateDataExporterIfNeeded
41+
origPrep := util.PrepareDownloadFunc
42+
origCreate := util.CreateDataExporterIfNeededFunc
43+
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
44+
return srv.URL + "/api/v1/files", "Filesystem", newNoAuthSafe(), nil
45+
}
46+
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
47+
return de, nil
48+
}
49+
defer func() {
50+
util.PrepareDownloadFunc = origPrep
51+
util.CreateDataExporterIfNeededFunc = origCreate
52+
}()
53+
54+
outFile := filepath.Join(t.TempDir(), "out.txt")
55+
56+
cmd := NewCommand(context.TODO(), slog.Default())
57+
cmd.SetArgs([]string{"myexport", "foo.txt", "-o", outFile})
58+
var buf bytes.Buffer
59+
cmd.SetOut(&buf)
60+
cmd.SetErr(&buf)
61+
62+
require.NoError(t, cmd.Execute())
63+
64+
data, err := os.ReadFile(outFile)
65+
require.NoError(t, err)
66+
require.Equal(t, []byte("abc"), data)
67+
}
68+
69+
func TestDownloadFilesystem_BadPath(t *testing.T) {
70+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
// Simulate Block-mode error when files endpoint is used
72+
http.Error(w, "VolumeMode: Block. Not supported downloading files.", http.StatusBadRequest)
73+
}))
74+
defer srv.Close()
75+
76+
origPrep := util.PrepareDownloadFunc
77+
origCreate := util.CreateDataExporterIfNeededFunc
78+
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
79+
return srv.URL + "/api/v1/files", "Block", newNoAuthSafe(), nil
80+
}
81+
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
82+
return de, nil
83+
}
84+
defer func() { util.PrepareDownloadFunc = origPrep; util.CreateDataExporterIfNeededFunc = origCreate }()
85+
86+
cmd := NewCommand(context.TODO(), slog.Default())
87+
cmd.SetArgs([]string{"myexport", "foo.txt", "-o", filepath.Join(t.TempDir(), "out.txt")})
88+
require.NoError(t, cmd.Execute())
89+
}
90+
91+
func TestDownloadBlock_OK(t *testing.T) {
92+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
require.Equal(t, "/api/v1/block", r.URL.Path)
94+
w.Header().Set("Content-Length", "4")
95+
w.WriteHeader(200)
96+
w.Write([]byte("raw!"))
97+
}))
98+
defer srv.Close()
99+
100+
origPrep := util.PrepareDownloadFunc
101+
origCreate := util.CreateDataExporterIfNeededFunc
102+
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
103+
return srv.URL + "/api/v1/block", "Block", newNoAuthSafe(), nil
104+
}
105+
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
106+
return de, nil
107+
}
108+
defer func() {
109+
util.PrepareDownloadFunc = origPrep
110+
util.CreateDataExporterIfNeededFunc = origCreate
111+
}()
112+
113+
outFile := filepath.Join(t.TempDir(), "raw.img")
114+
cmd := NewCommand(context.TODO(), slog.Default())
115+
cmd.SetArgs([]string{"myexport", "-o", outFile})
116+
cmd.SetOut(io.Discard)
117+
cmd.SetErr(io.Discard)
118+
require.NoError(t, cmd.Execute())
119+
data, err := os.ReadFile(outFile)
120+
require.NoError(t, err)
121+
require.Equal(t, []byte("raw!"), data)
122+
}
123+
124+
func TestDownloadBlock_WrongEndpoint(t *testing.T) {
125+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
http.Error(w, "VolumeMode: Filesystem. Not supported downloading raw block.", http.StatusBadRequest)
127+
}))
128+
defer srv.Close()
129+
130+
origPrep := util.PrepareDownloadFunc
131+
origCreate := util.CreateDataExporterIfNeededFunc
132+
util.PrepareDownloadFunc = func(_ context.Context, _ *slog.Logger, _, _ string, _ bool, _ *safereq.SafeClient) (string, string, *safereq.SafeClient, error) {
133+
return srv.URL + "/api/v1/block", "Filesystem", newNoAuthSafe(), nil
134+
}
135+
util.CreateDataExporterIfNeededFunc = func(_ context.Context, _ *slog.Logger, de, _ string, _ bool, _ string, _ ctrlclient.Client) (string, error) {
136+
return de, nil
137+
}
138+
defer func() { util.PrepareDownloadFunc = origPrep; util.CreateDataExporterIfNeededFunc = origCreate }()
139+
140+
cmd := NewCommand(context.TODO(), slog.Default())
141+
cmd.SetArgs([]string{"myexport", "-o", filepath.Join(t.TempDir(), "raw.img")})
142+
cmd.SetOut(io.Discard)
143+
cmd.SetErr(io.Discard)
144+
require.NoError(t, cmd.Execute())
145+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package download
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestParseArgs(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input []string
13+
wantDe string
14+
wantPath string
15+
wantError bool
16+
}{
17+
{
18+
name: "name only",
19+
input: []string{"my-export"},
20+
wantDe: "my-export",
21+
wantPath: "/",
22+
},
23+
{
24+
name: "name and path",
25+
input: []string{"vd/mydisk", "file.txt"},
26+
wantDe: "vd/mydisk",
27+
wantPath: "/file.txt",
28+
},
29+
{
30+
name: "too many args",
31+
input: []string{"a", "b", "c"},
32+
wantError: true,
33+
},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
de, path, err := parseArgs(tt.input)
39+
if tt.wantError {
40+
require.Error(t, err)
41+
return
42+
}
43+
require.NoError(t, err)
44+
require.Equal(t, tt.wantDe, de)
45+
require.Equal(t, tt.wantPath, path)
46+
})
47+
}
48+
}

internal/dataexport/cmd/list/list.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
neturl "net/url"
2626
"os"
27+
"strconv"
2728
"strings"
2829
"time"
2930

@@ -32,6 +33,7 @@ import (
3233
"github.com/deckhouse/deckhouse-cli/internal/dataexport/api/v1alpha1"
3334
"github.com/deckhouse/deckhouse-cli/internal/dataexport/util"
3435
safeClient "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client"
36+
"k8s.io/apimachinery/pkg/api/resource"
3537
)
3638

3739
const (
@@ -63,6 +65,7 @@ func NewCommand(ctx context.Context, log *slog.Logger) *cobra.Command {
6365

6466
cmd.Flags().StringP("namespace", "n", "d8-data-exporter", "data volume namespace")
6567
cmd.Flags().Bool("publish", false, "Provide access outside of cluster")
68+
cmd.Flags().String("ttl", "2m", "Time to live for auto-created DataExport")
6669

6770
return cmd
6871
}
@@ -89,7 +92,7 @@ func downloadFunc(
8992
sClient *safeClient.SafeClient,
9093
foo func(body io.Reader) error,
9194
) error {
92-
url, volumeMode, subClient, err := util.PrepareDownload(ctx, log, deName, namespace, publish, sClient)
95+
url, volumeMode, subClient, err := util.PrepareDownloadFunc(ctx, log, deName, namespace, publish, sClient)
9396
if err != nil {
9497
return err
9598
}
@@ -111,7 +114,7 @@ func downloadFunc(
111114
log.Info("Start listing", slog.String("url", url))
112115
req, _ = http.NewRequest("HEAD", url, nil)
113116
default:
114-
return fmt.Errorf("%w: %s", util.UnsupportedVolumeModeErr, volumeMode)
117+
return fmt.Errorf("%w: %s", util.ErrUnsupportedVolumeMode, volumeMode)
115118
}
116119

117120
resp, err := subClient.HttpDo(req.WithContext(ctx))
@@ -133,13 +136,22 @@ func downloadFunc(
133136
case "Block":
134137
body := ""
135138
if contLen := resp.Header.Get("Content-Length"); contLen != "" {
136-
body = fmt.Sprintf("Content-Length: %s", contLen)
139+
// Convert raw bytes value to human-readable size using k8s quantity library.
140+
// We deliberately ignore conversion errors and fallback to raw bytes if any.
141+
if size, err := strconv.ParseInt(contLen, 10, 64); err == nil {
142+
q := resource.NewQuantity(size, resource.BinarySI)
143+
body = fmt.Sprintf("Disk size: %s", q.String())
144+
} else {
145+
body = fmt.Sprintf("Disk size: %s bytes", contLen)
146+
}
147+
// Ensure the size information is printed on a dedicated line for better readability.
148+
body += "\n"
137149
}
138150
return foo(strings.NewReader(body))
139151
case "Filesystem":
140152
return foo(resp.Body)
141153
default:
142-
return fmt.Errorf("%w: %s", util.UnsupportedVolumeModeErr, volumeMode)
154+
return fmt.Errorf("%w: %s", util.ErrUnsupportedVolumeMode, volumeMode)
143155
}
144156
}
145157

@@ -149,6 +161,7 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
149161

150162
namespace, _ := cmd.Flags().GetString("namespace")
151163
publish, _ := cmd.Flags().GetBool("publish")
164+
ttl, _ := cmd.Flags().GetString("ttl")
152165

153166
dataName, srcPath, err := parseArgs(args)
154167
if err != nil {
@@ -166,7 +179,7 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
166179
if err != nil {
167180
return err
168181
}
169-
deName, err := util.CreateDataExporterIfNeeded(ctx, log, dataName, namespace, publish, rtClient)
182+
deName, err := util.CreateDataExporterIfNeededFunc(ctx, log, dataName, namespace, publish, ttl, rtClient)
170183
if err != nil {
171184
return err
172185
}

0 commit comments

Comments
 (0)