-
Notifications
You must be signed in to change notification settings - Fork 18
feat: implement tfe plannable agent #956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,195 @@ | ||||||||||||||||||||||||||||
| package terraformcloud | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||
| "crypto/sha256" | ||||||||||||||||||||||||||||
| "encoding/hex" | ||||||||||||||||||||||||||||
| "encoding/json" | ||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| "workspace-engine/pkg/jobagents/types" | ||||||||||||||||||||||||||||
| "workspace-engine/pkg/oapi" | ||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const planTimeout = 5 * time.Minute | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| var _ types.Plannable = (*TFCPlanner)(nil) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // WorkspaceSetup handles workspace provisioning for a plan. | ||||||||||||||||||||||||||||
| type WorkspaceSetup interface { | ||||||||||||||||||||||||||||
| Setup(ctx context.Context, dispatchCtx *oapi.DispatchContext) (workspaceID string, err error) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // SpeculativeRunner creates and reads speculative (plan-only) runs. | ||||||||||||||||||||||||||||
| type SpeculativeRunner interface { | ||||||||||||||||||||||||||||
| CreateSpeculativeRun( | ||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||
| cfg *tfeConfig, | ||||||||||||||||||||||||||||
| workspaceID string, | ||||||||||||||||||||||||||||
| ) (runID string, err error) | ||||||||||||||||||||||||||||
| ReadRunStatus(ctx context.Context, cfg *tfeConfig, runID string) (*RunStatus, error) | ||||||||||||||||||||||||||||
| ReadPlanJSON(ctx context.Context, cfg *tfeConfig, planID string) ([]byte, error) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // RunStatus is the information read back from a TFC run. | ||||||||||||||||||||||||||||
| type RunStatus struct { | ||||||||||||||||||||||||||||
| Status string | ||||||||||||||||||||||||||||
| PlanID string | ||||||||||||||||||||||||||||
| ResourceAdditions int | ||||||||||||||||||||||||||||
| ResourceChanges int | ||||||||||||||||||||||||||||
| ResourceDestructions int | ||||||||||||||||||||||||||||
| IsFinished bool | ||||||||||||||||||||||||||||
| IsErrored bool | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| type TFCPlanner struct { | ||||||||||||||||||||||||||||
| workspace WorkspaceSetup | ||||||||||||||||||||||||||||
| runner SpeculativeRunner | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| func NewTFCPlanner(workspace WorkspaceSetup, runner SpeculativeRunner) *TFCPlanner { | ||||||||||||||||||||||||||||
| return &TFCPlanner{workspace: workspace, runner: runner} | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| func (p *TFCPlanner) Type() string { | ||||||||||||||||||||||||||||
| return "tfe" | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| type tfePlanState struct { | ||||||||||||||||||||||||||||
| RunID string `json:"runId"` | ||||||||||||||||||||||||||||
| PollCount int `json:"pollCount"` | ||||||||||||||||||||||||||||
| FirstPolled *time.Time `json:"firstPolled,omitempty"` | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| func (p *TFCPlanner) Plan( | ||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||
| dispatchCtx *oapi.DispatchContext, | ||||||||||||||||||||||||||||
| state json.RawMessage, | ||||||||||||||||||||||||||||
| ) (*types.PlanResult, error) { | ||||||||||||||||||||||||||||
| cfg, err := parseJobAgentConfig(dispatchCtx.JobAgentConfig) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| var s tfePlanState | ||||||||||||||||||||||||||||
| if state != nil { | ||||||||||||||||||||||||||||
| if err := json.Unmarshal(state, &s); err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("unmarshal plan state: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if s.RunID == "" { | ||||||||||||||||||||||||||||
| workspaceID, err := p.workspace.Setup(ctx, dispatchCtx) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("setup workspace: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return p.createRun(ctx, cfg, workspaceID) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return p.pollRun(ctx, cfg, s) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| func (p *TFCPlanner) createRun( | ||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||
| cfg *tfeConfig, | ||||||||||||||||||||||||||||
| workspaceID string, | ||||||||||||||||||||||||||||
| ) (*types.PlanResult, error) { | ||||||||||||||||||||||||||||
| runID, err := p.runner.CreateSpeculativeRun(ctx, cfg, workspaceID) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("create speculative run: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| now := time.Now() | ||||||||||||||||||||||||||||
| s := tfePlanState{ | ||||||||||||||||||||||||||||
| RunID: runID, | ||||||||||||||||||||||||||||
| PollCount: 0, | ||||||||||||||||||||||||||||
| FirstPolled: &now, | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| stateJSON, err := json.Marshal(s) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("marshal plan state: %w", err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return &types.PlanResult{ | ||||||||||||||||||||||||||||
| State: stateJSON, | ||||||||||||||||||||||||||||
| Message: fmt.Sprintf("Speculative run %s created, waiting for plan", runID), | ||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| func (p *TFCPlanner) pollRun( | ||||||||||||||||||||||||||||
| ctx context.Context, | ||||||||||||||||||||||||||||
| cfg *tfeConfig, | ||||||||||||||||||||||||||||
| s tfePlanState, | ||||||||||||||||||||||||||||
| ) (*types.PlanResult, error) { | ||||||||||||||||||||||||||||
| status, err := p.runner.ReadRunStatus(ctx, cfg, s.RunID) | ||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||
| return nil, fmt.Errorf("read run %s: %w", s.RunID, err) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| s.PollCount++ | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if status.IsFinished { | ||||||||||||||||||||||||||||
| return p.completePlan(ctx, cfg, status) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if status.IsErrored { | ||||||||||||||||||||||||||||
| now := time.Now() | ||||||||||||||||||||||||||||
| return &types.PlanResult{ | ||||||||||||||||||||||||||||
| CompletedAt: &now, | ||||||||||||||||||||||||||||
| Message: fmt.Sprintf("Run %s ended with status: %s", s.RunID, status.Status), | ||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if s.FirstPolled != nil && time.Since(*s.FirstPolled) > planTimeout { | ||||||||||||||||||||||||||||
| now := time.Now() | ||||||||||||||||||||||||||||
| return &types.PlanResult{ | ||||||||||||||||||||||||||||
| CompletedAt: &now, | ||||||||||||||||||||||||||||
| Message: fmt.Sprintf( | ||||||||||||||||||||||||||||
| "Run %s timed out after %d polls (%s elapsed), last status: %s", | ||||||||||||||||||||||||||||
| s.RunID, s.PollCount, time.Since(*s.FirstPolled).Round(time.Second), status.Status, | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||
|
Comment on lines
+146
to
+153
|
||||||||||||||||||||||||||||
| now := time.Now() | |
| return &types.PlanResult{ | |
| CompletedAt: &now, | |
| Message: fmt.Sprintf( | |
| "Run %s timed out after %d polls (%s elapsed), last status: %s", | |
| s.RunID, s.PollCount, time.Since(*s.FirstPolled).Round(time.Second), status.Status, | |
| ), | |
| }, nil | |
| elapsed := time.Since(*s.FirstPolled).Round(time.Second) | |
| return nil, fmt.Errorf( | |
| "run %s timed out after %d polls (%s elapsed), last status: %s", | |
| s.RunID, s.PollCount, elapsed, status.Status, | |
| ) |
Copilot
AI
Apr 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
completePlan assumes status.PlanID is populated whenever status.IsFinished is true. In ReadRunStatus, PlanID is only set when run.Plan != nil, so it's possible to reach here with an empty PlanID (e.g., unexpected API response), which will produce a confusing downstream error. Add an explicit check for empty PlanID and return a clear error before calling ReadPlanJSON.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| package terraformcloud | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "github.com/hashicorp/go-tfe" | ||
| "workspace-engine/pkg/oapi" | ||
| ) | ||
|
|
||
| // GoWorkspaceSetup is the production implementation of WorkspaceSetup. | ||
| type GoWorkspaceSetup struct{} | ||
|
|
||
| // Setup provisions the TFC workspace (upsert + variable sync) and returns its ID. | ||
| func (g *GoWorkspaceSetup) Setup( | ||
| ctx context.Context, | ||
| dispatchCtx *oapi.DispatchContext, | ||
| ) (string, error) { | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| cfg, err := parseJobAgentConfig(dispatchCtx.JobAgentConfig) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
|
|
||
| client, err := getClient(cfg.address, cfg.token) | ||
| if err != nil { | ||
| return "", fmt.Errorf("create tfe client: %w", err) | ||
| } | ||
|
|
||
| workspace, err := templateWorkspace(dispatchCtx, cfg.template) | ||
| if err != nil { | ||
| return "", fmt.Errorf("template workspace: %w", err) | ||
| } | ||
|
|
||
| targetWorkspace, err := upsertWorkspace(ctx, client, cfg.organization, workspace) | ||
| if err != nil { | ||
| return "", fmt.Errorf("upsert workspace: %w", err) | ||
| } | ||
|
|
||
| if len(workspace.Variables) > 0 { | ||
| if err := syncVariables(ctx, client, targetWorkspace.ID, workspace.Variables); err != nil { | ||
| return "", fmt.Errorf("sync variables: %w", err) | ||
| } | ||
| } | ||
|
|
||
| return targetWorkspace.ID, nil | ||
| } | ||
|
|
||
| // GoSpeculativeRunner is the production implementation of SpeculativeRunner. | ||
| type GoSpeculativeRunner struct{} | ||
|
|
||
| // CreateSpeculativeRun creates a plan-only run on the given workspace and returns the run ID. | ||
| func (g *GoSpeculativeRunner) CreateSpeculativeRun( | ||
| ctx context.Context, | ||
| cfg *tfeConfig, | ||
| workspaceID string, | ||
| ) (string, error) { | ||
| client, err := getClient(cfg.address, cfg.token) | ||
| if err != nil { | ||
| return "", fmt.Errorf("create tfe client: %w", err) | ||
| } | ||
|
|
||
| planOnly := true | ||
| message := "Speculative plan by ctrlplane" | ||
| run, err := client.Runs.Create(ctx, tfe.RunCreateOptions{ | ||
| Workspace: &tfe.Workspace{ID: workspaceID}, | ||
| PlanOnly: &planOnly, | ||
| Message: &message, | ||
| }) | ||
| if err != nil { | ||
| return "", fmt.Errorf("create speculative run: %w", err) | ||
| } | ||
| return run.ID, nil | ||
| } | ||
|
|
||
| // ReadRunStatus reads the current status of a TFC run and maps it to a RunStatus. | ||
| func (g *GoSpeculativeRunner) ReadRunStatus( | ||
| ctx context.Context, | ||
| cfg *tfeConfig, | ||
| runID string, | ||
| ) (*RunStatus, error) { | ||
| client, err := getClient(cfg.address, cfg.token) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create tfe client: %w", err) | ||
| } | ||
|
|
||
| run, err := client.Runs.Read(ctx, runID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read run: %w", err) | ||
| } | ||
|
|
||
| status := &RunStatus{ | ||
| Status: string(run.Status), | ||
| } | ||
|
|
||
| if run.Plan != nil { | ||
| status.PlanID = run.Plan.ID | ||
| status.ResourceAdditions = run.Plan.ResourceAdditions | ||
| status.ResourceChanges = run.Plan.ResourceChanges | ||
| status.ResourceDestructions = run.Plan.ResourceDestructions | ||
| } | ||
|
|
||
| switch run.Status { | ||
| case tfe.RunPlannedAndFinished: | ||
| status.IsFinished = true | ||
| case tfe.RunErrored, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunPolicySoftFailed: | ||
| status.IsErrored = true | ||
| } | ||
|
Comment on lines
+102
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes. The Terraform Cloud (HCP Terraform) Runs API documents For speculative (plan-only) runs, the documented successful terminal status is Source Citations: 🏁 Script executed: cat -n apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.go | sed -n '95,115p'Repository: ctrlplanedev/ctrlplane Length of output: 798 🏁 Script executed: rg "force_canceled|RunStatus" apps/workspace-engine/pkg/jobagents/terraformcloud/ -A 2 -B 2Repository: ctrlplanedev/ctrlplane Length of output: 8901 🏁 Script executed: rg "import.*tfe\|from.*tfe" apps/workspace-engine/pkg/jobagents/terraformcloud/ -A 3Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: head -30 apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.goRepository: ctrlplanedev/ctrlplane Length of output: 784 🏁 Script executed: rg "tfe\." apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.go | head -20Repository: ctrlplanedev/ctrlplane Length of output: 285 🏁 Script executed: rg "RunCanceled|RunErrored|RunPlannedAndFinished|RunDiscarded|RunPolicySoftFailed" apps/workspace-engine/pkg/jobagents/terraformcloud/ -B 2 -A 2Repository: ctrlplanedev/ctrlplane Length of output: 1738 🏁 Script executed: rg "RunForceCanceled|force_canceled" apps/workspace-engine/ -iRepository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: web_search
hashicorp go-tfe library RunForceCanceled constant or force_canceled statusRepository: ctrlplanedev/ctrlplane Length of output: 164 🌐 Web query:
💡 Result:
However, in the current What to do in Go
References (numbered)
Citations:
🏁 Script executed: rg "const.*Run" apps/workspace-engine/pkg/jobagents/terraformcloud/ -B 2 -A 2Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: rg "type RunStatus\|Status.*tfe\.RunStatus" apps/workspace-engine/pkg/jobagents/terraformcloud/ -A 3Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: rg "\.Status\s*=" apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.go -B 3 -A 1Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: python3 << 'EOF'
# Check the go-tfe package to understand RunStatus type
import subprocess
result = subprocess.run(['rg', 'github.com/hashicorp/go-tfe', 'go.mod', 'go.sum'],
capture_output=True, text=True, cwd='/tmp')
if result.returncode == 0:
print(result.stdout)
else:
print("Not found in go files")
EOFRepository: ctrlplanedev/ctrlplane Length of output: 87 🏁 Script executed: cat -n apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.go | sed -n '80,110p'Repository: ctrlplanedev/ctrlplane Length of output: 1039 🏁 Script executed: rg "run\.Status" apps/workspace-engine/pkg/jobagents/terraformcloud/tfe_plan_client.go -B 5 -A 5Repository: ctrlplanedev/ctrlplane Length of output: 607 Add The switch at line 102 does not handle Terraform Cloud's Proposed fix switch run.Status {
case tfe.RunPlannedAndFinished:
status.IsFinished = true
-case tfe.RunErrored, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunPolicySoftFailed:
+case tfe.RunErrored, tfe.RunCanceled, tfe.RunDiscarded, tfe.RunPolicySoftFailed, tfe.RunStatus("force_canceled"):
status.IsErrored = true
}🤖 Prompt for AI Agents |
||
|
|
||
| return status, nil | ||
| } | ||
|
|
||
| // ReadPlanJSON fetches the JSON output of a completed plan. | ||
| func (g *GoSpeculativeRunner) ReadPlanJSON( | ||
| ctx context.Context, | ||
| cfg *tfeConfig, | ||
| planID string, | ||
| ) ([]byte, error) { | ||
| client, err := getClient(cfg.address, cfg.token) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create tfe client: %w", err) | ||
| } | ||
|
|
||
| data, err := client.Plans.ReadJSONOutput(ctx, planID) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("read plan JSON output: %w", err) | ||
| } | ||
| return data, nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the TFC run ends in an errored/canceled/discarded state, this branch returns a non-nil CompletedAt with a nil error. The deploymentplanresult controller treats any nil error + non-nil CompletedAt as a successful completion and will persist Status=completed, which misclassifies failed plans. Return a non-nil error here (including run ID/status) so the controller records Status=errored (or introduce an explicit failure status in PlanResult and handle it in the controller).