Files
dagger-tofu/main.go
2026-05-25 09:22:36 -04:00

453 lines
18 KiB
Go

// A Dagger module for managing Infrastructure as Code (IaC) workflows using OpenTofu.
package main
import (
"context"
"fmt"
"strings"
"dagger/iac/internal/dagger"
)
type Iac struct{}
// Run all static validation and formatting checks on the OpenTofu code.
// This implements Dagger's standard "Checks" design pattern, verifying structure and styling without running speculative plans.
func (m *Iac) Check(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image. Defaults to alpine:3.
// +optional
baseImage string,
) (string, error) {
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
// Sequentially execute 'tofu init', 'tofu fmt -check', 'tofu validate', and 'validator'
res, err := container.
WithExec([]string{"tofu", "init", "-no-color"}).
WithExec([]string{"tofu", "fmt", "-no-color", "-check"}).
WithExec([]string{"tofu", "validate", "-no-color"}).
WithExec([]string{"validator", "-gitignore", "-groupby", "pass-fail", "."}).
Stdout(ctx)
if err != nil {
return "", fmt.Errorf("static checks failed: %w\n%s", err, res)
}
return "All static checks (tofu fmt, tofu validate & validator) passed successfully! 🎉", nil
}
// Perform the complete Pull Request (CI) check workflow.
// This runs TFLint, Format checking, Static Validation, and Speculative Planning sequentially,
// posting individual VCS commit status checks, and finally posts or updates a single consolidated feedback comment.
func (m *Iac) PRWorkflow(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image. Defaults to alpine:3.
// +optional
baseImage string,
// Optional VCS platform provider: "github", "github-enterprise", or "gitea". Defaults to "github".
// +optional
vcsType string,
// Optional VCS Token (Personal Access Token or GitHub Action Token) for posting statuses and comments.
// +optional
vcsToken *dagger.Secret,
// Optional VCS repository name formatted as "owner/repo" (e.g. "jswank/google-cloud-cli").
// +optional
vcsRepo string,
// Optional Git Commit SHA to register status checks against.
// +optional
vcsSha string,
// Optional target URL callback to attach to status checks (e.g., Cloud Build url).
// +optional
vcsTargetURL string,
// Optional base URL of the private Gitea or GitHub Enterprise server instance.
// +optional
vcsBaseURL string,
// Optional Pull Request / Issue Number to post consolidated Markdown feedback to.
// +optional
vcsPRNumber int,
// Optional Build ID label to attach to the comment footer (e.g. Cloud Build build ID).
// +optional
buildID string,
) (string, error) {
if vcsType == "" {
vcsType = "github"
}
if buildID == "" {
buildID = "local-run"
}
// 1. Mark all individual status checks as pending in the VCS
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/tflint", "pending", "Waiting...", vcsTargetURL)
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/fmt", "pending", "Waiting...", vcsTargetURL)
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/validate", "pending", "Waiting...", vcsTargetURL)
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/plan", "pending", "Waiting...", vcsTargetURL)
// 2. Setup the base execution container
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
// 3. Initialize OpenTofu (required before running validate, plan, etc.)
initContainer := container.WithExec([]string{"tofu", "init", "-no-color"})
_, err := initContainer.Stdout(ctx)
if err != nil {
// If tofu init itself fails, mark validate and plan as failed immediately
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/validate", "failure", "Init failed", vcsTargetURL)
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/plan", "failure", "Init failed", vcsTargetURL)
return "", fmt.Errorf("tofu init failed: %w", err)
}
// 4. Run CI Steps Sequentially to Capture Outputs and Exit Codes
// We wrap commands in thin shell executors to write outputs and exit codes to the local workspace filesystem.
execContainer := initContainer.
WithExec([]string{"sh", "-c", "tflint --init && tflint > /workspace/tflint-output.txt 2>&1; echo $? > /workspace/tflint.exit"}).
WithExec([]string{"sh", "-c", "tofu fmt -no-color -check > /workspace/fmt-output.txt 2>&1; echo $? > /workspace/fmt.exit"}).
WithExec([]string{"sh", "-c", "tofu validate -no-color > /workspace/validate-output.txt 2>&1; echo $? > /workspace/validate.exit"}).
WithExec([]string{"sh", "-c", "tofu plan -no-color -detailed-exitcode > /workspace/plan-output.txt 2>&1; echo $? > /workspace/plan.exit"})
// 5. Ingest Outputs and Exit Codes from Container Sandbox
tflintOut, _ := execContainer.File("/workspace/tflint-output.txt").Contents(ctx)
tflintCodeRaw, _ := execContainer.File("/workspace/tflint.exit").Contents(ctx)
tflintCode := strings.TrimSpace(tflintCodeRaw)
fmtOut, _ := execContainer.File("/workspace/fmt-output.txt").Contents(ctx)
fmtCodeRaw, _ := execContainer.File("/workspace/fmt.exit").Contents(ctx)
fmtCode := strings.TrimSpace(fmtCodeRaw)
validateOut, _ := execContainer.File("/workspace/validate-output.txt").Contents(ctx)
validateCodeRaw, _ := execContainer.File("/workspace/validate.exit").Contents(ctx)
validateCode := strings.TrimSpace(validateCodeRaw)
planOut, _ := execContainer.File("/workspace/plan-output.txt").Contents(ctx)
planCodeRaw, _ := execContainer.File("/workspace/plan.exit").Contents(ctx)
planCode := strings.TrimSpace(planCodeRaw)
// 6. Post final success/failure commit status checks based on actual execution exit codes
if tflintCode == "0" {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/tflint", "success", "TFLint passed", vcsTargetURL)
} else {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/tflint", "failure", "TFLint found static issues", vcsTargetURL)
}
if fmtCode == "0" {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/fmt", "success", "Format check passed", vcsTargetURL)
} else {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/fmt", "failure", "Formatting issues detected", vcsTargetURL)
}
if validateCode == "0" {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/validate", "success", "Validation passed", vcsTargetURL)
} else {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/validate", "failure", "Static validation failed", vcsTargetURL)
}
if planCode == "0" || planCode == "2" {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/plan", "success", "Speculative plan completed", vcsTargetURL)
} else {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, "ci/plan", "failure", "Plan generation failed", vcsTargetURL)
}
// 7. Assemble and post/update a single consolidated comment directly on Gitea/GitHub
if vcsPRNumber > 0 {
commentBody := m.buildConsolidatedPRComment(
tflintCode, fmtCode, validateCode, planCode,
tflintOut, fmtOut, validateOut, planOut,
buildID, vcsTargetURL,
)
_ = m.postOrUpdatePRComment(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsPRNumber, commentBody)
}
// Return consolidated logs for direct display in the GCB console
stdout := fmt.Sprintf("--- TFLINT OUT ---\n%s\n--- FMT OUT ---\n%s\n--- VALIDATE OUT ---\n%s\n--- PLAN OUT ---\n%s\n", tflintOut, fmtOut, validateOut, planOut)
if tflintCode != "0" || fmtCode != "0" || validateCode != "0" || (planCode != "0" && planCode != "2") {
return stdout, fmt.Errorf("CI pipeline failed some gating steps [tflint: %s, fmt: %s, validate: %s, plan: %s]", tflintCode, fmtCode, validateCode, planCode)
}
return stdout, nil
}
// Perform OpenTofu initialization and validation (tofu validate).
func (m *Iac) Validate(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image containing OpenTofu. Defaults to ghcr.io/opentofu/opentofu:1.8.8.
// +optional
baseImage string,
// Optional VCS platform provider: "github", "github-enterprise", or "gitea". Defaults to "github".
// +optional
vcsType string,
// Optional VCS Token (Personal Access Token or GitHub Action Token) for posting statuses.
// +optional
vcsToken *dagger.Secret,
// Optional VCS repository name formatted as "owner/repo" (e.g. "jswank/google-cloud-cli").
// +optional
vcsRepo string,
// Optional Git Commit SHA to register status checks against.
// +optional
vcsSha string,
// Optional target URL callback to attach to status checks (e.g., Cloud Build url).
// +optional
vcsTargetURL string,
// Optional base URL of the private Gitea or GitHub Enterprise server instance (e.g. "https://github.mycompany.com").
// +optional
vcsBaseURL string,
// Optional Pull Request / Issue Number to post rich feedback comments to.
// +optional
vcsPRNumber int,
) (string, error) {
if vcsType == "" {
vcsType = "github"
}
contextName := "ci/validate"
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "pending", "Running OpenTofu static validation...", vcsTargetURL)
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
res, err := container.
WithExec([]string{"tofu", "init", "-no-color"}).
WithExec([]string{"tofu", "validate", "-no-color"}).
Stdout(ctx)
if err != nil {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "failure", "Validation failed!", vcsTargetURL)
// Post a detailed error comment to the PR
if vcsPRNumber > 0 {
commentBody := m.buildValidationComment(vcsRepo, vcsSha, res, err)
_ = m.postOrUpdatePRComment(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsPRNumber, commentBody)
}
return "", fmt.Errorf("validation failed: %w", err)
}
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "success", "Validation succeeded.", vcsTargetURL)
// Post a success comment to the PR
if vcsPRNumber > 0 {
commentBody := m.buildValidationComment(vcsRepo, vcsSha, res, nil)
_ = m.postOrUpdatePRComment(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsPRNumber, commentBody)
}
return res, nil
}
// Perform OpenTofu planning (tofu plan). Returns the plan output text.
func (m *Iac) Plan(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image containing OpenTofu.
// +optional
baseImage string,
// Optional VCS platform provider: "github", "github-enterprise", or "gitea". Defaults to "github".
// +optional
vcsType string,
// Optional VCS Token (Personal Access Token or GitHub Action Token) for posting statuses.
// +optional
vcsToken *dagger.Secret,
// Optional VCS repository name formatted as "owner/repo" (e.g. "jswank/google-cloud-cli").
// +optional
vcsRepo string,
// Optional Git Commit SHA to register status checks against.
// +optional
vcsSha string,
// Optional target URL callback to attach to status checks (e.g., Cloud Build url).
// +optional
vcsTargetURL string,
// Optional base URL of the private Gitea or GitHub Enterprise server instance.
// +optional
vcsBaseURL string,
// Optional Pull Request / Issue Number to post rich feedback comments to.
// +optional
vcsPRNumber int,
) (string, error) {
if vcsType == "" {
vcsType = "github"
}
contextName := "ci/plan"
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "pending", "Generating speculative plan...", vcsTargetURL)
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
res, err := container.
WithExec([]string{"tofu", "init", "-no-color"}).
WithExec([]string{"tofu", "plan", "-no-color"}).
Stdout(ctx)
if err != nil {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "failure", "Plan failed!", vcsTargetURL)
// Post a detailed error comment to the PR
if vcsPRNumber > 0 {
commentBody := m.buildPlanComment(vcsRepo, vcsSha, res, err)
_ = m.postOrUpdatePRComment(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsPRNumber, commentBody)
}
return "", fmt.Errorf("planning failed: %w", err)
}
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "success", "Plan generated successfully.", vcsTargetURL)
// Post a rich success summary comment to the PR
if vcsPRNumber > 0 {
commentBody := m.buildPlanComment(vcsRepo, vcsSha, res, nil)
_ = m.postOrUpdatePRComment(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsPRNumber, commentBody)
}
return res, nil
}
// Perform OpenTofu planning and export the plan binary file.
func (m *Iac) PlanExport(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image containing OpenTofu.
// +optional
baseImage string,
// Optional VCS platform provider: "github", "github-enterprise", or "gitea". Defaults to "github".
// +optional
vcsType string,
// Optional VCS Token (Personal Access Token or GitHub Action Token) for posting statuses.
// +optional
vcsToken *dagger.Secret,
// Optional VCS repository name formatted as "owner/repo" (e.g. "jswank/google-cloud-cli").
// +optional
vcsRepo string,
// Optional Git Commit SHA to register status checks against.
// +optional
vcsSha string,
// Optional target URL callback to attach to status checks (e.g., Cloud Build url).
// +optional
vcsTargetURL string,
// Optional base URL of the private Gitea or GitHub Enterprise server instance.
// +optional
vcsBaseURL string,
) (*dagger.File, error) {
if vcsType == "" {
vcsType = "github"
}
contextName := "cd/plan-export"
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "pending", "Generating and exporting plan file...", vcsTargetURL)
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
planFile := "/workspace/tfplan"
container = container.
WithExec([]string{"tofu", "init", "-no-color"}).
WithExec([]string{"tofu", "plan", "-no-color", "-out=" + planFile})
file := container.File(planFile)
_, err := container.Stdout(ctx)
if err != nil {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "failure", "Plan export failed!", vcsTargetURL)
return nil, fmt.Errorf("planning and exporting failed: %w", err)
}
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "success", "Plan exported successfully.", vcsTargetURL)
return file, nil
}
// Perform OpenTofu applying (tofu apply).
func (m *Iac) Apply(
ctx context.Context,
// The directory containing the OpenTofu code.
source *dagger.Directory,
// Optional pre-generated plan file from PlanExport. If provided, apply will use this plan.
// +optional
planFile *dagger.File,
// Optional GCP credentials JSON (service account key or local ADC json) passed as a Secret.
// +optional
gcpCreds *dagger.Secret,
// Optional GCP Project ID to inject as an environment variable.
// +optional
projectID string,
// Optional custom Docker/OCI image containing OpenTofu.
// +optional
baseImage string,
// Optional VCS platform provider: "github", "github-enterprise", or "gitea". Defaults to "github".
// +optional
vcsType string,
// Optional VCS Token (Personal Access Token or GitHub Action Token) for posting statuses.
// +optional
vcsToken *dagger.Secret,
// Optional VCS repository name formatted as "owner/repo" (e.g. "jswank/google-cloud-cli").
// +optional
vcsRepo string,
// Optional Git Commit SHA to register status checks against.
// +optional
vcsSha string,
// Optional target URL callback to attach to status checks (e.g., Cloud Build url).
// +optional
vcsTargetURL string,
// Optional base URL of the private Gitea or GitHub Enterprise server instance.
// +optional
vcsBaseURL string,
) (string, error) {
if vcsType == "" {
vcsType = "github"
}
contextName := "cd/apply"
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "pending", "Applying infrastructure changes...", vcsTargetURL)
container := m.baseContainer(source, gcpCreds, projectID, baseImage)
container = container.WithExec([]string{"tofu", "init", "-no-color"})
if planFile != nil {
mountedPlanPath := "/workspace/tfplan"
container = container.
WithFile(mountedPlanPath, planFile).
WithExec([]string{"tofu", "apply", "-no-color", mountedPlanPath})
} else {
container = container.WithExec([]string{"tofu", "apply", "-no-color", "-auto-approve"})
}
res, err := container.Stdout(ctx)
if err != nil {
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "failure", "Apply failed!", vcsTargetURL)
return "", fmt.Errorf("applying failed: %w", err)
}
_ = m.postVCSStatus(ctx, vcsToken, vcsType, vcsBaseURL, vcsRepo, vcsSha, contextName, "success", "Infrastructure applied successfully.", vcsTargetURL)
return res, nil
}