initial revision
This commit is contained in:
5
dagger.json
Normal file
5
dagger.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "iac",
|
||||||
|
"sdk": "go",
|
||||||
|
"source": "."
|
||||||
|
}
|
||||||
63
gcp.go
Normal file
63
gcp.go
Normal 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
|
||||||
|
}
|
||||||
452
main.go
Normal file
452
main.go
Normal 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
488
vcs.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user