cli: iam destroy (#946)

This commit is contained in:
miampf 2023-02-24 11:36:41 +01:00 committed by GitHub
parent f1b331bbbd
commit 5137e9fa57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 659 additions and 9 deletions

View File

@ -13,6 +13,7 @@ import (
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
tfjson "github.com/hashicorp/terraform-json"
)
type terraformClient interface {
@ -22,6 +23,7 @@ type terraformClient interface {
Destroy(ctx context.Context) error
CleanUpWorkspace() error
RemoveInstaller()
Show(ctx context.Context) (*tfjson.State, error)
}
type libvirtRunner interface {

View File

@ -14,6 +14,7 @@ import (
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
tfjson "github.com/hashicorp/terraform-json"
"go.uber.org/goleak"
)
@ -30,14 +31,17 @@ type stubTerraformClient struct {
initSecret string
iamOutput terraform.IAMOutput
uid string
tfjsonState *tfjson.State
cleanUpWorkspaceCalled bool
removeInstallerCalled bool
destroyCalled bool
showCalled bool
createClusterErr error
destroyErr error
prepareWorkspaceErr error
cleanUpWorkspaceErr error
iamOutputErr error
showErr error
}
func (c *stubTerraformClient) CreateCluster(ctx context.Context) (terraform.CreateOutput, error) {
@ -70,6 +74,11 @@ func (c *stubTerraformClient) RemoveInstaller() {
c.removeInstallerCalled = true
}
func (c *stubTerraformClient) Show(ctx context.Context) (*tfjson.State, error) {
c.showCalled = true
return c.tfjsonState, c.showErr
}
type stubLibvirtRunner struct {
startCalled bool
stopCalled bool

View File

@ -7,6 +7,9 @@ package cloudcmd
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"path"
@ -15,9 +18,65 @@ import (
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
"github.com/edgelesssys/constellation/v2/internal/constants"
)
// IAMDestroyer destroys an IAM configuration.
type IAMDestroyer struct {
client terraformClient
}
// NewIAMDestroyer creates a new IAM Destroyer.
func NewIAMDestroyer(ctx context.Context) (*IAMDestroyer, error) {
cl, err := terraform.New(ctx, constants.TerraformIAMWorkingDir)
if err != nil {
return nil, err
}
return &IAMDestroyer{client: cl}, nil
}
// GetTfstateServiceAccountKey returns the sa_key output from the terraform state.
func (d *IAMDestroyer) GetTfstateServiceAccountKey(ctx context.Context) (gcpshared.ServiceAccountKey, error) {
tfState, err := d.client.Show(ctx)
if err != nil {
return gcpshared.ServiceAccountKey{}, err
}
if tfState.Values == nil {
return gcpshared.ServiceAccountKey{}, errors.New("no Values field in terraform state")
}
saKeyJSON := tfState.Values.Outputs["sa_key"]
if saKeyJSON == nil {
return gcpshared.ServiceAccountKey{}, errors.New("no sa_key in outputs of the terraform state")
}
saKeyString, ok := saKeyJSON.Value.(string)
if !ok {
return gcpshared.ServiceAccountKey{}, errors.New("sa_key field in terraform state is not a string")
}
saKey, err := base64.StdEncoding.DecodeString(saKeyString)
if err != nil {
return gcpshared.ServiceAccountKey{}, err
}
var tfSaKey gcpshared.ServiceAccountKey
if err := json.Unmarshal(saKey, &tfSaKey); err != nil {
return gcpshared.ServiceAccountKey{}, err
}
return tfSaKey, nil
}
// DestroyIAMConfiguration destroys the previously created IAM configuration and deletes the local IAM terraform files.
func (d *IAMDestroyer) DestroyIAMConfiguration(ctx context.Context) error {
if err := d.client.Destroy(ctx); err != nil {
return err
}
return d.client.CleanUpWorkspace()
}
// IAMCreator creates the IAM configuration on the cloud provider.
type IAMCreator struct {
out io.Writer

View File

@ -8,13 +8,18 @@ package cloudcmd
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIAMCreator(t *testing.T) {
@ -148,3 +153,186 @@ func TestIAMCreator(t *testing.T) {
})
}
}
func TestDestroyIAMConfiguration(t *testing.T) {
newError := func() error {
return errors.New("failed")
}
testCases := map[string]struct {
tfClient *stubTerraformClient
wantErr bool
wantDestroyCalled bool
wantCleanupWorkspaceCalled bool
}{
"destroy error": {
tfClient: &stubTerraformClient{destroyErr: newError()},
wantErr: true,
wantDestroyCalled: true,
},
"destroy": {
tfClient: &stubTerraformClient{},
wantDestroyCalled: true,
wantCleanupWorkspaceCalled: true,
},
"cleanup error": {
tfClient: &stubTerraformClient{cleanUpWorkspaceErr: newError()},
wantErr: true,
wantDestroyCalled: true,
wantCleanupWorkspaceCalled: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
destroyer := &IAMDestroyer{client: tc.tfClient}
err := destroyer.DestroyIAMConfiguration(context.Background())
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
assert.Equal(tc.wantDestroyCalled, tc.tfClient.destroyCalled)
assert.Equal(tc.wantCleanupWorkspaceCalled, tc.tfClient.cleanUpWorkspaceCalled)
})
}
}
func TestGetTfstateServiceAccountKey(t *testing.T) {
someError := errors.New("failed")
gcpFile := `
{
"auth_provider_x509_cert_url": "",
"auth_uri": "",
"client_email": "",
"client_id": "",
"client_x509_cert_url": "",
"private_key": "",
"private_key_id": "",
"project_id": "",
"token_uri": "",
"type": ""
}
`
gcpFileB64 := base64.StdEncoding.EncodeToString([]byte(gcpFile))
testCases := map[string]struct {
cl *stubTerraformClient
wantValidSaKey bool
wantErr bool
wantShowCalled bool
}{
"valid": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: &tfjson.StateValues{
Outputs: map[string]*tfjson.StateOutput{
"sa_key": {
Value: gcpFileB64,
},
},
},
},
},
wantValidSaKey: true,
wantShowCalled: true,
},
"show error": {
cl: &stubTerraformClient{
showErr: someError,
},
wantErr: true,
wantShowCalled: true,
},
"nil tfstate values": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: nil,
},
},
wantErr: true,
wantShowCalled: true,
},
"no key": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: &tfjson.StateValues{},
},
},
wantErr: true,
wantShowCalled: true,
},
"invalid base64": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: &tfjson.StateValues{
Outputs: map[string]*tfjson.StateOutput{
"sa_key": {
Value: "iamnotvalid",
},
},
},
},
},
wantErr: true,
wantShowCalled: true,
},
"valid base64 invalid json": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: &tfjson.StateValues{
Outputs: map[string]*tfjson.StateOutput{
"sa_key": {
Value: base64.StdEncoding.EncodeToString([]byte("asdf")),
},
},
},
},
},
wantErr: true,
wantShowCalled: true,
},
"not string": {
cl: &stubTerraformClient{
tfjsonState: &tfjson.State{
Values: &tfjson.StateValues{
Outputs: map[string]*tfjson.StateOutput{
"sa_key": {
Value: 1,
},
},
},
},
},
wantErr: true,
wantShowCalled: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
destroyer := IAMDestroyer{client: tc.cl}
saKey, err := destroyer.GetTfstateServiceAccountKey(context.Background())
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
var saKeyComp gcpshared.ServiceAccountKey
require.NoError(t, json.Unmarshal([]byte(gcpFile), &saKeyComp))
assert.Equal(saKey, saKeyComp)
assert.Equal(tc.wantShowCalled, tc.cl.showCalled)
})
}
}

