diff --git a/.mockery.yml b/.mockery.yml index fba11d2..77eb5b0 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -42,3 +42,7 @@ packages: config: all: true interfaces: + github.com/codesphere-cloud/oms/pkg/codesphere: + config: + all: true + interfaces: diff --git a/NOTICE b/NOTICE index d7e513d..3bef1f0 100644 --- a/NOTICE +++ b/NOTICE @@ -22,10 +22,10 @@ License: MIT License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE ---------- -Module: github.com/codesphere-cloud/cs-go/pkg/io -Version: v0.14.1 +Module: github.com/codesphere-cloud/cs-go +Version: v0.15.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.14.1/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.15.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -77,9 +77,9 @@ License URL: https://github.com/inconshreveable/go-update/blob/8152e7eb6ccf/inte ---------- Module: github.com/jedib0t/go-pretty/v6 -Version: v6.7.5 +Version: v6.7.7 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.5/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.7/LICENSE ---------- Module: github.com/mattn/go-runewidth @@ -155,9 +155,9 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.45.0 +Version: v0.46.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.45.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.46.0:LICENSE ---------- Module: golang.org/x/oauth2 @@ -167,9 +167,15 @@ License URL: https://cs.opensource.google/go/x/oauth2/+/v0.33.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.31.0 +Version: v0.32.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.31.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.32.0:LICENSE + +---------- +Module: gopkg.in/validator.v2 +Version: v2.0.1 +License: Apache-2.0 +License URL: https://github.com/go-validator/validator/blob/v2.0.1/LICENSE ---------- Module: gopkg.in/yaml.v3 diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 43d08c3..10afd54 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -72,6 +72,9 @@ func GetRootCmd() *cobra.Command { AddRegisterCmd(rootCmd, opts) AddRevokeCmd(rootCmd, opts) + // Smoke test commands + AddSmoketestCmd(rootCmd, opts) + return rootCmd } diff --git a/cli/cmd/smoketest.go b/cli/cmd/smoketest.go new file mode 100644 index 0000000..a19ba4a --- /dev/null +++ b/cli/cmd/smoketest.go @@ -0,0 +1,27 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +// SmoketestCmd represents the smoketest command +type SmoketestCmd struct { + cmd *cobra.Command +} + +func AddSmoketestCmd(rootCmd *cobra.Command, opts *GlobalOptions) { + smoketest := SmoketestCmd{ + cmd: &cobra.Command{ + Use: "smoketest", + Short: "Run smoke tests for Codesphere components", + Long: io.Long(`Run automated smoke tests for Codesphere installations to verify functionality.`), + }, + } + rootCmd.AddCommand(smoketest.cmd) + + AddSmoketestCodesphereCmd(smoketest.cmd, opts) +} diff --git a/cli/cmd/smoketest_codesphere.go b/cli/cmd/smoketest_codesphere.go new file mode 100644 index 0000000..caedace --- /dev/null +++ b/cli/cmd/smoketest_codesphere.go @@ -0,0 +1,322 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/codesphere" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +const ( + defaultTimeout = 10 * time.Minute + defaultProfile = "ci.yml" + smoketestEnvVarKey = "TEST_VAR" + smoketestEnvVarValue = "smoketest" + smoketestPipelineStage = "run" + + // Step names + stepCreateWorkspace = "createWorkspace" + stepSetEnvVar = "setEnvVar" + stepCreateFiles = "createFiles" + stepSyncLandscape = "syncLandscape" + stepStartPipeline = "startPipeline" + stepDeleteWorkspace = "deleteWorkspace" + + ciYmlContent = `schemaVersion: v0.2 +prepare: + steps: [] +test: + steps: [] +run: + service: + steps: + - name: Run php server + command: php -S 0.0.0.0:3000 index.html + plan: 20 + replicas: 1 + network: + ports: + - port: 3000 + isPublic: true + paths: + - port: 3000 + path: / + env: {} +` + + indexHtmlContent = ` + + + Smoketest + + +

Smoketest Successful

