Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
42 changes: 39 additions & 3 deletions cmd/ctrlc/root/run/exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,60 @@ package exec
import (
"fmt"

"github.com/MakeNowJust/heredoc/v2"
"github.com/charmbracelet/log"
"github.com/ctrlplanedev/cli/internal/api"
"github.com/ctrlplanedev/cli/pkg/jobagent"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

type JobAgentType string

const (
JobAgentTypeLinux JobAgentType = "exec-linux"
JobAgentTypeWindows JobAgentType = "exec-windows"
)

func NewRunExecCmd() *cobra.Command {
return &cobra.Command{
var name string
var jobAgentType string

cmd := &cobra.Command{
Use: "exec",
Short: "Execute commands directly when a job is received",
Example: heredoc.Doc(`
$ ctrlc run exec --name "my-script-agent" --workspace 123e4567-e89b-12d3-a456-426614174000
$ ctrlc run exec --name "my-script-agent" --workspace 123e4567-e89b-12d3-a456-426614174000 --type windows
`),
RunE: func(cmd *cobra.Command, args []string) error {
apiURL := viper.GetString("url")
apiKey := viper.GetString("api-key")
workspaceId := viper.GetString("workspace")
client, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey)
if err != nil {
return fmt.Errorf("failed to create API client: %w", err)
}
if name == "" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could move input validation above the client creation?

return fmt.Errorf("name is required")
}
if workspaceId == "" {
return fmt.Errorf("workspace is required")
}
validTypes := map[string]bool{
string(JobAgentTypeLinux): true,
string(JobAgentTypeWindows): true,
}
if !validTypes[jobAgentType] {
return fmt.Errorf("invalid type: %s. Must be one of: linux, windows", jobAgentType)
}

ja, err := jobagent.NewJobAgent(
client,
api.UpsertJobAgentJSONRequestBody{
Name: "exec",
Type: "exec",
Name: name,
Type: jobAgentType,
WorkspaceId: workspaceId,
},
&ExecRunner{},
)
Expand All @@ -41,4 +72,9 @@ func NewRunExecCmd() *cobra.Command {
return nil
},
}

cmd.Flags().StringVar(&name, "name", "", "Name of the job agent")
cmd.MarkFlagRequired("name")
cmd.Flags().StringVar(&jobAgentType, "type", "exec-linux", "Type of the job agent, defaults to linux")
return cmd
}
29 changes: 28 additions & 1 deletion cmd/ctrlc/root/run/exec/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package exec

import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
Expand All @@ -13,6 +14,7 @@ import (

"github.com/ctrlplanedev/cli/internal/api"
"github.com/ctrlplanedev/cli/pkg/jobagent"
"github.com/spf13/viper"
)

var _ jobagent.Runner = &ExecRunner{}
Expand All @@ -25,6 +27,10 @@ type ExecConfig struct {
}

func (r *ExecRunner) Status(job api.Job) (api.JobStatus, string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the pid is missing is that considered sucessful? what if the pid is now a different process?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its not clear how to keep track of these processes, maybe we assum they are not deamon so if the command closes so does the children. This means we won't need this status check and instead create a goroutine that updates the job when it exists

if job.ExternalId == nil {
return api.JobStatusExternalRunNotFound, fmt.Sprintf("external ID is nil: %v", job.ExternalId)
}

externalId, err := strconv.Atoi(*job.ExternalId)
if err != nil {
return api.JobStatusExternalRunNotFound, fmt.Sprintf("invalid process id: %v", err)
Expand Down Expand Up @@ -67,13 +73,34 @@ func (r *ExecRunner) Start(job api.Job) (string, error) {
return "", fmt.Errorf("failed to unmarshal job agent config: %w", err)
}

client, err := api.NewAPIKeyClientWithResponses(
viper.GetString("url"),
viper.GetString("api-key"),
)
if err != nil {
return "", fmt.Errorf("failed to create API client for job details: %w", err)
}

resp, err := client.GetJobWithResponse(context.Background(), job.Id.String())
if err != nil {
return "", fmt.Errorf("failed to get job details: %w", err)
}

if resp.JSON200 == nil {
return "", fmt.Errorf("received empty response from job details API")
}

var jobDetails map[string]interface{}
detailsBytes, _ := json.Marshal(resp.JSON200)
json.Unmarshal(detailsBytes, &jobDetails)

templatedScript, err := template.New("script").Parse(config.Script)
if err != nil {
return "", fmt.Errorf("failed to parse script template: %w", err)
}

buf := new(bytes.Buffer)
if err := templatedScript.Execute(buf, job); err != nil {
if err := templatedScript.Execute(buf, jobDetails); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid potential code injection in the script.
Passing jobDetails directly into the template and executing as a script can result in code injection if jobDetails includes malicious input. Consider sanitizing or validating fields in jobDetails and config.Script to reduce security risks.

return "", fmt.Errorf("failed to execute script template: %w", err)
}
script := buf.String()
Expand Down
4 changes: 2 additions & 2 deletions pkg/jobagent/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ func NewJobAgent(
}

ja := &JobAgent{
client: client,

client: client,
id: agent.JSON200.Id,
workspaceId: config.WorkspaceId,
runner: runner,
}

return ja, nil
Expand Down