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