Skip to content

Commit c9d2617

Browse files
committed
wip remote runs
1 parent be058a3 commit c9d2617

32 files changed

+2810
-131
lines changed

taco/internal/api/internal.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,34 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
142142

143143
// Create identifier resolver for TFE org resolution
144144
var tfeIdentifierResolver domain.IdentifierResolver
145+
var runRepo domain.TFERunRepository
146+
var planRepo domain.TFEPlanRepository
147+
var configVerRepo domain.TFEConfigurationVersionRepository
148+
145149
if deps.QueryStore != nil {
146150
if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil {
147151
tfeIdentifierResolver = repositories.NewIdentifierResolver(db)
152+
// Create TFE repositories for runs, plans, and configuration versions
153+
runRepo = repositories.NewTFERunRepository(db)
154+
planRepo = repositories.NewTFEPlanRepository(db)
155+
configVerRepo = repositories.NewTFEConfigurationVersionRepository(db)
156+
log.Println("TFE repositories initialized successfully (internal routes)")
148157
}
149158
}
150159

151160
// Create TFE handler with webhook auth context
152161
// Pass both wrapped (for authenticated calls) and unwrapped (for signed URLs) repositories
153-
tfeHandler := tfe.NewTFETokenHandler(authHandler, deps.Repository, deps.UnwrappedRepository, deps.BlobStore, deps.RBACManager, tfeIdentifierResolver)
162+
tfeHandler := tfe.NewTFETokenHandler(
163+
authHandler,
164+
deps.Repository,
165+
deps.UnwrappedRepository,
166+
deps.BlobStore,
167+
deps.RBACManager,
168+
tfeIdentifierResolver,
169+
runRepo,
170+
planRepo,
171+
configVerRepo,
172+
)
154173

155174
// TFE group with webhook auth (for UI pass-through)
156175
tfeInternal := e.Group("/internal/tfe/api/v2")
@@ -179,11 +198,13 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
179198
tfeInternal.GET("/configuration-versions/:id", tfeHandler.GetConfigurationVersion)
180199
tfeInternal.POST("/runs", tfeHandler.CreateRun)
181200
tfeInternal.GET("/runs/:id", tfeHandler.GetRun)
182-
tfeInternal.GET("/runs/:id/policy-checks", tfeHandler.EmptyListResponse)
183-
tfeInternal.GET("/runs/:id/task-stages", tfeHandler.EmptyListResponse)
184-
tfeInternal.GET("/runs/:id/cost-estimates", tfeHandler.EmptyListResponse)
185-
tfeInternal.GET("/runs/:id/run-events", tfeHandler.EmptyListResponse)
201+
tfeInternal.POST("/runs/:id/actions/apply", tfeHandler.ApplyRun)
202+
tfeInternal.GET("/runs/:id/policy-checks", tfeHandler.GetPolicyChecks)
203+
tfeInternal.GET("/runs/:id/task-stages", tfeHandler.GetTaskStages)
204+
tfeInternal.GET("/runs/:id/cost-estimates", tfeHandler.GetCostEstimates)
205+
tfeInternal.GET("/runs/:id/run-events", tfeHandler.GetRunEvents)
186206
tfeInternal.GET("/plans/:id", tfeHandler.GetPlan)
207+
tfeInternal.GET("/applies/:id", tfeHandler.GetApply)
187208

188209

189210
log.Println("TFE API endpoints registered at /internal/tfe/api/v2 with webhook auth")

