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
88 changes: 81 additions & 7 deletions cmd/cloudstic/cmd_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

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

Expand Down Expand Up @@ -69,18 +70,50 @@ func parseSetupWorkstationArgs() *setupWorkstationArgs {

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)
}
ensureProfilesMaps(cfg)
if r.client == nil {
r.client = &cloudstic.Client{}
}

if !args.dryRun && args.storeRef == "" {
if len(cfg.Stores) == 0 {
if !r.canPrompt() || args.yes {
return r.fail("No store is configured; create one first with 'cloudstic store new' or rerun interactively")
}
ref, created, code := r.promptStoreSelection(ctx, cfg)
if code != 0 {
return code
}
args.storeRef = ref
if created {
s := cfg.Stores[args.storeRef]
if !storeHasExplicitEncryption(s) {
r.promptEncryptionConfig(ctx, cfg, args.storeRef, args.profilesFile)
}
if err := r.checkOrInitStoreWithRecovery(ctx, cfg, args.storeRef, args.profilesFile, checkOrInitOptions{
allowMissingSecrets: true,
warnOnMissingSecrets: true,
offerInit: true,
}, true); err != nil {
_, _ = fmt.Fprintf(r.errOut, "%v\n", err)
}
}
} else if len(cfg.Stores) > 1 {
if !r.canPrompt() || args.yes {
return r.fail("Multiple stores are configured; pass -store-ref or rerun interactively")
}
ref, _, code := r.promptStoreSelection(ctx, cfg)
if code != 0 {
return code
}
args.storeRef = ref
}
}

opts := []cloudstic.WorkstationSetupOption{cloudstic.WithWorkstationProfiles(cfg)}
if args.storeRef != "" {
opts = append(opts, cloudstic.WithWorkstationStoreRef(args.storeRef))
Expand All @@ -94,12 +127,53 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int {
return r.writeJSON(plan)
}

printWorkstationSetupPlan(r.out, plan)
printWorkstationSetupPlan(r.out, plan, args.dryRun)
if args.dryRun {
return 0
}

if plan.StoreRef == "" {
return r.fail("Store selection is still unresolved; pass -store-ref or rerun interactively")
}
if len(plan.Profiles) == 0 {
_, _ = fmt.Fprintln(r.out, "\nNothing to save.")
return 0
}
if !args.yes {
if !r.canPrompt() {
return r.fail("setup workstation requires an interactive terminal or -yes")
}
ok, err := r.promptConfirm(ctx, "Save workstation setup?", true)
if err != nil {
return r.fail("Failed to confirm workstation setup: %v", err)
}
if !ok {
_, _ = fmt.Fprintln(r.out, "Workstation setup cancelled.")
return 0
}
}

result, err := engine.ApplyWorkstationSetupPlan(cfg, (*engine.WorkstationSetupPlan)(plan))
if err != nil {
return r.fail("Failed to apply workstation setup plan: %v", err)
}
if err := cloudstic.SaveProfilesFile(args.profilesFile, cfg); err != nil {
return r.fail("Failed to save profiles: %v", err)
}
_, _ = fmt.Fprintf(r.out, "\nSaved %d profile(s) in %s", len(result.ProfileNames), args.profilesFile)
if result.ProfilesCreated > 0 || result.ProfilesUpdated > 0 {
_, _ = fmt.Fprintf(r.out, " (%d created, %d updated)", result.ProfilesCreated, result.ProfilesUpdated)
}
_, _ = fmt.Fprintln(r.out)
return 0
}

func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPlan) {
_, _ = fmt.Fprintln(out, "Workstation setup plan (dry-run)")
func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPlan, dryRun bool) {
if dryRun {
_, _ = fmt.Fprintln(out, "Workstation setup plan (dry-run)")
} else {
_, _ = fmt.Fprintln(out, "Workstation setup plan")
}
_, _ = fmt.Fprintf(out, "Host: %s\n", plan.Hostname)
if plan.StoreRef != "" {
_, _ = fmt.Fprintf(out, "Store: %s (%s)\n", plan.StoreRef, plan.StoreAction)
Expand Down
46 changes: 42 additions & 4 deletions cmd/cloudstic/cmd_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,54 @@ func TestRunSetupWorkstation_JSON(t *testing.T) {
}
}

func TestRunSetupWorkstation_RequiresDryRun(t *testing.T) {
func TestRunSetupWorkstation_ApplyYes(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"},
},
},
}
profilesPath := filepath.Join(t.TempDir(), "profiles.yaml")
if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{
Version: 1,
Stores: map[string]cloudstic.ProfileStore{"primary": {URI: "local:/repo"}},
}); err != nil {
t.Fatalf("SaveProfilesFile: %v", err)
}

osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "setup", "workstation", "-yes", "-profiles-file", profilesPath}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut, client: client, noPrompt: true}
if code := r.runSetup(context.Background()); code != 0 {
t.Fatalf("code=%d err=%s", code, errOut.String())
}
cfg, err := cloudstic.LoadProfilesFile(profilesPath)
if err != nil {
t.Fatalf("LoadProfilesFile: %v", err)
}
if got := cfg.Profiles["documents"].Store; got != "primary" {
t.Fatalf("documents store = %q, want primary", got)
}
}

