initial revision

This commit is contained in:
Jason Swank
2026-05-25 09:22:36 -04:00
commit dbeb905844
5 changed files with 1011 additions and 0 deletions

5
dagger.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "iac",
"sdk": "go",
"source": "."
}

63
gcp.go Normal file
View File

@@ -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
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module dagger/iac
go 1.22

452
main.go Normal file
View File

@@ -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
}

488
vcs.go Normal file
View File

@@ -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 = "<!-- cloud-build-tofu-ci -->"
// 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 <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("<details><summary>%s output</summary>\n\n```\n%s\n```\n</details>\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("<details open><summary>Show Plan</summary>\n\n```diff\n%s\n```\n</details>\n", clean)
}
// Failed plan block format
return fmt.Sprintf("<details><summary>Plan output</summary>\n\n```\n%s\n```\n</details>\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** |
<br>
<details>
<summary>🔍 Click to Expand Detailed OpenTofu Plan logs</summary>
%s
</details>
<br>
*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** |
<br>
<details>
<summary>🔍 Click to Expand Validation Details</summary>
%s
</details>
<br>
*Feedback posted automatically via [Dagger.io](https://dagger.io).*`, statusEmoji, repo, sha, statusEmoji, statusText, summary, rawLogs)
}