package policy import ( "fmt" "os" "path/filepath" "runtime" "strings" 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/core/terraform" "github.com/runatlantis/atlantis/server/events/command" "github.com/runatlantis/atlantis/server/logging" ) const ( DefaultConftestVersionEnvKey = "DEFAULT_CONFTEST_VERSION" conftestBinaryName = "conftest" conftestDownloadURLPrefix = "https://github.com/open-policy-agent/conftest/releases/download/v" conftestArch = "x86_64" ) 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 } //go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_conftest_client.go SourceResolver // SourceResolver resolves the policy set to a local fs path 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) } } type ConfTestVersionDownloader struct { downloader terraform.Downloader } func (c ConfTestVersionDownloader) downloadConfTestVersion(v *version.Version, destPath string) (runtime_models.FilePath, error) { versionURLPrefix := fmt.Sprintf("%s%s", conftestDownloadURLPrefix, v.Original()) // download binary in addition to checksum file binURL := fmt.Sprintf("%s/conftest_%s_%s_%s.tar.gz", versionURLPrefix, v.Original(), strings.Title(runtime.GOOS), conftestArch) 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 terraform.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.Warn("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) { policyArgs := []Arg{} policySetNames := []string{} ctx.Log.Debug("policy sets, %s ", ctx.PolicySets) for _, policySet := range ctx.PolicySets.PolicySets { path, err := c.SourceResolver.Resolve(policySet) // Let's not fail the whole step because of a single failure. Log and fail silently if err != nil { ctx.Log.Err("Error resolving policyset %s. err: %s", policySet.Name, err.Error()) continue } policyArg := NewPolicyArg(path) policyArgs = append(policyArgs, policyArg) policySetNames = append(policySetNames, policySet.Name) } inputFile := filepath.Join(workdir, ctx.GetShowResultFileName()) args := ConftestTestCommandArgs{ PolicyArgs: policyArgs, ExtraArgs: extraArgs, InputFile: inputFile, Command: executablePath, } serializedArgs, err := args.build() if err != 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") } initialOutput := fmt.Sprintf("Checking plan against the following policies: \n %s\n", strings.Join(policySetNames, "\n ")) cmdOutput, err := c.Exec.CombinedOutput(serializedArgs, envs, workdir) return c.sanitizeOutput(inputFile, initialOutput+cmdOutput), err } func (c *ConfTestExecutorWorkflow) sanitizeOutput(inputFile string, output string) string { return strings.Replace(output, inputFile, "", -1) } func (c *ConfTestExecutorWorkflow) EnsureExecutorVersion(log logging.SimpleLogging, v *version.Version) (string, error) { // we have no information to proceed so fail hard if c.DefaultConftestVersion == nil && v == nil { return "", errors.New("no conftest version configured/specified") } 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 }