func TestRunSetupWorkstation_RequiresStoreResolutionWithoutPrompt(t *testing.T) {
osArgs := os.Args
t.Cleanup(func() { os.Args = osArgs })
os.Args = []string{"cloudstic", "setup", "workstation"}
os.Args = []string{"cloudstic", "setup", "workstation", "-yes"}

var out strings.Builder
var errOut strings.Builder
r := &runner{out: &out, errOut: &errOut, client: &stubClient{}}
r := &runner{out: &out, errOut: &errOut, client: &stubClient{}, noPrompt: true}
if code := r.runSetup(context.Background()); code == 0 {
t.Fatal("expected failure without -dry-run")
t.Fatal("expected failure without store resolution")
}
}

Expand Down
8 changes: 4 additions & 4 deletions cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +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"},
{"setup workstation", "Guide workstation onboarding and profile scaffolding"},
{"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"},
Expand Down Expand Up @@ -233,14 +233,14 @@ func printUsage() {
t.Command("setup workstation", "")
t.Flags([][2]string{
{"-dry-run", "Preview generated profiles without writing configuration"},
{"-yes", "Accept default selections without prompting"},
{"-yes", "Accept the proposed plan without confirmation"},
{"-profiles-file <path>", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")},
{"-store-ref <name>", "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.",
" Build a workstation onboarding flow using OS-aware folder suggestions and portable-drive discovery.",
" Interactive by default. Use -dry-run to preview without writing, or -yes to save without confirmation.",
)
t.Blank()

Expand Down
21 changes: 18 additions & 3 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,10 +543,24 @@ considered portable.

### setup

Preview a workstation onboarding plan without writing configuration.
Guide workstation onboarding and profile scaffolding.

#### setup workstation

Interactive onboarding flow:

```bash
cloudstic setup workstation
```

Accept the proposed plan without confirmation:

```bash
cloudstic setup workstation -yes -store-ref my-s3
```

Preview only:

```bash
cloudstic setup workstation -dry-run
```
Expand All @@ -564,8 +578,9 @@ 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`.
to generate a review-first profile plan. The command is interactive by default.
Use `-dry-run` for a side-effect-free preview or `-yes` to accept the proposed
plan without a confirmation prompt.

---

Expand Down
42 changes: 42 additions & 0 deletions internal/engine/workstation_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ type WorkstationCoverageSummary struct {
Warnings []string `json:"warnings,omitempty"`
}

type WorkstationApplyResult struct {
ProfilesCreated int `json:"profiles_created"`
ProfilesUpdated int `json:"profiles_updated"`
ProfileNames []string `json:"profile_names,omitempty"`
}

type WorkstationSetupPlan struct {
Hostname string `json:"hostname"`
StoreRef string `json:"store_ref,omitempty"`
Expand Down Expand Up @@ -157,6 +163,42 @@ func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) (
return plan, nil
}

func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) {
if plan == nil {
return nil, fmt.Errorf("workstation setup plan is required")
}
cfg = normalizeProfilesConfig(cfg)

result := &WorkstationApplyResult{
ProfileNames: make([]string, 0, len(plan.Profiles)),
}

for _, draft := range plan.Profiles {
if strings.TrimSpace(draft.Name) == "" {
return nil, fmt.Errorf("workstation setup plan contains a draft with no profile name")
}
if strings.TrimSpace(draft.SourceURI) == "" {
return nil, fmt.Errorf("workstation setup plan contains an empty source URI for profile %q", draft.Name)
}

if _, ok := cfg.Profiles[draft.Name]; ok {
result.ProfilesUpdated++
} else {
result.ProfilesCreated++
}

cfg.Profiles[draft.Name] = BackupProfile{
Source: draft.SourceURI,
Store: draft.StoreRef,
Tags: slices.Clone(draft.Tags),
}
result.ProfileNames = append(result.ProfileNames, draft.Name)
}

slices.Sort(result.ProfileNames)
return result, nil
}

func discoverWorkstationFolders() ([]WorkstationFolderCandidate, []string, error) {
home, err := workstationUserHomeDirFunc()
if err != nil {
Expand Down
42 changes: 42 additions & 0 deletions internal/engine/workstation_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,48 @@ func TestPlanWorkstationSetup_ErrorPaths(t *testing.T) {
}
}

func TestApplyWorkstationSetupPlan(t *testing.T) {
cfg := &ProfilesConfig{
Profiles: map[string]BackupProfile{
"documents": {Source: "local:/old"},
},
}
result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{
Profiles: []WorkstationProfileDraft{
{Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}},
{Name: "archive", SourceURI: "local:/Volumes/Archive", StoreRef: "primary", Tags: []string{"portable", "workstation"}},
},
})
if err != nil {
t.Fatalf("ApplyWorkstationSetupPlan: %v", err)
}
if result.ProfilesCreated != 1 || result.ProfilesUpdated != 1 {
t.Fatalf("unexpected counts: %#v", result)
}
if got := cfg.Profiles["archive"].Store; got != "primary" {
t.Fatalf("archive store = %q, want primary", got)
}
if got := cfg.Profiles["documents"].Source; got != "local:/Users/test/Documents" {
t.Fatalf("documents source = %q", got)
}
}

func TestApplyWorkstationSetupPlan_Errors(t *testing.T) {
if _, err := ApplyWorkstationSetupPlan(nil, nil); err == nil {
t.Fatal("expected nil plan error")
}
if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{
Profiles: []WorkstationProfileDraft{{Name: "", SourceURI: "local:/tmp"}},
}); err == nil {
t.Fatal("expected missing name error")
}
if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{
Profiles: []WorkstationProfileDraft{{Name: "docs", SourceURI: ""}},
}); err == nil {
t.Fatal("expected missing source error")
}
}

func stubWorkstationSetupEnv(t *testing.T) func() {
t.Helper()
oldDiscover := workstationDiscoverSourcesFunc
Expand Down
Loading