Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d3ed12fab | ||
|
|
29809bb8e4 |
No files matched your search
@@ -20,3 +20,4 @@ __pycache__
|
||||
.DS_Store
|
||||
resource_windows_*.syso
|
||||
.devcontainer
|
||||
.vscode/launch.json
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/rclone/rclone/fs/cache"
|
||||
"github.com/rclone/rclone/fs/config/configfile"
|
||||
"github.com/rclone/rclone/fs/config/configflags"
|
||||
profiles "github.com/rclone/rclone/fs/config/configprofiles"
|
||||
"github.com/rclone/rclone/fs/config/flags"
|
||||
"github.com/rclone/rclone/fs/filter"
|
||||
"github.com/rclone/rclone/fs/fserrors"
|
||||
@@ -536,6 +537,7 @@ func AddBackendFlags() {
|
||||
func Main() {
|
||||
setupRootCommand(Root)
|
||||
AddBackendFlags()
|
||||
profiles.AddProfiles(context.Background(), Root)
|
||||
if err := Root.Execute(); err != nil {
|
||||
if strings.HasPrefix(err.Error(), "unknown command") && selfupdateEnabled {
|
||||
Root.PrintErrf("You could use '%s selfupdate' to get latest features.\n\n", Root.CommandPath())
|
||||
|
||||
@@ -26,6 +26,7 @@ func init() {
|
||||
configCommand.AddCommand(configTouchCommand)
|
||||
configCommand.AddCommand(configPathsCommand)
|
||||
configCommand.AddCommand(configShowCommand)
|
||||
configCommand.AddCommand(configProfileCommand)
|
||||
configCommand.AddCommand(configRedactedCommand)
|
||||
configCommand.AddCommand(configDumpCommand)
|
||||
configCommand.AddCommand(configProvidersCommand)
|
||||
@@ -123,6 +124,21 @@ var configShowCommand = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var configProfileCommand = &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: `Manage saved profiles of flags.`,
|
||||
Long: `Manage saved profiles of flags, stored in the config file under
|
||||
"[profile:NAME]" sections.
|
||||
|
||||
Use the save, list, show and delete subcommands. See ` + "`rclone config profile --help`" + `
|
||||
for the full list, and the [Profiles](/docs/#profiles) section of the
|
||||
docs for an overview of the feature.`,
|
||||
Annotations: map[string]string{
|
||||
"versionIntroduced": "v1.66",
|
||||
},
|
||||
Aliases: []string{"profiles"},
|
||||
}
|
||||
|
||||
var configRedactedCommand = &cobra.Command{
|
||||
Use: "redacted [<remote>]",
|
||||
Short: `Print redacted (decrypted) config file, or the redacted config for a single remote.`,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// Profile subcommands for `rclone config profile`.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/rclone/rclone/cmd"
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
profiles "github.com/rclone/rclone/fs/config/configprofiles"
|
||||
"github.com/rclone/rclone/fs/config/flags"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configProfileCommand.AddCommand(configProfileListCommand)
|
||||
configProfileCommand.AddCommand(configProfileShowCommand)
|
||||
configProfileCommand.AddCommand(configProfileDeleteCommand)
|
||||
configProfileCommand.AddCommand(configProfileSaveCommand)
|
||||
|
||||
flags.FVarP(configProfileSaveCommand.Flags(), &profileSaveFlagsFrom,
|
||||
"flags-from", "",
|
||||
"Import command-specific flags from these subcommands (comma separated)", "")
|
||||
configProfileSaveCommand.PersistentPreRunE = profileSaveAddFlagsFrom
|
||||
configProfileSaveCommand.FParseErrWhitelist.UnknownFlags = true
|
||||
}
|
||||
|
||||
// profileSaveFlagsFrom backs --flags-from on `rclone config profile save`.
|
||||
var profileSaveFlagsFrom fs.CommaSepList
|
||||
|
||||
var configProfileListCommand = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the profiles in the config file.",
|
||||
Annotations: map[string]string{
|
||||
"versionIntroduced": "v1.72",
|
||||
},
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(0, 0, command, args)
|
||||
profiles.ShowProfiles()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var configProfileShowCommand = &cobra.Command{
|
||||
Use: "show NAME",
|
||||
Short: "Show the flags saved in a profile.",
|
||||
Annotations: map[string]string{
|
||||
"versionIntroduced": "v1.72",
|
||||
},
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(1, 1, command, args)
|
||||
name := args[0]
|
||||
section := config.ProfileSectionPrefix + name
|
||||
if !config.LoadedData().HasSection(section) {
|
||||
return fmt.Errorf("no profile named %q", name)
|
||||
}
|
||||
fmt.Print(profiles.SprintSection(section))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var configProfileDeleteCommand = &cobra.Command{
|
||||
Use: "delete NAME",
|
||||
Short: "Delete a profile.",
|
||||
Annotations: map[string]string{
|
||||
"versionIntroduced": "v1.72",
|
||||
},
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(1, 1, command, args)
|
||||
name := args[0]
|
||||
section := config.ProfileSectionPrefix + name
|
||||
if !config.LoadedData().HasSection(section) {
|
||||
return fmt.Errorf("no profile named %q", name)
|
||||
}
|
||||
config.LoadedData().DeleteSection(section)
|
||||
if err := config.LoadedData().Save(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var configProfileSaveCommand = &cobra.Command{
|
||||
Use: "save NAME [args...]",
|
||||
Short: "Save the given flags (and optional args) as a reusable profile.",
|
||||
Long: `Save a profile under NAME built from the flags (and, if
|
||||
` + "`--profile-save-args`" + ` is set, the positional arguments) passed
|
||||
on this command line.
|
||||
|
||||
Without ` + "`--flags-from`" + ` only global rclone flags can be saved. To
|
||||
save flags that belong to a specific command (e.g. VFS flags from
|
||||
` + "`rclone mount`" + ` or bisync-specific flags), import that command's
|
||||
flag set with ` + "`--flags-from CMD[,CMD...]`" + `.
|
||||
`,
|
||||
Annotations: map[string]string{
|
||||
"versionIntroduced": "v1.72",
|
||||
},
|
||||
RunE: func(command *cobra.Command, args []string) error {
|
||||
cmd.CheckArgs(1, 100, command, args)
|
||||
name := args[0]
|
||||
ctx := context.Background()
|
||||
opt := profiles.ProfileOptFromCtx(ctx)
|
||||
p := profiles.NewProfile(name)
|
||||
p.NewFromCommand(command, args[1:])
|
||||
return p.Save(ctx, &opt)
|
||||
},
|
||||
}
|
||||
|
||||
// profileSaveAddFlagsFrom is the PersistentPreRunE for
|
||||
// `rclone config profile save`. It interprets --flags-from, merges in
|
||||
// the requested commands' flag sets, then re-parses the command line so
|
||||
// the newly-added flags pick up their values.
|
||||
func profileSaveAddFlagsFrom(saveCmd *cobra.Command, args []string) error {
|
||||
// The first parse was done with UnknownFlags=true so --verbose may
|
||||
// have been counted; preserve its current value across the reparse.
|
||||
var verboseSaved string
|
||||
if vf := saveCmd.Flags().Lookup("verbose"); vf != nil {
|
||||
verboseSaved = vf.Value.String()
|
||||
defer func() {
|
||||
if err := vf.Value.Set(verboseSaved); err != nil {
|
||||
fs.Errorf(nil, "error restoring --verbose: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, sub := range profileSaveFlagsFrom {
|
||||
sc, _, err := cmd.Root.Find([]string{sub})
|
||||
if err != nil {
|
||||
return fmt.Errorf("--flags-from %s: %w", sub, err)
|
||||
}
|
||||
if sc == cmd.Root {
|
||||
return fmt.Errorf("--flags-from %s: not a known subcommand", sub)
|
||||
}
|
||||
saveCmd.Flags().AddFlagSet(sc.Flags())
|
||||
}
|
||||
|
||||
// Re-parse strictly now that the flag set is complete.
|
||||
saveCmd.FParseErrWhitelist.UnknownFlags = false
|
||||
flagArgs, err := argsAfterCommand(saveCmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveCmd.ParseFlags(flagArgs)
|
||||
}
|
||||
|
||||
// argsAfterCommand returns the portion of os.Args that came after the
|
||||
// chain of cobra command names leading to cmd.
|
||||
func argsAfterCommand(cmd *cobra.Command) ([]string, error) {
|
||||
depth := 0
|
||||
for c := cmd; c != nil; c = c.Parent() {
|
||||
depth++
|
||||
}
|
||||
if depth > len(os.Args) {
|
||||
return nil, errors.New("internal error: command depth exceeds os.Args length")
|
||||
}
|
||||
return os.Args[depth:], nil
|
||||
}
|
||||
@@ -542,6 +542,212 @@ If two remotes set the same global variable then the first one instantiated will
|
||||
be overridden by the second one. A `global.var` will override all other config
|
||||
methods when the remote is created.
|
||||
|
||||
## Profiles {#profiles}
|
||||
|
||||
Profiles let you save a named set of flags (and optionally arguments)
|
||||
in the config file and reuse them on later command lines, so you
|
||||
don't have to retype long flag combinations every time.
|
||||
|
||||
For example, if you regularly sync a directory to Google Drive with a
|
||||
specific set of flags, you can save that combination once as a
|
||||
profile and from then on use `--profile mybackup` instead.
|
||||
|
||||
### Saving a profile
|
||||
|
||||
Add `--profile-save NAME` to any rclone command. The command will run
|
||||
as normal, and rclone will additionally write all the explicitly-set
|
||||
flags from that command line into the config file under a new section
|
||||
named `[profile:NAME]`. The `profile:` prefix is the namespace
|
||||
profiles live in - because `:` is illegal in remote names, a profile
|
||||
called `backup` and a remote called `backup` can coexist without
|
||||
colliding.
|
||||
|
||||
For example:
|
||||
|
||||
```sh
|
||||
rclone sync src: dst: --checkers 16 --transfers 8 --metadata --profile-save fast-sync
|
||||
```
|
||||
|
||||
writes:
|
||||
|
||||
```ini
|
||||
[profile:fast-sync]
|
||||
checkers = 16
|
||||
transfers = 8
|
||||
metadata = true
|
||||
```
|
||||
|
||||
Only flags whose values you explicitly changed are saved - flags left
|
||||
at their default are not.
|
||||
|
||||
The profile-control flags themselves (`--profile-save`, `--profile`,
|
||||
`--profile-save-args`, `--profile-strict-flags`) are never saved
|
||||
into the profile. `--config` is also excluded - it's
|
||||
environment-specific and would re-point the config storage mid-load
|
||||
when applied from a profile.
|
||||
|
||||
### Using a profile
|
||||
|
||||
Add `--profile NAME` to apply a saved profile:
|
||||
|
||||
```sh
|
||||
rclone sync src: dst: --profile fast-sync
|
||||
```
|
||||
|
||||
This is equivalent to repeating all the flags from the saved profile
|
||||
on the command line.
|
||||
|
||||
You can apply several profiles in one go by passing a comma-separated
|
||||
list. They are applied in lowest-to-highest priority order, so later
|
||||
profiles override earlier ones:
|
||||
|
||||
```sh
|
||||
rclone sync src: dst: --profile common,fast-sync
|
||||
```
|
||||
|
||||
Any flag you pass explicitly on the command line overrides the
|
||||
profile's value for that flag, so you can use a profile as a baseline
|
||||
and tweak individual values for one run.
|
||||
|
||||
For safety, a profile can never turn off `--dry-run` if it was set
|
||||
explicitly on the command line.
|
||||
|
||||
### Including command arguments
|
||||
|
||||
By default `--profile-save` only saves flags. If you also want to
|
||||
save the positional arguments (e.g. the source and destination paths)
|
||||
add `--profile-save-args`:
|
||||
|
||||
```sh
|
||||
rclone sync /local/path remote:backup --profile-save nightly --profile-save-args
|
||||
```
|
||||
|
||||
When the profile is later used, saved arguments are applied
|
||||
automatically - you don't need to pass anything extra at use time.
|
||||
If you pass your own positional arguments on the command line, they
|
||||
take precedence over the profile's saved ones (the profile only
|
||||
fills slots you didn't provide).
|
||||
|
||||
### Strict flag checking
|
||||
|
||||
When you save a profile from one command (say `sync`) and then apply
|
||||
it to another (say `copy`), the profile may contain flags that don't
|
||||
exist on the new command. By default these flags are silently
|
||||
ignored. Pass `--profile-strict-flags` to make rclone error out
|
||||
instead of skipping them.
|
||||
|
||||
### Command-specific flags
|
||||
|
||||
Profiles work with the flag set of the command they're saved from
|
||||
and applied to, so command-specific flags (e.g. the [VFS
|
||||
flags](/commands/rclone_mount/#vfs-options) on `rclone mount`, or
|
||||
bisync's `--resilient`) are saved and loaded just like global flags:
|
||||
|
||||
```sh
|
||||
rclone mount remote: /mnt --vfs-cache-mode full --dir-cache-time 24h \
|
||||
--profile-save media-mount
|
||||
|
||||
rclone mount remote: /mnt --profile media-mount
|
||||
```
|
||||
|
||||
The resulting section looks like any other profile:
|
||||
|
||||
```ini
|
||||
[profile:media-mount]
|
||||
vfs_cache_mode = full
|
||||
dir_cache_time = 24h0m0s
|
||||
```
|
||||
|
||||
If you later apply this profile to a different command that doesn't
|
||||
have those flags (e.g. `rclone copy --profile media-mount`), the
|
||||
VFS flags are silently skipped - or rejected if you also pass
|
||||
`--profile-strict-flags`.
|
||||
|
||||
To build a profile for command-specific flags without actually
|
||||
running the command, use `rclone config profile save NAME --flags-from CMD`:
|
||||
|
||||
```sh
|
||||
rclone config profile save media-mount --flags-from mount \
|
||||
--vfs-cache-mode full --dir-cache-time 24h
|
||||
```
|
||||
|
||||
### Inheriting from parent profiles
|
||||
|
||||
A profile can inherit settings from one or more parent profiles by
|
||||
listing them in `parent_profiles` in the config:
|
||||
|
||||
```ini
|
||||
[profile:shared]
|
||||
checkers = 16
|
||||
metadata = true
|
||||
|
||||
[profile:nightly]
|
||||
parent_profiles = shared
|
||||
transfers = 4
|
||||
```
|
||||
|
||||
Parent names are also given in their user-facing form (without the
|
||||
`profile:` prefix).
|
||||
|
||||
Using `--profile nightly` first applies `shared`, then applies
|
||||
`nightly` on top, so `nightly` overrides any flag the two have in
|
||||
common. Parents themselves can have parents - profiles are resolved
|
||||
recursively.
|
||||
|
||||
### Always applying a profile
|
||||
|
||||
There is no "default profile" mechanism - if you want a profile to
|
||||
apply every time, set it in the environment instead:
|
||||
|
||||
```sh
|
||||
export RCLONE_PROFILE=mybackup
|
||||
```
|
||||
|
||||
`--profile` is exposed as a standard rclone option, so the
|
||||
`RCLONE_PROFILE` environment variable Just Works. Use a shell
|
||||
alias or wrapper script if you want different defaults in different
|
||||
contexts. This keeps "what flags am I actually running with" answerable
|
||||
from the command line and environment alone, with no spooky action
|
||||
from the config file.
|
||||
|
||||
### Managing profiles
|
||||
|
||||
Use the `rclone config profile` subcommands to manage profiles
|
||||
without editing the config file by hand:
|
||||
|
||||
```sh
|
||||
rclone config profile save NAME [args...] [flags...]
|
||||
rclone config profile list
|
||||
rclone config profile show NAME
|
||||
rclone config profile delete NAME
|
||||
```
|
||||
|
||||
- `save` writes a new profile from the flags (and optional positional
|
||||
args, with `--profile-save-args`) given on the command line.
|
||||
Pass `--flags-from CMD[,CMD...]` to make flags from other commands
|
||||
(e.g. `mount`, `bisync`) available for saving without having to
|
||||
actually run those commands.
|
||||
- `list` prints the names of all profiles in the config file.
|
||||
- `show` prints the keys and values stored in one profile.
|
||||
- `delete` removes a profile.
|
||||
|
||||
To edit an existing profile, either re-save it (overwriting the old
|
||||
one) or edit the corresponding `[profile:NAME]` section in the
|
||||
config file by hand.
|
||||
|
||||
### Config file layout
|
||||
|
||||
Profiles live under the `[profile:NAME]` namespace in the config
|
||||
file. The `profile:` prefix is the canonical signal that a section
|
||||
is a profile - `:` is illegal in remote names, so profiles and
|
||||
remotes can share a name without colliding. Profile sections are
|
||||
hidden from `rclone listremotes`, `rclone config show <remote>` and
|
||||
the rc API's `config/dump` (they don't appear as remotes).
|
||||
|
||||
Saved flags are written with their option name in snake_case (e.g.
|
||||
`drive-skip-gdocs` becomes `drive_skip_gdocs = true`). Saved
|
||||
arguments are written as `arg_1 = ...`, `arg_2 = ...` and so on.
|
||||
|
||||
## Quoting and the shell
|
||||
|
||||
When you are typing commands to your computer you are using something
|
||||
@@ -2464,6 +2670,36 @@ See the [Configuration Encryption](#configuration-encryption) for more info.
|
||||
|
||||
See a [Windows PowerShell example on the Wiki](https://github.com/rclone/rclone/wiki/Windows-Powershell-use-rclone-password-command-for-Config-file-password).
|
||||
|
||||
### --profile CommaSepList
|
||||
|
||||
Apply one or more saved profiles to this command, as if their flags
|
||||
had been typed on the command line. Multiple profiles are
|
||||
comma-separated and applied lowest-to-highest priority (later
|
||||
profiles override earlier ones). Explicit command-line flags still
|
||||
take precedence over the profile. See the [Profiles](#profiles)
|
||||
section.
|
||||
|
||||
### --profile-save string
|
||||
|
||||
Save the explicitly-set flags from this command (and optionally its
|
||||
arguments, with `--profile-save-args`) into the config file as a
|
||||
reusable profile under the given name. The command still runs as
|
||||
normal. See the [Profiles](#profiles) section.
|
||||
|
||||
### --profile-save-args
|
||||
|
||||
When used with `--profile-save`, also save the command's positional
|
||||
arguments (e.g. the source and destination paths) into the profile.
|
||||
At use time the saved arguments are applied automatically (the user's
|
||||
own positional arguments, if any, take precedence). See the
|
||||
[Profiles](#profiles) section.
|
||||
|
||||
### --profile-strict-flags
|
||||
|
||||
When used with `--profile`, error out if the profile contains a
|
||||
flag that isn't valid for the current command. The default is to
|
||||
silently skip such flags. See the [Profiles](#profiles) section.
|
||||
|
||||
### -P, --progress
|
||||
|
||||
This flag makes rclone update the stats in a static block in the
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
backend: protondrive
|
||||
name: Proton Drive
|
||||
tier: Tier 5
|
||||
maintainers: None
|
||||
tier: Tier 4
|
||||
maintainers: Community
|
||||
features_score: 4
|
||||
integration_tests: Failing
|
||||
integration_tests: Passing
|
||||
data_integrity: Hash
|
||||
performance: High
|
||||
adoption: Often used
|
||||
|
||||
@@ -566,6 +566,26 @@ var ConfigOptionsInfo = Options{{
|
||||
Default: "",
|
||||
Help: "HTTP proxy URL.",
|
||||
Groups: "Networking",
|
||||
}, {
|
||||
Name: "profile_save",
|
||||
Default: "",
|
||||
Help: "Save this flag configuration as a reusable preset",
|
||||
Groups: "Config",
|
||||
}, {
|
||||
Name: "profile",
|
||||
Default: []string{},
|
||||
Help: "Apply the flag configuration saved with this name. If more than one is provided, priority will be lowest to highest.",
|
||||
Groups: "Config",
|
||||
}, {
|
||||
Name: "profile_save_args",
|
||||
Default: false,
|
||||
Help: "When used with --profile-save, also save positional args (e.g. the paths being synced) into the profile",
|
||||
Groups: "Config",
|
||||
}, {
|
||||
Name: "profile_strict_flags",
|
||||
Default: false,
|
||||
Help: "If set, --profile will error if any flags are invalid for this command, instead of ignoring.",
|
||||
Groups: "Config",
|
||||
}}
|
||||
|
||||
// ConfigInfo is filesystem config options
|
||||
@@ -680,6 +700,10 @@ type ConfigInfo struct {
|
||||
MaxConnections int `config:"max_connections"`
|
||||
NameTransform []string `config:"name_transform"`
|
||||
HTTPProxy string `config:"http_proxy"`
|
||||
SaveProfile string `config:"profile_save"`
|
||||
UseProfile CommaSepList `config:"profile"`
|
||||
ProfileSaveArgs bool `config:"profile_save_args"`
|
||||
ProfileStrictFlags bool `config:"profile_strict_flags"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -396,6 +396,19 @@ func SaveConfig() {
|
||||
fs.Errorf(nil, "Failed to save config after %d tries: %v", ci.LowLevelRetries, err)
|
||||
}
|
||||
|
||||
// ProfileSectionPrefix is the namespace prefix used for profile
|
||||
// sections in the config file (e.g. [profile:fast-sync]). It lets
|
||||
// profiles and remotes share the config file without colliding on
|
||||
// names: a section called "backup" is a remote, "profile:backup" is
|
||||
// a profile.
|
||||
const ProfileSectionPrefix = "profile:"
|
||||
|
||||
// IsProfileSection reports whether the given section name lives in
|
||||
// the profiles namespace.
|
||||
func IsProfileSection(name string) bool {
|
||||
return strings.HasPrefix(name, ProfileSectionPrefix)
|
||||
}
|
||||
|
||||
// FileSections returns the sections in the config file
|
||||
func FileSections() []string {
|
||||
return LoadedData().GetSectionList()
|
||||
@@ -710,6 +723,9 @@ func DumpRcRemote(name string) (dump rc.Params) {
|
||||
func DumpRcBlob() (dump rc.Params) {
|
||||
dump = rc.Params{}
|
||||
for _, name := range LoadedData().GetSectionList() {
|
||||
if IsProfileSection(name) {
|
||||
continue
|
||||
}
|
||||
dump[name] = DumpRcRemote(name)
|
||||
}
|
||||
return dump
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
// Package profiles handles presets for config
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configflags"
|
||||
"github.com/rclone/rclone/fs/operations"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// ignoredFlags are flag names (in profile-key form, i.e. snake_case)
|
||||
// that must never be persisted into a profile or copied out of one.
|
||||
//
|
||||
// "config" is excluded because applying it from a profile would
|
||||
// re-point the config-file storage mid-Use(), while we're reading
|
||||
// the profile out of that very file.
|
||||
var ignoredFlags = []string{"profile", "profile_save", "profile_save_args", "profile_strict_flags", "flags_from", "config"}
|
||||
|
||||
const (
|
||||
argPrefix = "arg_"
|
||||
parentKey = "parent_profiles"
|
||||
)
|
||||
|
||||
// ProfileOpt stores the settings for Profiles
|
||||
type ProfileOpt struct {
|
||||
SaveProfile string // name of profile to save
|
||||
UseProfile fs.CommaSepList // name(s) of profiles to use
|
||||
StrictFlags bool // If set, --profile will error if any flags are invalid for this command, instead of ignoring.
|
||||
SaveArgs bool // When saving, also include positional args (e.g. the paths being synced)
|
||||
}
|
||||
|
||||
// Profile represents a flag/arg configuration to save/use as a reusable preset
|
||||
type Profile struct {
|
||||
Name string // the name of the profile
|
||||
Args []string // command args (ex. the paths being synced)
|
||||
Flags map[string]string // any flags (command, config, backend...)
|
||||
Parents []string // see p.GetParents() to get a slice of *Profiles instead
|
||||
}
|
||||
|
||||
// NewProfile returns a new empty *Profile
|
||||
func NewProfile(name string) *Profile {
|
||||
return &Profile{
|
||||
Name: name,
|
||||
Args: []string{},
|
||||
Flags: map[string]string{},
|
||||
Parents: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileOptFromCtx gets a new ProfileOpt from config settings
|
||||
func ProfileOptFromCtx(ctx context.Context) ProfileOpt {
|
||||
ci := fs.GetConfig(ctx)
|
||||
return ProfileOpt{
|
||||
SaveProfile: ci.SaveProfile,
|
||||
UseProfile: ci.UseProfile,
|
||||
StrictFlags: ci.ProfileStrictFlags,
|
||||
SaveArgs: ci.ProfileSaveArgs,
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromCommand sets the profile's Args and Flags from the cmd and args passed in
|
||||
func (p *Profile) NewFromCommand(cmd *cobra.Command, args []string) {
|
||||
p.Args = args
|
||||
|
||||
setProfileVal := func(flag *pflag.Flag) {
|
||||
key := optionToProfileKey(flag.Name)
|
||||
if slices.Contains(ignoredFlags, key) {
|
||||
return
|
||||
}
|
||||
if !flag.Changed {
|
||||
return
|
||||
}
|
||||
p.Flags[key] = flag.Value.String()
|
||||
}
|
||||
|
||||
// .Visit() does not visit the flags we add manually via
|
||||
// --flags-from (in `rclone config profile save`), but .VisitAll()
|
||||
// with a flag.Changed check does, so use that.
|
||||
cmd.Flags().VisitAll(setProfileVal)
|
||||
}
|
||||
|
||||
// sectionName returns the config-file section name for the profile
|
||||
// "name" (e.g. "fast" -> "profile:fast"). Profile.Name itself stays
|
||||
// in its user-facing form.
|
||||
func sectionName(name string) string {
|
||||
return config.ProfileSectionPrefix + name
|
||||
}
|
||||
|
||||
// GetProfile gets a profile from the config
|
||||
func GetProfile(name string) (*Profile, error) {
|
||||
p := NewProfile(name)
|
||||
section := sectionName(p.Name)
|
||||
data := config.LoadedData()
|
||||
if !data.HasSection(section) {
|
||||
return nil, fmt.Errorf("no profile named %q found in config file", p.Name)
|
||||
}
|
||||
|
||||
// Args may appear out of order or be sparse (e.g. arg_3 without
|
||||
// arg_1) so collect them first then size the slice from the max
|
||||
// index.
|
||||
args := map[int]string{}
|
||||
|
||||
keys := data.GetKeyList(section)
|
||||
for _, key := range keys {
|
||||
if slices.Contains(ignoredFlags, key) {
|
||||
continue
|
||||
}
|
||||
|
||||
if key == parentKey {
|
||||
parentList, _ := data.GetValue(section, key)
|
||||
p.Parents = strings.Split(parentList, ",")
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, argPrefix) {
|
||||
i, err := argNum(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing arg %s: %v", key, err)
|
||||
}
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("arg index out of range: %s", key)
|
||||
}
|
||||
val, _ := data.GetValue(section, key)
|
||||
args[i] = val
|
||||
continue
|
||||
}
|
||||
|
||||
val, _ := data.GetValue(section, key)
|
||||
p.Flags[key] = val
|
||||
}
|
||||
if len(args) > 0 {
|
||||
maxIdx := -1
|
||||
for i := range args {
|
||||
if i > maxIdx {
|
||||
maxIdx = i
|
||||
}
|
||||
}
|
||||
p.Args = make([]string, maxIdx+1)
|
||||
for i, v := range args {
|
||||
p.Args[i] = v
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProfiles gets a slice of profiles
|
||||
func GetProfiles(names []string) ([]*Profile, error) {
|
||||
profiles := []*Profile{}
|
||||
for _, name := range names {
|
||||
p, err := GetProfile(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
// UseProfiles gets one or more Profiles from opt.UseProfile and applies them to context (priority: lowest to highest)
|
||||
func UseProfiles(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt) error {
|
||||
for _, profileName := range opt.UseProfile {
|
||||
err := GetAndUseProfile(ctx, cmd, args, opt, profileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAndUseProfile gets one Profile and uses it
|
||||
func GetAndUseProfile(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt, profileName string) error {
|
||||
profile, err := GetProfile(profileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = profile.Use(ctx, cmd, args, opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save saves a profile to the config file
|
||||
func (p *Profile) Save(ctx context.Context, opt *ProfileOpt) error {
|
||||
var err error
|
||||
p.Name, err = validateProfileName(p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if operations.SkipDestructive(ctx, p.Name, "save profile") {
|
||||
return nil
|
||||
}
|
||||
|
||||
section := sectionName(p.Name)
|
||||
data := config.LoadedData()
|
||||
data.DeleteSection(section) // clear it if already exists
|
||||
|
||||
for k, v := range p.Flags {
|
||||
// Defensive normalisation: callers building a *Profile
|
||||
// directly may pass keys in either form.
|
||||
data.SetValue(section, optionToProfileKey(k), v)
|
||||
}
|
||||
|
||||
if opt.SaveArgs {
|
||||
p.saveArgs()
|
||||
}
|
||||
|
||||
if len(p.Parents) > 0 {
|
||||
data.SetValue(section, parentKey, strings.Join(p.Parents, ","))
|
||||
}
|
||||
|
||||
if err := data.Save(); err != nil {
|
||||
return fmt.Errorf("error saving config: %v", err)
|
||||
}
|
||||
|
||||
fs.Debugf(p.Name, "saved profile: %v", SprintSection(sectionName(p.Name)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Profile) saveArgs() {
|
||||
section := sectionName(p.Name)
|
||||
for i, arg := range p.Args {
|
||||
config.LoadedData().SetValue(section, argPrefix+fmt.Sprint(i+1), arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Use sets the current command args and flags from the profile struct
|
||||
func (p *Profile) Use(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) error {
|
||||
// set logging first
|
||||
ci := fs.GetConfig(ctx)
|
||||
section := sectionName(p.Name)
|
||||
if ci.LogLevel == fs.LogLevelNotice {
|
||||
if verbose, ok := config.FileGetValue(section, "verbose"); ok {
|
||||
_ = cmd.Flags().Lookup("verbose").Value.Set(verbose)
|
||||
configflags.SetFlags(ci)
|
||||
}
|
||||
}
|
||||
fs.Debugf(p.Name, "loading profile: %v", SprintSection(section))
|
||||
|
||||
err := p.useParents(ctx, cmd, cobraArgs, opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = p.useFlags(ctx, cmd, cobraArgs, opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply saved args whenever the profile carries them. Whether or
|
||||
// not args were saved is a per-profile decision made at save time
|
||||
// (--profile-save-args); the user shouldn't have to know or
|
||||
// re-pass that knob at use time.
|
||||
if len(p.Args) > 0 {
|
||||
p.useArgs(ctx, cmd, cobraArgs, opt)
|
||||
}
|
||||
|
||||
// Copy any option-backed flag values we just changed (e.g.
|
||||
// --checkers, --transfers, --metadata, backend flags) out of the
|
||||
// pflag/options registry and into ConfigInfo.
|
||||
if err := fs.GlobalOptionsInit(); err != nil {
|
||||
return fmt.Errorf("failed to reload global options from profile: %w", err)
|
||||
}
|
||||
// Re-run configflags.SetFlags() for the hand-rolled flags it owns
|
||||
// (-v, -q, --bind, --headers, --config, --cache-dir...).
|
||||
configflags.SetFlags(ci)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Profile) useFlags(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) error {
|
||||
ci := fs.GetConfig(ctx)
|
||||
for rawKey, val := range p.Flags {
|
||||
key := optionToProfileKey(rawKey) // tolerate hyphenated keys from external callers
|
||||
if slices.Contains(ignoredFlags, key) {
|
||||
continue
|
||||
}
|
||||
|
||||
flag := cmd.Flags().Lookup(profileKeyToFlag(key))
|
||||
if flag == nil {
|
||||
if opt.StrictFlags {
|
||||
return fmt.Errorf("invalid flag: %s", profileKeyToFlag(key))
|
||||
}
|
||||
fs.Debugf(p.Name, "ignoring unknown flag %s for this command", profileKeyToFlag(key))
|
||||
continue
|
||||
}
|
||||
|
||||
if ci.DryRun && key == "dry_run" && val == "false" {
|
||||
// disallow overriding --dry-run if it was specifically set
|
||||
fs.Logf(nil, "for safety, profiles cannot change --dry-run from true to false. Ignoring.")
|
||||
continue
|
||||
}
|
||||
logChange(p.Name, key, flag.Value.String(), val)
|
||||
err := flag.Value.Set(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: error setting val %s from profile: %v", flag.Name, val, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// useArgs fills positional args from the profile's saved Args list,
|
||||
// but only into slots the user did not provide on the command line.
|
||||
// We don't CheckArgs() here as the command will do it later; users
|
||||
// should take care to not exceed MaxArgs.
|
||||
//
|
||||
// Args on parent profiles are generally a bad idea (children would
|
||||
// overwrite parent), but the same precedence rule applies if you
|
||||
// use them.
|
||||
func (p *Profile) useArgs(ctx context.Context, cmd *cobra.Command, cobraArgs []string, opt *ProfileOpt) {
|
||||
userProvided := len(cobraArgs)
|
||||
for i, pArg := range p.Args { // we DO assume that the p.Args are in the right order here.
|
||||
if pArg == "" {
|
||||
continue
|
||||
}
|
||||
if i < userProvided {
|
||||
// User gave an explicit positional arg for this slot - keep it.
|
||||
continue
|
||||
}
|
||||
if i > len(cobraArgs)-1 {
|
||||
cobraArgs = append(cobraArgs, "")
|
||||
}
|
||||
logChange(p.Name, fmt.Sprintf("Arg %d", i+1), cobraArgs[i], pArg)
|
||||
cobraArgs[i] = pArg
|
||||
}
|
||||
|
||||
// rclone command [args...]
|
||||
os.Args = append([]string{os.Args[0], os.Args[1]}, cobraArgs...)
|
||||
os.Args = cleanArgs(os.Args)
|
||||
}
|
||||
|
||||
// allows nested profiles (priority lowest to highest, parents all lower than child)
|
||||
func (p *Profile) useParents(ctx context.Context, cmd *cobra.Command, args []string, opt *ProfileOpt) error {
|
||||
if len(p.Parents) == 0 {
|
||||
return nil
|
||||
}
|
||||
newopt := *opt
|
||||
newopt.UseProfile = p.Parents
|
||||
return UseProfiles(ctx, cmd, args, &newopt)
|
||||
}
|
||||
|
||||
// GetParents returns the parent profiles of this profile
|
||||
func (p *Profile) GetParents() ([]*Profile, error) {
|
||||
return GetProfiles(p.Parents)
|
||||
}
|
||||
|
||||
/*
|
||||
TODO:
|
||||
- should we be storing more in memory to cut down on config.LoadedData() calls?
|
||||
- when profile/key names have illegal characters should we error or auto-convert them? currently we do some of both.
|
||||
- more tests
|
||||
- docs
|
||||
- document handling of blank or default values
|
||||
- UI could use some cleanup (and see the note there about the use of the word "remote")
|
||||
- rc methods
|
||||
- API for other commands (bisync) to save/use profiles
|
||||
- better handle scenario where logging level passed in is higher than that of the profile we're getting/using...
|
||||
...probably needs a defer to make sure debugs are seen (if requested) during profile setting, then set the real level (from profile) after
|
||||
- maybe edit more of the helper functions to use the types and methods instead of modifying config directly
|
||||
- the ProfileOpt logic maybe needs another look -- slightly confusing that we store the profile name both there and in the profile struct.
|
||||
- search other TODOs
|
||||
*/
|
||||
@@ -0,0 +1,47 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestProfileRequested(t *testing.T) {
|
||||
// Clear any RCLONE_PROFILE* env vars set in the caller's
|
||||
// environment so they don't leak into the test cases. t.Setenv
|
||||
// restores them automatically.
|
||||
t.Setenv("RCLONE_PROFILE", "")
|
||||
t.Setenv("RCLONE_PROFILE_SAVE", "")
|
||||
|
||||
savedArgs := os.Args
|
||||
defer func() { os.Args = savedArgs }()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
env map[string]string
|
||||
expect bool
|
||||
}{
|
||||
{"no flag, no env", []string{"rclone", "ls", "remote:"}, nil, false},
|
||||
{"--profile NAME", []string{"rclone", "ls", "remote:", "--profile", "x"}, nil, true},
|
||||
{"--profile=NAME", []string{"rclone", "ls", "remote:", "--profile=x"}, nil, true},
|
||||
{"--profile-save NAME", []string{"rclone", "ls", "remote:", "--profile-save", "x"}, nil, true},
|
||||
{"--profile-save=NAME", []string{"rclone", "ls", "remote:", "--profile-save=x"}, nil, true},
|
||||
{"--profile-save-args alone does not trigger", []string{"rclone", "ls", "remote:", "--profile-save-args"}, nil, false},
|
||||
{"--profile-strict-flags alone does not trigger", []string{"rclone", "ls", "remote:", "--profile-strict-flags"}, nil, false},
|
||||
{"RCLONE_PROFILE env var", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE": "x"}, true},
|
||||
{"RCLONE_PROFILE_SAVE env var", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE_SAVE": "x"}, true},
|
||||
{"empty RCLONE_PROFILE does not trigger", []string{"rclone", "ls", "remote:"}, map[string]string{"RCLONE_PROFILE": ""}, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
os.Args = c.args
|
||||
for k, v := range c.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
assert.Equal(t, c.expect, profileRequested())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package profiles handles presets for config
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configfile"
|
||||
"github.com/rclone/rclone/fs/config/configflags"
|
||||
"github.com/rclone/rclone/fs/fspath"
|
||||
fslog "github.com/rclone/rclone/fs/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// AddProfiles handles saving/loading of config profiles
|
||||
// We do this before Root.Execute() because there's otherwise
|
||||
// no good way to add args to an already-executing command
|
||||
func AddProfiles(ctx context.Context, Root *cobra.Command) {
|
||||
if !profileRequested() {
|
||||
return
|
||||
}
|
||||
cmd, remaining, err := Root.Find(os.Args[1:])
|
||||
if err != nil {
|
||||
fs.Fatalf(nil, "Fatal error: %v", err)
|
||||
}
|
||||
// Anything under `rclone config` (notably `config profile save`)
|
||||
// manages profiles directly and must not be intercepted here.
|
||||
for c := cmd; c != nil; c = c.Parent() {
|
||||
if c.Name() == "config" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start the logger so debug/info messages from the profile code
|
||||
// reach the user; the normal cobra.OnInitialize path hasn't run
|
||||
// yet because Root.Execute() hasn't been called.
|
||||
fslog.InitLogging()
|
||||
|
||||
if err := cmd.Flags().Parse(remaining); err != nil {
|
||||
fs.Fatalf(nil, "Fatal error: %v", err)
|
||||
}
|
||||
|
||||
ci := fs.GetConfig(ctx)
|
||||
// The hand-rolled flags (--config, --cache-dir, -v, -q, ...) live
|
||||
// on pflag.CommandLine rather than on `cmd`, so parse it as well
|
||||
// to populate the package vars configflags.SetFlags reads from.
|
||||
if err := pflag.CommandLine.Parse(os.Args[1:]); err != nil {
|
||||
fs.Fatalf(nil, "Fatal error: %v", err)
|
||||
}
|
||||
// Populate ConfigInfo from the option-registry values (covers
|
||||
// every option-backed flag including --profile, --profile-save,
|
||||
// and env vars like RCLONE_PROFILE).
|
||||
if err := fs.GlobalOptionsInit(); err != nil {
|
||||
fs.Fatalf(nil, "Failed to initialise global options: %v", err)
|
||||
}
|
||||
// Apply the hand-rolled flags last - SetFlags writes directly to
|
||||
// ConfigInfo (e.g. ci.LogLevel from -vv) and would otherwise be
|
||||
// overwritten by GlobalOptionsInit reloading from the option
|
||||
// defaults.
|
||||
configflags.SetFlags(ci)
|
||||
// Install the config file handler *after* SetFlags has applied
|
||||
// --config (or RCLONE_CONFIG), otherwise the install is a no-op
|
||||
// against an empty path and any save goes to an in-memory
|
||||
// storage that's discarded at exit.
|
||||
configfile.Install()
|
||||
|
||||
if err := HandleProfiles(ctx, cmd, cmd.Flags().Args()); err != nil {
|
||||
fs.Fatalf(nil, "Fatal error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleProfiles handles --profile-save and --profile
|
||||
func HandleProfiles(ctx context.Context, cmd *cobra.Command, args []string) error {
|
||||
opt := ProfileOptFromCtx(ctx)
|
||||
|
||||
if len(opt.UseProfile) > 0 {
|
||||
err := UseProfiles(ctx, cmd, args, &opt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if opt.SaveProfile != "" {
|
||||
saveProfile := NewProfile(opt.SaveProfile)
|
||||
saveProfile.NewFromCommand(cmd, args)
|
||||
return saveProfile.Save(ctx, &opt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func argNum(key string) (int, error) {
|
||||
s := strings.TrimPrefix(key, argPrefix)
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return i, err
|
||||
}
|
||||
return i - 1, nil
|
||||
}
|
||||
|
||||
func optionToProfileKey(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, " ", "_")
|
||||
return strings.ToLower(strings.ReplaceAll(name, "-", "_"))
|
||||
}
|
||||
|
||||
// note that this is case-insensitive
|
||||
func profileKeyToFlag(name string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(name, "_", "-"))
|
||||
}
|
||||
|
||||
func validateKeyName(name string) (convertedName string, err error) {
|
||||
convertedName = fspath.MakeConfigName(optionToProfileKey(name))
|
||||
if convertedName != name {
|
||||
return convertedName, errors.New("invalid characters detected")
|
||||
}
|
||||
return convertedName, nil
|
||||
}
|
||||
|
||||
func validateProfileName(name string) (convertedName string, err error) {
|
||||
// Profiles live in the "profile:" namespace so there is no
|
||||
// conflict possible with remote names - we only need to validate
|
||||
// the name's character set.
|
||||
return validateKeyName(name)
|
||||
}
|
||||
|
||||
func logChange(useProfileName, key, oldval, newval string) {
|
||||
if oldval == newval {
|
||||
return
|
||||
}
|
||||
if oldval == "" {
|
||||
oldval = "[blank]"
|
||||
}
|
||||
fs.Infof(useProfileName, "changing %s from %s to %s", key, oldval, newval)
|
||||
}
|
||||
|
||||
// SprintSection returns a string of this section from the config file
|
||||
func SprintSection(name string) string {
|
||||
keys := config.LoadedData().GetKeyList(name)
|
||||
s := fmt.Sprintf("\n[%s]\n", name)
|
||||
for _, key := range keys {
|
||||
if value, ok := config.FileGetValue(name, key); ok {
|
||||
s += fmt.Sprintf("%s%s%s\n", key, " = ", value)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func cleanArgs(s []string) []string {
|
||||
var r []string
|
||||
// s = s[:cap(s)]
|
||||
for _, str := range s {
|
||||
if str != "" {
|
||||
r = append(r, str)
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// profileRequested returns true when the user has asked for profile
|
||||
// machinery to fire on this invocation, via either a CLI flag or an
|
||||
// env var.
|
||||
//
|
||||
// We need this *before* cobra/options have done any of their work,
|
||||
// so we look at the raw os.Args / os.Environ directly rather than
|
||||
// reading from ConfigInfo (which is still populated from defaults
|
||||
// at this point).
|
||||
func profileRequested() bool {
|
||||
if os.Getenv("RCLONE_PROFILE") != "" || os.Getenv("RCLONE_PROFILE_SAVE") != "" {
|
||||
return true
|
||||
}
|
||||
return slices.ContainsFunc(os.Args, func(a string) bool {
|
||||
// Exact "--profile" / "--profile-save" and their "=value" forms.
|
||||
// Anything else (e.g. --profile-save-args, --profile-strict-flags)
|
||||
// is a modifier and does not on its own trigger the profile
|
||||
// machinery.
|
||||
return a == "--profile" || strings.HasPrefix(a, "--profile=") ||
|
||||
a == "--profile-save" || strings.HasPrefix(a, "--profile-save=")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package profiles_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configfile"
|
||||
profiles "github.com/rclone/rclone/fs/config/configprofiles"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTestCmd returns a cobra.Command with a small synthetic flag set
|
||||
// covering the flag types the profile code actually has to handle.
|
||||
//
|
||||
// It is deliberately decoupled from cmd.Root and the real
|
||||
// configflags/filterflags registrations: tests should exercise the
|
||||
// profile machinery against a known, minimal surface rather than
|
||||
// against the whole of rclone.
|
||||
func newTestCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
fs := cmd.Flags()
|
||||
fs.Int("checkers", 8, "Number of checkers")
|
||||
fs.Bool("checksum", false, "Use checksum")
|
||||
fs.Bool("metadata", false, "Preserve metadata")
|
||||
fs.Bool("dry-run", false, "Do a trial run")
|
||||
fs.String("backup-dir", "", "Backup directory")
|
||||
fs.String("drive-skip-gdocs", "", "Drive: skip gdocs")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// useTempConfig points the config subsystem at a fresh empty file
|
||||
// inside t.TempDir() and registers cleanup to restore the previous
|
||||
// path.
|
||||
func useTempConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
configfile.Install()
|
||||
old := config.GetConfigPath()
|
||||
path := filepath.Join(t.TempDir(), "rclone.conf")
|
||||
require.NoError(t, os.WriteFile(path, nil, 0600))
|
||||
require.NoError(t, config.SetConfigPath(path))
|
||||
t.Cleanup(func() {
|
||||
_ = config.SetConfigPath(old)
|
||||
})
|
||||
// Make sure we start from an empty in-memory view too.
|
||||
for _, section := range config.LoadedData().GetSectionList() {
|
||||
config.LoadedData().DeleteSection(section)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// parse parses argv into the given command's flag set and returns the
|
||||
// remaining positional arguments.
|
||||
func parse(t *testing.T, cmd *cobra.Command, argv ...string) []string {
|
||||
t.Helper()
|
||||
require.NoError(t, cmd.Flags().Parse(argv))
|
||||
return cmd.Flags().Args()
|
||||
}
|
||||
|
||||
func TestNewFromCommand_CollectsOnlyChangedFlags(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
cmd := newTestCmd()
|
||||
args := parse(t, cmd,
|
||||
"--checkers", "16",
|
||||
"--checksum",
|
||||
"--drive-skip-gdocs", "true",
|
||||
"src", "dst",
|
||||
)
|
||||
|
||||
p := profiles.NewProfile("name")
|
||||
p.NewFromCommand(cmd, args)
|
||||
|
||||
assert.Equal(t, []string{"src", "dst"}, p.Args)
|
||||
assert.Equal(t, map[string]string{
|
||||
"checkers": "16",
|
||||
"checksum": "true",
|
||||
"drive_skip_gdocs": "true",
|
||||
}, p.Flags)
|
||||
}
|
||||
|
||||
func TestNewFromCommand_SkipsIgnoredFlags(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
cmd := newTestCmd()
|
||||
// Profile-control flags must not be persisted into the profile,
|
||||
// even if the test command happens to define them.
|
||||
cmd.Flags().String("profile-save", "", "")
|
||||
cmd.Flags().String("profile", "", "")
|
||||
cmd.Flags().Bool("profile-save-args", false, "")
|
||||
parse(t, cmd,
|
||||
"--checkers", "4",
|
||||
"--profile-save", "ignored",
|
||||
"--profile", "ignored",
|
||||
"--profile-save-args",
|
||||
)
|
||||
|
||||
p := profiles.NewProfile("name")
|
||||
p.NewFromCommand(cmd, nil)
|
||||
|
||||
assert.Equal(t, map[string]string{"checkers": "4"}, p.Flags)
|
||||
}
|
||||
|
||||
func TestSaveAndGet_RoundTripsFlagsAndArgs(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := profiles.NewProfile("preset")
|
||||
p.Flags = map[string]string{
|
||||
"checkers": "16",
|
||||
"metadata": "true",
|
||||
"drive_skip_gdocs": "true",
|
||||
}
|
||||
p.Args = []string{"src", "remote:dst"}
|
||||
p.Parents = []string{"parent_a", "parent_b"}
|
||||
|
||||
opt := &profiles.ProfileOpt{SaveArgs: true}
|
||||
require.NoError(t, p.Save(ctx, opt))
|
||||
|
||||
got, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, p, got)
|
||||
}
|
||||
|
||||
func TestUse_UserPositionalArgsBeatProfileArgs(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Save a profile with positional args.
|
||||
p := profiles.NewProfile("paths")
|
||||
p.Args = []string{"saved-src", "saved-dst"}
|
||||
require.NoError(t, p.Save(ctx, &profiles.ProfileOpt{SaveArgs: true}))
|
||||
|
||||
// useArgs mutates os.Args; save/restore.
|
||||
savedOSArgs := os.Args
|
||||
defer func() { os.Args = savedOSArgs }()
|
||||
os.Args = []string{"rclone", "copy", "user-src", "user-dst", "--profile", "paths"}
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("paths")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, []string{"user-src", "user-dst"}, &profiles.ProfileOpt{}))
|
||||
|
||||
// The user's positional args must win over the profile's.
|
||||
assert.Equal(t, []string{"rclone", "copy", "user-src", "user-dst"}, os.Args)
|
||||
}
|
||||
|
||||
func TestUse_ProfileArgsFillUnprovidedSlots(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
p := profiles.NewProfile("paths")
|
||||
p.Args = []string{"saved-src", "saved-dst"}
|
||||
require.NoError(t, p.Save(ctx, &profiles.ProfileOpt{SaveArgs: true}))
|
||||
|
||||
savedOSArgs := os.Args
|
||||
defer func() { os.Args = savedOSArgs }()
|
||||
// User provided no positional args.
|
||||
os.Args = []string{"rclone", "copy", "--profile", "paths"}
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("paths")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
|
||||
|
||||
// With no user args, the profile's saved args fill in.
|
||||
assert.Equal(t, []string{"rclone", "copy", "saved-src", "saved-dst"}, os.Args)
|
||||
}
|
||||
|
||||
func TestSave_DefensiveKeyNormalisation(t *testing.T) {
|
||||
// Callers building a *Profile directly may pass keys in either
|
||||
// snake_case or hyphenated form. Both should land in the same
|
||||
// section.
|
||||
useTempConfig(t)
|
||||
p := profiles.NewProfile("preset")
|
||||
p.Flags = map[string]string{"drive-skip-gdocs": "true"}
|
||||
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
|
||||
|
||||
got, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"drive_skip_gdocs": "true"}, got.Flags)
|
||||
}
|
||||
|
||||
func TestSave_RemoteAndProfileWithSameNameCoexist(t *testing.T) {
|
||||
// Profiles live in the "profile:" namespace so they can share a
|
||||
// name with a remote without colliding.
|
||||
useTempConfig(t)
|
||||
config.LoadedData().SetValue("clash", "type", "drive")
|
||||
require.NoError(t, config.LoadedData().Save())
|
||||
|
||||
p := profiles.NewProfile("clash")
|
||||
p.Flags = map[string]string{"checkers": "4"}
|
||||
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
|
||||
|
||||
// Remote section still intact.
|
||||
rt, ok := config.LoadedData().GetValue("clash", "type")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "drive", rt)
|
||||
// Profile under its namespaced section name.
|
||||
assert.True(t, config.LoadedData().HasSection("profile:clash"))
|
||||
}
|
||||
|
||||
func TestGetProfile_HandlesSparseArgIndices(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
// Write arg_3 with no arg_1 / arg_2 - GetProfile used to crash
|
||||
// here.
|
||||
config.LoadedData().SetValue("profile:preset", "arg_3", "third")
|
||||
require.NoError(t, config.LoadedData().Save())
|
||||
|
||||
got, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"", "", "third"}, got.Args)
|
||||
}
|
||||
|
||||
func TestUse_AppliesFlagValues(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
saved := profiles.NewProfile("preset")
|
||||
saved.Flags = map[string]string{
|
||||
"checkers": "16",
|
||||
"metadata": "true",
|
||||
}
|
||||
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
|
||||
|
||||
assert.Equal(t, "16", cmd.Flags().Lookup("checkers").Value.String())
|
||||
assert.Equal(t, "true", cmd.Flags().Lookup("metadata").Value.String())
|
||||
}
|
||||
|
||||
func TestUse_UnknownFlagSkippedByDefault(t *testing.T) {
|
||||
// A profile may carry flags for commands other than the one it
|
||||
// is currently being applied to; non-strict mode should just
|
||||
// ignore them rather than erroring or nil-dereferencing.
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
saved := profiles.NewProfile("preset")
|
||||
saved.Flags = map[string]string{
|
||||
"checkers": "12",
|
||||
"not_a_real_flag": "value",
|
||||
}
|
||||
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
|
||||
assert.Equal(t, "12", cmd.Flags().Lookup("checkers").Value.String())
|
||||
}
|
||||
|
||||
func TestUse_UnknownFlagErrorsInStrictMode(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
|
||||
saved := profiles.NewProfile("preset")
|
||||
saved.Flags = map[string]string{"not_a_real_flag": "value"}
|
||||
require.NoError(t, saved.Save(ctx, &profiles.ProfileOpt{}))
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
err = loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{StrictFlags: true})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not-a-real-flag")
|
||||
}
|
||||
|
||||
func TestUse_Parents_LowestToHighestPriority(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx := context.Background()
|
||||
opt := &profiles.ProfileOpt{}
|
||||
|
||||
parentA := profiles.NewProfile("parent_a")
|
||||
parentA.Flags = map[string]string{
|
||||
"checkers": "4",
|
||||
"metadata": "true",
|
||||
}
|
||||
require.NoError(t, parentA.Save(ctx, opt))
|
||||
|
||||
parentB := profiles.NewProfile("parent_b")
|
||||
parentB.Flags = map[string]string{
|
||||
"checkers": "8",
|
||||
"checksum": "true",
|
||||
}
|
||||
require.NoError(t, parentB.Save(ctx, opt))
|
||||
|
||||
child := profiles.NewProfile("child")
|
||||
child.Flags = map[string]string{"checkers": "16"}
|
||||
child.Parents = []string{"parent_a", "parent_b"}
|
||||
require.NoError(t, child.Save(ctx, opt))
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("child")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, nil, opt))
|
||||
|
||||
// child overrides both parents
|
||||
assert.Equal(t, "16", cmd.Flags().Lookup("checkers").Value.String())
|
||||
// parent_b overrides parent_a where both set the same flag
|
||||
// (n/a here for checkers; for checksum only parent_b sets)
|
||||
assert.Equal(t, "true", cmd.Flags().Lookup("checksum").Value.String())
|
||||
// flag set only by parent_a still applies
|
||||
assert.Equal(t, "true", cmd.Flags().Lookup("metadata").Value.String())
|
||||
}
|
||||
|
||||
func TestUse_DryRunCannotBeTurnedOff(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
|
||||
saved := profiles.NewProfile("preset")
|
||||
saved.Flags = map[string]string{"dry_run": "false"}
|
||||
require.NoError(t, saved.Save(context.Background(), &profiles.ProfileOpt{}))
|
||||
|
||||
// Turn dry-run on *after* saving, otherwise Save itself is
|
||||
// skipped as a destructive op under --dry-run.
|
||||
ctx, ci := fs.AddConfig(context.Background())
|
||||
ci.DryRun = true
|
||||
|
||||
cmd := newTestCmd()
|
||||
loaded, err := profiles.GetProfile("preset")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, loaded.Use(ctx, cmd, nil, &profiles.ProfileOpt{}))
|
||||
// The profile's "dry_run = false" must be ignored.
|
||||
assert.Equal(t, "false", cmd.Flags().Lookup("dry-run").Value.String())
|
||||
// We don't assert on ci.DryRun because Profile.Use re-runs
|
||||
// GlobalOptionsInit, which would copy ci.DryRun back from the
|
||||
// flag default - the relevant guarantee is that the flag was
|
||||
// not overwritten.
|
||||
}
|
||||
|
||||
func TestHandleProfiles_SaveRoundTrip(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
ctx, ci := fs.AddConfig(context.Background())
|
||||
|
||||
cmd := newTestCmd()
|
||||
parse(t, cmd, "--checkers", "32", "--checksum")
|
||||
|
||||
ci.SaveProfile = "fromcmd"
|
||||
require.NoError(t, profiles.HandleProfiles(ctx, cmd, cmd.Flags().Args()))
|
||||
|
||||
loaded, err := profiles.GetProfile("fromcmd")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{
|
||||
"checkers": "32",
|
||||
"checksum": "true",
|
||||
}, loaded.Flags)
|
||||
}
|
||||
|
||||
func TestSprintSection_ShowsAllKeys(t *testing.T) {
|
||||
useTempConfig(t)
|
||||
p := profiles.NewProfile("preset")
|
||||
p.Flags = map[string]string{"checkers": "9"}
|
||||
require.NoError(t, p.Save(context.Background(), &profiles.ProfileOpt{}))
|
||||
|
||||
s := profiles.SprintSection("profile:preset")
|
||||
lines := sortedNonBlank(s)
|
||||
want := sortedNonBlank("[profile:preset]\ncheckers = 9\n")
|
||||
assert.Equal(t, want, lines)
|
||||
}
|
||||
|
||||
func sortedNonBlank(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGlobalOptionsLoadable pins the contract that callers like
|
||||
// HandleProfiles / Profile.Use rely on: fs.GlobalOptionsInit must be
|
||||
// callable on an initialised process without erroring. If the global
|
||||
// options registry ever ends up in a state that can't be loaded the
|
||||
// rest of this suite will start blowing up too, but this gives a
|
||||
// pointed failure.
|
||||
func TestGlobalOptionsLoadable(t *testing.T) {
|
||||
require.NoError(t, fs.GlobalOptionsInit())
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package profiles handles presets for config
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
)
|
||||
|
||||
// ShowProfiles prints an overview of every profile in the config file.
|
||||
func ShowProfiles() {
|
||||
profiles := GetProfileList()
|
||||
fmt.Printf("%-20s %s\n", "Name", "Type")
|
||||
fmt.Printf("%-20s %s\n", "====", "====")
|
||||
for _, profile := range profiles {
|
||||
fmt.Printf("%-20s %s\n", profile, "profile")
|
||||
}
|
||||
}
|
||||
|
||||
// GetProfileList returns the user-facing names of all profile
|
||||
// sections (i.e. without the "profile:" prefix), sorted.
|
||||
func GetProfileList() []string {
|
||||
sections := config.LoadedData().GetSectionList()
|
||||
profiles := []string{}
|
||||
for _, s := range sections {
|
||||
if !config.IsProfileSection(s) {
|
||||
continue
|
||||
}
|
||||
profiles = append(profiles, strings.TrimPrefix(s, config.ProfileSectionPrefix))
|
||||
}
|
||||
sort.Strings(profiles)
|
||||
return profiles
|
||||
}
|
||||
+17
-3
@@ -294,7 +294,7 @@ func ChooseNumber(what string, min, max int) int {
|
||||
|
||||
// ShowRemotes shows an overview of the config file
|
||||
func ShowRemotes() {
|
||||
remotes := LoadedData().GetSectionList()
|
||||
remotes := remoteSections()
|
||||
if len(remotes) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -308,12 +308,26 @@ func ShowRemotes() {
|
||||
|
||||
// ChooseRemote chooses a remote name
|
||||
func ChooseRemote() string {
|
||||
remotes := LoadedData().GetSectionList()
|
||||
remotes := remoteSections()
|
||||
sort.Strings(remotes)
|
||||
fmt.Println("Select remote.")
|
||||
return Choose("remote", "value", remotes, nil, "", true, false)
|
||||
}
|
||||
|
||||
// remoteSections returns the section names that are remotes (i.e.
|
||||
// everything except profile sections).
|
||||
func remoteSections() []string {
|
||||
all := LoadedData().GetSectionList()
|
||||
out := make([]string, 0, len(all))
|
||||
for _, s := range all {
|
||||
if IsProfileSection(s) {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mustFindByName finds the RegInfo for the remote name passed in or
|
||||
// exits with a fatal error.
|
||||
func mustFindByName(name string) *fs.RegInfo {
|
||||
@@ -702,7 +716,7 @@ func ShowRedactedConfig() {
|
||||
// EditConfig edits the config file interactively
|
||||
func EditConfig(ctx context.Context) (err error) {
|
||||
for {
|
||||
haveRemotes := len(LoadedData().GetSectionList()) != 0
|
||||
haveRemotes := len(remoteSections()) != 0
|
||||
what := []string{"eEdit existing remote", "nNew remote", "dDelete remote", "rRename remote", "cCopy remote", "sSet configuration password", "qQuit config"}
|
||||
if haveRemotes {
|
||||
fmt.Printf("Current remotes:\n\n")
|
||||
|
||||
Reference in new issue
Block a user