2022-09-26 09:52:31 -04:00
/ *
Copyright ( c ) Edgeless Systems GmbH
SPDX - License - Identifier : AGPL - 3.0 - only
* /
2023-01-19 09:57:50 -05:00
/ *
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 .
* /
2022-09-26 09:52:31 -04:00
package terraform
import (
"context"
"errors"
2023-04-14 08:15:07 -04:00
"fmt"
2023-05-22 07:31:20 -04:00
"io"
2022-11-14 12:18:58 -05:00
"path/filepath"
2022-09-26 09:52:31 -04:00
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
2023-04-14 08:15:07 -04:00
"github.com/edgelesssys/constellation/v2/internal/constants"
2022-09-26 09:52:31 -04:00
"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 (
2023-05-18 06:59:10 -04:00
tfVersion = ">= 1.4.6"
2022-09-26 09:52:31 -04:00
terraformVarsFile = "terraform.tfvars"
)
2022-11-16 10:33:51 -05:00
// 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" )
2022-09-26 09:52:31 -04:00
// Client manages interaction with Terraform.
type Client struct {
tf tfInterface
2023-06-27 07:12:50 -04:00
manualStateMigrations [ ] StateMigration
file file . Handler
workingDir string
remove func ( )
2022-09-26 09:52:31 -04:00
}
// New sets up a new Client for Terraform.
2022-11-14 12:18:58 -05:00
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 )
2022-09-26 09:52:31 -04:00
if err != nil {
return nil , err
}
return & Client {
2022-11-14 12:18:58 -05:00
tf : tf ,
remove : remove ,
file : file ,
workingDir : workingDir ,
2022-09-26 09:52:31 -04:00
} , nil
}
2023-06-27 07:12:50 -04:00
// WithManualStateMigration adds a manual state migration to the Client.
func ( c * Client ) WithManualStateMigration ( migration StateMigration ) * Client {
c . manualStateMigrations = append ( c . manualStateMigrations , migration )
return c
}
2023-02-24 05:36:41 -05:00
// 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 )
}
2022-11-15 08:00:44 -05:00
// PrepareWorkspace prepares a Terraform workspace for a Constellation cluster.
2022-12-07 05:48:54 -05:00
func ( c * Client ) PrepareWorkspace ( path string , vars Variables ) error {
if err := prepareWorkspace ( path , c . file , c . workingDir ) ; err != nil {
2023-05-22 07:31:20 -04:00
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.
2023-06-21 03:22:32 -04:00
func ( c * Client ) PrepareUpgradeWorkspace ( path , oldWorkingDir , newWorkingDir , backupDir string , vars Variables ) error {
if err := prepareUpgradeWorkspace ( path , c . file , oldWorkingDir , newWorkingDir , backupDir ) ; err != nil {
2023-05-22 07:31:20 -04:00
return fmt . Errorf ( "prepare upgrade workspace: %w" , err )
2022-09-26 09:52:31 -04:00
}
2023-03-20 06:06:51 -04:00
return c . writeVars ( vars )
2022-11-15 08:00:44 -05:00
}
// CreateCluster creates a Constellation cluster using Terraform.
2023-07-21 10:43:51 -04:00
func ( c * Client ) CreateCluster ( ctx context . Context , logLevel LogLevel ) ( ApplyOutput , error ) {
2023-04-14 08:15:07 -04:00
if err := c . setLogLevel ( logLevel ) ; err != nil {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , fmt . Errorf ( "set terraform log level %s: %w" , logLevel . String ( ) , err )
2023-04-14 08:15:07 -04:00
}
2022-11-15 05:18:52 -05:00
if err := c . tf . Init ( ctx ) ; err != nil {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , fmt . Errorf ( "terraform init: %w" , err )
2022-09-26 09:52:31 -04:00
}
2023-06-27 07:12:50 -04:00
if err := c . applyManualStateMigrations ( ctx ) ; err != nil {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , fmt . Errorf ( "apply manual state migrations: %w" , err )
2023-06-27 07:12:50 -04:00
}
2023-06-27 05:27:50 -04:00
if err := c . tf . Apply ( ctx ) ; err != nil {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , fmt . Errorf ( "terraform apply: %w" , err )
2022-09-26 09:52:31 -04:00
}
tfState , err := c . tf . Show ( ctx )
if err != nil {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , fmt . Errorf ( "terraform show: %w" , err )
2022-09-26 09:52:31 -04:00
}
ipOutput , ok := tfState . Values . Outputs [ "ip" ]
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "no IP output found" )
2022-09-26 09:52:31 -04:00
}
ip , ok := ipOutput . Value . ( string )
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "invalid type in IP output: not a string" )
}
apiServerCertSANsOutput , ok := tfState . Values . Outputs [ "api_server_cert_sans" ]
if ! ok {
return ApplyOutput { } , errors . New ( "no api_server_cert_sans output found" )
}
apiServerCertSANsUntyped , ok := apiServerCertSANsOutput . Value . ( [ ] any )
if ! ok {
return ApplyOutput { } , fmt . Errorf ( "invalid type in api_server_cert_sans output: %s is not a list of elements" , apiServerCertSANsOutput . Type . FriendlyName ( ) )
}
apiServerCertSANs , err := toStringSlice ( apiServerCertSANsUntyped )
if err != nil {
return ApplyOutput { } , fmt . Errorf ( "convert api_server_cert_sans output: %w" , err )
2022-09-26 09:52:31 -04:00
}
2022-11-26 13:44:34 -05:00
secretOutput , ok := tfState . Values . Outputs [ "initSecret" ]
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "no initSecret output found" )
2022-11-26 13:44:34 -05:00
}
secret , ok := secretOutput . Value . ( string )
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "invalid type in initSecret output: not a string" )
2022-11-26 13:44:34 -05:00
}
2023-01-19 04:41:07 -05:00
uidOutput , ok := tfState . Values . Outputs [ "uid" ]
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "no uid output found" )
2023-01-19 04:41:07 -05:00
}
uid , ok := uidOutput . Value . ( string )
if ! ok {
2023-07-21 10:43:51 -04:00
return ApplyOutput { } , errors . New ( "invalid type in uid output: not a string" )
2023-01-19 04:41:07 -05:00
}
2023-03-20 08:33:04 -04:00
var attestationURL string
if attestationURLOutput , ok := tfState . Values . Outputs [ "attestationURL" ] ; ok {
if attestationURLString , ok := attestationURLOutput . Value . ( string ) ; ok {
attestationURL = attestationURLString
}
}
2023-07-21 10:43:51 -04:00
return ApplyOutput {
IP : ip ,
APIServerCertSANs : apiServerCertSANs ,
Secret : secret ,
UID : uid ,
AttestationURL : attestationURL ,
2023-01-19 04:41:07 -05:00
} , nil
}
2023-07-21 10:43:51 -04:00
// ApplyOutput contains the Terraform output values of a cluster creation
// or apply operation.
type ApplyOutput struct {
IP string
APIServerCertSANs [ ] string
Secret string
UID string
2023-03-20 08:33:04 -04:00
// AttestationURL is the URL of the attestation provider.
// It is only set if the cluster is created on Azure.
AttestationURL string
2022-09-26 09:52:31 -04:00
}
2022-12-07 05:48:54 -05:00
// 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 {
2023-05-26 05:45:03 -04:00
SubscriptionID string
TenantID string
UAMIID string
2022-12-07 05:48:54 -05:00
}
// 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.
2023-04-14 08:15:07 -04:00
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 )
}
2022-12-07 05:48:54 -05:00
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 {
2023-05-26 05:45:03 -04:00
SubscriptionID : subscriptionIDOutput ,
TenantID : tenantIDOutput ,
UAMIID : uamiIDOutput ,
2022-12-07 05:48:54 -05:00
} ,
} , 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" )
}
}
2023-05-22 07:31:20 -04:00
// 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.
2023-06-27 05:27:50 -04:00
func ( c * Client ) Plan ( ctx context . Context , logLevel LogLevel , planFile string ) ( bool , error ) {
2023-05-22 07:31:20 -04:00
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 )
}
2023-06-27 07:12:50 -04:00
if err := c . applyManualStateMigrations ( ctx ) ; err != nil {
return false , fmt . Errorf ( "apply manual state migrations: %w" , err )
}
2023-05-22 07:31:20 -04:00
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
}
2023-02-13 02:42:54 -05:00
// Destroy destroys Terraform-created cloud resources.
2023-04-14 08:15:07 -04:00
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 )
}
2022-11-15 06:50:17 -05:00
if err := c . tf . Init ( ctx ) ; err != nil {
2023-04-14 08:15:07 -04:00
return fmt . Errorf ( "terraform init: %w" , err )
2022-11-15 06:50:17 -05:00
}
2022-09-26 09:52:31 -04:00
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 {
2023-03-20 06:06:51 -04:00
return cleanUpWorkspace ( c . file , c . workingDir )
2022-09-26 09:52:31 -04:00
}
// 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
}
2023-06-27 07:12:50 -04:00
// 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")
}
2022-11-15 05:18:52 -05:00
// 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 {
2023-05-23 04:49:47 -04:00
return fmt . Errorf ( "read variables file: %w" , err )
2022-11-15 05:18:52 -05:00
}
if vars . String ( ) != string ( varsContent ) {
2022-11-16 10:33:51 -05:00
return ErrTerraformWorkspaceExistsWithDifferentVariables
2022-11-15 05:18:52 -05:00
}
} else if err != nil {
2023-05-23 04:49:47 -04:00
return fmt . Errorf ( "write variables file: %w" , err )
2022-11-15 05:18:52 -05:00
}
return nil
}
2023-04-14 08:15:07 -04:00
// 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
}
2023-06-27 07:12:50 -04:00
// 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
}
2023-07-21 10:43:51 -04:00
func toStringSlice ( in [ ] any ) ( [ ] string , error ) {
out := make ( [ ] string , len ( in ) )
for i , v := range in {
s , ok := v . ( string )
if ! ok {
return nil , fmt . Errorf ( "invalid type in list: item at index %v of list is not a string" , i )
}
out [ i ] = s
}
return out , nil
}
2022-09-26 09:52:31 -04:00
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 )
2023-05-22 07:31:20 -04:00
Plan ( ctx context . Context , opts ... tfexec . PlanOption ) ( bool , error )
ShowPlanFileRaw ( ctx context . Context , planPath string , opts ... tfexec . ShowOption ) ( string , error )
2023-04-14 08:15:07 -04:00
SetLog ( level string ) error
SetLogPath ( path string ) error
2023-06-27 07:12:50 -04:00
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
2022-09-26 09:52:31 -04:00
}