github.com/opentofu/opentofu@v1.7.1/internal/backend/remote-state/azure/client.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package azure
     7  
     8  import (
     9  	"context"
    10  	"encoding/base64"
    11  	"encoding/json"
    12  	"fmt"
    13  	"log"
    14  	"net/http"
    15  
    16  	"github.com/hashicorp/go-multierror"
    17  	"github.com/hashicorp/go-uuid"
    18  	"github.com/opentofu/opentofu/internal/states/remote"
    19  	"github.com/opentofu/opentofu/internal/states/statemgr"
    20  	"github.com/tombuildsstuff/giovanni/storage/2018-11-09/blob/blobs"
    21  )
    22  
    23  const (
    24  	leaseHeader = "x-ms-lease-id"
    25  	// Must be lower case
    26  	lockInfoMetaKey = "terraformlockid"
    27  )
    28  
    29  type RemoteClient struct {
    30  	giovanniBlobClient blobs.Client
    31  	accountName        string
    32  	containerName      string
    33  	keyName            string
    34  	leaseID            string
    35  	snapshot           bool
    36  }
    37  
    38  func (c *RemoteClient) Get() (*remote.Payload, error) {
    39  	options := blobs.GetInput{}
    40  	if c.leaseID != "" {
    41  		options.LeaseID = &c.leaseID
    42  	}
    43  
    44  	ctx := context.TODO()
    45  	blob, err := c.giovanniBlobClient.Get(ctx, c.accountName, c.containerName, c.keyName, options)
    46  	if err != nil {
    47  		if blob.Response.IsHTTPStatus(http.StatusNotFound) {
    48  			return nil, nil
    49  		}
    50  		return nil, err
    51  	}
    52  
    53  	payload := &remote.Payload{
    54  		Data: blob.Contents,
    55  	}
    56  
    57  	// If there was no data, then return nil
    58  	if len(payload.Data) == 0 {
    59  		return nil, nil
    60  	}
    61  
    62  	return payload, nil
    63  }
    64  
    65  func (c *RemoteClient) Put(data []byte) error {
    66  	getOptions := blobs.GetPropertiesInput{}
    67  	setOptions := blobs.SetPropertiesInput{}
    68  	putOptions := blobs.PutBlockBlobInput{}
    69  
    70  	options := blobs.GetInput{}
    71  	if c.leaseID != "" {
    72  		options.LeaseID = &c.leaseID
    73  		getOptions.LeaseID = &c.leaseID
    74  		setOptions.LeaseID = &c.leaseID
    75  		putOptions.LeaseID = &c.leaseID
    76  	}
    77  
    78  	ctx := context.TODO()
    79  
    80  	if c.snapshot {
    81  		snapshotInput := blobs.SnapshotInput{LeaseID: options.LeaseID}
    82  
    83  		log.Printf("[DEBUG] Snapshotting existing Blob %q (Container %q / Account %q)", c.keyName, c.containerName, c.accountName)
    84  		if _, err := c.giovanniBlobClient.Snapshot(ctx, c.accountName, c.containerName, c.keyName, snapshotInput); err != nil {
    85  			return fmt.Errorf("error snapshotting Blob %q (Container %q / Account %q): %w", c.keyName, c.containerName, c.accountName, err)
    86  		}
    87  
    88  		log.Print("[DEBUG] Created blob snapshot")
    89  	}
    90  
    91  	blob, err := c.giovanniBlobClient.GetProperties(ctx, c.accountName, c.containerName, c.keyName, getOptions)
    92  	if err != nil {
    93  		if blob.StatusCode != 404 {
    94  			return err
    95  		}
    96  	}
    97  
    98  	contentType := "application/json"
    99  	putOptions.Content = &data
   100  	putOptions.ContentType = &contentType
   101  	putOptions.MetaData = blob.MetaData
   102  	_, err = c.giovanniBlobClient.PutBlockBlob(ctx, c.accountName, c.containerName, c.keyName, putOptions)
   103  
   104  	return err
   105  }
   106  
   107  func (c *RemoteClient) Delete() error {
   108  	options := blobs.DeleteInput{}
   109  
   110  	if c.leaseID != "" {
   111  		options.LeaseID = &c.leaseID
   112  	}
   113  
   114  	ctx := context.TODO()
   115  	resp, err := c.giovanniBlobClient.Delete(ctx, c.accountName, c.containerName, c.keyName, options)
   116  	if err != nil {
   117  		if !resp.IsHTTPStatus(http.StatusNotFound) {
   118  			return err
   119  		}
   120  	}
   121  	return nil
   122  }
   123  
   124  func (c *RemoteClient) Lock(info *statemgr.LockInfo) (string, error) {
   125  	stateName := fmt.Sprintf("%s/%s", c.containerName, c.keyName)
   126  	info.Path = stateName
   127  
   128  	if info.ID == "" {
   129  		lockID, err := uuid.GenerateUUID()
   130  		if err != nil {
   131  			return "", err
   132  		}
   133  
   134  		info.ID = lockID
   135  	}
   136  
   137  	getLockInfoErr := func(err error) error {
   138  		lockInfo, infoErr := c.getLockInfo()
   139  		if infoErr != nil {
   140  			err = multierror.Append(err, infoErr)
   141  		}
   142  
   143  		return &statemgr.LockError{
   144  			Err:  err,
   145  			Info: lockInfo,
   146  		}
   147  	}
   148  
   149  	leaseOptions := blobs.AcquireLeaseInput{
   150  		ProposedLeaseID: &info.ID,
   151  		LeaseDuration:   -1,
   152  	}
   153  	ctx := context.TODO()
   154  
   155  	// obtain properties to see if the blob lease is already in use. If the blob doesn't exist, create it
   156  	properties, err := c.giovanniBlobClient.GetProperties(ctx, c.accountName, c.containerName, c.keyName, blobs.GetPropertiesInput{})
   157  	if err != nil {
   158  		// error if we had issues getting the blob
   159  		if !properties.Response.IsHTTPStatus(http.StatusNotFound) {
   160  			return "", getLockInfoErr(err)
   161  		}
   162  		// if we don't find the blob, we need to build it
   163  
   164  		contentType := "application/json"
   165  		putGOptions := blobs.PutBlockBlobInput{
   166  			ContentType: &contentType,
   167  		}
   168  
   169  		_, err = c.giovanniBlobClient.PutBlockBlob(ctx, c.accountName, c.containerName, c.keyName, putGOptions)
   170  		if err != nil {
   171  			return "", getLockInfoErr(err)
   172  		}
   173  	}
   174  
   175  	// if the blob is already locked then error
   176  	if properties.LeaseStatus == blobs.Locked {
   177  		return "", getLockInfoErr(fmt.Errorf("state blob is already locked"))
   178  	}
   179  
   180  	leaseID, err := c.giovanniBlobClient.AcquireLease(ctx, c.accountName, c.containerName, c.keyName, leaseOptions)
   181  	if err != nil {
   182  		return "", getLockInfoErr(err)
   183  	}
   184  
   185  	info.ID = leaseID.LeaseID
   186  	c.leaseID = leaseID.LeaseID
   187  
   188  	if err := c.writeLockInfo(info); err != nil {
   189  		return "", err
   190  	}
   191  
   192  	return info.ID, nil
   193  }
   194  
   195  func (c *RemoteClient) getLockInfo() (*statemgr.LockInfo, error) {
   196  	options := blobs.GetPropertiesInput{}
   197  	if c.leaseID != "" {
   198  		options.LeaseID = &c.leaseID
   199  	}
   200  
   201  	ctx := context.TODO()
   202  	blob, err := c.giovanniBlobClient.GetProperties(ctx, c.accountName, c.containerName, c.keyName, options)
   203  	if err != nil {
   204  		return nil, err
   205  	}
   206  
   207  	raw := blob.MetaData[lockInfoMetaKey]
   208  	if raw == "" {
   209  		return nil, fmt.Errorf("blob metadata %q was empty", lockInfoMetaKey)
   210  	}
   211  
   212  	data, err := base64.StdEncoding.DecodeString(raw)
   213  	if err != nil {
   214  		return nil, err
   215  	}
   216  
   217  	lockInfo := &statemgr.LockInfo{}
   218  	err = json.Unmarshal(data, lockInfo)
   219  	if err != nil {
   220  		return nil, err
   221  	}
   222  
   223  	return lockInfo, nil
   224  }
   225  
   226  // writes info to blob meta data, deletes metadata entry if info is nil
   227  func (c *RemoteClient) writeLockInfo(info *statemgr.LockInfo) error {
   228  	ctx := context.TODO()
   229  	blob, err := c.giovanniBlobClient.GetProperties(ctx, c.accountName, c.containerName, c.keyName, blobs.GetPropertiesInput{LeaseID: &c.leaseID})
   230  	if err != nil {
   231  		return err
   232  	}
   233  	if err != nil {
   234  		return err
   235  	}
   236  
   237  	if info == nil {
   238  		delete(blob.MetaData, lockInfoMetaKey)
   239  	} else {
   240  		value := base64.StdEncoding.EncodeToString(info.Marshal())
   241  		blob.MetaData[lockInfoMetaKey] = value
   242  	}
   243  
   244  	opts := blobs.SetMetaDataInput{
   245  		LeaseID:  &c.leaseID,
   246  		MetaData: blob.MetaData,
   247  	}
   248  
   249  	_, err = c.giovanniBlobClient.SetMetaData(ctx, c.accountName, c.containerName, c.keyName, opts)
   250  	return err
   251  }
   252  
   253  func (c *RemoteClient) Unlock(id string) error {
   254  	lockErr := &statemgr.LockError{}
   255  
   256  	lockInfo, err := c.getLockInfo()
   257  	if err != nil {
   258  		lockErr.Err = fmt.Errorf("failed to retrieve lock info: %w", err)
   259  		return lockErr
   260  	}
   261  	lockErr.Info = lockInfo
   262  
   263  	if lockInfo.ID != id {
   264  		lockErr.Err = fmt.Errorf("lock id %q does not match existing lock", id)
   265  		return lockErr
   266  	}
   267  
   268  	c.leaseID = lockInfo.ID
   269  	if err := c.writeLockInfo(nil); err != nil {
   270  		lockErr.Err = fmt.Errorf("failed to delete lock info from metadata: %w", err)
   271  		return lockErr
   272  	}
   273  
   274  	ctx := context.TODO()
   275  	_, err = c.giovanniBlobClient.ReleaseLease(ctx, c.accountName, c.containerName, c.keyName, id)
   276  	if err != nil {
   277  		lockErr.Err = err
   278  		return lockErr
   279  	}
   280  
   281  	c.leaseID = ""
   282  
   283  	return nil
   284  }