commit dbeb9058449287f03eb2b708362a21a4914e4fc8 Author: Jason Swank Date: Mon May 25 09:22:36 2026 -0400 initial revision diff --git a/dagger.json b/dagger.json new file mode 100644 index 0000000..3fca291 --- /dev/null +++ b/dagger.json @@ -0,0 +1,5 @@ +{ + "name": "iac", + "sdk": "go", + "source": "." +} diff --git a/gcp.go b/gcp.go new file mode 100644 index 0000000..af394ef --- /dev/null +++ b/gcp.go @@ -0,0 +1,63 @@ +// GCP container helpers for the OpenTofu Dagger module. +package main + +import ( + "dagger/iac/internal/dagger" +) + +// Helper to construct the base Container with the source code and GCP credentials. +func (m *Iac) baseContainer( + source *dagger.Directory, + gcpCreds *dagger.Secret, + projectID string, + baseImage string, +) *dagger.Container { + // 1. Start from a clean alpine:3 base image (or user-customized alpine) + if baseImage == "" { + baseImage = "alpine:3" + } + container := dag.Container().From(baseImage) + + // 2. Add the required packages (git, curl, and bash are needed for module installations, ca-certificates for secure TLS, libc6-compat for glibc compatibility) + container = container.WithExec([]string{ + "apk", "add", "--no-cache", "git", "curl", "ca-certificates", "bash", "libc6-compat", + }) + + // Set BINSTALLER_BIN so that the standard install scripts place binaries in /usr/local/bin + container = container.WithEnvVariable("BINSTALLER_BIN", "/usr/local/bin") + + // 3. Securely install OpenTofu into the container using the standard install script and symlink it to tofu + container = container. + WithExec([]string{ + "sh", "-c", "curl -sSL https://jswank.github.io/install/tofu-install.sh | bash", + }) + + // 4. Securely install tflint into the container using the standard install script + container = container.WithExec([]string{ + "sh", "-c", "curl -sSL https://jswank.github.io/install/tflint-install.sh | bash", + }) + + // 5. Securely install validator into the container using the standard install script + container = container.WithExec([]string{ + "sh", "-c", "curl -sSL https://jswank.github.io/install/validator-install.sh | bash", + }) + + // 6. Set working directory to /workspace and copy the IaC files + container = container. + WithWorkdir("/workspace"). + WithDirectory("/workspace", source) + + // Mount credentials and set the standard GOOGLE_APPLICATION_CREDENTIALS environment variable + if gcpCreds != nil { + credsPath := "/gcp-creds.json" + container = container. + WithMountedSecret(credsPath, gcpCreds). + WithEnvVariable("GOOGLE_APPLICATION_CREDENTIALS", credsPath) + } + + if projectID != "" { + container = container.WithEnvVariable("GCP_PROJECT", projectID) + } + + return container +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6767cb8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module dagger/iac + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..1dc1b1e --- /dev/null +++ b/main.go @@ -0,0 +1,452 @@ +// 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 +} diff --git a/vcs.go b/vcs.go new file mode 100644 index 0000000..eea8eae --- /dev/null +++ b/vcs.go @@ -0,0 +1,488 @@ +// VCS (GitHub/Gitea) API integration helpers for the OpenTofu Dagger module. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "dagger/iac/internal/dagger" +) + +const MARKER = "" + +// Helper function to post commit status back to GitHub, GitHub Enterprise, or Gitea using standard HTTP. +func (m *Iac) postVCSStatus( + ctx context.Context, + token *dagger.Secret, + vcsType string, // "github", "github-enterprise" (or "ghe"), or "gitea" + vcsBaseURL string, // Base URL of the VCS server (e.g. "https://github.mycompany.com" or "https://gitea.com") + repo string, // "owner/repo" + sha string, // Commit SHA + contextName string, // Status context (e.g. "ci/plan") + state string, // "pending", "success", "failure", "error" + description string, // Status description text + targetURL string, // Optional URL link (e.g. GCB build details URL) +) error { + // Quietly bypass if VCS status variables are not configured (making local testing zero-overhead) + if token == nil || repo == "" || sha == "" { + return nil + } + + tokenStr, err := token.Plaintext(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve VCS token: %w", err) + } + + // Clean and normalize VCS types + vcsType = strings.ToLower(strings.TrimSpace(vcsType)) + vcsBaseURL = strings.TrimSuffix(strings.TrimSpace(vcsBaseURL), "/") + + // 1. Construct the API URL based on VCS Type + var apiURL string + switch vcsType { + case "gitea": + if vcsBaseURL == "" { + vcsBaseURL = "https://gitea.com" + } + // Gitea API prefix is /api/v1 + apiURL = fmt.Sprintf("%s/api/v1/repos/%s/statuses/%s", vcsBaseURL, repo, sha) + + case "github-enterprise", "github_enterprise", "ghe": + if vcsBaseURL == "" { + return fmt.Errorf("vcsBaseURL is required when using github-enterprise") + } + // GitHub Enterprise Server API prefix is /api/v3 + apiURL = fmt.Sprintf("%s/api/v3/repos/%s/statuses/%s", vcsBaseURL, repo, sha) + + default: // Default to public GitHub + if vcsBaseURL == "" { + vcsBaseURL = "https://api.github.com" + } + // Public GitHub uses /repos direct endpoint + apiURL = fmt.Sprintf("%s/repos/%s/statuses/%s", vcsBaseURL, repo, sha) + } + + // 2. Prepare Payload (All these platforms share the exact same JSON schema for commit statuses) + type StatusPayload struct { + State string `json:"state"` + TargetURL string `json:"target_url,omitempty"` + Description string `json:"description,omitempty"` + Context string `json:"context"` + } + + payload := StatusPayload{ + State: strings.ToLower(state), + TargetURL: targetURL, + Description: description, + Context: contextName, + } + + jsonBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal status payload: %w", err) + } + + // 3. Execute HTTP Request + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + // All three authorize using the "Authorization: token " header pattern + req.Header.Set("Authorization", "token "+tokenStr) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("VCS API returned unexpected HTTP code: %s", resp.Status) + } + + return nil +} + +// Helper function to list issue comments to find any existing CI comment with our marker tag. +func (m *Iac) findExistingComment( + ctx context.Context, + tokenStr string, + apiURL string, +) (int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return 0, err + } + req.Header.Set("Authorization", "token "+tokenStr) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("failed to list comments: %s", resp.Status) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return 0, err + } + + type Comment struct { + ID int `json:"id"` + Body string `json:"body"` + } + + var comments []Comment + if err := json.Unmarshal(bodyBytes, &comments); err != nil { + return 0, err + } + + // Find the first comment that contains our marker + for _, comment := range comments { + if strings.Contains(comment.Body, MARKER) { + return comment.ID, nil + } + } + + return 0, nil +} + +// Helper function to post or update a single consolidated PR comment containing detailed CI feedback. +func (m *Iac) postOrUpdatePRComment( + ctx context.Context, + token *dagger.Secret, + vcsType string, // "github", "github-enterprise", or "gitea" + vcsBaseURL string, // Base URL of the VCS server + repo string, // "owner/repo" + prNumber int, // Pull Request Number (e.g. 42) + body string, // Comment body in Markdown +) error { + // Quietly bypass if VCS commenting parameters are not fully set + if token == nil || repo == "" || prNumber <= 0 || body == "" { + return nil + } + + tokenStr, err := token.Plaintext(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve VCS token for PR comment: %w", err) + } + + // Normalize VCS types + vcsType = strings.ToLower(strings.TrimSpace(vcsType)) + vcsBaseURL = strings.TrimSuffix(strings.TrimSpace(vcsBaseURL), "/") + + // 1. Determine Endpoint URLs + var commentsURL string + var patchURL string + switch vcsType { + case "gitea": + if vcsBaseURL == "" { + vcsBaseURL = "https://gitea.com" + } + commentsURL = fmt.Sprintf("%s/api/v1/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber) + patchURL = fmt.Sprintf("%s/api/v1/repos/%s/issues/comments", vcsBaseURL, repo) + + case "github-enterprise", "github_enterprise", "ghe": + if vcsBaseURL == "" { + return fmt.Errorf("vcsBaseURL is required when using github-enterprise") + } + commentsURL = fmt.Sprintf("%s/api/v3/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber) + patchURL = fmt.Sprintf("%s/api/v3/repos/%s/issues/comments", vcsBaseURL, repo) + + default: // Public GitHub + if vcsBaseURL == "" { + vcsBaseURL = "https://api.github.com" + } + commentsURL = fmt.Sprintf("%s/repos/%s/issues/%d/comments", vcsBaseURL, repo, prNumber) + patchURL = fmt.Sprintf("%s/repos/%s/issues/comments", vcsBaseURL) + } + + // 2. Search for an existing comment with our marker tag + existingID, err := m.findExistingComment(ctx, tokenStr, commentsURL) + if err != nil { + fmt.Printf("Warning: Failed to search for existing comments: %v\n", err) + } + + // 3. Prepare JSON Payload + type CommentPayload struct { + Body string `json:"body"` + } + payload := CommentPayload{ + Body: body, + } + jsonBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal comment payload: %w", err) + } + + // 4. Create or Update Comment via standard HTTP APIs + var req *http.Request + if existingID > 0 { + // Update existing comment using PATCH + apiURL := fmt.Sprintf("%s/%d", patchURL, existingID) + req, err = http.NewRequestWithContext(ctx, "PATCH", apiURL, bytes.NewBuffer(jsonBytes)) + } else { + // Post a brand-new comment using POST + req, err = http.NewRequestWithContext(ctx, "POST", commentsURL, bytes.NewBuffer(jsonBytes)) + } + if err != nil { + return fmt.Errorf("failed to create comment request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "token "+tokenStr) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("comment HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("comment API returned error code %s", resp.Status) + } + + return nil +} + +// Return status badge matching the original google-cloud-cli format. +func (m *Iac) statusBadge(code string) string { + switch code { + case "0", "2": + return "✅ `success`" + case "skipped": + return "⏭ `skipped`" + default: + return "❌ `failure`" + } +} + +// Clean ANSI colors from captured stdout +func (m *Iac) stripAnsi(text string) string { + ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + return ansiRegex.ReplaceAllString(text, "") +} + +// Generate the failure details block (only outputs when step failed and has stdout) +func (m *Iac) failureDetails(label string, code string, output string) string { + if code == "0" || code == "skipped" { + return "" + } + cleanOutput := m.stripAnsi(output) + if cleanOutput == "" { + return "" + } + return fmt.Sprintf("
%s output\n\n```\n%s\n```\n
\n", label, cleanOutput) +} + +// Format the OpenTofu plan block matching the exact regex transformations of the shell script. +func (m *Iac) planBlock(code string, rawOutput string) string { + if code == "skipped" || rawOutput == "" { + return "" + } + + clean := m.stripAnsi(rawOutput) + + if code == "0" || code == "2" { + // Match the sed logic: Keep from the execution plan header onward + headers := []string{ + "An execution plan has been generated and is shown below.", + "Terraform used the selected providers to generate the following execution", + "OpenTofu used the selected providers to generate the following execution", + "No changes. Infrastructure is up-to-date.", + "No changes. Your infrastructure matches the configuration.", + "Note: Objects have changed outside of Terraform", + } + + firstIdx := -1 + for _, h := range headers { + idx := strings.Index(clean, h) + if idx != -1 && (firstIdx == -1 || idx < firstIdx) { + firstIdx = idx + } + } + if firstIdx != -1 { + clean = clean[firstIdx:] + } + + // Match the sed logic: Truncate at the plan summary line ("Plan: ...") + if idx := strings.Index(clean, "Plan: "); idx != -1 { + lineEnd := strings.Index(clean[idx:], "\n") + if lineEnd != -1 { + clean = clean[:idx+lineEnd] + } + } + + // Truncate if too large for Gitea/GitHub comments + if len(clean) > 65000 { + clean = clean[:65000] + "\n...(truncated due to size limits)" + } + + // Match the sed logic: Move diff indicators (+, -, ~) to the very start of lines for highlight coloring + lines := strings.Split(clean, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if len(trimmed) > 0 { + firstChar := trimmed[0] + if firstChar == '-' || firstChar == '+' || firstChar == '~' { + if firstChar == '~' { + firstChar = '!' + } + rest := strings.TrimSpace(trimmed[1:]) + lines[i] = string(firstChar) + " " + rest + } + } + } + clean = strings.Join(lines, "\n") + + return fmt.Sprintf("
Show Plan\n\n```diff\n%s\n```\n
\n", clean) + } + + // Failed plan block format + return fmt.Sprintf("
Plan output\n\n```\n%s\n```\n
\n", clean) +} + +// Assemble the single consolidated PR comment body mapping exactly to the shell script wrapper format. +func (m *Iac) buildConsolidatedPRComment( + tflintCode, fmtCode, validateCode, planCode string, + tflintOut, fmtOut, validateOut, planOut string, + buildID, buildURL string, +) string { + tflintBadge := m.statusBadge(tflintCode) + fmtBadge := m.statusBadge(fmtCode) + validateBadge := m.statusBadge(validateCode) + planBadge := m.statusBadge(planCode) + + tflintDetails := m.failureDetails("TFLint", tflintCode, tflintOut) + fmtDetails := m.failureDetails("Format", fmtCode, fmtOut) + validateDetails := m.failureDetails("Validate", validateCode, validateOut) + planDetails := m.planBlock(planCode, planOut) + + return fmt.Sprintf(`%s + +#### TFLint %s +%s +#### TF Format %s +%s +#### TF Validate %s +%s +#### TF Plan %s +%s +*Build: [%s](%s)*`, + MARKER, + tflintBadge, tflintDetails, + fmtBadge, fmtDetails, + validateBadge, validateDetails, + planBadge, planDetails, + buildID, buildURL, + ) +} + +// Helper to scan OpenTofu logs and extract the summary line (e.g. "Plan: 5 to add, 0 to change, 2 to destroy.") +func (m *Iac) extractPlanSummary(stdout string) string { + lines := strings.Split(stdout, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "Plan:") { + return trimmed + } + if strings.Contains(trimmed, "No changes. Your infrastructure matches the configuration.") || strings.Contains(trimmed, "No changes.") { + return "Plan: No changes. Your infrastructure matches your configuration perfectly! 🎉" + } + } + return "Plan summary line could not be parsed." +} + +// Helper to construct a beautiful Markdown feedback comment for the Plan stage. +func (m *Iac) buildPlanComment(repo string, sha string, stdout string, executionErr error) string { + statusEmoji := "✅" + statusText := "Succeeded" + summary := m.extractPlanSummary(stdout) + + if executionErr != nil { + statusEmoji = "❌" + statusText = "Failed" + summary = "OpenTofu execution failed. Please check the logs inside GCB or detailed view below." + } + + var rawLogs string + if stdout != "" { + rawLogs = fmt.Sprintf("```terraform\n%s\n```", stdout) + } else { + rawLogs = "*(No log output was captured)*" + } + + return fmt.Sprintf(`### 🤖 OpenTofu Speculative Plan Results %s + +| Property | Details | +| :--- | :--- | +| **VCS Repository** | %s | +| **Git Commit SHA** | %s | +| **Plan Status** | %s **%s** | +| **Execution Summary** | **%s** | + +
+ +
+🔍 Click to Expand Detailed OpenTofu Plan logs + +%s + +
+ +
+ +*Feedback posted automatically via [Dagger.io](https://dagger.io).*`, statusEmoji, repo, sha, statusEmoji, statusText, summary, rawLogs) +} + +// Helper to construct a beautiful Markdown feedback comment for the Validate stage. +func (m *Iac) buildValidationComment(repo string, sha string, stdout string, executionErr error) string { + statusEmoji := "✅" + statusText := "Passed" + summary := "OpenTofu configuration structure and variables are 100% valid." + + if executionErr != nil { + statusEmoji = "❌" + statusText = "Failed" + summary = "OpenTofu static validation checks failed. See syntax errors below." + } + + var rawLogs string + if stdout != "" { + rawLogs = fmt.Sprintf("```terraform\n%s\n```", stdout) + } else { + rawLogs = "*(No validation logs recorded)*" + } + + return fmt.Sprintf(`### 🔍 OpenTofu Static Validation Results %s + +| Property | Details | +| :--- | :--- | +| **VCS Repository** | %s | +| **Git Commit SHA** | %s | +| **Validation Status** | %s **%s** | +| **Validation Details** | **%s** | + +
+ +
+🔍 Click to Expand Validation Details + +%s + +
+ +
+ +*Feedback posted automatically via [Dagger.io](https://dagger.io).*`, statusEmoji, repo, sha, statusEmoji, statusText, summary, rawLogs) +}