taco/internal/api/routes.go

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,32 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) {
250250
// Unwrapped repository is used for signed URL operations (pre-authorized, no RBAC checks needed)
251251
// Create identifier resolver for org resolution
252252
var tfeIdentifierResolver domain.IdentifierResolver
253+
var runRepo domain.TFERunRepository
254+
var planRepo domain.TFEPlanRepository
255+
var configVerRepo domain.TFEConfigurationVersionRepository
256+
253257
if deps.QueryStore != nil {
254258
if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil {
255259
tfeIdentifierResolver = repositories.NewIdentifierResolver(db)
260+
// Create TFE repositories for runs, plans, and configuration versions
261+
runRepo = repositories.NewTFERunRepository(db)
262+
planRepo = repositories.NewTFEPlanRepository(db)
263+
configVerRepo = repositories.NewTFEConfigurationVersionRepository(db)
264+
log.Println("TFE repositories initialized successfully")
256265
}
257266
}
258-
tfeHandler := tfe.NewTFETokenHandler(authHandler, deps.Repository, deps.UnwrappedRepository, deps.BlobStore, deps.RBACManager, tfeIdentifierResolver)
267+
268+
tfeHandler := tfe.NewTFETokenHandler(
269+
authHandler,
270+
deps.Repository,
271+
deps.UnwrappedRepository,
272+
deps.BlobStore,
273+
deps.RBACManager,
274+
tfeIdentifierResolver,
275+
runRepo,
276+
planRepo,
277+
configVerRepo,
278+
)
259279

260280
// Create protected TFE group - opaque tokens only
261281
tfeGroup := e.Group("/tfe/api/v2")
@@ -276,7 +296,24 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) {
276296
tfeGroup.POST("/workspaces/:workspace_id/state-versions", tfeHandler.CreateStateVersion)
277297
tfeGroup.GET("/state-versions/:id", tfeHandler.ShowStateVersion)
278298

279-
tfeGroup.GET("/plans/:planID/logs/:blobId", tfeHandler.GetPlanLogs)
299+
// Configuration version routes
300+
tfeGroup.POST("/workspaces/:workspace_name/configuration-versions", tfeHandler.CreateConfigurationVersions)
301+
tfeGroup.GET("/configuration-versions/:id", tfeHandler.GetConfigurationVersion)
302+
303+
// Run routes
304+
tfeGroup.POST("/runs", tfeHandler.CreateRun)
305+
tfeGroup.GET("/runs/:id", tfeHandler.GetRun)
306+
tfeGroup.POST("/runs/:id/actions/apply", tfeHandler.ApplyRun)
307+
tfeGroup.GET("/runs/:id/policy-checks", tfeHandler.GetPolicyChecks)
308+
tfeGroup.GET("/runs/:id/task-stages", tfeHandler.GetTaskStages)
309+
tfeGroup.GET("/runs/:id/cost-estimates", tfeHandler.GetCostEstimates)
310+
tfeGroup.GET("/runs/:id/run-events", tfeHandler.GetRunEvents)
311+
312+
// Plan routes
313+
tfeGroup.GET("/plans/:id", tfeHandler.GetPlan)
314+
315+
// Apply routes
316+
tfeGroup.GET("/applies/:id", tfeHandler.GetApply)
280317

281318
// Upload endpoints exempt from auth middleware (Terraform doesn't send auth headers)
282319
// Security: These validate lock ownership and have RBAC checks in handlers
@@ -287,6 +324,14 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) {
287324
tfeSignedUrlsGroup.PUT("/state-versions/:id/upload", tfeHandler.UploadStateVersion)
288325
tfeSignedUrlsGroup.PUT("/state-versions/:id/json-upload", tfeHandler.UploadJSONStateOutputs)
289326
tfeSignedUrlsGroup.PUT("/configuration-versions/:id/upload", tfeHandler.UploadConfigurationArchive)
327+
328+
// Plan log streaming - token-based auth (token embedded in path, not query string)
329+
// Security: Time-limited HMAC-signed tokens, Terraform CLI preserves path
330+
e.GET("/tfe/api/v2/plans/:planID/logs/:token", tfeHandler.GetPlanLogs)
331+
332+
// Apply log streaming - token-based auth (token embedded in path, not query string)
333+
// Security: Time-limited HMAC-signed tokens, Terraform CLI preserves path
334+
e.GET("/tfe/api/v2/applies/:applyID/logs/:token", tfeHandler.GetApplyLogs)
290335

291336
// Keep discovery endpoints unprotected (needed for terraform login)
292337
e.GET("/.well-known/terraform.json", tfeHandler.GetWellKnownJson)

taco/internal/auth/signed_url.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"net/url"
99
"strconv"
10+
"strings"
1011
"time"
1112

1213
"github.com/diggerhq/digger/opentaco/internal/config"
@@ -65,4 +66,84 @@ func VerifySignedUrl(signedUrl string) error {
6566
return fmt.Errorf("the signed url is invalid")
6667
}
6768
return nil
69+
}
70+
71+
// GenerateLogStreamToken creates a time-limited token for log streaming
72+
// Format: {expiry-unix}.{base64-hmac-signature}
73+
// This is designed to be embedded in URL paths (not query strings) since
74+
// Terraform CLI preserves paths but strips/replaces query parameters
75+
func GenerateLogStreamToken(planID string, validFor time.Duration) (string, error) {
76+
secret, err := config.GetConfig().GetSecretKey()
77+
if err != nil {
78+
return "", fmt.Errorf("failed to get secret key: %w", err)
79+
}
80+
81+
expiry := time.Now().Add(validFor).Unix()
82+
expiryStr := strconv.FormatInt(expiry, 10)
83+
84+
// Compute HMAC: HMAC(planID + expiry)
85+
mac := hmac.New(sha256.New, []byte(secret))
86+
mac.Write([]byte(planID + expiryStr))
87+
sig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
88+
89+
// Token format: expiry.signature (URL-safe, no special chars)
90+
token := expiryStr + "." + sig
91+
return token, nil
92+
}
93+
94+
// VerifyLogStreamToken verifies a log streaming token for a specific plan
95+
// Token format: {expiry}.{signature} (embedded in URL path, not query string)
96+
func VerifyLogStreamToken(token string, planID string) bool {
97+
secret, err := config.GetConfig().GetSecretKey()
98+
if err != nil {
99+
fmt.Printf("[VerifyLogStreamToken] Failed to get secret key: %v\n", err)
100+
return false
101+
}
102+
103+
// Parse token: expiry.signature (use strings.Split - simpler!)
104+
parts := strings.SplitN(token, ".", 2)
105+
if len(parts) != 2 {
106+
fmt.Printf("[VerifyLogStreamToken] Invalid token format (expected expiry.signature): %s\n", token)
107+
return false
108+
}
109+
110+
expiryStr := parts[0]
111+
sig := parts[1]
112+
113+
fmt.Printf("[VerifyLogStreamToken] planID=%s, expiry=%s, sig=%s...\n", planID, expiryStr, sig[:min(20, len(sig))])
114+
115+
// Check expiry
116+
expiry, err := strconv.ParseInt(expiryStr, 10, 64)
117+
if err != nil {
118+
fmt.Printf("[VerifyLogStreamToken] Failed to parse expiry: %v\n", err)
119+
return false
120+
}
121+
122+
now := time.Now().Unix()
123+
if now > expiry {
124+
fmt.Printf("[VerifyLogStreamToken] Token expired (now=%d, expiry=%d, diff=%d sec)\n", now, expiry, now-expiry)
125+
return false
126+
}
127+
128+
// Verify signature using same HMAC logic as generation
129+
mac := hmac.New(sha256.New, []byte(secret))
130+
mac.Write([]byte(planID + expiryStr))
131+
expectedSig := base64.URLEncoding.EncodeToString(mac.Sum(nil))
132+
133+
isValid := hmac.Equal([]byte(sig), []byte(expectedSig))
134+
if !isValid {
135+
fmt.Printf("[VerifyLogStreamToken] SIGNATURE MISMATCH - expected=%s..., got=%s...\n",
136+
expectedSig[:min(20, len(expectedSig))], sig[:min(20, len(sig))])
137+
} else {
138+
fmt.Printf("[VerifyLogStreamToken] ✓ Token valid for planID=%s\n", planID)
139+
}
140+
141+
return isValid
142+
}
143+
144+
func min(a, b int) int {
145+
if a < b {
146+
return a
147+
}
148+
return b
68149
}

