Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
k8s.io/api v0.31.1
k8s.io/apimachinery v0.31.1
k8s.io/client-go v0.31.1
k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3
)

require (
Expand Down Expand Up @@ -170,7 +171,6 @@ require (
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
k8s.io/kubectl v0.31.0 // indirect
k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/kustomize/api v0.17.2 // indirect
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/weka/gohomecli/internal/cli/api"
"github.com/weka/gohomecli/internal/cli/config"
"github.com/weka/gohomecli/internal/cli/local/remote"
"github.com/weka/gohomecli/internal/env"
)

Expand All @@ -28,7 +29,8 @@ var appCmd = &cobra.Command{

env.InitEnv()

if cmdHasGroup(cmd, api.APIGroup.ID, config.ConfigGroup.ID) {
if cmdHasGroup(cmd, api.APIGroup.ID, config.ConfigGroup.ID, remote.RemoteAccessGroup.ID) {
env.SkipAPIKeyValidation = cmdHasGroup(cmd, remote.RemoteAccessGroup.ID)
env.InitConfig(env.SiteName)
}

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import (
"github.com/weka/gohomecli/internal/cli/app"
"github.com/weka/gohomecli/internal/cli/config"
"github.com/weka/gohomecli/internal/cli/local"
"github.com/weka/gohomecli/internal/cli/local/remote"
"github.com/weka/gohomecli/internal/utils"
)

func init() {
api.Cli.InitCobra(app.Cmd())
config.Cli.InitCobra(app.Cmd())
local.Cli.InitCobra(app.Cmd())
remote.Cli.InitCobra(app.Cmd())
}

// Execute adds all child commands to the root command and sets flags appropriately.
Expand Down
1 change: 1 addition & 0 deletions internal/cli/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func init() {
setup.Cli.InitCobra(localCmd)
upgrade.Cli.InitCobra(localCmd)
cleanup.Cli.InitCobra(localCmd)

statusCli := status.CliHook()
statusCli.InitCobra(localCmd)
})
Expand Down
203 changes: 203 additions & 0 deletions internal/cli/local/remote/copy_recording.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package remote

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"

"github.com/google/uuid"
"github.com/spf13/cobra"

"github.com/weka/gohomecli/internal/local/chart"
"github.com/weka/gohomecli/internal/utils"
)

const outputDirPerms = 0o750

var (
// safeFilenamePattern allows safe characters for recording filenames
safeFilenamePattern = regexp.MustCompile(`^[a-zA-Z0-9_\-:.]+\.cast$`)

// ErrMissingCopyFilter is returned when no filter is specified for copy-recording command
ErrMissingCopyFilter = errors.New("must specify one of: --recording, --cluster-id, or --all")

// ErrInvalidFilename is returned when a recording filename contains unsafe characters
ErrInvalidFilename = errors.New("invalid recording filename: must be a .cast file with safe characters")

// ErrRecordingNotFound is returned when a specific recording cannot be found
ErrRecordingNotFound = errors.New("recording not found")
)

type copyRecordingOptions struct {
recording string
clusterID string
output string
all bool
}

func newCopyRecordingCmd() *cobra.Command {
opts := &copyRecordingOptions{}

cmd := &cobra.Command{
Use: "copy-recording",
Short: "Copy recording files from PVC to local filesystem",
Long: `Copy session recording files from the recordings PVC to a local directory.

Recordings can be copied individually or in bulk by cluster ID or all at once.

Examples:
homecli remote-access copy-recording --recording "2024-01-15T10:30:00-abc123.cast" --output /tmp/
homecli remote-access copy-recording --cluster-id "550e8400-e29b-41d4-a716-446655440000" --output /tmp/
homecli remote-access copy-recording --all --output /tmp/
`,
RunE: func(cmd *cobra.Command, _ []string) error {
return copyRecordingRun(cmd, opts)
},
PreRunE: func(_ *cobra.Command, _ []string) error {
if opts.recording == "" && opts.clusterID == "" && !opts.all {
return ErrMissingCopyFilter
}

// Validate recording filename to prevent path traversal
if opts.recording != "" && !safeFilenamePattern.MatchString(opts.recording) {
return ErrInvalidFilename
}

// Validate cluster ID is a valid UUID
if opts.clusterID != "" {
if _, err := uuid.Parse(opts.clusterID); err != nil {
return ErrInvalidClusterID
}
}

return nil
},
}

cmd.Flags().StringVar(&opts.recording, "recording", "", "Specific recording filename to copy")
cmd.Flags().StringVar(&opts.clusterID, "cluster-id", "", "Copy all recordings for a cluster")
cmd.Flags().BoolVar(&opts.all, "all", false, "Copy all recordings")
cmd.Flags().StringVarP(&opts.output, "output", "o", "", "Local destination directory (required)")
_ = cmd.MarkFlagRequired("output") //nolint:errcheck // flag exists

return cmd
}

func copyRecordingRun(cmd *cobra.Command, opts *copyRecordingOptions) error {
ctx := cmd.Context()

// Ensure output directory exists
if err := os.MkdirAll(opts.output, outputDirPerms); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}

// Create exec client
execClient, err := chart.NewK8sExecClient(ctx, recordingsSidecarLabel, recordingsSidecarContainer)
if err != nil {
return fmt.Errorf("failed to create Kubernetes client: %w", err)
}

// Determine which files to copy
filesToCopy, err := resolveFilesToCopy(ctx, execClient, opts)
if err != nil {
return err
}

if len(filesToCopy) == 0 {
utils.UserNote("No matching recordings found")

return nil
}

utils.UserOutput("Copying %d recording(s) to %s\n", len(filesToCopy), opts.output)

// Copy each file
copiedCount := 0
for _, recording := range filesToCopy {
// Construct remote path from ClusterID + Filename
// Filename may contain subdirectories (e.g., "subdir/file.cast")
remotePath := filepath.Join(recordingsPath, recording.Filename)
if recording.ClusterID != "" {
remotePath = filepath.Join(recordingsPath, recording.ClusterID, recording.Filename)
}

// Use basename for local destination (flatten directory structure)
localFilename := filepath.Base(recording.Filename)
dstPath := filepath.Join(opts.output, localFilename)

if err := execClient.CopyFromPod(ctx, remotePath, dstPath); err != nil {
utils.UserWarning("Failed to copy %s: %v", localFilename, err)

continue
}

utils.UserOutput(" Copied: %s\n", localFilename)
copiedCount++
}

utils.UserNote("Successfully copied %d/%d recording(s)", copiedCount, len(filesToCopy))

return nil
}

