open-consul/agent/connect/ca/testing.go
R.B. Boyer ec0857075e
connect: use -dev-no-store-token for test vaults to reduce source of flakes (#15691)
It turns out that by default the dev mode vault server will attempt to interact with the
filesystem to store the provided root token. If multiple vault instances are running
they'll all awkwardly share the filesystem and if timing results in one server stopping
while another one is starting then the starting one will error with:

    Error initializing Dev mode: rename /home/circleci/.vault-token.tmp /home/circleci/.vault-token: no such file or directory

This change uses `-dev-no-store-token` to bypass that source of flakes. Also the
stdout/stderr from the vault process is included if the test fails.

The introduction of more `t.Parallel` use in https://github.com/hashicorp/consul/pull/15669
increased the likelihood of this failure, but any of the tests with multiple vaults in use
(or running multiple package tests in parallel that all use vault) were eventually going
to flake on this.
2022-12-06 13:15:13 -06:00

365 lines
8.9 KiB
Go

package ca
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-uuid"
vaultapi "github.com/hashicorp/vault/api"
"github.com/mitchellh/go-testing-interface"
"github.com/stretchr/testify/require"
"github.com/hashicorp/consul/agent/connect"
"github.com/hashicorp/consul/sdk/freeport"
"github.com/hashicorp/consul/sdk/testutil/retry"
)
// KeyTestCases is a list of the important CA key types that we should test
// against when signing. For now leaf keys are always EC P256 but CA can be EC
// (any NIST curve) or RSA (2048, 4096). Providers must be able to complete all
// signing operations with both types that includes:
// - Sign must be able to sign EC P256 leaf with all these types of CA key
// - CrossSignCA must be able to sign all these types of new CA key with all
// these types of old CA key.
// - SignIntermediate muse bt able to sign all the types of secondary
// intermediate CA key with all these types of primary CA key
var KeyTestCases = []KeyTestCase{
{
Desc: "Default Key Type (EC 256)",
KeyType: connect.DefaultPrivateKeyType,
KeyBits: connect.DefaultPrivateKeyBits,
},
{
Desc: "RSA 2048",
KeyType: "rsa",
KeyBits: 2048,
},
}
type KeyTestCase struct {
Desc string
KeyType string
KeyBits int
}
// CASigningKeyTypes is a struct with params for tests that sign one CA CSR with
// another CA key.
type CASigningKeyTypes struct {
Desc string
SigningKeyType string
SigningKeyBits int
CSRKeyType string
CSRKeyBits int
}
// CASigningKeyTypeCases returns the cross-product of the important supported CA
// key types for generating table tests for CA signing tests (CrossSignCA and
// SignIntermediate).
func CASigningKeyTypeCases() []CASigningKeyTypes {
cases := make([]CASigningKeyTypes, 0, len(KeyTestCases)*len(KeyTestCases))
for _, outer := range KeyTestCases {
for _, inner := range KeyTestCases {
cases = append(cases, CASigningKeyTypes{
Desc: fmt.Sprintf("%s-%d signing %s-%d", outer.KeyType, outer.KeyBits,
inner.KeyType, inner.KeyBits),
SigningKeyType: outer.KeyType,
SigningKeyBits: outer.KeyBits,
CSRKeyType: inner.KeyType,
CSRKeyBits: inner.KeyBits,
})
}
}
return cases
}
// TestConsulProvider creates a new ConsulProvider, taking care to stub out it's
// Logger so that logging calls don't panic. If logging output is important
func TestConsulProvider(t testing.T, d ConsulProviderStateDelegate) *ConsulProvider {
logger := hclog.New(&hclog.LoggerOptions{Output: io.Discard})
provider := &ConsulProvider{Delegate: d, logger: logger}
return provider
}
// SkipIfVaultNotPresent skips the test if the vault binary is not in PATH.
//
// These tests may be skipped in CI. They are run as part of a separate
// integration test suite.
func SkipIfVaultNotPresent(t testing.T) {
// Try to safeguard against tests that will never run in CI.
// This substring should match the pattern used by the
// test-connect-ca-providers CI job.
if !strings.Contains(t.Name(), "Vault") {
t.Fatalf("test name must contain Vault, otherwise CI will never run it")
}
vaultBinaryName := os.Getenv("VAULT_BINARY_NAME")
if vaultBinaryName == "" {
vaultBinaryName = "vault"
}
path, err := exec.LookPath(vaultBinaryName)
if err != nil || path == "" {
t.Skipf("%q not found on $PATH - download and install to run this test", vaultBinaryName)
}
}
func NewTestVaultServer(t testing.T) *TestVaultServer {
vaultBinaryName := os.Getenv("VAULT_BINARY_NAME")
if vaultBinaryName == "" {
vaultBinaryName = "vault"
}
path, err := exec.LookPath(vaultBinaryName)
if err != nil || path == "" {
t.Fatalf("%q not found on $PATH", vaultBinaryName)
}
ports := freeport.GetN(t, 2)
var (
clientAddr = fmt.Sprintf("127.0.0.1:%d", ports[0])
clusterAddr = fmt.Sprintf("127.0.0.1:%d", ports[1])
)
const token = "root"
client, err := vaultapi.NewClient(&vaultapi.Config{
Address: "http://" + clientAddr,
})
require.NoError(t, err)
client.SetToken(token)
args := []string{
"server",
"-dev",
"-dev-root-token-id",
token,
"-dev-listen-address",
clientAddr,
"-address",
clusterAddr,
// We pass '-dev-no-store-token' to avoid having multiple vaults oddly
// interact and fail like this:
//
// Error initializing Dev mode: rename /home/circleci/.vault-token.tmp /home/circleci/.vault-token: no such file or directory
//
"-dev-no-store-token",
}
cmd := exec.Command(vaultBinaryName, args...)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
require.NoError(t, cmd.Start())
testVault := &TestVaultServer{
RootToken: token,
Addr: "http://" + clientAddr,
cmd: cmd,
client: client,
}
t.Cleanup(func() {
if err := testVault.Stop(); err != nil {
t.Logf("failed to stop vault server: %v", err)
}
})
testVault.WaitUntilReady(t)
return testVault
}
type TestVaultServer struct {
RootToken string
Addr string
cmd *exec.Cmd
client *vaultapi.Client
}
var printedVaultVersion sync.Once
func (v *TestVaultServer) Client() *vaultapi.Client {
return v.client
}
func (v *TestVaultServer) WaitUntilReady(t testing.T) {
var version string
retry.Run(t, func(r *retry.R) {
resp, err := v.client.Sys().Health()
if err != nil {
r.Fatalf("err: %v", err)
}
if !resp.Initialized {
r.Fatalf("vault server is not initialized")
}
if resp.Sealed {
r.Fatalf("vault server is sealed")
}
version = resp.Version
})
printedVaultVersion.Do(func() {
fmt.Fprintf(os.Stderr, "[INFO] agent/connect/ca: testing with vault server version: %s\n", version)
})
}
func (v *TestVaultServer) Stop() error {
// There was no process
if v.cmd == nil {
return nil
}
if v.cmd.Process != nil {
if err := v.cmd.Process.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("failed to kill vault server: %v", err)
}
}
// wait for the process to exit to be sure that the data dir can be
// deleted on all platforms.
if err := v.cmd.Wait(); err != nil {
if strings.Contains(err.Error(), "exec: Wait was already called") {
return nil
}
return err
}
return nil
}
func requireTrailingNewline(t testing.T, leafPEM string) {
t.Helper()
if len(leafPEM) == 0 {
t.Fatalf("cert is empty")
}
if '\n' != rune(leafPEM[len(leafPEM)-1]) {
t.Fatalf("cert do not end with a new line")
}
}
// The zero value implies unprivileged.
type VaultTokenAttributes struct {
RootPath, IntermediatePath string
ConsulManaged bool
VaultManaged bool
WithSudo bool
CustomRules string
}
func (a *VaultTokenAttributes) DisplayName() string {
switch {
case a == nil:
return "unprivileged"
case a.CustomRules != "":
return "custom"
case a.ConsulManaged:
return "consul-managed"
case a.VaultManaged:
return "vault-managed"
default:
return "unprivileged"
}
}
func (a *VaultTokenAttributes) Rules(t testing.T) string {
switch {
case a == nil:
return ""
case a.CustomRules != "":
return a.CustomRules
case a.RootPath == "":
t.Fatal("missing required RootPath")
return "" // dead code
case a.IntermediatePath == "":
t.Fatal("missing required IntermediatePath")
return "" // dead code
case a.ConsulManaged:
// Consul Managed PKI Mounts
rules := fmt.Sprintf(`
path "sys/mounts" {
capabilities = [ "read" ]
}
path "sys/mounts/%[1]s" {
capabilities = [ "create", "read", "update", "delete", "list" ]
}
path "sys/mounts/%[2]s" {
capabilities = [ "create", "read", "update", "delete", "list" ]
}
# Needed for Consul 1.11+
path "sys/mounts/%[2]s/tune" {
capabilities = [ "update" ]
}
# vault token renewal
path "auth/token/renew-self" {
capabilities = [ "update" ]
}
path "auth/token/lookup-self" {
capabilities = [ "read" ]
}
path "%[1]s/*" {
capabilities = [ "create", "read", "update", "delete", "list" ]
}
path "%[2]s/*" {
capabilities = [ "create", "read", "update", "delete", "list" ]
}
`, a.RootPath, a.IntermediatePath)
if a.WithSudo {
rules += fmt.Sprintf(`
path "%[1]s/root/sign-self-issued" {
capabilities = [ "sudo", "update" ]
}
`, a.RootPath)
}
return rules
case a.VaultManaged:
// Vault-managed PKI root.
t.Fatal("TODO: implement this and use it in tests")
return ""
default:
// zero value
return ""
}
}
func CreateVaultTokenWithAttrs(t testing.T, client *vaultapi.Client, attr *VaultTokenAttributes) string {
policyName, err := uuid.GenerateUUID()
require.NoError(t, err)
rules := attr.Rules(t)
token := createVaultTokenAndPolicy(t, client, policyName, rules)
// t.Logf("created vault token with scope %q: %s", attr.DisplayName(), token)
return token
}
func createVaultTokenAndPolicy(t testing.T, client *vaultapi.Client, policyName, policyRules string) string {
require.NoError(t, client.Sys().PutPolicy(policyName, policyRules))
renew := true
tok, err := client.Auth().Token().Create(&vaultapi.TokenCreateRequest{
Policies: []string{policyName},
Renewable: &renew,
})
require.NoError(t, err)
return tok.Auth.ClientToken
}