github.com/eliastor/durgaform@v0.0.0-20220816172711-d0ab2d17673e/internal/cloud/backend_state.go (about)

     1  package cloud
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"crypto/md5"
     7  	"encoding/base64"
     8  	"encoding/json"
     9  	"errors"
    10  	"fmt"
    11  
    12  	tfe "github.com/hashicorp/go-tfe"
    13  
    14  	"github.com/eliastor/durgaform/internal/command/jsonstate"
    15  	"github.com/eliastor/durgaform/internal/states/remote"
    16  	"github.com/eliastor/durgaform/internal/states/statefile"
    17  	"github.com/eliastor/durgaform/internal/states/statemgr"
    18  )
    19  
    20  type remoteClient struct {
    21  	client         *tfe.Client
    22  	lockInfo       *statemgr.LockInfo
    23  	organization   string
    24  	runID          string
    25  	stateUploadErr bool
    26  	workspace      *tfe.Workspace
    27  	forcePush      bool
    28  }
    29  
    30  // Get the remote state.
    31  func (r *remoteClient) Get() (*remote.Payload, error) {
    32  	ctx := context.Background()
    33  
    34  	sv, err := r.client.StateVersions.ReadCurrent(ctx, r.workspace.ID)
    35  	if err != nil {
    36  		if err == tfe.ErrResourceNotFound {
    37  			// If no state exists, then return nil.
    38  			return nil, nil
    39  		}
    40  		return nil, fmt.Errorf("failed to retrieve state: %w", err)
    41  	}
    42  
    43  	state, err := r.client.StateVersions.Download(ctx, sv.DownloadURL)
    44  	if err != nil {
    45  		return nil, fmt.Errorf("failed to download state: %w", err)
    46  	}
    47  
    48  	// If the state is empty, then return nil.
    49  	if len(state) == 0 {
    50  		return nil, nil
    51  	}
    52  
    53  	// Get the MD5 checksum of the state.
    54  	sum := md5.Sum(state)
    55  
    56  	return &remote.Payload{
    57  		Data: state,
    58  		MD5:  sum[:],
    59  	}, nil
    60  }
    61  
    62  // Put the remote state.
    63  func (r *remoteClient) Put(state []byte) error {
    64  	ctx := context.Background()
    65  
    66  	// Read the raw state into a Durgaform state.
    67  	stateFile, err := statefile.Read(bytes.NewReader(state))
    68  	if err != nil {
    69  		return fmt.Errorf("failed to read state: %w", err)
    70  	}
    71  
    72  	ov, err := jsonstate.MarshalOutputs(stateFile.State.RootModule().OutputValues)
    73  	if err != nil {
    74  		return fmt.Errorf("failed to translate outputs: %w", err)
    75  	}
    76  	o, err := json.Marshal(ov)
    77  	if err != nil {
    78  		return fmt.Errorf("failed to marshal outputs to json: %w", err)
    79  	}
    80  
    81  	options := tfe.StateVersionCreateOptions{
    82  		Lineage:          tfe.String(stateFile.Lineage),
    83  		Serial:           tfe.Int64(int64(stateFile.Serial)),
    84  		MD5:              tfe.String(fmt.Sprintf("%x", md5.Sum(state))),
    85  		State:            tfe.String(base64.StdEncoding.EncodeToString(state)),
    86  		Force:            tfe.Bool(r.forcePush),
    87  		JSONStateOutputs: tfe.String(base64.StdEncoding.EncodeToString(o)),
    88  	}
    89  
    90  	// If we have a run ID, make sure to add it to the options
    91  	// so the state will be properly associated with the run.
    92  	if r.runID != "" {
    93  		options.Run = &tfe.Run{ID: r.runID}
    94  	}
    95  
    96  	// Create the new state.
    97  	_, err = r.client.StateVersions.Create(ctx, r.workspace.ID, options)
    98  	if err != nil {
    99  		r.stateUploadErr = true
   100  		return fmt.Errorf("failed to upload state: %w", err)
   101  	}
   102  
   103  	return nil
   104  }
   105  
   106  // Delete the remote state.
   107  func (r *remoteClient) Delete() error {
   108  	err := r.client.Workspaces.Delete(context.Background(), r.organization, r.workspace.Name)
   109  	if err != nil && err != tfe.ErrResourceNotFound {
   110  		return fmt.Errorf("failed to delete workspace %s: %w", r.workspace.Name, err)
   111  	}
   112  
   113  	return nil
   114  }
   115  
   116  // EnableForcePush to allow the remote client to overwrite state
   117  // by implementing remote.ClientForcePusher
   118  func (r *remoteClient) EnableForcePush() {
   119  	r.forcePush = true
   120  }
   121  
   122  // Lock the remote state.
   123  func (r *remoteClient) Lock(info *statemgr.LockInfo) (string, error) {
   124  	ctx := context.Background()
   125  
   126  	lockErr := &statemgr.LockError{Info: r.lockInfo}
   127  
   128  	// Lock the workspace.
   129  	_, err := r.client.Workspaces.Lock(ctx, r.workspace.ID, tfe.WorkspaceLockOptions{
   130  		Reason: tfe.String("Locked by Durgaform"),
   131  	})
   132  	if err != nil {
   133  		if err == tfe.ErrWorkspaceLocked {
   134  			lockErr.Info = info
   135  			err = fmt.Errorf("%s (lock ID: \"%s/%s\")", err, r.organization, r.workspace.Name)
   136  		}
   137  		lockErr.Err = err
   138  		return "", lockErr
   139  	}
   140  
   141  	r.lockInfo = info
   142  
   143  	return r.lockInfo.ID, nil
   144  }
   145  
   146  // Unlock the remote state.
   147  func (r *remoteClient) Unlock(id string) error {
   148  	ctx := context.Background()
   149  
   150  	// We first check if there was an error while uploading the latest
   151  	// state. If so, we will not unlock the workspace to prevent any
   152  	// changes from being applied until the correct state is uploaded.
   153  	if r.stateUploadErr {
   154  		return nil
   155  	}
   156  
   157  	lockErr := &statemgr.LockError{Info: r.lockInfo}
   158  
   159  	// With lock info this should be treated as a normal unlock.
   160  	if r.lockInfo != nil {
   161  		// Verify the expected lock ID.
   162  		if r.lockInfo.ID != id {
   163  			lockErr.Err = errors.New("lock ID does not match existing lock")
   164  			return lockErr
   165  		}
   166  
   167  		// Unlock the workspace.
   168  		_, err := r.client.Workspaces.Unlock(ctx, r.workspace.ID)
   169  		if err != nil {
   170  			lockErr.Err = err
   171  			return lockErr
   172  		}
   173  
   174  		return nil
   175  	}
   176  
   177  	// Verify the optional force-unlock lock ID.
   178  	if r.organization+"/"+r.workspace.Name != id {
   179  		lockErr.Err = fmt.Errorf(
   180  			"lock ID %q does not match existing lock ID \"%s/%s\"",
   181  			id,
   182  			r.organization,
   183  			r.workspace.Name,
   184  		)
   185  		return lockErr
   186  	}
   187  
   188  	// Force unlock the workspace.
   189  	_, err := r.client.Workspaces.ForceUnlock(ctx, r.workspace.ID)
   190  	if err != nil {
   191  		lockErr.Err = err
   192  		return lockErr
   193  	}
   194  
   195  	return nil
   196  }