mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## Problem
Tests that run real Terraform (e.g.
`enterprise/coderd.TestWorkspaceTemplateParamsChange`,
`provisioner/terraform.TestProvision`) intermittently fail when
populating the shared provider cache. On a cache miss,
`DownloadTFProviders` shells out to `terraform init` and `terraform
providers mirror` against the live registry, which periodically returns
transient 5xx errors from the registry/GitHub (504, 500). The
cache-population helper had no application-level retry, so a single
transient failure failed the whole test. Terraform's own registry client
only retries each request once ("the request failed after 2 attempts"),
which is insufficient for these bursts.
## Fix
`runCmd` now retries on any non-zero exit using
`github.com/coder/retry`, logging each failed attempt and preserving the
original failure message format. Retry-all is safe here because
`terraform init` and `terraform providers mirror` are idempotent: each
run reconciles the existing state in the working directory.
The backoff window is deliberately wide: 5 attempts with a 5s floor and
30s ceiling. `coder/retry` grows the delay by phi (~1.618) from the
floor and caps it at the ceiling, so attempts start at roughly t=0s, 8s,
21s, 42s, and 72s (waits of ~8.1s, ~13.1s, ~21.2s, and 30s capped, plus
command runtime). Registry/GitHub incidents typically last seconds to
minutes rather than a single unlucky request, so a narrow window would
only survive an isolated blip, while the early second attempt (~8s)
still recovers quickly from brief ones. This is affordable because the
network path runs only on a cache miss, not on every test: a populated
cache short-circuits via `os.Stat` and is reused within and across runs
(persisted by `.github/actions/test-cache`). The wait is therefore
rarely incurred and is negligible against the 20m per-package test
timeout. The only downside is a slower failure on a genuinely doomed
run.
This only affects the test provider-cache helper. Production provisioner
code, the Windows no-op path, and the CI cache strategy are unchanged.
Refs https://github.com/coder/internal/issues/1201
<details>
<summary>Investigation notes</summary>
The CI cache (`~/.cache/coderv2-test`, via `.github/actions/test-cache`)
is persisted across runs and keyed by a hash of a stable caller-supplied
label + template file contents, so cache hits avoid the network
entirely. The flake only surfaces on a cache miss (provider version
bump, monthly cache reset, or new label/template), where the populating
`terraform init` was the sole unprotected network call. This change
closes that gap without weakening the "use real Terraform" intent of the
tests.
</details>
---
🤖 Generated with Coder Agents on behalf of @jscottmiller.
223 lines
8.1 KiB
Go
223 lines
8.1 KiB
Go
//go:build linux || darwin
|
|
|
|
package testutil
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/retry"
|
|
)
|
|
|
|
const (
|
|
// terraformConfigFileName is the name of the Terraform CLI config file.
|
|
terraformConfigFileName = "terraform.rc"
|
|
// cacheProvidersDirName is the subdirectory name for the provider mirror.
|
|
cacheProvidersDirName = "providers"
|
|
// cacheTemplateFilesDirName is the subdirectory name for template files.
|
|
cacheTemplateFilesDirName = "files"
|
|
)
|
|
|
|
// hashTemplateFilesAndTestName generates a unique hash based on test name and template files.
|
|
func hashTemplateFilesAndTestName(t *testing.T, testName string, templateFiles map[string]string) string {
|
|
t.Helper()
|
|
|
|
sortedFileNames := make([]string, 0, len(templateFiles))
|
|
for fileName := range templateFiles {
|
|
sortedFileNames = append(sortedFileNames, fileName)
|
|
}
|
|
slices.Sort(sortedFileNames)
|
|
|
|
// Inserting a delimiter between the file name and the file content
|
|
// ensures that a file named `ab` with content `cd`
|
|
// will not hash to the same value as a file named `abc` with content `d`.
|
|
// This can still happen if the file name or content include the delimiter,
|
|
// but hopefully they won't.
|
|
delimiter := []byte("🎉 🌱 🌷")
|
|
|
|
hasher := sha256.New()
|
|
for _, fileName := range sortedFileNames {
|
|
file := templateFiles[fileName]
|
|
_, err := hasher.Write([]byte(fileName))
|
|
require.NoError(t, err)
|
|
_, err = hasher.Write(delimiter)
|
|
require.NoError(t, err)
|
|
_, err = hasher.Write([]byte(file))
|
|
require.NoError(t, err)
|
|
}
|
|
_, err := hasher.Write(delimiter)
|
|
require.NoError(t, err)
|
|
_, err = hasher.Write([]byte(testName))
|
|
require.NoError(t, err)
|
|
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
// WriteTFCliConfig writes a Terraform CLI config file (`terraform.rc`) in `dir` to enforce using the local provider mirror.
|
|
// This blocks network access for providers, forcing Terraform to use only what's cached in `dir`.
|
|
// Returns the path to the generated config file.
|
|
func WriteTFCliConfig(t *testing.T, dir string) string {
|
|
t.Helper()
|
|
|
|
cliConfigPath := filepath.Join(dir, terraformConfigFileName)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(cliConfigPath), 0o700))
|
|
|
|
content := fmt.Sprintf(`
|
|
provider_installation {
|
|
filesystem_mirror {
|
|
path = "%s"
|
|
include = ["*/*"]
|
|
}
|
|
direct {
|
|
exclude = ["*/*"]
|
|
}
|
|
}
|
|
`, filepath.Join(dir, cacheProvidersDirName))
|
|
require.NoError(t, os.WriteFile(cliConfigPath, []byte(content), 0o600))
|
|
return cliConfigPath
|
|
}
|
|
|
|
const (
|
|
runCmdMaxAttempts = 5
|
|
runCmdRetryFloor = 5 * time.Second
|
|
runCmdRetryCeil = 30 * time.Second
|
|
)
|
|
|
|
// runCmd runs the given command, retrying on any non-zero exit. The provider
|
|
// cache population commands hit the network and intermittently fail with
|
|
// transient registry/GitHub 5xx errors; retrying is safe because `terraform
|
|
// init` and `terraform providers mirror` are idempotent. The backoff window is
|
|
// wide because registry incidents last seconds to minutes, and the wait is
|
|
// only incurred on a cache miss (see DownloadTFProviders).
|
|
func runCmd(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
|
|
ctx := t.Context()
|
|
var (
|
|
attempt int
|
|
lastErr error
|
|
lastStdout, lastStderr string
|
|
)
|
|
for r := retry.New(runCmdRetryFloor, runCmdRetryCeil); attempt < runCmdMaxAttempts && r.Wait(ctx); attempt++ {
|
|
stdout, stderr := bytes.NewBuffer(nil), bytes.NewBuffer(nil)
|
|
// #nosec G204 - args are test-controlled (the terraform binary plus fixed
|
|
// subcommands and a cache dir path), never external input.
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = stdout
|
|
cmd.Stderr = stderr
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return
|
|
}
|
|
lastErr = err
|
|
lastStdout, lastStderr = stdout.String(), stderr.String()
|
|
t.Logf("attempt %d/%d to run %s failed: %s\nstdout: %s\nstderr: %s",
|
|
attempt+1, runCmdMaxAttempts, strings.Join(args, " "), err, lastStdout, lastStderr)
|
|
}
|
|
if lastErr == nil {
|
|
// The loop exited without running a command, which means r.Wait saw the
|
|
// context canceled before the first attempt. Report that, not a nil error.
|
|
t.Fatalf("failed to run %s: %v", strings.Join(args, " "), ctx.Err())
|
|
}
|
|
t.Fatalf("failed to run %s after %d attempts: %s\nstdout: %s\nstderr: %s",
|
|
strings.Join(args, " "), attempt, lastErr, lastStdout, lastStderr)
|
|
}
|
|
|
|
// GetTestTFCacheDir returns a unique cache directory path based on the test name and template files.
|
|
// Each test gets a unique cache dir based on its name and template files.
|
|
// This ensures that tests can download providers in parallel and that they
|
|
// will redownload providers if the template files change.
|
|
func GetTestTFCacheDir(t *testing.T, rootDir string, testName string, templateFiles map[string]string) string {
|
|
t.Helper()
|
|
|
|
hash := hashTemplateFilesAndTestName(t, testName, templateFiles)
|
|
dir := filepath.Join(rootDir, hash[:12])
|
|
return dir
|
|
}
|
|
|
|
// DownloadTFProviders ensures Terraform providers are downloaded and cached locally in a unique directory for the test.
|
|
// Uses `terraform init` then `mirror` to populate the cache if needed.
|
|
// Returns the cache directory path.
|
|
func DownloadTFProviders(t *testing.T, rootDir string, testName string, templateFiles map[string]string) string {
|
|
t.Helper()
|
|
|
|
dir := GetTestTFCacheDir(t, rootDir, testName, templateFiles)
|
|
if _, err := os.Stat(dir); err == nil {
|
|
t.Logf("%s: using cached terraform providers", testName)
|
|
return dir
|
|
}
|
|
filesDir := filepath.Join(dir, cacheTemplateFilesDirName)
|
|
defer func() {
|
|
// The files dir will contain a copy of terraform providers generated
|
|
// by the terraform init command. We don't want to persist them since
|
|
// we already have a registry mirror in the providers dir.
|
|
if err := os.RemoveAll(filesDir); err != nil {
|
|
t.Logf("failed to remove files dir %s: %s", filesDir, err)
|
|
}
|
|
if !t.Failed() {
|
|
return
|
|
}
|
|
// If `DownloadTFProviders` function failed, clean up the cache dir.
|
|
// We don't want to leave it around because it may be incomplete or corrupted.
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
t.Logf("failed to remove dir %s: %s", dir, err)
|
|
}
|
|
}()
|
|
|
|
require.NoError(t, os.MkdirAll(filesDir, 0o700))
|
|
|
|
for fileName, file := range templateFiles {
|
|
filePath := filepath.Join(filesDir, fileName)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o700))
|
|
require.NoError(t, os.WriteFile(filePath, []byte(file), 0o600))
|
|
}
|
|
|
|
providersDir := filepath.Join(dir, cacheProvidersDirName)
|
|
require.NoError(t, os.MkdirAll(providersDir, 0o700))
|
|
|
|
// We need to run init because if a test uses modules in its template,
|
|
// the mirror command will fail without it.
|
|
runCmd(t, filesDir, "terraform", "init")
|
|
// Now, mirror the providers into `providersDir`. We use this explicit mirror
|
|
// instead of relying only on the standard Terraform plugin cache.
|
|
//
|
|
// Why? Because this mirror, when used with the CLI config from `WriteCliConfig`,
|
|
// prevents Terraform from hitting the network registry during `plan`. This cuts
|
|
// down on network calls, making CI tests less flaky.
|
|
//
|
|
// In contrast, the standard cache *still* contacts the registry for metadata
|
|
// during `init`, even if the plugins are already cached locally - see link below.
|
|
//
|
|
// Ref: https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache
|
|
// > When a plugin cache directory is enabled, the terraform init command will
|
|
// > still use the configured or implied installation methods to obtain metadata
|
|
// > about which plugins are available
|
|
runCmd(t, filesDir, "terraform", "providers", "mirror", providersDir)
|
|
|
|
return dir
|
|
}
|
|
|
|
// CacheTFProviders caches providers locally and generates a Terraform CLI config to use *only* that cache.
|
|
// This setup prevents network access for providers during `terraform init`, improving reliability
|
|
// in subsequent test runs.
|
|
// Returns the path to the generated CLI config file.
|
|
func CacheTFProviders(t *testing.T, rootDir string, testName string, templateFiles map[string]string) string {
|
|
t.Helper()
|
|
|
|
providersParentDir := DownloadTFProviders(t, rootDir, testName, templateFiles)
|
|
cliConfigPath := WriteTFCliConfig(t, providersParentDir)
|
|
return cliConfigPath
|
|
}
|