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 @@ -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
Expand Down
11 changes: 11 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
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 @@ -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)
Expand Down
156 changes: 156 additions & 0 deletions cmd/cloudstic/cmd_setup.go
Original file line number Diff line number Diff line change
@@ -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 <subcommand> [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 ""
}
93 changes: 93 additions & 0 deletions cmd/cloudstic/cmd_setup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading