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 1/2] 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 +} From ecde2e7a2ac9b84c9574ac15251503d15cd4f55e Mon Sep 17 00:00:00 2001 From: Alex <132889147+alexvcodesphere@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:24:09 +0100 Subject: [PATCH 2/2] refactor: extract pipeline logic into shared pkg/pipeline package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the pipeline polling logic (stage validation, IDE server checks, allRunning, shouldAbort, deploy landscape sync) from cli/cmd/start_pipeline.go into a shared pkg/pipeline package. Both start_pipeline.go and pkg/deploy now use pipeline.Runner, eliminating duplicated and incorrect polling logic. The deploy.Client interface now embeds pipeline.Client for shared pipeline operations (StartPipelineStage, GetPipelineState, DeployLandscape). The pipeline flow is: prepare → test (if present) → sync landscape → run. Signed-off-by: Alex <132889147+alexvcodesphere@users.noreply.github.com> --- cli/cmd/start_pipeline.go | 124 +++----------------------- cli/cmd/start_pipeline_test.go | 10 ++- pkg/deploy/deploy.go | 64 ++++---------- pkg/deploy/mocks.go | 57 ++++++++++++ pkg/pipeline/pipeline.go | 157 +++++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 165 deletions(-) create mode 100644 pkg/pipeline/pipeline.go diff --git a/cli/cmd/start_pipeline.go b/cli/cmd/start_pipeline.go index 3807854..3cb950e 100644 --- a/cli/cmd/start_pipeline.go +++ b/cli/cmd/start_pipeline.go @@ -5,12 +5,11 @@ package cmd import ( "fmt" - "log" - "slices" "time" "github.com/codesphere-cloud/cs-go/api" "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/cs-go/pkg/pipeline" "github.com/spf13/cobra" ) @@ -27,8 +26,6 @@ type StartPipelineOpts struct { Timeout *time.Duration } -const IdeServer string = "codesphere-ide" - func (c *StartPipelineCmd) RunE(_ *cobra.Command, args []string) error { workspaceId, err := c.Opts.GetWorkspaceId() @@ -45,7 +42,7 @@ func (c *StartPipelineCmd) RunE(_ *cobra.Command, args []string) error { } func AddStartPipelineCmd(start *cobra.Command, opts GlobalOptions) { - pipeline := StartPipelineCmd{ + p := StartPipelineCmd{ cmd: &cobra.Command{ Use: "pipeline", Short: "Start pipeline stages of a workspace", @@ -72,116 +69,17 @@ func AddStartPipelineCmd(start *cobra.Command, opts GlobalOptions) { Time: &api.RealTime{}, } - pipeline.Opts.Timeout = pipeline.cmd.Flags().Duration("timeout", 30*time.Minute, "Time to wait per stage before stopping the command execution (e.g. 10m)") - pipeline.Opts.Profile = pipeline.cmd.Flags().StringP("profile", "p", "", "CI profile to use (e.g. 'prod' for the profile defined in 'ci.prod.yml'), defaults to the ci.yml profile") - start.AddCommand(pipeline.cmd) + p.Opts.Timeout = p.cmd.Flags().Duration("timeout", 30*time.Minute, "Time to wait per stage before stopping the command execution (e.g. 10m)") + p.Opts.Profile = p.cmd.Flags().StringP("profile", "p", "", "CI profile to use (e.g. 'prod' for the profile defined in 'ci.prod.yml'), defaults to the ci.yml profile") + start.AddCommand(p.cmd) - pipeline.cmd.RunE = pipeline.RunE + p.cmd.RunE = p.RunE } func (c *StartPipelineCmd) StartPipelineStages(client Client, wsId int, stages []string) error { - for _, stage := range stages { - if !isValidStage(stage) { - return fmt.Errorf("invalid pipeline stage: %s", stage) - } - } - for _, stage := range stages { - err := c.startStage(client, wsId, stage) - if err != nil { - return err - } - } - return nil -} - -func isValidStage(stage string) bool { - return slices.Contains([]string{"prepare", "test", "run"}, stage) -} - -func (c *StartPipelineCmd) startStage(client Client, wsId int, stage string) error { - log.Printf("starting %s stage on workspace %d...", stage, wsId) - - err := client.StartPipelineStage(wsId, *c.Opts.Profile, stage) - if err != nil { - log.Println() - return fmt.Errorf("failed to start pipeline stage %s: %w", stage, err) - } - - err = c.waitForPipelineStage(client, wsId, stage) - if err != nil { - return fmt.Errorf("failed waiting for stage %s to finish: %w", stage, err) - - } - return nil -} - -func (c *StartPipelineCmd) waitForPipelineStage(client Client, wsId int, stage string) error { - delay := 5 * time.Second - - maxWaitTime := c.Time.Now().Add(*c.Opts.Timeout) - for { - status, err := client.GetPipelineState(wsId, stage) - if err != nil { - log.Printf("\nError getting pipeline status: %s, trying again...", err.Error()) - c.Time.Sleep(delay) - continue - } - - if c.allFinished(status) { - log.Println("(finished)") - break - } - - if allRunning(status) && stage == "run" { - log.Println("(running)") - break - } - - err = shouldAbort(status) - if err != nil { - log.Println("(failed)") - return fmt.Errorf("stage %s failed: %w", stage, err) - } - - log.Print(".") - if c.Time.Now().After(maxWaitTime) { - log.Println() - return fmt.Errorf("timed out waiting for pipeline stage %s to be complete", stage) - } - c.Time.Sleep(delay) - } - return nil -} - -func allRunning(status []api.PipelineStatus) bool { - for _, s := range status { - // Run stage is only running customer servers, ignore IDE server - if s.Server != IdeServer && s.State != "running" { - return false - } - } - return true -} - -func (c *StartPipelineCmd) allFinished(status []api.PipelineStatus) bool { - io.Verboseln(*c.Opts.Verbose, "====") - for _, s := range status { - io.Verbosef(*c.Opts.Verbose, "Server: %s, State: %s, Replica: %s\n", s.Server, s.State, s.Replica) - } - for _, s := range status { - // Prepare and Test stage is only running in the IDE server, ignore customer servers - if s.Server == IdeServer && s.State != "success" { - return false - } - } - return true -} - -func shouldAbort(status []api.PipelineStatus) error { - for _, s := range status { - if slices.Contains([]string{"failure", "aborted"}, s.State) { - return fmt.Errorf("server %s, replica %s reached unexpected state %s", s.Server, s.Replica, s.State) - } - } - return nil + runner := pipeline.NewRunner(client, c.Time) + return runner.RunStages(wsId, stages, pipeline.Config{ + Profile: *c.Opts.Profile, + Timeout: *c.Opts.Timeout, + }) } diff --git a/cli/cmd/start_pipeline_test.go b/cli/cmd/start_pipeline_test.go index c1f664d..c190f68 100644 --- a/cli/cmd/start_pipeline_test.go +++ b/cli/cmd/start_pipeline_test.go @@ -95,7 +95,8 @@ var _ = Describe("StartPipeline", func() { testStartCall := mockClient.EXPECT().StartPipelineStage(wsId, profile, stages[1]).Return(nil).NotBefore(prepareStatusCall) testStatusCall := mockClient.EXPECT().GetPipelineState(wsId, stages[1]).Return(reportedStatusSuccess, nil).NotBefore(testStartCall) - runStartCall := mockClient.EXPECT().StartPipelineStage(wsId, profile, stages[2]).Return(nil).NotBefore(testStatusCall) + syncCall := mockClient.EXPECT().DeployLandscape(wsId, profile).Return(nil).NotBefore(testStatusCall) + runStartCall := mockClient.EXPECT().StartPipelineStage(wsId, profile, stages[2]).Return(nil).NotBefore(syncCall) mockClient.EXPECT().GetPipelineState(wsId, stages[2]).Return(reportedStatusRunning, nil).NotBefore(runStartCall) }) @@ -125,7 +126,8 @@ var _ = Describe("StartPipeline", func() { testStatusCall := mockClient.EXPECT().GetPipelineState(wsId, stages[1]).Return(reportedStatusRunning, nil).Times(2).NotBefore(testStartCall) testStatusCallSuccess := mockClient.EXPECT().GetPipelineState(wsId, stages[1]).Return(reportedStatusSuccess, nil).NotBefore(testStatusCall) - runStartCall := mockClient.EXPECT().StartPipelineStage(wsId, profile, stages[2]).Return(nil).NotBefore(testStatusCallSuccess) + syncCall := mockClient.EXPECT().DeployLandscape(wsId, profile).Return(nil).NotBefore(testStatusCallSuccess) + runStartCall := mockClient.EXPECT().StartPipelineStage(wsId, profile, stages[2]).Return(nil).NotBefore(syncCall) mockClient.EXPECT().GetPipelineState(wsId, stages[2]).Return(reportedStatusWaiting, nil).Times(2).NotBefore(runStartCall) mockClient.EXPECT().GetPipelineState(wsId, stages[2]).Return(reportedStatusRunning, nil).NotBefore(runStartCall) @@ -145,7 +147,7 @@ var _ = Describe("StartPipeline", func() { mockClient.EXPECT().GetPipelineState(wsId, stages[1]).Return(reportedStatusRunning, nil).Times(8).NotBefore(testStartCall) err := c.StartPipelineStages(mockClient, wsId, stages) - Expect(err).To(MatchError("failed waiting for stage test to finish: timed out waiting for pipeline stage test to be complete")) + Expect(err).To(MatchError("timed out waiting for pipeline stage test to be complete")) }) }) @@ -155,7 +157,7 @@ var _ = Describe("StartPipeline", func() { mockClient.EXPECT().GetPipelineState(wsId, stages[0]).Return(reportedStatusFailure, nil).NotBefore(prepareStartCall) err := c.StartPipelineStages(mockClient, wsId, stages) - Expect(err).To(MatchError("failed waiting for stage prepare to finish: stage prepare failed: server A, replica 0 reached unexpected state failure")) + Expect(err).To(MatchError("stage prepare failed: server A, replica 0 reached unexpected state failure")) }) }) }) diff --git a/pkg/deploy/deploy.go b/pkg/deploy/deploy.go index ccecfcd..8542f32 100644 --- a/pkg/deploy/deploy.go +++ b/pkg/deploy/deploy.go @@ -9,19 +9,21 @@ import ( "time" "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/pkg/pipeline" ) // Client defines the API operations needed for preview deployments. // This is a subset of the full Codesphere API client. +// Pipeline operations (StartPipelineStage, GetPipelineState, DeployLandscape) +// are handled via the pipeline.Client interface. type Client interface { + pipeline.Client 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. @@ -36,6 +38,7 @@ type Config struct { Stages []string RepoUrl string Timeout time.Duration + Profile string } // Result holds the output of a successful deployment. @@ -126,55 +129,20 @@ func (d *Deployer) DeleteWorkspace(wsId int) error { 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 { +// RunPipeline runs pipeline stages using the shared pipeline runner. +// The flow is: prepare → test (if present) → sync landscape → run. +func (d *Deployer) RunPipeline(wsId int, cfg Config) error { + if len(cfg.Stages) == 0 { return nil } - fmt.Printf("🔧 Running pipeline: %s\n", strings.Join(stages, " → ")) + fmt.Printf("🔧 Running pipeline: %s\n", strings.Join(cfg.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 + runner := pipeline.NewRunner(d.Client, nil) + return runner.RunStages(wsId, cfg.Stages, pipeline.Config{ + Profile: cfg.Profile, + Timeout: cfg.Timeout, + }) } // Deploy orchestrates the full preview environment lifecycle: @@ -224,7 +192,7 @@ func (d *Deployer) Deploy(cfg Config, isDelete bool) (*Result, error) { fmt.Println("✅ New workspace created.") } - if err := d.RunPipeline(wsId, cfg.Stages); err != nil { + if err := d.RunPipeline(wsId, cfg); err != nil { return nil, fmt.Errorf("running pipeline: %w", err) } diff --git a/pkg/deploy/mocks.go b/pkg/deploy/mocks.go index 1581265..b8c5be8 100644 --- a/pkg/deploy/mocks.go +++ b/pkg/deploy/mocks.go @@ -88,6 +88,63 @@ func (_c *MockClient_DeleteWorkspace_Call) RunAndReturn(run func(wsId int) error return _c } +// DeployLandscape provides a mock function for the type MockClient +func (_mock *MockClient) DeployLandscape(wsId int, profile string) error { + ret := _mock.Called(wsId, profile) + + if len(ret) == 0 { + panic("no return value specified for DeployLandscape") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(int, string) error); ok { + r0 = returnFunc(wsId, profile) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockClient_DeployLandscape_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployLandscape' +type MockClient_DeployLandscape_Call struct { + *mock.Call +} + +// DeployLandscape is a helper method to define mock.On call +// - wsId int +// - profile string +func (_e *MockClient_Expecter) DeployLandscape(wsId interface{}, profile interface{}) *MockClient_DeployLandscape_Call { + return &MockClient_DeployLandscape_Call{Call: _e.mock.On("DeployLandscape", wsId, profile)} +} + +func (_c *MockClient_DeployLandscape_Call) Run(run func(wsId int, profile string)) *MockClient_DeployLandscape_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_DeployLandscape_Call) Return(err error) *MockClient_DeployLandscape_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockClient_DeployLandscape_Call) RunAndReturn(run func(wsId int, profile string) error) *MockClient_DeployLandscape_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) diff --git a/pkg/pipeline/pipeline.go b/pkg/pipeline/pipeline.go new file mode 100644 index 0000000..f5756d2 --- /dev/null +++ b/pkg/pipeline/pipeline.go @@ -0,0 +1,157 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package pipeline + +import ( + "fmt" + "log" + "slices" + "time" + + "github.com/codesphere-cloud/cs-go/api" +) + +const IdeServer string = "codesphere-ide" + +// Client defines the API operations needed for pipeline execution. +type Client interface { + StartPipelineStage(wsId int, profile string, stage string) error + GetPipelineState(wsId int, stage string) ([]api.PipelineStatus, error) + DeployLandscape(wsId int, profile string) error +} + +// Config holds parameters for pipeline execution. +type Config struct { + Profile string + Timeout time.Duration +} + +// Runner orchestrates pipeline stage execution. +type Runner struct { + Client Client + Time api.Time +} + +// NewRunner creates a new pipeline runner with the given API client. +func NewRunner(client Client, clock api.Time) *Runner { + if clock == nil { + clock = &api.RealTime{} + } + return &Runner{Client: client, Time: clock} +} + +// RunStages runs pipeline stages sequentially: prepare and test are awaited, +// the run stage is preceded by a landscape sync and then fire-and-forget. +func (r *Runner) RunStages(wsId int, stages []string, cfg Config) error { + for _, stage := range stages { + if !IsValidStage(stage) { + return fmt.Errorf("invalid pipeline stage: %s", stage) + } + } + + for _, stage := range stages { + // Sync the landscape before the run stage + if stage == "run" { + fmt.Println(" 🔄 Syncing landscape...") + if err := r.Client.DeployLandscape(wsId, cfg.Profile); err != nil { + return fmt.Errorf("syncing landscape: %w", err) + } + fmt.Println(" ✅ Landscape synced.") + } + + if err := r.runStage(wsId, stage, cfg); err != nil { + return err + } + } + return nil +} + +func (r *Runner) runStage(wsId int, stage string, cfg Config) error { + log.Printf("starting %s stage on workspace %d...", stage, wsId) + + if err := r.Client.StartPipelineStage(wsId, cfg.Profile, stage); err != nil { + log.Println() + return fmt.Errorf("failed to start pipeline stage %s: %w", stage, err) + } + + return r.waitForStage(wsId, stage, cfg) +} + +func (r *Runner) waitForStage(wsId int, stage string, cfg Config) error { + delay := 5 * time.Second + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 * time.Minute + } + + maxWaitTime := r.Time.Now().Add(timeout) + for { + status, err := r.Client.GetPipelineState(wsId, stage) + if err != nil { + log.Printf("\nError getting pipeline status: %s, trying again...", err.Error()) + r.Time.Sleep(delay) + continue + } + + if AllFinished(status) { + log.Println("(finished)") + break + } + + if AllRunning(status) && stage == "run" { + log.Println("(running)") + break + } + + if err = ShouldAbort(status); err != nil { + log.Println("(failed)") + return fmt.Errorf("stage %s failed: %w", stage, err) + } + + log.Print(".") + if r.Time.Now().After(maxWaitTime) { + log.Println() + return fmt.Errorf("timed out waiting for pipeline stage %s to be complete", stage) + } + r.Time.Sleep(delay) + } + return nil +} + +// IsValidStage returns true if the given stage name is valid. +func IsValidStage(stage string) bool { + return slices.Contains([]string{"prepare", "test", "run"}, stage) +} + +// AllFinished returns true when all IDE server replicas have succeeded. +// Prepare and test stages only run in the IDE server; customer servers are ignored. +func AllFinished(status []api.PipelineStatus) bool { + for _, s := range status { + if s.Server == IdeServer && s.State != "success" { + return false + } + } + return true +} + +// AllRunning returns true when all customer server replicas are running. +// The IDE server is ignored since the run stage only applies to customer servers. +func AllRunning(status []api.PipelineStatus) bool { + for _, s := range status { + if s.Server != IdeServer && s.State != "running" { + return false + } + } + return true +} + +// ShouldAbort returns an error if any replica has reached a terminal failure state. +func ShouldAbort(status []api.PipelineStatus) error { + for _, s := range status { + if slices.Contains([]string{"failure", "aborted"}, s.State) { + return fmt.Errorf("server %s, replica %s reached unexpected state %s", s.Server, s.Replica, s.State) + } + } + return nil +}