Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ cloudstic restore

# Preview what a backup would do (dry run)
cloudstic backup -source local:~/Documents -dry-run

# Discover local source candidates and portable drives
cloudstic source discover -portable-only
```

## Profiles
Expand Down
5 changes: 5 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ type ProfilesConfig = engine.ProfilesConfig
type ProfileStore = engine.ProfileStore
type ProfileAuth = engine.ProfileAuth
type BackupProfile = engine.BackupProfile
type DiscoveredSource = engine.DiscoveredSource

var (
WithVerbose = engine.WithVerbose
Expand All @@ -337,6 +338,10 @@ func (c *Client) Backup(ctx context.Context, src source.Source, opts ...BackupOp
return result, nil
}

func (c *Client) DiscoverSources(ctx context.Context) ([]DiscoveredSource, error) {
return engine.DiscoverSources(ctx)
}

// LoadProfilesFile parses a backup profiles YAML file.
func LoadProfilesFile(path string) (*ProfilesConfig, error) {
return engine.LoadProfilesFile(path)
Expand Down
7 changes: 7 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ func TestAddRecoveryKey_WrongCredentials(t *testing.T) {
}
}

func TestClientDiscoverSources(t *testing.T) {
c := &Client{}
if _, err := c.DiscoverSources(context.Background()); err != nil {
t.Fatalf("DiscoverSources: %v", err)
}
}

// ---------------------------------------------------------------------------
// Cat
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions cmd/cloudstic/client_iface.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
// cloudsticClient is the interface commands use to interact with the repository.
type cloudsticClient interface {
Backup(ctx context.Context, src source.Source, opts ...cloudstic.BackupOption) (*cloudstic.BackupResult, error)
DiscoverSources(ctx context.Context) ([]cloudstic.DiscoveredSource, error)
Restore(ctx context.Context, w io.Writer, snapshotRef string, opts ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error)
RestoreToDir(ctx context.Context, outputDir, snapshotRef string, opts ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error)
List(ctx context.Context, opts ...cloudstic.ListOption) (*cloudstic.ListResult, error)
Expand Down
78 changes: 78 additions & 0 deletions cmd/cloudstic/cmd_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"context"
"flag"
"fmt"
"os"

cloudstic "github.com/cloudstic/cli"
"github.com/jedib0t/go-pretty/v6/table"
)

func (r *runner) runSource(ctx context.Context) int {
if len(os.Args) < 3 {
_, _ = fmt.Fprintln(r.errOut, "Usage: cloudstic source <subcommand> [options]")
_, _ = fmt.Fprintln(r.errOut, "")
_, _ = fmt.Fprintln(r.errOut, "Available subcommands: discover")
return 1
}

switch os.Args[2] {
case "discover":
return r.runSourceDiscover(ctx)
default:
return r.fail("Unknown source subcommand: %s", os.Args[2])
}
}

func (r *runner) runSourceDiscover(ctx context.Context) int {
fs := flag.NewFlagSet("source discover", flag.ExitOnError)
portableOnly := fs.Bool("portable-only", false, "Only show portable/external source candidates")
jsonOutput := fs.Bool("json", false, "Write discovered sources as JSON")
_ = fs.Parse(reorderArgs(fs, os.Args[3:]))

if r.client == nil {
r.client = &cloudstic.Client{}
}

results, err := r.client.DiscoverSources(ctx)
if err != nil {
return r.fail("Failed to discover sources: %v", err)
}

if *portableOnly {
filtered := results[:0]
for _, result := range results {
if result.Portable {
filtered = append(filtered, result)
}
}
results = filtered
}

if *jsonOutput {
return r.writeJSON(results)
}

if len(results) == 0 {
_, _ = fmt.Fprintln(r.out, "No sources discovered.")
return 0
}

t := table.NewWriter()
t.SetOutputMirror(r.out)
t.AppendHeader(table.Row{"Name", "Source URI", "Mount", "Identity", "FS", "Portable"})
for _, result := range results {
t.AppendRow(table.Row{
result.DisplayName,
result.SourceURI,
result.MountPoint,
result.Identity,
result.FsType,
boolLabel(result.Portable),
})
}
t.Render()
return 0
}
97 changes: 97 additions & 0 deletions cmd/cloudstic/cmd_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"context"
"os"
"strings"
"testing"

cloudstic "github.com/cloudstic/cli"
)

func TestRunSourceDiscover(t *testing.T) {
client := &stubClient{
discoverResult: []cloudstic.DiscoveredSource{
{DisplayName: "System", SourceURI: "local:/", MountPoint: "/", Identity: "HOST-1", FsType: "apfs", Portable: false},
{DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Identity: "UUID-1", FsType: "exfat", Portable: true},
},
}

osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "source", "discover"}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut, client: client}
if code := r.runSource(context.Background()); code != 0 {
t.Fatalf("code=%d err=%s", code, errOut.String())
}
got := out.String()
if !strings.Contains(got, "Photos") || !strings.Contains(got, "local:/Volumes/Photos") {
t.Fatalf("unexpected output:\n%s", got)
}
}

func TestRunSourceDiscover_PortableOnly(t *testing.T) {
client := &stubClient{
discoverResult: []cloudstic.DiscoveredSource{
{DisplayName: "System", SourceURI: "local:/", MountPoint: "/", Portable: false},
{DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Portable: true},
},
}

osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "source", "discover", "-portable-only"}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut, client: client}
if code := r.runSource(context.Background()); code != 0 {
t.Fatalf("code=%d err=%s", code, errOut.String())
}
got := out.String()
if strings.Contains(got, "System") {
t.Fatalf("expected portable-only output, got:\n%s", got)
}
if !strings.Contains(got, "Photos") {
t.Fatalf("missing portable source:\n%s", got)
}
}

func TestRunSourceDiscover_JSON(t *testing.T) {
client := &stubClient{
discoverResult: []cloudstic.DiscoveredSource{
{DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Portable: true},
},
}

osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "source", "discover", "-json"}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut, client: client}
if code := r.runSource(context.Background()); code != 0 {
t.Fatalf("code=%d err=%s", code, errOut.String())
}
got := out.String()
if !strings.Contains(got, "\"source_uri\": \"local:/Volumes/Photos\"") {
t.Fatalf("unexpected json output:\n%s", got)
}
}

func TestRunSourceDiscover_DefaultClient(t *testing.T) {
osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "source", "discover", "-json"}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut}
if code := r.runSource(context.Background()); code != 0 {
t.Fatalf("code=%d err=%s", code, errOut.String())
}
}
56 changes: 55 additions & 1 deletion cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ _cloudstic() {
local cur prev words cword
_init_completion || return

local commands="init backup auth profile store restore list ls prune forget diff break-lock key cat completion version help"
local commands="init backup auth profile store source restore list ls prune forget diff break-lock key cat completion version help"

local global_flags="-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 -source-sftp-insecure -store-sftp-password -store-sftp-key -store-sftp-known-hosts -store-sftp-insecure -encryption-key -password -recovery-key -kms-key-arn -kms-region -kms-endpoint -disable-packfile -prompt -no-prompt -verbose -quiet -json -debug"

Expand Down Expand Up @@ -188,6 +188,26 @@ _cloudstic() {
cmd_flags="" ;;
esac
;;
source)
local source_sub=""
local j
for ((j=i+1; j < cword; j++)); do
case "${words[j]}" in
-*) ;;
*) source_sub="${words[j]}"; break ;;
esac
done
if [[ -z "$source_sub" ]]; then
COMPREPLY=($(compgen -W "discover" -- "$cur"))
return
fi
case "$source_sub" in
discover)
cmd_flags="-portable-only -json" ;;
*)
cmd_flags="" ;;
esac
;;
check)
cmd_flags="-read-data" ;;
ls|diff|break-lock|version|help)
Expand Down Expand Up @@ -232,6 +252,7 @@ _cloudstic() {
'backup:Create a new backup snapshot from a source'
'auth:Manage reusable cloud auth entries'
'profile:Manage backup profiles'
'source:Discover source candidates for onboarding'
'restore:Restore files from a backup snapshot'
'list:List all backup snapshots in the repository'
'ls:List files within a specific snapshot'
Expand Down Expand Up @@ -505,6 +526,33 @@ _cloudstic() {
;;
esac
;;
source)
local -a source_commands
source_commands=(
'discover:Discover local source candidates'
)
local source_sub
local -i soi=$((i+1))
while (( soi < CURRENT )); do
case "${words[soi]}" in
-*) ;;
*) source_sub="${words[soi]}"; break ;;
esac
(( soi++ ))
done
if [[ -z "$source_sub" ]]; then
_describe -t source-commands 'source subcommand' source_commands
return
fi
case "$source_sub" in
discover)
_arguments '-portable-only[Only show portable or external source candidates]' '-json[Write discovered sources as JSON]'
;;
*)
_arguments
;;
esac
;;
restore)
_arguments $global_flags \
'-output[Output path for zip or dir restore]:path:_files' \
Expand Down Expand Up @@ -607,6 +655,7 @@ complete -c cloudstic -n __fish_use_subcommand -a init -d 'Initialize a new repo
complete -c cloudstic -n __fish_use_subcommand -a backup -d 'Create a new backup snapshot'
complete -c cloudstic -n __fish_use_subcommand -a auth -d 'Manage reusable cloud auth entries'
complete -c cloudstic -n __fish_use_subcommand -a profile -d 'Manage backup profiles'
complete -c cloudstic -n __fish_use_subcommand -a source -d 'Discover source candidates for onboarding'
complete -c cloudstic -n __fish_use_subcommand -a restore -d 'Restore files from a snapshot'
complete -c cloudstic -n __fish_use_subcommand -a list -d 'List all backup snapshots'
complete -c cloudstic -n __fish_use_subcommand -a ls -d 'List files within a snapshot'
Expand Down Expand Up @@ -723,6 +772,11 @@ complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_sub
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l profiles-file -r -F -d 'Path to profiles YAML file'
complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l yes -d 'Initialize without confirmation prompt'

# source subcommands
complete -c cloudstic -n '__fish_seen_subcommand_from source; and not __fish_seen_subcommand_from discover' -a discover -d 'Discover local source candidates'
complete -c cloudstic -n '__fish_seen_subcommand_from source; and __fish_seen_subcommand_from discover' -l portable-only -d 'Only show portable or external source candidates'
complete -c cloudstic -n '__fish_seen_subcommand_from source; and __fish_seen_subcommand_from discover' -l json -d 'Write discovered sources as JSON'

# auth subcommands
complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a list -d 'List auth entries from profiles.yaml'
complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a show -d 'Show one auth entry'
Expand Down
5 changes: 4 additions & 1 deletion cmd/cloudstic/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func TestCompletionBash(t *testing.T) {
"_cloudstic()",
"complete -F _cloudstic cloudstic",
// All commands are listed
"init", "backup", "auth", "profile", "restore", "list", "ls", "prune", "forget",
"init", "backup", "auth", "profile", "store", "source", "restore", "list", "ls", "prune", "forget",
"diff", "break-lock", "key", "cat", "completion",
// Key subcommands
"list add-recovery passwd",
Expand Down Expand Up @@ -66,6 +66,8 @@ func TestCompletionZsh(t *testing.T) {
"login:Run OAuth login flow for one auth entry",
"key:Manage encryption key slots",
"completion:Generate shell completion scripts",
"source:Discover source candidates for onboarding",
"discover:Discover local source candidates",
// Key subcommands
"list:List all encryption key slots",
"add-recovery:Generate a 24-word recovery key",
Expand Down Expand Up @@ -109,6 +111,7 @@ func TestCompletionFish(t *testing.T) {
"complete -c cloudstic -n __fish_use_subcommand -a backup",
"complete -c cloudstic -n __fish_use_subcommand -a profile",
"complete -c cloudstic -n __fish_use_subcommand -a auth",
"complete -c cloudstic -n __fish_use_subcommand -a source",
"complete -c cloudstic -n __fish_use_subcommand -a key",
"complete -c cloudstic -n __fish_use_subcommand -a completion",
// Key subcommands
Expand Down
2 changes: 2 additions & 0 deletions cmd/cloudstic/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ func runCmd(cmd string) int {
return r.runAuth(ctx)
case "store":
return r.runStore(ctx)
case "source":
return r.runSource(ctx)
case "completion":
runCompletion()
return 0
Expand Down
6 changes: 6 additions & 0 deletions cmd/cloudstic/stub_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
type stubClient struct {
backupResult *cloudstic.BackupResult
backupErr error
discoverResult []cloudstic.DiscoveredSource
discoverErr error
restoreResult *cloudstic.RestoreResult
restoreErr error
listResult *cloudstic.ListResult
Expand All @@ -39,6 +41,10 @@ func (s *stubClient) Backup(_ context.Context, _ source.Source, _ ...cloudstic.B
return s.backupResult, s.backupErr
}

func (s *stubClient) DiscoverSources(_ context.Context) ([]cloudstic.DiscoveredSource, error) {
return s.discoverResult, s.discoverErr
}

func (s *stubClient) Restore(_ context.Context, _ io.Writer, _ string, _ ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error) {
return s.restoreResult, s.restoreErr
}
Expand Down
Loading
Loading