diff --git a/cmd/cloudstic/cmd_backup.go b/cmd/cloudstic/cmd_backup.go index d61fbe4..6f62330 100644 --- a/cmd/cloudstic/cmd_backup.go +++ b/cmd/cloudstic/cmd_backup.go @@ -32,6 +32,7 @@ type backupArgs struct { volumeUUID string googleCreds string googleCredsRef string + googleCredsJSON string googleTokenFile string googleTokenRef string onedriveClientID string @@ -59,6 +60,7 @@ func parseBackupArgs() *backupArgs { volumeUUID := fs.String("volume-uuid", envDefault("CLOUDSTIC_VOLUME_UUID", ""), "Override volume UUID for local source (enables cross-machine incremental backup)") googleCreds := fs.String("google-credentials", envDefault("GOOGLE_APPLICATION_CREDENTIALS", ""), "Path to Google service account credentials JSON file") googleCredsRef := fs.String("google-credentials-ref", "", "Secret reference to Google service account credentials JSON") + googleCredsJSON := fs.String("google-credentials-json", envDefault("GOOGLE_CREDENTIALS_JSON", ""), "Inline Google credentials JSON (OAuth client or service account)") googleTokenFile := fs.String("google-token-file", envDefault("GOOGLE_TOKEN_FILE", ""), "Path to Google OAuth token file") googleTokenRef := fs.String("google-token-ref", "", "Secret reference to Google OAuth token") onedriveClientID := fs.String("onedrive-client-id", envDefault("ONEDRIVE_CLIENT_ID", ""), "OneDrive OAuth client ID") @@ -82,6 +84,7 @@ func parseBackupArgs() *backupArgs { a.volumeUUID = *volumeUUID a.googleCreds = *googleCreds a.googleCredsRef = *googleCredsRef + a.googleCredsJSON = *googleCredsJSON a.googleTokenFile = *googleTokenFile a.googleTokenRef = *googleTokenRef a.onedriveClientID = *onedriveClientID @@ -144,6 +147,7 @@ func (r *runner) runSingleBackup(a *backupArgs) int { volumeUUID: a.volumeUUID, googleCreds: a.googleCreds, googleCredsRef: a.googleCredsRef, + googleCredsJSON: a.googleCredsJSON, googleTokenFile: a.googleTokenFile, googleTokenRef: a.googleTokenRef, onedriveClientID: a.onedriveClientID, @@ -243,6 +247,9 @@ func ensureDefaultAuthRefForCloudBackup(a *backupArgs) error { if a.googleCreds != "" { auth.GoogleCreds = a.googleCreds } + if a.googleCredsJSON != "" { + auth.GoogleCredsJSON = a.googleCredsJSON + } auth.GoogleTokenFile = tokenPath } if provider == "onedrive" { @@ -337,6 +344,9 @@ func mergeProfileBackupArgs(base *backupArgs, profileName string, p cloudstic.Ba if !a.flagsSet["google-credentials-ref"] && p.GoogleCredsRef != "" { a.googleCredsRef = p.GoogleCredsRef } + if !a.flagsSet["google-credentials-json"] && p.GoogleCredsJSON != "" { + a.googleCredsJSON = p.GoogleCredsJSON + } if !a.flagsSet["google-token-file"] && p.GoogleTokenFile != "" { a.googleTokenFile = p.GoogleTokenFile } @@ -425,6 +435,9 @@ func applyProfileAuthToBackupArgs(a *backupArgs, auth cloudstic.ProfileAuth) err if !a.flagsSet["google-credentials-ref"] && auth.GoogleCredsRef != "" { a.googleCredsRef = auth.GoogleCredsRef } + if !a.flagsSet["google-credentials-json"] && auth.GoogleCredsJSON != "" { + a.googleCredsJSON = auth.GoogleCredsJSON + } if !a.flagsSet["google-token-file"] && auth.GoogleTokenFile != "" { a.googleTokenFile = auth.GoogleTokenFile } @@ -639,6 +652,7 @@ type initSourceOptions struct { volumeUUID string googleCreds string googleCredsRef string + googleCredsJSON string googleTokenFile string googleTokenRef string onedriveClientID string @@ -695,6 +709,7 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err source.WithResolver(resolver), source.WithCredsPath(opts.googleCreds), source.WithCredsRef(opts.googleCredsRef), + source.WithCredsJSON([]byte(opts.googleCredsJSON)), source.WithTokenPath(tokenPath), source.WithTokenRef(opts.googleTokenRef), source.WithDriveName(uri.host), @@ -714,6 +729,7 @@ func initSource(ctx context.Context, opts initSourceOptions) (source.Source, err source.WithResolver(resolver), source.WithCredsPath(opts.googleCreds), source.WithCredsRef(opts.googleCredsRef), + source.WithCredsJSON([]byte(opts.googleCredsJSON)), source.WithTokenPath(tokenPath), source.WithTokenRef(opts.googleTokenRef), source.WithDriveName(uri.host), diff --git a/cmd/cloudstic/cmd_profile.go b/cmd/cloudstic/cmd_profile.go index a57be3e..abcae03 100644 --- a/cmd/cloudstic/cmd_profile.go +++ b/cmd/cloudstic/cmd_profile.go @@ -158,9 +158,13 @@ type profileNewArgs struct { skipNativeFiles bool volumeUUID string googleCreds string + googleCredsRef string + googleCredsJSON string googleTokenFile string + googleTokenRef string onedriveClientID string onedriveTokenFile string + onedriveTokenRef string flagsSet map[string]bool } @@ -181,9 +185,13 @@ func parseProfileNewArgs() *profileNewArgs { skipNativeFiles := fs.Bool("skip-native-files", false, "Exclude Google-native files (Docs, Sheets, Slides, etc.)") volumeUUID := fs.String("volume-uuid", "", "Override volume UUID for local source") googleCreds := fs.String("google-credentials", "", "Path to Google service account credentials JSON file") + googleCredsRef := fs.String("google-credentials-ref", "", "Secret reference to Google service account credentials JSON") + googleCredsJSON := fs.String("google-credentials-json", "", "Inline Google credentials JSON") googleTokenFile := fs.String("google-token-file", "", "Path to Google OAuth token file") + googleTokenRef := fs.String("google-token-ref", "", "Secret reference to Google OAuth token") onedriveClientID := fs.String("onedrive-client-id", "", "OneDrive OAuth client ID") onedriveTokenFile := fs.String("onedrive-token-file", "", "Path to OneDrive OAuth token file") + onedriveTokenRef := fs.String("onedrive-token-ref", "", "Secret reference to OneDrive OAuth token") fs.Var(&a.tags, "tag", "Tag to apply to snapshots (repeatable)") fs.Var(&a.excludes, "exclude", "Exclude pattern (repeatable)") _ = fs.Parse(reorderArgs(fs, os.Args[3:])) @@ -201,9 +209,13 @@ func parseProfileNewArgs() *profileNewArgs { a.skipNativeFiles = *skipNativeFiles a.volumeUUID = *volumeUUID a.googleCreds = *googleCreds + a.googleCredsRef = *googleCredsRef + a.googleCredsJSON = *googleCredsJSON a.googleTokenFile = *googleTokenFile + a.googleTokenRef = *googleTokenRef a.onedriveClientID = *onedriveClientID a.onedriveTokenFile = *onedriveTokenFile + a.onedriveTokenRef = *onedriveTokenRef return a } @@ -374,9 +386,13 @@ func (r *runner) runProfileNew(ctx context.Context) int { SkipNativeFiles: a.skipNativeFiles, VolumeUUID: a.volumeUUID, GoogleCreds: a.googleCreds, + GoogleCredsRef: a.googleCredsRef, + GoogleCredsJSON: a.googleCredsJSON, GoogleTokenFile: a.googleTokenFile, + GoogleTokenRef: a.googleTokenRef, OneDriveClientID: a.onedriveClientID, OneDriveTokenFile: a.onedriveTokenFile, + OneDriveTokenRef: a.onedriveTokenRef, } cfg.Profiles[a.name] = p @@ -515,15 +531,27 @@ func prefillProfileArgs(a *profileNewArgs, p cloudstic.BackupProfile) { if !a.flagsSet["google-credentials"] && p.GoogleCreds != "" { a.googleCreds = p.GoogleCreds } + if !a.flagsSet["google-credentials-ref"] && p.GoogleCredsRef != "" { + a.googleCredsRef = p.GoogleCredsRef + } + if !a.flagsSet["google-credentials-json"] && p.GoogleCredsJSON != "" { + a.googleCredsJSON = p.GoogleCredsJSON + } if !a.flagsSet["google-token-file"] && p.GoogleTokenFile != "" { a.googleTokenFile = p.GoogleTokenFile } + if !a.flagsSet["google-token-ref"] && p.GoogleTokenRef != "" { + a.googleTokenRef = p.GoogleTokenRef + } if !a.flagsSet["onedrive-client-id"] && p.OneDriveClientID != "" { a.onedriveClientID = p.OneDriveClientID } if !a.flagsSet["onedrive-token-file"] && p.OneDriveTokenFile != "" { a.onedriveTokenFile = p.OneDriveTokenFile } + if !a.flagsSet["onedrive-token-ref"] && p.OneDriveTokenRef != "" { + a.onedriveTokenRef = p.OneDriveTokenRef + } if len(a.tags) == 0 && len(p.Tags) > 0 { a.tags = append(stringArrayFlags{}, p.Tags...) } diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index c0851ad..66b8f5a 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -52,8 +52,7 @@ _cloudstic() { case "${words[i]}" in -*) # skip flags and their values - case "${words[i]}" in - -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-source-sftp-known-hosts|-store-sftp-password|-store-sftp-key|-store-sftp-known-hosts|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-all-profiles|-auth-ref|-google-credentials|-google-token-file|-onedrive-client-id|-onedrive-token-file|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-json|-xattr-namespaces) + -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-source-sftp-known-hosts|-store-sftp-password|-store-sftp-key|-store-sftp-known-hosts|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-all-profiles|-auth-ref|-google-credentials|-google-credentials-ref|-google-credentials-json|-google-token-file|-google-token-ref|-onedrive-client-id|-onedrive-token-file|-onedrive-token-ref|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-json|-xattr-namespaces) ((i++)) ;; esac ;; @@ -76,7 +75,7 @@ _cloudstic() { init) cmd_flags="-add-recovery-key -no-encryption -adopt-slots" ;; backup) - cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-token-file -onedrive-client-id -onedrive-token-file -tag -dry-run -skip-mode -skip-flags -skip-xattrs -xattr-namespaces" ;; + cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref -tag -dry-run -skip-mode -skip-flags -skip-xattrs -xattr-namespaces" ;; restore) cmd_flags="-output -format -path -dry-run" ;; prune) @@ -130,7 +129,7 @@ _cloudstic() { show) cmd_flags="-profiles-file" ;; new) - cmd_flags="-profiles-file -name -source -store-ref -store -auth-ref -tag -exclude -exclude-file -skip-native-files -volume-uuid -google-credentials -google-token-file -onedrive-client-id -onedrive-token-file" ;; + cmd_flags="-profiles-file -name -source -store-ref -store -auth-ref -tag -exclude -exclude-file -skip-native-files -volume-uuid -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref" ;; *) cmd_flags="" ;; esac @@ -154,7 +153,7 @@ _cloudstic() { show) cmd_flags="-profiles-file" ;; new) - cmd_flags="-profiles-file -name -provider -google-credentials -google-token-file -onedrive-client-id -onedrive-token-file" ;; + cmd_flags="-profiles-file -name -provider -google-credentials -google-credentials-ref -google-credentials-json -google-token-file -google-token-ref -onedrive-client-id -onedrive-token-file -onedrive-token-ref" ;; login) cmd_flags="-profiles-file -name" ;; *) @@ -287,7 +286,7 @@ _cloudstic() { -*) # Skip flags with values case "${words[i]}" in - -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-store-sftp-password|-store-sftp-key|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-auth-ref|-google-credentials|-google-token-file|-onedrive-client-id|-onedrive-token-file|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account) + -store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-store-sftp-password|-store-sftp-key|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-auth-ref|-google-credentials|-google-credentials-ref|-google-credentials-json|-google-token-file|-google-token-ref|-onedrive-client-id|-onedrive-token-file|-onedrive-token-ref|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account) (( i++ )) ;; esac ;; @@ -321,9 +320,13 @@ _cloudstic() { '-profiles-file[Path to profiles YAML file]:path:_files' \ '-skip-native-files[Exclude Google-native files]' \ '-google-credentials[Google service account credentials JSON]:path:_files' \ + '-google-credentials-ref[Secret reference to Google credentials]:ref:' \ + '-google-credentials-json[Inline Google credentials JSON]:json:' \ '-google-token-file[Google OAuth token file]:path:_files' \ + '-google-token-ref[Secret reference to Google OAuth token]:ref:' \ '-onedrive-client-id[OneDrive OAuth client ID]:id:' \ '-onedrive-token-file[OneDrive OAuth token file]:path:_files' \ + '-onedrive-token-ref[Secret reference to OneDrive OAuth token]:ref:' \ '*-tag[Tag for the snapshot]:tag:' \ '-dry-run[Scan without writing]' \ '-skip-mode[Skip POSIX mode/uid/gid/btime/flags]' \ @@ -372,9 +375,13 @@ _cloudstic() { '-skip-native-files[Exclude Google-native files]' \ '-volume-uuid[Volume UUID override]:uuid:' \ '-google-credentials[Google service account credentials JSON]:path:_files' \ + '-google-credentials-ref[Secret reference to Google credentials]:ref:' \ + '-google-credentials-json[Inline Google credentials JSON]:json:' \ '-google-token-file[Google OAuth token file]:path:_files' \ + '-google-token-ref[Secret reference to Google OAuth token]:ref:' \ '-onedrive-client-id[OneDrive OAuth client ID]:id:' \ - '-onedrive-token-file[OneDrive OAuth token file]:path:_files' + '-onedrive-token-file[OneDrive OAuth token file]:path:_files' \ + '-onedrive-token-ref[Secret reference to OneDrive OAuth token]:ref:' ;; *) _arguments @@ -415,6 +422,7 @@ _cloudstic() { '-name[Auth reference name]:name:' \ '-provider[Auth provider]:provider:(google onedrive)' \ '-google-credentials[Google service account credentials JSON]:path:_files' \ + '-google-credentials-json[Inline Google credentials JSON]:json:' \ '-google-token-file[Google OAuth token file]:path:_files' \ '-onedrive-client-id[OneDrive OAuth client ID]:id:' \ '-onedrive-token-file[OneDrive OAuth token file]:path:_files' @@ -653,9 +661,13 @@ complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l auth-ref -x -d complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l profiles-file -r -F -d 'Path to profiles YAML file' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-native-files -d 'Exclude Google-native files' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-credentials -r -F -d 'Google service account credentials JSON' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-credentials-ref -x -d 'Secret reference to Google credentials' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-credentials-json -x -d 'Inline Google credentials JSON' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-token-file -r -F -d 'Google OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l google-token-ref -x -d 'Secret reference to Google OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-client-id -x -d 'OneDrive OAuth client ID' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-token-ref -x -d 'Secret reference to OneDrive OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l tag -x -d 'Tag for the snapshot' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l dry-run -d 'Scan without writing' complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-mode -d 'Skip POSIX mode/uid/gid/btime/flags' @@ -681,9 +693,13 @@ complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_s complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l skip-native-files -d 'Exclude Google-native files' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l volume-uuid -x -d 'Volume UUID override' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-credentials -r -F -d 'Google service account credentials JSON' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-credentials-ref -x -d 'Secret reference to Google credentials' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-credentials-json -x -d 'Inline Google credentials JSON' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-token-file -r -F -d 'Google OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l google-token-ref -x -d 'Secret reference to Google OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l onedrive-client-id -x -d 'OneDrive OAuth client ID' complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from profile; and __fish_seen_subcommand_from new' -l onedrive-token-ref -x -d 'Secret reference to OneDrive OAuth token' # store subcommands complete -c cloudstic -n '__fish_seen_subcommand_from store; and not __fish_seen_subcommand_from list show new verify init' -a list -d 'List configured stores' @@ -713,9 +729,13 @@ complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subc complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l name -x -d 'Auth reference name' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l provider -x -a 'google onedrive' -d 'Auth provider' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l google-credentials -r -F -d 'Google service account credentials JSON' +complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l google-credentials-ref -x -d 'Secret reference to Google credentials' +complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l google-credentials-json -x -d 'Inline Google credentials JSON' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l google-token-file -r -F -d 'Google OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l google-token-ref -x -d 'Secret reference to Google OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l onedrive-client-id -x -d 'OneDrive OAuth client ID' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file' +complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from new' -l onedrive-token-ref -x -d 'Secret reference to OneDrive OAuth token' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from login' -l profiles-file -r -F -d 'Path to profiles YAML file' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and __fish_seen_subcommand_from login' -l name -x -d 'Auth reference name' diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index f406d6e..df7f7d1 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -134,9 +134,13 @@ func printUsage() { {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, {"-skip-native-files", "Exclude Google-native files (Docs, Sheets, Slides, etc.)"}, {"-google-credentials ", ui.Env("Path to Google service account credentials JSON", "GOOGLE_APPLICATION_CREDENTIALS")}, + {"-google-credentials-ref ", "Secret reference to Google service account credentials JSON"}, + {"-google-credentials-json ", ui.Env("Inline Google credentials JSON (OAuth client or service account)", "GOOGLE_CREDENTIALS_JSON")}, {"-google-token-file ", ui.Env("Path to Google OAuth token file", "GOOGLE_TOKEN_FILE")}, + {"-google-token-ref ", "Secret reference to Google OAuth token"}, {"-onedrive-client-id ", ui.Env("OneDrive OAuth client ID", "ONEDRIVE_CLIENT_ID")}, {"-onedrive-token-file ", ui.Env("Path to OneDrive OAuth token file", "ONEDRIVE_TOKEN_FILE")}, + {"-onedrive-token-ref ", "Secret reference to OneDrive OAuth token"}, {"-tag ", "Tag to apply to the snapshot (repeatable)"}, {"-exclude ", "Exclude pattern, gitignore syntax (repeatable)"}, {"-exclude-file ", "Load exclude patterns from file (one per line, gitignore syntax)"}, @@ -238,9 +242,13 @@ func printUsage() { {"-skip-native-files", "Exclude Google-native files"}, {"-volume-uuid ", "Override local source volume UUID"}, {"-google-credentials ", "Path to Google service account credentials JSON"}, + {"-google-credentials-ref ", "Secret reference to Google service account credentials JSON"}, + {"-google-credentials-json ", "Inline Google credentials JSON (OAuth client or service account)"}, {"-google-token-file ", "Path to Google OAuth token file"}, + {"-google-token-ref ", "Secret reference to Google OAuth token"}, {"-onedrive-client-id ", "OneDrive OAuth client ID"}, {"-onedrive-token-file ", "Path to OneDrive OAuth token file"}, + {"-onedrive-token-ref ", "Secret reference to OneDrive OAuth token"}, {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, }) t.Note( @@ -266,9 +274,13 @@ func printUsage() { {"-name ", "Auth reference name"}, {"-provider ", "Cloud provider for this auth entry"}, {"-google-credentials ", "Google service account credentials JSON (optional)"}, + {"-google-credentials-ref ", "Secret reference to Google service account credentials JSON"}, + {"-google-credentials-json ", "Inline Google credentials JSON (OAuth client or service account)"}, {"-google-token-file ", "Google OAuth token file path (required for provider=google)"}, + {"-google-token-ref ", "Secret reference to Google OAuth token"}, {"-onedrive-client-id ", "OneDrive OAuth client ID (optional)"}, {"-onedrive-token-file ", "OneDrive OAuth token file path (required for provider=onedrive)"}, + {"-onedrive-token-ref ", "Secret reference to OneDrive OAuth token"}, {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, }) t.Note(" Create or update a reusable cloud auth entry.") diff --git a/docs/user-guide.md b/docs/user-guide.md index 05a80da..fe0b68e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1327,7 +1327,43 @@ cloudstic backup -source "gdrive://Company Data/path/to/folder" | Variable | Description | |----------|-------------| | `GOOGLE_APPLICATION_CREDENTIALS` | Path to your own Google OAuth credentials JSON file (overrides built-in credentials) | -| `GOOGLE_TOKEN_FILE` | Override token cache path (default: `/google_token.json`) | +| `GOOGLE_CREDENTIALS_JSON` | Inline Google credentials JSON string — OAuth client or service account (useful in CI/CD where mounting files is inconvenient) | +| `GOOGLE_TOKEN_FILE` | Override token cache path (flag: `-google-token-file`) | +| `-google-token-ref` | (Flag only) Secret reference to Google OAuth token (e.g., `config-token://google/default`) | +| `-google-credentials-ref` | (Flag only) Secret reference to service account credentials JSON | + +**Using your own credentials or a service account:** + +Cloudstic ships with built-in OAuth credentials, but you can use your own OAuth client or a Google service account instead. + +Credentials can be provided in three ways (in priority order): + +1. **Inline JSON** via flag or env var (highest priority, ideal for CI/CD): + + ```bash + # Via environment variable + export GOOGLE_CREDENTIALS_JSON='{"type":"service_account", ...}' + cloudstic backup -source gdrive-changes + + # Via flag + cloudstic backup -source gdrive-changes \ + -google-credentials-json '{"type":"service_account", ...}' + ``` + +2. **Secret reference** (for profiles using secret managers): + + ```bash + cloudstic backup -source gdrive -google-credentials-ref keychain://cloudstic/google-creds + ``` + +3. **File path**: + + ```bash + export GOOGLE_APPLICATION_CREDENTIALS=~/service-account.json + cloudstic backup -source gdrive-changes + ``` + +Both OAuth client JSON (authorized-user flow) and service-account JSON are auto-detected — Cloudstic tries the OAuth user flow first, then falls back to service-account auth. ### Google Drive (Incremental) @@ -1382,7 +1418,8 @@ No client secret is needed — Cloudstic uses the public client flow with PKCE. | Variable | Description | |----------|-------------| | `ONEDRIVE_CLIENT_ID` | Azure app client ID (overrides built-in credentials) | -| `ONEDRIVE_TOKEN_FILE` | Override token cache path (default: `/onedrive_token.json`) | +| `ONEDRIVE_TOKEN_FILE` | Override token cache path (flag: `-onedrive-token-file`) | +| `-onedrive-token-ref` | (Flag only) Secret reference to OneDrive OAuth token (e.g., `config-token://onedrive/default`) | ### OneDrive (Incremental) @@ -1664,8 +1701,9 @@ cloudstic forget -keep-daily 7 -keep-monthly 12 -dry-run | `CLOUDSTIC_KMS_ENDPOINT` | `-kms-endpoint` | Custom AWS KMS endpoint URL | | `CLOUDSTIC_PROFILES_FILE` | `-profiles-file` | Path to profiles YAML file | | `CLOUDSTIC_CONFIG_DIR` | — | Override config directory path | -| `GOOGLE_APPLICATION_CREDENTIALS` | — | Path to your own Google OAuth credentials file (optional, overrides built-in) | -| `GOOGLE_TOKEN_FILE` | — | Override Google OAuth token path | +| `GOOGLE_APPLICATION_CREDENTIALS` | `-google-credentials` | Path to your own Google OAuth credentials file (optional, overrides built-in) | +| `GOOGLE_CREDENTIALS_JSON` | `-google-credentials-json` | Inline Google credentials JSON (OAuth client or service account) | +| `GOOGLE_TOKEN_FILE` | `-google-token-file` | Override Google OAuth token path | | `ONEDRIVE_CLIENT_ID` | — | Microsoft app client ID (optional, overrides built-in) | | `ONEDRIVE_TOKEN_FILE` | — | Override OneDrive token path | | `B2_KEY_ID` | — | Backblaze B2 key ID | diff --git a/internal/engine/profiles.go b/internal/engine/profiles.go index cbd092b..0d253c2 100644 --- a/internal/engine/profiles.go +++ b/internal/engine/profiles.go @@ -53,6 +53,7 @@ type BackupProfile struct { VolumeUUID string `yaml:"volume_uuid,omitempty"` GoogleCreds string `yaml:"google_credentials,omitempty"` GoogleCredsRef string `yaml:"google_credentials_ref,omitempty"` + GoogleCredsJSON string `yaml:"google_credentials_json,omitempty"` GoogleTokenFile string `yaml:"google_token_file,omitempty"` GoogleTokenRef string `yaml:"google_token_ref,omitempty"` OneDriveClientID string `yaml:"onedrive_client_id,omitempty"` @@ -66,6 +67,7 @@ type ProfileAuth struct { Provider string `yaml:"provider"` // google | onedrive GoogleCreds string `yaml:"google_credentials,omitempty"` GoogleCredsRef string `yaml:"google_credentials_ref,omitempty"` + GoogleCredsJSON string `yaml:"google_credentials_json,omitempty"` GoogleTokenFile string `yaml:"google_token_file,omitempty"` GoogleTokenRef string `yaml:"google_token_ref,omitempty"` OneDriveClientID string `yaml:"onedrive_client_id,omitempty"` diff --git a/pkg/source/gdrive.go b/pkg/source/gdrive.go index c8e8ca1..bd9cfd5 100644 --- a/pkg/source/gdrive.go +++ b/pkg/source/gdrive.go @@ -24,10 +24,12 @@ import ( // gDriveOptions holds configuration for a Google Drive source. type gDriveOptions struct { + service *drive.Service // pre-built service; highest priority httpClient *http.Client resolver *secretref.Resolver credsPath string credsRef string + credsJSON []byte // inline credential JSON (OAuth client or service-account) tokenPath string tokenRef string driveID string @@ -42,6 +44,15 @@ type gDriveOptions struct { // GDriveOption configures a Google Drive source. type GDriveOption func(*gDriveOptions) +// WithDriveService injects a fully-constructed *drive.Service. +// When set, all credential/token options are ignored — the caller is +// responsible for scopes, token refresh, etc. +func WithDriveService(srv *drive.Service) GDriveOption { + return func(o *gDriveOptions) { + o.service = srv + } +} + // WithHTTPClient sets a custom HTTP client for OAuth. func WithHTTPClient(client *http.Client) GDriveOption { return func(o *gDriveOptions) { @@ -71,6 +82,15 @@ func WithCredsRef(ref string) GDriveOption { } } +// WithCredsJSON provides credentials as raw JSON bytes (inline). +// Supports both OAuth-client and service-account credential formats. +// This mirrors rclone's service_account_credentials option. +func WithCredsJSON(data []byte) GDriveOption { + return func(o *gDriveOptions) { + o.credsJSON = data + } +} + // WithTokenPath sets the path where the OAuth token is cached. func WithTokenPath(path string) GDriveOption { return func(o *gDriveOptions) { @@ -162,86 +182,14 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, opt(&cfg) } - var srv *drive.Service - var err error - if cfg.httpClient != nil { - srv, err = drive.NewService(ctx, option.WithHTTPClient(cfg.httpClient)) - if err != nil { - return nil, fmt.Errorf("create drive client (custom http client): %w", err) - } - } else if cfg.credsRef != "" || cfg.credsPath != "" { - var b []byte - if cfg.credsRef != "" && cfg.resolver != nil { - b, err = cfg.resolver.LoadBlob(ctx, cfg.credsRef) - if err != nil { - return nil, fmt.Errorf("load credentials from ref %q: %w", cfg.credsRef, err) - } - } else if cfg.credsPath != "" { - b, err = os.ReadFile(cfg.credsPath) - if err != nil { - return nil, fmt.Errorf("read credentials file: %w", err) - } - } - - config, err := google.ConfigFromJSON(b, drive.DriveReadonlyScope) - if err == nil { - client, err := oauthClient(ctx, config, cfg.resolver, cfg.tokenRef, cfg.tokenPath) - if err != nil { - return nil, err - } - srv, err = drive.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - return nil, fmt.Errorf("create drive client (user auth): %w", err) - } - } else { - // Try service account if it wasn't a user config - if cfg.credsPath != "" { - srv, err = drive.NewService(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, cfg.credsPath)) - } else { - srv, err = drive.NewService(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, b)) - } - if err != nil { - return nil, fmt.Errorf("create drive client (service account): %w", err) - } - } - } else { - config := &oauth2.Config{ - ClientID: defaultGoogleClientID, - ClientSecret: defaultGoogleClientSecret, - Scopes: []string{drive.DriveReadonlyScope}, - Endpoint: google.Endpoint, - } - client, err := oauthClient(ctx, config, cfg.resolver, cfg.tokenRef, cfg.tokenPath) - if err != nil { - return nil, err - } - srv, err = drive.NewService(ctx, option.WithHTTPClient(client)) - if err != nil { - return nil, fmt.Errorf("create drive client: %w", err) - } + srv, err := buildDriveService(ctx, cfg) + if err != nil { + return nil, err } if cfg.driveName != "" && cfg.driveID == "" { - // Try to see if the provided name is actually an ID - if d, err := srv.Drives.Get(cfg.driveName).Fields("id, name").Do(); err == nil { - cfg.driveID = d.Id - cfg.driveName = d.Name - } else { - // Search by name - query := fmt.Sprintf("name = '%s'", strings.ReplaceAll(cfg.driveName, "'", "\\'")) - call := srv.Drives.List().Q(query).Fields("drives(id, name)").Context(ctx) - r, err := driveCallWithRetry(ctx, func() (*drive.DriveList, error) { return call.Do() }) - if err != nil { - return nil, fmt.Errorf("resolve drive %q: %w", cfg.driveName, err) - } - if len(r.Drives) == 0 { - return nil, fmt.Errorf("shared drive %q not found", cfg.driveName) - } - if len(r.Drives) > 1 { - return nil, fmt.Errorf("ambiguous shared drive name: multiple drives named %q found", cfg.driveName) - } - cfg.driveID = r.Drives[0].Id - cfg.driveName = r.Drives[0].Name + if err := resolveDriveName(ctx, srv, &cfg); err != nil { + return nil, err } } @@ -256,7 +204,7 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, skipNativeFiles: cfg.skipNativeFiles, } - // Resolve the shared drive name for VolumeLabel if driveID was set directly + // Resolve the shared drive name for VolumeLabel if driveID was set directly. if cfg.driveID != "" && src.driveName == "" { if d, err := srv.Drives.Get(cfg.driveID).Fields("name").Do(); err == nil { src.driveName = d.Name @@ -276,6 +224,140 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource, return src, nil } +// --------------------------------------------------------------------------- +// Service construction helpers +// --------------------------------------------------------------------------- + +// buildDriveService creates a *drive.Service from the supplied options. +// Auth strategies are tried in priority order with early returns: +// 1. Pre-built service (WithDriveService) +// 2. Custom HTTP client (WithHTTPClient) +// 3. Explicit credentials (WithCredsJSON / WithCredsRef / WithCredsPath) +// 4. Built-in default OAuth client +func buildDriveService(ctx context.Context, cfg gDriveOptions) (*drive.Service, error) { + // 1. Pre-built service — caller manages auth entirely. + if cfg.service != nil { + return cfg.service, nil + } + + // 2. Custom HTTP client. + if cfg.httpClient != nil { + srv, err := drive.NewService(ctx, option.WithHTTPClient(cfg.httpClient)) + if err != nil { + return nil, fmt.Errorf("create drive client (custom http client): %w", err) + } + return srv, nil + } + + // 3. Explicit credentials: inline JSON, secret ref, or file path. + if b, ok, err := loadCredsBytes(ctx, cfg); err != nil { + return nil, err + } else if ok { + return serviceFromCredsBytes(ctx, cfg, b) + } + + // 4. Default built-in OAuth client. + config := &oauth2.Config{ + ClientID: defaultGoogleClientID, + ClientSecret: defaultGoogleClientSecret, + Scopes: []string{drive.DriveReadonlyScope}, + Endpoint: google.Endpoint, + } + client, err := oauthClient(ctx, config, cfg.resolver, cfg.tokenRef, cfg.tokenPath) + if err != nil { + return nil, err + } + srv, err := drive.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("create drive client: %w", err) + } + return srv, nil +} + +// loadCredsBytes resolves credential JSON from the first available source: +// inline JSON > secret ref > file path. Returns (nil, false, nil) when no +// credential source is configured. +func loadCredsBytes(ctx context.Context, cfg gDriveOptions) ([]byte, bool, error) { + if len(cfg.credsJSON) > 0 { + return cfg.credsJSON, true, nil + } + if cfg.credsRef != "" && cfg.resolver != nil { + b, err := cfg.resolver.LoadBlob(ctx, cfg.credsRef) + if err != nil { + return nil, false, fmt.Errorf("load credentials from ref %q: %w", cfg.credsRef, err) + } + return b, true, nil + } + if cfg.credsPath != "" { + b, err := os.ReadFile(cfg.credsPath) + if err != nil { + return nil, false, fmt.Errorf("read credentials file: %w", err) + } + return b, true, nil + } + return nil, false, nil +} + +// serviceFromCredsBytes builds a *drive.Service from raw credential JSON. +// It first tries to interpret the JSON as an OAuth user-credential config; +// if that fails it falls back to service-account authentication. +func serviceFromCredsBytes(ctx context.Context, cfg gDriveOptions, b []byte) (*drive.Service, error) { + // Try OAuth user config first. + oauthCfg, err := google.ConfigFromJSON(b, drive.DriveReadonlyScope) + if err == nil { + client, err := oauthClient(ctx, oauthCfg, cfg.resolver, cfg.tokenRef, cfg.tokenPath) + if err != nil { + return nil, err + } + srv, err := drive.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("create drive client (user auth): %w", err) + } + return srv, nil + } + + // Fall back to service-account credentials. + if cfg.credsPath != "" { + srv, err := drive.NewService(ctx, option.WithAuthCredentialsFile(option.ServiceAccount, cfg.credsPath)) + if err != nil { + return nil, fmt.Errorf("create drive client (service account): %w", err) + } + return srv, nil + } + srv, err := drive.NewService(ctx, option.WithAuthCredentialsJSON(option.ServiceAccount, b)) + if err != nil { + return nil, fmt.Errorf("create drive client (service account): %w", err) + } + return srv, nil +} + +// resolveDriveName resolves a shared drive name (or ID) to an actual driveID. +func resolveDriveName(ctx context.Context, srv *drive.Service, cfg *gDriveOptions) error { + // Try to see if the provided name is actually an ID. + if d, err := srv.Drives.Get(cfg.driveName).Fields("id, name").Do(); err == nil { + cfg.driveID = d.Id + cfg.driveName = d.Name + return nil + } + + // Search by name. + query := fmt.Sprintf("name = '%s'", strings.ReplaceAll(cfg.driveName, "'", "\\'")) + call := srv.Drives.List().Q(query).Fields("drives(id, name)").Context(ctx) + r, err := driveCallWithRetry(ctx, func() (*drive.DriveList, error) { return call.Do() }) + if err != nil { + return fmt.Errorf("resolve drive %q: %w", cfg.driveName, err) + } + if len(r.Drives) == 0 { + return fmt.Errorf("shared drive %q not found", cfg.driveName) + } + if len(r.Drives) > 1 { + return fmt.Errorf("ambiguous shared drive name: multiple drives named %q found", cfg.driveName) + } + cfg.driveID = r.Drives[0].Id + cfg.driveName = r.Drives[0].Name + return nil +} + func (s *GDriveSource) Info() core.SourceInfo { account := s.account accountID := s.accountID diff --git a/pkg/source/gdrive_test.go b/pkg/source/gdrive_test.go index 311fbd9..02ddee2 100644 --- a/pkg/source/gdrive_test.go +++ b/pkg/source/gdrive_test.go @@ -1,6 +1,8 @@ package source import ( + "context" + "net/http" "testing" "github.com/cloudstic/cli/internal/core" @@ -541,6 +543,72 @@ func TestWithSkipNativeFiles(t *testing.T) { } } +func TestWithDriveService(t *testing.T) { + // Verify the option sets the service field on gDriveOptions. + srv := &drive.Service{} + var cfg gDriveOptions + opt := WithDriveService(srv) + opt(&cfg) + if cfg.service != srv { + t.Error("WithDriveService should set the service field") + } +} + +func TestWithCredsJSON(t *testing.T) { + data := []byte(`{"type":"service_account"}`) + var cfg gDriveOptions + opt := WithCredsJSON(data) + opt(&cfg) + if string(cfg.credsJSON) != string(data) { + t.Errorf("WithCredsJSON: credsJSON = %q, want %q", cfg.credsJSON, data) + } +} + +func TestBuildDriveService_PrebuiltServiceTakesPriority(t *testing.T) { + injected := &drive.Service{} + cfg := gDriveOptions{ + service: injected, + httpClient: &http.Client{}, // should be ignored + credsJSON: []byte(`{"type":"service_account"}`), // should be ignored + } + srv, err := buildDriveService(context.Background(), cfg) + if err != nil { + t.Fatalf("buildDriveService: %v", err) + } + if srv != injected { + t.Error("buildDriveService should return the pre-built service when set") + } +} + +func TestLoadCredsBytes_InlineJSON(t *testing.T) { + data := []byte(`{"inline":"creds"}`) + cfg := gDriveOptions{ + credsJSON: data, + credsPath: "/should/be/ignored", + } + b, ok, err := loadCredsBytes(context.Background(), cfg) + if err != nil { + t.Fatalf("loadCredsBytes: %v", err) + } + if !ok { + t.Fatal("loadCredsBytes should return ok=true for inline JSON") + } + if string(b) != string(data) { + t.Errorf("loadCredsBytes = %q, want %q", b, data) + } +} + +func TestLoadCredsBytes_NoneConfigured(t *testing.T) { + cfg := gDriveOptions{} + _, ok, err := loadCredsBytes(context.Background(), cfg) + if err != nil { + t.Fatalf("loadCredsBytes: %v", err) + } + if ok { + t.Error("loadCredsBytes should return ok=false when nothing configured") + } +} + func TestGDriveInfo_MyDrive_Root(t *testing.T) { s := &GDriveSource{account: "user@gmail.com", rootPath: "/"} info := s.Info()