taco/internal/domain/interfaces.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,57 @@ type TFEOperations interface {
6161
StateOperations
6262
}
6363

64+
// TFERunRepository manages TFE run lifecycle
65+
type TFERunRepository interface {
66+
// Create a new run
67+
CreateRun(ctx context.Context, run *TFERun) error
68+
69+
// Get run by ID
70+
GetRun(ctx context.Context, runID string) (*TFERun, error)
71+
72+
// List runs for a unit (workspace)
73+
ListRunsForUnit(ctx context.Context, unitID string, limit int) ([]*TFERun, error)
74+
75+
// Update run status
76+
UpdateRunStatus(ctx context.Context, runID string, status string) error
77+
78+
// Update run with plan ID
79+
UpdateRunPlanID(ctx context.Context, runID string, planID string) error
80+
81+
// Update run status and can_apply together
82+
UpdateRunStatusAndCanApply(ctx context.Context, runID string, status string, canApply bool) error
83+
}
84+
85+
// TFEPlanRepository manages TFE plan lifecycle
86+
type TFEPlanRepository interface {
87+
// Create a new plan
88+
CreatePlan(ctx context.Context, plan *TFEPlan) error
89+
90+
// Get plan by ID
91+
GetPlan(ctx context.Context, planID string) (*TFEPlan, error)
92+
93+
// Update plan status and results
94+
UpdatePlan(ctx context.Context, planID string, updates *TFEPlanUpdate) error
95+
96+
// Get plan by run ID
97+
GetPlanByRunID(ctx context.Context, runID string) (*TFEPlan, error)
98+
}
99+
100+
// TFEConfigurationVersionRepository manages configuration versions
101+
type TFEConfigurationVersionRepository interface {
102+
// Create a new configuration version
103+
CreateConfigurationVersion(ctx context.Context, cv *TFEConfigurationVersion) error
104+
105+
// Get configuration version by ID
106+
GetConfigurationVersion(ctx context.Context, cvID string) (*TFEConfigurationVersion, error)
107+
108+
// Update configuration version status (and optionally the archive blob ID)
109+
UpdateConfigurationVersionStatus(ctx context.Context, cvID string, status string, uploadedAt *time.Time, archiveBlobID *string) error
110+
111+
// List configuration versions for a unit (workspace)
112+
ListConfigurationVersionsForUnit(ctx context.Context, unitID string, limit int) ([]*TFEConfigurationVersion, error)
113+
}
114+
64115
// ============================================
65116
// Full Repository Interface
66117
// ============================================
@@ -156,3 +207,82 @@ func DecodeUnitID(encoded string) string {
156207
return NormalizeUnitID(encoded)
157208
}
158209

