From e780a866db2cdd82ddcbf26b3fcfa35d29920cf2 Mon Sep 17 00:00:00 2001 From: Alex <132889147+alexvcodesphere@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:03:40 +0100 Subject: [PATCH] feat(cli): add cs deploy github command for PR deployments Refactor the standalone gh-action-deploy binary into two layers: - pkg/deploy/: generic, provider-agnostic deployment engine (find, create, update, delete workspace + run pipeline) - cli/cmd/deploy_github.go: GitHub-specific subcommand that reads GitHub Actions env vars and delegates to pkg/deploy New commands: cs deploy - parent command for CI/CD deployments cs deploy github - GitHub Actions integration The generic engine in pkg/deploy/ can be reused by future providers (e.g. cs deploy gitlab). Signed-off-by: Alex <132889147+alexvcodesphere@users.noreply.github.com> --- .mockery.yml | 4 + cli/cmd/deploy.go | 26 ++ cli/cmd/deploy_github.go | 199 ++++++++++++ cli/cmd/root.go | 1 + pkg/deploy/deploy.go | 245 +++++++++++++++ pkg/deploy/deploy_suite_test.go | 16 + pkg/deploy/deploy_test.go | 258 ++++++++++++++++ pkg/deploy/mocks.go | 521 ++++++++++++++++++++++++++++++++ 8 files changed, 1270 insertions(+) create mode 100644 cli/cmd/deploy.go create mode 100644 cli/cmd/deploy_github.go create mode 100644 pkg/deploy/deploy.go create mode 100644 pkg/deploy/deploy_suite_test.go create mode 100644 pkg/deploy/deploy_test.go create mode 100644 pkg/deploy/mocks.go diff --git a/.mockery.yml b/.mockery.yml index c1fe4a6..fb09ef4 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -38,3 +38,7 @@ packages: config: all: true interfaces: + github.com/codesphere-cloud/cs-go/pkg/deploy: + config: + all: true + interfaces: diff --git a/cli/cmd/deploy.go b/cli/cmd/deploy.go new file mode 100644 index 0000000..83979d7 --- /dev/null +++ b/cli/cmd/deploy.go @@ -0,0 +1,26 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +type DeployCmd struct { + cmd *cobra.Command +} + +func AddDeployCmd(rootCmd *cobra.Command, opts GlobalOptions) { + deploy := DeployCmd{ + cmd: &cobra.Command{ + Use: "deploy", + Short: "Deploy workspaces from CI/CD", + Long: `Deploy workspaces from CI/CD pipelines. Supports creating, updating, and deleting workspaces tied to git provider events like pull requests.`, + }, + } + rootCmd.AddCommand(deploy.cmd) + + // Add provider-specific subcommands + AddDeployGitHubCmd(deploy.cmd, opts) +} diff --git a/cli/cmd/deploy_github.go b/cli/cmd/deploy_github.go new file mode 100644 index 0000000..66c5b01 --- /dev/null +++ b/cli/cmd/deploy_github.go @@ -0,0 +1,199 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/codesphere-cloud/cs-go/pkg/deploy" + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type DeployGitHubCmd struct { + cmd *cobra.Command + Opts DeployGitHubOpts +} + +type DeployGitHubOpts struct { + GlobalOptions + PlanId *int + Env *[]string + VpnConfig *string + Branch *string + Stages *string + Timeout *time.Duration +} + +func (c *DeployGitHubCmd) RunE(_ *cobra.Command, args []string) error { + client, err := NewClient(c.Opts.GlobalOptions) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + teamId, err := c.Opts.GetTeamId() + if err != nil { + return fmt.Errorf("failed to get team ID: %w", err) + } + + // Load GitHub context + eventName := os.Getenv("GITHUB_EVENT_NAME") + prAction, prNumber := loadGitHubEvent() + repository := os.Getenv("GITHUB_REPOSITORY") + serverUrl := os.Getenv("GITHUB_SERVER_URL") + + // Determine workspace name: -# + parts := strings.Split(repository, "/") + repo := parts[len(parts)-1] + wsName := fmt.Sprintf("%s-#%s", repo, prNumber) + + // Resolve branch + branch := c.resolveBranch() + + // Resolve repo URL + repoUrl := fmt.Sprintf("%s/%s.git", serverUrl, repository) + + // Parse stages + var stages []string + for _, s := range strings.Fields(*c.Opts.Stages) { + if s != "" { + stages = append(stages, s) + } + } + + // Parse env vars + envVars := make(map[string]string) + for _, e := range *c.Opts.Env { + if idx := strings.Index(e, "="); idx > 0 { + envVars[e[:idx]] = e[idx+1:] + } + } + + cfg := deploy.Config{ + TeamId: teamId, + PlanId: *c.Opts.PlanId, + Name: wsName, + EnvVars: envVars, + VpnConfig: *c.Opts.VpnConfig, + Branch: branch, + Stages: stages, + RepoUrl: repoUrl, + Timeout: *c.Opts.Timeout, + } + + // Determine if this is a delete operation + isDelete := eventName == "pull_request" && prAction == "closed" + + deployer := deploy.NewDeployer(client) + result, err := deployer.Deploy(cfg, isDelete) + if err != nil { + return err + } + + // Write GitHub-specific outputs + if result != nil { + setGitHubOutputs(result.WorkspaceId, result.WorkspaceURL) + } + + return nil +} + +// resolveBranch determines the branch to deploy with priority: +// flag > GITHUB_HEAD_REF > GITHUB_REF_NAME > "main" +func (c *DeployGitHubCmd) resolveBranch() string { + if c.Opts.Branch != nil && *c.Opts.Branch != "" { + return *c.Opts.Branch + } + if headRef := os.Getenv("GITHUB_HEAD_REF"); headRef != "" { + return headRef + } + if refName := os.Getenv("GITHUB_REF_NAME"); refName != "" { + return refName + } + return "main" +} + +// loadGitHubEvent reads the PR action and number from GITHUB_EVENT_PATH. +func loadGitHubEvent() (action string, number string) { + path := os.Getenv("GITHUB_EVENT_PATH") + if path == "" { + return "", "" + } + data, err := os.ReadFile(path) + if err != nil { + return "", "" + } + var event struct { + Action string `json:"action"` + Number int `json:"number"` + } + if json.Unmarshal(data, &event) == nil { + return event.Action, strconv.Itoa(event.Number) + } + return "", "" +} + +// setGitHubOutputs writes deployment results to GitHub Actions output files. +func setGitHubOutputs(wsId int, url string) { + if f := os.Getenv("GITHUB_OUTPUT"); f != "" { + appendToFile(f, fmt.Sprintf("deployment-url=%s\nworkspace-id=%d\n", url, wsId)) + } + + if f := os.Getenv("GITHUB_STEP_SUMMARY"); f != "" { + appendToFile(f, fmt.Sprintf( + "### 🚀 Codesphere Deployment\n\n| Property | Value |\n|----------|-------|\n| **URL** | [%s](%s) |\n| **Workspace** | `%d` |\n", + url, url, wsId, + )) + } +} + +func appendToFile(path, content string) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() //nolint:errcheck // best-effort append + _, _ = f.WriteString(content) +} + +func AddDeployGitHubCmd(deployCmd *cobra.Command, opts GlobalOptions) { + github := DeployGitHubCmd{ + cmd: &cobra.Command{ + Use: "github", + Short: "Deploy from GitHub Actions", + Long: io.Long(`Deploy workspaces from GitHub Actions. + + Automatically detects the PR context from GitHub Actions environment + variables (GITHUB_EVENT_NAME, GITHUB_HEAD_REF, GITHUB_REPOSITORY, etc.) + and creates, updates, or deletes workspaces accordingly. + + On PR open/synchronize: creates or updates a workspace. + On PR close: deletes the workspace. + + Designed to be used from GitHub Actions workflows.`), + Example: io.FormatExampleCommands("deploy github", []io.Example{ + {Cmd: "", Desc: "Deploy using GitHub Actions environment variables"}, + {Cmd: "--plan-id 20", Desc: "Deploy with a specific plan"}, + {Cmd: "--stages 'prepare test run'", Desc: "Deploy and run specific pipeline stages"}, + {Cmd: "--branch feature-x", Desc: "Override the branch to deploy"}, + }), + }, + Opts: DeployGitHubOpts{GlobalOptions: opts}, + } + + github.Opts.PlanId = github.cmd.Flags().Int("plan-id", 8, "Plan ID for the workspace") + github.Opts.Env = github.cmd.Flags().StringArray("env", []string{}, "Environment variables in KEY=VALUE format") + github.Opts.VpnConfig = github.cmd.Flags().String("vpn-config", "", "VPN config name to connect the workspace to") + github.Opts.Branch = github.cmd.Flags().StringP("branch", "b", "", "Git branch to deploy (auto-detected from GitHub context if not set)") + github.Opts.Stages = github.cmd.Flags().String("stages", "prepare run", "Pipeline stages to run (space-separated: prepare test run)") + github.Opts.Timeout = github.cmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for workspace creation/readiness") + + deployCmd.AddCommand(github.cmd) + github.cmd.RunE = github.RunE +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 64559f7..30b481f 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -94,6 +94,7 @@ func GetRootCmd() *cobra.Command { AddGoCmd(rootCmd) AddWakeUpCmd(rootCmd, opts) AddCurlCmd(rootCmd, opts) + AddDeployCmd(rootCmd, opts) return rootCmd } diff --git a/pkg/deploy/deploy.go b/pkg/deploy/deploy.go new file mode 100644 index 0000000..ccecfcd --- /dev/null +++ b/pkg/deploy/deploy.go @@ -0,0 +1,245 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deploy + +import ( + "fmt" + "strings" + "time" + + "github.com/codesphere-cloud/cs-go/api" +) + +// Client defines the API operations needed for preview deployments. +// This is a subset of the full Codesphere API client. +type Client interface { + ListWorkspaces(teamId int) ([]api.Workspace, error) + DeployWorkspace(args api.DeployWorkspaceArgs) (*api.Workspace, error) + DeleteWorkspace(wsId int) error + WaitForWorkspaceRunning(workspace *api.Workspace, timeout time.Duration) error + SetEnvVarOnWorkspace(workspaceId int, vars map[string]string) error + GitPull(wsId int, remote string, branch string) error + StartPipelineStage(wsId int, profile string, stage string) error + GetPipelineState(wsId int, stage string) ([]api.PipelineStatus, error) +} + +// Config holds all parameters needed for a preview deployment. +// This is provider-agnostic — no references to GitHub, GitLab, etc. +type Config struct { + TeamId int + PlanId int + Name string + EnvVars map[string]string + VpnConfig string + Branch string + Stages []string + RepoUrl string + Timeout time.Duration +} + +// Result holds the output of a successful deployment. +type Result struct { + WorkspaceId int + WorkspaceURL string +} + +// Deployer orchestrates preview environment lifecycle operations. +type Deployer struct { + Client Client +} + +// NewDeployer creates a new preview deployer with the given API client. +func NewDeployer(client Client) *Deployer { + return &Deployer{Client: client} +} + +// FindWorkspace looks for an existing workspace by name within a team. +// Returns nil if no workspace with the given name is found. +func (d *Deployer) FindWorkspace(teamId int, name string) (*api.Workspace, error) { + fmt.Printf("🔍 Looking for workspace '%s'...\n", name) + + workspaces, err := d.Client.ListWorkspaces(teamId) + if err != nil { + return nil, fmt.Errorf("listing workspaces: %w", err) + } + + for i := range workspaces { + if workspaces[i].Name == name { + fmt.Printf(" Found: id=%d\n", workspaces[i].Id) + return &workspaces[i], nil + } + } + return nil, nil +} + +// CreateWorkspace creates a new preview workspace with the given configuration. +func (d *Deployer) CreateWorkspace(cfg Config) (*api.Workspace, error) { + fmt.Printf("🚀 Creating workspace '%s'...\n", cfg.Name) + + ws, err := d.Client.DeployWorkspace(api.DeployWorkspaceArgs{ + TeamId: cfg.TeamId, + PlanId: cfg.PlanId, + Name: cfg.Name, + EnvVars: cfg.EnvVars, + VpnConfigName: strPtr(cfg.VpnConfig), + IsPrivateRepo: true, + GitUrl: strPtr(cfg.RepoUrl), + Branch: strPtr(cfg.Branch), + Timeout: cfg.Timeout, + }) + if err != nil { + return nil, fmt.Errorf("creating workspace: %w", err) + } + + fmt.Printf(" Created: id=%d\n", ws.Id) + return ws, nil +} + +// UpdateWorkspace updates an existing preview workspace by pulling the latest +// branch and setting environment variables. +func (d *Deployer) UpdateWorkspace(ws *api.Workspace, cfg Config) error { + fmt.Println(" ⏰ Waiting for workspace to be running...") + if err := d.Client.WaitForWorkspaceRunning(ws, cfg.Timeout); err != nil { + return err + } + fmt.Println(" ✅ Workspace is running.") + + fmt.Printf(" đŸ“Ĩ Pulling branch '%s'...\n", cfg.Branch) + if err := d.Client.GitPull(ws.Id, "origin", cfg.Branch); err != nil { + return fmt.Errorf("git pull: %w", err) + } + + if len(cfg.EnvVars) > 0 { + fmt.Printf(" 🔧 Setting %d environment variable(s)...\n", len(cfg.EnvVars)) + if err := d.Client.SetEnvVarOnWorkspace(ws.Id, cfg.EnvVars); err != nil { + return fmt.Errorf("setting env vars: %w", err) + } + } + + return nil +} + +// DeleteWorkspace deletes a workspace by ID. +func (d *Deployer) DeleteWorkspace(wsId int) error { + fmt.Printf("đŸ—‘ī¸ Deleting workspace %d...\n", wsId) + return d.Client.DeleteWorkspace(wsId) +} + +// RunPipeline runs pipeline stages sequentially. For non-"run" stages it polls +// until completion. The "run" stage is fire-and-forget. +func (d *Deployer) RunPipeline(wsId int, stages []string) error { + if len(stages) == 0 { + return nil + } + + fmt.Printf("🔧 Running pipeline: %s\n", strings.Join(stages, " → ")) + + for _, stage := range stages { + fmt.Printf(" â–ļ Starting '%s'...\n", stage) + if err := d.Client.StartPipelineStage(wsId, "", stage); err != nil { + return fmt.Errorf("starting stage '%s': %w", stage, err) + } + + // 'run' is fire-and-forget + if stage == "run" { + fmt.Printf(" ✅ '%s' triggered.\n", stage) + continue + } + + // Poll until done + deadline := time.Now().Add(30 * time.Minute) + for time.Now().Before(deadline) { + time.Sleep(5 * time.Second) + statuses, err := d.Client.GetPipelineState(wsId, stage) + if err != nil { + continue // transient error, retry + } + + allDone := true + for _, s := range statuses { + switch s.State { + case "failure", "aborted": + return fmt.Errorf("pipeline '%s' failed (state: %s)", stage, s.State) + case "success": + // good + default: + allDone = false + } + } + + if allDone && len(statuses) > 0 { + fmt.Printf(" ✅ '%s' completed.\n", stage) + break + } + } + } + return nil +} + +// Deploy orchestrates the full preview environment lifecycle: +// - If isDelete is true, finds and deletes the workspace. +// - Otherwise, creates a new workspace or updates an existing one, +// then runs the configured pipeline stages. +// +// Returns a Result with the workspace ID and URL on success. +func (d *Deployer) Deploy(cfg Config, isDelete bool) (*Result, error) { + fmt.Printf("đŸŒŋ Target branch: %s\n", cfg.Branch) + + if isDelete { + ws, err := d.FindWorkspace(cfg.TeamId, cfg.Name) + if err != nil { + return nil, fmt.Errorf("finding workspace: %w", err) + } + if ws != nil { + if err := d.DeleteWorkspace(ws.Id); err != nil { + return nil, fmt.Errorf("deleting workspace: %w", err) + } + fmt.Println("✅ Workspace deleted.") + } else { + fmt.Println("â„šī¸ No workspace found — nothing to delete.") + } + return nil, nil + } + + // Create or update + existing, err := d.FindWorkspace(cfg.TeamId, cfg.Name) + if err != nil { + return nil, fmt.Errorf("finding workspace: %w", err) + } + + var wsId int + if existing != nil { + if err := d.UpdateWorkspace(existing, cfg); err != nil { + return nil, fmt.Errorf("updating workspace: %w", err) + } + wsId = existing.Id + fmt.Printf("✅ Workspace %d updated.\n", wsId) + } else { + ws, err := d.CreateWorkspace(cfg) + if err != nil { + return nil, fmt.Errorf("creating workspace: %w", err) + } + wsId = ws.Id + fmt.Println("✅ New workspace created.") + } + + if err := d.RunPipeline(wsId, cfg.Stages); err != nil { + return nil, fmt.Errorf("running pipeline: %w", err) + } + + url := fmt.Sprintf("https://%d-3000.2.codesphere.com/", wsId) + fmt.Printf("🔗 Deployment URL: %s\n", url) + + return &Result{ + WorkspaceId: wsId, + WorkspaceURL: url, + }, nil +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/pkg/deploy/deploy_suite_test.go b/pkg/deploy/deploy_suite_test.go new file mode 100644 index 0000000..e933fb7 --- /dev/null +++ b/pkg/deploy/deploy_suite_test.go @@ -0,0 +1,16 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deploy_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPreview(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Deploy Suite") +} diff --git a/pkg/deploy/deploy_test.go b/pkg/deploy/deploy_test.go new file mode 100644 index 0000000..6338695 --- /dev/null +++ b/pkg/deploy/deploy_test.go @@ -0,0 +1,258 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package deploy_test + +import ( + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/pkg/deploy" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Deployer", func() { + var ( + mockClient *deploy.MockClient + deployer *deploy.Deployer + teamId int + wsName string + ) + + BeforeEach(func() { + teamId = 5 + wsName = "my-app-#42" + }) + + JustBeforeEach(func() { + mockClient = deploy.NewMockClient(GinkgoT()) + deployer = deploy.NewDeployer(mockClient) + }) + + Describe("FindWorkspace", func() { + Context("when workspace exists", func() { + It("returns the matching workspace", func() { + workspaces := []api.Workspace{ + {Id: 100, Name: "other-ws"}, + {Id: 200, Name: wsName}, + } + mockClient.EXPECT().ListWorkspaces(teamId).Return(workspaces, nil) + + ws, err := deployer.FindWorkspace(teamId, wsName) + Expect(err).ToNot(HaveOccurred()) + Expect(ws).ToNot(BeNil()) + Expect(ws.Id).To(Equal(200)) + Expect(ws.Name).To(Equal(wsName)) + }) + }) + + Context("when workspace does not exist", func() { + It("returns nil without error", func() { + workspaces := []api.Workspace{ + {Id: 100, Name: "other-ws"}, + } + mockClient.EXPECT().ListWorkspaces(teamId).Return(workspaces, nil) + + ws, err := deployer.FindWorkspace(teamId, wsName) + Expect(err).ToNot(HaveOccurred()) + Expect(ws).To(BeNil()) + }) + }) + + Context("when listing fails", func() { + It("returns the error", func() { + mockClient.EXPECT().ListWorkspaces(teamId).Return(nil, errors.New("api error")) + + ws, err := deployer.FindWorkspace(teamId, wsName) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("listing workspaces")) + Expect(ws).To(BeNil()) + }) + }) + }) + + Describe("CreateWorkspace", func() { + var cfg deploy.Config + + BeforeEach(func() { + cfg = deploy.Config{ + TeamId: teamId, + PlanId: 8, + Name: wsName, + EnvVars: map[string]string{"KEY": "val"}, + Branch: "feature-branch", + RepoUrl: "https://github.com/org/repo.git", + Timeout: 5 * time.Minute, + } + }) + + It("creates workspace with correct args", func() { + branch := "feature-branch" + repoUrl := "https://github.com/org/repo.git" + mockClient.EXPECT().DeployWorkspace(api.DeployWorkspaceArgs{ + TeamId: teamId, + PlanId: 8, + Name: wsName, + EnvVars: map[string]string{"KEY": "val"}, + IsPrivateRepo: true, + GitUrl: &repoUrl, + Branch: &branch, + Timeout: 5 * time.Minute, + }).Return(&api.Workspace{Id: 300, Name: wsName}, nil) + + ws, err := deployer.CreateWorkspace(cfg) + Expect(err).ToNot(HaveOccurred()) + Expect(ws.Id).To(Equal(300)) + }) + + It("returns error when deploy fails", func() { + mockClient.EXPECT().DeployWorkspace(mock.Anything).Return(nil, errors.New("deploy failed")) + + ws, err := deployer.CreateWorkspace(cfg) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("creating workspace")) + Expect(ws).To(BeNil()) + }) + }) + + Describe("UpdateWorkspace", func() { + var ( + ws *api.Workspace + cfg deploy.Config + ) + + BeforeEach(func() { + ws = &api.Workspace{Id: 200, Name: wsName} + cfg = deploy.Config{ + Branch: "feature-branch", + EnvVars: map[string]string{"KEY": "val"}, + Timeout: 5 * time.Minute, + } + }) + + It("waits for running, pulls, and sets env vars", func() { + mockClient.EXPECT().WaitForWorkspaceRunning(ws, 5*time.Minute).Return(nil) + mockClient.EXPECT().GitPull(200, "origin", "feature-branch").Return(nil) + mockClient.EXPECT().SetEnvVarOnWorkspace(200, map[string]string{"KEY": "val"}).Return(nil) + + err := deployer.UpdateWorkspace(ws, cfg) + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips env vars when none provided", func() { + cfg.EnvVars = map[string]string{} + mockClient.EXPECT().WaitForWorkspaceRunning(ws, 5*time.Minute).Return(nil) + mockClient.EXPECT().GitPull(200, "origin", "feature-branch").Return(nil) + // SetEnvVarOnWorkspace should NOT be called + + err := deployer.UpdateWorkspace(ws, cfg) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when wait fails", func() { + mockClient.EXPECT().WaitForWorkspaceRunning(ws, 5*time.Minute).Return(errors.New("timeout")) + + err := deployer.UpdateWorkspace(ws, cfg) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout")) + }) + + It("returns error when git pull fails", func() { + mockClient.EXPECT().WaitForWorkspaceRunning(ws, 5*time.Minute).Return(nil) + mockClient.EXPECT().GitPull(200, "origin", "feature-branch").Return(errors.New("pull failed")) + + err := deployer.UpdateWorkspace(ws, cfg) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("git pull")) + }) + }) + + Describe("DeleteWorkspace", func() { + It("deletes the workspace", func() { + mockClient.EXPECT().DeleteWorkspace(200).Return(nil) + + err := deployer.DeleteWorkspace(200) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error on failure", func() { + mockClient.EXPECT().DeleteWorkspace(200).Return(errors.New("delete failed")) + + err := deployer.DeleteWorkspace(200) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Deploy", func() { + var cfg deploy.Config + + BeforeEach(func() { + cfg = deploy.Config{ + TeamId: teamId, + PlanId: 8, + Name: wsName, + EnvVars: map[string]string{}, + Branch: "feature-branch", + RepoUrl: "https://github.com/org/repo.git", + Stages: []string{}, + Timeout: 5 * time.Minute, + } + }) + + Context("delete mode", func() { + It("finds and deletes existing workspace", func() { + workspaces := []api.Workspace{{Id: 200, Name: wsName}} + mockClient.EXPECT().ListWorkspaces(teamId).Return(workspaces, nil) + mockClient.EXPECT().DeleteWorkspace(200).Return(nil) + + result, err := deployer.Deploy(cfg, true) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("does nothing when workspace not found", func() { + mockClient.EXPECT().ListWorkspaces(teamId).Return([]api.Workspace{}, nil) + + result, err := deployer.Deploy(cfg, true) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Context("create mode (no existing workspace)", func() { + It("creates a new workspace and returns result", func() { + // FindWorkspace returns nothing + mockClient.EXPECT().ListWorkspaces(teamId).Return([]api.Workspace{}, nil) + // CreateWorkspace + mockClient.EXPECT().DeployWorkspace(mock.Anything).Return(&api.Workspace{Id: 300, Name: wsName}, nil) + + result, err := deployer.Deploy(cfg, false) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.WorkspaceId).To(Equal(300)) + Expect(result.WorkspaceURL).To(ContainSubstring("300")) + }) + }) + + Context("update mode (existing workspace)", func() { + It("updates existing workspace and returns result", func() { + existing := &api.Workspace{Id: 200, Name: wsName} + workspaces := []api.Workspace{*existing} + // FindWorkspace + mockClient.EXPECT().ListWorkspaces(teamId).Return(workspaces, nil) + // UpdateWorkspace + mockClient.EXPECT().WaitForWorkspaceRunning(mock.Anything, 5*time.Minute).Return(nil) + mockClient.EXPECT().GitPull(200, "origin", "feature-branch").Return(nil) + + result, err := deployer.Deploy(cfg, false) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.WorkspaceId).To(Equal(200)) + }) + }) + }) +}) diff --git a/pkg/deploy/mocks.go b/pkg/deploy/mocks.go new file mode 100644 index 0000000..1581265 --- /dev/null +++ b/pkg/deploy/mocks.go @@ -0,0 +1,521 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package deploy + +import ( + "github.com/codesphere-cloud/cs-go/api" + mock "github.com/stretchr/testify/mock" + "time" +) + +// 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} +} + +// DeleteWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) DeleteWorkspace(wsId int) error { + ret := _mock.Called(wsId) + + if len(ret) == 0 { + panic("no return value specified for DeleteWorkspace") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(int) error); ok { + r0 = returnFunc(wsId) + } 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 +// - wsId int +func (_e *MockClient_Expecter) DeleteWorkspace(wsId interface{}) *MockClient_DeleteWorkspace_Call { + return &MockClient_DeleteWorkspace_Call{Call: _e.mock.On("DeleteWorkspace", wsId)} +} + +func (_c *MockClient_DeleteWorkspace_Call) Run(run func(wsId int)) *MockClient_DeleteWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + run( + arg0, + ) + }) + 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(wsId int) error) *MockClient_DeleteWorkspace_Call { + _c.Call.Return(run) + return _c +} + +// DeployWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) DeployWorkspace(args api.DeployWorkspaceArgs) (*api.Workspace, error) { + ret := _mock.Called(args) + + if len(ret) == 0 { + panic("no return value specified for DeployWorkspace") + } + + var r0 *api.Workspace + var r1 error + if returnFunc, ok := ret.Get(0).(func(api.DeployWorkspaceArgs) (*api.Workspace, error)); ok { + return returnFunc(args) + } + if returnFunc, ok := ret.Get(0).(func(api.DeployWorkspaceArgs) *api.Workspace); ok { + r0 = returnFunc(args) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.Workspace) + } + } + if returnFunc, ok := ret.Get(1).(func(api.DeployWorkspaceArgs) error); ok { + r1 = returnFunc(args) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_DeployWorkspace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployWorkspace' +type MockClient_DeployWorkspace_Call struct { + *mock.Call +} + +// DeployWorkspace is a helper method to define mock.On call +// - args api.DeployWorkspaceArgs +func (_e *MockClient_Expecter) DeployWorkspace(args interface{}) *MockClient_DeployWorkspace_Call { + return &MockClient_DeployWorkspace_Call{Call: _e.mock.On("DeployWorkspace", args)} +} + +func (_c *MockClient_DeployWorkspace_Call) Run(run func(args api.DeployWorkspaceArgs)) *MockClient_DeployWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 api.DeployWorkspaceArgs + if args[0] != nil { + arg0 = args[0].(api.DeployWorkspaceArgs) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockClient_DeployWorkspace_Call) Return(v *api.Workspace, err error) *MockClient_DeployWorkspace_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockClient_DeployWorkspace_Call) RunAndReturn(run func(args api.DeployWorkspaceArgs) (*api.Workspace, error)) *MockClient_DeployWorkspace_Call { + _c.Call.Return(run) + return _c +} + +// GetPipelineState provides a mock function for the type MockClient +func (_mock *MockClient) GetPipelineState(wsId int, stage string) ([]api.PipelineStatus, error) { + ret := _mock.Called(wsId, stage) + + if len(ret) == 0 { + panic("no return value specified for GetPipelineState") + } + + var r0 []api.PipelineStatus + var r1 error + if returnFunc, ok := ret.Get(0).(func(int, string) ([]api.PipelineStatus, error)); ok { + return returnFunc(wsId, stage) + } + if returnFunc, ok := ret.Get(0).(func(int, string) []api.PipelineStatus); ok { + r0 = returnFunc(wsId, stage) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api.PipelineStatus) + } + } + if returnFunc, ok := ret.Get(1).(func(int, string) error); ok { + r1 = returnFunc(wsId, stage) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetPipelineState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPipelineState' +type MockClient_GetPipelineState_Call struct { + *mock.Call +} + +// GetPipelineState is a helper method to define mock.On call +// - wsId int +// - stage string +func (_e *MockClient_Expecter) GetPipelineState(wsId interface{}, stage interface{}) *MockClient_GetPipelineState_Call { + return &MockClient_GetPipelineState_Call{Call: _e.mock.On("GetPipelineState", wsId, stage)} +} + +func (_c *MockClient_GetPipelineState_Call) Run(run func(wsId int, stage string)) *MockClient_GetPipelineState_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_GetPipelineState_Call) Return(vs []api.PipelineStatus, err error) *MockClient_GetPipelineState_Call { + _c.Call.Return(vs, err) + return _c +} + +func (_c *MockClient_GetPipelineState_Call) RunAndReturn(run func(wsId int, stage string) ([]api.PipelineStatus, error)) *MockClient_GetPipelineState_Call { + _c.Call.Return(run) + return _c +} + +// GitPull provides a mock function for the type MockClient +func (_mock *MockClient) GitPull(wsId int, remote string, branch string) error { + ret := _mock.Called(wsId, remote, branch) + + if len(ret) == 0 { + panic("no return value specified for GitPull") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(int, string, string) error); ok { + r0 = returnFunc(wsId, remote, branch) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_GitPull_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GitPull' +type MockClient_GitPull_Call struct { + *mock.Call +} + +// GitPull is a helper method to define mock.On call +// - wsId int +// - remote string +// - branch string +func (_e *MockClient_Expecter) GitPull(wsId interface{}, remote interface{}, branch interface{}) *MockClient_GitPull_Call { + return &MockClient_GitPull_Call{Call: _e.mock.On("GitPull", wsId, remote, branch)} +} + +func (_c *MockClient_GitPull_Call) Run(run func(wsId int, remote string, branch string)) *MockClient_GitPull_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_GitPull_Call) Return(err error) *MockClient_GitPull_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_GitPull_Call) RunAndReturn(run func(wsId int, remote string, branch string) error) *MockClient_GitPull_Call { + _c.Call.Return(run) + return _c +} + +// ListWorkspaces provides a mock function for the type MockClient +func (_mock *MockClient) ListWorkspaces(teamId int) ([]api.Workspace, error) { + ret := _mock.Called(teamId) + + if len(ret) == 0 { + panic("no return value specified for ListWorkspaces") + } + + var r0 []api.Workspace + var r1 error + if returnFunc, ok := ret.Get(0).(func(int) ([]api.Workspace, error)); ok { + return returnFunc(teamId) + } + if returnFunc, ok := ret.Get(0).(func(int) []api.Workspace); ok { + r0 = returnFunc(teamId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api.Workspace) + } + } + if returnFunc, ok := ret.Get(1).(func(int) error); ok { + r1 = returnFunc(teamId) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_ListWorkspaces_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkspaces' +type MockClient_ListWorkspaces_Call struct { + *mock.Call +} + +// ListWorkspaces is a helper method to define mock.On call +// - teamId int +func (_e *MockClient_Expecter) ListWorkspaces(teamId interface{}) *MockClient_ListWorkspaces_Call { + return &MockClient_ListWorkspaces_Call{Call: _e.mock.On("ListWorkspaces", teamId)} +} + +func (_c *MockClient_ListWorkspaces_Call) Run(run func(teamId int)) *MockClient_ListWorkspaces_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockClient_ListWorkspaces_Call) Return(vs []api.Workspace, err error) *MockClient_ListWorkspaces_Call { + _c.Call.Return(vs, err) + return _c +} + +func (_c *MockClient_ListWorkspaces_Call) RunAndReturn(run func(teamId int) ([]api.Workspace, error)) *MockClient_ListWorkspaces_Call { + _c.Call.Return(run) + return _c +} + +// SetEnvVarOnWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) SetEnvVarOnWorkspace(workspaceId int, vars map[string]string) error { + ret := _mock.Called(workspaceId, vars) + + if len(ret) == 0 { + panic("no return value specified for SetEnvVarOnWorkspace") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(int, map[string]string) error); ok { + r0 = returnFunc(workspaceId, vars) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_SetEnvVarOnWorkspace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetEnvVarOnWorkspace' +type MockClient_SetEnvVarOnWorkspace_Call struct { + *mock.Call +} + +// SetEnvVarOnWorkspace is a helper method to define mock.On call +// - workspaceId int +// - vars map[string]string +func (_e *MockClient_Expecter) SetEnvVarOnWorkspace(workspaceId interface{}, vars interface{}) *MockClient_SetEnvVarOnWorkspace_Call { + return &MockClient_SetEnvVarOnWorkspace_Call{Call: _e.mock.On("SetEnvVarOnWorkspace", workspaceId, vars)} +} + +func (_c *MockClient_SetEnvVarOnWorkspace_Call) Run(run func(workspaceId int, vars map[string]string)) *MockClient_SetEnvVarOnWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + var arg1 map[string]string + if args[1] != nil { + arg1 = args[1].(map[string]string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_SetEnvVarOnWorkspace_Call) Return(err error) *MockClient_SetEnvVarOnWorkspace_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_SetEnvVarOnWorkspace_Call) RunAndReturn(run func(workspaceId int, vars map[string]string) error) *MockClient_SetEnvVarOnWorkspace_Call { + _c.Call.Return(run) + return _c +} + +// StartPipelineStage provides a mock function for the type MockClient +func (_mock *MockClient) StartPipelineStage(wsId int, profile string, stage string) error { + ret := _mock.Called(wsId, profile, stage) + + if len(ret) == 0 { + panic("no return value specified for StartPipelineStage") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(int, string, string) error); ok { + r0 = returnFunc(wsId, profile, stage) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_StartPipelineStage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'StartPipelineStage' +type MockClient_StartPipelineStage_Call struct { + *mock.Call +} + +// StartPipelineStage is a helper method to define mock.On call +// - wsId int +// - profile string +// - stage string +func (_e *MockClient_Expecter) StartPipelineStage(wsId interface{}, profile interface{}, stage interface{}) *MockClient_StartPipelineStage_Call { + return &MockClient_StartPipelineStage_Call{Call: _e.mock.On("StartPipelineStage", wsId, profile, stage)} +} + +func (_c *MockClient_StartPipelineStage_Call) Run(run func(wsId int, profile string, stage string)) *MockClient_StartPipelineStage_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 int + if args[0] != nil { + arg0 = args[0].(int) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockClient_StartPipelineStage_Call) Return(err error) *MockClient_StartPipelineStage_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_StartPipelineStage_Call) RunAndReturn(run func(wsId int, profile string, stage string) error) *MockClient_StartPipelineStage_Call { + _c.Call.Return(run) + return _c +} + +// WaitForWorkspaceRunning provides a mock function for the type MockClient +func (_mock *MockClient) WaitForWorkspaceRunning(workspace *api.Workspace, timeout time.Duration) error { + ret := _mock.Called(workspace, timeout) + + if len(ret) == 0 { + panic("no return value specified for WaitForWorkspaceRunning") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*api.Workspace, time.Duration) error); ok { + r0 = returnFunc(workspace, timeout) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_WaitForWorkspaceRunning_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WaitForWorkspaceRunning' +type MockClient_WaitForWorkspaceRunning_Call struct { + *mock.Call +} + +// WaitForWorkspaceRunning is a helper method to define mock.On call +// - workspace *api.Workspace +// - timeout time.Duration +func (_e *MockClient_Expecter) WaitForWorkspaceRunning(workspace interface{}, timeout interface{}) *MockClient_WaitForWorkspaceRunning_Call { + return &MockClient_WaitForWorkspaceRunning_Call{Call: _e.mock.On("WaitForWorkspaceRunning", workspace, timeout)} +} + +func (_c *MockClient_WaitForWorkspaceRunning_Call) Run(run func(workspace *api.Workspace, timeout time.Duration)) *MockClient_WaitForWorkspaceRunning_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *api.Workspace + if args[0] != nil { + arg0 = args[0].(*api.Workspace) + } + var arg1 time.Duration + if args[1] != nil { + arg1 = args[1].(time.Duration) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockClient_WaitForWorkspaceRunning_Call) Return(err error) *MockClient_WaitForWorkspaceRunning_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_WaitForWorkspaceRunning_Call) RunAndReturn(run func(workspace *api.Workspace, timeout time.Duration) error) *MockClient_WaitForWorkspaceRunning_Call { + _c.Call.Return(run) + return _c +}