mirror of
				https://github.com/edgelesssys/constellation.git
				synced 2025-10-31 03:39:04 -04:00 
			
		
		
		
	 3edc1c3ebb
			
		
	
	
		3edc1c3ebb
		
	
	
	
	
		
			
			This commit is designed to be reverted in the future (AB#3248). Terraform does not implement moved blocks with dynamic targets: https://github.com/hashicorp/terraform/issues/31335 so we have to migrate the terraform state ourselves.
		
			
				
	
	
		
			466 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			466 lines
		
	
	
	
		
			15 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| /*
 | |
| Copyright (c) Edgeless Systems GmbH
 | |
| 
 | |
| SPDX-License-Identifier: AGPL-3.0-only
 | |
| */
 | |
| 
 | |
| /*
 | |
| Package terraform handles creation/destruction of a Constellation cluster using Terraform.
 | |
| 
 | |
| Since Terraform does not provide a stable Go API, we use the `terraform-exec` package to interact with Terraform.
 | |
| 
 | |
| The Terraform templates are located in the "terraform" subdirectory. The templates are embedded into the CLI binary using `go:embed`.
 | |
| On use the relevant template is extracted to the working directory and the user customized variables are written to a `terraform.tfvars` file.
 | |
| */
 | |
| package terraform
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"path/filepath"
 | |
| 
 | |
| 	"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
 | |
| 	"github.com/edgelesssys/constellation/v2/internal/constants"
 | |
| 	"github.com/edgelesssys/constellation/v2/internal/file"
 | |
| 	"github.com/hashicorp/go-version"
 | |
| 	install "github.com/hashicorp/hc-install"
 | |
| 	"github.com/hashicorp/hc-install/fs"
 | |
| 	"github.com/hashicorp/hc-install/product"
 | |
| 	"github.com/hashicorp/hc-install/releases"
 | |
| 	"github.com/hashicorp/hc-install/src"
 | |
| 	"github.com/hashicorp/terraform-exec/tfexec"
 | |
| 	tfjson "github.com/hashicorp/terraform-json"
 | |
| 	"github.com/spf13/afero"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	tfVersion         = ">= 1.4.6"
 | |
| 	terraformVarsFile = "terraform.tfvars"
 | |
| )
 | |
| 
 | |
| // ErrTerraformWorkspaceExistsWithDifferentVariables is returned when existing Terraform files differ from the version the CLI wants to extract.
 | |
| var ErrTerraformWorkspaceExistsWithDifferentVariables = errors.New("creating cluster: a Terraform workspace already exists with different variables")
 | |
| 
 | |
| // Client manages interaction with Terraform.
 | |
| type Client struct {
 | |
| 	tf tfInterface
 | |
| 
 | |
| 	manualStateMigrations []StateMigration
 | |
| 	file                  file.Handler
 | |
| 	workingDir            string
 | |
| 	remove                func()
 | |
| }
 | |
| 
 | |
| // New sets up a new Client for Terraform.
 | |
