diff --git a/cmd/oadp-vmdp/README.md b/cmd/oadp-vmdp/README.md new file mode 100644 index 00000000000..86b056023aa --- /dev/null +++ b/cmd/oadp-vmdp/README.md @@ -0,0 +1,123 @@ +# OADP-VMDP User Guide + +This guide will help you create backups of your VM files and restore them when needed. + +## Prerequisites + +Your administrator should have provided you with: +- S3 bucket name +- S3 endpoint URL +- Access key ID +- Secret access key + +## First-Time Setup: Connect to Your Backup Storage Location (BSL) + +Before creating backups, you need to connect to your Backup Storage Location (BSL). The BSL can be an S3-compatible storage or a server. Run this command once: + +```bash +oadp-vmdp bsl create s3 \ + --bucket=YOUR_BUCKET_NAME \ + --endpoint=YOUR_S3_ENDPOINT \ + --access-key=YOUR_ACCESS_KEY \ + --secret-access-key=YOUR_SECRET_KEY +``` + +You'll be prompted to set a password. **Remember this password** - you'll need it to restore your backups. + +## Creating a Backup + +To backup a folder or file: + +```bash +oadp-vmdp backup create /path/to/folder +``` + +**Examples:** +```bash +# Backup your home directory +oadp-vmdp backup create /home/myuser + +# Backup specific folders +oadp-vmdp backup create /home/myuser/documents /home/myuser/photos + +# Backup a single file +oadp-vmdp backup create /home/myuser/important-file.txt +``` + +## Listing Your Backups + +To see all your backups: + +```bash +oadp-vmdp backup list +``` + +To see backups of a specific folder: + +```bash +oadp-vmdp backup list /path/to/folder +``` + +## Restoring Files from a Backup + +To restore files to their original location: + +```bash +oadp-vmdp backup restore /path/to/folder +``` + +To restore to a different location: + +```bash +oadp-vmdp restore /path/to/folder --target=/path/to/restore/location +``` + +**Examples:** +```bash +# Restore your documents folder +oadp-vmdp backup restore /home/myuser/documents + +# Restore to a different location +oadp-vmdp restore /home/myuser/documents --target=/tmp/restored-docs + +# Restore a specific file +oadp-vmdp backup restore /home/myuser/important-file.txt +``` + +## Connecting to an Existing BSL + +If you already created a BSL (Backup Storage Location) and need to reconnect (e.g., after a reboot): + +```bash +oadp-vmdp bsl connect s3 \ + --bucket=YOUR_BUCKET_NAME \ + --endpoint=YOUR_S3_ENDPOINT \ + --access-key=YOUR_ACCESS_KEY \ + --secret-access-key=YOUR_SECRET_KEY +``` + +Enter the password you set during BSL creation. + +## Disconnecting from BSL + +When you're done: + +```bash +oadp-vmdp bsl disconnect +``` + +## Common Tips + +- **Backup regularly**: Schedule regular backups of your important folders +- **Test your restores**: Occasionally test restoring to make sure your backups work +- **Keep your password safe**: Without it, you cannot restore your backups +- **Check backup status**: Use `oadp-vmdp bsl status` to verify your BSL connection + +## Need Help? + +For more options and advanced features: +```bash +oadp-vmdp --help +oadp-vmdp backup --help +oadp-vmdp restore --help +``` diff --git a/cmd/oadp-vmdp/args_processor.go b/cmd/oadp-vmdp/args_processor.go new file mode 100644 index 00000000000..9e750d4f0e2 --- /dev/null +++ b/cmd/oadp-vmdp/args_processor.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// argsProcessor handles preprocessing of command-line arguments before they reach kingpin. +type argsProcessor struct{} + +// newArgsProcessor creates a new arguments processor. +func newArgsProcessor() *argsProcessor { + return &argsProcessor{} +} + +// processS3Prefix modifies os.Args to automatically prepend "oadp-vmdp/" to S3 --prefix flags. +// This allows transparent prefix normalization without modifying core Kopia code. +func (ap *argsProcessor) processS3Prefix() error { + // Look for S3-related commands and --prefix flag + inS3Context := false + + for i := 0; i < len(os.Args); i++ { + arg := os.Args[i] + + // Check if we're in an S3 context (repository/bsl create/connect s3) + if arg == "s3" { + inS3Context = true + continue + } + + // If we're in S3 context, look for --prefix flag + if inS3Context { + // Handle --prefix=value format + if strings.HasPrefix(arg, "--prefix=") { + parts := strings.SplitN(arg, "=", 2) + if len(parts) == 2 { + userPrefix := parts[1] + normalized, err := NormalizeOADPPrefix(userPrefix) + if err != nil { + return err + } + os.Args[i] = fmt.Sprintf("--prefix=%s", normalized) + } + continue + } + + // Handle --prefix value format (two separate args) + if arg == "--prefix" { + if i+1 < len(os.Args) { + userPrefix := os.Args[i+1] + normalized, err := NormalizeOADPPrefix(userPrefix) + if err != nil { + return err + } + os.Args[i+1] = normalized + i++ // Skip the next arg since we just processed it + } + continue + } + } + } + + return nil +} + +// processAllArgs runs all argument preprocessing steps. +func (ap *argsProcessor) processAllArgs() error { + // Process S3 prefix normalization + if err := ap.processS3Prefix(); err != nil { + return err + } + + return nil +} diff --git a/cmd/oadp-vmdp/command_filter.go b/cmd/oadp-vmdp/command_filter.go new file mode 100644 index 00000000000..70be19d6949 --- /dev/null +++ b/cmd/oadp-vmdp/command_filter.go @@ -0,0 +1,132 @@ +package main + +import ( + "github.com/alecthomas/kingpin/v2" +) + +// hiddenCommands defines top-level commands that should be hidden in OADP. +// These are advanced/dangerous commands not needed for basic VM backup/restore. +var hiddenCommands = map[string]bool{ + "benchmark": true, // Performance testing - not needed for users + "diff": true, // Advanced repository comparison + "list": true, // Advanced file listing - use 'backup list' instead + "notification": true, // Server-specific feature + "server": true, // Server mode not used in OADP + "policy": true, // Advanced policy management - use defaults + "mount": true, // FUSE mounting - not supported in containers + "maintenance": true, // Advanced maintenance - handled automatically +} + +// hiddenRepositorySubcommands defines repository subcommands to hide. +// Only keep: connect, create, disconnect, status +var hiddenRepositorySubcommands = map[string]bool{ + "repair": true, // Dangerous - can corrupt repository + "set-client": true, // Advanced configuration + "set-parameters": true, // Advanced configuration + "sync-to": true, // Advanced replication feature + "throttle": true, // Advanced performance tuning + "change-password": true, // Use OADP password management instead + "validate-provider": true, // Internal testing command + "upgrade": true, // Dangerous - can break compatibility +} + +// hiddenSnapshotSubcommands defines snapshot subcommands to hide. +// Only keep: create, delete, list, restore +var hiddenSnapshotSubcommands = map[string]bool{ + "copy-history": true, // Advanced history management + "move-history": true, // Advanced history management + "estimate": true, // Advanced analysis + "expire": true, // Handled automatically by retention policy + "fix": true, // Dangerous - can corrupt snapshots + "migrate": true, // Advanced migration feature + "pin": true, // Advanced retention management + "verify": true, // Advanced verification - use defaults +} + +// commandFilter handles hiding unwanted commands from the CLI. +type commandFilter struct { + kp *kingpin.Application +} + +// newCommandFilter creates a new command filter for the given kingpin application. +func newCommandFilter(kp *kingpin.Application) *commandFilter { + return &commandFilter{ + kp: kp, + } +} + +// apply walks the command tree and hides unwanted commands. +func (f *commandFilter) apply() { + // Hide top-level commands + f.hideTopLevelCommands() + + // Hide subcommands within repository/snapshot + f.hideSubcommands() +} + +// hideTopLevelCommands hides top-level commands that shouldn't be exposed. +func (f *commandFilter) hideTopLevelCommands() { + // Note: kingpin doesn't expose a direct way to iterate commands, + // so we'll mark them as hidden when we know they exist + // + // Since we can't easily iterate, we'll take a different approach: + // We'll let the setup happen normally and then mark specific commands as hidden +} + +// hideSubcommands hides subcommands within specific command groups. +func (f *commandFilter) hideSubcommands() { + // Same challenge as above - kingpin doesn't expose command iteration + // We'll need to mark commands as hidden if they exist +} + +// shouldHideCommand checks if a top-level command should be hidden. +func shouldHideCommand(cmdName string) bool { + return hiddenCommands[cmdName] +} + +// shouldHideRepositorySubcommand checks if a repository subcommand should be hidden. +func shouldHideRepositorySubcommand(subcmdName string) bool { + return hiddenRepositorySubcommands[subcmdName] +} + +// shouldHideSnapshotSubcommand checks if a snapshot subcommand should be hidden. +func shouldHideSnapshotSubcommand(subcmdName string) bool { + return hiddenSnapshotSubcommands[subcmdName] +} + +// getVisibleCommands returns a list of commands that should be visible in OADP. +func getVisibleCommands() []string { + return []string{ + "repository", // Aliased as "bsl" + "snapshot", // Aliased as "backup" + "cache", // Cache management + "blob", // Low-level blob operations (hidden in main help) + "content", // Low-level content operations (hidden in main help) + "index", // Index operations (hidden in main help) + "logs", // Log management + "session", // Session management + "restore", // File restore + "show", // Show repository objects + "manifest", // Manifest operations + } +} + +// getVisibleRepositorySubcommands returns allowed repository subcommands. +func getVisibleRepositorySubcommands() []string { + return []string{ + "connect", + "create", + "disconnect", + "status", + } +} + +// getVisibleSnapshotSubcommands returns allowed snapshot subcommands. +func getVisibleSnapshotSubcommands() []string { + return []string{ + "create", + "delete", + "list", + "restore", + } +} diff --git a/cmd/oadp-vmdp/command_interceptor.go b/cmd/oadp-vmdp/command_interceptor.go new file mode 100644 index 00000000000..fbb60a5e186 --- /dev/null +++ b/cmd/oadp-vmdp/command_interceptor.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// commandInterceptor checks if a command should be blocked before execution. +type commandInterceptor struct{} + +// newCommandInterceptor creates a new command interceptor. +func newCommandInterceptor() *commandInterceptor { + return &commandInterceptor{} +} + +// checkAndBlock examines os.Args and blocks execution of hidden commands. +// Returns true if the command should be blocked, false otherwise. +func (ci *commandInterceptor) checkAndBlock() bool { + if len(os.Args) < 2 { + return false // No command, let it proceed (will show help) + } + + // Skip flags to find the actual command + var command string + var subcommand string + + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + + // Skip flags + if strings.HasPrefix(arg, "-") { + continue + } + + if command == "" { + command = arg + } else if subcommand == "" { + subcommand = arg + break // We have both command and subcommand + } + } + + // Check if this is a hidden top-level command + if shouldHideCommand(command) { + ci.printBlockedMessage(command, "") + return true + } + + // Check repository subcommands + if (command == "repository" || command == "repo" || command == "bsl" || command == "bsls") && subcommand != "" { + if shouldHideRepositorySubcommand(subcommand) { + ci.printBlockedMessage(command, subcommand) + return true + } + } + + // Check snapshot subcommands + if (command == "snapshot" || command == "snap" || command == "backup" || command == "bkp") && subcommand != "" { + if shouldHideSnapshotSubcommand(subcommand) { + ci.printBlockedMessage(command, subcommand) + return true + } + } + + return false // Command is allowed +} + +// printBlockedMessage displays an error message for blocked commands. +func (ci *commandInterceptor) printBlockedMessage(command, subcommand string) { + var fullCommand string + if subcommand != "" { + fullCommand = fmt.Sprintf("%s %s", command, subcommand) + } else { + fullCommand = command + } + + fmt.Fprintf(os.Stderr, "Error: The command '%s' is not available in oadp-vmdp.\n", fullCommand) + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "OADP VM Data Protection provides a simplified interface for VM backup and restore operations.\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "Available commands:\n") + fmt.Fprintf(os.Stderr, " oadp-vmdp bsl create|connect|disconnect|status - Manage Backup Storage Location\n") + fmt.Fprintf(os.Stderr, " oadp-vmdp backup create|list|restore|delete - Manage backups\n") + fmt.Fprintf(os.Stderr, " oadp-vmdp cache info|set - Manage cache\n") + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "For more information: oadp-vmdp --help\n") + fmt.Fprintf(os.Stderr, "\n") +} diff --git a/cmd/oadp-vmdp/command_mapper.go b/cmd/oadp-vmdp/command_mapper.go new file mode 100644 index 00000000000..7299bafc220 --- /dev/null +++ b/cmd/oadp-vmdp/command_mapper.go @@ -0,0 +1,79 @@ +package main + +import ( + "os" + "strings" +) + +// commandAliases maps OADP-friendly command names to their Kopia equivalents. +var commandAliases = map[string]string{ + "bsl": "repository", // BSL (Backup Storage Location) -> repository + "bsls": "repository", // Alternative plural form + "backup": "snapshot", // backup -> snapshot + "bkp": "snapshot", // Alternative short form +} + +// mapCommandArgs translates OADP command names in os.Args to Kopia equivalents. +// This allows users to type "oadp-vmdp bsl connect s3 ..." which gets translated +// to "kopia repository connect s3 ..." internally. +// +// The function modifies os.Args in-place and returns the modified slice. +func mapCommandArgs(args []string) []string { + if len(args) < 2 { + return args // No command to map + } + + // Look for the first command (skip flags) + for i := 1; i < len(args); i++ { + arg := args[i] + + // Skip flags (anything starting with -) + if strings.HasPrefix(arg, "-") { + continue + } + + // Check if this is an aliased command + if kopiaCmd, exists := commandAliases[arg]; exists { + // Replace the OADP command with the Kopia equivalent + args[i] = kopiaCmd + break + } + + // Stop at the first non-flag argument (the command) + break + } + + return args +} + +// getCommandMapping returns the full command alias map. +// Useful for documentation and testing. +func getCommandMapping() map[string]string { + // Return a copy to prevent external modification + mapping := make(map[string]string, len(commandAliases)) + for k, v := range commandAliases { + mapping[k] = v + } + return mapping +} + +// isOADPCommand checks if a given command is an OADP-specific alias. +func isOADPCommand(cmd string) bool { + _, exists := commandAliases[cmd] + return exists +} + +// getKopiaCommand returns the Kopia command for a given OADP command. +// Returns the original command if no mapping exists. +func getKopiaCommand(oadpCmd string) string { + if kopiaCmd, exists := commandAliases[oadpCmd]; exists { + return kopiaCmd + } + return oadpCmd +} + +// translateArgs translates the entire argument list, handling both +// command aliases and any future argument transformations. +func translateArgs() { + os.Args = mapCommandArgs(os.Args) +} diff --git a/cmd/oadp-vmdp/command_mapper_test.go b/cmd/oadp-vmdp/command_mapper_test.go new file mode 100644 index 00000000000..5a68a19b082 --- /dev/null +++ b/cmd/oadp-vmdp/command_mapper_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestMapCommandArgs(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "map bsl to repository", + input: []string{"oadp-vmdp", "bsl", "connect", "s3"}, + expected: []string{"oadp-vmdp", "repository", "connect", "s3"}, + }, + { + name: "map bsls to repository", + input: []string{"oadp-vmdp", "bsls", "status"}, + expected: []string{"oadp-vmdp", "repository", "status"}, + }, + { + name: "map backup to snapshot", + input: []string{"oadp-vmdp", "backup", "create", "/path"}, + expected: []string{"oadp-vmdp", "snapshot", "create", "/path"}, + }, + { + name: "map bkp to snapshot", + input: []string{"oadp-vmdp", "bkp", "list"}, + expected: []string{"oadp-vmdp", "snapshot", "list"}, + }, + { + name: "no mapping for standard kopia commands", + input: []string{"oadp-vmdp", "cache", "info"}, + expected: []string{"oadp-vmdp", "cache", "info"}, + }, + { + name: "mapping with flags before command", + input: []string{"oadp-vmdp", "--log-level=debug", "bsl", "connect"}, + expected: []string{"oadp-vmdp", "--log-level=debug", "repository", "connect"}, + }, + { + name: "mapping with flags and short flags", + input: []string{"oadp-vmdp", "-v", "--config=/path", "backup", "create"}, + expected: []string{"oadp-vmdp", "-v", "--config=/path", "snapshot", "create"}, + }, + { + name: "no command (just binary name)", + input: []string{"oadp-vmdp"}, + expected: []string{"oadp-vmdp"}, + }, + { + name: "help command not mapped", + input: []string{"oadp-vmdp", "help"}, + expected: []string{"oadp-vmdp", "help"}, + }, + { + name: "only flags, no command", + input: []string{"oadp-vmdp", "--version"}, + expected: []string{"oadp-vmdp", "--version"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Make a copy to avoid modifying the test case + input := make([]string, len(tt.input)) + copy(input, tt.input) + + result := mapCommandArgs(input) + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("mapCommandArgs(%v) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestGetCommandMapping(t *testing.T) { + mapping := getCommandMapping() + + // Check expected mappings + expectedMappings := map[string]string{ + "bsl": "repository", + "bsls": "repository", + "backup": "snapshot", + "bkp": "snapshot", + } + + for oadpCmd, expectedKopiaCmd := range expectedMappings { + if kopiaCmd, exists := mapping[oadpCmd]; !exists { + t.Errorf("Expected mapping for %q to exist", oadpCmd) + } else if kopiaCmd != expectedKopiaCmd { + t.Errorf("Expected %q -> %q, got %q -> %q", oadpCmd, expectedKopiaCmd, oadpCmd, kopiaCmd) + } + } + + // Test that the returned map is a copy (modifying it shouldn't affect the original) + mapping["test"] = "modified" + newMapping := getCommandMapping() + if _, exists := newMapping["test"]; exists { + t.Error("Modifying returned mapping affected the original") + } +} + +func TestIsOADPCommand(t *testing.T) { + tests := []struct { + name string + command string + expected bool + }{ + { + name: "bsl is OADP command", + command: "bsl", + expected: true, + }, + { + name: "bsls is OADP command", + command: "bsls", + expected: true, + }, + { + name: "backup is OADP command", + command: "backup", + expected: true, + }, + { + name: "bkp is OADP command", + command: "bkp", + expected: true, + }, + { + name: "repository is not OADP command", + command: "repository", + expected: false, + }, + { + name: "snapshot is not OADP command", + command: "snapshot", + expected: false, + }, + { + name: "cache is not OADP command", + command: "cache", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isOADPCommand(tt.command) + if result != tt.expected { + t.Errorf("isOADPCommand(%q) = %v, want %v", tt.command, result, tt.expected) + } + }) + } +} + +func TestGetKopiaCommand(t *testing.T) { + tests := []struct { + name string + oadpCommand string + expected string + }{ + { + name: "bsl maps to repository", + oadpCommand: "bsl", + expected: "repository", + }, + { + name: "backup maps to snapshot", + oadpCommand: "backup", + expected: "snapshot", + }, + { + name: "unmapped command returns original", + oadpCommand: "cache", + expected: "cache", + }, + { + name: "empty string returns empty", + oadpCommand: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getKopiaCommand(tt.oadpCommand) + if result != tt.expected { + t.Errorf("getKopiaCommand(%q) = %q, want %q", tt.oadpCommand, result, tt.expected) + } + }) + } +} diff --git a/cmd/oadp-vmdp/env_translator.go b/cmd/oadp-vmdp/env_translator.go new file mode 100644 index 00000000000..ad2168a0759 --- /dev/null +++ b/cmd/oadp-vmdp/env_translator.go @@ -0,0 +1,89 @@ +package main + +import ( + "os" + "strings" +) + +// envMapping defines the translation from OADP environment variables to Kopia equivalents. +var envMapping = map[string]string{ + // Core configuration + "OADP_PASSWORD": "KOPIA_PASSWORD", + "OADP_CONFIG_PATH": "KOPIA_CONFIG_PATH", + "OADP_CACHE_DIRECTORY": "KOPIA_CACHE_DIRECTORY", + "OADP_CHECK_FOR_UPDATES": "KOPIA_CHECK_FOR_UPDATES", + "OADP_PERSIST_CREDENTIALS": "KOPIA_PERSIST_CREDENTIALS_ON_CONNECT", + + // Logging configuration + "OADP_LOG_DIR": "KOPIA_LOG_DIR", + "OADP_LOG_DIR_MAX_FILES": "KOPIA_LOG_DIR_MAX_FILES", + "OADP_LOG_DIR_MAX_AGE": "KOPIA_LOG_DIR_MAX_AGE", + "OADP_LOG_DIR_MAX_SIZE_MB": "KOPIA_LOG_DIR_MAX_SIZE_MB", + "OADP_LOG_FILE_MAX_SEGMENT_SIZE": "KOPIA_LOG_FILE_MAX_SEGMENT_SIZE", + "OADP_CONTENT_LOG_DIR_MAX_FILES": "KOPIA_CONTENT_LOG_DIR_MAX_FILES", + "OADP_CONTENT_LOG_DIR_MAX_AGE": "KOPIA_CONTENT_LOG_DIR_MAX_AGE", + "OADP_CONTENT_LOG_DIR_MAX_SIZE_MB": "KOPIA_CONTENT_LOG_DIR_MAX_SIZE_MB", + "OADP_FILE_LOG_LOCAL_TZ": "KOPIA_FILE_LOG_LOCAL_TZ", + "OADP_FORCE_COLOR": "KOPIA_FORCE_COLOR", + "OADP_DISABLE_COLOR": "KOPIA_DISABLE_COLOR", + "OADP_CONSOLE_TIMESTAMPS": "KOPIA_CONSOLE_TIMESTAMPS", + "OADP_DISABLE_INTERNAL_LOG": "KOPIA_DISABLE_INTERNAL_LOG", + + // Advanced configuration + "OADP_INITIAL_UPDATE_CHECK_DELAY": "KOPIA_INITIAL_UPDATE_CHECK_DELAY", + "OADP_UPDATE_CHECK_INTERVAL": "KOPIA_UPDATE_CHECK_INTERVAL", + "OADP_UPDATE_NOTIFY_INTERVAL": "KOPIA_UPDATE_NOTIFY_INTERVAL", + "OADP_TRACK_RELEASABLE": "KOPIA_TRACK_RELEASABLE", + "OADP_DUMP_ALLOCATOR_STATS": "KOPIA_DUMP_ALLOCATOR_STATS", + "OADP_REPO_UPGRADE_OWNER_ID": "KOPIA_REPO_UPGRADE_OWNER_ID", + "OADP_REPO_UPGRADE_NO_BLOCK": "KOPIA_REPO_UPGRADE_NO_BLOCK", + "OADP_SEND_ERROR_NOTIFICATIONS": "KOPIA_SEND_ERROR_NOTIFICATIONS", + "OADP_RESTORE_CONSISTENT_ATTRIBUTES": "KOPIA_RESTORE_CONSISTENT_ATTRIBUTES", + "OADP_USE_KEYRING": "KOPIA_USE_KEYRING", +} + +// translateEnvironmentVariables translates OADP_* environment variables to KOPIA_* +// equivalents. This allows users to use OADP-branded environment variables while +// maintaining compatibility with Kopia's internal expectations. +// +// If both OADP_X and KOPIA_X are set, OADP_X takes precedence. +func translateEnvironmentVariables() { + for oadpEnv, kopiaEnv := range envMapping { + if oadpValue, exists := os.LookupEnv(oadpEnv); exists { + // OADP env var is set, use it (overriding any existing KOPIA var) + os.Setenv(kopiaEnv, oadpValue) + } + } +} + +// getEnvPrefix returns "OADP" for use in environment variable naming. +// This is used by the CLI to generate help text and flag descriptions. +func getEnvPrefix() string { + return "OADP" +} + +// translateSingleEnvVar translates a single OADP environment variable name to its +// Kopia equivalent. Returns the Kopia name and true if a mapping exists, +// or the original name and false if no mapping exists. +func translateSingleEnvVar(oadpEnvName string) (string, bool) { + if kopiaName, exists := envMapping[oadpEnvName]; exists { + return kopiaName, true + } + + // If it starts with OADP_, try automatic translation + if strings.HasPrefix(oadpEnvName, "OADP_") { + return "KOPIA_" + strings.TrimPrefix(oadpEnvName, "OADP_"), true + } + + return oadpEnvName, false +} + +// getAllOADPEnvVars returns a list of all supported OADP environment variable names. +// Useful for documentation and help text generation. +func getAllOADPEnvVars() []string { + vars := make([]string, 0, len(envMapping)) + for k := range envMapping { + vars = append(vars, k) + } + return vars +} diff --git a/cmd/oadp-vmdp/env_translator_test.go b/cmd/oadp-vmdp/env_translator_test.go new file mode 100644 index 00000000000..4196a6aad34 --- /dev/null +++ b/cmd/oadp-vmdp/env_translator_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "os" + "testing" +) + +func TestTranslateEnvironmentVariables(t *testing.T) { + tests := []struct { + name string + setup map[string]string // env vars to set before test + expectedKopia map[string]string // expected KOPIA_* vars after translation + cleanup []string // env vars to unset after test + }{ + { + name: "translate OADP_PASSWORD to KOPIA_PASSWORD", + setup: map[string]string{ + "OADP_PASSWORD": "secret123", + }, + expectedKopia: map[string]string{ + "KOPIA_PASSWORD": "secret123", + }, + cleanup: []string{"OADP_PASSWORD", "KOPIA_PASSWORD"}, + }, + { + name: "translate OADP_CONFIG_PATH to KOPIA_CONFIG_PATH", + setup: map[string]string{ + "OADP_CONFIG_PATH": "/path/to/config", + }, + expectedKopia: map[string]string{ + "KOPIA_CONFIG_PATH": "/path/to/config", + }, + cleanup: []string{"OADP_CONFIG_PATH", "KOPIA_CONFIG_PATH"}, + }, + { + name: "OADP var overrides existing KOPIA var", + setup: map[string]string{ + "OADP_PASSWORD": "oadp-secret", + "KOPIA_PASSWORD": "kopia-secret", + }, + expectedKopia: map[string]string{ + "KOPIA_PASSWORD": "oadp-secret", + }, + cleanup: []string{"OADP_PASSWORD", "KOPIA_PASSWORD"}, + }, + { + name: "translate multiple environment variables", + setup: map[string]string{ + "OADP_PASSWORD": "secret", + "OADP_CACHE_DIRECTORY": "/cache", + "OADP_LOG_DIR": "/logs", + }, + expectedKopia: map[string]string{ + "KOPIA_PASSWORD": "secret", + "KOPIA_CACHE_DIRECTORY": "/cache", + "KOPIA_LOG_DIR": "/logs", + }, + cleanup: []string{ + "OADP_PASSWORD", "KOPIA_PASSWORD", + "OADP_CACHE_DIRECTORY", "KOPIA_CACHE_DIRECTORY", + "OADP_LOG_DIR", "KOPIA_LOG_DIR", + }, + }, + { + name: "no OADP vars set does not clear existing KOPIA vars", + setup: map[string]string{ + "KOPIA_PASSWORD": "existing", + }, + expectedKopia: map[string]string{ + "KOPIA_PASSWORD": "existing", + }, + cleanup: []string{"KOPIA_PASSWORD"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + for k, v := range tt.setup { + os.Setenv(k, v) + } + + // Execute translation + translateEnvironmentVariables() + + // Verify expected KOPIA vars + for kopiaVar, expectedValue := range tt.expectedKopia { + actualValue := os.Getenv(kopiaVar) + if actualValue != expectedValue { + t.Errorf("Expected %s=%q, got %q", kopiaVar, expectedValue, actualValue) + } + } + + // Cleanup + for _, v := range tt.cleanup { + os.Unsetenv(v) + } + }) + } +} + +func TestTranslateSingleEnvVar(t *testing.T) { + tests := []struct { + name string + input string + expectedOutput string + expectedExists bool + }{ + { + name: "translate OADP_PASSWORD", + input: "OADP_PASSWORD", + expectedOutput: "KOPIA_PASSWORD", + expectedExists: true, + }, + { + name: "translate OADP_CACHE_DIRECTORY", + input: "OADP_CACHE_DIRECTORY", + expectedOutput: "KOPIA_CACHE_DIRECTORY", + expectedExists: true, + }, + { + name: "automatic translation for unmapped OADP var", + input: "OADP_CUSTOM_VAR", + expectedOutput: "KOPIA_CUSTOM_VAR", + expectedExists: true, + }, + { + name: "no translation for non-OADP var", + input: "SOME_OTHER_VAR", + expectedOutput: "SOME_OTHER_VAR", + expectedExists: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, exists := translateSingleEnvVar(tt.input) + if output != tt.expectedOutput { + t.Errorf("Expected output %q, got %q", tt.expectedOutput, output) + } + if exists != tt.expectedExists { + t.Errorf("Expected exists=%v, got %v", tt.expectedExists, exists) + } + }) + } +} + +func TestGetEnvPrefix(t *testing.T) { + prefix := getEnvPrefix() + if prefix != "OADP" { + t.Errorf("Expected prefix 'OADP', got %q", prefix) + } +} + +func TestGetAllOADPEnvVars(t *testing.T) { + vars := getAllOADPEnvVars() + + // Check that we have some variables + if len(vars) == 0 { + t.Error("Expected at least one OADP environment variable") + } + + // Check that common vars are present + expectedVars := []string{ + "OADP_PASSWORD", + "OADP_CONFIG_PATH", + "OADP_CACHE_DIRECTORY", + } + + varMap := make(map[string]bool) + for _, v := range vars { + varMap[v] = true + } + + for _, expected := range expectedVars { + if !varMap[expected] { + t.Errorf("Expected %q to be in the list of OADP vars", expected) + } + } +} diff --git a/cmd/oadp-vmdp/help_handler.go b/cmd/oadp-vmdp/help_handler.go new file mode 100644 index 00000000000..a5f7769c44c --- /dev/null +++ b/cmd/oadp-vmdp/help_handler.go @@ -0,0 +1,139 @@ +package main + +import ( + "fmt" + "os" +) + +// helpHandler provides custom help output for oadp-vmdp. +type helpHandler struct{} + +// newHelpHandler creates a new help handler. +func newHelpHandler() *helpHandler { + return &helpHandler{} +} + +// shouldShowCustomHelp checks if we should intercept and show custom help. +func (h *helpHandler) shouldShowCustomHelp() bool { + // Show custom help if: + // 1. No arguments (just binary name) + // 2. --help or -h flag at top level + // 3. "help" command with no subcommand + // 4. --help-full (intercept and show custom help instead of all commands) + // 5. --help-long or --help-man (also intercept) + + if len(os.Args) == 1 { + // Just "oadp-vmdp" with no args + return true + } + + for i, arg := range os.Args { + // Intercept --help-full, --help-long, --help-man and show our custom help + if arg == "--help-full" || arg == "--help-long" || arg == "--help-man" { + return true + } + + if arg == "--help" || arg == "-h" { + // Only show custom help at the top level (no command specified before --help) + // Check if there's a command before the help flag + hasCommand := false + for j := 1; j < i; j++ { + if !isFlag(os.Args[j]) { + hasCommand = true + break + } + } + if !hasCommand { + return true + } + } + + if arg == "help" && i == 1 { + // "oadp-vmdp help" (with no subcommand) + if len(os.Args) == 2 { + return true + } + } + } + return false +} + +// isFlag checks if an argument is a flag (starts with -). +func isFlag(arg string) bool { + return len(arg) > 0 && arg[0] == '-' +} + +// showCustomHelp displays custom, clean help output. +func (h *helpHandler) showCustomHelp() { + helpText := `OADP Virtual Machine Data Protection for OpenShift Virtualization + +Usage: + oadp-vmdp [flags] [arguments] + +Available Commands: + BSL (Backup Storage Location) Management: + bsl create s3 Create S3 backup storage location + bsl connect s3 Connect to existing S3 backup storage location + bsl disconnect Disconnect from backup storage location + bsl status Show backup storage location status + + Backup Management: + backup create Create a backup of files/directories + backup list List available backups + backup delete Delete a backup + backup restore Restore files from a backup (alias: restore) + + Cache Management: + cache info Display cache information + cache set Configure cache settings + cache clear Clear the cache + + Other Commands: + help Show this help message + --version Show version information + +Common Flags: + -p, --password=PASSWORD Repository password (or use OADP_PASSWORD) + --config-file=FILE Config file path (default: repository.config) + --log-level=LEVEL Log level (default: info) + +Environment Variables: + OADP_PASSWORD Repository password + OADP_CONFIG_PATH Configuration file path + OADP_CACHE_DIRECTORY Cache directory location + OADP_LOG_DIR Log file directory + +Quick Start: + 1. Connect to backup storage: + oadp-vmdp bsl create s3 --bucket=my-bucket --endpoint=s3.amazonaws.com \ + --access-key=KEY --secret-access-key=SECRET + + 2. Create a backup: + oadp-vmdp backup create /path/to/data + + 3. List backups: + oadp-vmdp backup list + + 4. Restore from backup: + oadp-vmdp backup restore /path/to/data + +For detailed help on a specific command: + oadp-vmdp --help + +Examples: + oadp-vmdp bsl create s3 --help + oadp-vmdp backup create --help + oadp-vmdp backup list --help +` + fmt.Fprint(os.Stdout, helpText) +} + +// checkAndShowHelp checks if custom help should be shown and displays it if needed. +// Returns true if help was shown (and program should exit). +func (h *helpHandler) checkAndShowHelp() bool { + if h.shouldShowCustomHelp() { + h.showCustomHelp() + return true + } + return false +} diff --git a/cmd/oadp-vmdp/main.go b/cmd/oadp-vmdp/main.go new file mode 100644 index 00000000000..aeefc362eb9 --- /dev/null +++ b/cmd/oadp-vmdp/main.go @@ -0,0 +1,75 @@ +// Package main implements the OADP VM Data Protection CLI wrapper around Kopia. +// +// This is a thin wrapper that provides OADP-specific command names and environment +// variables while using Kopia's core functionality underneath. +// +// Usage: +// +// $ oadp-vmdp [] [ ...] +// +// Use 'oadp-vmdp help' to see more details. +package main + +import ( + "fmt" + "os" + + "github.com/alecthomas/kingpin/v2" + + "github.com/kopia/kopia/cli" + "github.com/kopia/kopia/internal/logfile" + "github.com/kopia/kopia/repo" +) + +func main() { + // Step 1: Check if we should show custom help + helpHandler := newHelpHandler() + if helpHandler.checkAndShowHelp() { + os.Exit(0) + } + + // Step 2: Check if command should be blocked (hidden commands) + interceptor := newCommandInterceptor() + if interceptor.checkAndBlock() { + os.Exit(1) + } + + // Step 3: Process arguments (S3 prefix normalization, etc.) + processor := newArgsProcessor() + if err := processor.processAllArgs(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Step 4: Translate command aliases (bsl → repository, backup → snapshot) + translateArgs() + + // Step 5: Translate OADP_* environment variables to KOPIA_* for compatibility + translateEnvironmentVariables() + + // Step 5: Create the base Kopia CLI app + app := cli.NewApp() + + // Step 6: Create kingpin application with OADP branding + kp := kingpin.New("oadp-vmdp", "OADP VM Data Protection - Virtual Machine Data Protection for OpenShift Virtualization"). + Author("Red Hat, Inc. ") + + kp.Version(repo.BuildVersion + " build: " + repo.BuildInfo + " from: " + repo.BuildGitHubRepo) + + // Step 7: Attach logfile support + logfile.Attach(app, kp) + + // Step 8: Setup the OADP wrapper with customizations + wrapper := newOADPWrapper(app, kp) + wrapper.setup() + + // Step 9: Configure kingpin output + kp.ErrorWriter(os.Stderr) + kp.UsageWriter(os.Stdout) + + // Step 10: Setup the Kopia CLI + app.Attach(kp) + + // Step 11: Parse arguments and execute + kingpin.MustParse(kp.Parse(os.Args[1:])) +} diff --git a/cmd/oadp-vmdp/s3_prefix.go b/cmd/oadp-vmdp/s3_prefix.go new file mode 100644 index 00000000000..5ba517116e1 --- /dev/null +++ b/cmd/oadp-vmdp/s3_prefix.go @@ -0,0 +1,63 @@ +package main + +import ( + "strings" + + "github.com/pkg/errors" +) + +const ( + // oadpS3Prefix is the required prefix for all OADP S3 repositories. + // This ensures isolation from other Kopia repositories in shared S3 buckets. + oadpS3Prefix = "oadp-vmdp/" +) + +// NormalizeOADPPrefix prepends "oadp-vmdp/" to the user-provided prefix. +// It handles various user input formats: +// - Errors out if user prefix contains "oadp-vmdp" (case-insensitive) +// - Removes leading slashes from user prefix +// - Always prepends "oadp-vmdp/" to the result +// - Preserves trailing slashes from user input +// +// Examples: +// - "abc/" → "oadp-vmdp/abc/" +// - "abc" → "oadp-vmdp/abc" +// - "/abc/bce/" → "oadp-vmdp/abc/bce/" +// - "" → "oadp-vmdp/" +// - "oadp-vmdp/abc" → error +func NormalizeOADPPrefix(userPrefix string) (string, error) { + // Check if user is trying to provide the oadp-vmdp prefix themselves + lowerPrefix := strings.ToLower(userPrefix) + if strings.Contains(lowerPrefix, "oadp-vmdp") { + return "", errors.New("prefix must not contain 'oadp-vmdp' - this prefix is automatically added") + } + + // Remove leading slashes from user prefix + cleanedPrefix := strings.TrimLeft(userPrefix, "/") + + // Combine oadp prefix with cleaned user prefix + return oadpS3Prefix + cleanedPrefix, nil +} + +// DenormalizeOADPPrefix removes the "oadp-vmdp/" prefix from a normalized prefix. +// This is used when displaying prefixes to users or saving to configuration files. +// +// Examples: +// - "oadp-vmdp/abc/" → "abc/" +// - "oadp-vmdp/abc" → "abc" +// - "oadp-vmdp/" → "" +// - "something-else" → "something-else" (unchanged if no OADP prefix) +func DenormalizeOADPPrefix(normalizedPrefix string) string { + return strings.TrimPrefix(normalizedPrefix, oadpS3Prefix) +} + +// GetOADPPrefix returns the OADP S3 prefix constant. +// This is useful for testing and documentation purposes. +func GetOADPPrefix() string { + return oadpS3Prefix +} + +// IsOADPPrefix checks if a given prefix starts with the OADP prefix. +func IsOADPPrefix(prefix string) bool { + return strings.HasPrefix(prefix, oadpS3Prefix) +} diff --git a/cmd/oadp-vmdp/s3_prefix_test.go b/cmd/oadp-vmdp/s3_prefix_test.go new file mode 100644 index 00000000000..961a8da40e1 --- /dev/null +++ b/cmd/oadp-vmdp/s3_prefix_test.go @@ -0,0 +1,237 @@ +package main + +import ( + "strings" + "testing" +) + +func TestNormalizeOADPPrefix(t *testing.T) { + tests := []struct { + name string + userPrefix string + expected string + expectError bool + }{ + { + name: "prefix with trailing slash", + userPrefix: "abc/", + expected: "oadp-vmdp/abc/", + expectError: false, + }, + { + name: "prefix without trailing slash", + userPrefix: "abc", + expected: "oadp-vmdp/abc", + expectError: false, + }, + { + name: "prefix with leading and trailing slash", + userPrefix: "/abc/bce/", + expected: "oadp-vmdp/abc/bce/", + expectError: false, + }, + { + name: "prefix with leading slash no trailing slash", + userPrefix: "/abc/bce", + expected: "oadp-vmdp/abc/bce", + expectError: false, + }, + { + name: "just slash", + userPrefix: "/", + expected: "oadp-vmdp/", + expectError: false, + }, + { + name: "empty string", + userPrefix: "", + expected: "oadp-vmdp/", + expectError: false, + }, + { + name: "multiple leading slashes", + userPrefix: "///abc/", + expected: "oadp-vmdp/abc/", + expectError: false, + }, + // Error cases: user provides oadp-vmdp in prefix + { + name: "user provides exact oadp-vmdp prefix", + userPrefix: "oadp-vmdp/", + expected: "", + expectError: true, + }, + { + name: "user provides oadp-vmdp with leading slash", + userPrefix: "/oadp-vmdp/", + expected: "", + expectError: true, + }, + { + name: "user provides oadp-vmdp in middle", + userPrefix: "some/oadp-vmdp/path", + expected: "", + expectError: true, + }, + { + name: "user provides uppercase OADP-VMDP", + userPrefix: "OADP-VMDP/", + expected: "", + expectError: true, + }, + { + name: "user provides mixed case OaDp-VmDp", + userPrefix: "OaDp-VmDp/backup", + expected: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := NormalizeOADPPrefix(tt.userPrefix) + if tt.expectError { + if err == nil { + t.Errorf("NormalizeOADPPrefix(%q) should return an error", tt.userPrefix) + } else if !containsString(err.Error(), "must not contain 'oadp-vmdp'") { + t.Errorf("Expected error to contain 'must not contain 'oadp-vmdp'', got: %v", err) + } + } else { + if err != nil { + t.Errorf("NormalizeOADPPrefix(%q) should not return an error: %v", tt.userPrefix, err) + } + if result != tt.expected { + t.Errorf("NormalizeOADPPrefix(%q) = %q, want %q", tt.userPrefix, result, tt.expected) + } + } + }) + } +} + +func TestDenormalizeOADPPrefix(t *testing.T) { + tests := []struct { + name string + normalizedPrefix string + expected string + }{ + { + name: "prefix with trailing slash", + normalizedPrefix: "oadp-vmdp/abc/", + expected: "abc/", + }, + { + name: "prefix without trailing slash", + normalizedPrefix: "oadp-vmdp/abc", + expected: "abc", + }, + { + name: "just oadp prefix", + normalizedPrefix: "oadp-vmdp/", + expected: "", + }, + { + name: "no oadp prefix", + normalizedPrefix: "something-else/", + expected: "something-else/", + }, + { + name: "empty string", + normalizedPrefix: "", + expected: "", + }, + { + name: "nested paths", + normalizedPrefix: "oadp-vmdp/path/to/backup/", + expected: "path/to/backup/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DenormalizeOADPPrefix(tt.normalizedPrefix) + if result != tt.expected { + t.Errorf("DenormalizeOADPPrefix(%q) = %q, want %q", tt.normalizedPrefix, result, tt.expected) + } + }) + } +} + +func TestGetOADPPrefix(t *testing.T) { + prefix := GetOADPPrefix() + expected := "oadp-vmdp/" + if prefix != expected { + t.Errorf("GetOADPPrefix() = %q, want %q", prefix, expected) + } +} + +func TestIsOADPPrefix(t *testing.T) { + tests := []struct { + name string + prefix string + expected bool + }{ + { + name: "valid OADP prefix", + prefix: "oadp-vmdp/", + expected: true, + }, + { + name: "valid OADP prefix with path", + prefix: "oadp-vmdp/abc/def", + expected: true, + }, + { + name: "invalid prefix", + prefix: "something-else/", + expected: false, + }, + { + name: "empty string", + prefix: "", + expected: false, + }, + { + name: "partial match", + prefix: "oadp-vm", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsOADPPrefix(tt.prefix) + if result != tt.expected { + t.Errorf("IsOADPPrefix(%q) = %v, want %v", tt.prefix, result, tt.expected) + } + }) + } +} + +func TestRoundTripNormalizeDenormalize(t *testing.T) { + tests := []string{ + "abc/", + "abc", + "path/to/backup/", + "", + "single", + } + + for _, userPrefix := range tests { + t.Run("roundtrip_"+userPrefix, func(t *testing.T) { + normalized, err := NormalizeOADPPrefix(userPrefix) + if err != nil { + t.Fatalf("NormalizeOADPPrefix(%q) returned error: %v", userPrefix, err) + } + + denormalized := DenormalizeOADPPrefix(normalized) + if denormalized != userPrefix { + t.Errorf("Round-trip failed: %q -> %q -> %q", userPrefix, normalized, denormalized) + } + }) + } +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/cmd/oadp-vmdp/wrapper.go b/cmd/oadp-vmdp/wrapper.go new file mode 100644 index 00000000000..e77a0e22c7b --- /dev/null +++ b/cmd/oadp-vmdp/wrapper.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/alecthomas/kingpin/v2" + "github.com/kopia/kopia/cli" +) + +// oadpAppWrapper wraps the Kopia CLI app with OADP-specific customizations. +type oadpAppWrapper struct { + kopiaApp *cli.App + kp *kingpin.Application +} + +// newOADPWrapper creates a new OADP wrapper around a Kopia CLI app. +func newOADPWrapper(app *cli.App, kp *kingpin.Application) *oadpAppWrapper { + return &oadpAppWrapper{ + kopiaApp: app, + kp: kp, + } +} + +// setup configures the OADP-specific customizations. +func (w *oadpAppWrapper) setup() { + // Apply OADP branding and help text customizations + w.customizeHelpText() + + // Configure OADP-specific defaults + w.setOADPDefaults() +} + +// customizeHelpText modifies help text to use OADP terminology. +func (w *oadpAppWrapper) customizeHelpText() { + // The kingpin application already has OADP branding from main.go + // Help is handled by help_handler.go which shows clean, filtered output +} + +// setOADPDefaults sets OADP-specific default values. +func (w *oadpAppWrapper) setOADPDefaults() { + // OADP defaults are primarily handled through environment variables + // and command-line flag defaults in the base Kopia CLI. + // This function is reserved for future OADP-specific defaults. +} diff --git a/cmd/oadp-vmdp/wrapper_test.go b/cmd/oadp-vmdp/wrapper_test.go new file mode 100644 index 00000000000..d765f5bb223 --- /dev/null +++ b/cmd/oadp-vmdp/wrapper_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/kopia/kopia/cli" +) + +func TestNewOADPWrapper(t *testing.T) { + app := cli.NewApp() + kp := kingpin.New("test", "test app") + + wrapper := newOADPWrapper(app, kp) + + if wrapper == nil { + t.Fatal("newOADPWrapper returned nil") + } + + if wrapper.kopiaApp != app { + t.Error("wrapper.kopiaApp is not the same as the provided app") + } + + if wrapper.kp != kp { + t.Error("wrapper.kp is not the same as the provided kingpin app") + } +} + +func TestOADPWrapperSetup(t *testing.T) { + app := cli.NewApp() + kp := kingpin.New("test", "test app") + + wrapper := newOADPWrapper(app, kp) + + // Setup should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("wrapper.setup() panicked: %v", r) + } + }() + + wrapper.setup() +} +