+ + +` + + // ANSI color codes + colorGreen = "\033[32m" + colorRed = "\033[31m" + colorReset = "\033[0m" +) + +type SmoketestCodesphereCmd struct { + cmd *cobra.Command + Opts *SmoketestCodesphereOpts + Client codesphere.Client +} + +type SmoketestCodesphereOpts struct { + *GlobalOptions + BaseURL string + Token string + TeamID string + PlanID string + Quiet bool + Timeout time.Duration + Profile string + Steps string +} + +func (c *SmoketestCodesphereCmd) RunE(_ *cobra.Command, args []string) error { + // Initialize client if not set (for testing) + if c.Client == nil { + client, err := codesphere.NewClient(c.Opts.BaseURL, c.Opts.Token) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + c.Client = client + } + + return c.RunSmoketest() +} + +func AddSmoketestCodesphereCmd(parent *cobra.Command, opts *GlobalOptions) { + c := SmoketestCodesphereCmd{ + cmd: &cobra.Command{ + Use: "codesphere", + Short: "Run smoke tests for a Codesphere installation", + Long: io.Long(`Run automated smoke tests for a Codesphere installation by creating a workspace, + setting environment variables, executing commands, syncing landscape, and running a pipeline stage. + The workspace is automatically deleted after the test completes.`), + Example: formatExamplesWithBinary("smoketest codesphere", []io.Example{ + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID", + Desc: "Run smoke tests against a Codesphere installation", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --quiet", + Desc: "Run smoke tests in quiet mode (no progress logging)", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --timeout 15m", + Desc: "Run smoke tests with custom timeout", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --steps createWorkspace,syncLandscape", + Desc: "Run only specific steps of the smoke test (workspace won't be deleted)", + }, + { + Cmd: "--baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --steps createWorkspace,syncLandscape,deleteWorkspace", + Desc: "Run specific steps and delete the workspace afterwards", + }, + }, "oms-cli"), + }, + Opts: &SmoketestCodesphereOpts{GlobalOptions: opts}, + } + c.cmd.Flags().StringVar(&c.Opts.BaseURL, "baseurl", "", "Base URL of the Codesphere API") + c.cmd.Flags().StringVar(&c.Opts.Token, "token", "", "API token for authentication") + c.cmd.Flags().StringVar(&c.Opts.TeamID, "team-id", "", "Team ID for workspace creation") + c.cmd.Flags().StringVar(&c.Opts.PlanID, "plan-id", "", "Plan ID for workspace creation") + c.cmd.Flags().BoolVarP(&c.Opts.Quiet, "quiet", "q", false, "Suppress progress logging") + c.cmd.Flags().DurationVar(&c.Opts.Timeout, "timeout", defaultTimeout, "Timeout for the entire smoke test") + c.cmd.Flags().StringVar(&c.Opts.Profile, "profile", defaultProfile, "CI profile to use for landscape and pipeline") + c.cmd.Flags().StringVar(&c.Opts.Steps, "steps", "", "Comma-separated list of steps to run (createWorkspace,setEnvVar,createFiles,syncLandscape,startPipeline,deleteWorkspace). If empty, all steps including deleteWorkspace are run. If specified without deleteWorkspace, the workspace will be kept for manual inspection.") + + util.MarkFlagRequired(c.cmd, "baseurl") + util.MarkFlagRequired(c.cmd, "token") + util.MarkFlagRequired(c.cmd, "team-id") + util.MarkFlagRequired(c.cmd, "plan-id") + + c.cmd.RunE = c.RunE + + parent.AddCommand(c.cmd) +} + +func (c *SmoketestCodesphereCmd) RunSmoketest() (err error) { + ctx, cancel := context.WithTimeout(context.Background(), c.Opts.Timeout) + defer cancel() + + teamID, parseErr := strconv.Atoi(c.Opts.TeamID) + if parseErr != nil { + return fmt.Errorf("invalid team-id: %w", parseErr) + } + planID, parseErr := strconv.Atoi(c.Opts.PlanID) + if parseErr != nil { + return fmt.Errorf("invalid plan-id: %w", parseErr) + } + steps := []string{stepCreateWorkspace, stepSetEnvVar, stepCreateFiles, stepSyncLandscape, stepStartPipeline, stepDeleteWorkspace} + if c.Opts.Steps != "" { + steps = strings.Split(c.Opts.Steps, ",") + for i := range steps { + steps[i] = strings.TrimSpace(steps[i]) + } + } + workspaceName := fmt.Sprintf("smoketest-%s", time.Now().Format("20060102-150405")) + + var workspaceID int + defer func() { + if err != nil { + c.logf("\n%sSmoketest failed: %s%s\n", colorRed, err.Error(), colorReset) + } + + if workspaceID != 0 && slices.Contains(steps, stepDeleteWorkspace) { + c.logStep(fmt.Sprintf("\nDeleting workspace %d", workspaceID)) + deleteErr := c.Client.DeleteWorkspace(context.Background(), workspaceID) + if deleteErr != nil { + c.logFailure() + if err == nil { + err = fmt.Errorf("failed to delete workspace: %w", deleteErr) + } + } + c.logSuccess() + } + + if err == nil { + c.logf("\n%sSmoketest completed successfully!%s\n", colorGreen, colorReset) + } + }() + + // Execute steps + for _, step := range steps { + switch step { + case stepCreateWorkspace: + if err = c.stepCreateWorkspace(ctx, teamID, planID, workspaceName, &workspaceID); err != nil { + return err + } + case stepSetEnvVar: + if err = c.stepSetEnvVar(ctx, workspaceID); err != nil { + return err + } + case stepCreateFiles: + if err = c.stepCreateFiles(ctx, workspaceID); err != nil { + return err + } + case stepSyncLandscape: + if err = c.stepSyncLandscape(ctx, workspaceID); err != nil { + return err + } + case stepStartPipeline: + if err = c.stepStartPipeline(ctx, workspaceID); err != nil { + return err + } + case stepDeleteWorkspace: + // Skip - handled in defer + continue + default: + return fmt.Errorf("unknown step: %s", step) + } + } + + return nil +} + +func (c *SmoketestCodesphereCmd) stepCreateWorkspace(ctx context.Context, teamID, planID int, workspaceName string, workspaceID *int) error { + c.logStep(fmt.Sprintf("Creating empty workspace '%s'", workspaceName)) + id, err := c.Client.CreateWorkspace(ctx, teamID, planID, workspaceName, nil) + if err != nil { + c.logFailure() + return fmt.Errorf("failed to create workspace: %w", err) + } + *workspaceID = id + c.logSuccess() + return nil +} + +func (c *SmoketestCodesphereCmd) stepSetEnvVar(ctx context.Context, workspaceID int) error { + c.logStep(fmt.Sprintf("Setting environment variable %s=%s", smoketestEnvVarKey, smoketestEnvVarValue)) + if err := c.Client.SetEnvVar(ctx, workspaceID, smoketestEnvVarKey, smoketestEnvVarValue); err != nil { + c.logFailure() + return fmt.Errorf("failed to set environment variable: %w", err) + } + c.logSuccess() + return nil +} + +func (c *SmoketestCodesphereCmd) stepCreateFiles(ctx context.Context, workspaceID int) error { + c.logStep("Creating ci.yml file") + ciYmlCmd := fmt.Sprintf(`echo '%s' > ci.yml`, ciYmlContent) + err := c.Client.ExecuteCommand(ctx, workspaceID, ciYmlCmd) + if err != nil { + c.logFailure() + return fmt.Errorf("failed to create ci.yml: %w", err) + } + c.logSuccess() + + c.logStep("Creating index.html file") + indexHtmlCmd := fmt.Sprintf(`echo '%s' > index.html`, indexHtmlContent) + err = c.Client.ExecuteCommand(ctx, workspaceID, indexHtmlCmd) + if err != nil { + c.logFailure() + return fmt.Errorf("failed to create index.html: %w", err) + } + c.logSuccess() + return nil +} + +func (c *SmoketestCodesphereCmd) stepSyncLandscape(ctx context.Context, workspaceID int) error { + c.logStep(fmt.Sprintf("Syncing landscape with profile '%s'", c.Opts.Profile)) + if err := c.Client.SyncLandscape(ctx, workspaceID, c.Opts.Profile); err != nil { + c.logFailure() + return fmt.Errorf("failed to sync landscape: %w", err) + } + c.logSuccess() + return nil +} + +func (c *SmoketestCodesphereCmd) stepStartPipeline(ctx context.Context, workspaceID int) error { + c.logStep(fmt.Sprintf("Starting '%s' pipeline stage", smoketestPipelineStage)) + if err := c.Client.StartPipeline(ctx, workspaceID, c.Opts.Profile, smoketestPipelineStage); err != nil { + c.logFailure() + return fmt.Errorf("failed to start pipeline: %w", err) + } + c.logSuccess() + return nil +} + +// Logging helpers + +func (c *SmoketestCodesphereCmd) logf(format string, args ...interface{}) { + if !c.Opts.Quiet { + fmt.Printf(format, args...) + } +} + +func (c *SmoketestCodesphereCmd) logStep(message string) { + if !c.Opts.Quiet { + fmt.Printf("%s...", message) + } +} + +func (c *SmoketestCodesphereCmd) logSuccess() { + if !c.Opts.Quiet { + fmt.Printf(" %ssucceeded%s\n", colorGreen, colorReset) + } +} + +func (c *SmoketestCodesphereCmd) logFailure() { + if !c.Opts.Quiet { + fmt.Printf(" %sfailed%s\n", colorRed, colorReset) + } +} diff --git a/cli/cmd/smoketest_codesphere_test.go b/cli/cmd/smoketest_codesphere_test.go new file mode 100644 index 0000000..c1d1822 --- /dev/null +++ b/cli/cmd/smoketest_codesphere_test.go @@ -0,0 +1,390 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/codesphere" +) + +var _ = Describe("SmoketestCodesphereCmd", func() { + var ( + mockClient *codesphere.MockClient + c cmd.SmoketestCodesphereCmd + opts *cmd.SmoketestCodesphereOpts + ) + + BeforeEach(func() { + mockClient = codesphere.NewMockClient(GinkgoT()) + opts = &cmd.SmoketestCodesphereOpts{ + BaseURL: "https://test.codesphere.com/api", + Token: "test-token", + TeamID: "123", + PlanID: "456", + Quiet: true, // Suppress log output in tests + Timeout: 10 * time.Minute, + Profile: "ci.yml", + } + c = cmd.SmoketestCodesphereCmd{ + Opts: opts, + Client: mockClient, + } + }) + + AfterEach(func() { + mockClient.AssertExpectations(GinkgoT()) + }) + + Context("RunSmoketest", func() { + It("completes successfully with all steps", func() { + workspaceID := 789 + + // Expect all API calls in order + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), // workspace name is timestamped + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + // Create ci.yml + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> ci.yml") + }), + ).Return(nil).Once() + + // Create index.html + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> index.html") + }), + ).Return(nil).Once() + + mockClient.EXPECT().SyncLandscape( + mock.Anything, + workspaceID, + "ci.yml", + ).Return(nil).Once() + + mockClient.EXPECT().StartPipeline( + mock.Anything, + workspaceID, + "ci.yml", + "run", + ).Return(nil).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(BeNil()) + }) + + It("deletes workspace even on CreateWorkspace failure", func() { + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(0, fmt.Errorf("create failed")).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to create workspace"))) + }) + + It("deletes workspace on SetEnvVar failure", func() { + workspaceID := 789 + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(fmt.Errorf("setenv failed")).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to set environment variable"))) + }) + + It("deletes workspace on ExecuteCommand failure", func() { + workspaceID := 789 + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + // Create ci.yml fails + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> ci.yml") + }), + ).Return(fmt.Errorf("exec failed")).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to create ci.yml"))) + }) + + It("deletes workspace on SyncLandscape failure", func() { + workspaceID := 789 + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + // Create ci.yml + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> ci.yml") + }), + ).Return(nil).Once() + + // Create index.html + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> index.html") + }), + ).Return(nil).Once() + + mockClient.EXPECT().SyncLandscape( + mock.Anything, + workspaceID, + "ci.yml", + ).Return(fmt.Errorf("sync failed")).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to sync landscape"))) + }) + + It("deletes workspace on StartPipeline failure", func() { + workspaceID := 789 + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + // Create ci.yml + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> ci.yml") + }), + ).Return(nil).Once() + + // Create index.html + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> index.html") + }), + ).Return(nil).Once() + + mockClient.EXPECT().SyncLandscape( + mock.Anything, + workspaceID, + "ci.yml", + ).Return(nil).Once() + + mockClient.EXPECT().StartPipeline( + mock.Anything, + workspaceID, + "ci.yml", + "run", + ).Return(fmt.Errorf("pipeline failed")).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to start pipeline"))) + }) + + It("returns cleanup error when DeleteWorkspace fails", func() { + workspaceID := 789 + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), // empty workspace + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + // Create ci.yml + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> ci.yml") + }), + ).Return(nil).Once() + + // Create index.html + mockClient.EXPECT().ExecuteCommand( + mock.Anything, + workspaceID, + mock.MatchedBy(func(cmd string) bool { + return strings.Contains(cmd, "> index.html") + }), + ).Return(nil).Once() + + mockClient.EXPECT().SyncLandscape( + mock.Anything, + workspaceID, + "ci.yml", + ).Return(nil).Once() + + mockClient.EXPECT().StartPipeline( + mock.Anything, + workspaceID, + "ci.yml", + "run", + ).Return(nil).Once() + + mockClient.EXPECT().DeleteWorkspace( + mock.Anything, + workspaceID, + ).Return(fmt.Errorf("delete failed")).Once() + + err := c.RunSmoketest() + Expect(err).To(MatchError(ContainSubstring("failed to delete workspace"))) + }) + + It("runs only specified steps when steps flag is set", func() { + workspaceID := 789 + opts.Steps = "createWorkspace,setEnvVar" + + mockClient.EXPECT().CreateWorkspace( + mock.Anything, + 123, // teamID + 456, // planID + mock.AnythingOfType("string"), + (*string)(nil), + ).Return(workspaceID, nil).Once() + + mockClient.EXPECT().SetEnvVar( + mock.Anything, + workspaceID, + "TEST_VAR", + "smoketest", + ).Return(nil).Once() + + err := c.RunSmoketest() + Expect(err).To(BeNil()) + }) + }) +}) + +var _ = Describe("AddSmoketestCodesphereCmd", func() { + It("adds the smoketest codesphere command to the parent", func() { + parent := &cobra.Command{} + opts := &cmd.GlobalOptions{} + cmd.AddSmoketestCodesphereCmd(parent, opts) + found := false + for _, c := range parent.Commands() { + if c.Use == "codesphere" { + found = true + break + } + } + Expect(found).To(BeTrue()) + }) +}) diff --git a/docs/README.md b/docs/README.md index 6408711..f24192c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ like downloading new versions. * [oms-cli list](oms-cli_list.md) - List resources available through OMS * [oms-cli register](oms-cli_register.md) - Register a new API key * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS +* [oms-cli smoketest](oms-cli_smoketest.md) - Run smoke tests for Codesphere components * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version diff --git a/docs/oms-cli.md b/docs/oms-cli.md index 6408711..f24192c 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -26,6 +26,7 @@ like downloading new versions. * [oms-cli list](oms-cli_list.md) - List resources available through OMS * [oms-cli register](oms-cli_register.md) - Register a new API key * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS +* [oms-cli smoketest](oms-cli_smoketest.md) - Run smoke tests for Codesphere components * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version diff --git a/docs/oms-cli_smoketest.md b/docs/oms-cli_smoketest.md new file mode 100644 index 0000000..7b2c87d --- /dev/null +++ b/docs/oms-cli_smoketest.md @@ -0,0 +1,19 @@ +## oms-cli smoketest + +Run smoke tests for Codesphere components + +### Synopsis + +Run automated smoke tests for Codesphere installations to verify functionality. + +### Options + +``` + -h, --help help for smoketest +``` + +### SEE ALSO + +* [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +* [oms-cli smoketest codesphere](oms-cli_smoketest_codesphere.md) - Run smoke tests for a Codesphere installation + diff --git a/docs/oms-cli_smoketest_codesphere.md b/docs/oms-cli_smoketest_codesphere.md new file mode 100644 index 0000000..ac90f0f --- /dev/null +++ b/docs/oms-cli_smoketest_codesphere.md @@ -0,0 +1,52 @@ +## oms-cli smoketest codesphere + +Run smoke tests for a Codesphere installation + +### Synopsis + +Run automated smoke tests for a Codesphere installation by creating a workspace, +setting environment variables, executing commands, syncing landscape, and running a pipeline stage. +The workspace is automatically deleted after the test completes. + +``` +oms-cli smoketest codesphere [flags] +``` + +### Examples + +``` +# Run smoke tests against a Codesphere installation +$ oms-cli smoketest codesphere --baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID + +# Run smoke tests in quiet mode (no progress logging) +$ oms-cli smoketest codesphere --baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --quiet + +# Run smoke tests with custom timeout +$ oms-cli smoketest codesphere --baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --timeout 15m + +# Run only specific steps of the smoke test (workspace won't be deleted) +$ oms-cli smoketest codesphere --baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --steps createWorkspace,syncLandscape + +# Run specific steps and delete the workspace afterwards +$ oms-cli smoketest codesphere --baseurl https://codesphere.example.com/api --token YOUR_TOKEN --team-id TEAM_ID --plan-id PLAN_ID --steps createWorkspace,syncLandscape,deleteWorkspace + +``` + +### Options + +``` + --baseurl string Base URL of the Codesphere API + -h, --help help for codesphere + --plan-id string Plan ID for workspace creation + --profile string CI profile to use for landscape and pipeline (default "ci.yml") + -q, --quiet Suppress progress logging + --steps string Comma-separated list of steps to run (createWorkspace,setEnvVar,createFiles,syncLandscape,startPipeline,deleteWorkspace). If empty, all steps including deleteWorkspace are run. If specified without deleteWorkspace, the workspace will be kept for manual inspection. + --team-id string Team ID for workspace creation + --timeout duration Timeout for the entire smoke test (default 10m0s) + --token string API token for authentication +``` + +### SEE ALSO + +* [oms-cli smoketest](oms-cli_smoketest.md) - Run smoke tests for Codesphere components + diff --git a/go.mod b/go.mod index f1951b9..ef912ee 100644 --- a/go.mod +++ b/go.mod @@ -490,6 +490,7 @@ require ( google.golang.org/grpc v1.75.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 // indirect + gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect honnef.co/go/tools v0.6.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/go.sum b/go.sum index 017a4cf..65eac84 100644 --- a/go.sum +++ b/go.sum @@ -1566,6 +1566,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/codesphere/codesphere.go b/internal/codesphere/codesphere.go new file mode 100644 index 0000000..f25462c --- /dev/null +++ b/internal/codesphere/codesphere.go @@ -0,0 +1,114 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package codesphere + +import ( + "context" + "fmt" + "net/url" + "time" + + "github.com/codesphere-cloud/cs-go/api" +) + +// Client interface abstracts Codesphere API operations for testing +type Client interface { + CreateWorkspace(ctx context.Context, teamID, planID int, name string, repoURL *string) (workspaceID int, err error) + SetEnvVar(ctx context.Context, workspaceID int, key, value string) error + ExecuteCommand(ctx context.Context, workspaceID int, command string) error + SyncLandscape(ctx context.Context, workspaceID int, profile string) error + StartPipeline(ctx context.Context, workspaceID int, profile, stage string) error + DeleteWorkspace(ctx context.Context, workspaceID int) error +} + +// APIClient wraps the cs-go API client +type APIClient struct { + client *api.Client +} + +// NewClient creates a new Codesphere API client +func NewClient(baseURL, token string) (Client, error) { + if baseURL == "" { + return nil, fmt.Errorf("baseURL is required") + } + if token == "" { + return nil, fmt.Errorf("token is required") + } + + parsedURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid baseURL: %w", err) + } + + ctx := context.Background() + client := api.NewClient(ctx, api.Configuration{ + BaseUrl: parsedURL, + Token: token, + }) + + return &APIClient{client: client}, nil +} + +// CreateWorkspace creates a new workspace and waits for it to be running +func (c *APIClient) CreateWorkspace(ctx context.Context, teamID, planID int, name string, repoURL *string) (int, error) { + workspace, err := c.client.DeployWorkspace(api.DeployWorkspaceArgs{ + TeamId: teamID, + PlanId: planID, + Name: name, + GitUrl: repoURL, + Timeout: 10 * time.Minute, + EnvVars: map[string]string{}, // Empty map to avoid null + IsPrivateRepo: true, + }) + if err != nil { + return 0, fmt.Errorf("failed to create workspace: %w", err) + } + return workspace.Id, nil +} + +// SetEnvVar sets an environment variable in the workspace +func (c *APIClient) SetEnvVar(ctx context.Context, workspaceID int, key, value string) error { + envVars := map[string]string{key: value} + err := c.client.SetEnvVarOnWorkspace(workspaceID, envVars) + if err != nil { + return fmt.Errorf("failed to set environment variable: %w", err) + } + return nil +} + +// ExecuteCommand executes a command in the workspace +func (c *APIClient) ExecuteCommand(ctx context.Context, workspaceID int, command string) error { + _, _, err := c.client.ExecCommand(workspaceID, command, "", map[string]string{}) + if err != nil { + return fmt.Errorf("failed to execute command: %w", err) + } + return nil +} + +// SyncLandscape syncs the landscape/CI configuration +func (c *APIClient) SyncLandscape(ctx context.Context, workspaceID int, profile string) error { + err := c.client.DeployLandscape(workspaceID, profile) + if err != nil { + return fmt.Errorf("failed to sync landscape: %w", err) + } + return nil +} + +// StartPipeline starts a pipeline stage +func (c *APIClient) StartPipeline(ctx context.Context, workspaceID int, profile, stage string) error { + err := c.client.StartPipelineStage(workspaceID, profile, stage) + if err != nil { + return fmt.Errorf("failed to start pipeline: %w", err) + } + return nil +} + +// DeleteWorkspace deletes a workspace +func (c *APIClient) DeleteWorkspace(ctx context.Context, workspaceID int) error { + err := c.client.DeleteWorkspace(workspaceID) + if err != nil { + return fmt.Errorf("failed to delete workspace: %w", err) + } + return nil +} diff --git a/internal/codesphere/mocks.go b/internal/codesphere/mocks.go new file mode 100644 index 0000000..bb486ef --- /dev/null +++ b/internal/codesphere/mocks.go @@ -0,0 +1,331 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package codesphere + +import ( + "context" + mock "github.com/stretchr/testify/mock" +) + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// CreateWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) CreateWorkspace(ctx context.Context, teamID int, planID int, name string, repoURL *string) (int, error) { + ret := _mock.Called(ctx, teamID, planID, name, repoURL) + + if len(ret) == 0 { + panic("no return value specified for CreateWorkspace") + } + + var r0 int + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, string, *string) (int, error)); ok { + return returnFunc(ctx, teamID, planID, name, repoURL) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int, int, string, *string) int); ok { + r0 = returnFunc(ctx, teamID, planID, name, repoURL) + } else { + r0 = ret.Get(0).(int) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int, int, string, *string) error); ok { + r1 = returnFunc(ctx, teamID, planID, name, repoURL) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_CreateWorkspace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateWorkspace' +type MockClient_CreateWorkspace_Call struct { + *mock.Call +} + +// CreateWorkspace is a helper method to define mock.On call +// - ctx +// - teamID +// - planID +// - name +// - repoURL +func (_e *MockClient_Expecter) CreateWorkspace(ctx interface{}, teamID interface{}, planID interface{}, name interface{}, repoURL interface{}) *MockClient_CreateWorkspace_Call { + return &MockClient_CreateWorkspace_Call{Call: _e.mock.On("CreateWorkspace", ctx, teamID, planID, name, repoURL)} +} + +func (_c *MockClient_CreateWorkspace_Call) Run(run func(ctx context.Context, teamID int, planID int, name string, repoURL *string)) *MockClient_CreateWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(int), args[3].(string), args[4].(*string)) + }) + return _c +} + +func (_c *MockClient_CreateWorkspace_Call) Return(workspaceID int, err error) *MockClient_CreateWorkspace_Call { + _c.Call.Return(workspaceID, err) + return _c +} + +func (_c *MockClient_CreateWorkspace_Call) RunAndReturn(run func(ctx context.Context, teamID int, planID int, name string, repoURL *string) (int, error)) *MockClient_CreateWorkspace_Call { + _c.Call.Return(run) + return _c +} + +// DeleteWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) DeleteWorkspace(ctx context.Context, workspaceID int) error { + ret := _mock.Called(ctx, workspaceID) + + if len(ret) == 0 { + panic("no return value specified for DeleteWorkspace") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = returnFunc(ctx, workspaceID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_DeleteWorkspace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteWorkspace' +type MockClient_DeleteWorkspace_Call struct { + *mock.Call +} + +// DeleteWorkspace is a helper method to define mock.On call +// - ctx +// - workspaceID +func (_e *MockClient_Expecter) DeleteWorkspace(ctx interface{}, workspaceID interface{}) *MockClient_DeleteWorkspace_Call { + return &MockClient_DeleteWorkspace_Call{Call: _e.mock.On("DeleteWorkspace", ctx, workspaceID)} +} + +func (_c *MockClient_DeleteWorkspace_Call) Run(run func(ctx context.Context, workspaceID int)) *MockClient_DeleteWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int)) + }) + return _c +} + +func (_c *MockClient_DeleteWorkspace_Call) Return(err error) *MockClient_DeleteWorkspace_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_DeleteWorkspace_Call) RunAndReturn(run func(ctx context.Context, workspaceID int) error) *MockClient_DeleteWorkspace_Call { + _c.Call.Return(run) + return _c +} + +// ExecuteCommand provides a mock function for the type MockClient +func (_mock *MockClient) ExecuteCommand(ctx context.Context, workspaceID int, command string) error { + ret := _mock.Called(ctx, workspaceID, command) + + if len(ret) == 0 { + panic("no return value specified for ExecuteCommand") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = returnFunc(ctx, workspaceID, command) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_ExecuteCommand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecuteCommand' +type MockClient_ExecuteCommand_Call struct { + *mock.Call +} + +// ExecuteCommand is a helper method to define mock.On call +// - ctx +// - workspaceID +// - command +func (_e *MockClient_Expecter) ExecuteCommand(ctx interface{}, workspaceID interface{}, command interface{}) *MockClient_ExecuteCommand_Call { + return &MockClient_ExecuteCommand_Call{Call: _e.mock.On("ExecuteCommand", ctx, workspaceID, command)} +} + +func (_c *MockClient_ExecuteCommand_Call) Run(run func(ctx context.Context, workspaceID int, command string)) *MockClient_ExecuteCommand_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string)) + }) + return _c +} + +func (_c *MockClient_ExecuteCommand_Call) Return(err error) *MockClient_ExecuteCommand_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_ExecuteCommand_Call) RunAndReturn(run func(ctx context.Context, workspaceID int, command string) error) *MockClient_ExecuteCommand_Call { + _c.Call.Return(run) + return _c +} + +// SetEnvVar provides a mock function for the type MockClient +func (_mock *MockClient) SetEnvVar(ctx context.Context, workspaceID int, key string, value string) error { + ret := _mock.Called(ctx, workspaceID, key, value) + + if len(ret) == 0 { + panic("no return value specified for SetEnvVar") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, string, string) error); ok { + r0 = returnFunc(ctx, workspaceID, key, value) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_SetEnvVar_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetEnvVar' +type MockClient_SetEnvVar_Call struct { + *mock.Call +} + +// SetEnvVar is a helper method to define mock.On call +// - ctx +// - workspaceID +// - key +// - value +func (_e *MockClient_Expecter) SetEnvVar(ctx interface{}, workspaceID interface{}, key interface{}, value interface{}) *MockClient_SetEnvVar_Call { + return &MockClient_SetEnvVar_Call{Call: _e.mock.On("SetEnvVar", ctx, workspaceID, key, value)} +} + +func (_c *MockClient_SetEnvVar_Call) Run(run func(ctx context.Context, workspaceID int, key string, value string)) *MockClient_SetEnvVar_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockClient_SetEnvVar_Call) Return(err error) *MockClient_SetEnvVar_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_SetEnvVar_Call) RunAndReturn(run func(ctx context.Context, workspaceID int, key string, value string) error) *MockClient_SetEnvVar_Call { + _c.Call.Return(run) + return _c +} + +// StartPipeline provides a mock function for the type MockClient +func (_mock *MockClient) StartPipeline(ctx context.Context, workspaceID int, profile string, stage string) error { + ret := _mock.Called(ctx, workspaceID, profile, stage) + + if len(ret) == 0 { + panic("no return value specified for StartPipeline") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, string, string) error); ok { + r0 = returnFunc(ctx, workspaceID, profile, stage) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_StartPipeline_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartPipeline' +type MockClient_StartPipeline_Call struct { + *mock.Call +} + +// StartPipeline is a helper method to define mock.On call +// - ctx +// - workspaceID +// - profile +// - stage +func (_e *MockClient_Expecter) StartPipeline(ctx interface{}, workspaceID interface{}, profile interface{}, stage interface{}) *MockClient_StartPipeline_Call { + return &MockClient_StartPipeline_Call{Call: _e.mock.On("StartPipeline", ctx, workspaceID, profile, stage)} +} + +func (_c *MockClient_StartPipeline_Call) Run(run func(ctx context.Context, workspaceID int, profile string, stage string)) *MockClient_StartPipeline_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockClient_StartPipeline_Call) Return(err error) *MockClient_StartPipeline_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_StartPipeline_Call) RunAndReturn(run func(ctx context.Context, workspaceID int, profile string, stage string) error) *MockClient_StartPipeline_Call { + _c.Call.Return(run) + return _c +} + +// SyncLandscape provides a mock function for the type MockClient +func (_mock *MockClient) SyncLandscape(ctx context.Context, workspaceID int, profile string) error { + ret := _mock.Called(ctx, workspaceID, profile) + + if len(ret) == 0 { + panic("no return value specified for SyncLandscape") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int, string) error); ok { + r0 = returnFunc(ctx, workspaceID, profile) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_SyncLandscape_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyncLandscape' +type MockClient_SyncLandscape_Call struct { + *mock.Call +} + +// SyncLandscape is a helper method to define mock.On call +// - ctx +// - workspaceID +// - profile +func (_e *MockClient_Expecter) SyncLandscape(ctx interface{}, workspaceID interface{}, profile interface{}) *MockClient_SyncLandscape_Call { + return &MockClient_SyncLandscape_Call{Call: _e.mock.On("SyncLandscape", ctx, workspaceID, profile)} +} + +func (_c *MockClient_SyncLandscape_Call) Run(run func(ctx context.Context, workspaceID int, profile string)) *MockClient_SyncLandscape_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int), args[2].(string)) + }) + return _c +} + +func (_c *MockClient_SyncLandscape_Call) Return(err error) *MockClient_SyncLandscape_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_SyncLandscape_Call) RunAndReturn(run func(ctx context.Context, workspaceID int, profile string) error) *MockClient_SyncLandscape_Call { + _c.Call.Return(run) + return _c +}