github.com/openshift/installer@v1.4.17/pkg/infrastructure/openstack/preprovision/bootstrapignition.go (about)

     1  package preprovision
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"encoding/pem"
     9  	"fmt"
    10  	"os"
    11  	"strings"
    12  
    13  	igntypes "github.com/coreos/ignition/v2/config/v3_2/types"
    14  	"github.com/gophercloud/gophercloud/v2"
    15  	gophercloud_openstack "github.com/gophercloud/gophercloud/v2/openstack"
    16  	"github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens"
    17  	"github.com/gophercloud/gophercloud/v2/openstack/image/v2/imagedata"
    18  	"github.com/gophercloud/gophercloud/v2/openstack/image/v2/images"
    19  	"github.com/gophercloud/utils/v2/openstack/clientconfig"
    20  	"github.com/sirupsen/logrus"
    21  	"github.com/vincent-petithory/dataurl"
    22  	"k8s.io/utils/ptr"
    23  
    24  	"github.com/openshift/installer/pkg/asset"
    25  	"github.com/openshift/installer/pkg/asset/ignition"
    26  	"github.com/openshift/installer/pkg/asset/installconfig"
    27  	"github.com/openshift/installer/pkg/types"
    28  	openstackdefaults "github.com/openshift/installer/pkg/types/openstack/defaults"
    29  )
    30  
    31  // ReplaceBootstrapIgnitionInTFVars replaces the ignition file in the terraform variables.
    32  func ReplaceBootstrapIgnitionInTFVars(ctx context.Context, tfvarsFile *asset.File, installConfig *installconfig.InstallConfig, clusterID *installconfig.ClusterID) error {
    33  	if tfvarsFile == nil {
    34  		return fmt.Errorf("missing tfvars file")
    35  	}
    36  
    37  	var tfvars map[string]json.RawMessage
    38  	if err := json.Unmarshal(tfvarsFile.Data, &tfvars); err != nil {
    39  		return fmt.Errorf("unable to decode tfvars: %w", err)
    40  	}
    41  
    42  	bootstrapIgnitionJSON, ok := tfvars["openstack_bootstrap_shim_ignition"]
    43  	if !ok {
    44  		return fmt.Errorf("bootstrap's Ignition file not found")
    45  	}
    46  
    47  	var bootstrapIgnition string
    48  	if err := json.Unmarshal(bootstrapIgnitionJSON, &bootstrapIgnition); err != nil {
    49  		return fmt.Errorf("failed to decode bootstrap's Ignition")
    50  	}
    51  
    52  	logrus.Debugf("Uploading Ignition to Glance")
    53  	bootstrapShim, err := UploadIgnitionAndBuildShim(ctx, installConfig.Config.Platform.OpenStack.Cloud, clusterID.InfraID, installConfig.Config.Proxy, []byte(bootstrapIgnition))
    54  	if err != nil {
    55  		return fmt.Errorf("failed to build bootstrap's Ignition shim: %w", err)
    56  	}
    57  
    58  	bootstrapShimJSON, err := json.Marshal(bootstrapShim)
    59  	if err != nil {
    60  		return fmt.Errorf("failed to encode bootstrap's Ignition shim: %w", err)
    61  	}
    62  
    63  	logrus.Debugf("Replacing the Ignition file in the Terraform variables")
    64  	tfvars["openstack_bootstrap_shim_ignition"] = bootstrapShimJSON
    65  
    66  	b, err := json.MarshalIndent(tfvars, "", "  ")
    67  	if err != nil {
    68  		return fmt.Errorf("unable to encode tfvars: %w", err)
    69  	}
    70  	tfvarsFile.Data = b
    71  	return nil
    72  }
    73  
    74  // UploadIgnitionAndBuildShim uploads the bootstrap Ignition config in Glance.
    75  func UploadIgnitionAndBuildShim(ctx context.Context, cloud string, infraID string, proxy *types.Proxy, bootstrapIgn []byte) ([]byte, error) {
    76  	opts := openstackdefaults.DefaultClientOpts(cloud)
    77  	conn, err := openstackdefaults.NewServiceClient(ctx, "image", opts)
    78  	if err != nil {
    79  		return nil, err
    80  	}
    81  
    82  	var userCA []byte
    83  	{
    84  		cloudConfig, err := clientconfig.GetCloudFromYAML(opts)
    85  		if err != nil {
    86  			return nil, err
    87  		}
    88  		// Get the ca-cert-bundle key if there is a value for cacert in clouds.yaml
    89  		if caPath := cloudConfig.CACertFile; caPath != "" {
    90  			userCA, err = os.ReadFile(caPath)
    91  			if err != nil {
    92  				return nil, fmt.Errorf("failed to read clouds.yaml ca-cert from disk: %w", err)
    93  			}
    94  		}
    95  	}
    96  
    97  	// we need to obtain Glance public endpoint that will be used by Ignition to download bootstrap ignition files.
    98  	// By design this should be done by using https://www.terraform.io/docs/providers/openstack/d/identity_endpoint_v3.html
    99  	// but OpenStack default policies forbid to use this API for regular users.
   100  	// On the other hand when a user authenticates in OpenStack (i.e. gets a token), it includes the whole service
   101  	// catalog in the output json. So we are able to parse the data and get the endpoint from there
   102  	// https://docs.openstack.org/api-ref/identity/v3/?expanded=token-authentication-with-scoped-authorization-detail#token-authentication-with-scoped-authorization
   103  	// Unfortunately this feature is not currently supported by Terraform, so we had to implement it here.
   104  	var glancePublicURL string
   105  	{
   106  		// Authenticate in OpenStack, get the token and extract the service catalog
   107  		var serviceCatalog *tokens.ServiceCatalog
   108  		{
   109  			authResult := conn.GetAuthResult()
   110  			auth, ok := authResult.(tokens.CreateResult)
   111  			if !ok {
   112  				return nil, fmt.Errorf("unable to extract service catalog")
   113  			}
   114  
   115  			var err error
   116  			serviceCatalog, err = auth.ExtractServiceCatalog()
   117  			if err != nil {
   118  				return nil, err
   119  			}
   120  		}
   121  		clientConfigCloud, err := clientconfig.GetCloudFromYAML(openstackdefaults.DefaultClientOpts(cloud))
   122  		if err != nil {
   123  			return nil, err
   124  		}
   125  		glancePublicURL, err = gophercloud_openstack.V3EndpointURL(serviceCatalog, gophercloud.EndpointOpts{
   126  			Type:         "image",
   127  			Availability: gophercloud.AvailabilityPublic,
   128  			Region:       clientConfigCloud.RegionName,
   129  		})
   130  		if err != nil {
   131  			return nil, fmt.Errorf("cannot retrieve Glance URL from the service catalog: %w", err)
   132  		}
   133  	}
   134  
   135  	// upload the bootstrap Ignition config in Glance and save its location
   136  	var bootstrapConfigURL string
   137  	{
   138  		img, err := images.Create(ctx, conn, images.CreateOpts{
   139  			Name:            infraID + "-ignition",
   140  			ContainerFormat: "bare",
   141  			DiskFormat:      "raw",
   142  			Tags:            []string{"openshiftClusterID=" + infraID},
   143  		}).Extract()
   144  		if err != nil {
   145  			return nil, fmt.Errorf("unable to create a Glance image for the bootstrap server's Ignition file: %w", err)
   146  		}
   147  
   148  		if res := imagedata.Upload(ctx, conn, img.ID, bytes.NewReader(bootstrapIgn)); res.Err != nil {
   149  			return nil, fmt.Errorf("unable to upload a Glance image for the bootstrap server's Ignition file: %w", res.Err)
   150  		}
   151  
   152  		bootstrapConfigURL = glancePublicURL + img.File
   153  	}
   154  
   155  	// To allow Ignition to download its config on the bootstrap machine from a location secured by a
   156  	// self-signed certificate, we have to provide it a valid custom ca bundle.
   157  	// To do so we generate a small ignition config that contains just Security section with the bundle
   158  	// and later append it to the main ignition config.
   159  	tokenID, err := conn.GetAuthResult().ExtractTokenID()
   160  	if err != nil {
   161  		return nil, fmt.Errorf("unable to extract an OpenStack token: %w", err)
   162  	}
   163  
   164  	caRefs, err := parseCertificateBundle(userCA)
   165  	if err != nil {
   166  		return nil, err
   167  	}
   168  
   169  	var ignProxy igntypes.Proxy
   170  	if proxy != nil {
   171  		if proxy.HTTPProxy != "" {
   172  			ignProxy.HTTPProxy = &proxy.HTTPProxy
   173  		}
   174  		if proxy.HTTPSProxy != "" {
   175  			ignProxy.HTTPSProxy = &proxy.HTTPSProxy
   176  		}
   177  		if proxy.NoProxy != "" {
   178  			noProxy := strings.Split(proxy.NoProxy, ",")
   179  			ignProxy.NoProxy = make([]igntypes.NoProxyItem, len(noProxy))
   180  			for i := range noProxy {
   181  				ignProxy.NoProxy[i] = igntypes.NoProxyItem(noProxy[i])
   182  			}
   183  		}
   184  	}
   185  
   186  	data, err := ignition.Marshal(igntypes.Config{
   187  		Ignition: igntypes.Ignition{
   188  			Version: igntypes.MaxVersion.String(),
   189  			Timeouts: igntypes.Timeouts{
   190  				HTTPResponseHeaders: ptr.To(120),
   191  			},
   192  			Security: igntypes.Security{
   193  				TLS: igntypes.TLS{
   194  					CertificateAuthorities: caRefs,
   195  				},
   196  			},
   197  			Config: igntypes.IgnitionConfig{
   198  				Merge: []igntypes.Resource{
   199  					{
   200  						Source: &bootstrapConfigURL,
   201  						HTTPHeaders: []igntypes.HTTPHeader{
   202  							{
   203  								Name:  "X-Auth-Token",
   204  								Value: &tokenID,
   205  							},
   206  						},
   207  					},
   208  				},
   209  			},
   210  			Proxy: ignProxy,
   211  		},
   212  		Storage: igntypes.Storage{
   213  			Files: []igntypes.File{
   214  				{
   215  					Node: igntypes.Node{
   216  						Path:      "/etc/hostname",
   217  						Overwrite: ptr.To(true),
   218  					},
   219  					FileEmbedded1: igntypes.FileEmbedded1{
   220  						Mode: ptr.To(420),
   221  						Contents: igntypes.Resource{
   222  							Source: ptr.To(dataurl.EncodeBytes([]byte(infraID + "bootstrap"))),
   223  						},
   224  					},
   225  				},
   226  				{
   227  					Node: igntypes.Node{
   228  						Path:      "/opt/openshift/tls/cloud-ca-cert.pem",
   229  						Overwrite: ptr.To(true),
   230  					},
   231  					FileEmbedded1: igntypes.FileEmbedded1{
   232  						Mode: ptr.To(420),
   233  						Contents: igntypes.Resource{
   234  							Source: ptr.To(dataurl.EncodeBytes(userCA)),
   235  						},
   236  					},
   237  				},
   238  			},
   239  		},
   240  	})
   241  	if err != nil {
   242  		return nil, fmt.Errorf("unable to encode the Ignition shim: %w", err)
   243  	}
   244  
   245  	// Check the size of the base64-rendered ignition shim isn't to big for nova
   246  	// https://docs.openstack.org/nova/latest/user/metadata.html#user-data
   247  	if len(base64.StdEncoding.EncodeToString(data)) > 65535 {
   248  		return nil, fmt.Errorf("rendered bootstrap ignition shim exceeds the 64KB limit for nova user data -- try reducing the size of your CA cert bundle")
   249  	}
   250  	return data, nil
   251  }
   252  
   253  // ParseCertificateBundle loads each certificate in the bundle to the Ignition
   254  // carrier type, ignoring any invisible character before, after and in between
   255  // certificates.
   256  func parseCertificateBundle(userCA []byte) ([]igntypes.Resource, error) {
   257  	var caRefs []igntypes.Resource
   258  	userCA = bytes.TrimSpace(userCA)
   259  	for len(userCA) > 0 {
   260  		var block *pem.Block
   261  		block, userCA = pem.Decode(userCA)
   262  		if block == nil {
   263  			return nil, fmt.Errorf("unable to parse certificate, please check the cacert section of clouds.yaml")
   264  		}
   265  		caRefs = append(caRefs, igntypes.Resource{Source: ptr.To(dataurl.EncodeBytes(pem.EncodeToMemory(block)))})
   266  		userCA = bytes.TrimSpace(userCA)
   267  	}
   268  	return caRefs, nil
   269  }