diff --git a/.env.compose b/.env.compose index 26098dd041..cb6cb7640b 100644 --- a/.env.compose +++ b/.env.compose @@ -33,5 +33,8 @@ GITHUB_BOT_CLIENT_SECRET= GITHUB_BOT_PRIVATE_KEY= GITHUB_WEBHOOK_SECRET= +# TFE (optional) +TFE_WEBHOOK_SECRET= # openssl rand -hex 32 + ROUTER_URL=http://workspace-engine-router:9091 WORKSPACE_ENGINE_URL=http://workspace-engine:9090 diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index f5b6547e3f..e9fcbfe5b7 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -32,6 +32,7 @@ export const env = createEnv({ GITHUB_BOT_APP_ID: z.string().optional(), GITHUB_BOT_PRIVATE_KEY: z.string().optional(), GITHUB_WEBHOOK_SECRET: z.string().optional(), + TFE_WEBHOOK_SECRET: z.string().optional(), BASE_URL: z.string().optional(), diff --git a/apps/api/src/routes/tfe/__tests__/run_notification.test.ts b/apps/api/src/routes/tfe/__tests__/run_notification.test.ts new file mode 100644 index 0000000000..fb0e8c448f --- /dev/null +++ b/apps/api/src/routes/tfe/__tests__/run_notification.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import { mapTriggerToStatus } from "../run_notification.js"; + +describe("mapTriggerToStatus", () => { + it.each([ + ["run:created", JobStatus.Pending], + ["run:planning", JobStatus.InProgress], + ["run:needs_attention", JobStatus.ActionRequired], + ["run:applying", JobStatus.InProgress], + ["run:completed", JobStatus.Successful], + ["run:errored", JobStatus.Failure], + ])("maps trigger %s to %s", (trigger, expected) => { + expect(mapTriggerToStatus(trigger)).toBe(expected); + }); + + it("returns null for unknown triggers", () => { + expect(mapTriggerToStatus("run:unknown")).toBeNull(); + expect(mapTriggerToStatus("")).toBeNull(); + expect(mapTriggerToStatus("something:else")).toBeNull(); + }); +}); diff --git a/apps/api/src/routes/tfe/__tests__/webhook_router.test.ts b/apps/api/src/routes/tfe/__tests__/webhook_router.test.ts new file mode 100644 index 0000000000..4ba6f59a55 --- /dev/null +++ b/apps/api/src/routes/tfe/__tests__/webhook_router.test.ts @@ -0,0 +1,164 @@ +import crypto from "node:crypto"; +import type { Request, Response } from "express"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock config before importing the module under test +vi.mock("@/config.js", () => ({ + env: { TFE_WEBHOOK_SECRET: "test-secret-123" }, +})); + +vi.mock("@ctrlplane/logger", () => ({ + logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})); + +const mockHandleRunNotification = vi.fn().mockResolvedValue(undefined); +vi.mock("../run_notification.js", () => ({ + handleRunNotification: (...args: unknown[]) => + mockHandleRunNotification(...args), +})); + +import { createTfeRouter } from "../index.js"; + +function signPayload(body: object, secret: string): string { + const json = JSON.stringify(body); + return crypto.createHmac("sha512", secret).update(json).digest("hex"); +} + +function makeMockRes() { + const res = { statusCode: 200, _json: null as unknown }; + return Object.assign(res, { + status: (code: number) => { + res.statusCode = code; + return res; + }, + json: (data: unknown) => { + res._json = data; + return res; + }, + }) as typeof res & Response; +} + +function getWebhookHandler() { + const router = createTfeRouter(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const layer = (router as any).stack.find( + (l: any) => l.route?.path === "/webhook" && l.route?.methods?.post, + ); + if (!layer) throw new Error("POST /webhook route not found on router"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const handlers = layer.route.stack.filter( + (s: any) => s.method === "post", + ); + return handlers[handlers.length - 1].handle as ( + req: Request, + res: Response, + ) => Promise; +} + +describe("TFE webhook router", () => { + let handler: (req: Request, res: Response) => Promise; + + beforeEach(() => { + handler = getWebhookHandler(); + vi.clearAllMocks(); + }); + + const payload = { + payload_version: 1, + notification_configuration_id: "nc-test", + run_url: "https://app.terraform.io/runs/run-abc", + run_id: "run-abc", + run_message: "test", + run_created_at: "2024-01-01T00:00:00Z", + run_created_by: "user", + workspace_id: "ws-test", + workspace_name: "test-ws", + organization_name: "org", + notifications: [ + { + message: "Applied", + trigger: "run:completed", + run_status: "applied", + run_updated_at: "2024-01-01T00:01:00Z", + run_updated_by: "user", + }, + ], + }; + + it("returns 200 and calls handler with valid signature", async () => { + const signature = signPayload(payload, "test-secret-123"); + const req = { + headers: { "x-tfe-notification-signature": signature }, + body: payload, + } as unknown as Request; + const res = makeMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect((res as any)._json).toEqual({ message: "OK" }); + expect(mockHandleRunNotification).toHaveBeenCalledOnce(); + expect(mockHandleRunNotification).toHaveBeenCalledWith(payload); + }); + + it("returns 401 with missing signature header", async () => { + const req = { + headers: {}, + body: payload, + } as unknown as Request; + const res = makeMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect((res as any)._json).toEqual({ message: "Unauthorized" }); + expect(mockHandleRunNotification).not.toHaveBeenCalled(); + }); + + it("returns 401 with wrong signature", async () => { + const req = { + headers: { + "x-tfe-notification-signature": "deadbeef".repeat(16), + }, + body: payload, + } as unknown as Request; + const res = makeMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(401); + expect(mockHandleRunNotification).not.toHaveBeenCalled(); + }); + + it("returns 200 without calling handler when notifications is empty", async () => { + const emptyPayload = { ...payload, notifications: [] }; + const signature = signPayload(emptyPayload, "test-secret-123"); + const req = { + headers: { "x-tfe-notification-signature": signature }, + body: emptyPayload, + } as unknown as Request; + const res = makeMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(mockHandleRunNotification).not.toHaveBeenCalled(); + }); + + it("returns 500 when handler throws", async () => { + mockHandleRunNotification.mockRejectedValueOnce( + new Error("db connection lost"), + ); + const signature = signPayload(payload, "test-secret-123"); + const req = { + headers: { "x-tfe-notification-signature": signature }, + body: payload, + } as unknown as Request; + const res = makeMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect((res as any)._json).toEqual({ message: "db connection lost" }); + }); +}); diff --git a/apps/api/src/routes/tfe/index.ts b/apps/api/src/routes/tfe/index.ts new file mode 100644 index 0000000000..0aeebc201c --- /dev/null +++ b/apps/api/src/routes/tfe/index.ts @@ -0,0 +1,49 @@ +import type { Request, Response } from "express"; +import crypto from "node:crypto"; +import { env } from "@/config.js"; +import { Router } from "express"; + +import { logger } from "@ctrlplane/logger"; + +import { handleRunNotification } from "./run_notification.js"; + +export const createTfeRouter = (): Router => + Router().post("/webhook", handleWebhookRequest); + +const verifySignature = (req: Request): boolean => { + const secret = env.TFE_WEBHOOK_SECRET; + if (secret == null) return false; + + const signature = req.headers["x-tfe-notification-signature"]?.toString(); + if (signature == null) return false; + + const body = JSON.stringify(req.body); + const expected = crypto + .createHmac("sha512", secret) + .update(body) + .digest("hex"); + + const sigBuf = Buffer.from(signature, "hex"); + const expBuf = Buffer.from(expected, "hex"); + if (sigBuf.length !== expBuf.length) return false; + return crypto.timingSafeEqual(sigBuf, expBuf); +}; + +const handleWebhookRequest = async (req: Request, res: Response) => { + try { + if (!verifySignature(req)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const payload = req.body; + if (payload.notifications != null && payload.notifications.length > 0) + await handleRunNotification(payload); + + res.status(200).json({ message: "OK" }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + logger.error(message); + res.status(500).json({ message }); + } +}; diff --git a/apps/api/src/routes/tfe/run_notification.ts b/apps/api/src/routes/tfe/run_notification.ts new file mode 100644 index 0000000000..363317161f --- /dev/null +++ b/apps/api/src/routes/tfe/run_notification.ts @@ -0,0 +1,117 @@ +import { eq, sql, takeFirstOrNull } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import { enqueueAllReleaseTargetsDesiredVersion } from "@ctrlplane/db/reconcilers"; +import * as schema from "@ctrlplane/db/schema"; +import { logger } from "@ctrlplane/logger"; +import { ReservedMetadataKey } from "@ctrlplane/validators/conditions"; +import { exitedStatus, JobStatus } from "@ctrlplane/validators/jobs"; + +/** + * TFC notification trigger → ctrlplane job status. + * https://developer.hashicorp.com/terraform/cloud-docs/workspaces/settings/notifications#notification-triggers + */ +const triggerStatusMap: Record = { + "run:created": JobStatus.Pending, + "run:planning": JobStatus.InProgress, + "run:needs_attention": JobStatus.ActionRequired, + "run:applying": JobStatus.InProgress, + "run:completed": JobStatus.Successful, + "run:errored": JobStatus.Failure, +}; + +export const mapTriggerToStatus = (trigger: string): JobStatus | null => + triggerStatusMap[trigger] ?? null; + +const uuidRegex = + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/; + +/** + * Extract the ctrlplane job ID from the TFC run message. + * The dispatcher sets: "Triggered by ctrlplane job " + */ +const extractJobId = (runMessage: string): string | null => { + const match = uuidRegex.exec(runMessage); + return match ? match[0] : null; +}; + +export const handleRunNotification = async (payload: { + run_url: string; + run_id: string; + run_message: string; + workspace_name: string; + organization_name: string; + notifications: Array<{ message: string; trigger: string }>; +}) => { + if (payload.notifications.length === 0) return; + + const notification = payload.notifications[0]!; + const status = mapTriggerToStatus(notification.trigger); + if (status == null) { + logger.warn("Unknown TFC notification trigger, ignoring", { + trigger: notification.trigger, + }); + return; + } + + const jobId = extractJobId(payload.run_message); + if (jobId == null) return; + + const now = new Date(); + const isCompleted = exitedStatus.includes(status); + const isInProgress = status === JobStatus.InProgress; + + const [updated] = await db + .update(schema.job) + .set({ + externalId: payload.run_id, + status, + updatedAt: now, + message: notification.message, + ...(isInProgress + ? { startedAt: sql`COALESCE(${schema.job.startedAt}, ${now})` } + : {}), + ...(isCompleted ? { completedAt: now } : {}), + }) + .where(eq(schema.job.id, jobId)) + .returning(); + + if (updated == null) return; + + // Derive workspace URL from run_url (works for both TFC and TFE) + const runUrlParts = payload.run_url.split("/runs/"); + const workspaceUrl = runUrlParts[0] ?? payload.run_url; + const links = JSON.stringify({ + Run: payload.run_url, + Workspace: workspaceUrl, + }); + const metadataEntries = [ + { jobId, key: String(ReservedMetadataKey.Links), value: links }, + { jobId, key: "run_url", value: payload.run_url }, + ]; + + for (const entry of metadataEntries) + await db + .insert(schema.jobMetadata) + .values(entry) + .onConflictDoUpdate({ + target: [schema.jobMetadata.key, schema.jobMetadata.jobId], + set: { value: entry.value }, + }); + + const result = await db + .select({ workspaceId: schema.deployment.workspaceId }) + .from(schema.releaseJob) + .innerJoin( + schema.release, + eq(schema.releaseJob.releaseId, schema.release.id), + ) + .innerJoin( + schema.deployment, + eq(schema.release.deploymentId, schema.deployment.id), + ) + .where(eq(schema.releaseJob.jobId, jobId)) + .then(takeFirstOrNull); + + if (result?.workspaceId != null) + enqueueAllReleaseTargetsDesiredVersion(db, result.workspaceId); +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a247086435..6003c7eb2f 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -17,6 +17,7 @@ import { appRouter, createTRPCContext } from "@ctrlplane/trpc"; import swaggerDocument from "../openapi/openapi.json" with { type: "json" }; import { createGithubRouter } from "./routes/github/index.js"; +import { createTfeRouter } from "./routes/tfe/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -25,7 +26,7 @@ const specFile = join(__dirname, "../openapi/openapi.json"); const oapiValidatorMiddleware = OpenApiValidator.middleware({ apiSpec: specFile, validateRequests: true, - ignorePaths: /\/api\/(auth|trpc|github|ui|healthz)/, + ignorePaths: /\/api\/(auth|trpc|github|tfe|ui|healthz)/, }); const trpcMiddleware = trpcExpress.createExpressMiddleware({ @@ -79,6 +80,7 @@ const app = express() .use("/api/v1", requireAuth) .use("/api/v1", createV1Router()) .use("/api/github", createGithubRouter()) + .use("/api/tfe", createTfeRouter()) .use("/api/trpc", trpcMiddleware) .use(errorHandler); diff --git a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet index bacafdc48a..64437deb64 100644 --- a/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/jobs.jsonnet @@ -144,12 +144,14 @@ local JobPropertyKeys = std.objectFields(Job.properties); TerraformCloudJobAgentConfig: { type: 'object', - required: ['address', 'organization', 'token', 'template'], + required: ['address', 'organization', 'token', 'template', 'webhookUrl'], properties: { address: { type: 'string', description: 'Terraform Cloud address (e.g. https://app.terraform.io).' }, organization: { type: 'string', description: 'Terraform Cloud organization name.' }, token: { type: 'string', description: 'Terraform Cloud API token.' }, template: { type: 'string', description: 'Terraform Cloud workspace template.' }, + webhookUrl: { type: 'string', description: 'The ctrlplane API endpoint for TFC webhook notifications (e.g. https://ctrlplane.example.com/api/tfe/webhook).' }, + triggerRunOnChange: { type: 'boolean', default: true, description: 'Whether to create a TFC run on dispatch. When false, only the workspace and variables are synced.' }, }, }, diff --git a/apps/workspace-engine/package.json b/apps/workspace-engine/package.json index 4882fb8502..906fffd813 100644 --- a/apps/workspace-engine/package.json +++ b/apps/workspace-engine/package.json @@ -2,7 +2,7 @@ "name": "@ctrlplane/workspace-engine", "scripts": { "dev": "air", - "build": "go build -o ./bin/workspace-engine main.go", + "build": "go build -o ./bin/workspace-engine .", "lint": "bash -c 'golangci-lint run --allow-parallel-runners'", "lint:fix": "golangci-lint run --fix", "start": "./bin/workspace-engine", diff --git a/apps/workspace-engine/svc/controllers/jobdispatch/controller.go b/apps/workspace-engine/svc/controllers/jobdispatch/controller.go index 7947bb1c72..6d7ae6e4ee 100644 --- a/apps/workspace-engine/svc/controllers/jobdispatch/controller.go +++ b/apps/workspace-engine/svc/controllers/jobdispatch/controller.go @@ -18,6 +18,7 @@ import ( "workspace-engine/svc/controllers/jobdispatch/jobagents" "workspace-engine/svc/controllers/jobdispatch/jobagents/argo" "workspace-engine/svc/controllers/jobdispatch/jobagents/github" + "workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud" "workspace-engine/svc/controllers/jobdispatch/jobagents/testrunner" ) @@ -115,6 +116,7 @@ func New(workerID string, pgxPool *pgxpool.Pool) *reconcile.Worker { dispatcher.Register( github.New(&github.GoGitHubWorkflowDispatcher{}, pgSetter), ) + dispatcher.Register(terraformcloud.New(pgSetter)) maxConcurrency := config.GetMaxConcurrency(kind) log.Debug( diff --git a/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/config.go b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/config.go new file mode 100644 index 0000000000..49635bba28 --- /dev/null +++ b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/config.go @@ -0,0 +1,74 @@ +package terraformcloud + +import ( + "fmt" + + "github.com/hashicorp/go-tfe" + "workspace-engine/pkg/oapi" +) + +type tfeConfig struct { + address string + token string + organization string + template string + webhookUrl string + triggerRunOnChange bool +} + +func parseJobAgentConfig(jobAgentConfig oapi.JobAgentConfig) (*tfeConfig, error) { + address, ok := jobAgentConfig["address"].(string) + if !ok { + return nil, fmt.Errorf("address is required") + } + token, ok := jobAgentConfig["token"].(string) + if !ok { + return nil, fmt.Errorf("token is required") + } + organization, ok := jobAgentConfig["organization"].(string) + if !ok { + return nil, fmt.Errorf("organization is required") + } + template, ok := jobAgentConfig["template"].(string) + if !ok { + return nil, fmt.Errorf("template is required") + } + if address == "" || token == "" || organization == "" || template == "" { + return nil, fmt.Errorf("missing required fields in job agent config") + } + + webhookUrl, ok := jobAgentConfig["webhookUrl"].(string) + if !ok || webhookUrl == "" { + return nil, fmt.Errorf("webhookUrl is required") + } + + triggerRunOnChange := true + if v, ok := jobAgentConfig["triggerRunOnChange"]; ok { + switch val := v.(type) { + case bool: + triggerRunOnChange = val + case string: + triggerRunOnChange = val != "false" + } + } + + return &tfeConfig{ + address: address, + token: token, + organization: organization, + template: template, + webhookUrl: webhookUrl, + triggerRunOnChange: triggerRunOnChange, + }, nil +} + +func getClient(address, token string) (*tfe.Client, error) { + client, err := tfe.NewClient(&tfe.Config{ + Address: address, + Token: token, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Terraform Cloud client: %w", err) + } + return client, nil +} diff --git a/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe.go b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe.go new file mode 100644 index 0000000000..7e4941b882 --- /dev/null +++ b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe.go @@ -0,0 +1,113 @@ +package terraformcloud + +import ( + "context" + "fmt" + "os" + + "github.com/charmbracelet/log" + "workspace-engine/pkg/oapi" + "workspace-engine/svc/controllers/jobdispatch/jobagents/types" +) + +var _ types.Dispatchable = (*TFE)(nil) + +const notificationConfigName = "ctrlplane-webhook" + +// Setter persists job status updates. +type Setter interface { + UpdateJob( + ctx context.Context, + jobID string, + status oapi.JobStatus, + message string, + metadata map[string]string, + ) error +} + +type TFE struct { + setter Setter +} + +func New(setter Setter) *TFE { + return &TFE{setter: setter} +} + +func (t *TFE) Type() string { + return "tfe" +} + +func (t *TFE) Dispatch(ctx context.Context, job *oapi.Job) error { + dispatchCtx := job.DispatchContext + cfg, err := parseJobAgentConfig(dispatchCtx.JobAgentConfig) + if err != nil { + return fmt.Errorf("failed to parse job agent config: %w", err) + } + + workspace, err := templateWorkspace(job.DispatchContext, cfg.template) + if err != nil { + return fmt.Errorf("failed to generate workspace from template: %w", err) + } + + client, err := getClient(cfg.address, cfg.token) + if err != nil { + t.updateJobStatus(ctx, job.Id, oapi.JobStatusFailure, + fmt.Sprintf("failed to create Terraform Cloud client: %s", err.Error()), nil) + return fmt.Errorf("failed to create Terraform Cloud client: %w", err) + } + + targetWorkspace, err := upsertWorkspace(ctx, client, cfg.organization, workspace) + if err != nil { + t.updateJobStatus(ctx, job.Id, oapi.JobStatusFailure, + fmt.Sprintf("failed to upsert workspace: %s", err.Error()), nil) + return fmt.Errorf("failed to upsert workspace: %w", err) + } + + if len(workspace.Variables) > 0 { + if err := syncVariables(ctx, client, targetWorkspace.ID, workspace.Variables); err != nil { + t.updateJobStatus(ctx, job.Id, oapi.JobStatusFailure, + fmt.Sprintf("failed to sync variables: %s", err.Error()), nil) + return fmt.Errorf("failed to sync variables: %w", err) + } + } + + webhookSecret := os.Getenv("TFE_WEBHOOK_SECRET") + if err := ensureNotificationConfig( + ctx, + client, + targetWorkspace.ID, + cfg.webhookUrl, + webhookSecret, + ); err != nil { + log.Warn("Failed to ensure notification config, continuing dispatch", "error", err) + } + + if !cfg.triggerRunOnChange { + t.updateJobStatus(ctx, job.Id, oapi.JobStatusInProgress, + "Workspace synced, waiting for VCS-triggered run", nil) + return nil + } + + _, err = createRun(ctx, client, targetWorkspace.ID, job.Id) + if err != nil { + t.updateJobStatus(ctx, job.Id, oapi.JobStatusFailure, + fmt.Sprintf("failed to create run: %s", err.Error()), nil) + return fmt.Errorf("failed to create run: %w", err) + } + + t.updateJobStatus(ctx, job.Id, oapi.JobStatusInProgress, + "Run created, webhook will track status", nil) + return nil +} + +func (t *TFE) updateJobStatus( + ctx context.Context, + jobID string, + status oapi.JobStatus, + message string, + metadata map[string]string, +) { + if err := t.setter.UpdateJob(ctx, jobID, status, message, metadata); err != nil { + log.Error("Failed to update job status", "jobID", jobID, "error", err) + } +} diff --git a/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe_test.go b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe_test.go new file mode 100644 index 0000000000..771541a43b --- /dev/null +++ b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/tfe_test.go @@ -0,0 +1,315 @@ +package terraformcloud + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/go-tfe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "workspace-engine/pkg/oapi" +) + +// ===== parseJobAgentConfig ===== + +func TestParseJobAgentConfig_Valid(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "my-token", + "organization": "my-org", + "template": "name: {{ .Resource.Name }}", + "webhookUrl": "https://ctrlplane.example.com/api/tfe/webhook", + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.Equal(t, "https://app.terraform.io", parsed.address) + assert.Equal(t, "my-token", parsed.token) + assert.Equal(t, "my-org", parsed.organization) + assert.Equal(t, "name: {{ .Resource.Name }}", parsed.template) + assert.Equal(t, "https://ctrlplane.example.com/api/tfe/webhook", parsed.webhookUrl) + assert.True(t, parsed.triggerRunOnChange) +} + +func TestParseJobAgentConfig_MissingWebhookUrl(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "my-token", + "organization": "my-org", + "template": "name: foo", + } + _, err := parseJobAgentConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "webhookUrl is required") +} + +func TestParseJobAgentConfig_WithWebhookUrl(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "my-token", + "organization": "my-org", + "template": "name: foo", + "webhookUrl": "https://ctrlplane.example.com/api/tfe/webhook", + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.Equal(t, "https://ctrlplane.example.com/api/tfe/webhook", parsed.webhookUrl) +} + +func TestParseJobAgentConfig_TriggerRunOnChange(t *testing.T) { + t.Run("defaults to true", func(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "t", + "organization": "o", + "template": "t", + "webhookUrl": "https://example.com/api/tfe/webhook", + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.True(t, parsed.triggerRunOnChange) + }) + + t.Run("bool false", func(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "t", + "organization": "o", + "template": "t", + "webhookUrl": "https://example.com/api/tfe/webhook", + "triggerRunOnChange": false, + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.False(t, parsed.triggerRunOnChange) + }) + + t.Run("string false", func(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "t", + "organization": "o", + "template": "t", + "webhookUrl": "https://example.com/api/tfe/webhook", + "triggerRunOnChange": "false", + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.False(t, parsed.triggerRunOnChange) + }) + + t.Run("bool true", func(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "https://app.terraform.io", + "token": "t", + "organization": "o", + "template": "t", + "webhookUrl": "https://example.com/api/tfe/webhook", + "triggerRunOnChange": true, + } + parsed, err := parseJobAgentConfig(cfg) + require.NoError(t, err) + assert.True(t, parsed.triggerRunOnChange) + }) +} + +func TestParseJobAgentConfig_MissingFields(t *testing.T) { + tests := []struct { + name string + cfg oapi.JobAgentConfig + }{ + { + "missing address", + oapi.JobAgentConfig{"token": "t", "organization": "o", "template": "t"}, + }, + { + "missing token", + oapi.JobAgentConfig{"address": "a", "organization": "o", "template": "t"}, + }, + { + "missing organization", + oapi.JobAgentConfig{"address": "a", "token": "t", "template": "t"}, + }, + { + "missing template", + oapi.JobAgentConfig{"address": "a", "token": "t", "organization": "o"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseJobAgentConfig(tt.cfg) + require.Error(t, err) + }) + } +} + +func TestParseJobAgentConfig_EmptyValues(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": "", + "token": "my-token", + "organization": "my-org", + "template": "name: foo", + } + _, err := parseJobAgentConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required fields") +} + +func TestParseJobAgentConfig_WrongType(t *testing.T) { + cfg := oapi.JobAgentConfig{ + "address": 123, + "token": "my-token", + "organization": "my-org", + "template": "name: foo", + } + _, err := parseJobAgentConfig(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "address is required") +} + +// ===== toCreateOptions / toUpdateOptions — workspace template ===== + +func TestWorkspaceTemplate_ToCreateOptions(t *testing.T) { + ws := &WorkspaceTemplate{ + Name: "my-workspace", + Description: "test desc", + AutoApply: true, + TerraformVersion: "1.5.0", + ExecutionMode: "remote", + AgentPoolID: "apool-123", + Project: "prj-abc", + WorkingDirectory: "infra/", + VCSRepo: &VCSRepoTemplate{ + Identifier: "org/repo", + Branch: "main", + OAuthTokenID: "ot-123", + }, + } + opts := ws.toCreateOptions() + + assert.Equal(t, "my-workspace", *opts.Name) + assert.Equal(t, "test desc", *opts.Description) + assert.True(t, *opts.AutoApply) + assert.Equal(t, "1.5.0", *opts.TerraformVersion) + assert.Equal(t, "remote", *opts.ExecutionMode) + assert.Equal(t, "apool-123", *opts.AgentPoolID) + assert.Equal(t, "prj-abc", opts.Project.ID) + assert.Equal(t, "infra/", *opts.WorkingDirectory) + require.NotNil(t, opts.VCSRepo) + assert.Equal(t, "org/repo", *opts.VCSRepo.Identifier) + assert.Equal(t, "main", *opts.VCSRepo.Branch) + assert.Equal(t, "ot-123", *opts.VCSRepo.OAuthTokenID) +} + +func TestWorkspaceTemplate_ToCreateOptions_Minimal(t *testing.T) { + ws := &WorkspaceTemplate{Name: "bare"} + opts := ws.toCreateOptions() + assert.Equal(t, "bare", *opts.Name) + assert.Nil(t, opts.ExecutionMode) + assert.Nil(t, opts.TerraformVersion) + assert.Nil(t, opts.Project) + assert.Empty(t, opts.AgentPoolID) + assert.Nil(t, opts.VCSRepo) +} + +func TestWorkspaceTemplate_ToUpdateOptions(t *testing.T) { + ws := &WorkspaceTemplate{ + Name: "updated-ws", + Description: "updated", + AutoApply: false, + TerraformVersion: "1.6.0", + ExecutionMode: "agent", + AgentPoolID: "apool-456", + VCSRepo: &VCSRepoTemplate{ + Identifier: "org/repo2", + Branch: "develop", + OAuthTokenID: "ot-456", + }, + } + opts := ws.toUpdateOptions() + + assert.Equal(t, "updated-ws", *opts.Name) + assert.Equal(t, "updated", *opts.Description) + assert.False(t, *opts.AutoApply) + assert.Equal(t, "1.6.0", *opts.TerraformVersion) + assert.Equal(t, "agent", *opts.ExecutionMode) + assert.Equal(t, "apool-456", *opts.AgentPoolID) + require.NotNil(t, opts.VCSRepo) + assert.Equal(t, "org/repo2", *opts.VCSRepo.Identifier) +} + +func TestWorkspaceTemplate_ToUpdateOptions_Minimal(t *testing.T) { + ws := &WorkspaceTemplate{Name: "bare"} + opts := ws.toUpdateOptions() + assert.Equal(t, "bare", *opts.Name) + assert.Nil(t, opts.ExecutionMode) + assert.Nil(t, opts.TerraformVersion) + assert.Nil(t, opts.AgentPoolID) + assert.Nil(t, opts.VCSRepo) +} + +// ===== toCreateOptions / toUpdateOptions — variable template ===== + +func TestVariableTemplate_ToCreateOptions(t *testing.T) { + v := VariableTemplate{ + Key: "AWS_REGION", + Value: "us-east-1", + Description: "AWS region", + Category: "env", + HCL: false, + Sensitive: true, + } + opts := v.toCreateOptions() + assert.Equal(t, "AWS_REGION", *opts.Key) + assert.Equal(t, "us-east-1", *opts.Value) + assert.Equal(t, "AWS region", *opts.Description) + assert.Equal(t, tfe.CategoryType("env"), *opts.Category) + assert.False(t, *opts.HCL) + assert.True(t, *opts.Sensitive) +} + +func TestVariableTemplate_ToUpdateOptions(t *testing.T) { + v := VariableTemplate{ + Key: "TF_VAR_foo", + Value: `{"bar":"baz"}`, + Description: "HCL variable", + Category: "terraform", + HCL: true, + Sensitive: false, + } + opts := v.toUpdateOptions() + assert.Equal(t, "TF_VAR_foo", *opts.Key) + assert.JSONEq(t, `{"bar":"baz"}`, *opts.Value) + assert.Equal(t, tfe.CategoryType("terraform"), *opts.Category) + assert.True(t, *opts.HCL) + assert.False(t, *opts.Sensitive) +} + +// ===== WorkspaceTemplate JSON round-trip ===== + +func TestWorkspaceTemplate_JSONRoundTrip(t *testing.T) { + ws := WorkspaceTemplate{ + Name: "test-ws", + Description: "desc", + AutoApply: true, + TerraformVersion: "1.5.0", + Variables: []VariableTemplate{ + {Key: "k1", Value: "v1", Category: "env"}, + }, + } + data, err := json.Marshal(ws) + require.NoError(t, err) + + var got WorkspaceTemplate + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, ws.Name, got.Name) + assert.Equal(t, ws.AutoApply, got.AutoApply) + require.Len(t, got.Variables, 1) + assert.Equal(t, "k1", got.Variables[0].Key) +} + +// ===== Type() ===== + +func TestTFE_Type(t *testing.T) { + tfeInst := &TFE{} + assert.Equal(t, "tfe", tfeInst.Type()) +} diff --git a/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/workspace.go b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/workspace.go new file mode 100644 index 0000000000..5996220d96 --- /dev/null +++ b/apps/workspace-engine/svc/controllers/jobdispatch/jobagents/terraformcloud/workspace.go @@ -0,0 +1,361 @@ +package terraformcloud + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/hashicorp/go-tfe" + "sigs.k8s.io/yaml" + "workspace-engine/pkg/oapi" + "workspace-engine/pkg/templatefuncs" +) + +type VCSRepoTemplate struct { + Identifier string `json:"identifier" yaml:"identifier"` + Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` + OAuthTokenID string `json:"oauth_token_id,omitempty" yaml:"oauth_token_id,omitempty"` + IngressSubmodules bool `json:"ingress_submodules,omitempty" yaml:"ingress_submodules,omitempty"` + TagsRegex string `json:"tags_regex,omitempty" yaml:"tags_regex,omitempty"` +} + +type WorkspaceTemplate struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + ExecutionMode string `json:"execution_mode,omitempty" yaml:"execution_mode,omitempty"` + AutoApply bool `json:"auto_apply,omitempty" yaml:"auto_apply,omitempty"` + AllowDestroyPlan bool `json:"allow_destroy_plan,omitempty" yaml:"allow_destroy_plan,omitempty"` + FileTriggersEnabled bool `json:"file_triggers_enabled,omitempty" yaml:"file_triggers_enabled,omitempty"` + GlobalRemoteState bool `json:"global_remote_state,omitempty" yaml:"global_remote_state,omitempty"` + QueueAllRuns bool `json:"queue_all_runs,omitempty" yaml:"queue_all_runs,omitempty"` + SpeculativeEnabled bool `json:"speculative_enabled,omitempty" yaml:"speculative_enabled,omitempty"` + TerraformVersion string `json:"terraform_version,omitempty" yaml:"terraform_version,omitempty"` + TriggerPrefixes []string `json:"trigger_prefixes,omitempty" yaml:"trigger_prefixes,omitempty"` + TriggerPatterns []string `json:"trigger_patterns,omitempty" yaml:"trigger_patterns,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty" yaml:"working_directory,omitempty"` + AgentPoolID string `json:"agent_pool_id,omitempty" yaml:"agent_pool_id,omitempty"` + VCSRepo *VCSRepoTemplate `json:"vcs_repo,omitempty" yaml:"vcs_repo,omitempty"` + Variables []VariableTemplate `json:"variables,omitempty" yaml:"variables,omitempty"` +} + +type VariableTemplate struct { + Key string `json:"key" yaml:"key"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Category string `json:"category" yaml:"category"` + HCL bool `json:"hcl,omitempty" yaml:"hcl,omitempty"` + Sensitive bool `json:"sensitive,omitempty" yaml:"sensitive,omitempty"` +} + +// templateWorkspace renders the workspace YAML template using the dispatch context. +func templateWorkspace(dispatchCtx *oapi.DispatchContext, tmpl string) (*WorkspaceTemplate, error) { + t, err := templatefuncs.Parse("terraformWorkspaceTemplate", tmpl) + if err != nil { + return nil, fmt.Errorf("failed to parse template: %w", err) + } + var buf bytes.Buffer + if err := t.Execute(&buf, dispatchCtx.Map()); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + var workspace WorkspaceTemplate + if err := yaml.Unmarshal(buf.Bytes(), &workspace); err != nil { + return nil, fmt.Errorf("failed to unmarshal workspace: %w", err) + } + return &workspace, nil +} + +func upsertWorkspace( + ctx context.Context, + client *tfe.Client, + organization string, + workspace *WorkspaceTemplate, +) (*tfe.Workspace, error) { + existing, err := client.Workspaces.Read(ctx, organization, workspace.Name) + if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) { + return nil, fmt.Errorf("failed to read workspace: %w", err) + } + + if existing == nil { + created, err := client.Workspaces.Create(ctx, organization, workspace.toCreateOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create workspace: %w", err) + } + return created, nil + } + + updated, err := client.Workspaces.UpdateByID(ctx, existing.ID, workspace.toUpdateOptions()) + if err != nil { + return nil, fmt.Errorf("failed to update workspace: %w", err) + } + return updated, nil +} + +func syncVariables( + ctx context.Context, + client *tfe.Client, + workspaceID string, + desiredVars []VariableTemplate, +) error { + existingByKey := make(map[string]*tfe.Variable) + listOpts := &tfe.VariableListOptions{} + for { + existingVars, err := client.Variables.List(ctx, workspaceID, listOpts) + if err != nil { + return fmt.Errorf("failed to list variables: %w", err) + } + for _, v := range existingVars.Items { + existingByKey[v.Key] = v + } + if existingVars.Pagination == nil || existingVars.CurrentPage >= existingVars.TotalPages { + break + } + listOpts.PageNumber = existingVars.NextPage + } + + desiredKeys := make(map[string]bool, len(desiredVars)) + for _, desired := range desiredVars { + desiredKeys[desired.Key] = true + if _, err := desired.categoryType(); err != nil { + return err + } + if existing, ok := existingByKey[desired.Key]; ok { + _, err := client.Variables.Update( + ctx, + workspaceID, + existing.ID, + desired.toUpdateOptions(), + ) + if err != nil { + return fmt.Errorf("failed to update variable %s: %w", desired.Key, err) + } + } else { + _, err := client.Variables.Create(ctx, workspaceID, desired.toCreateOptions()) + if err != nil { + return fmt.Errorf("failed to create variable %s: %w", desired.Key, err) + } + } + } + + for key, existing := range existingByKey { + if !desiredKeys[key] { + if err := client.Variables.Delete(ctx, workspaceID, existing.ID); err != nil { + return fmt.Errorf("failed to delete variable %s: %w", key, err) + } + } + } + + return nil +} + +func createRun( + ctx context.Context, + client *tfe.Client, + workspaceID, jobID string, +) (*tfe.Run, error) { + message := fmt.Sprintf("Triggered by ctrlplane job %s", jobID) + run, err := client.Runs.Create(ctx, tfe.RunCreateOptions{ + Workspace: &tfe.Workspace{ID: workspaceID}, + Message: &message, + }) + if err != nil { + return nil, fmt.Errorf("failed to create run: %w", err) + } + return run, nil +} + +// ensureNotificationConfig creates or updates a notification configuration +// on the TFC workspace to send run events to the ctrlplane webhook endpoint. +// It is idempotent — safe to call on every dispatch. +func ensureNotificationConfig( + ctx context.Context, + client *tfe.Client, + workspaceID, webhookURL, webhookSecret string, +) error { + configs, err := client.NotificationConfigurations.List(ctx, workspaceID, nil) + if err != nil { + return fmt.Errorf("failed to list notification configs: %w", err) + } + + for _, cfg := range configs.Items { + if cfg.Name != notificationConfigName { + continue + } + // Always reconcile the full desired state: URL, token, enabled, + // and triggers. The token is write-only so we can't verify it + // matches, and any field could have been changed in the TFC UI. + enabled := true + updateOpts := tfe.NotificationConfigurationUpdateOptions{ + URL: &webhookURL, + Enabled: &enabled, + Triggers: []tfe.NotificationTriggerType{ + tfe.NotificationTriggerCreated, + tfe.NotificationTriggerPlanning, + tfe.NotificationTriggerNeedsAttention, + tfe.NotificationTriggerApplying, + tfe.NotificationTriggerCompleted, + tfe.NotificationTriggerErrored, + }, + } + if webhookSecret != "" { + updateOpts.Token = &webhookSecret + } + _, err := client.NotificationConfigurations.Update( + ctx, + cfg.ID, + updateOpts, + ) + if err != nil { + return fmt.Errorf("failed to update notification config: %w", err) + } + return nil + } + + enabled := true + destType := tfe.NotificationDestinationTypeGeneric + name := notificationConfigName + opts := tfe.NotificationConfigurationCreateOptions{ + Name: &name, + DestinationType: &destType, + Enabled: &enabled, + URL: &webhookURL, + Triggers: []tfe.NotificationTriggerType{ + tfe.NotificationTriggerCreated, + tfe.NotificationTriggerPlanning, + tfe.NotificationTriggerNeedsAttention, + tfe.NotificationTriggerApplying, + tfe.NotificationTriggerCompleted, + tfe.NotificationTriggerErrored, + }, + } + if webhookSecret != "" { + opts.Token = &webhookSecret + } + _, err = client.NotificationConfigurations.Create(ctx, workspaceID, opts) + if err != nil { + return fmt.Errorf("failed to create notification config: %w", err) + } + return nil +} + +func (w *WorkspaceTemplate) toCreateOptions() tfe.WorkspaceCreateOptions { + opts := tfe.WorkspaceCreateOptions{ + Name: &w.Name, + Description: &w.Description, + AutoApply: &w.AutoApply, + AllowDestroyPlan: &w.AllowDestroyPlan, + FileTriggersEnabled: &w.FileTriggersEnabled, + GlobalRemoteState: &w.GlobalRemoteState, + QueueAllRuns: &w.QueueAllRuns, + SpeculativeEnabled: &w.SpeculativeEnabled, + TriggerPrefixes: w.TriggerPrefixes, + TriggerPatterns: w.TriggerPatterns, + WorkingDirectory: &w.WorkingDirectory, + } + + if w.Project != "" { + opts.Project = &tfe.Project{ID: w.Project} + } + if w.ExecutionMode != "" { + opts.ExecutionMode = &w.ExecutionMode + } + if w.TerraformVersion != "" { + opts.TerraformVersion = &w.TerraformVersion + } + if w.AgentPoolID != "" { + opts.AgentPoolID = &w.AgentPoolID + } + if w.VCSRepo != nil && w.VCSRepo.Identifier != "" { + opts.VCSRepo = &tfe.VCSRepoOptions{ + Identifier: &w.VCSRepo.Identifier, + Branch: &w.VCSRepo.Branch, + OAuthTokenID: &w.VCSRepo.OAuthTokenID, + IngressSubmodules: &w.VCSRepo.IngressSubmodules, + TagsRegex: &w.VCSRepo.TagsRegex, + } + } + + return opts +} + +func (w *WorkspaceTemplate) toUpdateOptions() tfe.WorkspaceUpdateOptions { + opts := tfe.WorkspaceUpdateOptions{ + Name: &w.Name, + Description: &w.Description, + AutoApply: &w.AutoApply, + AllowDestroyPlan: &w.AllowDestroyPlan, + FileTriggersEnabled: &w.FileTriggersEnabled, + GlobalRemoteState: &w.GlobalRemoteState, + QueueAllRuns: &w.QueueAllRuns, + SpeculativeEnabled: &w.SpeculativeEnabled, + TriggerPrefixes: w.TriggerPrefixes, + TriggerPatterns: w.TriggerPatterns, + WorkingDirectory: &w.WorkingDirectory, + } + + if w.Project != "" { + opts.Project = &tfe.Project{ID: w.Project} + } + if w.ExecutionMode != "" { + opts.ExecutionMode = &w.ExecutionMode + } + if w.TerraformVersion != "" { + opts.TerraformVersion = &w.TerraformVersion + } + if w.AgentPoolID != "" { + opts.AgentPoolID = &w.AgentPoolID + } + if w.VCSRepo != nil && w.VCSRepo.Identifier != "" { + opts.VCSRepo = &tfe.VCSRepoOptions{ + Identifier: &w.VCSRepo.Identifier, + Branch: &w.VCSRepo.Branch, + OAuthTokenID: &w.VCSRepo.OAuthTokenID, + IngressSubmodules: &w.VCSRepo.IngressSubmodules, + TagsRegex: &w.VCSRepo.TagsRegex, + } + } + + return opts +} + +var validCategories = map[string]tfe.CategoryType{ + "terraform": tfe.CategoryTerraform, + "env": tfe.CategoryEnv, +} + +func (v *VariableTemplate) categoryType() (tfe.CategoryType, error) { + if ct, ok := validCategories[v.Category]; ok { + return ct, nil + } + return "", fmt.Errorf( + "invalid variable category %q for key %q (must be \"terraform\" or \"env\")", + v.Category, + v.Key, + ) +} + +func (v *VariableTemplate) toCreateOptions() tfe.VariableCreateOptions { + category, _ := v.categoryType() + return tfe.VariableCreateOptions{ + Key: &v.Key, + Value: &v.Value, + Description: &v.Description, + Category: &category, + HCL: &v.HCL, + Sensitive: &v.Sensitive, + } +} + +func (v *VariableTemplate) toUpdateOptions() tfe.VariableUpdateOptions { + category, _ := v.categoryType() + return tfe.VariableUpdateOptions{ + Key: &v.Key, + Value: &v.Value, + Description: &v.Description, + Category: &category, + HCL: &v.HCL, + Sensitive: &v.Sensitive, + } +} diff --git a/docs/integrations/job-agents/terraform-cloud.mdx b/docs/integrations/job-agents/terraform-cloud.mdx index 94405da82e..34384a3a2c 100644 --- a/docs/integrations/job-agents/terraform-cloud.mdx +++ b/docs/integrations/job-agents/terraform-cloud.mdx @@ -4,7 +4,7 @@ description: "Manage infrastructure deployments with Terraform Cloud/Enterprise" --- The Terraform Cloud job agent creates workspaces and triggers runs, enabling -infrastructure-as-code deployments with automatic run verification. +infrastructure-as-code deployments with webhook-based status tracking. ## How It Works @@ -16,25 +16,31 @@ sequenceDiagram C->>T: Create/Update Workspace C->>T: Sync Variables + C->>T: Ensure Notification Config C->>T: Create Run T->>I: Plan & Apply - C->>T: Poll run status - T-->>C: Applied - C->>C: Mark job successful + T->>C: Webhook: run status updates + C->>C: Update job status ``` 1. Ctrlplane renders a workspace configuration from your template 2. The workspace is created or updated via Terraform Cloud API 3. Variables are synced to match your template -4. A run is triggered with auto-apply -5. Ctrlplane verifies the run reaches `applied` status -6. Job is marked successful when verification passes +4. A webhook notification configuration (`ctrlplane-webhook`) is created on the workspace (idempotent) +5. A run is triggered with auto-apply +6. Terraform Cloud sends webhook notifications as the run progresses +7. The ctrlplane API receives webhooks and updates job status in the database + +This is a **fire-and-forget** dispatch model — the workspace-engine does not poll +or maintain long-running goroutines. Status tracking is handled entirely by +TFC webhooks, making it resilient to engine restarts. ## Prerequisites - Terraform Cloud or Terraform Enterprise account -- API token with workspace and run permissions +- API token with workspace, run, and notification configuration permissions - VCS connection (optional, for Git-based workflows) +- A reachable webhook endpoint (the ctrlplane API must be accessible from TFC) ## Configuration @@ -58,6 +64,8 @@ jobAgentConfig: organization: your-org address: https://app.terraform.io token: "{{.variables.tfe_token}}" + webhookUrl: https://ctrlplane.example.com/api/tfe/webhook + triggerRunOnChange: true template: | name: {{.deployment.slug}}-{{.resource.identifier}} description: "Managed by Ctrlplane" @@ -78,12 +86,37 @@ jobAgentConfig: category: terraform ``` -| Field | Required | Description | -| -------------- | -------- | --------------------------------------- | -| `organization` | Yes | Terraform Cloud organization | -| `address` | Yes | Terraform Cloud/Enterprise URL | -| `token` | Yes | API token | -| `template` | Yes | Go template for workspace configuration | +| Field | Required | Default | Description | +| -------------------- | -------- | ------- | --------------------------------------------------------------------------- | +| `organization` | Yes | | Terraform Cloud organization | +| `address` | Yes | | Terraform Cloud/Enterprise URL | +| `token` | Yes | | API token | +| `template` | Yes | | Go template for workspace configuration | +| `webhookUrl` | Yes | | Ctrlplane API endpoint for TFC notifications (e.g. `https://ctrlplane.example.com/api/tfe/webhook`) | +| `triggerRunOnChange` | No | `true` | Whether to create a TFC run on dispatch. When `false`, only the workspace and variables are synced. | + +### Environment Variables + +| Variable | Where | Description | +| -------------------- | ----------- | -------------------------------------------------------- | +| `TFE_WEBHOOK_SECRET` | API | HMAC secret for verifying incoming TFC webhooks | +| `TFE_WEBHOOK_SECRET` | Workspace Engine | Same secret, used when creating notification configs on TFC | + +Both the API and workspace-engine must share the same `TFE_WEBHOOK_SECRET`. + +## Webhook Status Mapping + +When TFC sends a notification, the webhook handler maps the trigger to a +ctrlplane job status: + +| TFC Trigger | Example Run Status | Ctrlplane Status | +| -------------------- | ------------------------- | ----------------- | +| `run:created` | pending | `pending` | +| `run:planning` | planning | `inProgress` | +| `run:needs_attention`| planned (confirmable), policy_override | `actionRequired` | +| `run:applying` | applying | `inProgress` | +| `run:completed` | applied, planned_and_finished | `successful` | +| `run:errored` | errored | `failure` | ## Workspace Template @@ -125,35 +158,32 @@ variables: ## Template Context -The template has access to all job context: - -| Variable | Description | -| -------------- | --------------------------- | -| `.job` | Job details | -| `.version` | Version being deployed | -| `.deployment` | Deployment details | -| `.environment` | Environment details | -| `.resource` | Target resource | -| `.variables` | Merged deployment variables | +The template has access to the full dispatch context: -## Automatic Verification +| Variable | Description | +| -------------- | ----------------------------------- | +| `.deployment` | Deployment details | +| `.environment` | Environment details | +| `.resource` | Target resource (config, metadata) | +| `.release` | Release details | +| `.version` | Deployment version (tag, name) | +| `.variables` | Merged deployment variables | -When a run is created, Ctrlplane automatically starts a verification that -monitors the run status: +## `triggerRunOnChange: false` -**Success conditions:** +When `triggerRunOnChange` is set to `false`, the dispatcher will: -- `applied` - Run completed successfully -- `planned_and_finished` - Plan-only run completed -- `planned_and_saved` - Plan saved for later apply +1. Upsert the workspace +2. Sync variables +3. Ensure the notification config exists +4. **Skip** creating a run -**Failure conditions:** +This is useful when you want VCS pushes to trigger runs instead of ctrlplane +creating them directly. The webhook notification config is still created so +that run status updates flow back to ctrlplane. -- `canceled` - Run was canceled -- `discarded` - Run was discarded -- `errored` - Run failed with error - -The verification polls every 60 seconds for up to 100 iterations. +> **Note:** Correlating VCS-triggered runs back to ctrlplane jobs (by workspace +> name/ID instead of run ID) is a planned follow-up. ## Example: Multi-Environment Infrastructure @@ -165,6 +195,7 @@ jobAgentConfig: organization: "{{.variables.tfe_org}}" address: "{{.variables.tfe_address}}" token: "{{.variables.tfe_token}}" + webhookUrl: "{{.variables.ctrlplane_webhook_url}}" template: | name: vpc-{{.environment.name}}-{{.resource.metadata.region}} description: "VPC for {{.environment.name}} in {{.resource.metadata.region}}" @@ -215,6 +246,25 @@ template: | category: terraform ``` +## Terraform Provider Configuration + +When using the ctrlplane Terraform provider: + +```hcl +resource "ctrlplane_job_agent" "tfc" { + name = "terraform-cloud" + + terraform_cloud { + address = "https://app.terraform.io" + organization = "your-org" + token = var.tfc_token + webhook_url = "https://ctrlplane.example.com/api/tfe/webhook" + trigger_run_on_change = true + template = file("workspace-template.yaml") + } +} +``` + ## Troubleshooting ### Workspace creation fails @@ -240,3 +290,21 @@ template: | - Verify variable keys match expected format - Check for duplicate variable definitions - Sensitive variables won't show values in UI + +### Webhook returns 401 + +- Check `TFE_WEBHOOK_SECRET` is set on the API +- Verify the same secret was used when creating the notification config on TFC +- Ensure the `x-tfe-notification-signature` header is present + +### No webhook notifications received + +- Verify the `webhookUrl` is reachable from Terraform Cloud +- Check the notification config exists on the TFC workspace (Settings > Notifications) +- Review TFC's notification delivery log for errors +- For local development, use smee.io or localtunnel to expose your API + +### Job stays in `inProgress` + +- Verify webhooks are reaching the API (check API logs for `POST /api/tfe/webhook`) +- Check the TFC run status directly in the TFC UI