View File

@ -13,6 +13,7 @@ import (
"github.com/edgelesssys/constellation/v2/cli/internal/clusterid"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
"github.com/edgelesssys/constellation/v2/internal/config"
)
@ -34,6 +35,11 @@ type cloudIAMCreator interface {
) (iamid.File, error)
}
type iamDestroyer interface {
DestroyIAMConfiguration(ctx context.Context) error
GetTfstateServiceAccountKey(ctx context.Context) (gcpshared.ServiceAccountKey, error)
}
type cloudTerminator interface {
Terminate(context.Context) error
}

View File

@ -14,6 +14,7 @@ import (
"github.com/edgelesssys/constellation/v2/cli/internal/clusterid"
"github.com/edgelesssys/constellation/v2/cli/internal/iamid"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
"github.com/edgelesssys/constellation/v2/internal/config"
"go.uber.org/goleak"
)
@ -72,3 +73,21 @@ func (c *stubIAMCreator) Create(
c.id.CloudProvider = provider
return c.id, c.createErr
}
type stubIAMDestroyer struct {
destroyCalled bool
getTfstateKeyCalled bool
gcpSaKey gcpshared.ServiceAccountKey
destroyErr error
getTfstateKeyErr error
}
func (d *stubIAMDestroyer) DestroyIAMConfiguration(ctx context.Context) error {
d.destroyCalled = true
return d.destroyErr
}
func (d *stubIAMDestroyer) GetTfstateServiceAccountKey(ctx context.Context) (gcpshared.ServiceAccountKey, error) {
d.getTfstateKeyCalled = true
return d.gcpSaKey, d.getTfstateKeyErr
}

