diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f0f2a2e..f23aade 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -38,7 +38,7 @@ jobs: run: | echo "Cleaning up any orphaned test workspaces..." # List all workspaces and delete any with test name prefixes - ./cs list workspaces -t $CS_TEAM_ID | grep -E "cli-(test|git-test|pipeline-test|log-test|sync-test|open-test|setenv-test|edge-test)-" | awk '{print $2}' | while read ws_id; do + ./cs list workspaces -t $CS_TEAM_ID | grep -E "cli-(test|git-test|pipeline-test|log-test|sync-test|open-test|setenv-test|edge-test|wakeup-test|curl-test)-" | awk '{print $2}' | while read ws_id; do if [ ! -z "$ws_id" ]; then echo "Deleting orphaned workspace: $ws_id" ./cs delete workspace -w $ws_id --yes || true diff --git a/NOTICE b/NOTICE index d0e20ce..b1ae0f8 100644 --- a/NOTICE +++ b/NOTICE @@ -351,12 +351,6 @@ Version: v0.3.3 License: Apache-2.0 License URL: https://github.com/xanzy/ssh-agent/blob/v0.3.3/LICENSE ----------- -Module: github.com/yaml/go-yaml -Version: v2.1.0 -License: Apache-2.0 -License URL: https://github.com/yaml/go-yaml/blob/v2.1.0/LICENSE - ---------- Module: go.yaml.in/yaml/v2 Version: v2.4.3 diff --git a/api/workspace.go b/api/workspace.go index ee68c38..338ada9 100644 --- a/api/workspace.go +++ b/api/workspace.go @@ -119,8 +119,12 @@ func (client *Client) WaitForWorkspaceRunning(workspace *Workspace, timeout time status, err := client.WorkspaceStatus(workspace.Id) if err != nil { - // TODO: log error and retry until timeout is reached. - return errors.FormatAPIError(err) + // Retry on error (e.g., 404 if workspace not yet registered) until timeout + if client.time.Now().After(maxWaitTime) { + return errors.FormatAPIError(err) + } + client.time.Sleep(delay) + continue } if status.IsRunning { return nil diff --git a/cli/cmd/curl.go b/cli/cmd/curl.go new file mode 100644 index 0000000..4574f0b --- /dev/null +++ b/cli/cmd/curl.go @@ -0,0 +1,136 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "time" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type CurlCmd struct { + cmd *cobra.Command + Opts GlobalOptions + Port *int + Timeout *time.Duration + Insecure bool +} + +func (c *CurlCmd) RunE(_ *cobra.Command, args []string) error { + client, err := NewClient(c.Opts) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + wsId, err := c.Opts.GetWorkspaceId() + if err != nil { + return fmt.Errorf("failed to get workspace ID: %w", err) + } + + token, err := c.Opts.Env.GetApiToken() + if err != nil { + return fmt.Errorf("failed to get API token: %w", err) + } + + if len(args) == 0 { + return fmt.Errorf("path is required (e.g., / or /api/endpoint)") + } + + path := args[0] + curlArgs := args[1:] + + return c.CurlWorkspace(client, wsId, token, path, curlArgs) +} + +func AddCurlCmd(rootCmd *cobra.Command, opts GlobalOptions) { + curl := CurlCmd{ + cmd: &cobra.Command{ + Use: "curl [path] [-- curl-args...]", + Short: "Send authenticated HTTP requests to workspace dev domain", + Long: `Send authenticated HTTP requests to a workspace's development domain using curl-like syntax.`, + Example: io.FormatExampleCommands("curl", []io.Example{ + {Cmd: "/ -w 1234", Desc: "GET request to workspace root"}, + {Cmd: "/api/health -w 1234 -p 3001", Desc: "GET request to port 3001"}, + {Cmd: "/api/data -w 1234 -- -XPOST -d '{\"key\":\"value\"}'", Desc: "POST request with data"}, + {Cmd: "/api/endpoint -w 1234 -- -v", Desc: "verbose output"}, + {Cmd: "/ -- -I", Desc: "HEAD request using workspace from env var"}, + }), + Args: cobra.MinimumNArgs(1), + }, + Opts: opts, + } + curl.Port = curl.cmd.Flags().IntP("port", "p", 3000, "Port to connect to") + curl.Timeout = curl.cmd.Flags().DurationP("timeout", "", 30*time.Second, "Timeout for the request") + curl.cmd.Flags().BoolVar(&curl.Insecure, "insecure", false, "skip TLS certificate verification (for testing only)") + rootCmd.AddCommand(curl.cmd) + curl.cmd.RunE = curl.RunE +} + +func (c *CurlCmd) CurlWorkspace(client Client, wsId int, token string, path string, curlArgs []string) error { + workspace, err := client.GetWorkspace(wsId) + if err != nil { + return fmt.Errorf("failed to get workspace: %w", err) + } + + port := 3000 + if c.Port != nil { + port = *c.Port + } + + url, err := ConstructWorkspaceServiceURL(workspace, port, path) + if err != nil { + return err + } + + log.Printf("Sending request to workspace %d (%s) at %s\n", wsId, workspace.Name, url) + + timeout := 30 * time.Second + if c.Timeout != nil { + timeout = *c.Timeout + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Build curl command + cmdArgs := []string{"curl"} + + // Add authentication header + cmdArgs = append(cmdArgs, "-H", fmt.Sprintf("x-forward-security: %s", token)) + + // Add insecure flag if specified + if c.Insecure { + cmdArgs = append(cmdArgs, "-k") + } + + // Add user's curl arguments + cmdArgs = append(cmdArgs, curlArgs...) + + // Add URL as the last argument + cmdArgs = append(cmdArgs, url) + + // Execute curl command + cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout exceeded while requesting workspace %d", wsId) + } + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + return fmt.Errorf("failed to execute curl: %w", err) + } + + return nil +} diff --git a/cli/cmd/curl_test.go b/cli/cmd/curl_test.go new file mode 100644 index 0000000..df12130 --- /dev/null +++ b/cli/cmd/curl_test.go @@ -0,0 +1,103 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/cli/cmd" +) + +var _ = Describe("Curl", func() { + var ( + mockEnv *cmd.MockEnv + mockClient *cmd.MockClient + c *cmd.CurlCmd + wsId int + teamId int + token string + port int + ) + + JustBeforeEach(func() { + mockClient = cmd.NewMockClient(GinkgoT()) + mockEnv = cmd.NewMockEnv(GinkgoT()) + wsId = 42 + teamId = 21 + token = "test-api-token" + port = 3000 + c = &cmd.CurlCmd{ + Opts: cmd.GlobalOptions{ + Env: mockEnv, + WorkspaceId: &wsId, + }, + Port: &port, + } + }) + + Context("CurlWorkspace", func() { + It("should construct the correct URL with default port", func() { + devDomain := "team-slug.codesphere.com" + workspace := api.Workspace{ + Id: wsId, + TeamId: teamId, + Name: "test-workspace", + DevDomain: &devDomain, + } + + mockClient.EXPECT().GetWorkspace(wsId).Return(workspace, nil) + + err := c.CurlWorkspace(mockClient, wsId, token, "/api/health", []string{"-I"}) + + Expect(err).To(HaveOccurred()) + }) + + It("should construct the correct URL with custom port", func() { + customPort := 3001 + c.Port = &customPort + devDomain := "team-slug.codesphere.com" + workspace := api.Workspace{ + Id: wsId, + TeamId: teamId, + Name: "test-workspace", + DevDomain: &devDomain, + } + + mockClient.EXPECT().GetWorkspace(wsId).Return(workspace, nil) + + err := c.CurlWorkspace(mockClient, wsId, token, "/custom/path", []string{}) + + Expect(err).To(HaveOccurred()) + }) + + It("should return error if workspace has no dev domain", func() { + workspace := api.Workspace{ + Id: wsId, + TeamId: teamId, + Name: "test-workspace", + DevDomain: nil, + } + + mockClient.EXPECT().GetWorkspace(wsId).Return(workspace, nil) + + err := c.CurlWorkspace(mockClient, wsId, token, "/", []string{}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have a development domain configured")) + }) + + It("should return error if GetWorkspace fails", func() { + mockClient.EXPECT().GetWorkspace(wsId).Return(api.Workspace{}, fmt.Errorf("api error")) + + err := c.CurlWorkspace(mockClient, wsId, token, "/", []string{}) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get workspace")) + }) + }) +}) diff --git a/cli/cmd/list_workspaces.go b/cli/cmd/list_workspaces.go index 679df0a..dfc73b7 100644 --- a/cli/cmd/list_workspaces.go +++ b/cli/cmd/list_workspaces.go @@ -25,7 +25,7 @@ func addListWorkspacesCmd(p *cobra.Command, opts GlobalOptions) { Short: "List workspaces", Long: `List workspaces available in Codesphere`, Example: io.FormatExampleCommands("list workspaces", []io.Example{ - {Cmd: "--team-id ", Desc: "List all workspaces"}, + {Cmd: "-t ", Desc: "List all workspaces"}, }), }, Opts: opts, diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 71e6d86..64559f7 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -92,6 +92,8 @@ func GetRootCmd() *cobra.Command { AddSyncCmd(rootCmd, &opts) AddUpdateCmd(rootCmd) AddGoCmd(rootCmd) + AddWakeUpCmd(rootCmd, opts) + AddCurlCmd(rootCmd, opts) return rootCmd } diff --git a/cli/cmd/wakeup.go b/cli/cmd/wakeup.go new file mode 100644 index 0000000..cd91e23 --- /dev/null +++ b/cli/cmd/wakeup.go @@ -0,0 +1,136 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net/http" + "time" + + "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" +) + +type WakeUpCmd struct { + cmd *cobra.Command + Opts GlobalOptions + Timeout *time.Duration + Insecure bool +} + +func (c *WakeUpCmd) RunE(_ *cobra.Command, args []string) error { + client, err := NewClient(c.Opts) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + wsId, err := c.Opts.GetWorkspaceId() + if err != nil { + return fmt.Errorf("failed to get workspace ID: %w", err) + } + + token, err := c.Opts.Env.GetApiToken() + if err != nil { + return fmt.Errorf("failed to get API token: %w", err) + } + + return c.WakeUpWorkspace(client, wsId, token) +} + +func AddWakeUpCmd(rootCmd *cobra.Command, opts GlobalOptions) { + wakeup := WakeUpCmd{ + cmd: &cobra.Command{ + Use: "wake-up", + Short: "Wake up an on-demand workspace", + Long: `Wake up an on-demand workspace by making an authenticated request to its services domain.`, + Example: io.FormatExampleCommands("wake-up", []io.Example{ + {Cmd: "-w 1234", Desc: "wake up workspace 1234"}, + {Cmd: "", Desc: "wake up workspace set by environment variable CS_WORKSPACE_ID"}, + {Cmd: "-w 1234 --timeout 60s", Desc: "wake up workspace with 60 second timeout"}, + }), + }, + Opts: opts, + } + wakeup.Timeout = wakeup.cmd.Flags().DurationP("timeout", "", 120*time.Second, "Timeout for waking up the workspace") + wakeup.cmd.Flags().BoolVar(&wakeup.Insecure, "insecure", false, "skip TLS certificate verification (for testing only)") + rootCmd.AddCommand(wakeup.cmd) + wakeup.cmd.RunE = wakeup.RunE +} + +func (c *WakeUpCmd) WakeUpWorkspace(client Client, wsId int, token string) error { + workspace, err := client.GetWorkspace(wsId) + if err != nil { + return fmt.Errorf("failed to get workspace: %w", err) + } + + // Construct the services domain: ${WORKSPACE_ID}-3000.${DEV_DOMAIN} + servicesDomain, err := ConstructWorkspaceServiceURL(workspace, 3000, "") + if err != nil { + return err + } + + log.Printf("Waking up workspace %d (%s)...\n", wsId, workspace.Name) + timeout := 120 * time.Second + if c.Timeout != nil { + timeout = *c.Timeout + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = makeWakeUpRequest(ctx, servicesDomain, token, c.Insecure) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("timeout exceeded while waking up workspace %d", wsId) + } + return fmt.Errorf("failed to wake up workspace: %w", err) + } + + log.Printf("Successfully woke up workspace %d\n", wsId) + return nil +} + +func makeWakeUpRequest(ctx context.Context, servicesDomain string, token string, insecure bool) error { + req, err := http.NewRequestWithContext(ctx, "GET", servicesDomain, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("x-forward-security", token) + + transport := &http.Transport{} + if insecure { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + req.Header.Set("x-forward-security", token) + return nil + }, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + // 4xx errors are considered failures + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return fmt.Errorf("received error response: %s", resp.Status) + } + + return nil +} diff --git a/cli/cmd/wakeup_test.go b/cli/cmd/wakeup_test.go new file mode 100644 index 0000000..130b0d3 --- /dev/null +++ b/cli/cmd/wakeup_test.go @@ -0,0 +1,84 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/cli/cmd" +) + +var _ = Describe("WakeUp", func() { + var ( + mockEnv *cmd.MockEnv + mockClient *cmd.MockClient + c *cmd.WakeUpCmd + wsId int + teamId int + token string + ) + + JustBeforeEach(func() { + mockClient = cmd.NewMockClient(GinkgoT()) + mockEnv = cmd.NewMockEnv(GinkgoT()) + wsId = 42 + teamId = 21 + token = "test-api-token" + c = &cmd.WakeUpCmd{ + Opts: cmd.GlobalOptions{ + Env: mockEnv, + WorkspaceId: &wsId, + }, + } + }) + + Context("WakeUpWorkspace", func() { + It("should construct the correct services domain and wake up the workspace", func() { + devDomain := "team-slug.codesphere.com" + workspace := api.Workspace{ + Id: wsId, + TeamId: teamId, + Name: "test-workspace", + DevDomain: &devDomain, + } + + mockClient.EXPECT().GetWorkspace(wsId).Return(workspace, nil) + + err := c.WakeUpWorkspace(mockClient, wsId, token) + + // This will fail because we're making a real HTTP request + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to wake up workspace")) + }) + + It("should return error if workspace has no dev domain", func() { + workspace := api.Workspace{ + Id: wsId, + TeamId: teamId, + Name: "test-workspace", + DevDomain: nil, + } + + mockClient.EXPECT().GetWorkspace(wsId).Return(workspace, nil) + + err := c.WakeUpWorkspace(mockClient, wsId, token) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have a development domain configured")) + }) + + It("should return error if GetWorkspace fails", func() { + mockClient.EXPECT().GetWorkspace(wsId).Return(api.Workspace{}, fmt.Errorf("api error")) + + err := c.WakeUpWorkspace(mockClient, wsId, token) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get workspace")) + }) + }) +}) diff --git a/cli/cmd/workspace_url.go b/cli/cmd/workspace_url.go new file mode 100644 index 0000000..bf57c8a --- /dev/null +++ b/cli/cmd/workspace_url.go @@ -0,0 +1,21 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/codesphere-cloud/cs-go/api" +) + +// ConstructWorkspaceServiceURL constructs a URL for accessing a workspace service +// Format: https://${WORKSPACE_ID}-${PORT}.${DEV_DOMAIN}${PATH} +func ConstructWorkspaceServiceURL(workspace api.Workspace, port int, path string) (string, error) { + if workspace.DevDomain == nil { + return "", fmt.Errorf("workspace %d does not have a development domain configured", workspace.Id) + } + + url := fmt.Sprintf("https://%d-%d.%s%s", workspace.Id, port, *workspace.DevDomain, path) + return url, nil +} diff --git a/cli/cmd/workspace_url_test.go b/cli/cmd/workspace_url_test.go new file mode 100644 index 0000000..3d6642b --- /dev/null +++ b/cli/cmd/workspace_url_test.go @@ -0,0 +1,67 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/cli/cmd" +) + +var _ = Describe("WorkspaceURL", func() { + Context("ConstructWorkspaceServiceURL", func() { + It("should construct URL correctly with all parameters", func() { + devDomain := "team-slug.codesphere.com" + workspace := api.Workspace{ + Id: 1234, + DevDomain: &devDomain, + } + + url, err := cmd.ConstructWorkspaceServiceURL(workspace, 3000, "/api/health") + + Expect(err).NotTo(HaveOccurred()) + Expect(url).To(Equal("https://1234-3000.team-slug.codesphere.com/api/health")) + }) + + It("should construct URL correctly with root path", func() { + devDomain := "team-slug.codesphere.com" + workspace := api.Workspace{ + Id: 5678, + DevDomain: &devDomain, + } + + url, err := cmd.ConstructWorkspaceServiceURL(workspace, 8080, "/") + + Expect(err).NotTo(HaveOccurred()) + Expect(url).To(Equal("https://5678-8080.team-slug.codesphere.com/")) + }) + + It("should construct URL correctly with empty path", func() { + devDomain := "dev.example.com" + workspace := api.Workspace{ + Id: 999, + DevDomain: &devDomain, + } + + url, err := cmd.ConstructWorkspaceServiceURL(workspace, 3001, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(url).To(Equal("https://999-3001.dev.example.com")) + }) + + It("should return error when dev domain is nil", func() { + workspace := api.Workspace{ + Id: 1234, + DevDomain: nil, + } + + _, err := cmd.ConstructWorkspaceServiceURL(workspace, 3000, "/") + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not have a development domain configured")) + }) + }) +}) diff --git a/docs/README.md b/docs/README.md index 550ed80..890b480 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ Manage and debug resources deployed in Codesphere via command line. ### SEE ALSO * [cs create](cs_create.md) - Create codesphere resource +* [cs curl](cs_curl.md) - Send authenticated HTTP requests to workspace dev domain * [cs delete](cs_delete.md) - Delete Codesphere resources * [cs exec](cs_exec.md) - Run a command in Codesphere workspace * [cs generate](cs_generate.md) - Generate codesphere artifacts @@ -33,4 +34,5 @@ Manage and debug resources deployed in Codesphere via command line. * [cs sync](cs_sync.md) - Sync Codesphere resources * [cs update](cs_update.md) - Update Codesphere CLI * [cs version](cs_version.md) - Print version +* [cs wake-up](cs_wake-up.md) - Wake up an on-demand workspace diff --git a/docs/cs.md b/docs/cs.md index 550ed80..890b480 100644 --- a/docs/cs.md +++ b/docs/cs.md @@ -19,6 +19,7 @@ Manage and debug resources deployed in Codesphere via command line. ### SEE ALSO * [cs create](cs_create.md) - Create codesphere resource +* [cs curl](cs_curl.md) - Send authenticated HTTP requests to workspace dev domain * [cs delete](cs_delete.md) - Delete Codesphere resources * [cs exec](cs_exec.md) - Run a command in Codesphere workspace * [cs generate](cs_generate.md) - Generate codesphere artifacts @@ -33,4 +34,5 @@ Manage and debug resources deployed in Codesphere via command line. * [cs sync](cs_sync.md) - Sync Codesphere resources * [cs update](cs_update.md) - Update Codesphere CLI * [cs version](cs_version.md) - Print version +* [cs wake-up](cs_wake-up.md) - Wake up an on-demand workspace diff --git a/docs/cs_curl.md b/docs/cs_curl.md new file mode 100644 index 0000000..4ace46e --- /dev/null +++ b/docs/cs_curl.md @@ -0,0 +1,53 @@ +## cs curl + +Send authenticated HTTP requests to workspace dev domain + +### Synopsis + +Send authenticated HTTP requests to a workspace's development domain using curl-like syntax. + +``` +cs curl [path] [-- curl-args...] [flags] +``` + +### Examples + +``` +# GET request to workspace root +$ cs curl / -w 1234 + +# GET request to port 3001 +$ cs curl /api/health -w 1234 -p 3001 + +# POST request with data +$ cs curl /api/data -w 1234 -- -XPOST -d '{"key":"value"}' + +# verbose output +$ cs curl /api/endpoint -w 1234 -- -v + +# HEAD request using workspace from env var +$ cs curl / -- -I +``` + +### Options + +``` + -h, --help help for curl + --insecure skip TLS certificate verification (for testing only) + -p, --port int Port to connect to (default 3000) + --timeout duration Timeout for the request (default 30s) +``` + +### Options inherited from parent commands + +``` + -a, --api string URL of Codesphere API (can also be CS_API) + -t, --team int Team ID (relevant for some commands, can also be CS_TEAM_ID) (default -1) + -v, --verbose Verbose output + -w, --workspace int Workspace ID (relevant for some commands, can also be CS_WORKSPACE_ID) (default -1) +``` + +### SEE ALSO + +* [cs](cs.md) - The Codesphere CLI + diff --git a/docs/cs_list_workspaces.md b/docs/cs_list_workspaces.md index b0c8201..3327f01 100644 --- a/docs/cs_list_workspaces.md +++ b/docs/cs_list_workspaces.md @@ -14,7 +14,7 @@ cs list workspaces [flags] ``` # List all workspaces -$ cs list workspaces --team-id +$ cs list workspaces -t ``` ### Options diff --git a/docs/cs_wake-up.md b/docs/cs_wake-up.md new file mode 100644 index 0000000..a68ca55 --- /dev/null +++ b/docs/cs_wake-up.md @@ -0,0 +1,46 @@ +## cs wake-up + +Wake up an on-demand workspace + +### Synopsis + +Wake up an on-demand workspace by making an authenticated request to its services domain. + +``` +cs wake-up [flags] +``` + +### Examples + +``` +# wake up workspace 1234 +$ cs wake-up -w 1234 + +# wake up workspace set by environment variable CS_WORKSPACE_ID +$ cs wake-up + +# wake up workspace with 60 second timeout +$ cs wake-up -w 1234 --timeout 60s +``` + +### Options + +``` + -h, --help help for wake-up + --insecure skip TLS certificate verification (for testing only) + --timeout duration Timeout for waking up the workspace (default 2m0s) +``` + +### Options inherited from parent commands + +``` + -a, --api string URL of Codesphere API (can also be CS_API) + -t, --team int Team ID (relevant for some commands, can also be CS_TEAM_ID) (default -1) + -v, --verbose Verbose output + -w, --workspace int Workspace ID (relevant for some commands, can also be CS_WORKSPACE_ID) (default -1) +``` + +### SEE ALSO + +* [cs](cs.md) - The Codesphere CLI + diff --git a/int/integration_test.go b/int/integration_test.go index 43ae670..9f1c4ac 100644 --- a/int/integration_test.go +++ b/int/integration_test.go @@ -822,6 +822,8 @@ var _ = Describe("Command Error Handling Tests", func() { {"start pipeline", []string{"start", "pipeline", "-w", "99999999"}}, {"git pull", []string{"git", "pull", "-w", "99999999"}}, {"set-env", []string{"set-env", "-w", "99999999", "TEST_VAR=test"}}, + {"wake-up", []string{"wake-up", "-w", "99999999"}}, + {"curl", []string{"curl", "/", "-w", "99999999"}}, } for _, tc := range testCases { @@ -975,6 +977,327 @@ var _ = Describe("Git Pull Integration Tests", func() { }) }) +var _ = Describe("Wake Up Workspace Integration Tests", func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.SkipIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-wakeup-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Wake Up Command", func() { + BeforeEach(func() { + By("Creating a workspace for wake-up testing") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Waiting for workspace to be fully provisioned") + time.Sleep(5 * time.Second) + }) + + It("should wake up workspace successfully", func() { + By("Waking up the workspace") + output := intutil.RunCommand( + "wake-up", + "-w", workspaceId, + "--insecure", + ) + fmt.Printf("Wake up workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Waking up workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + + It("should respect custom timeout", func() { + By("Waking up workspace with custom timeout") + output, exitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", workspaceId, + "--timeout", "5s", + "--insecure", + ) + fmt.Printf("Wake up with timeout output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).To(ContainSubstring("Waking up workspace")) + }) + + It("should work with workspace ID from environment variable", func() { + By("Setting CS_WORKSPACE_ID environment variable") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + By("Waking up workspace using environment variable") + output := intutil.RunCommand("wake-up", "--insecure") + fmt.Printf("Wake up with env var output: %s\n", output) + + Expect(output).To(ContainSubstring("Waking up workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + }) + + Context("Wake Up Error Handling", func() { + It("should fail when workspace ID is missing", func() { + By("Attempting to wake up workspace without ID") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Unsetenv("CS_WORKSPACE_ID") + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + output, exitCode := intutil.RunCommandWithExitCode("wake-up") + fmt.Printf("Wake up without workspace ID output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("workspace"), + ContainSubstring("required"), + ContainSubstring("not set"), + )) + }) + + It("should fail gracefully with non-existent workspace", func() { + By("Attempting to wake up non-existent workspace") + output, exitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", "99999999", + ) + fmt.Printf("Wake up non-existent workspace output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("failed to get workspace"), + ContainSubstring("not found"), + ContainSubstring("404"), + )) + }) + + It("should handle workspace without dev domain gracefully", func() { + By("Creating a workspace (which might not have dev domain configured)") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Attempting to wake up the workspace") + wakeupOutput, wakeupExitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", workspaceId, + ) + fmt.Printf("Wake up workspace output: %s (exit code: %d)\n", wakeupOutput, wakeupExitCode) + + if wakeupExitCode != 0 { + Expect(wakeupOutput).To(Or( + ContainSubstring("development domain"), + ContainSubstring("dev domain"), + ContainSubstring("failed to wake up"), + )) + } + }) + }) + + Context("Wake Up Command Help", func() { + It("should display help information", func() { + By("Running wake-up --help") + output := intutil.RunCommand("wake-up", "--help") + fmt.Printf("Wake up help output: %s\n", output) + + Expect(output).To(ContainSubstring("Wake up an on-demand workspace")) + Expect(output).To(ContainSubstring("--timeout")) + Expect(output).To(ContainSubstring("-w, --workspace")) + }) + }) +}) + +var _ = Describe("Curl Workspace Integration Tests", func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.SkipIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-curl-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Curl Command", func() { + BeforeEach(func() { + By("Creating a workspace for curl testing") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Waiting for workspace to be fully provisioned") + time.Sleep(5 * time.Second) + }) + + It("should send authenticated request to workspace", func() { + By("Sending curl request to workspace root") + output := intutil.RunCommand( + "curl", "/", + "-w", workspaceId, + "--insecure", + "--", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + + It("should support custom port", func() { + By("Sending curl request to custom port") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", "/", + "-w", workspaceId, + "-p", "3001", + "--insecure", + "--", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl with custom port output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + }) + + It("should pass through curl arguments", func() { + By("Sending HEAD request using curl -I flag") + output := intutil.RunCommand( + "curl", "/", + "-w", workspaceId, + "--insecure", + "--", "-I", + ) + fmt.Printf("Curl with -I flag output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + }) + + It("should work with workspace ID from environment variable", func() { + By("Setting CS_WORKSPACE_ID environment variable") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + By("Sending curl request using environment variable") + output := intutil.RunCommand( + "curl", "/", + "--insecure", + "--", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl with env var output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + }) + + Context("Curl Error Handling", func() { + It("should fail when workspace ID is missing", func() { + By("Attempting to curl without workspace ID") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Unsetenv("CS_WORKSPACE_ID") + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + output, exitCode := intutil.RunCommandWithExitCode("curl", "/") + fmt.Printf("Curl without workspace ID output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("workspace"), + ContainSubstring("required"), + ContainSubstring("not set"), + )) + }) + + It("should fail gracefully with non-existent workspace", func() { + By("Attempting to curl non-existent workspace") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", "/", + "-w", "99999999", + ) + fmt.Printf("Curl non-existent workspace output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("failed to get workspace"), + ContainSubstring("not found"), + ContainSubstring("404"), + )) + }) + + It("should require path argument", func() { + By("Attempting to curl without path") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", + "-w", "1234", + ) + fmt.Printf("Curl without path output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("path"), + ContainSubstring("required"), + ContainSubstring("argument"), + )) + }) + }) + + Context("Curl Command Help", func() { + It("should display help information", func() { + By("Running curl --help") + output := intutil.RunCommand("curl", "--help") + fmt.Printf("Curl help output: %s\n", output) + + Expect(output).To(ContainSubstring("Send authenticated HTTP requests")) + Expect(output).To(ContainSubstring("--port")) + Expect(output).To(ContainSubstring("-w, --workspace")) + }) + }) +}) + var _ = Describe("Command Error Handling Tests", func() { It("should fail gracefully with non-existent workspace for all commands", func() { testCases := []struct { diff --git a/pkg/tmpl/NOTICE b/pkg/tmpl/NOTICE index d0e20ce..b1ae0f8 100644 --- a/pkg/tmpl/NOTICE +++ b/pkg/tmpl/NOTICE @@ -351,12 +351,6 @@ Version: v0.3.3 License: Apache-2.0 License URL: https://github.com/xanzy/ssh-agent/blob/v0.3.3/LICENSE ----------- -Module: github.com/yaml/go-yaml -Version: v2.1.0 -License: Apache-2.0 -License URL: https://github.com/yaml/go-yaml/blob/v2.1.0/LICENSE - ---------- Module: go.yaml.in/yaml/v2 Version: v2.4.3