Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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"
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated
)

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
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated
`),
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
Copy Markdown
Contributor

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)
}
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated

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
}
84 changes: 83 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 @@ -24,7 +26,66 @@ type ExecConfig struct {
Script string `json:"script"`
}

type Resource struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Version string `json:"version"`
Identifier string `json:"identifier"`
Config map[string]interface{} `json:"config"`
Metadata map[string]interface{} `json:"metadata"`
WorkspaceID string `json:"workspaceId"`
}

type Release struct {
ID string `json:"id"`
Version string `json:"version"`
Config map[string]interface{} `json:"config"`
Metadata map[string]interface{} `json:"metadata"`
}

type Environment struct {
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated
ID string `json:"id"`
Name string `json:"name"`
}

type Approval struct {
Approver *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"approver"`
}

type Deployment struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
SystemID string `json:"systemId"`
}

type Runbook struct {
ID string `json:"id"`
Name string `json:"name"`
SystemID string `json:"systemId"`
}

type JobData struct {
Variables map[string]string `json:"variables"`
Resource *Resource `json:"resource"`
Release *Release `json:"release"`
Environment *Environment `json:"environment"`
Deployment *Deployment `json:"deployment"`
Runbook *Runbook `json:"runbook"`
Approval *Approval `json:"approval"`
// Add the original config for backward compatibility
Config map[string]interface{} `json:"config"`
}

func (r *ExecRunner) Status(job api.Job) (api.JobStatus, string) {
Copy link
Copy Markdown
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
Copy Markdown
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 +128,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"),
)
Comment thread
zacharyblasczyk marked this conversation as resolved.
Outdated
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)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
Copy Markdown
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