| func New(ctx context.Context, workingDir string) (*Client, error) {
 | |
| 	file := file.NewHandler(afero.NewOsFs())
 | |
| 	if err := file.MkdirAll(workingDir); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	tf, remove, err := GetExecutable(ctx, workingDir)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return &Client{
 | |
| 		tf:         tf,
 | |
| 		remove:     remove,
 | |
| 		file:       file,
 | |
| 		workingDir: workingDir,
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| // WithManualStateMigration adds a manual state migration to the Client.
 | |
| func (c *Client) WithManualStateMigration(migration StateMigration) *Client {
 | |
| 	c.manualStateMigrations = append(c.manualStateMigrations, migration)
 | |
| 	return c
 | |
| }
 | |
| 
 | |
| // 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 {
 | |
| 		return fmt.Errorf("prepare workspace: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return c.writeVars(vars)
 | |
| }
 | |
| 
 | |
| // PrepareUpgradeWorkspace prepares a Terraform workspace for a Constellation version upgrade.
 | |
| // It copies the Terraform state from the old working dir and the embedded Terraform files into the new working dir.
 | |
| func (c *Client) PrepareUpgradeWorkspace(path, oldWorkingDir, newWorkingDir, backupDir string, vars Variables) error {
 | |
| 	if err := prepareUpgradeWorkspace(path, c.file, oldWorkingDir, newWorkingDir, backupDir); err != nil {
 | |
| 		return fmt.Errorf("prepare upgrade workspace: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return c.writeVars(vars)
 | |
| }
 | |
| 
 | |
| // CreateCluster creates a Constellation cluster using Terraform.
 | |
| func (c *Client) CreateCluster(ctx context.Context, logLevel LogLevel) (CreateOutput, error) {
 | |
| 	if err := c.setLogLevel(logLevel); err != nil {
 | |
| 		return CreateOutput{}, fmt.Errorf("set terraform log level %s: %w", logLevel.String(), err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Init(ctx); err != nil {
 | |
| 		return CreateOutput{}, fmt.Errorf("terraform init: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.applyManualStateMigrations(ctx); err != nil {
 | |
| 		return CreateOutput{}, fmt.Errorf("apply manual state migrations: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Apply(ctx); err != nil {
 | |
| 		return CreateOutput{}, fmt.Errorf("terraform apply: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	tfState, err := c.tf.Show(ctx)
 | |
| 	if err != nil {
 | |
| 		return CreateOutput{}, fmt.Errorf("terraform show: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	ipOutput, ok := tfState.Values.Outputs["ip"]
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("no IP output found")
 | |
| 	}
 | |
| 	ip, ok := ipOutput.Value.(string)
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("invalid type in IP output: not a string")
 | |
| 	}
 | |
| 
 | |
| 	secretOutput, ok := tfState.Values.Outputs["initSecret"]
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("no initSecret output found")
 | |
| 	}
 | |
| 	secret, ok := secretOutput.Value.(string)
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("invalid type in initSecret output: not a string")
 | |
| 	}
 | |
| 
 | |
| 	uidOutput, ok := tfState.Values.Outputs["uid"]
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("no uid output found")
 | |
| 	}
 | |
| 	uid, ok := uidOutput.Value.(string)
 | |
| 	if !ok {
 | |
| 		return CreateOutput{}, errors.New("invalid type in uid output: not a string")
 | |
| 	}
 | |
| 
 | |
| 	var attestationURL string
 | |
| 	if attestationURLOutput, ok := tfState.Values.Outputs["attestationURL"]; ok {
 | |
| 		if attestationURLString, ok := attestationURLOutput.Value.(string); ok {
 | |
| 			attestationURL = attestationURLString
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return CreateOutput{
 | |
| 		IP:             ip,
 | |
| 		Secret:         secret,
 | |
| 		UID:            uid,
 | |
| 		AttestationURL: attestationURL,
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| // CreateOutput contains the Terraform output values of a cluster creation.
 | |
| type CreateOutput struct {
 | |
| 	IP     string
 | |
| 	Secret string
 | |
| 	UID    string
 | |
| 	// AttestationURL is the URL of the attestation provider.
 | |
| 	// It is only set if the cluster is created on Azure.
 | |
| 	AttestationURL string
 | |
| }
 | |
| 
 | |
| // IAMOutput contains the output information of the Terraform IAM operations.
 | |
| type IAMOutput struct {
 | |
| 	GCP   GCPIAMOutput
 | |
| 	Azure AzureIAMOutput
 | |
| 	AWS   AWSIAMOutput
 | |
| }
 | |
| 
 | |
| // GCPIAMOutput contains the output information of the Terraform IAM operation on GCP.
 | |
| type GCPIAMOutput struct {
 | |
| 	SaKey string
 | |
| }
 | |
| 
 | |
| // AzureIAMOutput contains the output information of the Terraform IAM operation on Microsoft Azure.
 | |
| type AzureIAMOutput struct {
 | |
| 	SubscriptionID string
 | |
| 	TenantID       string
 | |
| 	UAMIID         string
 | |
| }
 | |
| 
 | |
| // AWSIAMOutput contains the output information of the Terraform IAM operation on GCP.
 | |
| type AWSIAMOutput struct {
 | |
| 	ControlPlaneInstanceProfile string
 | |
| 	WorkerNodeInstanceProfile   string
 | |
| }
 | |
| 
 | |
| // CreateIAMConfig creates an IAM configuration using Terraform.
 | |
| func (c *Client) CreateIAMConfig(ctx context.Context, provider cloudprovider.Provider, logLevel LogLevel) (IAMOutput, error) {
 | |
| 	if err := c.setLogLevel(logLevel); err != nil {
 | |
| 		return IAMOutput{}, fmt.Errorf("set terraform log level %s: %w", logLevel.String(), err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Init(ctx); err != nil {
 | |
| 		return IAMOutput{}, err
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Apply(ctx); err != nil {
 | |
| 		return IAMOutput{}, err
 | |
| 	}
 | |
| 
 | |
| 	tfState, err := c.tf.Show(ctx)
 | |
| 	if err != nil {
 | |
| 		return IAMOutput{}, err
 | |
| 	}
 | |
| 
 | |
| 	switch provider {
 | |
| 	case cloudprovider.GCP:
 | |
| 		saKeyOutputRaw, ok := tfState.Values.Outputs["sa_key"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no service account key output found")
 | |
| 		}
 | |
| 		saKeyOutput, ok := saKeyOutputRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in service account key output: not a string")
 | |
| 		}
 | |
| 		return IAMOutput{
 | |
| 			GCP: GCPIAMOutput{
 | |
| 				SaKey: saKeyOutput,
 | |
| 			},
 | |
| 		}, nil
 | |
| 	case cloudprovider.Azure:
 | |
| 		subscriptionIDRaw, ok := tfState.Values.Outputs["subscription_id"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no subscription id output found")
 | |
| 		}
 | |
| 		subscriptionIDOutput, ok := subscriptionIDRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in subscription id output: not a string")
 | |
| 		}
 | |
| 		tenantIDRaw, ok := tfState.Values.Outputs["tenant_id"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no tenant id output found")
 | |
| 		}
 | |
| 		tenantIDOutput, ok := tenantIDRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in tenant id output: not a string")
 | |
| 		}
 | |
| 		uamiIDRaw, ok := tfState.Values.Outputs["uami_id"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no UAMI id output found")
 | |
| 		}
 | |
| 		uamiIDOutput, ok := uamiIDRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in UAMI id output: not a string")
 | |
| 		}
 | |
| 		return IAMOutput{
 | |
| 			Azure: AzureIAMOutput{
 | |
| 				SubscriptionID: subscriptionIDOutput,
 | |
| 				TenantID:       tenantIDOutput,
 | |
| 				UAMIID:         uamiIDOutput,
 | |
| 			},
 | |
| 		}, nil
 | |
| 	case cloudprovider.AWS:
 | |
| 		controlPlaneProfileRaw, ok := tfState.Values.Outputs["control_plane_instance_profile"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no control plane instance profile output found")
 | |
| 		}
 | |
| 		controlPlaneProfileOutput, ok := controlPlaneProfileRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in control plane instance profile output: not a string")
 | |
| 		}
 | |
| 		workerNodeProfileRaw, ok := tfState.Values.Outputs["worker_nodes_instance_profile"]
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("no worker node instance profile output found")
 | |
| 		}
 | |
| 		workerNodeProfileOutput, ok := workerNodeProfileRaw.Value.(string)
 | |
| 		if !ok {
 | |
| 			return IAMOutput{}, errors.New("invalid type in worker node instance profile output: not a string")
 | |
| 		}
 | |
| 		return IAMOutput{
 | |
| 			AWS: AWSIAMOutput{
 | |
| 				ControlPlaneInstanceProfile: controlPlaneProfileOutput,
 | |
| 				WorkerNodeInstanceProfile:   workerNodeProfileOutput,
 | |
| 			},
 | |
| 		}, nil
 | |
| 	default:
 | |
| 		return IAMOutput{}, errors.New("unsupported cloud provider")
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // Plan determines the diff that will be applied by Terraform. The plan output is written to the planFile.
 | |
| // If there is a diff, the returned bool is true. Otherwise, it is false.
 | |
| func (c *Client) Plan(ctx context.Context, logLevel LogLevel, planFile string) (bool, error) {
 | |
| 	if err := c.setLogLevel(logLevel); err != nil {
 | |
| 		return false, fmt.Errorf("set terraform log level %s: %w", logLevel.String(), err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Init(ctx); err != nil {
 | |
| 		return false, fmt.Errorf("terraform init: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.applyManualStateMigrations(ctx); err != nil {
 | |
| 		return false, fmt.Errorf("apply manual state migrations: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	opts := []tfexec.PlanOption{
 | |
| 		tfexec.Out(planFile),
 | |
| 	}
 | |
| 	return c.tf.Plan(ctx, opts...)
 | |
| }
 | |
| 
 | |
| // ShowPlan formats the diff in planFilePath and writes it to the specified output.
 | |
| func (c *Client) ShowPlan(ctx context.Context, logLevel LogLevel, planFilePath string, output io.Writer) error {
 | |
| 	if err := c.setLogLevel(logLevel); err != nil {
 | |
| 		return fmt.Errorf("set terraform log level %s: %w", logLevel.String(), err)
 | |
| 	}
 | |
| 
 | |
| 	planResult, err := c.tf.ShowPlanFileRaw(ctx, planFilePath)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("terraform show plan: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	_, err = output.Write([]byte(planResult))
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("write plan output: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // Destroy destroys Terraform-created cloud resources.
 | |
| func (c *Client) Destroy(ctx context.Context, logLevel LogLevel) error {
 | |
| 	if err := c.setLogLevel(logLevel); err != nil {
 | |
| 		return fmt.Errorf("set terraform log level %s: %w", logLevel.String(), err)
 | |
| 	}
 | |
| 
 | |
| 	if err := c.tf.Init(ctx); err != nil {
 | |
| 		return fmt.Errorf("terraform init: %w", err)
 | |
| 	}
 | |
| 	return c.tf.Destroy(ctx)
 | |
| }
 | |
| 
 | |
| // RemoveInstaller removes the Terraform installer, if it was downloaded for this command.
 | |
| func (c *Client) RemoveInstaller() {
 | |
| 	c.remove()
 | |
| }
 | |
| 
 | |
| // CleanUpWorkspace removes terraform files from the current directory.
 | |
| func (c *Client) CleanUpWorkspace() error {
 | |
| 	return cleanUpWorkspace(c.file, c.workingDir)
 | |
| }
 | |
| 
 | |
| // GetExecutable returns a Terraform executable either from the local filesystem,
 | |
| // or downloads the latest version fulfilling the version constraint.
 | |
| func GetExecutable(ctx context.Context, workingDir string) (terraform *tfexec.Terraform, remove func(), err error) {
 | |
| 	inst := install.NewInstaller()
 | |
| 
 | |
| 	version, err := version.NewConstraint(tfVersion)
 | |
| 	if err != nil {
 | |
| 		return nil, nil, err
 | |
| 	}
 | |
| 
 | |
| 	downloadVersion := &releases.LatestVersion{
 | |
| 		Product:     product.Terraform,
 | |
| 		Constraints: version,
 | |
| 	}
 | |
| 	localVersion := &fs.Version{
 | |
| 		Product:     product.Terraform,
 | |
| 		Constraints: version,
 | |
| 	}
 | |
| 
 | |
| 	execPath, err := inst.Ensure(ctx, []src.Source{localVersion, downloadVersion})
 | |
| 	if err != nil {
 | |
| 		return nil, nil, err
 | |
| 	}
 | |
| 
 | |
| 	tf, err := tfexec.NewTerraform(workingDir, execPath)
 | |
| 
 | |
| 	return tf, func() { _ = inst.Remove(context.Background()) }, err
 | |
| }
 | |
| 
 | |
| // applyManualStateMigrations applies manual state migrations that are not handled by Terraform due to missing features.
 | |
| // Each migration is expected to be idempotent.
 | |
| // This is a temporary solution until we can remove the need for manual state migrations.
 | |
| func (c *Client) applyManualStateMigrations(ctx context.Context) error {
 | |
| 	for _, migration := range c.manualStateMigrations {
 | |
| 		if err := migration.Hook(ctx, c.tf); err != nil {
 | |
| 			return fmt.Errorf("apply manual state migration %s: %w", migration.DisplayName, err)
 | |
| 		}
 | |
| 	}
 | |
| 	return nil
 | |
| 	// expects to be run on initialized workspace
 | |
| 	// and only works for AWS
 | |
| 	// if migration fails, we expect to either be on a different CSP or that the migration has already been applied
 | |
| 	// and we can continue
 | |
| 	// c.tf.StateMv(ctx, "module.control_plane.aws_iam_role.this", "module.control_plane.aws_iam_role.control_plane")
 | |
| }
 | |
| 
 | |
| // writeVars tries to write the Terraform variables file or, if it exists, checks if it is the same as we are expecting.
 | |
| func (c *Client) writeVars(vars Variables) error {
 | |
| 	if vars == nil {
 | |
| 		return errors.New("creating cluster: vars is nil")
 | |
| 	}
 | |
| 
 | |
| 	pathToVarsFile := filepath.Join(c.workingDir, terraformVarsFile)
 | |
| 	if err := c.file.Write(pathToVarsFile, []byte(vars.String())); errors.Is(err, afero.ErrFileExists) {
 | |
| 		// If a variables file already exists, check if it's the same as we're expecting, so we can continue using it.
 | |
| 		varsContent, err := c.file.Read(pathToVarsFile)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("read variables file: %w", err)
 | |
| 		}
 | |
| 		if vars.String() != string(varsContent) {
 | |
| 			return ErrTerraformWorkspaceExistsWithDifferentVariables
 | |
| 		}
 | |
| 	} else if err != nil {
 | |
| 		return fmt.Errorf("write variables file: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // setLogLevel sets the log level for Terraform.
 | |
| func (c *Client) setLogLevel(logLevel LogLevel) error {
 | |
| 	if logLevel.String() != "" {
 | |
| 		if err := c.tf.SetLog(logLevel.String()); err != nil {
 | |
| 			return fmt.Errorf("set log level %s: %w", logLevel.String(), err)
 | |
| 		}
 | |
| 		if err := c.tf.SetLogPath(filepath.Join("..", constants.TerraformLogFile)); err != nil {
 | |
| 			return fmt.Errorf("set log path: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // StateMigration is a manual state migration that is not handled by Terraform due to missing features.
 | |
| // TODO(AB#3248): Remove this after we can assume that all existing clusters have been migrated.
 | |
| type StateMigration struct {
 | |
| 	DisplayName string
 | |
| 	Hook        func(ctx context.Context, tfClient TFMigrator) error
 | |
| }
 | |
| 
 | |
| type tfInterface interface {
 | |
| 	Apply(context.Context, ...tfexec.ApplyOption) error
 | |
| 	Destroy(context.Context, ...tfexec.DestroyOption) error
 | |
| 	Init(context.Context, ...tfexec.InitOption) error
 | |
| 	Show(context.Context, ...tfexec.ShowOption) (*tfjson.State, error)
 | |
| 	Plan(ctx context.Context, opts ...tfexec.PlanOption) (bool, error)
 | |
| 	ShowPlanFileRaw(ctx context.Context, planPath string, opts ...tfexec.ShowOption) (string, error)
 | |
| 	SetLog(level string) error
 | |
| 	SetLogPath(path string) error
 | |
| 	TFMigrator
 | |
| }
 | |
| 
 | |
| // TFMigrator is an interface for manual terraform state migrations (terraform state mv).
 | |
| // TODO(AB#3248): Remove this after we can assume that all existing clusters have been migrated.
 | |
| type TFMigrator interface {
 | |
| 	StateMv(ctx context.Context, src, dst string, opts ...tfexec.StateMvCmdOption) error
 | |
| }
 |