// resolveFilesToCopy determines which recording files to copy based on options.
func resolveFilesToCopy(
ctx context.Context,
execClient *chart.K8sExecClient,
opts *copyRecordingOptions,
) ([]RecordingInfo, error) {
if opts.recording == "" {
// List by cluster ID or all (empty clusterID = all)
// listRecordings validates clusterID internally
return listRecordings(ctx, execClient, opts.clusterID)
}

return resolveSpecificRecording(ctx, execClient, opts)
}

// resolveSpecificRecording finds a specific recording by name.
func resolveSpecificRecording(
ctx context.Context,
execClient *chart.K8sExecClient,
opts *copyRecordingOptions,
) ([]RecordingInfo, error) {
// Note: filename and clusterID validation is done in PreRunE

if opts.clusterID != "" {
return resolveWithClusterID(opts), nil
}

return searchForRecording(ctx, execClient, opts.recording)
}

// resolveWithClusterID returns recording info when cluster ID is explicitly provided.
// Note: clusterID validation is done in PreRunE.
func resolveWithClusterID(opts *copyRecordingOptions) []RecordingInfo {
return []RecordingInfo{{
Filename: opts.recording,
ClusterID: opts.clusterID,
}}
}

// searchForRecording searches all recordings to find the one matching the filename.
func searchForRecording(
ctx context.Context,
execClient *chart.K8sExecClient,
recordingName string,
) ([]RecordingInfo, error) {
allRecordings, listErr := listRecordings(ctx, execClient, "")
if listErr != nil {
return nil, fmt.Errorf("failed to list recordings: %w", listErr)
}

for _, r := range allRecordings {
if r.Filename == recordingName {
return []RecordingInfo{r}, nil
}
}

return nil, fmt.Errorf("%w: %s", ErrRecordingNotFound, recordingName)
}
Loading