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
235 changes: 230 additions & 5 deletions cmd/cloudstic/cmd_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,25 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int {
return r.writeJSON(plan)
}

if !args.dryRun && !args.yes {
if !r.canPrompt() {
return r.fail("setup workstation requires an interactive terminal or -yes")
}
if err := reviewWorkstationPlan(ctx, cfg, (*engine.WorkstationSetupPlan)(plan), workstationReviewPrompts{
confirm: func(ctx context.Context, label string, defaultYes bool) (bool, error) {
return r.promptConfirm(ctx, label, defaultYes)
},
selectOne: func(ctx context.Context, label string, options []string) (string, error) {
return r.promptSelect(ctx, label, options)
},
input: func(ctx context.Context, label, defaultValue string, validate func(string) error) (string, error) {
return r.promptValidatedLine(ctx, label, defaultValue, validate)
},
}); err != nil {
return r.fail("Failed to review workstation setup: %v", err)
}
}

printWorkstationSetupPlan(r.out, plan, args.dryRun)
if args.dryRun {
return 0
Expand All @@ -135,14 +154,11 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int {
if plan.StoreRef == "" {
return r.fail("Store selection is still unresolved; pass -store-ref or rerun interactively")
}
if len(plan.Profiles) == 0 {
if countSelectedWorkstationProfiles(plan) == 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)
Expand Down Expand Up @@ -192,7 +208,7 @@ func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPl
profile.SourceURI,
firstNonEmptyCLI(profile.StoreRef, "(none)"),
strings.Join(profile.Tags, ","),
profile.Action,
workstationDraftDecisionLabel(profile),
})
}
t.Render()
Expand Down Expand Up @@ -228,3 +244,212 @@ func firstNonEmptyCLI(values ...string) string {
}
return ""
}

type workstationReviewPrompts struct {
confirm func(context.Context, string, bool) (bool, error)
selectOne func(context.Context, string, []string) (string, error)
input func(context.Context, string, string, func(string) error) (string, error)
}

func reviewWorkstationPlan(ctx context.Context, cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, prompts workstationReviewPrompts) error {
if plan == nil {
return nil
}
for i := range plan.Profiles {
draft := &plan.Profiles[i]
switch draft.Action {
case "create":
ok, err := prompts.confirm(ctx, fmt.Sprintf("Create profile %q for %s?", draft.Name, draft.DisplayLabel), true)
if err != nil {
return err
}
draft.Selected = ok
case "update":
choice, err := prompts.selectOne(ctx,
fmt.Sprintf("Profile %q already exists for %s", draft.Name, draft.DisplayLabel),
[]string{
fmt.Sprintf("Update existing profile %q", draft.Name),
"Create renamed profile",
"Skip this source",
},
)
if err != nil {
return err
}
switch choice {
case fmt.Sprintf("Update existing profile %q", draft.Name):
draft.Selected = true
case "Create renamed profile":
name, err := promptWorkstationProfileName(ctx, prompts, cfg, plan, i, nextAvailableWorkstationProfileName(cfg, plan, draft.Name))
if err != nil {
return err
}
draft.Name = name
draft.Action = "rename"
draft.Selected = true
default:
draft.Selected = false
draft.Action = "skip"
}
case "rename":
choice, err := prompts.selectOne(ctx,
fmt.Sprintf("Profile name collision for %s", draft.DisplayLabel),
[]string{
fmt.Sprintf("Create renamed profile %q", draft.Name),
"Use a different name",
"Skip this source",
},
)
if err != nil {
return err
}
switch choice {
case fmt.Sprintf("Create renamed profile %q", draft.Name):
draft.Selected = true
case "Use a different name":
name, err := promptWorkstationProfileName(ctx, prompts, cfg, plan, i, draft.Name)
if err != nil {
return err
}
draft.Name = name
draft.Selected = true
default:
draft.Selected = false
draft.Action = "skip"
}
default:
draft.Selected = true
}
}
refreshWorkstationCoverage(plan)
return nil
}

func promptWorkstationProfileName(ctx context.Context, prompts workstationReviewPrompts, cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, index int, defaultName string) (string, error) {
return prompts.input(ctx, "Profile name", defaultName, func(v string) error {
if v == "" {
return fmt.Errorf("profile name is required")
}
if err := validateRefName("profile", v); err != nil {
return err
}
if nameTakenInWorkstationPlan(cfg, plan, index, v) {
return fmt.Errorf("profile %q already exists", v)
}
return nil
})
}

func nameTakenInWorkstationPlan(cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, index int, name string) bool {
if existing, ok := cfg.Profiles[name]; ok {
if index >= 0 {
current := plan.Profiles[index]
if current.Action == "update" && current.Name == name && existing.Source == current.SourceURI {
return false
}
}
return true
}
for i, draft := range plan.Profiles {
if i == index || !draft.Selected {
continue
}
if draft.Name == name {
return true
}
}
return false
}

func nextAvailableWorkstationProfileName(cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, base string) string {
base = sanitizeWorkstationProfileName(base)
if base == "" {
base = "workstation"
}
candidate := base
for i := 2; ; i++ {
if !nameTakenInWorkstationPlan(cfg, plan, -1, candidate) {
return candidate
}
candidate = fmt.Sprintf("%s-%d", base, i)
}
}

