consul/agent/hcp/bootstrap/bootstrap_test.go

322 lines
7.9 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package bootstrap
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/consul/agent/hcp/bootstrap/constants"
hcpclient "github.com/hashicorp/consul/agent/hcp/client"
"github.com/hashicorp/consul/lib"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/hashicorp/consul/tlsutil"
)
func Test_loadPersistedBootstrapConfig(t *testing.T) {
type expect struct {
loaded bool
warning string
}
type testCase struct {
existingCluster bool
disableManagementToken bool
mutateFn func(t *testing.T, dir string)
expect expect
}
run := func(t *testing.T, tc testCase) {
dataDir := testutil.TempDir(t, "load-bootstrap-cfg")
dir := filepath.Join(dataDir, constants.SubDir)
// Do some common setup as if we received config from HCP and persisted it to disk.
require.NoError(t, lib.EnsurePath(dir, true))
require.NoError(t, persistSuccessMarker(dir))
if !tc.existingCluster {
caCert, caKey, err := tlsutil.GenerateCA(tlsutil.CAOpts{})
require.NoError(t, err)
serverCert, serverKey, err := testLeaf(caCert, caKey)
require.NoError(t, err)
require.NoError(t, persistTLSCerts(dir, serverCert, serverKey, []string{caCert}))
cfgJSON := `{"bootstrap_expect": 8}`
require.NoError(t, persistBootstrapConfig(dir, cfgJSON))
}
var token string
if !tc.disableManagementToken {
var err error
token, err = uuid.GenerateUUID()
require.NoError(t, err)
require.NoError(t, persistManagementToken(dir, token))
}
// Optionally mutate the persisted data to trigger errors while loading.
if tc.mutateFn != nil {
tc.mutateFn(t, dir)
}
ui := cli.NewMockUi()
cfg, loaded := LoadPersistedBootstrapConfig(dataDir, ui)
require.Equal(t, tc.expect.loaded, loaded, ui.ErrorWriter.String())
if loaded {
require.Equal(t, token, cfg.ManagementToken)
require.Empty(t, ui.ErrorWriter.String())
} else {
require.Nil(t, cfg)
require.Contains(t, ui.ErrorWriter.String(), tc.expect.warning)
}
}
tt := map[string]testCase{
"existing cluster with valid files": {
existingCluster: true,
// Don't mutate, files from setup are valid.
mutateFn: nil,
expect: expect{
loaded: true,
warning: "",
},
},
"existing cluster no token": {
existingCluster: true,
disableManagementToken: true,
expect: expect{
loaded: false,
},
},
"existing cluster no files": {
existingCluster: true,
mutateFn: func(t *testing.T, dir string) {
// Remove all files
require.NoError(t, os.RemoveAll(dir))
},
expect: expect{
loaded: false,
// No warnings since we assume we need to fetch config from HCP for the first time.
warning: "",
},
},
"new cluster with valid files": {
// Don't mutate, files from setup are valid.
mutateFn: nil,
expect: expect{
loaded: true,
warning: "",
},
},
"new cluster with no token": {
disableManagementToken: true,
expect: expect{
loaded: false,
},
},
"new cluster some files": {
mutateFn: func(t *testing.T, dir string) {
// Remove one of the required files
require.NoError(t, os.Remove(filepath.Join(dir, CertFileName)))
},
expect: expect{
loaded: false,
warning: "configuration files on disk are incomplete",
},
},
"new cluster no files": {
mutateFn: func(t *testing.T, dir string) {
// Remove all files
require.NoError(t, os.RemoveAll(dir))
},
expect: expect{
loaded: false,
// No warnings since we assume we need to fetch config from HCP for the first time.
warning: "",
},
},
"new cluster invalid cert": {
mutateFn: func(t *testing.T, dir string) {
name := filepath.Join(dir, CertFileName)
require.NoError(t, os.WriteFile(name, []byte("not-a-cert"), 0600))
},
expect: expect{
loaded: false,
warning: "invalid server certificate",
},
},
"new cluster invalid CA": {
mutateFn: func(t *testing.T, dir string) {
name := filepath.Join(dir, CAFileName)
require.NoError(t, os.WriteFile(name, []byte("not-a-ca-cert"), 0600))
},
expect: expect{
loaded: false,
warning: "invalid CA certificate",
},
},
"existing cluster invalid token": {
existingCluster: true,
mutateFn: func(t *testing.T, dir string) {
name := filepath.Join(dir, TokenFileName)
require.NoError(t, os.WriteFile(name, []byte("not-a-uuid"), 0600))
},
expect: expect{
loaded: false,
warning: "is not a valid UUID",
},
},
}
for name, tc := range tt {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}
func TestFetchBootstrapConfig(t *testing.T) {
type testCase struct {
expectFetchErr error
expectRetry bool
}
run := func(t *testing.T, tc testCase) {
ui := cli.NewMockUi()
dataDir := testutil.TempDir(t, "fetch-bootstrap-cfg")
clientM := hcpclient.NewMockClient(t)
if tc.expectFetchErr != nil && tc.expectRetry {
clientM.On("FetchBootstrap", mock.Anything).
Return(nil, tc.expectFetchErr)
} else if tc.expectFetchErr != nil && !tc.expectRetry {
clientM.On("FetchBootstrap", mock.Anything).
Return(nil, tc.expectFetchErr).Once()
} else {
validToken, err := uuid.GenerateUUID()
require.NoError(t, err)
clientM.EXPECT().FetchBootstrap(mock.Anything).Return(&hcpclient.BootstrapConfig{
ManagementToken: validToken,
ConsulConfig: "{}",
}, nil).Once()
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cfg, err := FetchBootstrapConfig(ctx, clientM, dataDir, ui)
if tc.expectFetchErr == nil {
require.NoError(t, err)
require.NotNil(t, cfg)
return
}
require.Error(t, err)
require.Nil(t, cfg)
if tc.expectRetry {
require.ErrorIs(t, err, context.DeadlineExceeded)
} else {
require.ErrorIs(t, err, tc.expectFetchErr)
}
}
tt := map[string]testCase{
"success": {},
"unauthorized": {
expectFetchErr: hcpclient.ErrUnauthorized,
},
"forbidden": {
expectFetchErr: hcpclient.ErrForbidden,
},
"retryable fetch error": {
expectFetchErr: errors.New("error"),
expectRetry: true,
},
}
for name, tc := range tt {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}
func TestLoadManagementToken(t *testing.T) {
type testCase struct {
skipHCPConfigDir bool
skipTokenFile bool
tokenFileContent string
skipBootstrap bool
}
validToken, err := uuid.GenerateUUID()
require.NoError(t, err)
run := func(t *testing.T, tc testCase) {
dataDir := testutil.TempDir(t, "load-management-token")
hcpCfgDir := filepath.Join(dataDir, constants.SubDir)
if !tc.skipHCPConfigDir {
err := os.Mkdir(hcpCfgDir, 0755)
require.NoError(t, err)
}
tokenFilePath := filepath.Join(hcpCfgDir, TokenFileName)
if !tc.skipTokenFile {
err := os.WriteFile(tokenFilePath, []byte(tc.tokenFileContent), 0600)
require.NoError(t, err)
}
clientM := hcpclient.NewMockClient(t)
if !tc.skipBootstrap {
clientM.EXPECT().FetchBootstrap(mock.Anything).Return(&hcpclient.BootstrapConfig{
ManagementToken: validToken,
ConsulConfig: "{}",
}, nil).Once()
}
token, err := LoadManagementToken(context.Background(), hclog.NewNullLogger(), clientM, dataDir)
require.NoError(t, err)
require.Equal(t, validToken, token)
bytes, err := os.ReadFile(tokenFilePath)
require.NoError(t, err)
require.Equal(t, validToken, string(bytes))
}
tt := map[string]testCase{
"token configured": {
skipBootstrap: true,
tokenFileContent: validToken,
},
"no token configured": {
skipTokenFile: true,
},
"invalid token configured": {
tokenFileContent: "invalid",
},
"no hcp-config directory": {
skipHCPConfigDir: true,
skipTokenFile: true,
},
}
for name, tc := range tt {
t.Run(name, func(t *testing.T) {
run(t, tc)
})
}
}