From af1831c5a01a6dd92244b4ec74a3717fd9651fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 21:22:17 +0200 Subject: [PATCH] feat: rfc 15 api surface --- client.go | 7 ++- client_test.go | 18 ++++++- cmd/cloudstic/client_iface.go | 1 - cmd/cloudstic/cmd_setup.go | 9 ++-- cmd/cloudstic/cmd_setup_test.go | 65 ++++++++++++----------- internal/engine/workstation_setup.go | 34 ++++++++++++ internal/engine/workstation_setup_test.go | 48 +++++++++++++++++ pkg/source/gdrive_test.go | 2 +- 8 files changed, 144 insertions(+), 40 deletions(-) diff --git a/client.go b/client.go index 50d909f..d966d83 100644 --- a/client.go +++ b/client.go @@ -314,6 +314,7 @@ type BackupProfile = engine.BackupProfile type DiscoveredSource = engine.DiscoveredSource type WorkstationSetupPlan = engine.WorkstationSetupPlan type WorkstationSetupOption = engine.WorkstationSetupOption +type WorkstationApplyResult = engine.WorkstationApplyResult type WorkstationProfileDraft = engine.WorkstationProfileDraft type WorkstationFolderCandidate = engine.WorkstationFolderCandidate type WorkstationCoverageSummary = engine.WorkstationCoverageSummary @@ -349,10 +350,14 @@ func (c *Client) DiscoverSources(ctx context.Context) ([]DiscoveredSource, error return engine.DiscoverSources(ctx) } -func (c *Client) PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) (*WorkstationSetupPlan, error) { +func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) (*WorkstationSetupPlan, error) { return engine.PlanWorkstationSetup(ctx, opts...) } +func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) { + return engine.ApplyWorkstationSetupPlan(cfg, plan) +} + // 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 dd45ca2..7af8f40 100644 --- a/client_test.go +++ b/client_test.go @@ -224,12 +224,26 @@ func TestClientDiscoverSources(t *testing.T) { } func TestClientPlanWorkstationSetup(t *testing.T) { - c := &Client{} - if _, err := c.PlanWorkstationSetup(context.Background()); err != nil { + if _, err := PlanWorkstationSetup(context.Background()); err != nil { t.Fatalf("PlanWorkstationSetup: %v", err) } } +func TestApplyWorkstationSetupPlan(t *testing.T) { + cfg := &ProfilesConfig{} + result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Selected: true}, + }, + }) + if err != nil { + t.Fatalf("ApplyWorkstationSetupPlan: %v", err) + } + if result.ProfilesCreated != 1 || cfg.Profiles["documents"].Store != "primary" { + t.Fatalf("unexpected apply result: %#v %#v", result, cfg) + } +} + // --------------------------------------------------------------------------- // Cat // --------------------------------------------------------------------------- diff --git a/cmd/cloudstic/client_iface.go b/cmd/cloudstic/client_iface.go index b9afa7c..65cb504 100644 --- a/cmd/cloudstic/client_iface.go +++ b/cmd/cloudstic/client_iface.go @@ -12,7 +12,6 @@ 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 index 50e9b56..c65b5a5 100644 --- a/cmd/cloudstic/cmd_setup.go +++ b/cmd/cloudstic/cmd_setup.go @@ -14,6 +14,8 @@ import ( "github.com/jedib0t/go-pretty/v6/table" ) +var planWorkstationSetup = cloudstic.PlanWorkstationSetup + func defaultProfilesPathNoCreate() string { if path := os.Getenv("CLOUDSTIC_PROFILES_FILE"); path != "" { return path @@ -75,9 +77,6 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { 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 { @@ -118,7 +117,7 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { if args.storeRef != "" { opts = append(opts, cloudstic.WithWorkstationStoreRef(args.storeRef)) } - plan, err := r.client.PlanWorkstationSetup(ctx, opts...) + plan, err := planWorkstationSetup(ctx, opts...) if err != nil { return r.fail("Failed to plan workstation setup: %v", err) } @@ -169,7 +168,7 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { } } - result, err := engine.ApplyWorkstationSetupPlan(cfg, (*engine.WorkstationSetupPlan)(plan)) + result, err := cloudstic.ApplyWorkstationSetupPlan(cfg, plan) if err != nil { return r.fail("Failed to apply workstation setup plan: %v", err) } diff --git a/cmd/cloudstic/cmd_setup_test.go b/cmd/cloudstic/cmd_setup_test.go index e727913..91b1dc1 100644 --- a/cmd/cloudstic/cmd_setup_test.go +++ b/cmd/cloudstic/cmd_setup_test.go @@ -11,21 +11,29 @@ import ( "github.com/cloudstic/cli/internal/engine" ) +func stubSetupWorkstationPlan(t *testing.T, plan *cloudstic.WorkstationSetupPlan, err error) { + t.Helper() + old := planWorkstationSetup + planWorkstationSetup = func(context.Context, ...cloudstic.WorkstationSetupOption) (*cloudstic.WorkstationSetupPlan, error) { + return plan, err + } + t.Cleanup(func() { planWorkstationSetup = old }) +} + 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", Selected: true}, - }, - Coverage: cloudstic.WorkstationCoverageSummary{ - ProtectedNow: []string{"Documents (/Users/test/Documents)"}, - SkippedIntentionally: []string{"Downloads (/Users/test/Downloads)"}, - }, + t.Setenv("CLOUDSTIC_CONFIG_DIR", t.TempDir()) + stubSetupWorkstationPlan(t, &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", Selected: true}, }, - } + Coverage: cloudstic.WorkstationCoverageSummary{ + ProtectedNow: []string{"Documents (/Users/test/Documents)"}, + SkippedIntentionally: []string{"Downloads (/Users/test/Downloads)"}, + }, + }, nil) osArgs := os.Args t.Cleanup(func() { os.Args = osArgs }) @@ -33,7 +41,7 @@ func TestRunSetupWorkstation_DryRun(t *testing.T) { var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut, client: client} + r := &runner{out: &out, errOut: &errOut, client: &stubClient{}} if code := r.runSetup(context.Background()); code != 0 { t.Fatalf("code=%d err=%s", code, errOut.String()) } @@ -44,11 +52,10 @@ func TestRunSetupWorkstation_DryRun(t *testing.T) { } func TestRunSetupWorkstation_JSON(t *testing.T) { - client := &stubClient{ - setupPlan: &cloudstic.WorkstationSetupPlan{ - Hostname: "testbox", - }, - } + t.Setenv("CLOUDSTIC_CONFIG_DIR", t.TempDir()) + stubSetupWorkstationPlan(t, &cloudstic.WorkstationSetupPlan{ + Hostname: "testbox", + }, nil) osArgs := os.Args t.Cleanup(func() { os.Args = osArgs }) @@ -56,7 +63,7 @@ func TestRunSetupWorkstation_JSON(t *testing.T) { var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut, client: client} + r := &runner{out: &out, errOut: &errOut, client: &stubClient{}} if code := r.runSetup(context.Background()); code != 0 { t.Fatalf("code=%d err=%s", code, errOut.String()) } @@ -66,16 +73,14 @@ func TestRunSetupWorkstation_JSON(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", Selected: true}, - }, + stubSetupWorkstationPlan(t, &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", Selected: true}, }, - } + }, nil) profilesPath := filepath.Join(t.TempDir(), "profiles.yaml") if err := cloudstic.SaveProfilesFile(profilesPath, &cloudstic.ProfilesConfig{ Version: 1, @@ -90,7 +95,7 @@ func TestRunSetupWorkstation_ApplyYes(t *testing.T) { var out strings.Builder var errOut strings.Builder - r := &runner{out: &out, errOut: &errOut, client: client, noPrompt: true} + r := &runner{out: &out, errOut: &errOut, client: &stubClient{}, noPrompt: true} if code := r.runSetup(context.Background()); code != 0 { t.Fatalf("code=%d err=%s", code, errOut.String()) } diff --git a/internal/engine/workstation_setup.go b/internal/engine/workstation_setup.go index 686ae8e..7ecd6b8 100644 --- a/internal/engine/workstation_setup.go +++ b/internal/engine/workstation_setup.go @@ -27,6 +27,7 @@ type WorkstationSetupOption func(*workstationSetupOptions) type workstationSetupOptions struct { profiles *ProfilesConfig storeRef string + dryRun bool } func WithWorkstationProfiles(cfg *ProfilesConfig) WorkstationSetupOption { @@ -37,6 +38,10 @@ func WithWorkstationStoreRef(name string) WorkstationSetupOption { return func(o *workstationSetupOptions) { o.storeRef = strings.TrimSpace(name) } } +func WithWorkstationDryRun() WorkstationSetupOption { + return func(o *workstationSetupOptions) { o.dryRun = true } +} + type WorkstationFolderCandidate struct { Key string `json:"key"` Label string `json:"label"` @@ -68,6 +73,11 @@ type WorkstationApplyResult struct { ProfileNames []string `json:"profile_names,omitempty"` } +type WorkstationSetupResult struct { + Plan *WorkstationSetupPlan `json:"plan,omitempty"` + Applied *WorkstationApplyResult `json:"applied,omitempty"` +} + type WorkstationSetupPlan struct { Hostname string `json:"hostname"` StoreRef string `json:"store_ref,omitempty"` @@ -169,6 +179,30 @@ func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) ( return plan, nil } +func SetupWorkstation(ctx context.Context, opts ...WorkstationSetupOption) (*WorkstationSetupResult, error) { + options := workstationSetupOptions{} + for _, opt := range opts { + opt(&options) + } + + plan, err := PlanWorkstationSetup(ctx, opts...) + if err != nil { + return nil, err + } + + result := &WorkstationSetupResult{Plan: plan} + if options.dryRun { + return result, nil + } + + applied, err := ApplyWorkstationSetupPlan(options.profiles, plan) + if err != nil { + return nil, err + } + result.Applied = applied + return result, nil +} + func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) (*WorkstationApplyResult, error) { if plan == nil { return nil, fmt.Errorf("workstation setup plan is required") diff --git a/internal/engine/workstation_setup_test.go b/internal/engine/workstation_setup_test.go index 20904e0..409827e 100644 --- a/internal/engine/workstation_setup_test.go +++ b/internal/engine/workstation_setup_test.go @@ -182,6 +182,54 @@ func TestApplyWorkstationSetupPlan_Errors(t *testing.T) { } } +func TestSetupWorkstation_DryRun(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{} + result, err := SetupWorkstation(context.Background(), WithWorkstationProfiles(cfg), WithWorkstationDryRun()) + if err != nil { + t.Fatalf("SetupWorkstation: %v", err) + } + if result.Plan == nil || result.Applied != nil { + t.Fatalf("unexpected setup result: %#v", result) + } + if len(cfg.Profiles) != 0 { + t.Fatalf("dry-run should not mutate profiles: %#v", cfg.Profiles) + } +} + +func TestSetupWorkstation_Apply(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{ + "primary": {URI: "local:/repo"}, + }, + } + result, err := SetupWorkstation(context.Background(), WithWorkstationProfiles(cfg), WithWorkstationStoreRef("primary")) + if err != nil { + t.Fatalf("SetupWorkstation: %v", err) + } + if result.Applied == nil || result.Applied.ProfilesCreated != 1 { + t.Fatalf("unexpected apply result: %#v", result) + } + if got := cfg.Profiles["documents"].Store; got != "primary" { + t.Fatalf("documents store = %q, want primary", got) + } +} + func stubWorkstationSetupEnv(t *testing.T) func() { t.Helper() oldDiscover := workstationDiscoverSourcesFunc diff --git a/pkg/source/gdrive_test.go b/pkg/source/gdrive_test.go index 02ddee2..b4c6da9 100644 --- a/pkg/source/gdrive_test.go +++ b/pkg/source/gdrive_test.go @@ -568,7 +568,7 @@ func TestBuildDriveService_PrebuiltServiceTakesPriority(t *testing.T) { injected := &drive.Service{} cfg := gDriveOptions{ service: injected, - httpClient: &http.Client{}, // should be ignored + httpClient: &http.Client{}, // should be ignored credsJSON: []byte(`{"type":"service_account"}`), // should be ignored } srv, err := buildDriveService(context.Background(), cfg)