-
Notifications
You must be signed in to change notification settings - Fork 0
feat: homecli remote-access commands #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
danielBWeka
merged 17 commits into
release/v0.4
from
danielb/remote-session-cli-commands
Feb 1, 2026
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
40c9e45
Add remote session cli commands
danielBWeka bc0e2a3
get image from configMap
danielBWeka 38e54c1
enable remote access values for LWH
danielBWeka 64450ca
copy using sidecar deployment
danielBWeka d124737
remove http.go
danielBWeka a3e3564
fix copy recordings
danielBWeka 0d03b60
lint + fixes
danielBWeka e9b3818
fixes
danielBWeka 139eb6c
pre-commit
danielBWeka 6e143f5
fixes 2
danielBWeka b064b04
sort recordings list
danielBWeka 27636aa
fix error handling
danielBWeka 0825548
validate tmte server flags
danielBWeka a28d2b4
fixes 4
danielBWeka c5c9dd6
Squashed commit of the following:
danielBWeka 0fcc72b
Merge branch 'release/v0.4' into danielb/remote-session-cli-commands
danielBWeka abadb93
fix merge conflict
danielBWeka File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 := ©RecordingOptions{} | ||
|
|
||
| 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) | ||
|
|
||
danielBWeka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
danielBWeka marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.