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 1e67847..73f9dde 100644 --- a/NOTICE +++ b/NOTICE @@ -88,7 +88,7 @@ License: MIT License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE ---------- -Module: github.com/codesphere-cloud/cs-go/pkg/io +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.15.0/LICENSE @@ -405,6 +405,12 @@ Version: v1.36.11 License: BSD-3-Clause License URL: https://github.com/protocolbuffers/protobuf-go/blob/v1.36.11/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 Version: v3.0.1 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..ca69b7a --- /dev/null +++ b/cli/cmd/smoketest_codesphere.go @@ -0,0 +1,169 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "log" + "slices" + "strings" + "time" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/codesphere" + "github.com/codesphere-cloud/oms/internal/codesphere/teststeps" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +const ( + defaultTimeout = 10 * time.Minute + defaultProfile = "ci.yml" +) + +var availableSteps = []teststeps.SmokeTestStep{ + &teststeps.CreateWorkspaceStep{}, + &teststeps.SetEnvVarStep{}, + &teststeps.CreateFilesStep{}, + &teststeps.SyncLandscapeStep{}, + &teststeps.StartPipelineStep{}, + &teststeps.DeleteWorkspaceStep{}, +} + +type SmoketestCodesphereCmd struct { + cmd *cobra.Command + Opts *teststeps.SmoketestCodesphereOpts +} + +func (c *SmoketestCodesphereCmd) RunE(_ *cobra.Command, args []string) error { + // Initialize client if not set (for testing) + if c.Opts.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.Opts.Client = client + } + + return c.RunSmoketest() +} + +func AddSmoketestCodesphereCmd(parent *cobra.Command, opts *GlobalOptions) { + var stepNames []string + for _, s := range availableSteps { + stepNames = append(stepNames, s.Name()) + } + + 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: &teststeps.SmoketestCodesphereOpts{}, + } + 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().StringSliceVar(&c.Opts.Steps, "steps", []string{}, fmt.Sprintf("Comma-separated list of steps to run (%s). If empty, all steps including deleteWorkspace are run. If specified without deleteWorkspace, the workspace will be kept for manual inspection.", strings.Join(stepNames, ","))) + + 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() + + availableStepsMap := make(map[string]teststeps.SmokeTestStep) + for _, s := range availableSteps { + availableStepsMap[s.Name()] = s + } + + stepsToRun := make([]teststeps.SmokeTestStep, len(availableSteps)) + copy(stepsToRun, availableSteps) + + if len(c.Opts.Steps) > 0 { + stepsToRun = slices.DeleteFunc(stepsToRun, func(s teststeps.SmokeTestStep) bool { + return !slices.Contains(c.Opts.Steps, s.Name()) + }) + } + + var workspaceID int + deleteStep := &teststeps.DeleteWorkspaceStep{} + defer func() { + if err != nil { + log.Printf("Smoketest failed: %s", err.Error()) + } + + shouldDelete := false + for _, s := range stepsToRun { + if s.Name() == deleteStep.Name() { + shouldDelete = true + break + } + } + + if workspaceID != 0 && shouldDelete { + deleteErr := deleteStep.Run(context.Background(), c.Opts, &workspaceID) + if deleteErr != nil { + if err == nil { + err = deleteErr + } + } + } + + if err == nil { + log.Println("Smoketest completed successfully!") + } + }() + + // Execute steps + for _, step := range stepsToRun { + // Skip deleteWorkspace in the main loop as it's handled in defer + if step.Name() == deleteStep.Name() { + continue + } + if err = step.Run(ctx, c.Opts, &workspaceID); err != nil { + return err + } + } + + return nil +} diff --git a/cli/cmd/smoketest_codesphere_test.go b/cli/cmd/smoketest_codesphere_test.go new file mode 100644 index 0000000..ef7fcc8 --- /dev/null +++ b/cli/cmd/smoketest_codesphere_test.go @@ -0,0 +1,392 @@ +// 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" + "github.com/codesphere-cloud/oms/internal/codesphere/teststeps" +) + +var _ = Describe("SmoketestCodesphereCmd", func() { + var ( + mockClient *codesphere.MockClient + c cmd.SmoketestCodesphereCmd + opts *teststeps.SmoketestCodesphereOpts + ) + + BeforeEach(func() { + mockClient = codesphere.NewMockClient(GinkgoT()) + opts = &teststeps.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", + Steps: []string{}, + Client: mockClient, + } + c = cmd.SmoketestCodesphereCmd{ + Opts: opts, + } + }) + + 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 = []string{"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 7fe1b01..8cd34e5 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 7fe1b01..8cd34e5 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..50eb949 --- /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 strings 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 458a633..6c098a3 100644 --- a/go.mod +++ b/go.mod @@ -490,6 +490,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // 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 352879d..19446e4 100644 --- a/go.sum +++ b/go.sum @@ -1569,6 +1569,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 +} diff --git a/internal/codesphere/teststeps/csgo.go b/internal/codesphere/teststeps/csgo.go new file mode 100644 index 0000000..da41de2 --- /dev/null +++ b/internal/codesphere/teststeps/csgo.go @@ -0,0 +1,165 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package teststeps + +import ( + "context" + "fmt" + "strconv" + "time" +) + +const ( + smoketestEnvVarKey = "TEST_VAR" + smoketestEnvVarValue = "smoketest" + smoketestPipelineStage = "run" + + stepNameCreateWorkspace = "createWorkspace" + stepNameSetEnvVar = "setEnvVar" + stepNameCreateFiles = "createFiles" + stepNameSyncLandscape = "syncLandscape" + stepNameStartPipeline = "startPipeline" + stepNameDeleteWorkspace = "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 = ` + +
+