// Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. package events import ( "bytes" "embed" "fmt" "strings" "text/template" "github.com/Masterminds/sprig/v3" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/events/models" "golang.org/x/text/cases" "golang.org/x/text/language" ) var ( planCommandTitle = command.Plan.TitleString() applyCommandTitle = command.Apply.TitleString() policyCheckCommandTitle = command.PolicyCheck.TitleString() approvePoliciesCommandTitle = command.ApprovePolicies.TitleString() versionCommandTitle = command.Version.TitleString() importCommandTitle = command.Import.TitleString() stateCommandTitle = command.State.TitleString() // maxUnwrappedLines is the maximum number of lines the Terraform output // can be before we wrap it in an expandable template. maxUnwrappedLines = 12 //go:embed templates/* templatesFS embed.FS ) // MarkdownRenderer renders responses as markdown. type MarkdownRenderer struct { // gitlabSupportsCommonMark is true if the version of GitLab we're // using supports the CommonMark markdown format. // If we're not configured with a GitLab client, this will be false. gitlabSupportsCommonMark bool disableApplyAll bool disableApply bool disableMarkdownFolding bool disableRepoLocking bool enableDiffMarkdownFormat bool markdownTemplates *template.Template executableName string hideUnchangedPlanComments bool quietPolicyChecks bool } // commonData is data that all responses have. type commonData struct { Command string SubCommand string Verbose bool Log string PlansDeleted bool DisableApplyAll bool DisableApply bool DisableRepoLocking bool EnableDiffMarkdownFormat bool ExecutableName string HideUnchangedPlanComments bool QuietPolicyChecks bool VcsRequestType string } // errData is data about an error response. type errData struct { Error string RenderedContext string commonData } // failureData is data about a failure response. type failureData struct { Failure string RenderedContext string commonData } type resultData struct { Results []projectResultTmplData commonData } type planResultData struct { Results []projectResultTmplData commonData NumPlansWithChanges int NumPlansWithNoChanges int NumPlanFailures int } type applyResultData struct { Results []projectResultTmplData commonData NumApplySuccesses int NumApplyFailures int NumApplyErrors int } type planSuccessData struct { models.PlanSuccess PlanSummary string PlanWasDeleted bool DisableApply bool DisableRepoLocking bool EnableDiffMarkdownFormat bool PlanStats models.PlanSuccessStats } type policyCheckResultsData struct { models.PolicyCheckResults PreConftestOutput string PostConftestOutput string PolicyCheckSummary string PolicyApprovalSummary string PolicyCleared bool commonData } type projectResultTmplData struct { Workspace string RepoRelDir string ProjectName string Rendered string NoChanges bool IsSuccessful bool } // Initialize templates func NewMarkdownRenderer( gitlabSupportsCommonMark bool, disableApplyAll bool, disableApply bool, disableMarkdownFolding bool, disableRepoLocking bool, enableDiffMarkdownFormat bool, markdownTemplateOverridesDir string, executableName string, hideUnchangedPlanComments bool, quietPolicyChecks bool, ) *MarkdownRenderer { var templates *template.Template templates, _ = template.New("").Funcs(sprig.TxtFuncMap()).ParseFS(templatesFS, "templates/*.tmpl") if overrides, err := templates.ParseGlob(fmt.Sprintf("%s/*.tmpl", markdownTemplateOverridesDir)); err == nil { // doesn't override if templates directory doesn't exist templates = overrides } return &MarkdownRenderer{ gitlabSupportsCommonMark: gitlabSupportsCommonMark, disableApplyAll: disableApplyAll, disableMarkdownFolding: disableMarkdownFolding, disableApply: disableApply, disableRepoLocking: disableRepoLocking, enableDiffMarkdownFormat: enableDiffMarkdownFormat, markdownTemplates: templates, executableName: executableName, hideUnchangedPlanComments: hideUnchangedPlanComments, quietPolicyChecks: quietPolicyChecks, } } // Render formats the data into a markdown string. // nolint: interfacer func (m *MarkdownRenderer) Render(ctx *command.Context, res command.Result, cmd PullCommand) string { commandStr := cases.Title(language.English).String(strings.Replace(cmd.CommandName().String(), "_", " ", -1)) var vcsRequestType string if ctx.Pull.BaseRepo.VCSHost.Type == models.Gitlab { vcsRequestType = "Merge Request" } else { vcsRequestType = "Pull Request" } common := commonData{ Command: commandStr, SubCommand: cmd.SubCommandName(), Verbose: cmd.IsVerbose(), Log: ctx.Log.GetHistory(), PlansDeleted: res.PlansDeleted, DisableApplyAll: m.disableApplyAll || m.disableApply, DisableApply: m.disableApply, DisableRepoLocking: m.disableRepoLocking, EnableDiffMarkdownFormat: m.enableDiffMarkdownFormat, ExecutableName: m.executableName, HideUnchangedPlanComments: m.hideUnchangedPlanComments, QuietPolicyChecks: m.quietPolicyChecks, VcsRequestType: vcsRequestType, } templates := m.markdownTemplates if res.Error != nil { return m.renderTemplateTrimSpace(templates.Lookup("unwrappedErrWithLog"), errData{res.Error.Error(), "", common}) } if res.Failure != "" { return m.renderTemplateTrimSpace(templates.Lookup("failureWithLog"), failureData{res.Failure, "", common}) } return m.renderProjectResults(ctx, res.ProjectResults, common) } func (m *MarkdownRenderer) renderProjectResults(ctx *command.Context, results []command.ProjectResult, common commonData) string { vcsHost := ctx.Pull.BaseRepo.VCSHost.Type var resultsTmplData []projectResultTmplData numPlanSuccesses := 0 numPolicyCheckSuccesses := 0 numPolicyApprovalSuccesses := 0 numVersionSuccesses := 0 numPlansWithChanges := 0 numPlansWithNoChanges := 0 numApplySuccesses := 0 numApplyFailures := 0 numApplyErrors := 0 templates := m.markdownTemplates for _, result := range results { resultData := projectResultTmplData{ Workspace: result.Workspace, RepoRelDir: result.RepoRelDir, ProjectName: result.ProjectName, IsSuccessful: result.IsSuccessful(), } if result.PlanSuccess != nil { result.PlanSuccess.TerraformOutput = strings.TrimSpace(result.PlanSuccess.TerraformOutput) data := planSuccessData{ PlanSuccess: *result.PlanSuccess, PlanWasDeleted: common.PlansDeleted, DisableApply: common.DisableApply, DisableRepoLocking: common.DisableRepoLocking, EnableDiffMarkdownFormat: common.EnableDiffMarkdownFormat, PlanStats: result.PlanSuccess.Stats(), } if m.shouldUseWrappedTmpl(vcsHost, result.PlanSuccess.TerraformOutput) { data.PlanSummary = result.PlanSuccess.Summary() resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessWrapped"), data) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("planSuccessUnwrapped"), data) } resultData.NoChanges = result.PlanSuccess.NoChanges() if result.PlanSuccess.NoChanges() { numPlansWithNoChanges++ } else { numPlansWithChanges++ } numPlanSuccesses++ } else if result.PolicyCheckResults != nil && common.Command == policyCheckCommandTitle { policyCheckResults := policyCheckResultsData{ PreConftestOutput: result.PolicyCheckResults.PreConftestOutput, PostConftestOutput: result.PolicyCheckResults.PostConftestOutput, PolicyCheckResults: *result.PolicyCheckResults, PolicyCheckSummary: result.PolicyCheckResults.Summary(), PolicyApprovalSummary: result.PolicyCheckResults.PolicySummary(), PolicyCleared: result.PolicyCheckResults.PolicyCleared(), commonData: common, } if m.shouldUseWrappedTmpl(vcsHost, result.PolicyCheckResults.CombinedOutput()) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsWrapped"), policyCheckResults) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsUnwrapped"), policyCheckResults) } if result.Error == nil && result.Failure == "" { numPolicyCheckSuccesses++ } } else if result.PolicyCheckResults != nil && common.Command == approvePoliciesCommandTitle { policyCheckResults := policyCheckResultsData{ PolicyCheckResults: *result.PolicyCheckResults, PolicyCheckSummary: result.PolicyCheckResults.Summary(), PolicyApprovalSummary: result.PolicyCheckResults.PolicySummary(), PolicyCleared: result.PolicyCheckResults.PolicyCleared(), commonData: common, } if m.shouldUseWrappedTmpl(vcsHost, result.PolicyCheckResults.CombinedOutput()) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsWrapped"), policyCheckResults) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("policyCheckResultsUnwrapped"), policyCheckResults) } if result.Error == nil && result.Failure == "" { numPolicyApprovalSuccesses++ } } else if result.ApplySuccess != "" { output := strings.TrimSpace(result.ApplySuccess) if m.shouldUseWrappedTmpl(vcsHost, result.ApplySuccess) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("applyWrappedSuccess"), struct{ Output string }{output}) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("applyUnwrappedSuccess"), struct{ Output string }{output}) } numApplySuccesses++ } else if result.VersionSuccess != "" { output := strings.TrimSpace(result.VersionSuccess) if m.shouldUseWrappedTmpl(vcsHost, output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("versionWrappedSuccess"), struct{ Output string }{output}) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("versionUnwrappedSuccess"), struct{ Output string }{output}) } numVersionSuccesses++ } else if result.ImportSuccess != nil { result.ImportSuccess.Output = strings.TrimSpace(result.ImportSuccess.Output) if m.shouldUseWrappedTmpl(vcsHost, result.ImportSuccess.Output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("importSuccessWrapped"), result.ImportSuccess) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("importSuccessUnwrapped"), result.ImportSuccess) } } else if result.StateRmSuccess != nil { result.StateRmSuccess.Output = strings.TrimSpace(result.StateRmSuccess.Output) if m.shouldUseWrappedTmpl(vcsHost, result.StateRmSuccess.Output) { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("stateRmSuccessWrapped"), result.StateRmSuccess) } else { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("stateRmSuccessUnwrapped"), result.StateRmSuccess) } // Error out if no template was found, only if there are no errors or failures. // This is because some errors and failures rely on additional context rendered by templates, but not all errors or failures. } else if !(result.Error != nil || result.Failure != "") { resultData.Rendered = "Found no template. This is a bug!" } // Render error or failure templates. Done outside of previous block so that other context can be rendered for use here. if result.Error != nil { tmpl := templates.Lookup("unwrappedErr") if m.shouldUseWrappedTmpl(vcsHost, result.Error.Error()) { tmpl = templates.Lookup("wrappedErr") } resultData.Rendered = m.renderTemplateTrimSpace(tmpl, errData{result.Error.Error(), resultData.Rendered, common}) if common.Command == applyCommandTitle { numApplyErrors++ } } else if result.Failure != "" { resultData.Rendered = m.renderTemplateTrimSpace(templates.Lookup("failure"), failureData{result.Failure, resultData.Rendered, common}) if common.Command == applyCommandTitle { numApplyFailures++ } } resultsTmplData = append(resultsTmplData, resultData) } var tmpl *template.Template switch { case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") case len(resultsTmplData) == 1 && common.Command == planCommandTitle && numPlanSuccesses == 0: tmpl = templates.Lookup("singleProjectPlanUnsuccessful") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses > 0: tmpl = templates.Lookup("singleProjectPlanSuccess") case len(resultsTmplData) == 1 && common.Command == policyCheckCommandTitle && numPolicyCheckSuccesses == 0: tmpl = templates.Lookup("singleProjectPolicyUnsuccessful") case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses > 0: tmpl = templates.Lookup("singleProjectVersionSuccess") case len(resultsTmplData) == 1 && common.Command == versionCommandTitle && numVersionSuccesses == 0: tmpl = templates.Lookup("singleProjectVersionUnsuccessful") case len(resultsTmplData) == 1 && common.Command == applyCommandTitle: tmpl = templates.Lookup("singleProjectApply") case len(resultsTmplData) == 1 && common.Command == importCommandTitle: tmpl = templates.Lookup("singleProjectImport") case len(resultsTmplData) == 1 && common.Command == stateCommandTitle: switch common.SubCommand { case "rm": tmpl = templates.Lookup("singleProjectStateRm") default: return fmt.Sprintf("no template matched–this is a bug: command=%s, subcommand=%s", common.Command, common.SubCommand) } case common.Command == planCommandTitle: tmpl = templates.Lookup("multiProjectPlan") case common.Command == policyCheckCommandTitle: if numPolicyCheckSuccesses == len(results) { tmpl = templates.Lookup("multiProjectPolicy") } else { tmpl = templates.Lookup("multiProjectPolicyUnsuccessful") } case common.Command == approvePoliciesCommandTitle: if numPolicyApprovalSuccesses == len(results) { tmpl = templates.Lookup("approveAllProjects") } else { tmpl = templates.Lookup("multiProjectPolicyUnsuccessful") } case common.Command == applyCommandTitle: tmpl = templates.Lookup("multiProjectApply") case common.Command == versionCommandTitle: tmpl = templates.Lookup("multiProjectVersion") case common.Command == importCommandTitle: tmpl = templates.Lookup("multiProjectImport") case common.Command == stateCommandTitle: switch common.SubCommand { case "rm": tmpl = templates.Lookup("multiProjectStateRm") default: return fmt.Sprintf("no template matched–this is a bug: command=%s, subcommand=%s", common.Command, common.SubCommand) } default: return fmt.Sprintf("no template matched–this is a bug: command=%s", common.Command) } switch { case common.Command == planCommandTitle: numPlanFailures := len(results) - numPlanSuccesses return m.renderTemplateTrimSpace(tmpl, planResultData{resultsTmplData, common, numPlansWithChanges, numPlansWithNoChanges, numPlanFailures}) case common.Command == applyCommandTitle: return m.renderTemplateTrimSpace(tmpl, applyResultData{resultsTmplData, common, numApplySuccesses, numApplyFailures, numApplyErrors}) } return m.renderTemplateTrimSpace(tmpl, resultData{resultsTmplData, common}) } // shouldUseWrappedTmpl returns true if we should use the wrapped markdown // templates that collapse the output to make the comment smaller on initial // load. Some VCS providers or versions of VCS providers don't support this // syntax. func (m *MarkdownRenderer) shouldUseWrappedTmpl(vcsHost models.VCSHostType, output string) bool { if m.disableMarkdownFolding { return false } // Bitbucket Cloud and Server don't support the folding markdown syntax. if vcsHost == models.BitbucketServer || vcsHost == models.BitbucketCloud { return false } if vcsHost == models.Gitlab && !m.gitlabSupportsCommonMark { return false } return strings.Count(output, "\n") > maxUnwrappedLines } func (m *MarkdownRenderer) renderTemplateTrimSpace(tmpl *template.Template, data interface{}) string { buf := &bytes.Buffer{} if err := tmpl.Execute(buf, data); err != nil { return fmt.Sprintf("Failed to render template, this is a bug: %v", err) } return strings.TrimSpace(buf.String()) }