Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion internal/snapshot/cmd/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/rest"

dataio "github.com/deckhouse/deckhouse-cli/internal/data"
deapi "github.com/deckhouse/deckhouse-cli/internal/data/dataexport/api/v1alpha1"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/aggapi"
snapshotapi "github.com/deckhouse/deckhouse-cli/internal/snapshot/api/v1alpha1"
Expand All @@ -46,6 +47,7 @@ import (
"github.com/deckhouse/deckhouse-cli/internal/snapshot/progress"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/transport"
systemflags "github.com/deckhouse/deckhouse-cli/internal/system/flags"
safeClient "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client"
)

const (
Expand All @@ -61,6 +63,7 @@ const (
flagVolumeCompression = "volume-compression"
flagVolumeCompressionLevel = "volume-compression-level"
flagCleanup = "cleanup"
flagPublish = "publish"
)

// snapshotClientQPS/snapshotClientBurst raise the kube client's rate limiter
Expand All @@ -86,6 +89,22 @@ func NewCommand(log *slog.Logger) *cobra.Command {
Short: "Download a snapshot to a local directory tree",
SilenceUsage: true,
SilenceErrors: true,
Long: `Download a Snapshot CR's manifest tree and volume data into a local directory tree
(consumed by 'd8 snapshot upload' or 'd8 snapshot restore').

--publish selects how each data leaf's volume bytes are streamed from its DataExport
exporter pod. With --publish=false (or when autodetection picks it), bytes come straight
from the exporter's in-cluster service, trusting only its internal CA (status.ca). With
--publish=true, bytes come through the storage-foundation-published Ingress endpoint
(status.publicURL) instead, so a kubeconfig without direct network access to the cluster's
internal service network can still download. If --publish is not given, the command probes
whether the in-cluster exporter endpoint is reachable and picks accordingly. Reusing an
existing DataExport upgrades its spec.publish from false to true when --publish=true is
requested, but never downgrades it back to false, so a concurrent run streaming through the
public endpoint is never torn down. IMPORTANT: the publish path works only with a kubeconfig
authenticated by a bearer token. Ingress terminates TLS with its own certificate and does not
forward the client's TLS certificate to the exporter pod, so a certificate-based kubeconfig
receives a 401 when --publish=true.`,
Example: ` # Download snapshot "my-snap" from namespace "default" into directory ./out
d8 snapshot download my-snap -n default -o out

Expand All @@ -98,7 +117,10 @@ func NewCommand(log *slog.Logger) *cobra.Command {
d8 snapshot download my-snap -n default -o out --node DemoVirtualDisk/bk-disk-a

# Download only the root snapshot (equivalent to a full download)
d8 snapshot download my-snap -n default -o out --node Snapshot/my-snap`,
d8 snapshot download my-snap -n default -o out --node Snapshot/my-snap

# Download through the published Ingress endpoint (requires a bearer-token kubeconfig)
d8 snapshot download my-snap -n default -o out --publish=true`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return Run(cmd.Context(), log, cmd, args)
Expand All @@ -123,6 +145,9 @@ func NewCommand(log *slog.Logger) *cobra.Command {
cmd.Flags().Bool(flagCleanup, true,
"delete the per-volume DataExport (and its server-side export chain) after each volume completes; --cleanup=false leaves them in the cluster for debugging")

cmd.Flags().Bool(flagPublish, false, "download volume data through the published (ingress) exporter endpoint instead of the in-cluster service; "+
"if unset, the in-cluster endpoint's reachability is auto-detected")

return cmd
}

Expand Down Expand Up @@ -274,6 +299,21 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
return err
}

publishFlag, err := dataio.ParsePublishFlag(cmd.Flags())
if err != nil {
return fmt.Errorf("resolving --%s: %w", flagPublish, err)
}

// Probe from the command's already-resolved restConfig, not a fresh parse of
// --kubeconfig/--context: reparsing could target a different cluster than the one this
// command is actually downloading from.
probeClient := safeClient.NewSafeClientForConfig(restConfig)

publish, err := dataio.ResolvePublish(ctx, publishFlag, kubeClient, probeClient, log)
if err != nil {
return fmt.Errorf("resolving --%s: %w", flagPublish, err)
}

tty := term.IsTerminal(int(os.Stdout.Fd()))
// progress.New defaults to progress.DirectionDownload when WithDirection is
// omitted, so download intentionally relies on that default rather than
Expand Down Expand Up @@ -302,6 +342,7 @@ func Run(ctx context.Context, log *slog.Logger, cmd *cobra.Command, args []strin
PerVolumeConcurrency: perVolume,
MaxParallelDownloads: maxParallel,
TTL: ttl,
Publish: publish,
KeepExports: !cleanup,
Compression: codec,
KubeClient: kubeClient,
Expand Down
78 changes: 78 additions & 0 deletions internal/snapshot/cmd/download/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
"time"

"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/client-go/rest"

Expand Down Expand Up @@ -937,6 +939,82 @@ func TestRun_ReleasesLockOnCancelledContext(t *testing.T) {
defer func() { _ = fl.Unlock() }()
}

// TestNewCommand_PublishFlagDefault verifies --publish defaults to false, so
// autodetection (dataio.ResolvePublish) decides when the user does not opt in
// explicitly.
func TestNewCommand_PublishFlagDefault(t *testing.T) {
t.Parallel()

cmd := NewCommand(slog.Default())

got, err := cmd.Flags().GetBool(flagPublish)
require.NoError(t, err)
assert.False(t, got, "default --publish must be false")

flag := cmd.Flags().Lookup(flagPublish)
require.NotNil(t, flag, "flag --publish: not registered")
assert.False(t, flag.Changed, "--publish must not be marked Changed before the user sets it")
}

// TestNewCommand_PublishFlagExplicitlySet verifies --publish=true is parsed and
// reflected via cmd.Flags().GetBool, and that dataio.ParsePublishFlag observes it
// as explicitly set (Changed) rather than merely defaulted.
func TestNewCommand_PublishFlagExplicitlySet(t *testing.T) {
t.Parallel()

tests := []struct {
name string
args []string
want bool
}{
{name: "success: --publish=true is parsed", args: []string{"--publish=true"}, want: true},
{name: "success: --publish=false is parsed explicitly", args: []string{"--publish=false"}, want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

cmd := NewCommand(slog.Default())
require.NoError(t, cmd.Flags().Parse(tt.args))

got, err := cmd.Flags().GetBool(flagPublish)
require.NoError(t, err)
assert.Equal(t, tt.want, got)

flag := cmd.Flags().Lookup(flagPublish)
require.NotNil(t, flag)
assert.True(t, flag.Changed, "--publish must be marked Changed once explicitly set")
})
}
}

// TestNewCommand_PublishDocumentation verifies the --publish contract is
// actually documented in both the Long description and the Example block: the
// bearer-token-only caveat and an example invocation must both be present, since
// this is the only place a user learns why a certificate-based kubeconfig gets a
// 401 with --publish=true.
func TestNewCommand_PublishDocumentation(t *testing.T) {
t.Parallel()

cmd := NewCommand(slog.Default())

for _, want := range []string{
"--publish",
"bearer",
"401",
} {
assert.Contains(t, cmd.Long, want, "Long description must mention %q", want)
}

assert.Contains(t, cmd.Example, "--publish=true", "Example must show a --publish=true invocation")

flag := cmd.Flags().Lookup(flagPublish)
require.NotNil(t, flag, "flag --publish: not registered")
assert.NotEmpty(t, flag.Usage, "--publish usage text must not be empty")
assert.False(t, flag.Hidden, "--publish must be visible in help/completion")
}

func TestNewCommand_UsesExecutionContextInstalledAfterConstruction(t *testing.T) {
cancelCause := errors.New("cancel download after command construction")
ctx, cancel := context.WithCancelCause(context.Background())
Expand Down
48 changes: 45 additions & 3 deletions internal/snapshot/cmd/snapimport/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ import (
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"

dataio "github.com/deckhouse/deckhouse-cli/internal/data"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/aggapi"
snapshotapi "github.com/deckhouse/deckhouse-cli/internal/snapshot/api/v1alpha1"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/progress"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/snapimport"
"github.com/deckhouse/deckhouse-cli/internal/snapshot/transport"
systemflags "github.com/deckhouse/deckhouse-cli/internal/system/flags"
safeClient "github.com/deckhouse/deckhouse-cli/pkg/libsaferequest/client"
)

const (
Expand All @@ -55,6 +57,7 @@ const (
flagAllowExisting = "allow-existing"
flagAllowUnauthenticatedLegacy = "allow-unauthenticated-legacy"
flagSkipUnsupportedFSEntries = "skip-unsupported-fs-entries"
flagPublish = "publish"

defaultImportWorkers = 5

Expand Down Expand Up @@ -133,15 +136,29 @@ Scope and limitations:
downgraded and tampered current archive.
- Uploading requires RBAC to create DataImport (storage-volume-data-manager) and to call
the manifests-and-children-refs-upload subresource (e.g. an admin kubeconfig); the
read-only snapshot admin role is not sufficient.`,
read-only snapshot admin role is not sufficient.

--publish selects how each data leaf's bytes are streamed to its DataImport importer pod.
With --publish=false (or when autodetection picks it), bytes go straight to the importer's
in-cluster service, trusting only its internal CA (status.ca). With --publish=true, bytes go
through the storage-foundation-published Ingress endpoint (status.publicURL) instead, so a
kubeconfig without direct network access to the cluster's internal service network can still
upload. If --publish is not given, the command probes whether the in-cluster importer endpoint
is reachable and picks accordingly. IMPORTANT: the publish path works only with a kubeconfig
authenticated by a bearer token. Ingress terminates TLS with its own certificate and does not
forward the client's TLS certificate to the importer pod, so a certificate-based kubeconfig
receives a 401 when --publish=true.`,
Example: ` # Upload the archive in ./out into namespace "restored"
d8 snapshot upload -n restored -i ./out

# Upload only a single VolumeSnapshot data leaf and its subtree
d8 snapshot upload -n restored -i ./out --node VolumeSnapshot/pvc-1

# Upload with a longer DataImport TTL and overall timeout
d8 snapshot upload -n restored -i ./out --ttl 4h --timeout 30m`,
d8 snapshot upload -n restored -i ./out --ttl 4h --timeout 30m

# Upload through the published Ingress endpoint (requires a bearer-token kubeconfig)
d8 snapshot upload -n restored -i ./out --publish=true`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return Run(log, cmd, args)
Expand All @@ -159,6 +176,8 @@ Scope and limitations:
cmd.Flags().Bool(flagAllowExisting, false, "downgrade namespace preflight conflict check to a warning (import-mode markers from a prior run are never conflicts regardless of this flag)")
cmd.Flags().Bool(flagAllowUnauthenticatedLegacy, false, "allow trusted pre-version archives whose snapshot.yaml metadata is unauthenticated (unsafe; explicit compatibility mode)")
cmd.Flags().Bool(flagSkipUnsupportedFSEntries, false, "skip unsupported filesystem entries and report them after upload (causes data loss for skipped paths)")
cmd.Flags().Bool(flagPublish, false, "upload volume data through the published (ingress) importer endpoint instead of the in-cluster service; "+
"if unset, the in-cluster endpoint's reachability is auto-detected")

return cmd
}
Expand Down Expand Up @@ -279,6 +298,21 @@ func Run(log *slog.Logger, cmd *cobra.Command, _ []string) error {

dataPlaneClient := transport.NewClientForConfig(restConfig)

publishFlag, err := dataio.ParsePublishFlag(cmd.Flags())
if err != nil {
return fmt.Errorf("resolving --%s: %w", flagPublish, err)
}

// Probe from the command's already-resolved restConfig, not a fresh parse of
// --kubeconfig/--context: reparsing could target a different cluster than the one this
// command is actually uploading into.
probeClient := safeClient.NewSafeClientForConfig(restConfig)

publish, err := dataio.ResolvePublish(ctx, publishFlag, kubeClient, probeClient, log)
if err != nil {
return fmt.Errorf("resolving --%s: %w", flagPublish, err)
}

isTTY := term.IsTerminal(int(os.Stdout.Fd()))

// Upload shows Upload/Uploading/DataImport wording instead of progress.New's
Expand All @@ -298,7 +332,15 @@ func Run(log *slog.Logger, cmd *cobra.Command, _ []string) error {
runLog = slog.New(slog.NewTextHandler(sink.LogWriter(), &slog.HandlerOptions{Level: slog.LevelWarn}))
}

volumes := snapimport.NewClusterVolumeImporter(dynClient, dataPlaneClient, ttl, timeout, 3*time.Second, runLog)
volumes := snapimport.NewClusterVolumeImporter(snapimport.ClusterVolumeImporterOptions{
Dynamic: dynClient,
Transport: dataPlaneClient,
TTL: ttl,
Publish: publish,
Wait: timeout,
Poll: 3 * time.Second,
Log: runLog,
})

cfg := snapimport.Config{
Namespace: namespace,
Expand Down
Loading
Loading