View File

@ -42,6 +42,7 @@ func NewIAMCmd() *cobra.Command {
}
cmd.AddCommand(newIAMCreateCmd())
cmd.AddCommand(newIAMDestroyCmd())
return cmd
}
@ -563,3 +564,18 @@ func parseIDFile(serviceAccountKeyBase64 string) (map[string]string, error) {
}
return out, nil
}
// NewIAMDestroyCmd returns a new cobra.Command for the iam destroy subcommand.
func newIAMDestroyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "destroy",
Short: "Destroy an IAM configuration and delete local terraform files",
Long: "Destroy an IAM configuration and delete local terraform files.",
Args: cobra.ExactArgs(0),
RunE: runIAMDestroy,
}
cmd.Flags().BoolP("yes", "y", false, "destroy the IAM configuration without asking for confirmation")
return cmd
}

View File

@ -0,0 +1,142 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"errors"
"fmt"
"os"
"github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
func runIAMDestroy(cmd *cobra.Command, _args []string) error {
log, err := newCLILogger(cmd)
if err != nil {
return fmt.Errorf("creating logger: %w", err)
}
defer log.Sync()
spinner := newSpinner(cmd.ErrOrStderr())
destroyer, err := cloudcmd.NewIAMDestroyer(cmd.Context())
if err != nil {
return err
}
fsHandler := file.NewHandler(afero.NewOsFs())
c := &destroyCmd{log: log}
return c.iamDestroy(cmd, spinner, destroyer, fsHandler)
}
type destroyCmd struct {
log debugLog
}
func (c *destroyCmd) iamDestroy(cmd *cobra.Command, spinner spinnerInterf, destroyer iamDestroyer, fsHandler file.Handler) error {
// check if there is a possibility that the cluster is still running by looking out for specific files
c.log.Debugf("Checking if %q exists", constants.AdminConfFilename)
_, err := fsHandler.Stat(constants.AdminConfFilename)
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file %q still exists, please make sure to terminate your cluster before destroying your IAM configuration", constants.AdminConfFilename)
}
c.log.Debugf("Checking if %q exists", constants.ClusterIDsFileName)
_, err = fsHandler.Stat(constants.ClusterIDsFileName)
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("file %q still exists, please make sure to terminate your cluster before destroying your IAM configuration", constants.ClusterIDsFileName)
}
yes, err := cmd.Flags().GetBool("yes")
if err != nil {
return err
}
c.log.Debugf("\"yes\" flag is set to %t", yes)
gcpFileExists := false
c.log.Debugf("Checking if %q exists", constants.GCPServiceAccountKeyFile)
_, err = fsHandler.Stat(constants.GCPServiceAccountKeyFile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
} else {
c.log.Debugf("%q exists", constants.GCPServiceAccountKeyFile)
gcpFileExists = true
}
if !yes {
// Confirmation
confirmString := "Do you really want to destroy your IAM configuration?"
if gcpFileExists {
confirmString += fmt.Sprintf(" (This will also delete %q)", constants.GCPServiceAccountKeyFile)
}
ok, err := askToConfirm(cmd, confirmString)
if err != nil {
return err
}
if !ok {
cmd.Println("The destruction of the IAM configuration was aborted")
return nil
}
}
if gcpFileExists {
c.log.Debugf("Starting to delete %q", constants.GCPServiceAccountKeyFile)
proceed, err := c.deleteGCPServiceAccountKeyFile(cmd, destroyer, fsHandler)
if err != nil {
return err
}
if !proceed {
cmd.Println("Destruction was aborted")
return nil
}
}
c.log.Debugf("Starting to destroy IAM configuration")
spinner.Start("Destroying IAM configuration", false)
defer spinner.Stop()
if err := destroyer.DestroyIAMConfiguration(cmd.Context()); err != nil {
return fmt.Errorf("destroying IAM configuration: %w", err)
}
spinner.Stop() // stop the spinner to print a new line
fmt.Println("Successfully destroyed IAM configuration")
return nil
}
func (c *destroyCmd) deleteGCPServiceAccountKeyFile(cmd *cobra.Command, destroyer iamDestroyer, fsHandler file.Handler) (bool, error) {
var fileSaKey gcpshared.ServiceAccountKey
c.log.Debugf("Parsing %q", constants.GCPServiceAccountKeyFile)
if err := fsHandler.ReadJSON(constants.GCPServiceAccountKeyFile, &fileSaKey); err != nil {
return false, err
}
c.log.Debugf("Getting service account key from the tfstate")
tfSaKey, err := destroyer.GetTfstateServiceAccountKey(cmd.Context())
if err != nil {
return false, err
}
c.log.Debugf("Checking if keys are the same")
if tfSaKey != fileSaKey {
cmd.Printf("The key in %q don't match up with your terraform state. %q will not be deleted.\n", constants.GCPServiceAccountKeyFile, constants.GCPServiceAccountKeyFile)
return true, nil
}
if err := fsHandler.Remove(constants.GCPServiceAccountKeyFile); err != nil {
return false, err
}
c.log.Debugf("Successfully deleted %q", constants.GCPServiceAccountKeyFile)
return true, nil
}

