Files
atlantis/server/core/runtime/policy/conftest_client.go
Henry Muru Paenga 9c7e5fcac8 feat: OpenTofu support (#4499)
Co-authored-by: PePe Amengual <2208324+jamengual@users.noreply.github.com>
2024-09-29 18:13:20 -07:00

349 lines
10 KiB
Go

package policy
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"encoding/json"
"regexp"
"github.com/hashicorp/go-getter/v2"
"github.com/hashicorp/go-multierror"
version "github.com/hashicorp/go-version"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/core/config/valid"
"github.com/runatlantis/atlantis/server/core/runtime/cache"
runtime_models "github.com/runatlantis/atlantis/server/core/runtime/models"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/logging"
)
const (
DefaultConftestVersionEnvKey = "DEFAULT_CONFTEST_VERSION"
conftestBinaryName = "conftest"
conftestDownloadURLPrefix = "https://github.com/open-policy-agent/conftest/releases/download/v"
)
type Arg struct {
Param string
Option string
}
func (a Arg) build() []string {
return []string{a.Option, a.Param}
}
func NewPolicyArg(parameter string) Arg {
return Arg{
Param: parameter,
Option: "-p",
}
}
type ConftestTestCommandArgs struct {
PolicyArgs []Arg
ExtraArgs []string
InputFile string
Command string
}
func (c ConftestTestCommandArgs) build() ([]string, error) {
if len(c.PolicyArgs) == 0 {
return []string{}, errors.New("no policies specified")
}
// add the subcommand
commandArgs := []string{c.Command, "test"}
for _, a := range c.PolicyArgs {
commandArgs = append(commandArgs, a.build()...)
}
// add hardcoded options
commandArgs = append(commandArgs, c.InputFile, "--no-color")
// add extra args provided through server config
commandArgs = append(commandArgs, c.ExtraArgs...)
return commandArgs, nil
}
// SourceResolver resolves the policy set to a local fs path
//
//go:generate pegomock generate --package mocks -o mocks/mock_conftest_client.go SourceResolver
type SourceResolver interface {
Resolve(policySet valid.PolicySet) (string, error)
}
// LocalSourceResolver resolves a local policy set to a local fs path
type LocalSourceResolver struct {
}
func (p *LocalSourceResolver) Resolve(policySet valid.PolicySet) (string, error) {
return policySet.Path, nil
}
// SourceResolverProxy proxies to underlying source resolvers dynamically
type SourceResolverProxy struct {
localSourceResolver SourceResolver
}
func (p *SourceResolverProxy) Resolve(policySet valid.PolicySet) (string, error) {
switch source := policySet.Source; source {
case valid.LocalPolicySet:
return p.localSourceResolver.Resolve(policySet)
default:
return "", fmt.Errorf("unable to resolve policy set source %s", source)
}
}
//go:generate pegomock generate --package mocks -o mocks/mock_downloader.go Downloader
type Downloader interface {
GetAny(dst, src string) error
}
type ConfTestGoGetterVersionDownloader struct{}
func (c ConfTestGoGetterVersionDownloader) GetAny(dst, src string) error {
_, err := getter.GetAny(context.Background(), dst, src)
return err
}
type ConfTestVersionDownloader struct {
downloader Downloader
}
func (c ConfTestVersionDownloader) downloadConfTestVersion(v *version.Version, destPath string) (runtime_models.FilePath, error) {
versionURLPrefix := fmt.Sprintf("%s%s", conftestDownloadURLPrefix, v.Original())
conftestPlatform := getPlatform()
if conftestPlatform == "" {
return runtime_models.LocalFilePath(""), fmt.Errorf("don't know where to find conftest for %s on %s", runtime.GOOS, runtime.GOARCH)
}
// download binary in addition to checksum file
binURL := fmt.Sprintf("%s/conftest_%s_%s.tar.gz", versionURLPrefix, v.Original(), conftestPlatform)
checksumURL := fmt.Sprintf("%s/checksums.txt", versionURLPrefix)
// underlying implementation uses go-getter so the URL is formatted as such.
// i know i know, I'm assuming an interface implementation with my inputs.
// realistically though the interface just exists for testing so ¯\_(ツ)_/¯
fullSrcURL := fmt.Sprintf("%s?checksum=file:%s", binURL, checksumURL)
if err := c.downloader.GetAny(destPath, fullSrcURL); err != nil {
return runtime_models.LocalFilePath(""), errors.Wrapf(err, "downloading conftest version %s at %q", v.String(), fullSrcURL)
}
binPath := filepath.Join(destPath, "conftest")
return runtime_models.LocalFilePath(binPath), nil
}
// ConfTestExecutorWorkflow runs a versioned conftest binary with the args built from the project context.
// Project context defines whether conftest runs a local policy set or runs a test on a remote policy set.
type ConfTestExecutorWorkflow struct {
SourceResolver SourceResolver
VersionCache cache.ExecutionVersionCache
DefaultConftestVersion *version.Version
Exec runtime_models.Exec
}
func NewConfTestExecutorWorkflow(log logging.SimpleLogging, versionRootDir string, conftestDownloder Downloader) *ConfTestExecutorWorkflow {
downloader := ConfTestVersionDownloader{
downloader: conftestDownloder,
}
version, err := getDefaultVersion()
if err != nil {
// conftest default versions are not essential to service startup so let's not block on it.
log.Info("failed to get default conftest version. Will attempt request scoped lazy loads %s", err.Error())
}
versionCache := cache.NewExecutionVersionLayeredLoadingCache(
conftestBinaryName,
versionRootDir,
downloader.downloadConfTestVersion,
)
return &ConfTestExecutorWorkflow{
VersionCache: versionCache,
DefaultConftestVersion: version,
SourceResolver: &SourceResolverProxy{
localSourceResolver: &LocalSourceResolver{},
},
Exec: runtime_models.LocalExec{},
}
}
func (c *ConfTestExecutorWorkflow) Run(ctx command.ProjectContext, executablePath string, envs map[string]string, workdir string, extraArgs []string) (string, error) {
ctx.Log.Debug("policy sets, %s ", ctx.PolicySets)
inputFile := filepath.Join(workdir, ctx.GetShowResultFileName())
var policySetResults []models.PolicySetResult
var combinedErr error
for _, policySet := range ctx.PolicySets.PolicySets {
path, resolveErr := c.SourceResolver.Resolve(policySet)
// Let's not fail the whole step because of a single failure. Log and fail silently
if resolveErr != nil {
ctx.Log.Err("Error resolving policyset %s. err: %s", policySet.Name, resolveErr.Error())
continue
}
args := ConftestTestCommandArgs{
PolicyArgs: []Arg{NewPolicyArg(path)},
ExtraArgs: extraArgs,
InputFile: inputFile,
Command: executablePath,
}
serializedArgs, _ := args.build()
cmdOutput, cmdErr := c.Exec.CombinedOutput(serializedArgs, envs, workdir)
if cmdErr != nil {
// Since we're running conftest for each policyset, individual command errors should be concatenated.
if isValidConftestOutput(cmdOutput) {
combinedErr = multierror.Append(combinedErr, fmt.Errorf("policy_set: %s: conftest: some policies failed", policySet.Name))
} else {
combinedErr = multierror.Append(combinedErr, fmt.Errorf("policy_set: %s: conftest: %s", policySet.Name, cmdOutput))
}
}
passed := true
if hasFailures(cmdOutput) {
passed = false
}
policySetResults = append(policySetResults, models.PolicySetResult{
PolicySetName: policySet.Name,
PolicyOutput: cmdOutput,
Passed: passed,
ReqApprovals: policySet.ApproveCount,
})
}
if policySetResults == nil {
ctx.Log.Warn("no policies have been configured.")
return "", nil
// TODO: enable when we can pass policies in otherwise e2e tests with policy checks fail
// return "", errors.Wrap(err, "building args")
}
marshaledStatus, err := json.Marshal(policySetResults)
if err != nil {
return "", errors.New("cannot marshal data into []PolicySetResult. data")
}
// Write policy check results to a file which can be used by custom workflow run steps for metrics, notifications, etc.
policyCheckResultFile := filepath.Join(workdir, ctx.GetPolicyCheckResultFileName())
err = os.WriteFile(policyCheckResultFile, marshaledStatus, 0600)
combinedErr = multierror.Append(combinedErr, err)
// Multierror will wrap combined errors in a way that the upstream functions won't be able to read it as nil.
// Let's pass nil back if there are no wrapped errors.
if errors.Unwrap(combinedErr) == nil {
combinedErr = nil
}
output := string(marshaledStatus)
return c.sanitizeOutput(inputFile, output), combinedErr
}
func (c *ConfTestExecutorWorkflow) sanitizeOutput(inputFile string, output string) string {
return strings.Replace(output, inputFile, "<redacted plan file>", -1)
}
func (c *ConfTestExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *version.Version) (string, error) {
// we have no information to proceed, so fallback to `conftest` command or fail hard
if c.DefaultConftestVersion == nil && v == nil {
localPath, err := c.Exec.LookPath(conftestBinaryName)
if err == nil {
log.Info("conftest version is not specified, so fallback to conftest command")
return localPath, nil
}
return "", errors.New("no conftest version configured/specified or not found conftest command")
}
var versionToRetrieve *version.Version
if v == nil {
versionToRetrieve = c.DefaultConftestVersion
} else {
versionToRetrieve = v
}
localPath, err := c.VersionCache.Get(versionToRetrieve)
if err != nil {
return "", err
}
return localPath, nil
}
func getDefaultVersion() (*version.Version, error) {
// ensure version is not default version.
// first check for the env var and if that doesn't exist use the local executable version
defaultVersion, exists := os.LookupEnv(DefaultConftestVersionEnvKey)
if !exists {
return nil, fmt.Errorf("%s not set", DefaultConftestVersionEnvKey)
}
wrappedVersion, err := version.NewVersion(defaultVersion)
if err != nil {
return nil, errors.Wrapf(err, "wrapping version %s", defaultVersion)
}
return wrappedVersion, nil
}
// Checks if output from conftest is a valid output.
func isValidConftestOutput(output string) bool {
r := regexp.MustCompile(`^(WARN|FAIL|\[)`)
if match := r.FindString(output); match != "" {
return true
}
return false
}
// hasFailures checks whether any conftest policies have failed
func hasFailures(output string) bool {
r := regexp.MustCompile(`([1-9]([0-9]?)* failure|failures": \[)`)
if match := r.FindString(output); match != "" {
return true
}
return false
}
func getPlatform() string {
platform := runtime.GOOS + "_" + runtime.GOARCH
switch platform {
case "linux_amd64":
return "Linux_x86_64"
case "linux_arm64":
return "Linux_arm64"
case "darwin_amd64":
return "Darwin_x86_64"
case "darwin_arm64":
return "Darwin_arm64"
default:
return ""
}
}