Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions api/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions cli/cmd/curl.go
Original file line number Diff line number Diff line change
@@ -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
}
103 changes: 103 additions & 0 deletions cli/cmd/curl_test.go
Original file line number Diff line number Diff line change
@@ -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"))
})
})
})
2 changes: 1 addition & 1 deletion cli/cmd/list_workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <team-id>", Desc: "List all workspaces"},
{Cmd: "-t <team-id>", Desc: "List all workspaces"},
}),
},
Opts: opts,
Expand Down
2 changes: 2 additions & 0 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ func GetRootCmd() *cobra.Command {
AddSyncCmd(rootCmd, &opts)
AddUpdateCmd(rootCmd)
AddGoCmd(rootCmd)
AddWakeUpCmd(rootCmd, opts)
AddCurlCmd(rootCmd, opts)

return rootCmd
}
Expand Down
Loading
Loading