210+
// ============================================
211+
// TFE Domain Models
212+
// ============================================
213+
214+
// TFERun represents a Terraform run (plan/apply execution)
215+
type TFERun struct {
216+
ID string
217+
OrgID string
218+
UnitID string
219+
CreatedAt time.Time
220+
UpdatedAt time.Time
221+
Status string
222+
IsDestroy bool
223+
Message string
224+
PlanOnly bool
225+
AutoApply bool // Whether to auto-trigger apply after successful plan
226+
Source string
227+
IsCancelable bool
228+
CanApply bool
229+
ConfigurationVersionID string
230+
PlanID *string
231+
ApplyID *string
232+
CreatedBy string
233+
ApplyLogBlobID *string
234+
}
235+
236+
// TFEPlan represents a Terraform plan execution
237+
type TFEPlan struct {
238+
ID string
239+
OrgID string
240+
RunID string
241+
CreatedAt time.Time
242+
UpdatedAt time.Time
243+
Status string
244+
ResourceAdditions int
245+
ResourceChanges int
246+
ResourceDestructions int
247+
HasChanges bool
248+
LogBlobID *string
249+
LogReadURL *string
250+
PlanOutputBlobID *string
251+
PlanOutputJSON *string
252+
CreatedBy string
253+
}
254+
255+
// TFEPlanUpdate contains fields that can be updated on a plan
256+
type TFEPlanUpdate struct {
257+
Status *string
258+
ResourceAdditions *int
259+
ResourceChanges *int
260+
ResourceDestructions *int
261+
HasChanges *bool
262+
LogBlobID *string
263+
LogReadURL *string
264+
PlanOutputBlobID *string
265+
PlanOutputJSON *string
266+
}
267+
268+
// TFEConfigurationVersion represents an uploaded Terraform configuration
269+
type TFEConfigurationVersion struct {
270+
ID string
271+
OrgID string
272+
UnitID string
273+
CreatedAt time.Time
274+
UpdatedAt time.Time
275+
Status string
276+
Source string
277+
Speculative bool
278+
AutoQueueRuns bool
279+
Provisional bool
280+
Error *string
281+
ErrorMessage *string
282+
UploadURL *string
283+
UploadedAt *time.Time
284+
ArchiveBlobID *string
285+
StatusTimestamps string
286+
CreatedBy string
287+
}
288+

taco/internal/domain/tfe/apply.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package tfe
2+
3+
// ApplyRecord represents a Terraform apply operation
4+
type ApplyRecord struct {
5+
ID string `jsonapi:"primary,applies" json:"id"`
6+
Status string `jsonapi:"attr,status" json:"status"`
7+
LogReadURL string `jsonapi:"attr,log-read-url" json:"log-read-url"`
8+
9+
// Relationship to run (RunRef is declared in plan.go)
10+
Run *RunRef `jsonapi:"relation,run,omitempty" json:"run,omitempty"`
11+
}
12+

taco/internal/domain/tfe/configuration-versions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type ConfigurationVersionRecord struct {
1818
StatusTimestamps map[string]string `jsonapi:"attr,status-timestamps" json:"status-timestamps"`
1919
UploadURL *string `jsonapi:"attr,upload-url" json:"upload-url"`
2020
Provisional bool `jsonapi:"attr,provisional" json:"provisional"`
21-
IngressAttributes *IngressAttributesStub `jsonapi:"relation,ingress-attributes" json:"ingress-attributes"`
21+
// IngressAttributes omitted - not used in remote execution mode
2222
}
2323

2424

0 commit comments

Comments
 (0)