From 30010e011da1eca7d69d1fadfa772a55cd60d13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 18:56:17 +0200 Subject: [PATCH] feat: add workstation setup dry-run preview --- README.md | 3 + client.go | 11 + client_test.go | 7 + cmd/cloudstic/client_iface.go | 1 + cmd/cloudstic/cmd_setup.go | 156 +++++++++++ cmd/cloudstic/cmd_setup_test.go | 93 +++++++ cmd/cloudstic/completion.go | 64 ++++- cmd/cloudstic/completion_test.go | 10 +- cmd/cloudstic/main.go | 2 + cmd/cloudstic/stub_client_test.go | 6 + cmd/cloudstic/usage.go | 15 + docs/user-guide.md | 29 ++ internal/engine/workstation_setup.go | 325 ++++++++++++++++++++++ internal/engine/workstation_setup_test.go | 138 +++++++++ 14 files changed, 858 insertions(+), 2 deletions(-) create mode 100644 cmd/cloudstic/cmd_setup.go create mode 100644 cmd/cloudstic/cmd_setup_test.go create mode 100644 internal/engine/workstation_setup.go create mode 100644 internal/engine/workstation_setup_test.go diff --git a/README.md b/README.md index 613535a..a394c99 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ cloudstic backup -source local:~/Documents -dry-run # Discover local source candidates and portable drives cloudstic source discover -portable-only + +# Preview a workstation onboarding plan +cloudstic setup workstation -dry-run ``` ## Profiles diff --git a/client.go b/client.go index 403b437..50d909f 100644 --- a/client.go +++ b/client.go @@ -312,6 +312,11 @@ type ProfileStore = engine.ProfileStore type ProfileAuth = engine.ProfileAuth type BackupProfile = engine.BackupProfile type DiscoveredSource = engine.DiscoveredSource +type WorkstationSetupPlan = engine.WorkstationSetupPlan +type WorkstationSetupOption = engine.WorkstationSetupOption +type WorkstationProfileDraft = engine.WorkstationProfileDraft +type WorkstationFolderCandidate = engine.WorkstationFolderCandidate +type WorkstationCoverageSummary = engine.WorkstationCoverageSummary var ( WithVerbose = engine.WithVerbose @@ -321,6 +326,8 @@ var ( WithGenerator = engine.WithGenerator WithMeta = engine.WithMeta WithExcludeHash = engine.WithExcludeHash + WithWorkstationProfiles = engine.WithWorkstationProfiles + WithWorkstationStoreRef = engine.WithWorkstationStoreRef ) func (c *Client) Backup(ctx context.Context, src source.Source, opts ...BackupOption) (*BackupResult, error) { @@ -342,6 +349,10 @@ func (c *Client) DiscoverSources(ctx context.Context) ([]DiscoveredSource, error return engine.DiscoverSources(ctx) } +func (c *Client) PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) (*WorkstationSetupPlan, error) { + return engine.PlanWorkstationSetup(ctx, opts...) +} + // LoadProfilesFile parses a backup profiles YAML file. func LoadProfilesFile(path string) (*ProfilesConfig, error) { return engine.LoadProfilesFile(path) diff --git a/client_test.go b/client_test.go index 5189e4e..dd45ca2 100644 --- a/client_test.go +++ b/client_test.go @@ -223,6 +223,13 @@ func TestClientDiscoverSources(t *testing.T) { } } +func TestClientPlanWorkstationSetup(t *testing.T) { + c := &Client{} + if _, err := c.PlanWorkstationSetup(context.Background()); err != nil { + t.Fatalf("PlanWorkstationSetup: %v", err) + } +} + // --------------------------------------------------------------------------- // Cat // --------------------------------------------------------------------------- diff --git a/cmd/cloudstic/client_iface.go b/cmd/cloudstic/client_iface.go index 65cb504..b9afa7c 100644 --- a/cmd/cloudstic/client_iface.go +++ b/cmd/cloudstic/client_iface.go @@ -12,6 +12,7 @@ import ( type cloudsticClient interface { Backup(ctx context.Context, src source.Source, opts ...cloudstic.BackupOption) (*cloudstic.BackupResult, error) DiscoverSources(ctx context.Context) ([]cloudstic.DiscoveredSource, error) + PlanWorkstationSetup(ctx context.Context, opts ...cloudstic.WorkstationSetupOption) (*cloudstic.WorkstationSetupPlan, 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) diff --git a/cmd/cloudstic/cmd_setup.go b/cmd/cloudstic/cmd_setup.go new file mode 100644 index 0000000..05483f3 --- /dev/null +++ b/cmd/cloudstic/cmd_setup.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + cloudstic "github.com/cloudstic/cli" + "github.com/jedib0t/go-pretty/v6/table" +) + +func defaultProfilesPathNoCreate() string { + if path := os.Getenv("CLOUDSTIC_PROFILES_FILE"); path != "" { + return path + } + if dir := os.Getenv("CLOUDSTIC_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, defaultProfilesFilename) + } + if dir, err := os.UserConfigDir(); err == nil { + return filepath.Join(dir, "cloudstic", defaultProfilesFilename) + } + return defaultProfilesFilename +} + +func (r *runner) runSetup(ctx context.Context) int { + if len(os.Args) < 3 { + _, _ = fmt.Fprintln(r.errOut, "Usage: cloudstic setup [options]") + _, _ = fmt.Fprintln(r.errOut, "") + _, _ = fmt.Fprintln(r.errOut, "Available subcommands: workstation") + return 1 + } + + switch os.Args[2] { + case "workstation": + return r.runSetupWorkstation(ctx) + default: + return r.fail("Unknown setup subcommand: %s", os.Args[2]) + } +} + +type setupWorkstationArgs struct { + dryRun bool + yes bool + jsonOutput bool + profilesFile string + storeRef string +} + +func parseSetupWorkstationArgs() *setupWorkstationArgs { + fs := flag.NewFlagSet("setup workstation", flag.ExitOnError) + a := &setupWorkstationArgs{} + dryRun := fs.Bool("dry-run", false, "Preview generated profiles without writing configuration") + yes := fs.Bool("yes", false, "Accept default selections without prompting") + jsonOutput := fs.Bool("json", false, "Write onboarding plan as JSON") + profilesFile := fs.String("profiles-file", defaultProfilesPathNoCreate(), "Path to profiles YAML file") + storeRef := fs.String("store-ref", "", "Existing store reference to attach to generated profiles") + _ = fs.Parse(reorderArgs(fs, os.Args[3:])) + a.dryRun = *dryRun + a.yes = *yes + a.jsonOutput = *jsonOutput + a.profilesFile = *profilesFile + a.storeRef = strings.TrimSpace(*storeRef) + return a +} + +func (r *runner) runSetupWorkstation(ctx context.Context) int { + args := parseSetupWorkstationArgs() + if !args.dryRun { + return r.fail("setup workstation write mode is not implemented yet; use -dry-run") + } + + cfg, err := loadProfilesOrInit(args.profilesFile) + if err != nil { + return r.fail("Failed to load profiles: %v", err) + } + if r.client == nil { + r.client = &cloudstic.Client{} + } + + opts := []cloudstic.WorkstationSetupOption{cloudstic.WithWorkstationProfiles(cfg)} + if args.storeRef != "" { + opts = append(opts, cloudstic.WithWorkstationStoreRef(args.storeRef)) + } + plan, err := r.client.PlanWorkstationSetup(ctx, opts...) + if err != nil { + return r.fail("Failed to plan workstation setup: %v", err) + } + + if args.jsonOutput { + return r.writeJSON(plan) + } + + printWorkstationSetupPlan(r.out, plan) + return 0 +} + +func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPlan) { + _, _ = fmt.Fprintln(out, "Workstation setup plan (dry-run)") + _, _ = fmt.Fprintf(out, "Host: %s\n", plan.Hostname) + if plan.StoreRef != "" { + _, _ = fmt.Fprintf(out, "Store: %s (%s)\n", plan.StoreRef, plan.StoreAction) + } else { + _, _ = fmt.Fprintf(out, "Store: unresolved (%s)\n", plan.StoreAction) + } + _, _ = fmt.Fprintln(out) + + if len(plan.Profiles) > 0 { + t := table.NewWriter() + t.SetOutputMirror(out) + t.AppendHeader(table.Row{"Profile", "Source URI", "Store", "Tags", "Action"}) + for _, profile := range plan.Profiles { + t.AppendRow(table.Row{ + profile.Name, + profile.SourceURI, + firstNonEmptyCLI(profile.StoreRef, "(none)"), + strings.Join(profile.Tags, ","), + profile.Action, + }) + } + t.Render() + } else { + _, _ = fmt.Fprintln(out, "No profile drafts generated.") + } + + printWorkstationCoverage(out, plan) +} + +func printWorkstationCoverage(out io.Writer, plan *cloudstic.WorkstationSetupPlan) { + writeWorkstationLines := func(title string, items []string) { + if len(items) == 0 { + return + } + _, _ = fmt.Fprintf(out, "\n%s:\n", title) + for _, item := range items { + _, _ = fmt.Fprintf(out, "- %s\n", item) + } + } + + writeWorkstationLines("Protected now", plan.Coverage.ProtectedNow) + writeWorkstationLines("Skipped intentionally", plan.Coverage.SkippedIntentionally) + writeWorkstationLines("Not available now", plan.Coverage.NotAvailableNow) + writeWorkstationLines("Warnings", plan.Coverage.Warnings) +} + +func firstNonEmptyCLI(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/cmd/cloudstic/cmd_setup_test.go b/cmd/cloudstic/cmd_setup_test.go new file mode 100644 index 0000000..a45fc94 --- /dev/null +++ b/cmd/cloudstic/cmd_setup_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + cloudstic "github.com/cloudstic/cli" +) + +func TestRunSetupWorkstation_DryRun(t *testing.T) { + client := &stubClient{ + setupPlan: &cloudstic.WorkstationSetupPlan{ + Hostname: "testbox", + StoreRef: "primary", + StoreAction: "use-existing", + Profiles: []cloudstic.WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"}, + }, + Coverage: cloudstic.WorkstationCoverageSummary{ + ProtectedNow: []string{"Documents (/Users/test/Documents)"}, + SkippedIntentionally: []string{"Downloads (/Users/test/Downloads)"}, + }, + }, + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "setup", "workstation", "-dry-run"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client} + if code := r.runSetup(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + got := out.String() + if !strings.Contains(got, "Workstation setup plan (dry-run)") || !strings.Contains(got, "documents") { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestRunSetupWorkstation_JSON(t *testing.T) { + client := &stubClient{ + setupPlan: &cloudstic.WorkstationSetupPlan{ + Hostname: "testbox", + }, + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "setup", "workstation", "-dry-run", "-json"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client} + if code := r.runSetup(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "\"hostname\": \"testbox\"") { + t.Fatalf("unexpected json output:\n%s", out.String()) + } +} + +func TestRunSetupWorkstation_RequiresDryRun(t *testing.T) { + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "setup", "workstation"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: &stubClient{}} + if code := r.runSetup(context.Background()); code == 0 { + t.Fatal("expected failure without -dry-run") + } +} + +func TestDefaultProfilesPathNoCreate(t *testing.T) { + configRoot := filepath.Join(t.TempDir(), "config") + t.Setenv("CLOUDSTIC_CONFIG_DIR", configRoot) + t.Setenv("CLOUDSTIC_PROFILES_FILE", "") + + got := defaultProfilesPathNoCreate() + want := filepath.Join(configRoot, defaultProfilesFilename) + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + if _, err := os.Stat(configRoot); !os.IsNotExist(err) { + t.Fatalf("config dir should not be created, err=%v", err) + } +} diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 51ed688..b61b9a7 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -41,7 +41,7 @@ _cloudstic() { local cur prev words cword _init_completion || return - local commands="init backup auth profile store source restore list ls prune forget diff break-lock key cat completion version help" + local commands="init backup auth profile store source setup 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" @@ -208,6 +208,26 @@ _cloudstic() { cmd_flags="" ;; esac ;; + setup) + local setup_sub="" + local j + for ((j=i+1; j < cword; j++)); do + case "${words[j]}" in + -*) ;; + *) setup_sub="${words[j]}"; break ;; + esac + done + if [[ -z "$setup_sub" ]]; then + COMPREPLY=($(compgen -W "workstation" -- "$cur")) + return + fi + case "$setup_sub" in + workstation) + cmd_flags="-dry-run -yes -profiles-file -store-ref -json" ;; + *) + cmd_flags="" ;; + esac + ;; check) cmd_flags="-read-data" ;; ls|diff|break-lock|version|help) @@ -253,6 +273,7 @@ _cloudstic() { 'auth:Manage reusable cloud auth entries' 'profile:Manage backup profiles' 'source:Discover source candidates for onboarding' + 'setup:Guided setup and onboarding flows' 'restore:Restore files from a backup snapshot' 'list:List all backup snapshots in the repository' 'ls:List files within a specific snapshot' @@ -553,6 +574,38 @@ _cloudstic() { ;; esac ;; + setup) + local -a setup_commands + setup_commands=( + 'workstation:Preview workstation onboarding plan' + ) + local setup_sub + local -i sui=$((i+1)) + while (( sui < CURRENT )); do + case "${words[sui]}" in + -*) ;; + *) setup_sub="${words[sui]}"; break ;; + esac + (( sui++ )) + done + if [[ -z "$setup_sub" ]]; then + _describe -t setup-commands 'setup subcommand' setup_commands + return + fi + case "$setup_sub" in + workstation) + _arguments \ + '-dry-run[Preview generated profiles without writing configuration]' \ + '-yes[Accept default selections without prompting]' \ + '-profiles-file[Path to profiles YAML file]:path:_files' \ + '-store-ref[Existing store reference to attach]:name:' \ + '-json[Write onboarding plan as JSON]' + ;; + *) + _arguments + ;; + esac + ;; restore) _arguments $global_flags \ '-output[Output path for zip or dir restore]:path:_files' \ @@ -656,6 +709,7 @@ complete -c cloudstic -n __fish_use_subcommand -a backup -d 'Create a new backup 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 setup -d 'Guided setup and onboarding flows' 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' @@ -777,6 +831,14 @@ complete -c cloudstic -n '__fish_seen_subcommand_from source; and not __fish_see 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' +# setup subcommands +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and not __fish_seen_subcommand_from workstation' -a workstation -d 'Preview workstation onboarding plan' +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from workstation' -l dry-run -d 'Preview generated profiles without writing configuration' +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from workstation' -l yes -d 'Accept default selections without prompting' +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from workstation' -l profiles-file -r -F -d 'Path to profiles YAML file' +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from workstation' -l store-ref -x -d 'Existing store reference to attach' +complete -c cloudstic -n '__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from workstation' -l json -d 'Write onboarding plan 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' diff --git a/cmd/cloudstic/completion_test.go b/cmd/cloudstic/completion_test.go index a2acdd9..8c7a240 100644 --- a/cmd/cloudstic/completion_test.go +++ b/cmd/cloudstic/completion_test.go @@ -20,7 +20,7 @@ func TestCompletionBash(t *testing.T) { "_cloudstic()", "complete -F _cloudstic cloudstic", // All commands are listed - "init", "backup", "auth", "profile", "store", "source", "restore", "list", "ls", "prune", "forget", + "init", "backup", "auth", "profile", "store", "source", "setup", "restore", "list", "ls", "prune", "forget", "diff", "break-lock", "key", "cat", "completion", // Key subcommands "list add-recovery passwd", @@ -32,6 +32,8 @@ func TestCompletionBash(t *testing.T) { "-profile", "-all-profiles", "-auth-ref", "-ignore-empty-snapshot", + "workstation", + "-store-ref", // Value completions "local: s3: b2: sftp://", "gdrive", "onedrive", @@ -68,6 +70,8 @@ func TestCompletionZsh(t *testing.T) { "completion:Generate shell completion scripts", "source:Discover source candidates for onboarding", "discover:Discover local source candidates", + "setup:Guided setup and onboarding flows", + "workstation:Preview workstation onboarding plan", // Key subcommands "list:List all encryption key slots", "add-recovery:Generate a 24-word recovery key", @@ -85,6 +89,7 @@ func TestCompletionZsh(t *testing.T) { "-all-profiles[Run all enabled backup profiles]", "-auth-ref[Use named auth entry from profiles.yaml]", "-ignore-empty-snapshot[Skip creating a new snapshot when nothing changed]", + "-store-ref[Existing store reference to attach]", // Value completions (source type list still present) "(local: sftp:// gdrive gdrive-changes onedrive onedrive-changes)", "(bash zsh fish)", @@ -112,6 +117,7 @@ func TestCompletionFish(t *testing.T) { "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 setup", "complete -c cloudstic -n __fish_use_subcommand -a key", "complete -c cloudstic -n __fish_use_subcommand -a completion", // Key subcommands @@ -134,6 +140,8 @@ func TestCompletionFish(t *testing.T) { "-l all-profiles", "-l auth-ref", "-l ignore-empty-snapshot", + "-a workstation -d 'Preview workstation onboarding plan'", + "-l store-ref", "-a show -d 'Show one profile and resolved refs'", "-a new -d 'Create or update backup profile'", "-a login -d 'Run OAuth login flow for auth entry'", diff --git a/cmd/cloudstic/main.go b/cmd/cloudstic/main.go index 9cf2723..3a9aaca 100644 --- a/cmd/cloudstic/main.go +++ b/cmd/cloudstic/main.go @@ -73,6 +73,8 @@ func runCmd(cmd string) int { return r.runStore(ctx) case "source": return r.runSource(ctx) + case "setup": + return r.runSetup(ctx) case "completion": runCompletion() return 0 diff --git a/cmd/cloudstic/stub_client_test.go b/cmd/cloudstic/stub_client_test.go index 9c911b1..9656a16 100644 --- a/cmd/cloudstic/stub_client_test.go +++ b/cmd/cloudstic/stub_client_test.go @@ -15,6 +15,8 @@ type stubClient struct { backupErr error discoverResult []cloudstic.DiscoveredSource discoverErr error + setupPlan *cloudstic.WorkstationSetupPlan + setupPlanErr error restoreResult *cloudstic.RestoreResult restoreErr error listResult *cloudstic.ListResult @@ -45,6 +47,10 @@ func (s *stubClient) DiscoverSources(_ context.Context) ([]cloudstic.DiscoveredS return s.discoverResult, s.discoverErr } +func (s *stubClient) PlanWorkstationSetup(_ context.Context, _ ...cloudstic.WorkstationSetupOption) (*cloudstic.WorkstationSetupPlan, error) { + return s.setupPlan, s.setupPlanErr +} + func (s *stubClient) Restore(_ context.Context, _ io.Writer, _ string, _ ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error) { return s.restoreResult, s.restoreErr } diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index 7bd4fda..ca3af3f 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -28,6 +28,7 @@ func printUsage() { {"store show", "Show one store and its configuration"}, {"store verify", "Verify one store's credentials and connectivity"}, {"source discover", "Discover local source candidates for onboarding"}, + {"setup workstation", "Preview workstation onboarding plan"}, {"profile new", "Create or update a backup profile in profiles.yaml"}, {"profile list", "List stores, auth entries, and backup profiles"}, {"profile show", "Show one profile and resolved store/auth references"}, @@ -229,6 +230,20 @@ func printUsage() { ) t.Blank() + t.Command("setup workstation", "") + t.Flags([][2]string{ + {"-dry-run", "Preview generated profiles without writing configuration"}, + {"-yes", "Accept default selections without prompting"}, + {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, + {"-store-ref ", "Existing store reference to attach to generated profiles"}, + {"-json", "Write onboarding plan as JSON"}, + }) + t.Note( + " Build a workstation onboarding plan using OS-aware folder suggestions and portable-drive discovery.", + " Only dry-run preview is implemented currently; no configuration is written.", + ) + t.Blank() + t.Command("profile list", "") t.Flags([][2]string{ {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, diff --git a/docs/user-guide.md b/docs/user-guide.md index 3adbbff..2de1ba8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -16,6 +16,7 @@ Cloudstic is a content-addressable backup tool that creates encrypted, deduplica - [auth](#auth) - [profile](#profile) - [store](#store) + - [setup](#setup) - [restore](#restore) - [list](#list) - [ls](#ls) @@ -540,6 +541,34 @@ considered portable. --- +### setup + +Preview a workstation onboarding plan without writing configuration. + +#### setup workstation + +```bash +cloudstic setup workstation -dry-run +``` + +Attach an existing store to the generated profile drafts: + +```bash +cloudstic setup workstation -dry-run -store-ref my-s3 +``` + +Write the onboarding plan as JSON: + +```bash +cloudstic setup workstation -dry-run -json +``` + +The preview uses OS-aware local folder suggestions and portable-drive discovery +to generate a review-first profile plan. At this stage only dry-run mode is +implemented; the command does not write `profiles.yaml`. + +--- + ### store Manage named store entries in `profiles.yaml`. Stores define storage backend, connection credentials, and encryption settings. diff --git a/internal/engine/workstation_setup.go b/internal/engine/workstation_setup.go new file mode 100644 index 0000000..510acff --- /dev/null +++ b/internal/engine/workstation_setup.go @@ -0,0 +1,325 @@ +package engine + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" +) + +var ( + workstationDiscoverSourcesFunc = DiscoverSources + workstationUserHomeDirFunc = os.UserHomeDir + workstationHostnameFunc = os.Hostname + workstationPathExistsFunc = func(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() + } + workstationGOOS = runtime.GOOS +) + +type WorkstationSetupOption func(*workstationSetupOptions) + +type workstationSetupOptions struct { + profiles *ProfilesConfig + storeRef string +} + +func WithWorkstationProfiles(cfg *ProfilesConfig) WorkstationSetupOption { + return func(o *workstationSetupOptions) { o.profiles = cfg } +} + +func WithWorkstationStoreRef(name string) WorkstationSetupOption { + return func(o *workstationSetupOptions) { o.storeRef = strings.TrimSpace(name) } +} + +type WorkstationFolderCandidate struct { + Key string `json:"key"` + Label string `json:"label"` + Category string `json:"category"` + Path string `json:"path"` + Selected bool `json:"selected"` +} + +type WorkstationProfileDraft struct { + Name string `json:"name"` + SourceURI string `json:"source_uri"` + StoreRef string `json:"store_ref,omitempty"` + Tags []string `json:"tags,omitempty"` + Action string `json:"action"` +} + +type WorkstationCoverageSummary struct { + ProtectedNow []string `json:"protected_now,omitempty"` + SkippedIntentionally []string `json:"skipped_intentionally,omitempty"` + NotAvailableNow []string `json:"not_available_now,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type WorkstationSetupPlan struct { + Hostname string `json:"hostname"` + StoreRef string `json:"store_ref,omitempty"` + StoreAction string `json:"store_action"` + Folders []WorkstationFolderCandidate `json:"folders,omitempty"` + PortableSources []DiscoveredSource `json:"portable_sources,omitempty"` + Profiles []WorkstationProfileDraft `json:"profiles,omitempty"` + Coverage WorkstationCoverageSummary `json:"coverage"` +} + +type workstationFolderSpec struct { + key string + label string + category string + path string +} + +func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) (*WorkstationSetupPlan, error) { + options := workstationSetupOptions{} + for _, opt := range opts { + opt(&options) + } + + cfg := normalizeProfilesConfig(options.profiles) + hostname, err := workstationHostnameFunc() + if err != nil { + return nil, fmt.Errorf("resolve hostname: %w", err) + } + hostname = strings.TrimSpace(hostname) + if hostname == "" { + hostname = "workstation" + } + + folders, skipped, err := discoverWorkstationFolders() + if err != nil { + return nil, err + } + + discovered, err := workstationDiscoverSourcesFunc(ctx) + if err != nil { + return nil, err + } + portable := make([]DiscoveredSource, 0, len(discovered)) + for _, result := range discovered { + if result.Portable { + portable = append(portable, result) + } + } + + storeRef, storeAction, warnings := resolveWorkstationStore(cfg, options.storeRef) + + plan := &WorkstationSetupPlan{ + Hostname: hostname, + StoreRef: storeRef, + StoreAction: storeAction, + Folders: folders, + PortableSources: portable, + } + + usedNames := make(map[string]struct{}, len(cfg.Profiles)) + for name := range cfg.Profiles { + usedNames[name] = struct{}{} + } + + for _, folder := range folders { + if !folder.Selected { + continue + } + name, action := nextWorkstationProfileName(cfg, usedNames, folder.Key, hostname, "local:"+folder.Path) + plan.Profiles = append(plan.Profiles, WorkstationProfileDraft{ + Name: name, + SourceURI: "local:" + folder.Path, + StoreRef: storeRef, + Tags: []string{"workstation"}, + Action: action, + }) + plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, folder.Label+" ("+folder.Path+")") + } + + for _, src := range portable { + base := sanitizeWorkstationName(firstNonEmpty(src.DriveName, src.DisplayName, filepath.Base(src.MountPoint), "portable")) + name, action := nextWorkstationProfileName(cfg, usedNames, base, hostname, src.SourceURI) + plan.Profiles = append(plan.Profiles, WorkstationProfileDraft{ + Name: name, + SourceURI: src.SourceURI, + StoreRef: storeRef, + Tags: []string{"portable", "workstation"}, + Action: action, + }) + plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, src.DisplayName+" ("+src.MountPoint+")") + } + + plan.Coverage.SkippedIntentionally = append(plan.Coverage.SkippedIntentionally, skipped...) + plan.Coverage.Warnings = append(plan.Coverage.Warnings, warnings...) + return plan, nil +} + +func discoverWorkstationFolders() ([]WorkstationFolderCandidate, []string, error) { + home, err := workstationUserHomeDirFunc() + if err != nil { + return nil, nil, fmt.Errorf("resolve home directory: %w", err) + } + + specs := make([]workstationFolderSpec, 0, 8) + addSpec := func(key, label, category string, elems ...string) { + path := filepath.Join(append([]string{home}, elems...)...) + specs = append(specs, workstationFolderSpec{ + key: key, + label: label, + category: category, + path: path, + }) + } + + addSpec("documents", "Documents", "core documents", "Documents") + addSpec("desktop", "Desktop", "desktop and workspace", "Desktop") + addSpec("pictures", "Pictures", "media libraries", "Pictures") + addSpec("videos", "Videos", "media libraries", "Videos") + if workstationGOOS != "windows" { + addSpec("music", "Music", "media libraries", "Music") + } + for _, name := range []string{"Projects", "projects", "code", "src"} { + addSpec(sanitizeWorkstationName(name), name, "developer projects", name) + } + + folders := make([]WorkstationFolderCandidate, 0, len(specs)) + seenPaths := map[string]struct{}{} + for _, spec := range specs { + if !workstationPathExistsFunc(spec.path) { + continue + } + cleanPath := filepath.Clean(spec.path) + if _, ok := seenPaths[cleanPath]; ok { + continue + } + seenPaths[cleanPath] = struct{}{} + folders = append(folders, WorkstationFolderCandidate{ + Key: spec.key, + Label: spec.label, + Category: spec.category, + Path: cleanPath, + Selected: true, + }) + } + slices.SortFunc(folders, func(a, b WorkstationFolderCandidate) int { + if v := strings.Compare(a.Category, b.Category); v != 0 { + return v + } + return strings.Compare(a.Path, b.Path) + }) + + skipped := []string{} + downloads := filepath.Join(home, "Downloads") + if workstationPathExistsFunc(downloads) { + skipped = append(skipped, "Downloads ("+filepath.Clean(downloads)+")") + } + + return folders, skipped, nil +} + +func resolveWorkstationStore(cfg *ProfilesConfig, requested string) (string, string, []string) { + if requested != "" { + if _, ok := cfg.Stores[requested]; ok { + return requested, "use-existing", nil + } + return "", "missing", []string{fmt.Sprintf("Store %q was requested but is not defined in profiles.yaml.", requested)} + } + + storeNames := sortedProfileNames(cfg.Stores) + switch len(storeNames) { + case 0: + return "", "missing", []string{"No store is configured yet. Create one with `cloudstic store new` or rerun with `-store-ref`."} + case 1: + return storeNames[0], "use-existing", nil + default: + return "", "choose-existing", []string{"Multiple stores are configured. Rerun with `-store-ref ` to attach one to the generated profiles."} + } +} + +func nextWorkstationProfileName(cfg *ProfilesConfig, used map[string]struct{}, base, hostname, sourceURI string) (string, string) { + base = sanitizeWorkstationName(base) + if base == "" { + base = "workstation" + } + if existing, ok := cfg.Profiles[base]; ok && existing.Source == sourceURI { + return base, "update" + } + if _, ok := used[base]; !ok { + used[base] = struct{}{} + return base, "create" + } + + prefixed := sanitizeWorkstationName(hostname + "-" + base) + if prefixed == "" { + prefixed = "workstation-" + base + } + if existing, ok := cfg.Profiles[prefixed]; ok && existing.Source == sourceURI { + return prefixed, "update" + } + if _, ok := used[prefixed]; !ok { + used[prefixed] = struct{}{} + return prefixed, "rename" + } + + for i := 2; ; i++ { + candidate := prefixed + "-" + strconv.Itoa(i) + if existing, ok := cfg.Profiles[candidate]; ok && existing.Source == sourceURI { + return candidate, "update" + } + if _, ok := used[candidate]; ok { + continue + } + used[candidate] = struct{}{} + return candidate, "rename" + } +} + +func sanitizeWorkstationName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + var b strings.Builder + prevDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == '.', r == '_', r == '-': + if b.Len() == 0 || prevDash { + continue + } + b.WriteRune(r) + prevDash = r == '-' + default: + if b.Len() == 0 || prevDash { + continue + } + b.WriteRune('-') + prevDash = true + } + } + return strings.Trim(b.String(), "-._") +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func sortedProfileNames[T any](m map[string]T) []string { + names := make([]string, 0, len(m)) + for name := range m { + names = append(names, name) + } + slices.Sort(names) + return names +} diff --git a/internal/engine/workstation_setup_test.go b/internal/engine/workstation_setup_test.go new file mode 100644 index 0000000..330af70 --- /dev/null +++ b/internal/engine/workstation_setup_test.go @@ -0,0 +1,138 @@ +package engine + +import ( + "context" + "errors" + "reflect" + "testing" +) + +func TestPlanWorkstationSetup_BuildsPreview(t *testing.T) { + reset := stubWorkstationSetupEnv(t) + defer reset() + + workstationHostnameFunc = func() (string, error) { return "MacBook-Pro", nil } + workstationUserHomeDirFunc = func() (string, error) { return "/Users/test", nil } + workstationGOOS = "darwin" + workstationPathExistsFunc = func(path string) bool { + switch path { + case "/Users/test/Documents", "/Users/test/Desktop", "/Users/test/Pictures", "/Users/test/Downloads", "/Users/test/Projects": + return true + default: + return false + } + } + workstationDiscoverSourcesFunc = func(context.Context) ([]DiscoveredSource, error) { + return []DiscoveredSource{ + {DisplayName: "System", SourceURI: "local:/", MountPoint: "/", Portable: false}, + {DisplayName: "Archive", DriveName: "Archive", SourceURI: "local:/Volumes/Archive", MountPoint: "/Volumes/Archive", Portable: true}, + }, nil + } + + cfg := &ProfilesConfig{ + Stores: map[string]ProfileStore{ + "primary": {URI: "s3:bucket"}, + }, + Profiles: map[string]BackupProfile{ + "documents": {Source: "local:/Users/test/Documents"}, + "archive": {Source: "local:/old-archive"}, + }, + } + + plan, err := PlanWorkstationSetup(context.Background(), WithWorkstationProfiles(cfg)) + if err != nil { + t.Fatalf("PlanWorkstationSetup: %v", err) + } + + if plan.StoreRef != "primary" || plan.StoreAction != "use-existing" { + t.Fatalf("unexpected store resolution: %#v", plan) + } + if len(plan.PortableSources) != 1 || plan.PortableSources[0].DisplayName != "Archive" { + t.Fatalf("unexpected portable sources: %#v", plan.PortableSources) + } + + gotProfiles := map[string]WorkstationProfileDraft{} + for _, profile := range plan.Profiles { + gotProfiles[profile.Name] = profile + } + if gotProfiles["documents"].Action != "update" { + t.Fatalf("documents action = %q, want update", gotProfiles["documents"].Action) + } + if gotProfiles["desktop"].Action != "create" { + t.Fatalf("desktop action = %q, want create", gotProfiles["desktop"].Action) + } + if gotProfiles["macbook-pro-archive"].Action != "rename" { + t.Fatalf("archive action = %q, want rename", gotProfiles["macbook-pro-archive"].Action) + } + if !reflect.DeepEqual(plan.Coverage.SkippedIntentionally, []string{"Downloads (/Users/test/Downloads)"}) { + t.Fatalf("unexpected skipped coverage: %#v", plan.Coverage.SkippedIntentionally) + } +} + +func TestPlanWorkstationSetup_StoreWarnings(t *testing.T) { + reset := stubWorkstationSetupEnv(t) + defer reset() + + workstationHostnameFunc = func() (string, error) { return "host", nil } + workstationUserHomeDirFunc = func() (string, error) { return "/home/test", nil } + workstationPathExistsFunc = func(path string) bool { return path == "/home/test/Documents" } + workstationDiscoverSourcesFunc = func(context.Context) ([]DiscoveredSource, error) { return nil, nil } + + cfg := &ProfilesConfig{ + Stores: map[string]ProfileStore{ + "a": {URI: "local:/a"}, + "b": {URI: "local:/b"}, + }, + } + plan, err := PlanWorkstationSetup(context.Background(), WithWorkstationProfiles(cfg)) + if err != nil { + t.Fatalf("PlanWorkstationSetup: %v", err) + } + if plan.StoreRef != "" || plan.StoreAction != "choose-existing" { + t.Fatalf("unexpected store selection: %#v", plan) + } + if len(plan.Coverage.Warnings) == 0 { + t.Fatal("expected warning for multiple stores") + } +} + +func TestPlanWorkstationSetup_ErrorPaths(t *testing.T) { + reset := stubWorkstationSetupEnv(t) + defer reset() + + workstationHostnameFunc = func() (string, error) { return "", errors.New("boom") } + if _, err := PlanWorkstationSetup(context.Background()); err == nil { + t.Fatal("expected hostname error") + } + + workstationHostnameFunc = func() (string, error) { return "host", nil } + workstationUserHomeDirFunc = func() (string, error) { return "", errors.New("no home") } + if _, err := PlanWorkstationSetup(context.Background()); err == nil { + t.Fatal("expected home dir error") + } + + workstationUserHomeDirFunc = func() (string, error) { return "/home/test", nil } + workstationPathExistsFunc = func(string) bool { return false } + workstationDiscoverSourcesFunc = func(context.Context) ([]DiscoveredSource, error) { + return nil, errors.New("discover failed") + } + if _, err := PlanWorkstationSetup(context.Background()); err == nil { + t.Fatal("expected discover error") + } +} + +func stubWorkstationSetupEnv(t *testing.T) func() { + t.Helper() + oldDiscover := workstationDiscoverSourcesFunc + oldHome := workstationUserHomeDirFunc + oldHost := workstationHostnameFunc + oldExists := workstationPathExistsFunc + oldGOOS := workstationGOOS + return func() { + workstationDiscoverSourcesFunc = oldDiscover + workstationUserHomeDirFunc = oldHome + workstationHostnameFunc = oldHost + workstationPathExistsFunc = oldExists + workstationGOOS = oldGOOS + } +}