View File

@ -0,0 +1,211 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"errors"
"testing"
"github.com/edgelesssys/constellation/v2/internal/cloud/gcpshared"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIAMDestroy(t *testing.T) {
require := require.New(t)
someError := errors.New("failed")
newFsExists := func() file.Handler {
fh := file.NewHandler(afero.NewMemMapFs())
require.NoError(fh.Write(constants.GCPServiceAccountKeyFile, []byte("{}")))
return fh
}
newFsMissing := func() file.Handler {
fh := file.NewHandler(afero.NewMemMapFs())
return fh
}
newFsWithAdminConf := func() file.Handler {
fh := file.NewHandler(afero.NewMemMapFs())
require.NoError(fh.Write(constants.AdminConfFilename, []byte("")))
return fh
}
newFsWithClusterIDFile := func() file.Handler {
fh := file.NewHandler(afero.NewMemMapFs())
require.NoError(fh.Write(constants.ClusterIDsFileName, []byte("")))
return fh
}
testCases := map[string]struct {
iamDestroyer *stubIAMDestroyer
fh file.Handler
stdin string
yesFlag string
wantErr bool
wantDestroyCalled bool
}{
"cluster running admin conf": {
fh: newFsWithAdminConf(),
iamDestroyer: &stubIAMDestroyer{},
yesFlag: "false",
wantErr: true,
},
"cluster running cluster ids": {
fh: newFsWithClusterIDFile(),
iamDestroyer: &stubIAMDestroyer{},
yesFlag: "false",
wantErr: true,
},
"file missing abort": {
fh: newFsMissing(),
stdin: "n\n",
yesFlag: "false",
iamDestroyer: &stubIAMDestroyer{},
},
"file missing": {
fh: newFsMissing(),
stdin: "y\n",
yesFlag: "false",
iamDestroyer: &stubIAMDestroyer{},
wantDestroyCalled: true,
},
"file exists abort": {
fh: newFsExists(),
stdin: "n\n",
yesFlag: "false",
iamDestroyer: &stubIAMDestroyer{},
},
"error destroying user": {
fh: newFsMissing(),
stdin: "y\n",
yesFlag: "false",
iamDestroyer: &stubIAMDestroyer{destroyErr: someError},
wantErr: true,
wantDestroyCalled: true,
},
"gcp delete error": {
fh: newFsExists(),
yesFlag: "true",
iamDestroyer: &stubIAMDestroyer{getTfstateKeyErr: someError},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
cmd := newIAMDestroyCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
assert.NoError(cmd.Flags().Set("yes", tc.yesFlag))
c := &destroyCmd{log: logger.NewTest(t)}
err := c.iamDestroy(cmd, &nopSpinner{}, tc.iamDestroyer, tc.fh)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
assert.Equal(tc.wantDestroyCalled, tc.iamDestroyer.destroyCalled)
})
}
}
func TestDeleteGCPServiceAccountKeyFile(t *testing.T) {
require := require.New(t)
someError := errors.New("failed")
gcpFile := `
{
"auth_provider_x509_cert_url": "",
"auth_uri": "",
"client_email": "",
"client_id": "",
"client_x509_cert_url": "",
"private_key": "",
"private_key_id": "",
"project_id": "",
"token_uri": "",
"type": ""
}
`
newFs := func() file.Handler {
fs := file.NewHandler(afero.NewMemMapFs())
require.NoError(fs.Write(constants.GCPServiceAccountKeyFile, []byte(gcpFile)))
return fs
}
newFsInvalidJSON := func() file.Handler {
fh := file.NewHandler(afero.NewMemMapFs())
require.NoError(fh.Write(constants.GCPServiceAccountKeyFile, []byte("asdf")))
return fh
}
testCases := map[string]struct {
destroyer *stubIAMDestroyer
fsHandler file.Handler
stdin string
wantErr bool
wantProceed bool
wantGetSaKeyCalled bool
}{
"invalid gcp json": {
destroyer: &stubIAMDestroyer{},
fsHandler: newFsInvalidJSON(),
wantErr: true,
},
"error getting key terraform": {
destroyer: &stubIAMDestroyer{getTfstateKeyErr: someError},
fsHandler: newFs(),
wantErr: true,
wantGetSaKeyCalled: true,
},
"keys not same": {
destroyer: &stubIAMDestroyer{gcpSaKey: gcpshared.ServiceAccountKey{
Type: "somethingelse",
}},
fsHandler: newFs(),
wantGetSaKeyCalled: true,
wantProceed: true,
},
"valid": {
destroyer: &stubIAMDestroyer{},
fsHandler: newFs(),
wantGetSaKeyCalled: true,
wantProceed: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
cmd := newIAMDestroyCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetIn(bytes.NewBufferString(tc.stdin))
c := &destroyCmd{log: logger.NewTest(t)}
proceed, err := c.deleteGCPServiceAccountKeyFile(cmd, tc.destroyer, tc.fsHandler)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
assert.Equal(tc.wantProceed, proceed)
assert.Equal(tc.wantGetSaKeyCalled, tc.destroyer.getTfstateKeyCalled)
})
}
}

View File

@ -68,6 +68,11 @@ func New(ctx context.Context, workingDir string) (*Client, error) {
}, nil
}
// Show reads the default state path and outputs the state.
func (c *Client) Show(ctx context.Context) (*tfjson.State, error) {
return c.tf.Show(ctx)
}
// PrepareWorkspace prepares a Terraform workspace for a Constellation cluster.
func (c *Client) PrepareWorkspace(path string, vars Variables) error {
if err := prepareWorkspace(path, c.file, c.workingDir); err != nil {

View File

@ -256,14 +256,7 @@ You can keep created IAM configurations and reuse them for new clusters. Alterna
* [Terraform](https://developer.hashicorp.com/terraform/downloads) is installed on your machine.
* Access to the `terraform.tfstate` file created by the `constellation iam create` command.
You can delete the IAM configuration using the following commands:
You can delete the IAM configuration by executing the following command in the same directory where you executed `constellation iam create`:
```bash
# Navigate to the directory containing the terraform.tfstate file
cd constellation-iam-terraform
# Destroy the IAM configuration via Terraform
terraform destroy
# Confirm deletion by typing "yes"
# Remove the Terraform state directory
cd ..
rm -rf constellation-iam-terraform
constellation iam destroy
```