func refreshWorkstationCoverage(plan *engine.WorkstationSetupPlan) {
if plan == nil {
return
}
profileLabels := map[string]struct{}{}
for _, draft := range plan.Profiles {
if draft.DisplayLabel != "" {
profileLabels[draft.DisplayLabel] = struct{}{}
}
}

preservedSkipped := make([]string, 0, len(plan.Coverage.SkippedIntentionally))
for _, item := range plan.Coverage.SkippedIntentionally {
if _, ok := profileLabels[item]; !ok {
preservedSkipped = append(preservedSkipped, item)
}
}

plan.Coverage.ProtectedNow = nil
plan.Coverage.SkippedIntentionally = preservedSkipped
for _, draft := range plan.Profiles {
label := firstNonEmptyCLI(draft.DisplayLabel, draft.SourceURI)
if draft.Selected {
plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, label)
} else {
plan.Coverage.SkippedIntentionally = append(plan.Coverage.SkippedIntentionally, label)
}
}
}

func workstationDraftDecisionLabel(draft cloudstic.WorkstationProfileDraft) string {
if !draft.Selected {
return "skip"
}
return draft.Action
}

func countSelectedWorkstationProfiles(plan *cloudstic.WorkstationSetupPlan) int {
if plan == nil {
return 0
}
count := 0
for _, draft := range plan.Profiles {
if draft.Selected {
count++
}
}
return count
}

func sanitizeWorkstationProfileName(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(), "-._")
}
73 changes: 71 additions & 2 deletions cmd/cloudstic/cmd_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"

cloudstic "github.com/cloudstic/cli"
"github.com/cloudstic/cli/internal/engine"
)

func TestRunSetupWorkstation_DryRun(t *testing.T) {
Expand All @@ -17,7 +18,7 @@ func TestRunSetupWorkstation_DryRun(t *testing.T) {
StoreRef: "primary",
StoreAction: "use-existing",
Profiles: []cloudstic.WorkstationProfileDraft{
{Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"},
{Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create", Selected: true},
},
Coverage: cloudstic.WorkstationCoverageSummary{
ProtectedNow: []string{"Documents (/Users/test/Documents)"},
Expand Down Expand Up @@ -71,7 +72,7 @@ func TestRunSetupWorkstation_ApplyYes(t *testing.T) {
StoreRef: "primary",
StoreAction: "use-existing",
Profiles: []cloudstic.WorkstationProfileDraft{
{Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"},
{Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create", Selected: true},
},
},
}
Expand Down Expand Up @@ -115,6 +116,74 @@ func TestRunSetupWorkstation_RequiresStoreResolutionWithoutPrompt(t *testing.T)
}
}

func TestReviewWorkstationPlan_CanSkipSources(t *testing.T) {
cfg := &cloudstic.ProfilesConfig{}
plan := &engine.WorkstationSetupPlan{
Profiles: []engine.WorkstationProfileDraft{
{Name: "documents", SourceURI: "local:/Users/test/Documents", Action: "create", DisplayLabel: "Documents (/Users/test/Documents)", Selected: true},
},
Coverage: engine.WorkstationCoverageSummary{
ProtectedNow: []string{"Documents (/Users/test/Documents)"},
},
}
err := reviewWorkstationPlan(context.Background(), cfg, plan, workstationReviewPrompts{
confirm: func(context.Context, string, bool) (bool, error) { return false, nil },
selectOne: func(context.Context, string, []string) (string, error) {
t.Fatal("selectOne should not be called")
return "", nil
},
input: func(context.Context, string, string, func(string) error) (string, error) {
t.Fatal("input should not be called")
return "", nil
},
})
if err != nil {
t.Fatalf("reviewWorkstationPlan: %v", err)
}
if plan.Profiles[0].Selected {
t.Fatal("expected draft to be deselected")
}
if !strings.Contains(strings.Join(plan.Coverage.SkippedIntentionally, ","), "Documents (/Users/test/Documents)") {
t.Fatalf("expected skipped coverage to include source: %#v", plan.Coverage)
}
}

func TestReviewWorkstationPlan_RenameUpdate(t *testing.T) {
cfg := &cloudstic.ProfilesConfig{
Profiles: map[string]cloudstic.BackupProfile{
"documents": {Source: "local:/Users/test/Documents"},
},
}
plan := &engine.WorkstationSetupPlan{
Profiles: []engine.WorkstationProfileDraft{
{Name: "documents", SourceURI: "local:/Users/test/Documents", Action: "update", DisplayLabel: "Documents (/Users/test/Documents)", Selected: true},
},
Coverage: engine.WorkstationCoverageSummary{
ProtectedNow: []string{"Documents (/Users/test/Documents)"},
},
}
var asked bool
err := reviewWorkstationPlan(context.Background(), cfg, plan, workstationReviewPrompts{
confirm: func(context.Context, string, bool) (bool, error) { return true, nil },
selectOne: func(context.Context, string, []string) (string, error) {
return "Create renamed profile", nil
},
input: func(_ context.Context, label, defaultValue string, validate func(string) error) (string, error) {
asked = true
if err := validate("documents-2"); err != nil {
t.Fatalf("validate: %v", err)
}
return "documents-2", nil
},
})
if err != nil {
t.Fatalf("reviewWorkstationPlan: %v", err)
}
if !asked || plan.Profiles[0].Name != "documents-2" || plan.Profiles[0].Action != "rename" {
t.Fatalf("unexpected draft after rename: %#v", plan.Profiles[0])
}
}

func TestDefaultProfilesPathNoCreate(t *testing.T) {
configRoot := filepath.Join(t.TempDir(), "config")
t.Setenv("CLOUDSTIC_CONFIG_DIR", configRoot)
Expand Down
Loading
Loading